feat: add BalanceSwapProxy for full-balance swaps with pre-signed routes - #494
Open
marktoda wants to merge 13 commits into
Open
feat: add BalanceSwapProxy for full-balance swaps with pre-signed routes#494marktoda wants to merge 13 commits into
marktoda wants to merge 13 commits into
Conversation
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>
This comment has been minimized.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
permitWitnessTransferFromwhose 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
executeWithSigexecuteapprove(proxy)+msg.sender == ownerexecuteWithPermit2AllowanceShared core: resolve
amount = min(balanceOf(owner), cap)→ requireamount ≥ minAmount→ pull directly into the router (proxy never custodies) → execute route → enforce rate floor on the recipient's output delta.Key design decisions
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.minPriceX36, 1e36 fixed point — same convention as UR'sminHopPriceX36), 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.minAmountanti-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.minAmountturns that grief into a donation.Testing (28 Foundry fork tests)
InvalidSignerInvalidNonce), expiry (SignatureExpired), dust-grief blocked + same-sig success after real fill, floor-revert unwind + same-sig retry after price improvesUNWRAP_WETHroute, ETH delta floor)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
fix: DeployTempo missing permissionsAdapterFactory—RouterParametersgained the field in refactor(v4-swap-router): inherit PermissionedV4Router from v4-periphery #476 butDeployTempo(Ur 2.1.1 deploy addresses #478) wasn't updated, breakingforgecompilation on main.Follow-ups (tracked separately)
BALANCE_SWAP_PROXY_ADDRESSregistry + encode helper/quote, new SwapType on/swap, state-override simulation🤖 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 toSwapProxy(#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
permitWitnessTransferFromwhose 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 ERC20approveexecuteWithPermit2Allowance— direct mode via Permit2 AllowanceTransferamount = min(balanceOf(owner), cap), enforceminAmountanti-grief gate, pull directly into the router (proxy never custodies), execute route, enforce rate floor (minPriceX36) on recipient's output deltaIBalanceSwapProxy.sol— Interface withSwapIntentstruct, errors, and NatSpecDeployBalanceSwapProxy.s.sol— Foundry deploy script using canonical Permit2 addressDeployTempo.s.sol— Fix missingpermissionsAdapterFactoryfield added in refactor(v4-swap-router): inherit PermissionedV4Router from v4-periphery #476Design Decisions
routeHash = keccak256(abi.encode(commands, inputs))folded into Permit2 witness hash — tampered routes fail signature verification; no proxy-side route check neededminPriceX36(1e36 fixed point) scales correctly with balance variance; an absolute minOut mis-scales with any estimate/actual gapminAmountanti-grief gate: Prevents dust-donation attacks that would consume a single-use nonce against a micro-swap that passes a rate floorTest Plan
28 Foundry fork tests covering:
InvalidSignerInvalidNonce), expiry (SignatureExpired), dust-grief blocked + same-sig success after real fillUNWRAP_WETHroute, ETH delta floor)Notes