Skip to content

feat: add BalanceSwapProxy for full-balance swaps with pre-signed routes - #494

Open
marktoda wants to merge 13 commits into
mainfrom
feat/balance-swap-proxy
Open

feat: add BalanceSwapProxy for full-balance swaps with pre-signed routes#494
marktoda wants to merge 13 commits into
mainfrom
feat/balance-swap-proxy

Conversation

@marktoda

@marktoda marktoda commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds BalanceSwapProxy: a stateless, ownerless proxy that swaps a user's entire then-current token balance through the Universal Router. Sibling to SwapProxy (#469) — same fund-then-execute pattern (payerIsUser=false), but the amount is resolved at execution time instead of baked into calldata.

Primary use case: bridge-and-swap. Bridges deliver an unknown amount (bps-level relayer fee variance) minutes after the user signs. With this contract the user signs once at bridge initiation — a Permit2 permitWitnessTransferFrom whose witness binds the exact route — and anyone can relay the swap the moment funds land. No second signature, no exact amount known at sign time.

Three entry points, one shared core

Mode Function Auth Gas payer
Signed & relayed executeWithSig Permit2 witness sig (single-use nonce) anyone
Direct, ERC20 approval execute approve(proxy) + msg.sender == owner owner
Direct, Permit2 allowance executeWithPermit2Allowance Permit2 AllowanceTransfer owner

Shared core: resolve amount = min(balanceOf(owner), cap) → require amount ≥ minAmount → pull directly into the router (proxy never custodies) → execute route → enforce rate floor on the recipient's output delta.

Key design decisions

  • Route binding is cryptographic, not checked. The proxy derives routeHash = keccak256(abi.encode(commands, inputs)) and folds it into the Permit2 witness hash — a tampered route fails signature verification inside Permit2. There is no proxy-side route check to audit; the relayer is a pure gas payer.
  • Rate floor (minPriceX36, 1e36 fixed point — same convention as UR's minHopPriceX36), not absolute minOut. The executed amount is the live balance, not the sign-time estimate; an absolute minOut mis-scales with any estimate/actual gap, a rate floor cannot.
  • minAmount anti-grief gate. Without it, an attacker could donate dust to the owner pre-fill and execute the intent against it — a fair micro-swap passes a rate floor, consuming the single-use nonce and killing the intent while the user is offline. minAmount turns that grief into a donation.
  • Failure is graceful: any revert unwinds the Permit2 pull atomically and does not consume the nonce; the same signature stays live for retry.

Testing (28 Foundry fork tests)

  • Happy paths ×3 modes; balance-below-cap and cap-below-balance amount resolution
  • Adversarial relayer: tampered commands/inputs/every intent field, wrong signer, wrong owner → all fail Permit2 InvalidSigner
  • Replay (InvalidNonce), expiry (SignatureExpired), dust-grief blocked + same-sig success after real fill, floor-revert unwind + same-sig retry after price improves
  • Floor boundary exact-pass / off-by-one-revert, 6↔18 decimals asymmetry, overflow → checked-math revert
  • Fuzzed no-residue invariant (proxy and router hold zero after any success)
  • Native ETH output (UNWRAP_WETH route, ETH delta floor)
  • Reentry statelessness via a second router; gas comparison (BalanceSwapProxy direct 123k vs SwapProxy 101k vs direct Permit2 94k)

Note: the test file deliberately duplicates the EIP-712 typestring rather than reading the contract's constants — self-referential tests would verify a typo'd typestring that no real wallet could sign.

Also in this PR

Follow-ups (tracked separately)

  • SDK: BALANCE_SWAP_PROXY_ADDRESS registry + encode helper
  • Trading service: witness permitData on /quote, new SwapType on /swap, state-override simulation
  • Full audit required before mainnet deployment — this contract gates third-party-triggered movement of user funds behind signature semantics

🤖 Generated with Claude Code

AI-Generated Description

Summary

Adds BalanceSwapProxy: a stateless, ownerless proxy that swaps a user's entire then-current token balance through the Universal Router. Sibling to SwapProxy (#469) — same fund-then-execute pattern (payerIsUser=false), but the amount is resolved at execution time instead of baked into calldata.
Primary use case: bridge-and-swap. Bridges deliver an unknown amount (bps-level relayer fee variance) minutes after the user signs. With this contract the user signs once at bridge initiation — a Permit2 permitWitnessTransferFrom whose witness binds the exact route — and anyone can relay the swap the moment funds land.

Changes

  • BalanceSwapProxy.sol — Three entry points sharing a common core:
    • executeWithSig — relayed mode via Permit2 witness signature (single-use nonce)
    • execute — direct mode via plain ERC20 approve
    • executeWithPermit2Allowance — direct mode via Permit2 AllowanceTransfer
    • Shared: resolve amount = min(balanceOf(owner), cap), enforce minAmount anti-grief gate, pull directly into the router (proxy never custodies), execute route, enforce rate floor (minPriceX36) on recipient's output delta
  • IBalanceSwapProxy.sol — Interface with SwapIntent struct, errors, and NatSpec
  • DeployBalanceSwapProxy.s.sol — Foundry deploy script using canonical Permit2 address
  • DeployTempo.s.sol — Fix missing permissionsAdapterFactory field added in refactor(v4-swap-router): inherit PermissionedV4Router from v4-periphery #476

Design Decisions

  • Route binding is cryptographic: routeHash = keccak256(abi.encode(commands, inputs)) folded into Permit2 witness hash — tampered routes fail signature verification; no proxy-side route check needed
  • Rate floor, not absolute minOut: minPriceX36 (1e36 fixed point) scales correctly with balance variance; an absolute minOut mis-scales with any estimate/actual gap
  • minAmount anti-grief gate: Prevents dust-donation attacks that would consume a single-use nonce against a micro-swap that passes a rate floor
  • Graceful failure: Reverts unwind the Permit2 pull atomically without consuming the nonce; same signature stays live for retry

Test Plan

28 Foundry fork tests covering:

  • Happy paths for all 3 modes; balance-below-cap and cap-below-balance amount resolution
  • Adversarial relayer: tampered commands/inputs/every intent field, wrong signer/owner → Permit2 InvalidSigner
  • Replay (InvalidNonce), expiry (SignatureExpired), dust-grief blocked + same-sig success after real fill
  • Floor boundary exact-pass / off-by-one-revert, 6↔18 decimals asymmetry, overflow → checked-math revert
  • Fuzzed no-residue invariant (proxy and router hold zero after any success)
  • Native ETH output (UNWRAP_WETH route, ETH delta floor)
  • Reentry statelessness via a second router; gas comparison (BalanceSwapProxy 123k vs SwapProxy 101k vs direct Permit2 94k)

Notes

  • Full audit required before mainnet deployment — this contract gates third-party-triggered movement of user funds behind signature semantics
  • Test file deliberately duplicates the EIP-712 typestring rather than reading the contract's constants — self-referential tests would verify a typo'd typestring that no real wallet could sign

marktoda and others added 12 commits July 18, 2026 16:58
RouterParameters gained permissionsAdapterFactory in #476 but DeployTempo
(added in #478) was not updated, breaking forge compilation on main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…elper

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…emantics

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@marktoda
marktoda requested a review from a team as a code owner July 18, 2026 22:08
@datadog-official

This comment has been minimized.

- extract _routerParams helper, drop inert setup line, normalize assert messages
- replace per-test fixture nonces with FIXTURE_NONCE constant
- reentry test: exact-output asserts and exhaustive residue checks
- document direct-mode params in IBalanceSwapProxy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant