Skip to content

feat(SpokePoolPeriphery): support ERC-6492 counterfactual contract-wallet signers#1467

Merged
grasphoper merged 7 commits into
feat/erc3009-bytes-signaturefrom
feat/erc6492-signature-handling
Jul 6, 2026
Merged

feat(SpokePoolPeriphery): support ERC-6492 counterfactual contract-wallet signers#1467
grasphoper merged 7 commits into
feat/erc3009-bytes-signaturefrom
feat/erc6492-signature-handling

Conversation

@mrice32

@mrice32 mrice32 commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Stacked on top of #1420 (feat/erc3009-bytes-signature). Review/merge that first; this PR's diff is against that branch.

Summary

PR #1420 added the bytes-signature ERC-3009 entry points (depositWithAuthorizationBytes, swapAndBridgeWithAuthorizationBytes) which pull tokens via the extended receiveWithAuthorization(...,bytes signature) overload (e.g. USDC v2.2). Those delegate signature verification to the token, which supports EIP-1271 contract wallets — but the token has no concept of ERC-6492. A counterfactual (not-yet-deployed) smart-contract wallet therefore can't authorize a transfer: there's no code at the signer's address to run isValidSignature on.

We can't change the token's verification. So, following the approach in Coinbase's ERC6492SignatureHandler, this PR implements only the deploy half of ERC-6492 in the periphery — it is not the signature verifier:

  • New abstract ERC6492SignatureHandler base contract. _handleERC6492Signature detects the ERC-6492 magic suffix (0x6492…6492) and, if present, runs the wrapped (factory, factoryCalldata) to materialize the wallet, then returns the unwrapped inner signature.
  • SpokePoolPeriphery inherits it and runs _handleERC6492Signature on the signature in both *WithAuthorizationBytes entry points before forwarding it to the token. Once the wallet exists, the token's own EIP-1271 check against the signer succeeds.
  • The wrapped factory/factoryCalldata are attacker-controlled, so the prepare call is routed through the canonical Multicall3 singleton (supplied as an immutable constructor arg) rather than made directly — the periphery is never the msg.sender of that call and so can't be tricked into abusing its own allowances. The call uses requireSuccess=false, so an already-deployed wallet is tolerated; the token remains the source of truth on validity.
  • New minimal IMulticall3 interface.
  • Constructor now takes the Multicall3 address; the deploy script + new Constants.getMulticall3() supply the canonical 0xcA11bde05977b3631167028862bE2a173976CA11.

Non-ERC-6492 signatures (EOA / already-deployed EIP-1271) fall straight through _handleERC6492Signature unchanged, so existing behavior is preserved.

Scope

Only the two *WithAuthorizationBytes flows need this — they're the only entry points where the token performs EIP-1271 verification. The v/r/s, permit, and permit2 flows are unaffected.

Test plan

  • yarn build-evm-foundry
  • yarn test-evm-foundry -- --match-contract "SpokePoolPeripheryTest|TransferProxy" — 53 passed (49 existing + 4 new)
  • New tests:
    • testTransferWithAuthBytesDepositERC6492CounterfactualWallet — wallet deployed by the prepare step, deposit succeeds
    • testTransferWithAuthBytesSwapAndBridgeERC6492CounterfactualWallet — same for swap-and-bridge
    • testTransferWithAuthBytesDepositERC6492AlreadyDeployedWallet — wrapped sig + already-deployed wallet (idempotent prepare noop)
    • testTransferWithAuthBytesDepositERC6492FailedPrepareTolerated — failing prepare target is swallowed (requireSuccess=false), deposit still succeeds via the already-deployed signer
    • Existing EOA + EIP-1271 bytes tests continue to exercise the passthrough path
  • yarn lint-fix

🤖 Generated with Claude Code

The bytes-signature ERC-3009 entry points (depositWithAuthorizationBytes,
swapAndBridgeWithAuthorizationBytes) delegate signature verification to the
token (e.g. USDC v2.2), which supports EIP-1271 contract wallets but has no
concept of ERC-6492. A *counterfactual* (not-yet-deployed) contract wallet
therefore cannot authorize a transfer, since there is no code at the signer's
address to run isValidSignature on.

We cannot change the token's verification, so we replicate only the deploy half
of the ERC-6492 flow: a new ERC6492SignatureHandler base contract detects the
ERC-6492 magic suffix and, if present, runs the wrapped factory call to
materialize the wallet before handing the unwrapped inner signature to the
token. The token's own EIP-1271 check then succeeds against the deployed signer.

The factory target/calldata are attacker-controlled, so the prepare call is
routed through the canonical Multicall3 singleton (supplied as an immutable
constructor arg) — this contract is never the msg.sender of that call, so it
cannot be tricked into abusing its own allowances. The call uses
requireSuccess=false so an already-deployed wallet is tolerated; the token
remains the source of truth on validity. Mirrors Coinbase's
ERC6492SignatureHandler (base/commerce-payments).

- New IMulticall3 interface and ERC6492SignatureHandler base contract.
- SpokePoolPeriphery inherits it; constructor now takes the Multicall3 address.
- Deploy script + Constants.getMulticall3() pass the canonical 0xcA11...CA11.
- Non-ERC-6492 signatures pass through unchanged (existing bytes/EIP-1271/EOA
  tests continue to exercise the passthrough path).

Test plan:
- yarn build-evm-foundry
- yarn test-evm-foundry -- --match-contract "SpokePoolPeripheryTest|TransferProxy"
  53 passed (49 existing + 4 new)
- New tests: counterfactual-wallet deposit & swap-and-bridge, already-deployed
  wallet with wrapped sig (idempotent prepare), and failing-prepare tolerance.
- yarn lint-fix

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7836ec32d7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread contracts/periphery/SpokePoolPeriphery.sol Outdated
@droplet-rl

Copy link
Copy Markdown
Contributor

🤖 Babysitting this PR — picked up the assignment. Working from feat/erc6492-signature-handling; I'll post a summary when this pass is done.

@droplet-rl droplet-rl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review on the babysit pass.

  • Addressed codex's P2 thread by updating the Tron deployment script (and the Tron README) to accept and forward the new multicall3 constructor argument. Without it any deployment via yarn tron-deploy-spoke-pool-periphery <permit2> would have reverted in the constructor's zero-address check.
  • Otherwise the diff looks solid: the design routes the attacker-controlled prepare call through the canonical Multicall3 so the periphery is never msg.sender, the requireSuccess=false tolerates already-deployed wallets, the nonReentrant guard on the entry points prevents prepare-driven reentry, and the abstract handler doesn't pretend to be a verifier.
  • One small nit on _handleERC6492Signature flagged inline — the manual byte-by-byte copy loop is correct but gas-wasteful and easy to replace with a memory copy. Non-blocking.

Comment thread contracts/periphery/ERC6492SignatureHandler.sol Outdated
The new SpokePoolPeriphery constructor requires a Multicall3 address; the
Tron deployment script was still encoding only `<permit2>`, so the
documented `yarn tron-deploy-spoke-pool-periphery` flow would have
reverted in the constructor's zero-address check. Take a second Tron
Base58Check address, validate it, and ABI-encode it as the second arg.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@droplet-rl

Copy link
Copy Markdown
Contributor

Babysit pass complete.

Reviewer threads (1 unresolved → 0)

  • codex P2 on SpokePoolPeriphery.sol:208 (Tron deploy script left out the new _multicall3 arg). Updated script/tron/periphery/tron-deploy-spoke-pool-periphery.ts to take and ABI-encode a second <multicall3> Tron Base58Check address (validated via validateTronAddresses) and bumped the usage string in script/tron/README.md to match. Replied + queued the thread for resolution.

Self-review

  • Submitted one inline nit on contracts/periphery/ERC6492SignatureHandler.sol:75: the byte-by-byte copy loop is correct but ~tens of thousands of gas heavier than necessary — a calldata-overloaded slice or a 32-byte assembly/mcopy loop would be cheaper. Non-blocking.
  • No correctness issues otherwise: prepare call is routed through Multicall3 so the periphery is never msg.sender of attacker-controlled calldata, requireSuccess=false tolerates already-deployed wallets, nonReentrant blocks prepare-driven reentry, and the abstract handler stays out of the verifier role.

Commits this pass

  • 07de37dd chore(tron): forward multicall3 arg to SpokePoolPeriphery deploy

Push will be handled by the checkpoint; the codex resolve is queued and will fire on push success.

@droplet-rl

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 07de37ddc5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@droplet-rl

Copy link
Copy Markdown
Contributor

Acknowledged codex's clean re-review of 07de37dd. No unresolved threads on the PR — both prior ones (codex's P2 on the Tron deploy script, my own nit on the byte-copy loop in ERC6492SignatureHandler) are resolved. Working tree clean, no further commits this pass.

@mrice32

mrice32 commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

@droplet-rl make the optimization you called out.

Take the signature as calldata and decode the (factory, factoryCalldata,
innerSignature) tuple directly from a calldata slice. Drops the entry
calldata-to-memory copy and the byte-by-byte loop into a memory scratch
buffer that used to precede abi.decode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@droplet-rl

Copy link
Copy Markdown
Contributor

Done — applied the optimization in 64bb534d. _handleERC6492Signature now takes bytes calldata and decodes (factory, factoryCalldata, innerSignature) directly from a calldata slice; the magic-suffix check is bytes32(signature[signatureLength - 32:]). That drops both the implicit calldata→memory copy at the call boundary and the byte-by-byte loop into a scratch memory buffer. Call sites in SpokePoolPeriphery already passed bytes calldata, so no logical changes there. Verified: yarn build-evm-foundry clean, 8/8 ERC-6492 + AuthBytes tests pass.

@droplet-rl

Copy link
Copy Markdown
Contributor

⚠️ Couldn't finish 1 background task(s) before the 30m budget ran out — their results weren't gathered:
• forge build completion or errors

Ask again to re-run the unfinished work.

@droplet-rl

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 64bb534d40

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@droplet-rl

Copy link
Copy Markdown
Contributor

Codex came back clean on the optimization commit (64bb534d). No unresolved threads on the PR; the byte-copy nit is now marked outdated. No further commits this pass.

@mrice32

mrice32 commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

@droplet-rl please make the optimization you mentioned in your review.

@droplet-rl

Copy link
Copy Markdown
Contributor

Already in — pushed as 64bb534d ("perf(ERC6492SignatureHandler): decode wrapped signature from calldata") on the previous trigger. It's the current PR head.

The handler now takes bytes calldata and decodes (factory, factoryCalldata, innerSignature) directly from a calldata slice (signature[:signatureLength - 32]); the magic-suffix check is bytes32(signature[signatureLength - 32:]). That drops both the implicit calldata→memory copy of the full signature at the call boundary and the byte-by-byte loop into a scratch bytes memory buffer before abi.decode. Codex re-ran on 64bb534d40 and came back clean. The original review thread is now marked outdated.

Nothing more to do this pass; let me know if you had a different optimization in mind.

@fusmanii fusmanii left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me, just two (optional) comments

Comment thread contracts/periphery/ERC6492SignatureHandler.sol Outdated
constructor(
IPermit2 _permit2,
address _multicall3
) EIP712("ACROSS-PERIPHERY", "1.0.0") ERC6492SignatureHandler(_multicall3) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we change the version here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think a bump is required here, but it's your call. The "1.0.0" version feeds the periphery's own EIP-712 domain (_domainSeparatorV4), which is used to verify the DepositData/SwapAndDepositData typed-data signatures the periphery itself checks. This PR doesn't change any of those typed-data structs — the ERC-6492 work only deploys the counterfactual signer before handing the unwrapped signature to the token, and the token's receiveWithAuthorization verifies against the token's EIP-712 domain (e.g. USDC's), not ours. So nothing the periphery signs over changed.

There's also no replay concern motivating a bump: this is a fresh deployment (new bytecode + new multicall3 constructor arg → new address), so the domain separator already differs from the existing periphery by verifyingContract. A version bump would only be warranted if we'd changed the typed-data schema while keeping the same address.

Happy to bump to "1.1.0" purely as a human-facing signal of the redeploy if you'd prefer — just let me know and I'll add it. Leaving this open for your call.

…l 32

Replace the hard-coded 32 magic-suffix length with _ERC6492_MAGIC_VALUE.length
(a compile-time constant for the bytes32) so the length check and the calldata
slices are self-documenting and stay in sync with the constant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@droplet-rl

Copy link
Copy Markdown
Contributor

Babysit pass done — handled fusmanii's two (optional) review comments.

1. ERC6492SignatureHandler.sol — use _ERC6492_MAGIC_VALUE.length instead of literal 32 → applied in 13f38536. Since the constant is a bytes32, .length is a compile-time 32; I used it for the length guard and both calldata-slice bounds so all three stay in sync with the constant. Replied + queued the thread for resolution.

2. SpokePoolPeriphery.sol:204 — "should we change the [EIP712] version?" → replied, left open for the author's call (no change). Reasoning: "1.0.0" feeds the periphery's own EIP-712 domain (_domainSeparatorV4), used to verify the DepositData/SwapAndDepositData typed-data sigs the periphery checks itself. This PR changes none of those structs — ERC-6492 only deploys the counterfactual signer, and the token's receiveWithAuthorization verifies against the token's domain (e.g. USDC's), not ours. No replay motive either: it's a fresh deployment (new bytecode + multicall3 arg → new address), so the domain separator already differs by verifyingContract. Offered to bump to "1.1.0" purely as a redeploy signal if preferred.

Self-review: no issues. Walked the handler edge cases (exact-32-byte input → [0:] slice valid; malformed wrapper just reverts the tx with no security impact; magic-suffix collision on a real ERC-3009 sig is negligible by EIP-6492 design). This pass's only delta is the cosmetic refactor.

Verification: yarn build-evm-foundry clean; forge test --match-path test/.../SpokePoolPeriphery.t.sol --match-test "ERC6492|AuthBytes" → 8/8 pass.

Commits this pass:

  • 13f38536 refactor(ERC6492SignatureHandler): use magic-value length over literal 32

@droplet-rl

Copy link
Copy Markdown
Contributor

@codex review

@droplet-rl

Copy link
Copy Markdown
Contributor

🔎 View trace

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 13f38536de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// same address on virtually all EVM chains (see https://github.com/mds1/multicall). Used by the
// SpokePoolPeriphery to route ERC-6492 prepare/deploy calls through a neutral contract.
function getMulticall3() public pure returns (address) {
return 0xcA11bde05977b3631167028862bE2a173976CA11;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use chain-specific Multicall3 addresses

When this deploy helper is used for ZK_SYNC (chain 324; generated/constants.json already has a Permit2 entry, so the script can target it), this hard-coded address is not the Multicall3 deployment—the Multicall3 deployment table lists zkSync Era at 0xF9cda624FBC7e059355ce98a31693d299FACd963, while SpokePoolPeriphery now rejects a non-contract _multicall3 in its constructor. That makes the deployment script revert on zkSync-like chains with non-standard Multicall3 addresses; please look the address up per chain instead of always returning 0xcA11....

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Made getMulticall3 chain-aware: it now takes a chainId and returns the zkSync-stack Multicall3 deployment (0xF9cda624FBC7e059355ce98a31693d299FACd963) for ZK_SYNC (324) and ZK_SYNC_SEPOLIA (300), falling back to the canonical 0xcA11…CA11 everywhere else. DeploySpokePoolPeriphery.s.sol now passes block.chainid through.

I verified those two addresses against the canonical mds1/multicall deployments.json (both zkSync networks share 0xF9cda624…). LENS/LENS_SEPOLIA aren't in that registry, so I left them on the canonical fallback rather than guess — and since the periphery constructor rejects a non-contract _multicall3, an unmapped chain whose Multicall3 turns out to be non-canonical fails loudly at deploy time instead of deploying with a dead reference. Easy to add a mapping there once an address is confirmed.

@droplet-rl

Copy link
Copy Markdown
Contributor

⚠️ Couldn't finish 4 background task(s) before the 30m budget ran out — their results weren't gathered:
• Build contracts + scripts
• forge build completion or errors (bkx9cyu9r)
• Reinstall deps and rebuild capturing exit code
• reinstall+build result (bzg02jzt3)

Ask again to re-run the unfinished work.

@droplet-rl

Copy link
Copy Markdown
Contributor

🔎 View trace

@grasphoper grasphoper left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, 2 Qs:

  • move isContract check to the ERC6492SignatureHandler constructor instead of SpokePoolPeriphery?
  • should we add the same logic to Permit2 signatures?

@grasphoper

Copy link
Copy Markdown
Collaborator

Added Permit2 support here #1488 if we want it

@droplet-rl

Copy link
Copy Markdown
Contributor

⚠️ Couldn't finish 2 background task(s) before the 30m budget ran out — their results weren't gathered:
• Build with exit-code capture
• build exit for bh7mpcjkw

Ask again to re-run the unfinished work.

@droplet-rl

Copy link
Copy Markdown
Contributor

🔎 View trace

grasphoper and others added 2 commits July 5, 2026 19:38
Signed-off-by: Ihor Farion <ihor@umaproject.org>
Signed-off-by: Ihor Farion <ihor@umaproject.org>
@grasphoper grasphoper merged commit 1420bc5 into feat/erc3009-bytes-signature Jul 6, 2026
6 of 7 checks passed
@grasphoper grasphoper deleted the feat/erc6492-signature-handling branch July 6, 2026 03:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants