fix: a PERMIT2_TRANSFER_FROM before a permit keeps its amount from standing as the Amount figure (closes #655)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s

A PERMIT2_TRANSFER_FROM that runs before a PERMIT2_PERMIT of the same
token moves it under whatever allowance the router already had, so it
can move more than the permit's amount. The permit's amount still stood
as the Amount figure, which could then read lower than what leaves the
wallet.

decode() now counts such a transfer, into the router or anywhere else,
among the steps before the permit that took its token, so the permit no
longer stands: the Amount line gives the total the steps take, as with
no permit, and the permit gets its own lines. A transfer after the
permit, or of another token before it, still lets it stand; tests cover
both. README Transaction Decoding states the condition.

Model: opus-5-5
This commit was merged in pull request #661.
This commit is contained in:
2026-10-21 03:16:51 +02:00
parent f53b503b15
commit 5f2b606754
3 changed files with 149 additions and 46 deletions
+20 -16
View File
@@ -1524,14 +1524,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// side never stands, so only one before it is kept here.
if (p && !inputEstablished) {
// Whether a step before it took its token, or may
// have: one in `userPaid`, when its token is the
// permit's or not known. No step has set the input
// side yet.
// have: one in `userPaid`, `movedIntoRouter` or
// `movedFromSender`, when its token is the permit's
// or not known. No step has set the input side yet.
const mayBeItsToken = (token) =>
!present(token) || sameAddress(token, p.token);
const tookBefore = userPaid.some((t) =>
mayBeItsToken(t.token),
);
const tookBefore = [
...userPaid,
...movedIntoRouter,
...movedFromSender,
].some((t) => mayBeItsToken(t.token));
permit = {
...p,
step: i,
@@ -2009,16 +2011,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// set the input side, it is the only PERMIT2_PERMIT in the
// transaction and there is no PERMIT2_PERMIT_BATCH, its spender is
// the router, the router may not let it fail, no step before it took
// its token or one not known, and this decoder read every step after
// it in full (see stepsUnread). Permit2 then lets the router take no
// more of the token than that amount, so the permit covers every take
// of it. A later permit or batch, or a later step this decoder did
// not read, may raise what Permit2 lets the router take. Every other
// PERMIT2_PERMIT leaves Token In and the Amount line as they read with
// no permit, and gets lines of its own below. A step that may take
// from the sender what this decoder did not read leaves no figure,
// permit or not (see spentUnread()), and so does a permit or batch
// whose input it cannot read (see permitUnread()). An
// its token or one not known (a PERMIT2_TRANSFER_FROM of its token
// included), and this decoder read every step after it in full (see
// stepsUnread). Permit2 then lets the router take no more of the token
// than that amount, so the permit covers every take of it. A step
// before it takes under whatever allowance the router already had, so
// it may take more. A later permit or batch, or a later step this
// decoder did not read, may raise what Permit2 lets the router take.
// Every other PERMIT2_PERMIT leaves Token In and the Amount line as
// they read with no permit, and gets lines of its own below. A step
// that may take from the sender what this decoder did not read leaves
// no figure, permit or not (see spentUnread()), and so does a permit
// or batch whose input it cannot read (see permitUnread()). An
// EXECUTE_SUB_PLAN step leaves both sides unknown (below).
const stepCommands = Array.from(
commandsBytes,