1 Commits
Author SHA1 Message Date
sneak de22b4edb0 docs: code comments name the headings README has now (closes #724)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The README rewrite renamed or merged several headings that comments still
cited. Each comment now names the current heading.

The e2e workflow's citations of "In CI" are dropped, because no section has what
they pointed at.

The comment on withLock() no longer states a Firefox private window exception,
because the manifest keeps the extension out of those windows.

Two comments also take corrections from reviews: the cases with no total in
DeleteAddress's warning, and the screens that say a swap's amount could not be
decoded.

Model: opus-5-5
2026-11-03 12:11:36 +00:00
12 changed files with 82 additions and 886 deletions
+6 -4
View File
@@ -19,10 +19,12 @@ on: [push]
# by the host daemon and mounts an empty directory, and the runner image's # by the host daemon and mounts an empty directory, and the runner image's
# node is too old to install this repo's dependencies. # node is too old to install this repo's dependencies.
# #
# These jobs REPORT, they do not gate. No branch requires these two jobs, so # These jobs REPORT, they do not gate. Whether a check blocks a merge is
# a failure is a red mark a reviewer has to account for. The Chrome suite has # Gitea branch protection, which this repo does not configure, so a failure
# failed under load before, and no such report is open. A gate that fails at # here is a red mark a reviewer has to account for rather than a hard
# random teaches people to merge past red. # block. Making e2e-chrome a required check is blocked while reports of the
# Chrome suite failing under load are still open. A gate that fails at random
# teaches people to merge past red.
# #
# Nothing here may pass vacuously. There is no continue-on-error and no # Nothing here may pass vacuously. There is no continue-on-error and no
# `|| true`. Both scripts exit non-zero when docker is missing, when the # `|| true`. Both scripts exit non-zero when docker is missing, when the
+12 -19
View File
@@ -471,9 +471,7 @@ When "Back" returns to a screen that this page load has not drawn yet,
Home is shown instead. "Back" onto a screen already drawn only unhides it, so Home is shown instead. "Back" onto a screen already drawn only unhides it, so
unsaved input in Settings survives. Home is drawn again on every "Back". When a unsaved input in Settings survives. Home is drawn again on every "Back". When a
balance refresh returns while AddressDetail or AddressToken is showing, that balance refresh returns while AddressDetail or AddressToken is showing, that
screen draws its balances again. When a refresh returns while Send is showing, screen draws its balances again.
Send draws its From address and current balance again. Its inputs and dropdown
stay as the user left them.
A network switch, or a change to "Use Blockscout", cuts the stack at the first A network switch, or a change to "Use Blockscout", cuts the stack at the first
screen that cannot be drawn from saved state. The cut also drops AddressToken screen that cannot be drawn from saved state. The cut also drops AddressToken
@@ -581,9 +579,8 @@ Transitions:
- "+": derives the next address in place. - "+": derives the next address in place.
- `[info]`: AddressDetail. - `[info]`: AddressDetail.
- `[x]`: DeleteAddress. - `[x]`: DeleteAddress.
- "Send": Send, with its dropdown on ETH. Refused with a flash message when the - "Send": Send. Refused with a flash message when the active address has a zero
active address has a zero ETH balance or belongs to a wallet carrying the ETH balance or belongs to a wallet carrying the `walletDefects.js` notice.
`walletDefects.js` notice.
- "Receive": Receive. - "Receive": Receive.
- Tap a transaction row: TransactionDetail. - Tap a transaction row: TransactionDetail.
- "Add additional wallet...": AddWallet. - "Add additional wallet...": AddWallet.
@@ -1415,19 +1412,15 @@ when both tokens are known. The lines are:
the sender that the `Amount` line does not include. The same three lines show the sender that the `Amount` line does not include. The same three lines show
what a `PERMIT2_TRANSFER_FROM` moves and where. A `WRAP_ETH` can pay its WETH what a `PERMIT2_TRANSFER_FROM` moves and where. A `WRAP_ETH` can pay its WETH
to a recipient other than the router. When its amount gets these lines, its to a recipient other than the router. When its amount gets these lines, its
`Moved to` line names that recipient, or reads `The sender`. For a `WRAP_ETH` `Moved to` line names that recipient, or reads `The sender`. A `WRAP_ETH`
into the router, `Moved to` reads `The router`. A `WRAP_ETH` after the step after the step that sets `Token In` takes the amount it states from the
that sets `Token In` takes the amount it states from the sender. When it pays sender. When it pays anyone but the router, that amount gets these lines. When
anyone but the router, that amount gets these lines. When it pays the router, it pays the router, that amount joins the `Amount` line if `Token In` is ETH,
that amount joins the `Amount` line if `Token In` is ETH, and gets these lines and gets these lines if not. A later `WRAP_ETH` of `CONTRACT_BALANCE` states
if not. A later `WRAP_ETH` of `CONTRACT_BALANCE` to anyone but the router gets no amount, so it gets no lines. ETH that the sender pays a V4 step or a
these lines. It gets none after an `UNWRAP_WETH` into the router, or while the `WRAP_ETH` of a stated amount can get these three lines when the router may
router holds ETH from a V4 step. When it gets them, its hold ETH from an earlier step. When the decoder knows the amount, its
`Moved from the sender` line reads `All the ETH the transaction carries`. ETH `Moved from the sender` line reads `Up to` and the amount.
that the sender pays a V4 step or a `WRAP_ETH` of a stated amount can get
these three lines when the router may hold ETH from an earlier step. When the
decoder knows the amount, its `Moved from the sender` line reads `Up to` and
the amount.
- `Permit2 allowance`, `Allowance token` and `Allowance spender`: each allowance - `Permit2 allowance`, `Allowance token` and `Allowance spender`: each allowance
that a `PERMIT2_PERMIT` or `PERMIT2_PERMIT_BATCH` grants, unless the permit's that a `PERMIT2_PERMIT` or `PERMIT2_PERMIT_BATCH` grants, unless the permit's
amount already stands as the `Amount` figure. amount already stands as the `Amount` figure.
+2 -5
View File
@@ -107,10 +107,8 @@ let refreshTimer = null;
// tracked token list stays as it is. // tracked token list stays as it is.
// //
// Once the save is done, the wallet list is drawn again. So are the balances // Once the save is done, the wallet list is drawn again. So are the balances
// on AddressDetail or AddressToken when that screen is up. When Send is up, its // on AddressDetail or AddressToken when that screen is up. No other screen is
// From address and current balance are drawn again, and its inputs and dropdown // drawn again, DeleteAddress and its balance lines included.
// stay as the user left them. No other screen is drawn again, DeleteAddress and
// its balance lines included.
async function doRefreshAndRender() { async function doRefreshAndRender() {
if (refreshInFlight) { if (refreshInFlight) {
refreshAgain = true; refreshAgain = true;
@@ -166,7 +164,6 @@ async function doRefreshAndRender() {
renderWalletList(); renderWalletList();
if (state.currentView === "address") addressDetail.renderBalances(); if (state.currentView === "address") addressDetail.renderBalances();
if (state.currentView === "address-token") addressToken.renderBalance(); if (state.currentView === "address-token") addressToken.renderBalance();
if (state.currentView === "send") send.updateSendBalance();
} catch (e) { } catch (e) {
// No caller waits for this function, so it must never reject. An // No caller waits for this function, so it must never reject. An
// unhandled rejection reports nothing. The save inside it reports its // unhandled rejection reports nothing. The save inside it reports its
-5
View File
@@ -360,11 +360,6 @@ function init(_ctx) {
}); });
$("btn-address-token-back").addEventListener("click", () => { $("btn-address-token-back").addEventListener("click", () => {
// AddressDetail's show() clears the token, but Back onto a drawn
// screen only unhides it. "Send" below may have left the dropdown on
// the token as well.
state.selectedToken = null;
$("send-token").value = "ETH";
goBack(); goBack();
}); });
-7
View File
@@ -510,15 +510,11 @@ function init(ctx) {
showFlash("Cannot send \u2014 zero balance."); showFlash("Cannot send \u2014 zero balance.");
return; return;
} }
// Send uses a selected token ahead of its dropdown, and AddressToken
// can leave one selected on the way here.
state.selectedToken = null;
$("send-to").value = ""; $("send-to").value = "";
$("send-amount").value = ""; $("send-amount").value = "";
$("send-token-dropdown").classList.remove("hidden"); $("send-token-dropdown").classList.remove("hidden");
$("send-token-static").classList.add("hidden"); $("send-token-static").classList.add("hidden");
renderSendTokenSelect(addr); renderSendTokenSelect(addr);
$("send-token").value = "ETH";
updateSendBalance(); updateSendBalance();
resetSendValidation(); resetSendValidation();
pushCurrentView(); pushCurrentView();
@@ -530,9 +526,6 @@ function init(ctx) {
showFlash("No active address selected."); showFlash("No active address selected.");
return; return;
} }
// Receive warns about a selected token, and AddressToken can leave one
// selected on the way here.
state.selectedToken = null;
ctx.showReceive(); ctx.showReceive();
}); });
} }
+26 -61
View File
@@ -404,11 +404,6 @@ const OPEN_DELTA = Symbol("v4-open-delta");
// that nothing is swapped. // that nothing is swapped.
const ALREADY_PAID = Symbol("v2-already-paid"); const ALREADY_PAID = Symbol("v2-already-paid");
// A later WRAP_ETH of CONTRACT_BALANCE wraps all the ETH the router holds, and
// so all the ETH the transaction carries. The calldata states no quantity for
// it. The approval screen's Value line gives that ETH.
const TRANSACTION_ETH = Symbol("transaction-eth");
// The router's whole-balance amount, universal-router // The router's whole-balance amount, universal-router
// `ActionConstants.CONTRACT_BALANCE`: a V2 exact-in given it as `amountIn` // `ActionConstants.CONTRACT_BALANCE`: a V2 exact-in given it as `amountIn`
// pays the pair all the router holds of the input token. // pays the pair all the router holds of the input token.
@@ -421,7 +416,6 @@ const CONTRACT_BALANCE = 2n ** 255n;
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)"; const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
const ALREADY_PAID_AMOUNT = const ALREADY_PAID_AMOUNT =
"Whatever an earlier step sent to the pair (V2 already paid)"; "Whatever an earlier step sent to the pair (V2 already paid)";
const TRANSACTION_ETH_AMOUNT = "All the ETH the transaction carries";
const NO_MINIMUM = "None (no minimum guaranteed)"; const NO_MINIMUM = "None (no minimum guaranteed)";
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded". // Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
@@ -1273,9 +1267,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// ERC-20 token from the router takes none of that token from the // ERC-20 token from the router takes none of that token from the
// sender. // sender.
let inputPaidByUser = false; let inputPaidByUser = false;
// The recipient of a WRAP_ETH that set the input side, as written; // The recipient of a WRAP_ETH that set the input side, as written,
// otherwise null. That WRAP_ETH pays its WETH there (see the Moved to // when it is not the router; otherwise null. That WRAP_ETH pays its
// lines). // WETH there, not to a swap step (see the Moved to lines).
let wrapEthRecipient = null; let wrapEthRecipient = null;
// The last PERMIT2_PERMIT this decoder read before a step set the // The last PERMIT2_PERMIT this decoder read before a step set the
// input side, or null (see permitStands after the loop). // input side, or null (see permitStands after the loop).
@@ -1295,9 +1289,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let minOutput = null; let minOutput = null;
let outputEstablished = false; let outputEstablished = false;
let hasUnwrapWeth = false; let hasUnwrapWeth = false;
// Set once an UNWRAP_WETH pays ETH to the router, which a later
// WRAP_ETH of CONTRACT_BALANCE then wraps.
let unwrappedIntoRouter = false;
// Set when an UNWRAP_WETH pays out, as ETH, the output side's WETH, // Set when an UNWRAP_WETH pays out, as ETH, the output side's WETH,
// which the router holds; cleared whenever the output side is set // which the router holds; cleared whenever the output side is set
// again, or a later step may pay that ETH out as WETH (see after the // again, or a later step may pay that ETH out as WETH (see after the
@@ -1377,9 +1368,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// After the loop each joins the Amount line or gets lines of its own // After the loop each joins the Amount line or gets lines of its own
// (see permitStands). A take with `atMost` set may take less than // (see permitStands). A take with `atMost` set may take less than
// `amount`: it never joins the Amount line, and its line reads // `amount`: it never joins the Amount line, and its line reads
// "Up to" and the amount. A take with a `recipient` is the ETH of a // "Up to" and the amount. A take with a `recipient`, the ETH of a
// later WRAP_ETH. If that recipient is not the router, the take never // later WRAP_ETH that pays anyone but the router, never joins the
// joins the Amount line either: its Moved to line names that recipient. // Amount line either: its Moved to line names that recipient.
const userPaid = []; const userPaid = [];
// A swap step the user pays for (`payerIsUser` true) takes `amount` // A swap step the user pays for (`payerIsUser` true) takes `amount`
// of `token` from the sender, or at most that when `atMost` is set. A // of `token` from the sender, or at most that when `atMost` is set. A
@@ -1915,7 +1906,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
w.amount, w.amount,
); );
inputPaidByUser = true; inputPaidByUser = true;
wrapEthRecipient = w.recipient; if (!isRouter(w.recipient)) {
wrapEthRecipient = w.recipient;
}
} else if ( } else if (
w.amount > 0n && w.amount > 0n &&
w.amount !== CONTRACT_BALANCE w.amount !== CONTRACT_BALANCE
@@ -1923,33 +1916,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// The ETH of a stated amount comes from the // The ETH of a stated amount comes from the
// transaction's value. ETH an earlier step left in // transaction's value. ETH an earlier step left in
// the router may pay part of it, and the sender // the router may pay part of it, and the sender
// then pays at most that amount. // then pays at most that amount. CONTRACT_BALANCE
// states no amount to take.
userPaid.push({ userPaid.push({
token: "0x0000000000000000000000000000000000000000", token: "0x0000000000000000000000000000000000000000",
amount: w.amount, amount: w.amount,
atMost: heldByRouter.has( atMost: heldByRouter.has(
"0x0000000000000000000000000000000000000000", "0x0000000000000000000000000000000000000000",
), ),
recipient: w.recipient, recipient: isRouter(w.recipient)
}); ? null
} else if ( : w.recipient,
w.amount === CONTRACT_BALANCE &&
!isRouter(w.recipient) &&
!unwrappedIntoRouter &&
!heldByRouter.has(
"0x0000000000000000000000000000000000000000",
)
) {
// It pays the recipient all the ETH the transaction
// carries. It gets no lines after an UNWRAP_WETH
// into the router, or while the router holds ETH
// from a V4 step: Uniswap's routes pay back what
// their swap did not spend this way, and carry no
// ETH.
userPaid.push({
token: "0x0000000000000000000000000000000000000000",
amount: TRANSACTION_ETH,
recipient: w.recipient,
}); });
} }
addPayee(w.recipient); addPayee(w.recipient);
@@ -2192,9 +2169,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
heldByRouter.has(w), heldByRouter.has(w),
); );
WETH_ADDRESSES.forEach((w) => heldByRouter.delete(w)); WETH_ADDRESSES.forEach((w) => heldByRouter.delete(w));
if (isRouter(u.recipient)) {
unwrappedIntoRouter = true;
}
if (isRouter(u.recipient) && heldWeth) { if (isRouter(u.recipient) && heldWeth) {
heldByRouter.set( heldByRouter.set(
"0x0000000000000000000000000000000000000000", "0x0000000000000000000000000000000000000000",
@@ -2292,8 +2266,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What the steps take that the Amount line does not give, as // What the steps take that the Amount line does not give, as
// { token, amount, atMost, recipient }. A null token is a step this // { token, amount, atMost, recipient }. A null token is a step this
// decoder did not read (see spentUnread()), or a token it does not // decoder did not read (see spentUnread()), or a token it does not
// know. `atMost` is as in `userPaid`. Only the take of a WRAP_ETH has // know. `atMost` is as in `userPaid`. Only the take of a WRAP_ETH that
// a recipient (see wrapEthRecipient and userPaid). // pays elsewhere has a recipient (see wrapEthRecipient and userPaid).
const spentFromSender = []; const spentFromSender = [];
let total = null; let total = null;
// The takes from the sender that `total` adds up, each a quantity, in // The takes from the sender that `total` adds up, each a quantity, in
@@ -2341,7 +2315,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
typeof total === "bigint" && typeof total === "bigint" &&
typeof t.amount === "bigint" && typeof t.amount === "bigint" &&
!t.atMost && !t.atMost &&
(!present(t.recipient) || isRouter(t.recipient)) !present(t.recipient)
) { ) {
total += t.amount; total += t.amount;
inTotal.push(t); inTotal.push(t);
@@ -2439,14 +2413,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
// The text of an amount the swap steps spend, for the Amount line and // The text of an amount the swap steps spend, for the Amount line and
// the lines for other tokens below it. Four amounts need no scale to // the lines for other tokens below it. Three amounts need no scale to
// describe and are named rather than formatted: V4's open delta, V2's // describe and are named rather than formatted: V4's open delta and
// already-paid zero and the ETH the transaction carries, none of which // V2's already-paid zero, neither of which is a quantity at all (see
// is a quantity at all (see OPEN_DELTA, ALREADY_PAID and // OPEN_DELTA and ALREADY_PAID), and an unbounded permit. Those two
// TRANSACTION_ETH), and an unbounded permit. Those three tests come // tests come first — the sentinels are not bigints and cannot be
// first — the sentinels are not bigints and cannot be compared with // compared with one. `atMost` marks an amount that is only the most a
// one. `atMost` marks an amount that is only the most a take may be // take may be (see userPaid).
// (see userPaid).
const spentText = (amount, info, atMost) => { const spentText = (amount, info, atMost) => {
if (amount === OPEN_DELTA) { if (amount === OPEN_DELTA) {
return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
@@ -2457,12 +2430,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
display: ALREADY_PAID_AMOUNT, display: ALREADY_PAID_AMOUNT,
}; };
} }
if (amount === TRANSACTION_ETH) {
return {
raw: TRANSACTION_ETH_AMOUNT,
display: TRANSACTION_ETH_AMOUNT,
};
}
if (amount >= MAX_UINT160) { if (amount >= MAX_UINT160) {
return { raw: "Unlimited", display: "Unlimited" }; return { raw: "Unlimited", display: "Unlimited" };
} }
@@ -2522,8 +2489,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// of the lines below for a PERMIT2_TRANSFER_FROM. An amount this // of the lines below for a PERMIT2_TRANSFER_FROM. An amount this
// decoder does not know is said so, and a step it did not read gets // decoder does not know is said so, and a step it did not read gets
// that one line alone. A take with no recipient goes to the swap step // that one line alone. A take with no recipient goes to the swap step
// that spends it. A take with a recipient goes to that recipient: // that spends it. A take with a recipient goes to that recipient;
// the router, the sender as `MSG_SENDER`, or an address. // `MSG_SENDER` means the sender.
for (const s of spentFromSender) { for (const s of spentFromSender) {
details.push({ details.push({
label: "Moved from the sender", label: "Moved from the sender",
@@ -2547,8 +2514,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
label: "Moved to", label: "Moved to",
value: "The swap step that spends it", value: "The swap step that spends it",
}); });
} else if (isRouter(s.recipient)) {
details.push({ label: "Moved to", value: "The router" });
} else if (s.recipient.toLowerCase() === MSG_SENDER) { } else if (s.recipient.toLowerCase() === MSG_SENDER) {
details.push({ label: "Moved to", value: "The sender" }); details.push({ label: "Moved to", value: "The sender" });
} else { } else {
-61
View File
@@ -19,17 +19,12 @@
// its ETH line reads the same, and Send states it as unknown // its ETH line reads the same, and Send states it as unknown
// (https://git.eeqj.de/sneak/AutistMask/issues/736). // (https://git.eeqj.de/sneak/AutistMask/issues/736).
// //
// A refresh that returns while Send is up draws Send's balance line again, and
// leaves what the user entered as it is
// (https://git.eeqj.de/sneak/AutistMask/issues/748).
//
// Driven through the real popup over a stored profile on Ethereum Mainnet, // Driven through the real popup over a stored profile on Ethereum Mainnet,
// with "Show tracked tokens with zero balance" on, as it is by default. // with "Show tracked tokens with zero balance" on, as it is by default.
const { HDNodeWallet, Mnemonic } = require("ethers"); const { HDNodeWallet, Mnemonic } = require("ethers");
const { const {
ADDRESS,
bootPopup, bootPopup,
cleanupPopup, cleanupPopup,
unversionedValidProfile, unversionedValidProfile,
@@ -436,62 +431,6 @@ test("Send for the new token reads its balance as unknown before any refresh has
await finish([MILLION]); await finish([MILLION]);
}); });
test("Send for the new token reads the fetched balance once the refresh returns, without being reopened", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
await openToken(page, NEW_TOKEN.toLowerCase());
await page.click("btn-address-token-send");
expect(page.text("send-balance")).toBe("Current balance: unknown (NEW)");
await finish([MILLION]);
expect(page.visibleViews()).toEqual(["send"]);
expect(page.text("send-balance")).toBe("Current balance: 1000000.0000 NEW");
});
test("a refresh that returns while Send is up draws an unread ETH balance again and leaves the inputs alone", async () => {
const page = await bootPopup(profileNotRead());
// The dropdown's first option, which a browser selects by itself.
page.node("send-token").value = "ETH";
await page.click("btn-main-send");
expect(page.text("send-balance")).toBe("Current balance: unknown (ETH)");
const recipient = "0x" + "dd".repeat(20);
page.node("send-to").value = recipient;
page.node("send-amount").value = "0.5";
// Stands for the options on the screen, which a refresh leaves as they are.
page.node("send-token").innerHTML = "options on the screen";
// Stands for the From address from before the refresh.
page.node("send-from").innerHTML = "From address before the refresh";
await refreshOnce(page, []);
expect(page.visibleViews()).toEqual(["send"]);
expect(page.text("send-balance")).toBe("Current balance: 2.0000 ETH");
expect(page.node("send-from").innerHTML).toContain(ADDRESS);
expect(page.value("send-to")).toBe(recipient);
expect(page.value("send-amount")).toBe("0.5");
expect(page.value("send-token")).toBe("ETH");
expect(page.node("send-token").innerHTML).toBe("options on the screen");
});
test("a refresh that returns while Send is up keeps the token picked in the dropdown and draws its new balance", async () => {
const page = await bootOnDerivingWallet();
const dropdown = page.node("send-token");
dropdown.value = "ETH";
await page.click("btn-main-send");
dropdown.value = NEW_TOKEN;
for (const fn of dropdown.listeners.change) await fn();
expect(page.text("send-balance")).toBe("Current balance: 1000000.0000 NEW");
page.node("send-amount").value = "0.5";
await refreshOnce(page, [{ ...MILLION, balance: "3000000.0" }]);
expect(page.text("send-balance")).toBe("Current balance: 3000000.0000 NEW");
expect(page.value("send-token")).toBe(NEW_TOKEN);
expect(page.value("send-amount")).toBe("0.5");
});
test("a token added by contract address on SettingsAddToken reads quantity unknown until the refresh returns", async () => { test("a token added by contract address on SettingsAddToken reads quantity unknown until the refresh returns", async () => {
const page = await bootPopup(unversionedValidProfile()); const page = await bootPopup(unversionedValidProfile());
expect(page.visibleViews()).toEqual(["main"]); expect(page.visibleViews()).toEqual(["main"]);
-165
View File
@@ -1,165 +0,0 @@
// "Back" from AddressToken leaves no token selected. AddressDetail's show()
// would clear it, but Back onto a drawn screen only unhides that screen, and
// Send and Receive both read the field
// (https://git.eeqj.de/sneak/AutistMask/issues/751). Driven through the real
// popup over a stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
const TOKEN = {
address: TOKEN_ADDRESS,
symbol: "TST",
decimals: 18,
name: "Test Token",
};
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto AddressDetail, at an address holding 1.5 ETH and
// 2.5 TST.
function bootOnAddress() {
const profile = unversionedValidProfile({
currentView: "address",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [TOKEN],
});
profile.wallets[0].addresses[0].tokenBalances = [
{ ...TOKEN, balance: "2.5" },
];
return bootPopup(profile);
}
// AddressToken opened from AddressDetail, as tapping the token's row does.
async function openToken(page) {
const { state } = require("../src/shared/state");
state.selectedToken = TOKEN_ADDRESS;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressToken").show();
await page.settle();
expect(page.visibleViews()).toEqual(["address-token"]);
}
async function backFromToken(page) {
await page.click("btn-address-token-back");
expect(page.visibleViews()).toEqual(["address"]);
}
// ETH chosen in Send's dropdown, as the user does.
async function chooseEth(page) {
const dropdown = page.node("send-token");
dropdown.value = "ETH";
for (const fn of dropdown.listeners.change) await fn();
}
// Review for 1 of the token Send has, to RECIPIENT.
async function review(page) {
page.node("send-to").value = RECIPIENT;
page.node("send-amount").value = "1";
await page.click("btn-send-review");
expect(page.visibleViews()).toEqual(["confirm-tx"]);
}
describe("after Back from AddressToken", () => {
test("no token is selected, on the page or in the stored record", async () => {
const page = await bootOnAddress();
await openToken(page);
await backFromToken(page);
const { state } = require("../src/shared/state");
expect(state.selectedToken).toBeNull();
expect(page.storage.read("autistmask").selectedToken).toBeNull();
});
test("Send with ETH chosen shows the ETH balance", async () => {
const page = await bootOnAddress();
await openToken(page);
await backFromToken(page);
await page.click("btn-send");
await chooseEth(page);
expect(page.text("send-balance")).toBe("Current balance: 1.5000 ETH");
});
test("Send with ETH chosen reviews ETH", async () => {
const page = await bootOnAddress();
await openToken(page);
await backFromToken(page);
await page.click("btn-send");
await chooseEth(page);
await review(page);
expect(page.text("confirm-type")).toBe("Native ETH transfer");
});
test("Receive shows no token warning", async () => {
const page = await bootOnAddress();
await openToken(page);
await backFromToken(page);
await page.click("btn-receive");
expect(page.visibleViews()).toEqual(["receive"]);
expect(page.text("receive-erc20-warning")).toBe("");
});
// AddressToken's Send sets the dropdown to the token, and nothing else
// rebuilds it on the way back to AddressDetail.
test("Send opens on ETH after AddressToken's Send has been used", async () => {
const page = await bootOnAddress();
await openToken(page);
await page.click("btn-address-token-send");
await page.click("btn-send-back");
await backFromToken(page);
await page.click("btn-send");
expect(page.value("send-token")).toBe("ETH");
expect(page.text("send-balance")).toBe("Current balance: 1.5000 ETH");
});
});
describe("Send opened from AddressToken sends that token", () => {
async function expectSendsToken(page) {
await page.click("btn-address-token-send");
expect(page.text("send-balance")).toBe("Current balance: 2.5000 TST");
await review(page);
expect(page.text("confirm-type")).toBe("ERC-20 token transfer (TST)");
}
test("the first time", async () => {
const page = await bootOnAddress();
await openToken(page);
await expectSendsToken(page);
});
test("after Back from Send", async () => {
const page = await bootOnAddress();
await openToken(page);
await page.click("btn-address-token-send");
await page.click("btn-send-back");
await expectSendsToken(page);
});
test("when the token is opened again after Back from AddressToken", async () => {
const page = await bootOnAddress();
await openToken(page);
await backFromToken(page);
await openToken(page);
await expectSendsToken(page);
});
});
+2 -2
View File
@@ -5770,8 +5770,8 @@ async function main() {
// There is no fourth: once the context is closed nothing can record, // There is no fourth: once the context is closed nothing can record,
// because the route handler and the console listeners died with it. // because the route handler and the console listeners died with it.
// Traffic that a test defers past the trailing drain is therefore // Traffic that a test defers past the trailing drain is therefore
// never observed at all — a real limit of this design, and not one // never observed at all — a real limit of this design, stated in the
// any post-teardown hook could close. // README, and not one any post-teardown hook could close.
// //
// Two green-but-vacuous runs on this harness were the same shape: a // Two green-but-vacuous runs on this harness were the same shape: a
// record falling outside somebody's window and being dropped. First // record falling outside somebody's window and being dropped. First
-185
View File
@@ -1,185 +0,0 @@
// Home's "Send" and "Receive" open with no token selected. Three routes reach
// Home with a token still selected, Send reads that token ahead of its dropdown,
// and Receive warns about it
// (https://git.eeqj.de/sneak/AutistMask/issues/753). Driven through the real
// popup over a stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
const OTHER_WALLET_ADDRESS = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const PRIVATE_KEY = "0x" + "11".repeat(32);
const PASSWORD = "correct horse battery staple";
const TOKEN = {
address: TOKEN_ADDRESS,
symbol: "TST",
decimals: 18,
name: "Test Token",
};
afterEach(() => {
cleanupPopup();
});
// AddressToken opened from AddressDetail, as tapping the token's row does.
async function openToken(page) {
const { state } = require("../src/shared/state");
state.selectedToken = TOKEN_ADDRESS;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressToken").show();
await page.settle();
expect(page.visibleViews()).toEqual(["address-token"]);
}
// The popup open on AddressToken, at an address holding 1.5 ETH and 2.5 TST.
// A second wallet is there for the route that deletes one.
async function bootOnToken() {
const profile = unversionedValidProfile({
currentView: "address",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [TOKEN],
});
profile.wallets[0].addresses[0].tokenBalances = [
{ ...TOKEN, balance: "2.5" },
];
profile.wallets.push({
type: "hd",
name: "Wallet 2",
xpub: "xpub-wallet-2",
encryptedSecret: "encrypted-secret-2",
nextIndex: 1,
addresses: [
{
address: OTHER_WALLET_ADDRESS,
balance: "0",
tokenBalances: [],
},
],
});
const page = await bootPopup(profile);
await openToken(page);
return page;
}
async function reopenAfterTokenSend() {
const closed = await bootOnToken();
await closed.click("btn-address-token-send");
cleanupPopup();
return bootPopup(undefined, { storage: closed.storage });
}
async function addWalletFromTokenSettings() {
const page = await bootOnToken();
await page.click("btn-settings");
await page.click("btn-main-add-wallet");
await page.click("tab-privkey");
page.node("import-private-key").value = PRIVATE_KEY;
page.node("add-wallet-password").value = PASSWORD;
page.node("add-wallet-password-confirm").value = PASSWORD;
await page.click("btn-add-wallet-confirm");
return page;
}
// Deletes through the lost-password screen. It asks for the wallet's name, and
// the stored wallets here have no real password.
async function deleteWalletFromTokenSettings() {
const page = await bootOnToken();
await page.click("btn-settings");
// What the second wallet's [x] button in Settings does.
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/deleteWallet").show(1);
await page.click("btn-delete-wallet-lost-password");
page.node("delete-wallet-lost-name-input").value = "Wallet 2";
await page.click("btn-delete-wallet-lost-confirm");
await page.click("btn-settings-back");
return page;
}
const ROUTES = [
["reopening the popup after AddressToken's Send", reopenAfterTokenSend],
[
"the Settings gear on AddressToken, then adding a wallet",
addWalletFromTokenSettings,
],
[
"the Settings gear on AddressToken, then deleting another wallet, then Back",
deleteWalletFromTokenSettings,
],
];
// A route that stopped reaching Home with the token selected would prove nothing
// in the tests below, so it fails here.
async function arriveOnHome(route) {
const page = await route();
const { state } = require("../src/shared/state");
expect(page.visibleViews()).toEqual(["main"]);
expect(state.selectedToken).toBe(TOKEN_ADDRESS);
return page;
}
// ETH chosen in Send's dropdown, as the user does.
async function chooseEth(page) {
const dropdown = page.node("send-token");
dropdown.value = "ETH";
for (const fn of dropdown.listeners.change) await fn();
}
// Review for 1 of the token Send has, to RECIPIENT.
async function review(page) {
page.node("send-to").value = RECIPIENT;
page.node("send-amount").value = "1";
await page.click("btn-send-review");
expect(page.visibleViews()).toEqual(["confirm-tx"]);
}
describe.each(ROUTES)("after %s", (_name, route) => {
test("choosing ETH on Home's Send reviews ETH", async () => {
const page = await arriveOnHome(route);
await page.click("btn-main-send");
await chooseEth(page);
await review(page);
expect(page.text("confirm-type")).toBe("Native ETH transfer");
expect(page.storage.read("autistmask").selectedToken).toBeNull();
});
test("Home's Send starts on ETH", async () => {
const page = await arriveOnHome(route);
await page.click("btn-main-send");
expect(page.text("send-balance")).toBe("Current balance: 1.5000 ETH");
expect(page.value("send-token")).toBe("ETH");
});
test("Home's Receive shows no token warning", async () => {
const page = await arriveOnHome(route);
await page.click("btn-main-receive");
expect(page.visibleViews()).toEqual(["receive"]);
expect(page.text("receive-erc20-warning")).toBe("");
expect(page.storage.read("autistmask").selectedToken).toBeNull();
});
});
test("AddressToken's Send still sends the token after Home's Send has opened", async () => {
const page = await arriveOnHome(deleteWalletFromTokenSettings);
await page.click("btn-main-send");
await page.click("btn-send-back");
await openToken(page);
await page.click("btn-address-token-send");
expect(page.text("send-balance")).toBe("Current balance: 2.5000 TST");
await review(page);
expect(page.text("confirm-type")).toBe("ERC-20 token transfer (TST)");
});
+13 -16
View File
@@ -1,15 +1,15 @@
// Tests for the address-poisoning defense in src/shared/transactions.js. // Tests for the address-poisoning defense in src/shared/transactions.js.
// //
// README, Scam protection, describes four filters as a core security property // README.md:730-814 describes four filters as a core security property of
// of the wallet: known token symbol verification, the low-holder threshold, the // the wallet: known token symbol verification, the low-holder threshold, the
// fraud contract blocklist, and the dust threshold. A regression in any of // fraud contract blocklist, and the dust threshold. A regression in any of
// them does not crash — it silently stops filtering, and poisoned look-alike // them does not crash — it silently stops filtering, and poisoned look-alike
// addresses reappear in the user's transaction history. These tests pin the // addresses reappear in the user's transaction history. These tests pin the
// behaviour down in both directions: the two attacks below must be filtered, // behaviour down in both directions: the documented attacks must be filtered,
// and legitimate transactions must survive untouched. // and legitimate transactions must survive untouched.
// //
// Fixtures are static local objects modelled on two real attacks. // Fixtures are static local objects modelled on the two real attacks cited in
// Nothing here touches the network: global.fetch is replaced // the README. Nothing here touches the network: global.fetch is replaced
// with a throwing stub and the only fetch path in the module under test // with a throwing stub and the only fetch path in the module under test
// (debugFetch, from src/shared/log) is mocked at the module boundary. // (debugFetch, from src/shared/log) is mocked at the module boundary.
@@ -46,12 +46,11 @@ const { debugFetch } = require("../src/shared/log");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Addresses and hashes from two real attacks. README, Scam protection, has the // Addresses and hashes from the two attacks documented in README.md:730-814.
// filters that stop them.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// The fake "Ethereum" token with symbol "ETH" and zero holders. See "Known // The fake "Ethereum" token with symbol "ETH" and zero holders
// symbol spoofing" and "Low-holder tokens" in README, Scam protection. // (README.md:735-750).
const FAKE_ETH_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82"; const FAKE_ETH_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82";
// The fraudulent Transfer event it emitted. // The fraudulent Transfer event it emitted.
const FAKE_ETH_TRANSFER_HASH = const FAKE_ETH_TRANSFER_HASH =
@@ -63,8 +62,7 @@ const LEGIT_RECIPIENT = "0xc3c693ae04bad5f13c45885c1e85a9557798f37e";
// The scam address the fake token transfer pointed at ("0xC3C0" vs "0xC3c6"). // The scam address the fake token transfer pointed at ("0xC3C0" vs "0xC3c6").
const TOKEN_SCAM_LOOKALIKE = "0xc3c0aea127c575b9ffd03bf11c6a878e8979c37f"; const TOKEN_SCAM_LOOKALIKE = "0xc3c0aea127c575b9ffd03bf11c6a878e8979c37f";
// The second wave: a real 1 gwei native transfer. See "Dust transactions" in // The second wave: a real 1 gwei native transfer (README.md:800-808).
// README, Scam protection.
const DUST_TX_HASH = const DUST_TX_HASH =
"0x2708ebddfb9b5fa3f7a89d3ea398ef9fd8771b83ed861ecb7c21cd55d18edc74"; "0x2708ebddfb9b5fa3f7a89d3ea398ef9fd8771b83ed861ecb7c21cd55d18edc74";
const DUST_SENDER_LOOKALIKE = "0xc3c6b3b4402bd78a9582ab6b00e747769344f37e"; const DUST_SENDER_LOOKALIKE = "0xc3c6b3b4402bd78a9582ab6b00e747769344f37e";
@@ -81,8 +79,7 @@ const NOVEL_SPAM_SYMBOL = "SPAMTKN";
// An ordinary counterparty for legitimate-transaction fixtures. // An ordinary counterparty for legitimate-transaction fixtures.
const ORDINARY_PEER = "0x5aa0f9f1e0a1d0e0e5c1e7ce3b7dbbe9c19f0a11"; const ORDINARY_PEER = "0x5aa0f9f1e0a1d0e0e5c1e7ce3b7dbbe9c19f0a11";
// The documented default settings (README, Settings; DEFAULT_STATE in // The documented default settings (README.md:810-814, state.js:24-27).
// src/shared/persistedState.js).
const DEFAULT_FILTERS = { const DEFAULT_FILTERS = {
hideSpoofedSymbols: true, hideSpoofedSymbols: true,
hideLowHolderTokens: true, hideLowHolderTokens: true,
@@ -223,7 +220,7 @@ describe("token list assumptions the fixtures rely on", () => {
}); });
}); });
describe("the real attacks", () => { describe("the real attacks documented in README.md:730-814", () => {
test('the fake "Ethereum"/"ETH" token transfer is filtered by default', () => { test('the fake "Ethereum"/"ETH" token transfer is filtered by default', () => {
const attack = fakeEthTokenTransfer(); const attack = fakeEthTokenTransfer();
const result = filterTransactions([attack], filters()); const result = filterTransactions([attack], filters());
@@ -402,8 +399,8 @@ describe("known-symbol spoof verification", () => {
}); });
// Turning the other three filters off must not turn this one off: each // Turning the other three filters off must not turn this one off: each
// filter is independent, and this is the one that stops the fake "ETH" // filter is independent, and this is the one the README calls out as the
// attack. // defense against the fake "ETH" attack.
test("the check still runs when the other three filters are off", () => { test("the check still runs when the other three filters are off", () => {
const result = filterTransactions( const result = filterTransactions(
[fakeEthTokenTransfer()], [fakeEthTokenTransfer()],
+21 -356
View File
@@ -2474,11 +2474,10 @@ describe("uniswap decoder", () => {
// SETTLE of ETH from the router's own ETH, whichever payer it names. // SETTLE of ETH from the router's own ETH, whichever payer it names.
// After an earlier step that may leave ETH in the router, the ETH that // After an earlier step that may leave ETH in the router, the ETH that
// the user pays a V4 step reads as a maximum, on lines of its own. // the user pays a V4 step reads as a maximum, on lines of its own.
// Every row reads at least the ETH that can leave the wallet. The row // Every row reads at least the ETH that can leave the wallet. The two
// marked "reads as on next" reads the same on next. The row with a // rows marked "reads as on next" read the same on next. On next every
// SETTLE_ALL of at most 3 ETH read that ETH as not known on next. On // other row added that ETH to the Amount line, or read it as an exact
// next every other row added that ETH to the Amount line, or read it as // amount.
// an exact amount.
// //
// ADDRESS_THIS is the router on every release. // ADDRESS_THIS is the router on every release.
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002"; const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
@@ -2626,7 +2625,7 @@ describe("uniswap decoder", () => {
], ],
lines: [ lines: [
["Amount", "600.0000 USDT"], ["Amount", "600.0000 USDT"],
...wrapLines("The router"), ...wrapLines("The swap step that spends it"),
...ethLines("Up to 1.0000 ETH"), ...ethLines("Up to 1.0000 ETH"),
], ],
}, },
@@ -2675,7 +2674,7 @@ describe("uniswap decoder", () => {
], ],
}, },
{ {
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 exact-out paid in ETH with a SETTLE_ALL of at most 3 ETH", kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 exact-out paid in ETH with a SETTLE_ALL of at most 3 ETH, reads as on next",
commands: [0x0b, 0x0c, 0x10], commands: [0x0b, 0x0c, 0x10],
inputs: [ inputs: [
WRAP_INTO_ROUTER, WRAP_INTO_ROUTER,
@@ -2698,7 +2697,10 @@ describe("uniswap decoder", () => {
], ],
), ),
], ],
lines: [...wrapLines("The router"), ...ethLines(UNDECODED)], lines: [
...wrapLines("The swap step that spends it"),
...ethLines(UNDECODED),
],
}, },
{ {
kind: "a WRAP_ETH of 1 ETH into the router, a V3 exact-out of at most 1 WETH paid from the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles", kind: "a WRAP_ETH of 1 ETH into the router, a V3 exact-out of at most 1 WETH paid from the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
@@ -2759,8 +2761,8 @@ describe("uniswap decoder", () => {
// does not set the input side takes the amount it states from the // does not set the input side takes the amount it states from the
// sender. On next no line gave that ETH. The rows marked "reads as on // sender. On next no line gave that ETH. The rows marked "reads as on
// next" state no amount to take from the sender. Uniswap's own routes // next" state no amount to take from the sender. Uniswap's own routes
// wrap CONTRACT_BALANCE into the router after their first step, as the // wrap CONTRACT_BALANCE after their first step, as the last two rows
// last row does. // do.
const CONTRACT_BALANCE = 2n ** 255n; const CONTRACT_BALANCE = 2n ** 255n;
const WRAP_TO_OTHER = encodeWrapEth(OTHER_ADDR, ONE_ETH); const WRAP_TO_OTHER = encodeWrapEth(OTHER_ADDR, ONE_ETH);
test.each([ test.each([
@@ -2785,7 +2787,7 @@ describe("uniswap decoder", () => {
inputs: [V3_600, WRAP_INTO_ROUTER], inputs: [V3_600, WRAP_INTO_ROUTER],
lines: [ lines: [
["Amount", "600.0000 USDT"], ["Amount", "600.0000 USDT"],
...wrapLines("The router"), ...wrapLines("The swap step that spends it"),
], ],
}, },
{ {
@@ -2794,7 +2796,7 @@ describe("uniswap decoder", () => {
inputs: [V3_600, encodeWrapEth(ROUTER_ADDR, ONE_ETH)], inputs: [V3_600, encodeWrapEth(ROUTER_ADDR, ONE_ETH)],
lines: [ lines: [
["Amount", "600.0000 USDT"], ["Amount", "600.0000 USDT"],
...wrapLines("The router"), ...wrapLines("The swap step that spends it"),
], ],
}, },
{ {
@@ -2837,9 +2839,7 @@ describe("uniswap decoder", () => {
], ],
lines: [ lines: [
["Amount", "1.0000 ETH"], ["Amount", "1.0000 ETH"],
["Moved from the sender", "Up to 1.0000 ETH"], ...ethLines("Up to 1.0000 ETH"),
["Token moved", "ETH"],
["Moved to", "The router"],
], ],
}, },
{ {
@@ -2877,6 +2877,12 @@ describe("uniswap decoder", () => {
inputs: [V3_600, encodeWrapEth(OTHER_ADDR, 0n)], inputs: [V3_600, encodeWrapEth(OTHER_ADDR, 0n)],
lines: [["Amount", "600.0000 USDT"]], lines: [["Amount", "600.0000 USDT"]],
}, },
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of CONTRACT_BALANCE to the sender, reads as on next",
commands: [0x00, 0x0b],
inputs: [V3_600, encodeWrapEth(MSG_SENDER, CONTRACT_BALANCE)],
lines: [["Amount", "600.0000 USDT"]],
},
{ {
kind: "a V4 swap of 600 USDT that takes ETH into the router, a WRAP_ETH of CONTRACT_BALANCE into the router, then a V3 exact-in of it, reads as on next", kind: "a V4 swap of 600 USDT that takes ETH into the router, a WRAP_ETH of CONTRACT_BALANCE into the router, then a V3 exact-in of it, reads as on next",
commands: [0x10, 0x0b, 0x00], commands: [0x10, 0x0b, 0x00],
@@ -2917,347 +2923,6 @@ describe("uniswap decoder", () => {
expect(linesBetweenTokens(result)).toEqual(lines); expect(linesBetweenTokens(result)).toEqual(lines);
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/747 — a WRAP_ETH into the
// router pays its WETH to the router, whichever step then takes it. On
// next that Moved to line read The swap step that spends it in every
// row.
const SWAP_STEP = "The swap step that spends it";
// Each pays 1 WETH from the router, for USDC, to the user.
const V3_WETH_FROM_ROUTER = coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
USER_ADDR,
ONE_ETH,
1n,
encodeV3Path([WETH_ADDR, USDC_ADDR]),
false,
],
);
const V2_WETH_FROM_ROUTER = coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[USER_ADDR, ONE_ETH, 1n, [WETH_ADDR, USDC_ADDR], false],
);
const V4_WETH_FROM_ROUTER = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(WETH_ADDR, USDC_ADDR, ONE_ETH, 1n),
settle(WETH_ADDR, 0n, false),
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
// SWEEP has the layout of TRANSFER, and its amount is a minimum.
const SWEEP_WETH = payOut(WETH_ADDR, 0n);
test.each([
{
kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH into the router, then a SWEEP of that WETH to another address",
commands: [0x00, 0x0b, 0x04],
inputs: [V3_600, WRAP_INTO_ROUTER, SWEEP_WETH],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The router"),
],
},
{
kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH into the router, a SWEEP of that WETH to another address, then a V3 exact-in paid from the router's WETH",
commands: [0x00, 0x0b, 0x04, 0x00],
inputs: [
V3_600,
WRAP_INTO_ROUTER,
SWEEP_WETH,
V3_WETH_FROM_ROUTER,
],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The router"),
],
},
{
kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH into the router, then a V3 exact-in of WETH that the user pays",
commands: [0x00, 0x0b, 0x00],
inputs: [
V3_600,
WRAP_INTO_ROUTER,
encodeV3SwapExactIn(USER_ADDR, ONE_ETH, 1n, [
WETH_ADDR,
USDC_ADDR,
]),
],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The router"),
["Moved from the sender", "1.0000 WETH"],
["Token moved", "WETH (" + WETH_ADDR + ")"],
["Moved to", SWAP_STEP],
],
},
{
kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH into the router, a V3 exact-in paid from the router's WETH, then another WRAP_ETH of 1 ETH into the router",
commands: [0x00, 0x0b, 0x00, 0x0b],
inputs: [
V3_600,
WRAP_INTO_ROUTER,
V3_WETH_FROM_ROUTER,
WRAP_INTO_ROUTER,
],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The router"),
...wrapLines("The router"),
],
},
{
kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH into the router, then a V3 exact-in paid from the router's WETH",
commands: [0x00, 0x0b, 0x00],
inputs: [V3_600, WRAP_INTO_ROUTER, V3_WETH_FROM_ROUTER],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The router"),
],
},
{
kind: "the same with a V2 exact-in paid from the router's WETH",
commands: [0x00, 0x0b, 0x08],
inputs: [V3_600, WRAP_INTO_ROUTER, V2_WETH_FROM_ROUTER],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The router"),
],
},
{
kind: "the same with a V3 exact-out paid from the router's WETH",
commands: [0x00, 0x0b, 0x01],
inputs: [
V3_600,
WRAP_INTO_ROUTER,
encodeV3SwapExactOut(
USER_ADDR,
390000000n,
ONE_ETH,
[USDC_ADDR, WETH_ADDR],
false,
),
],
lines: [
["Amount", "Up to 600.0000 USDT"],
["Moved from the sender", "Up to 1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", "The router"],
],
},
{
kind: "the same with a V4 swap that the router's WETH settles",
commands: [0x00, 0x0b, 0x10],
inputs: [V3_600, WRAP_INTO_ROUTER, V4_WETH_FROM_ROUTER],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The router"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router that sets the input side, a SWEEP of that WETH to another address, then an EXECUTE_SUB_PLAN",
commands: [0x0b, 0x04, 0x21],
inputs: [WRAP_INTO_ROUTER, SWEEP_WETH, SUB_PLAN],
lines: [
...wrapLines("The router"),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router that sets the input side, a V3 exact-in paid from the router's WETH, then an EXECUTE_SUB_PLAN",
commands: [0x0b, 0x00, 0x21],
inputs: [WRAP_INTO_ROUTER, V3_WETH_FROM_ROUTER, SUB_PLAN],
lines: [
...wrapLines("The router"),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router, a WRAP_ETH of 1 ETH into the router, then a V3 exact-in of 600 USDT",
commands: [0x0a, 0x0b, 0x00],
inputs: [
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
WRAP_INTO_ROUTER,
V3_600,
],
lines: [
["Amount", "5000.0000 USDT"],
...wrapLines("The router"),
],
},
{
kind: "the same with a V3 exact-in paid from the router's WETH after the WRAP_ETH",
commands: [0x0a, 0x0b, 0x00, 0x00],
inputs: [
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
WRAP_INTO_ROUTER,
V3_WETH_FROM_ROUTER,
V3_600,
],
lines: [
["Amount", "5000.0000 USDT"],
...wrapLines("The router"),
],
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// https://git.eeqj.de/sneak/AutistMask/issues/747 — a later WRAP_ETH
// of CONTRACT_BALANCE wraps all the ETH the router holds. Unless an
// earlier step paid ETH to the router, that is the ETH the transaction
// carries. On next no line gave it. Into the router it gets no lines.
// It gets none after an UNWRAP_WETH into the router, or while the
// router holds ETH from a V4 step: Uniswap's own routes pay back what
// their swap did not spend this way, and carry no ETH. In the rows
// marked "reads as on next" that WRAP_ETH gets no lines.
const WRAP_ALL_TO_OTHER = encodeWrapEth(OTHER_ADDR, CONTRACT_BALANCE);
const WRAP_ALL_TO_SENDER = encodeWrapEth(MSG_SENDER, CONTRACT_BALANCE);
// The lines of such a WRAP_ETH whose Moved to line reads `to`.
const allEthLines = (to) => [
["Moved from the sender", "All the ETH the transaction carries"],
["Token moved", "ETH"],
["Moved to", to],
];
// The first steps of a route that spends WETH on a V4 pool of ETH:
// a PERMIT2_TRANSFER_FROM of 1 WETH into the router, an UNWRAP_WETH
// into the router, then a V4 exact-out paid from the router's ETH.
const UNWRAP_ROUTE = [
coder.encode(
["address", "address", "uint160"],
[WETH_ADDR, ADDRESS_THIS, ONE_ETH],
),
UNWRAP_INTO_ROUTER,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
ZERO_ADDR,
USDC_ADDR,
390000000n,
ONE_ETH,
),
settle(ZERO_ADDR, 0n, false),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
];
// The lines of that route on next: this decoder cannot tell what the
// V4 step's SETTLE of ETH pays.
const UNWRAP_ROUTE_LINES = [
["Moved from the sender", UNDECODED],
["Token moved", "ETH"],
["Moved to", SWAP_STEP],
];
test.each([
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of CONTRACT_BALANCE to another address",
commands: [0x00, 0x0b],
inputs: [V3_600, WRAP_ALL_TO_OTHER],
lines: [
["Amount", "600.0000 USDT"],
...allEthLines(OTHER_ADDR),
],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of CONTRACT_BALANCE to the sender",
commands: [0x00, 0x0b],
inputs: [V3_600, WRAP_ALL_TO_SENDER],
lines: [
["Amount", "600.0000 USDT"],
...allEthLines("The sender"),
],
},
{
kind: "a V3 exact-out of at most 600 USDT, then a WRAP_ETH of CONTRACT_BALANCE to another address",
commands: [0x01, 0x0b],
inputs: [
encodeV3SwapExactOut(USER_ADDR, 390000000n, USDT_600, [
USDC_ADDR,
USDT_ADDR,
]),
WRAP_ALL_TO_OTHER,
],
lines: [
["Amount", "Up to 600.0000 USDT"],
...allEthLines(OTHER_ADDR),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a WRAP_ETH of CONTRACT_BALANCE to another address",
commands: [0x0b, 0x0b],
inputs: [WRAP_INTO_ROUTER, WRAP_ALL_TO_OTHER],
lines: [["Amount", "1.0000 ETH"], ...allEthLines(OTHER_ADDR)],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of CONTRACT_BALANCE into the router, reads as on next",
commands: [0x00, 0x0b],
inputs: [V3_600, encodeWrapEth(ADDRESS_THIS, CONTRACT_BALANCE)],
lines: [["Amount", "600.0000 USDT"]],
},
{
kind: "a V3 exact-in of 600 USDT, an UNWRAP_WETH to the sender, then a WRAP_ETH of CONTRACT_BALANCE to another address",
commands: [0x00, 0x0c, 0x0b],
inputs: [
V3_600,
encodeWrapEth(MSG_SENDER, 0n),
WRAP_ALL_TO_OTHER,
],
lines: [
["Amount", "600.0000 USDT"],
...allEthLines(OTHER_ADDR),
],
},
{
kind: "a V4 swap of 600 USDT that takes ETH into the router, then a WRAP_ETH of CONTRACT_BALANCE to the sender, reads as on next",
commands: [0x10, 0x0b],
inputs: [
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
ZERO_ADDR,
USDT_600,
1n,
),
settle(USDT_ADDR, 0n, true),
take(ZERO_ADDR, ADDRESS_THIS, 0n),
],
),
WRAP_ALL_TO_SENDER,
],
lines: [["Amount", "600.0000 USDT"]],
},
{
kind: "the unwrap route, then a WRAP_ETH of CONTRACT_BALANCE to the sender, reads as on next",
commands: [0x02, 0x0c, 0x10, 0x0b],
inputs: [...UNWRAP_ROUTE, WRAP_ALL_TO_SENDER],
lines: UNWRAP_ROUTE_LINES,
},
{
kind: "the unwrap route, then a WRAP_ETH of CONTRACT_BALANCE to another address, reads as on next",
commands: [0x02, 0x0c, 0x10, 0x0b],
inputs: [...UNWRAP_ROUTE, WRAP_ALL_TO_OTHER],
lines: UNWRAP_ROUTE_LINES,
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax` // A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax`
// USDT for USDC. // USDT for USDC.
const v3ExactOut = (amountInMax) => const v3ExactOut = (amountInMax) =>