2 Commits
Author SHA1 Message Date
clawbot 1a3fefde4a fix: the swap decoder shows the ETH of a WRAP_ETH that does not set the input side (closes #721)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A later WRAP_ETH of a stated amount now counts as a take from the sender.
When it pays anyone but the router, it gets its own Moved from the sender
lines, with the recipient on Moved to. When it pays the router, it joins
the Amount line if Token In is ETH, and otherwise gets those lines too.
When the router may already hold ETH, the amount reads Up to.

A WRAP_ETH whose input this decoder cannot read gets a line saying the
amount could not be decoded.

A WRAP_ETH of CONTRACT_BALANCE states no amount and reads as before, so
Uniswap's own routes do not change.

Model: opus-5-5
2026-11-03 05:08:45 +00:00
clawbot 1a4fb0803d fix: a save merges this page's tracked token change into the stored list (closes #717)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A save wrote `trackedTokens` whole, so two pages on one network dropped each
other's adds and brought back each other's removals.

A save that keeps the network now merges this page's change into the stored
list by contract address. A save that switches networks is unchanged.

`mergeTrackedTokens()` does that merge, and the two other merges of tracked
tokens now call it. It keeps one record per contract address, this page's for
a token both lists hold, so a token held twice no longer multiplies.

Model: opus-5-5
2026-11-03 05:59:42 +01:00
6 changed files with 420 additions and 43 deletions
+18 -7
View File
@@ -397,10 +397,16 @@ is missing, a write fails.
The popup's `saveState()` (`src/shared/state.js`) reads the stored record, The popup's `saveState()` (`src/shared/state.js`) reads the stored record,
merges this page's changes into it and writes it back. It writes only the fields merges this page's changes into it and writes it back. It writes only the fields
this page changed since its last save. Wallets, site lists and endpoints are this page changed since its last save. It merges the fields below entry by
merged entry by entry, so two pages changing different things do not overwrite entry, so two pages changing different things do not overwrite each other:
each other. The background's `updateState()` reads the record, applies one
change and writes it back. - wallets;
- site lists;
- endpoints;
- tracked tokens.
The background's `updateState()` reads the record, applies one change and writes
it back.
Every write stamps `schemaVersion` with `STATE_SCHEMA_VERSION` from Every write stamps `schemaVersion` with `STATE_SCHEMA_VERSION` from
`src/shared/stateSchema.js`, currently `1`. The version is raised only when the `src/shared/stateSchema.js`, currently `1`. The version is raised only when the
@@ -1359,9 +1365,14 @@ 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`. ETH that the `Moved to` line names that recipient, or reads `The sender`. A `WRAP_ETH`
sender pays a V4 step can get these three lines when the router may hold ETH after the step that sets `Token In` takes the amount it states from the
from an earlier step. When the decoder knows the amount, its sender. When it pays anyone but the router, that amount gets these lines. When
it pays the router, that amount joins the `Amount` line if `Token In` is ETH,
and gets these lines if not. A later `WRAP_ETH` of `CONTRACT_BALANCE` states
no amount, so it gets no lines. ETH 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. `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
+66 -22
View File
@@ -404,6 +404,36 @@ function mergeNetworkEndpoints(base, ours, theirs) {
return mergeMapByKey(base, ours, theirs, mergeEndpointEntry); return mergeMapByKey(base, ours, theirs, mergeEndpointEntry);
} }
// Merges one network's tracked tokens by contract address:
//
// - A token another page added or removed since this page's last save stays
// added or removed.
// - A token that this page and `theirs` both hold keeps this page's record.
// - The result holds one record per contract address.
//
// mergeListByIdentity() keeps records that share an identity, on purpose, for
// wallets. A contract address names one token, so this keeps one record for it.
// Without that, a token that `ours` and `theirs` each hold twice comes back four
// times, and each later merge multiplies it again.
//
// `theirs` takes this page's records first. mergeListByIdentity() would
// otherwise log an error for two different records of one token.
function mergeTrackedTokens(base, ours, theirs) {
const contract = (token) => token.address.toLowerCase();
const oursByContract = new Map(
ours.map((token) => [contract(token), token]),
);
const merged = mergeListByIdentity(
base,
ours,
theirs.map((token) => oursByContract.get(contract(token)) || token),
contract,
(baseToken, ourToken) => ourToken,
);
const byContract = new Map(merged.map((token) => [contract(token), token]));
return [...byContract.values()];
}
// Returns a copy of `stored.networkTrackedTokens` for when this page has not yet // Returns a copy of `stored.networkTrackedTokens` for when this page has not yet
// taken the switch in `stored`. `page` is this page's state. A token that this // taken the switch in `stored`. `page` is this page's state. A token that this
// page tracked or removed since its last save is tracked or removed in the list // page tracked or removed since its last save is tracked or removed in the list
@@ -411,12 +441,10 @@ function mergeNetworkEndpoints(base, ours, theirs) {
function networkTrackedTokensWithChange(stored, page) { function networkTrackedTokensWithChange(stored, page) {
return { return {
...stored.networkTrackedTokens, ...stored.networkTrackedTokens,
[page.networkId]: mergeListByIdentity( [page.networkId]: mergeTrackedTokens(
baseline.trackedTokens, baseline.trackedTokens,
page.trackedTokens, page.trackedTokens,
stored.networkTrackedTokens[page.networkId], stored.networkTrackedTokens[page.networkId] || [],
(token) => token.address.toLowerCase(),
(base, ours) => ours,
), ),
}; };
} }
@@ -461,23 +489,27 @@ function networkTrackedTokensWithChange(stored, page) {
// comment at mergeEndpointEntry() for why the collision this closes is // comment at mergeEndpointEntry() for why the collision this closes is
// milder than the other two. // milder than the other two.
// //
// `trackedTokens` is merged by contract address (mergeTrackedTokens()) when this
// page has changed it, so a token another page added or removed since this
// page's last save stays added or removed. A token both lists hold keeps this
// page's record. Two saves differ:
//
// - When the stored record is on another network than this page's, a save keeps
// the stored `trackedTokens`. It merges this page's change into the list in
// `networkTrackedTokens` for this page's network, by contract address.
// - A save that switches networks writes this page's list whole. A switch in
// Settings first merges that list by contract address
// (takeStoredTrackedTokens()).
//
// Every other persisted field stays a whole-field diff: // Every other persisted field stays a whole-field diff:
// `fraudContracts`/`viewStack` are arrays of scalars with no per-element // `fraudContracts`/`viewStack` are arrays of scalars with no per-element
// identity to merge by; a save diffs `trackedTokens` whole, and a switch in // identity to merge by; `networkTrackedTokens` is diffed whole like `networkId`,
// Settings first merges that list by contract address // which a network switch moves together with it; `tokenHolderCache` is a map
// (takeStoredTrackedTokens()); `networkTrackedTokens` is diffed whole like // shaped like the ones above, but nothing in src/ ever writes an entry into it —
// `trackedTokens` and `networkId`, the two fields a network switch moves // it is only ever reset wholesale to `{}` (applyChainSwitchFields()) — so there
// together with it; `tokenHolderCache` is a map shaped like the ones above, // is no in-place mutation for a whole-field diff to collide with; `viewData` is
// but nothing in src/ ever writes an entry into it — it is only ever reset // this page's own UI scratch space, not data another page has any reason to
// wholesale to `{}` (applyChainSwitchFields()) — so there is no in-place // share membership of.
// mutation for a whole-field diff to collide with; `viewData` is this page's
// own UI scratch space, not data another page has any reason to share
// membership of.
//
// One exception: when the stored record is on another network than this
// page's, a save keeps the stored `trackedTokens`. It merges this page's change
// into the list in `networkTrackedTokens` for this page's network, by contract
// address.
// //
// This does not make two pages that both change the SAME leaf concurrently // This does not make two pages that both change the SAME leaf concurrently
// safe: last write wins on that one leaf, same as before. What it removes // safe: last write wins on that one leaf, same as before. What it removes
@@ -564,6 +596,20 @@ async function saveStateOnce(beforeWrite) {
fresh, fresh,
current, current,
); );
} else if (
baseline !== null &&
baseline.networkId === current.networkId &&
!deepEqual(current.trackedTokens, baseline.trackedTokens)
) {
// This page's network is the one it last saved on, and the stored
// record is on it too, so all three lists are of one network. This
// page's change goes into the stored list, and another page's add or
// removal stays (https://git.eeqj.de/sneak/AutistMask/issues/717).
merged.trackedTokens = mergeTrackedTokens(
baseline.trackedTokens,
current.trackedTokens,
fresh.trackedTokens,
);
} }
merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0); merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0);
// Stamped on every write, never merged or diffed: the record that goes to // Stamped on every write, never merged or diffed: the record that goes to
@@ -680,12 +726,10 @@ function takeStoredNetwork(stored) {
// `baseline` takes the stored list, so the save stores the list in `state` // `baseline` takes the stored list, so the save stores the list in `state`
// whenever the two differ. // whenever the two differ.
function takeStoredTrackedTokens(stored) { function takeStoredTrackedTokens(stored) {
rawState.trackedTokens = mergeListByIdentity( rawState.trackedTokens = mergeTrackedTokens(
baseline.trackedTokens, baseline.trackedTokens,
rawState.trackedTokens, rawState.trackedTokens,
stored.trackedTokens, stored.trackedTokens,
(token) => token.address.toLowerCase(),
(base, ours) => ours,
); );
baseline.trackedTokens = structuredClone(stored.trackedTokens); baseline.trackedTokens = structuredClone(stored.trackedTokens);
} }
+30 -7
View File
@@ -1368,7 +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. // "Up to" and the amount. A take with a `recipient`, the ETH of a
// later WRAP_ETH that pays anyone but the router, never joins the
// 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
@@ -1380,8 +1382,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}; };
// A step may take from the sender what this decoder did not read: a // A step may take from the sender what this decoder did not read: a
// V4 step whose input or one of whose SETTLEs or SETTLE_ALLs it // V4 step whose input or one of whose SETTLEs or SETTLE_ALLs it
// cannot read; a V2 or V3 swap step or PERMIT2_TRANSFER_FROM whose // cannot read; a V2 or V3 swap step, PERMIT2_TRANSFER_FROM or WRAP_ETH
// input it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; an // whose input it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; an
// EXECUTE_SUB_PLAN; and a step it fails on. It is recorded with a null // EXECUTE_SUB_PLAN; and a step it fails on. It is recorded with a null
// token and amount: one line says so, there is no Amount line, whose // token and amount: one line says so, there is no Amount line, whose
// figure may then be too low, and the amount of a permit after it // figure may then be too low, and the amount of a permit after it
@@ -1907,6 +1909,25 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!isRouter(w.recipient)) { if (!isRouter(w.recipient)) {
wrapEthRecipient = w.recipient; wrapEthRecipient = w.recipient;
} }
} else if (
w.amount > 0n &&
w.amount !== CONTRACT_BALANCE
) {
// The ETH of a stated amount comes from the
// transaction's value. ETH an earlier step left in
// the router may pay part of it, and the sender
// then pays at most that amount. CONTRACT_BALANCE
// states no amount to take.
userPaid.push({
token: "0x0000000000000000000000000000000000000000",
amount: w.amount,
atMost: heldByRouter.has(
"0x0000000000000000000000000000000000000000",
),
recipient: isRouter(w.recipient)
? null
: w.recipient,
});
} }
addPayee(w.recipient); addPayee(w.recipient);
// CONTRACT_BALANCE wraps all the router's ETH. // CONTRACT_BALANCE wraps all the router's ETH.
@@ -1931,6 +1952,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else { } else {
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i); stepsUnread.push(i);
spentUnread();
} }
} else if (command === "V4_SWAP") { } else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]); const v4 = decodeV4Swap(inputs[i]);
@@ -2203,8 +2225,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// added to it while both are quantities. A take that cannot be added, // added to it while both are quantities. A take that cannot be added,
// being of another token, coming after an amount that is not a // being of another token, coming after an amount that is not a
// quantity (a V4 open delta, ALREADY_PAID, or none stated), or having // quantity (a V4 open delta, ALREADY_PAID, or none stated), or having
// `atMost` set, gets lines of its own. One whose amount is not known // `atMost` or a `recipient` set, gets lines of its own. One whose
// also leaves the total unknown, with no figure. // amount is not known also leaves the total unknown, with no figure.
// //
// A PERMIT2_PERMIT names Token In only when its amount stands as the // A PERMIT2_PERMIT names Token In only when its amount stands as the
// Amount figure in place of the total, which it does only in the // Amount figure in place of the total, which it does only in the
@@ -2245,7 +2267,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// { 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 that // know. `atMost` is as in `userPaid`. Only the take of a WRAP_ETH that
// pays elsewhere has a recipient (see wrapEthRecipient). // 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
@@ -2292,7 +2314,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
ofTokenIn && ofTokenIn &&
typeof total === "bigint" && typeof total === "bigint" &&
typeof t.amount === "bigint" && typeof t.amount === "bigint" &&
!t.atMost !t.atMost &&
!present(t.recipient)
) { ) {
total += t.amount; total += t.amount;
inTotal.push(t); inTotal.push(t);
+118
View File
@@ -117,6 +117,17 @@ async function chooseNetwork(boot, networkId) {
await boot.settle(); await boot.settle();
} }
// Adds NEW_TOKEN_ADDRESS by contract address on SettingsAddToken, as the user
// does. Returns the record the add tracks.
async function addNewToken(boot) {
const { lookupTokenInfo } = require("../src/shared/balances");
const info = { symbol: "NEW", decimals: 18, name: "New Token" };
lookupTokenInfo.mockResolvedValueOnce(info);
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
await boot.click("btn-settings-addtoken-manual");
return { address: NEW_TOKEN_ADDRESS, ...info, balanceNotFetched: true };
}
// The Settings gear, a switch to Sepolia, and Back. // The Settings gear, a switch to Sepolia, and Back.
async function gearSwitchBack(boot) { async function gearSwitchBack(boot) {
await boot.click("btn-settings"); await boot.click("btn-settings");
@@ -685,6 +696,113 @@ test("a token removed while this page waits to take another page's switch is gon
expect(stored.networkTrackedTokens.mainnet).toEqual([]); expect(stored.networkTrackedTokens.mainnet).toEqual([]);
}); });
// Another popup page tracks DAI after this page's last save. This page then
// adds a token. Its save keeps DAI.
test("a token another page added stays when this page adds a token", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [MAINNET_TOKEN],
});
const added = await addNewToken(boot);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
MAINNET_TOKEN,
added,
]);
});
// Another popup page tracks a new token after this page's last save. This page
// then removes DAI, as the `[x]` on a tracked token in Settings does. Its save
// keeps the new token.
test("a token another page added stays when this page removes a token", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
const otherPageToken = {
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
};
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [MAINNET_TOKEN, otherPageToken],
});
const { state, saveState } = require("../src/shared/state");
state.trackedTokens.splice(0, 1);
await saveState();
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
otherPageToken,
]);
});
// Another popup page stops tracking DAI after this page's last save. This page
// still holds DAI and adds a token. Its save does not store DAI again.
test("a token another page removed stays removed when this page adds a token", async () => {
const boot = await bootOnAddToken("mainnet", {
trackedTokens: [MAINNET_TOKEN],
});
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [],
});
const added = await addNewToken(boot);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([added]);
});
// Another popup page tracks DAI and the new token after this page's last save,
// and its refresh has read the new token's balance. This page adds the new token
// too, so the two pages hold different records for it. The save stores one
// record for the new token, this page's, and keeps DAI.
test("a token both pages added is stored once, with this page's record", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [
MAINNET_TOKEN,
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
},
],
});
const added = await addNewToken(boot);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
MAINNET_TOKEN,
added,
]);
});
// This page and the stored list each hold DAI twice. This page then adds a
// token. Its save stores DAI once.
test("a token held twice is stored once when this page adds a token", async () => {
const boot = await bootOnAddToken("mainnet", {
trackedTokens: [MAINNET_TOKEN, MAINNET_TOKEN],
});
expect(boot.pageErrors).toEqual([]);
const added = await addNewToken(boot);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
MAINNET_TOKEN,
added,
]);
});
describe("Back after a switch from the Settings gear", () => { describe("Back after a switch from the Settings gear", () => {
test("SettingsAddToken no longer offers the common tokens", async () => { test("SettingsAddToken no longer offers the common tokens", async () => {
const boot = await bootOnAddToken("mainnet"); const boot = await bootOnAddToken("mainnet");
+170
View File
@@ -2593,6 +2593,9 @@ describe("uniswap decoder", () => {
], ],
lines: [ lines: [
["Amount", "1.0000 ETH"], ["Amount", "1.0000 ETH"],
["Moved from the sender", "Up to 1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", OTHER_ADDR],
...ethLines("Up to 1.0000 ETH"), ...ethLines("Up to 1.0000 ETH"),
], ],
}, },
@@ -2622,6 +2625,7 @@ describe("uniswap decoder", () => {
], ],
lines: [ lines: [
["Amount", "600.0000 USDT"], ["Amount", "600.0000 USDT"],
...wrapLines("The swap step that spends it"),
...ethLines("Up to 1.0000 ETH"), ...ethLines("Up to 1.0000 ETH"),
], ],
}, },
@@ -2753,6 +2757,172 @@ describe("uniswap decoder", () => {
expect(linesBetweenTokens(result)).toEqual(lines); expect(linesBetweenTokens(result)).toEqual(lines);
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/721 — a WRAP_ETH that
// 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
// next" state no amount to take from the sender. Uniswap's own routes
// wrap CONTRACT_BALANCE after their first step, as the last two rows
// do.
const CONTRACT_BALANCE = 2n ** 255n;
const WRAP_TO_OTHER = encodeWrapEth(OTHER_ADDR, ONE_ETH);
test.each([
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to another address",
commands: [0x00, 0x0b],
inputs: [V3_600, WRAP_TO_OTHER],
lines: [["Amount", "600.0000 USDT"], ...wrapLines(OTHER_ADDR)],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to address(1)",
commands: [0x00, 0x0b],
inputs: [V3_600, encodeWrapEth(MSG_SENDER, ONE_ETH)],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The sender"),
],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to address(2)",
commands: [0x00, 0x0b],
inputs: [V3_600, WRAP_INTO_ROUTER],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The swap step that spends it"),
],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to the router by its own address",
commands: [0x00, 0x0b],
inputs: [V3_600, encodeWrapEth(ROUTER_ADDR, ONE_ETH)],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The swap step that spends it"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a WRAP_ETH of 1 ETH to another address",
commands: [0x0b, 0x0b],
inputs: [WRAP_INTO_ROUTER, WRAP_TO_OTHER],
lines: [["Amount", "1.0000 ETH"], ...wrapLines(OTHER_ADDR)],
},
{
kind: "two WRAP_ETHs of 1 ETH into the router, which the Amount line adds up",
commands: [0x0b, 0x0b],
inputs: [WRAP_INTO_ROUTER, WRAP_INTO_ROUTER],
lines: [["Amount", "2.0000 ETH"]],
},
{
kind: "a V3 exact-in of 600 USDT for WETH paid to the router, an UNWRAP_WETH into the router, then a WRAP_ETH of 1 ETH to another address",
commands: [0x00, 0x0c, 0x0b],
inputs: [
encodeV3SwapExactIn(ADDRESS_THIS, USDT_600, 1n, [
USDT_ADDR,
WETH_ADDR,
]),
UNWRAP_INTO_ROUTER,
WRAP_TO_OTHER,
],
lines: [
["Amount", "600.0000 USDT"],
["Moved from the sender", "Up to 1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", OTHER_ADDR],
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a WRAP_ETH of 1 ETH into the router",
commands: [0x0b, 0x0c, 0x0b],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
WRAP_INTO_ROUTER,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router, a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to another address",
commands: [0x0a, 0x00, 0x0b],
inputs: [
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
V3_600,
WRAP_TO_OTHER,
],
lines: [["Amount", "5000.0000 USDT"], ...wrapLines(OTHER_ADDR)],
},
{
kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH to another address, then an EXECUTE_SUB_PLAN",
commands: [0x00, 0x0b, 0x21],
inputs: [V3_600, WRAP_TO_OTHER, SUB_PLAN],
lines: [
...USDT_600_GROUP,
...wrapLines(OTHER_ADDR),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH whose input this decoder cannot read",
commands: [0x00, 0x0b],
inputs: [V3_600, "0x1234"],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of nothing to another address, reads as on next",
commands: [0x00, 0x0b],
inputs: [V3_600, encodeWrapEth(OTHER_ADDR, 0n)],
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",
commands: [0x10, 0x0b, 0x00],
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),
],
),
encodeWrapEth(ADDRESS_THIS, CONTRACT_BALANCE),
coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
USER_ADDR,
CONTRACT_BALANCE,
1n,
encodeV3Path([WETH_ADDR, USDC_ADDR]),
false,
],
),
],
lines: [["Amount", "600.0000 USDT"]],
},
])("$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) =>
+18 -7
View File
@@ -370,7 +370,8 @@ const PAID_ELSEWHERE = [
"Steps", "Steps",
"Deadline", "Deadline",
]; ];
// `lines` with the lines of one PERMIT2_TRANSFER_FROM after Amount. // `lines` with the lines of one PERMIT2_TRANSFER_FROM or later WRAP_ETH after
// Amount.
const withMove = (lines) => const withMove = (lines) =>
lines.flatMap((l) => lines.flatMap((l) =>
l === "Amount" l === "Amount"
@@ -985,7 +986,7 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
); );
// The SWEEP pays the user the swap's WETH, so the UNWRAP_WETH unwraps the // The SWEEP pays the user the swap's WETH, so the UNWRAP_WETH unwraps the
// WRAP_ETH's. // WRAP_ETH's. The WRAP_ETH's 1 ETH gets lines of its own.
test("a SWEEP of the swap's WETH to the user, then a WRAP_ETH into the router, reads as ETH with no minimum", () => { test("a SWEEP of the swap's WETH to the user, then a WRAP_ETH into the router, reads as ETH with no minimum", () => {
const result = decode( const result = decode(
execute( execute(
@@ -996,7 +997,10 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
), ),
); );
expect(result.name).toBe("Swap USDT → ETH"); expect(result.name).toBe("Swap USDT → ETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER); expect(labels(result)).toEqual(withMove(NO_MINIMUM_TO_USER));
expect(detail(result, "Moved from the sender").value).toBe(
"1.0000 ETH",
);
}); });
test("an UNWRAP_WETH into the router, then a WRAP_ETH of all its ETH to the user, reads as WETH with no minimum", () => { test("an UNWRAP_WETH into the router, then a WRAP_ETH of all its ETH to the user, reads as WETH with no minimum", () => {
@@ -1097,7 +1101,9 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
}, },
); );
// The user receives 0.5 WETH and the rest of the swap's output as ETH. // The user receives 0.5 WETH and the rest of the swap's output as ETH. The
// router holds ETH when the WRAP_ETH runs, so the sender pays at most its
// 0.5 ETH.
test("an UNWRAP_WETH into the router, then a WRAP_ETH of 0.5 ETH to the user, reads as WETH with no minimum", () => { test("an UNWRAP_WETH into the router, then a WRAP_ETH of 0.5 ETH to the user, reads as WETH with no minimum", () => {
const result = decode( const result = decode(
execute( execute(
@@ -1108,7 +1114,11 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
), ),
); );
expect(result.name).toBe("Swap USDT → WETH"); expect(result.name).toBe("Swap USDT → WETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER); expect(labels(result)).toEqual(withMove(NO_MINIMUM_TO_USER));
expect(detail(result, "Moved from the sender").value).toBe(
"Up to 0.5000 ETH",
);
expect(detail(result, "Moved to").value).toBe("The sender");
}); });
// The UNWRAP_WETH pays the user the swap's WETH as ETH; the WRAP_ETH wraps // The UNWRAP_WETH pays the user the swap's WETH as ETH; the WRAP_ETH wraps
@@ -1348,6 +1358,7 @@ describe("any token left in the router is not received", () => {
), ),
), ),
left: tokenLine(WETH, "WETH"), left: tokenLine(WETH, "WETH"),
spent: "1.0000 ETH",
}, },
// A SETTLE of OPEN_DELTA from the router pays only what the step // A SETTLE of OPEN_DELTA from the router pays only what the step
// still owes of its currency when it runs. // still owes of its currency when it runs.
@@ -1516,8 +1527,8 @@ describe("any token left in the router is not received", () => {
"Moved from the sender", "Moved from the sender",
] ]
: ["Amount"]), : ["Amount"]),
// A later swap step the user pays for spends `spent` of a token // A later swap step or WRAP_ETH the user pays for spends `spent`
// other than Token In. // of a token other than Token In.
...(spent ...(spent
? ["Moved from the sender", "Token moved", "Moved to"] ? ["Moved from the sender", "Token moved", "Moved to"]
: []), : []),