From 0c5988399153949a53a1e017bfca5a22901353da Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 20 Oct 2026 09:00:21 +0000 Subject: [PATCH] fix: the swap's unknown first take shows only when the user pays that step (closes #634) decode() now records whether the user pays the V4 step that sets the input side, from who pays its SETTLE of its input token. That step's Unknown (could not be decoded) line is added only then, and also when a PERMIT2_TRANSFER_FROM of its token goes into the router, whose figure is on its Paid to line. Paid from the router, the step takes nothing from the wallet and never gets the line. README Transaction Decoding says so. Model: opus-5-5 --- README.md | 16 ++++---- src/shared/uniswap.js | 23 +++++++---- tests/uniswap.test.js | 89 +++++++++++++++++++++++++++++++++++++------ 3 files changed, 102 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 7f5ea3b..b8d9f32 100644 --- a/README.md +++ b/README.md @@ -3193,13 +3193,15 @@ 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, or a `PERMIT2_TRANSFER_FROM` of its token gets the lines below, 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 - `Unknown (could not be decoded)` when the decoder does not know it; + 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. Paid from the + router, that step takes nothing from the user's wallet and never gets them. + 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 does not know gives the `Moved from the sender` line alone. A step may also diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index cc1bd83..1f8e32e 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,12 +1849,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // (see spentUnread()), or a token it does not know. const spentFromSender = []; let total = null; - // 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. + // The take of the step that set the input side, when the user pays + // that step, the total starts from it and it stated no amount (see + // below), or null. Paid from the router, the step takes nothing from + // the sender. let firstTakeUnstated = null; if (permit === null || sameAddress(inputToken, permit.token)) { total = inputAmount; - if (inputEstablished && !present(inputAmount)) { + if (inputEstablished && inputPaidByUser && !present(inputAmount)) { firstTakeUnstated = { token: inputToken, amount: null }; } } else if (inputEstablished) { @@ -1874,15 +1881,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } inputAmount = permitStands ? permit.amount : total; // With no Amount figure, a later take's own figure, or that of a - // PERMIT2_TRANSFER_FROM of the same token, 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. + // 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)) || - movedFromSender.some((m) => + [...movedFromSender, ...movedIntoRouter].some((m) => sameAddress(m.token, firstTakeUnstated.token), )) ) { diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index f896178..e98617a 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. { @@ -2427,11 +2452,12 @@ describe("uniswap decoder", () => { // 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. - const transferTo = (token, amount) => + // 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, OTHER_ADDR, amount], + [token, recipient, amount], ); const MOVED_400_USDT = [ ["Moved from the sender", "400.0000 USDT"], @@ -2442,29 +2468,70 @@ describe("uniswap decoder", () => { { 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, transferTo(USDT_ADDR, USDT_400)], + 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: [transferTo(USDT_ADDR, USDT_400), V4_EXACT_OUT_600], + 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 }) => { + ({ commands, inputs, lines }) => { const result = decodeSteps(commands, inputs); - expect(linesBetweenTokens(result)).toEqual([ - ...UNKNOWN_USDT, - ...MOVED_400_USDT, - ]); + 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, transferTo(DAI_ADDR, DAI_400)], + [V4_EXACT_OUT_600, transfer(DAI_ADDR, OTHER_ADDR, DAI_400)], ); expect(linesBetweenTokens(result)).toEqual([ ["Moved from the sender", "400.0000 DAI"],