Skip to content

fix(security): send-family gate follow-ups - NFT refusal wording + gate dedupe (#755) - #825

Merged
szhygulin merged 1 commit into
mainfrom
fix/755-send-family-followups
Jul 26, 2026
Merged

fix(security): send-family gate follow-ups - NFT refusal wording + gate dedupe (#755)#825
szhygulin merged 1 commit into
mainfrom
fix/755-send-family-followups

Conversation

@graciangabriel8

Copy link
Copy Markdown
Contributor

Closes #755

Scope

Per the ARCH re-scope comment on #755, this PR covers items 3, 5, 6
only:

What changed and why

Item 3 — misleading NFT refusal wording. The two transferFrom
(0x23b872dd) refusal messages in applyCustomCallClassifier
(src/security/custom-call-classifier.ts) unconditionally told the
caller to "use prepare_token_send instead." But transferFrom's
selector/signature (address,address,uint256) is byte-for-byte
identical for ERC-20 transferFrom and ERC-721 transferFrom — the
classifier cannot distinguish the two from the selector alone (see the
existing comment at line ~174-177), and no prepare_*-nft send tool
exists yet (src/modules/nft/index.ts exports four read-only tools:
getNftPortfolio, getNftCollection, getNftListings,
getNftHistory — no send tool). So an NFT-shaped transferFrom call
hitting either the self-as-from or recipient-mismatch refusal was told
to use a tool (prepare_token_send) that only handles ERC-20 and
cannot actually resolve the call. Reworded both throw messages to
state the ambiguity explicitly and note that no dedicated NFT send
tool exists yet, instead of unconditionally pointing at
prepare_token_send.

Item 5 — invariant comment at the gate binding. Added a one-line
(well, one-paragraph) comment at the send-family gate binding in
buildCustomCall (src/modules/custom-call/actions.ts) documenting
that the gate's correctness depends on data being built from
encodeFunctionData(p.args) — a future refactor that accepted
pre-encoded calldata directly would silently decouple the checked
recipient arg from the recipient actually being signed.

Item 6 — dedupe the double selector match. buildCustomCall called
matchSendFamilyGate(data) to compute sendFamilyGate, then called
assertSendFamilyRecipientIsWallet(data, ...), which re-ran
matchSendFamilyGate(data) internally on the same calldata. Changed
assertSendFamilyRecipientIsWallet's signature to take the
already-matched SendFamilyGateEntry | null instead of raw data, and
updated the one real call site to pass the entry it already computed.
Purely a redundancy removal — behavior is unchanged (both paths matched
the same selector against the same table).

Tests

Not run locally — Node/npm are not installed on the authoring
machine, so nothing here was executed. CI (Build & Test on Node 20 +
22) is the authoritative check for whether this compiles and passes.
Tests were written by matching test/custom-call.test.ts's existing
imports, ABI fixtures, and try/catch-into-caught assertion pattern
exactly.

Falsifier tests added (test/custom-call.test.ts):

  1. "transferFrom(self,...) refusal doesn't unconditionally claim prepare_token_send handles it — the selector is shared with ERC-721 (issue #755)" — drives buildCustomCall with a
    transferFrom(wallet, ATTACKER, "1") call (args decode as a
    plausible NFT transferFrom: a small integer "tokenId" rather than a
    token amount) and asserts the thrown message matches both
    /identical for ERC-20 and ERC-721 transferFrom/ and /no dedicated send tool exists yet/. On unfixed code this message doesn't contain
    either phrase — the test FAILS. On the fixed code the reworded
    message contains both — the test PASSES.
  2. "transferFrom(other, ATTACKER) refusal doesn't unconditionally claim prepare_token_send handles it — the selector is shared with ERC-721 (issue #755)" — same shape, but exercises the
    recipient-mismatch throw path (args[1] != wallet) instead of the
    self-as-from path. Same fail-on-unfixed / pass-on-fixed logic.

Existing test updated (not weakened — same assertions, new calling
convention): the assertSendFamilyRecipientIsWallet — unit (issue #741) describe block's three tests called
assertSendFamilyRecipientIsWallet("0x...", bool) directly with raw
calldata, relying on the function's old internal re-match. Since the
function's signature changed to take the pre-matched entry (item 6),
these three tests now call matchSendFamilyGate(...) first and pass
the resulting entry in. The assertions themselves (throws
non-bypassably / no-op when recipient is wallet / no-op for a
non-member selector) are unchanged.

Blast radius

  • src/security/custom-call-classifier.ts: two error-message string
    literals reworded (no logic change); assertSendFamilyRecipientIsWallet
    signature changed from (data, recipientIsWallet) to (entry, recipientIsWallet).
  • src/modules/custom-call/actions.ts: one comment added; one call site
    updated to pass the already-computed entry instead of raw data.
  • test/custom-call.test.ts: two new tests, three existing tests updated
    for the new signature.
  • assertSendFamilyRecipientIsWallet is exported from
    custom-call-classifier.ts — grepped the repo for other importers
    before changing the signature; src/modules/custom-call/actions.ts
    and test/custom-call.test.ts are the only two call sites, both
    updated in this diff.
  • No change to refusal/warn/bypass logic, selector tables, or the
    gate's actual verdicts — this PR is wording + a redundant-call
    removal, not a security-behavior change.

Residual concerns

  • The generic (non-self-as-from, non-recipient-mismatch) transferFrom
    refusal message — the one built from rule.message at the "ack !==
    true" branch — still frames itself as "ERC-20 transferFrom" and
    points at "the protocol-specific prepare_* tool (Aave Pool, Uniswap
    Router, etc.)" without the same ERC-721 caveat. That message never
    literally asserts prepare_token_send resolves the call (it says
    "protocol-specific prepare_* tool", not prepare_token_send), which
    is why the security: #741 send-family gate follow-ups — Permit2 completeness + struct-recipient model + NFT-refusal message + ERC-4626 over-refusal #755 plan and this PR scoped the fix to the two throws
    that do make that specific claim. Left as-is to keep this diff
    targeted; flagging in case a reviewer wants that third message
    reworded too for full consistency.
  • No dedicated NFT send tool exists yet, so an NFT-shaped transferFrom
    that fails the self-as-from or recipient checks still has no
    in-product remediation path beyond prepare_revoke_approval — that's
    a product gap, not something this PR's wording fix claims to close.

…mily gate match (#755)

Scope: items 3, 5, 6 from the #755 ARCH re-scope comment (item 4 is closed
as intentional product scope at #756; items 1/2 were absorbed into #776/
#757 by the same comment).

- The 0x23b872dd (transferFrom) refusal messages in
  applyCustomCallClassifier unconditionally told the caller to
  "use prepare_token_send instead". That selector's signature
  (address,address,uint256) is identical for ERC-20 and ERC-721
  transferFrom, and no prepare_*-nft send tool exists yet
  (src/modules/nft/index.ts only exports read-only tools). Reworded
  both throw messages to note the ambiguity and stop asserting
  prepare_token_send unconditionally resolves it.
- Added a one-line invariant comment at the send-family gate binding
  in buildCustomCall documenting that `data` must come from
  encodeFunctionData(p.args) — a future refactor accepting pre-encoded
  calldata would silently decouple the checked recipient from the
  signed recipient.
- assertSendFamilyRecipientIsWallet now takes the already-matched gate
  entry instead of re-running matchSendFamilyGate(data) internally;
  buildCustomCall already computes the entry once and now passes it
  through instead of triggering a second redundant match.
@szhygulin
szhygulin marked this pull request as ready for review July 26, 2026 08:05
@szhygulin
szhygulin merged commit 7cd0c41 into main Jul 26, 2026
3 checks passed
@szhygulin
szhygulin deleted the fix/755-send-family-followups branch July 26, 2026 08:05
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 26, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security: #741 send-family gate follow-ups — Permit2 completeness + struct-recipient model + NFT-refusal message + ERC-4626 over-refusal

2 participants