From 070ad823389248ffd60bbf5fc1f592d148de89cc Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 20 Oct 2026 12:37:01 +0200 Subject: [PATCH] fix: a swap's unknown first take shows beside a PERMIT2_TRANSFER_FROM, only when the user pays it (closes #634) decode() now records whether the user pays the V4 step that sets the input side any of its input token, and decides once, after the loop, whether that step counts as a take from the sender. A stated amount counts whoever pays it; with none stated, the step counts only when the user pays it. Paid from the router, it gets no Moved from the sender lines, with a PERMIT2_PERMIT of another token or without. When it counts and states no amount, its Unknown (could not be decoded) line also shows beside a PERMIT2_TRANSFER_FROM of its token, whose figure is on that move's own lines or, into the router, on its Paid to line. README Transaction Decoding says the same. Model: opus-5-5 --- README.md | 18 ++++-- src/shared/uniswap.js | 30 +++++++-- tests/uniswap.test.js | 147 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index dbba5db..5f5bd87 100644 --- a/README.md +++ b/README.md @@ -3185,7 +3185,8 @@ Currently supported: step whose input it cannot read, in whole or in part, and a step it fails on. In any other case the line gives the total, as with no permit. When the step that sets the input side spends a token other than the permit's, or one the - decoder does not know, its amount gets lines of its own and the total has no + decoder does not know, its amount gets lines of its own, unless it states no + amount and the user pays it none of its input token, and the total has no figure to start from: unless the permit's amount stands there is no `Amount` line, and each take of the permit's token gets lines of its own. Each take the total does not count gives three lines after the `Amount` line (after @@ -3193,11 +3194,16 @@ Currently supported: `Token In` after an amount that is not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their wording on the `Amount` line, or no stated amount, as of a V4 exact-out step); and one whose amount the decoder does not - know. When the step that sets the input side states no amount and there is no - `Amount` line, that step gets them too whenever another take's lines give a - figure, so that figure cannot read as all that leaves. They come in step - order, the step that sets the input side first when it has them: - `Moved from the sender`, its amount, worded as the `Amount` line words one, or + know. When the step that sets the input side states no amount, the user pays + it some of its input token, and there is no `Amount` line, that step gets them + too whenever another take's lines give a figure, or a `PERMIT2_TRANSFER_FROM` + of its token gives one, on the lines below or, into the router, on its + `Paid to` line, so that figure cannot read as all that leaves. When it states + no amount and the user pays it none of its input token, as when the router + pays it, that step takes none of that token from the user's wallet and never + gets them, with a permit or without. They come in step order, the step that + sets the input side first when it has them: `Moved from the sender`, its + amount, worded as the `Amount` line words one, or `Unknown (could not be decoded)` when the decoder does not know it; `Token moved`, giving the token's address as the `Token In` line does; and `Moved to`, which reads `The swap step that spends it`. A token the decoder diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 6563f62..3f9177d 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -1204,6 +1204,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) { let inputToken = null; let inputAmount = null; let inputEstablished = false; + // Whether the user pays the V4 step that set the input side any of + // its input currency (see decodeV4Swap()). Only a V4 step can set the + // input side with no amount, the one case in which this is read. + let inputPaidByUser = false; // The last PERMIT2_PERMIT this decoder read, or null (see // permitStands after the loop). let permit = null; @@ -1756,6 +1760,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // this decoder could not read may take anything: the // router still runs it. const setInputHere = inputEstablished && !inputSetBefore; + if (setInputHere) inputPaidByUser = paidIn !== undefined; if (v4) { for (const p of v4.paidByUser) { if (setInputHere && p === paidIn) continue; @@ -1844,15 +1849,22 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // (see spentUnread()), or a token it does not know. const spentFromSender = []; let total = null; + // Whether the step that set the input side counts as a take from the + // sender, which may get lines of its own. The amount it stated counts + // whoever pays it. Stating none, it counts only when the user pays it + // some of its input token: paid from the router, it takes nothing + // from the sender. + const hasFirstTake = + inputEstablished && (present(inputAmount) || inputPaidByUser); // The take of the step that set the input side, when the total // starts from it and it stated no amount (see below), or null. let firstTakeUnstated = null; if (permit === null || sameAddress(inputToken, permit.token)) { total = inputAmount; - if (inputEstablished && !present(inputAmount)) { + if (hasFirstTake && !present(inputAmount)) { firstTakeUnstated = { token: inputToken, amount: null }; } - } else if (inputEstablished) { + } else if (hasFirstTake) { // The step that set the input side spent a token other than the // permit's, or one not known, so the total starts with no figure. spentFromSender.push({ token: inputToken, amount: inputAmount }); @@ -1873,14 +1885,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) { if (ofTokenIn && !present(t.amount)) total = null; } inputAmount = permitStands ? permit.amount : total; - // With no Amount figure, a later take's own figure could read as all - // that leaves, so the step that set the input side, when it stated - // no amount, then gets lines of its own too, first, saying its amount - // is not known. + // With no Amount figure, a later take's own figure, or that of a + // PERMIT2_TRANSFER_FROM of the same token, into the router or not, + // could read as all that leaves, so the step that set the input side, + // when the user pays it and it stated no amount, then gets lines of + // its own too, first, saying its amount is not known. if ( !present(inputAmount) && firstTakeUnstated !== null && - spentFromSender.some((s) => present(s.amount)) + (spentFromSender.some((s) => present(s.amount)) || + [...movedFromSender, ...movedIntoRouter].some((m) => + sameAddress(m.token, firstTakeUnstated.token), + )) ) { spentFromSender.unshift(firstTakeUnstated); } diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 1ae22e6..277201f 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -2318,6 +2318,24 @@ describe("uniswap decoder", () => { encodeV4TakeAll(WETH_ADDR, 0n), ], ); + // The same step with its SETTLE of OPEN_DELTA paid from the router. + const V4_EXACT_OUT_600_FROM_ROUTER = encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_OUT_SINGLE_ID, + V4_SETTLE_ID, + V4_TAKE_ALL_ID, + ]), + [ + encodeV4ExactOutSingle( + USDT_ADDR, + WETH_ADDR, + ONE_WETH, + USDT_600, + ), + settle(USDT_ADDR, 0n, false), + encodeV4TakeAll(WETH_ADDR, 0n), + ], + ); test.each([ { kind: "a V4 exact-out step with a SETTLE of OPEN_DELTA by the user", @@ -2325,6 +2343,13 @@ describe("uniswap decoder", () => { first: V4_EXACT_OUT_600, lines: [...UNKNOWN_USDT, ...UP_TO_400_USDT], }, + // Paid from the router, the step takes nothing from the wallet. + { + kind: "a V4 exact-out step with a SETTLE of OPEN_DELTA from the router", + commands: [0x10, 0x09], + first: V4_EXACT_OUT_600_FROM_ROUTER, + lines: UP_TO_400_USDT, + }, // Settled by the user, the step's amount would be what that // SETTLE pays, which the decoder cannot state here. { @@ -2370,6 +2395,36 @@ describe("uniswap decoder", () => { }, ); + // https://git.eeqj.de/sneak/AutistMask/issues/634 — after a + // PERMIT2_PERMIT of another token, the V4 exact-out step paid from + // the router still takes nothing from the wallet, so it gets no lines + // of its own. On next it got lines saying its amount is not known. + const ONE_DAI_PERMIT = encodePermit2(DAI_ADDR, 10n ** 18n, ROUTER_ADDR); + test.each([ + { + kind: "a PERMIT2_PERMIT of 1 DAI, then a V4 exact-out step of at most 600 USDT paid from the router", + commands: [0x0a, 0x10], + inputs: [ONE_DAI_PERMIT, V4_EXACT_OUT_600_FROM_ROUTER], + lines: [["Amount", "1.0000 DAI"]], + }, + { + kind: "a PERMIT2_PERMIT of 1 DAI, a V4 exact-out step of at most 600 USDT paid from the router, then a V2 exact-out of at most 400 USDT the user pays for", + commands: [0x0a, 0x10, 0x09], + inputs: [ + ONE_DAI_PERMIT, + V4_EXACT_OUT_600_FROM_ROUTER, + V2_EXACT_OUT_400, + ], + lines: [["Amount", "Up to 1.0000 DAI"], ...UP_TO_400_USDT], + }, + ])( + "$kind, gives the V4 step no lines of its own", + ({ commands, inputs, lines }) => { + const result = decodeSteps(commands, inputs); + expect(linesBetweenTokens(result)).toEqual(lines); + }, + ); + // The V4 exact-out route above the other way round, where the later // take's unknown amount leaves no figure at all, and other ways the // step that sets the input side states no amount. @@ -2423,6 +2478,98 @@ describe("uniswap decoder", () => { }, ); + // https://git.eeqj.de/sneak/AutistMask/issues/634 — a + // PERMIT2_TRANSFER_FROM of the token the V4 exact-out step spends, + // before or after it, shows its own figure, so the step's amount is + // shown as not known beside it, and the 400 USDT cannot read as all + // that leaves. On next only the 400 USDT was shown. Into the router, + // the 400 USDT is on its Paid to line. + const transfer = (token, recipient, amount) => + coder.encode( + ["address", "address", "uint160"], + [token, recipient, amount], + ); + const MOVED_400_USDT = [ + ["Moved from the sender", "400.0000 USDT"], + ["Token moved", "USDT (" + USDT_ADDR + ")"], + ["Moved to", OTHER_ADDR], + ]; + test.each([ + { + kind: "a V4 exact-out step of at most 600 USDT, then a PERMIT2_TRANSFER_FROM of 400 USDT to another address", + commands: [0x10, 0x02], + inputs: [ + V4_EXACT_OUT_600, + transfer(USDT_ADDR, OTHER_ADDR, USDT_400), + ], + lines: [...UNKNOWN_USDT, ...MOVED_400_USDT], + }, + { + kind: "a PERMIT2_TRANSFER_FROM of 400 USDT to another address, then a V4 exact-out step of at most 600 USDT", + commands: [0x02, 0x10], + inputs: [ + transfer(USDT_ADDR, OTHER_ADDR, USDT_400), + V4_EXACT_OUT_600, + ], + lines: [...UNKNOWN_USDT, ...MOVED_400_USDT], + }, + { + kind: "a V4 exact-out step of at most 600 USDT, then a PERMIT2_TRANSFER_FROM of 400 USDT into the router", + commands: [0x10, 0x02], + inputs: [ + V4_EXACT_OUT_600, + transfer(USDT_ADDR, ROUTER_ADDR, USDT_400), + ], + lines: UNKNOWN_USDT, + }, + { + kind: "a PERMIT2_TRANSFER_FROM of 400 USDT into the router, then a V4 exact-out step of at most 600 USDT", + commands: [0x02, 0x10], + inputs: [ + transfer(USDT_ADDR, ROUTER_ADDR, USDT_400), + V4_EXACT_OUT_600, + ], + lines: UNKNOWN_USDT, + }, + ])( + "$kind, paid by the user, shows that the step's amount is not known", + ({ commands, inputs, lines }) => { + const result = decodeSteps(commands, inputs); + expect(linesBetweenTokens(result)).toEqual(lines); + }, + ); + + // Paid from the router, the V4 exact-out step takes nothing from the + // wallet, so the moves give all that leaves. + test("a PERMIT2_TRANSFER_FROM of 400 USDT into the router, one of 10 USDT to another address, then a V4 exact-out step paid from the router, shows only the moves", () => { + const result = decodeSteps( + [0x02, 0x02, 0x10], + [ + transfer(USDT_ADDR, ROUTER_ADDR, USDT_400), + transfer(USDT_ADDR, OTHER_ADDR, 10000000n), + V4_EXACT_OUT_600_FROM_ROUTER, + ], + ); + expect(linesBetweenTokens(result)).toEqual([ + ["Moved from the sender", "10.0000 USDT"], + ["Token moved", "USDT (" + USDT_ADDR + ")"], + ["Moved to", OTHER_ADDR], + ]); + }); + + // A move of another token says nothing of how much USDT leaves. + test("a V4 exact-out step of at most 600 USDT, then a PERMIT2_TRANSFER_FROM of 400 DAI, shows only the DAI moved", () => { + const result = decodeSteps( + [0x10, 0x02], + [V4_EXACT_OUT_600, transfer(DAI_ADDR, OTHER_ADDR, DAI_400)], + ); + expect(linesBetweenTokens(result)).toEqual([ + ["Moved from the sender", "400.0000 DAI"], + ["Token moved", "DAI (" + DAI_ADDR + ")"], + ["Moved to", OTHER_ADDR], + ]); + }); + // A V2 exact-out step makes every amount the swap steps spend a maximum, // the total included. test("a split with a V2 exact-out step the user pays for shows the total as a maximum", () => {