From c410b74a7218c3aed1acc3a2821d5654733f7a91 Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 3 Aug 2026 16:17:30 -0700 Subject: [PATCH 01/11] docs: design user-directed router --- .../2026-08-03-user-directed-router-design.md | 374 ++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-03-user-directed-router-design.md diff --git a/docs/superpowers/specs/2026-08-03-user-directed-router-design.md b/docs/superpowers/specs/2026-08-03-user-directed-router-design.md new file mode 100644 index 0000000..d26d37d --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-user-directed-router-design.md @@ -0,0 +1,374 @@ +# User-Directed Router Design + +**Date:** 2026-08-03 +**Status:** Approved design; implementation not started + +## Summary + +Add a standalone, non-upgradeable Solidity contract named `Router`. A swapper calls the Router directly after granting it an ordinary ERC-20 allowance. In one atomic transaction, the Router transfers each input leg directly from the swapper to a factory-registered LiquidLane adapter, invokes an authorized adapter swap selector with opaque calldata, verifies the output tokens received during this transaction, pays exact declared amounts to the declared recipients, and returns each declared token's surplus to the swapper. + +The Router is not a Reactor executor, does not validate RFQ orders, does not use Permit2, and does not retain user funds or approvals. Its security boundary is deliberately narrow: registered adapters, two permitted swap selectors, standard ERC-20 behavior, transaction-local balance deltas, and all-or-nothing execution. + +## Branch and ABI Compatibility + +The design is being documented on `codex/router`, which currently points at the old `origin/stage` commit `8687e48`. That tree contains the legacy single-adapter `IInstantRedemptionAdapter` interface and does not contain the current `IRegistry` interface. The implementation must nevertheless target the current mainline LiquidLane model: + +- The Router stores an immutable LiquidLane adapter factory. +- Every per-leg adapter is validated with `IRegistry(factory).isEntity(adapter)`. +- The stage branch receives a minimal read-only `IRegistry` interface containing only `isEntity(address) external view returns (bool)`. +- Selector constants are pinned to the current LiquidLane signed-swap and discount-swap ABI and covered by selector-shape tests. They must not be inferred from the stale stage `IInstantRedemptionAdapter` overloads. +- The legacy direct-swap selector and arbitrary adapter selectors are not accepted. + +This makes the Router source buildable from the old stage tree while preserving the current mainline deployment trust boundary. It does not make the Router compatible with a legacy deployment that has no factory registry or exposes different swap selectors. + +## Goals + +- Give a user one typed entrypoint for a batch of LiquidLane swap legs with a single input token. +- Pull each leg directly from `msg.sender` into its adapter; the Router never takes custody of input tokens. +- Allow backend- or solver-produced signed and discount adapter calldata without making the Router an arbitrary-call primitive. +- Require all expected adapter outputs to arrive at the Router. +- Enforce minimum output economically at the aggregate token level across the whole batch. +- Pay exact amounts to one or more recipients and return declared-token surplus to `msg.sender`. +- Make pre-existing Router balances unusable by the current or any later caller. +- Revert the entire batch on any validation, transfer, adapter, accounting, or payout failure. + +## Non-Goals + +- Reactor order execution or implementation of `IExecutor`. +- Permit2, EIP-2612 permits, relayed execution, or meta-transactions. +- Multiple input tokens in one batch. +- An output token equal to the common input token. +- Native input, native output, wrapping, or unwrapping. +- Direct, unsigned LiquidLane swaps. +- Arbitrary targets, arbitrary selectors, `delegatecall`, or calls carrying native value. +- Partial fills, partial success, or an "allow revert" flag. +- Per-leg output guarantees. V1 guarantees only the aggregate outputs declared for the batch. +- Support for fee-on-transfer, rebasing, ERC-777-style callback, or otherwise non-standard tokens. +- Upgradeability, governance, pausing, mutable adapter allowlists, rescue, or sweeping. + +## Public API + +The Router exposes two nonpayable overloads. Both are protected by the same reentrancy guard and execute the same internal flow. + +| Function | Semantics | +| --- | --- | +| `execute(address tokenIn, SwapCall[] swapCalls, Output[] outputs)` | Executes immediately with no Router-level expiry. Adapter-level signatures and deadlines still apply. | +| `execute(address tokenIn, SwapCall[] swapCalls, Output[] outputs, uint256 deadline)` | Executes only while `block.timestamp <= deadline`. Equality is valid; `block.timestamp > deadline` reverts before any token interaction. | + +Both functions return no value. Successful settlement is observable through token transfers and Router events. The caller is always the input payer, the surplus recipient, and the address reported as the swapper in events. + +The Router exposes `LIQUID_LANE_ADAPTER_FACTORY()` as a public immutable getter. + +## Data Structures + +The ABI field order is fixed as follows: + +```solidity +struct SwapCall { + address adapter; + uint256 amountIn; + bytes data; +} + +struct Output { + address token; + address recipient; + uint256 amount; +} +``` + +### `SwapCall` + +| Field | Type | Meaning | +| --- | --- | --- | +| `adapter` | `address` | Factory-registered LiquidLane adapter that receives this leg's input and is called. | +| `amountIn` | `uint256` | Exact amount of the common `tokenIn` transferred directly from `msg.sender` to `adapter` for this leg. Must be nonzero. | +| `data` | `bytes` | Complete adapter calldata, including one permitted selector and all encoded quote data and signatures. | + +The Router treats all calldata after the first four selector bytes as opaque. It forwards the bytes unchanged and ignores successful return data. + +### `Output` + +| Field | Type | Meaning | +| --- | --- | --- | +| `token` | `address` | Standard ERC-20 output token. The zero address and common `tokenIn` are invalid. | +| `recipient` | `address` | Final recipient. Must be neither the zero address nor the Router. | +| `amount` | `uint256` | Exact amount transferred to this entry's recipient after aggregate minimum validation. Must be nonzero. | + +Multiple entries may use the same token and may use the same recipient. For a token, the sum of all corresponding `Output.amount` values is both the batch's aggregate minimum for that token and the exact total allocated among declared recipients. Any transaction-local excess for that token goes to `msg.sender`. + +At least one `SwapCall` and one `Output` are required. A batch with no economic input or no declared economic output is rejected. + +## Adapter Trust and Call Validation + +For every `SwapCall`, the Router performs all validation before making the adapter call: + +1. `adapter` is nonzero and `IRegistry(LIQUID_LANE_ADAPTER_FACTORY).isEntity(adapter)` returns true. +2. `amountIn` is nonzero. +3. `data` contains at least four bytes. +4. The first four bytes are exactly one of the two permitted current-main selectors: + - signed swap: `swap((address,address,uint256,uint256,address,address,uint256,uint48),bytes)`; + - discount swap: `swap(((address,uint256,address,address,uint256,uint48),bytes,uint48),bytes,address,uint256)`. +5. No native value is attached to the adapter call. + +All other selectors are rejected, including the unsigned direct-swap overload. The allowlist prevents a user from exercising adapter administration, nonce invalidation, acquisition, withdrawal, or fallback behavior under the Router's identity. + +The backend or solver must encode the Router as the collateral recipient in both permitted payloads. For a signed swap, it must also encode the Router as the signed `caller`. The adapter authoritatively verifies those fields and the signatures. The Router does not decode or rewrite them. + +Because the payload remains opaque, a single leg is not required to produce its own pro-rata share of an output. One leg may overproduce while another underproduces, provided the user-approved batch meets every aggregate token minimum. This cross-leg netting is intentional in V1. + +## Execution Flow + +### 1. Validate the request + +Before transferring any token, the Router: + +- applies the deadline check for the deadline overload; +- rejects zero or non-contract `tokenIn`; +- rejects empty `swapCalls` or `outputs`; +- validates every output token, amount, and recipient; +- validates every adapter, amount, calldata length, and selector; +- computes the sum of all leg inputs for the final event; and +- groups duplicate output tokens and sums their required amounts with checked arithmetic. + +Pre-validating the complete request avoids entering external execution with a structurally invalid later leg. + +### 2. Snapshot declared output balances + +For every unique token appearing in `outputs`, the Router records its own ERC-20 balance before any input transfer or adapter call. These baselines define funds that predate the transaction and are never spendable by this execution. + +The caller must declare every output token expected from the adapters. A token delivered to the Router but absent from `outputs` is not observable through the V1 accounting set and remains permanently isolated in the Router. There is intentionally no rescue function that could turn such a mistake into a cross-user withdrawal primitive. + +### 3. Fund and execute each leg + +For each `SwapCall`, in array order, the Router: + +1. Records `tokenIn.balanceOf(adapter)` as the leg's adapter baseline. +2. Uses safe `transferFrom` to transfer exactly `amountIn` from `msg.sender` directly to the adapter. +3. Requires the adapter balance to equal `baseline + amountIn`. Any smaller or otherwise unexpected increase is rejected as unsupported token behavior. +4. Calls `adapter` with the opaque `data`, zero native value, and ordinary EVM `call`. `delegatecall` is never used. +5. If the call fails, reverts the whole batch with the leg index, adapter, and returned revert bytes. +6. Requires the adapter's `tokenIn` balance to equal its pre-transfer baseline after the call. + +The two balance equalities prove, for standard ERC-20 tokens, that this leg received and consumed exactly the top-level `amountIn` even though the Router does not decode the payload's token or amount fields. A payload naming another input token, consuming too little, or consuming from a pre-existing adapter balance fails this invariant. Because funding and calling occur in the same transaction and every failure reverts, no successful execution leaves a Router-funded prefund at an adapter. + +### 4. Verify aggregate outputs + +After all calls complete, the Router reads its final balance for every unique declared output token. + +- A final balance below the snapshot is a balance-isolation violation and reverts. +- `produced = finalBalance - snapshotBalance` is the only amount attributable to this transaction. +- `produced` must be at least the sum of declared output amounts for that token. +- Pre-existing balances never contribute to `produced` and therefore cannot satisfy a minimum. + +Adapter return values are not used for settlement. Router balance deltas are authoritative. + +### 5. Pay exact outputs + +The Router processes `outputs` in caller-supplied order. For each entry it: + +1. Records the recipient's token balance. +2. Safely transfers exactly `Output.amount` from the Router to the recipient. +3. Requires the recipient's balance to have increased by exactly `Output.amount`. + +This recipient-delta assertion gives `amount` received semantics, not merely `amount` sent semantics, and causes common fee-on-transfer outputs to revert atomically. + +### 6. Return surplus and restore baselines + +For each unique output token, the Router computes `surplus = produced - required` after reserving all exact recipient payments. If nonzero, it transfers the surplus to `msg.sender` and requires the swapper's balance to increase by exactly the surplus. + +Finally, the Router requires its balance of every declared output token to equal the original snapshot. This final assertion catches sender-side transfer fees and proves that the current transaction neither consumed old funds nor retained newly produced declared outputs. + +Only after all final assertions pass does the Router emit its completion event. Any failure at any point reverts the input transfers, adapter effects, output transfers, and events. + +## Core Invariants + +1. **Registered targets only:** every external call target is a current entity of the immutable LiquidLane adapter factory. +2. **Two selectors only:** the Router can invoke only the current signed-swap and discount-swap entrypoints. +3. **No arbitrary execution:** the Router never calls a user-selected non-adapter target, never uses `delegatecall`, and never forwards native value. +4. **Caller-funded:** every leg pulls from `msg.sender`; no arbitrary payer field exists. +5. **Direct input routing:** input moves from the swapper directly to the adapter and never through the Router. +6. **Exact input per leg:** the adapter balance increases by exactly `amountIn` on funding and returns to the same baseline after execution. +7. **Router-directed outputs:** successful batches rely on the backend encoding the Router as adapter recipient; declared aggregate deltas must arrive at the Router. +8. **Aggregate minimums:** for each declared output token, transaction-local production is at least the sum of its output entries. +9. **Exact recipient receipts:** each recipient's balance increases by its declared amount. +10. **Surplus belongs to the swapper:** all transaction-local declared-token production beyond required outputs is transferred to `msg.sender`. +11. **Pre-existing balance isolation:** a call can neither spend nor withdraw balances present before that call. +12. **No successful custody:** after success, each declared token balance equals its pre-call snapshot. +13. **Atomicity:** no partial batch, partial output, stranded leg prefund, or allow-failure mode exists. +14. **Reentrancy exclusion:** neither token, adapter, nor recipient callbacks can enter either `execute` overload during execution. + +## Reentrancy and External-Call Model + +Both overloads share a single `nonReentrant` boundary, preferably the same transient-storage OpenZeppelin guard already used by current mainline Reactor under the Cancun EVM target. All validation and snapshots happen inside that boundary. + +External interactions are limited to: + +- factory `isEntity` static calls; +- ERC-20 `balanceOf`, `transferFrom`, and `transfer` calls; and +- zero-value calls to registered adapters with an allowed selector. + +There is no callback entrypoint, `receive`, payable function, approval, or arbitrary target call. A malicious recipient or callback-capable token may force a revert but cannot execute a second Router batch or consume another caller's snapshot. + +## Errors + +The interface defines concise custom errors for these observable failure classes: + +| Error | Condition | +| --- | --- | +| `AdapterCallFailed(index, adapter, reason)` | An allowed adapter call reverted. | +| `BalanceIsolationViolation(token, baseline, actual)` | A declared Router token balance fell below its snapshot or failed to return to it. | +| `EmptyOutputs()` | No output was declared. | +| `EmptySwapCalls()` | No swap leg was supplied. | +| `Expired(deadline)` | The deadline overload was called after its deadline. | +| `InputConsumptionMismatch(index, expectedBaseline, actual)` | A leg did not consume exactly the transferred input increment. | +| `InputTransferMismatch(index, expected, actual)` | Direct funding did not increase the adapter balance by exactly `amountIn`. | +| `InsufficientOutput(token, required, produced)` | Aggregate transaction-local production is below the declared total. | +| `InvalidAdapter(index, adapter)` | The target is zero or is not a factory entity. | +| `InvalidAmount(index)` | A swap or output amount is zero. | +| `InvalidCalldata(index)` | Adapter calldata is shorter than one selector. | +| `InvalidOutputToken(index, token)` | An output is native, zero, equal to `tokenIn`, or not an ERC-20 contract. | +| `InvalidRecipient(index, recipient)` | A recipient is zero or the Router. | +| `InvalidSelector(index, selector)` | The adapter selector is not one of the two permitted selectors. | +| `InvalidTokenIn(token)` | `tokenIn` is zero or not a contract. | +| `OutputTransferMismatch(index, expected, actual)` | A declared recipient did not receive exactly the requested amount. | +| `SurplusTransferMismatch(token, expected, actual)` | The swapper did not receive the exact surplus. | + +The reentrancy guard's standard custom error remains part of the observable surface. Arithmetic overflow uses Solidity's checked-arithmetic panic and is not remapped. + +## Events + +The Router emits: + +- `OutputTransferred(token, recipient, amount)` after each exact recipient transfer; +- `SurplusTransferred(token, swapper, amount)` for each nonzero surplus; and +- `Execute(swapper, tokenIn, totalAmountIn, swapCallCount, outputCount)` once, after all transfers and final baseline assertions succeed. + +`swapper`, `tokenIn`, output `token`, and output `recipient` are indexed where Solidity's event topic limit permits. Failed batches emit no durable events. + +## Unsupported Token and Native Behavior + +V1 supports ordinary ERC-20 tokens whose balances change exactly by the requested transfer amount. + +- **Fee-on-transfer input:** rejected by the adapter funding delta check. +- **Fee-on-transfer output:** rejected by the recipient or surplus balance-delta check, or by the final Router baseline assertion. +- **Sender-side transfer fee:** rejected by the final Router baseline assertion. +- **Rebasing token:** unsupported. A rebase during external execution can invalidate snapshot arithmetic or make a delta appear to be swap production. Deployment and integration configuration must exclude rebasing assets even if a particular call happens to pass the checks. +- **Callback-capable token:** unsupported. The guard prevents reentrant settlement, but such a token may revert the batch or have balance semantics outside the V1 model. +- **Native currency:** both overloads are nonpayable, `address(0)` is rejected as an output token, there is no `receive` or payable fallback, and adapter calls always use zero value. ETH forced onto the contract is permanently inaccessible and never participates in accounting. + +## Security Assumptions and Explicit Trade-offs + +- The immutable factory correctly identifies authentic LiquidLane adapters. Factory compromise or registration of malicious adapters is outside the Router's local trust boundary. +- Current LiquidLane signed-swap and discount-swap selectors retain their documented semantics. +- The backend or solver encodes `recipient = Router`; signed swaps additionally encode `caller = Router`. Incorrect encoding normally fails aggregate output validation and reverts. +- The user authorizes the exact transaction calldata by submitting the transaction. There is no separate Router signature or relayer authorization in V1. +- Output protection is aggregate per token, not per leg. Cross-leg subsidy is accepted because the user receives the declared batch result. +- Only tokens listed in `outputs` are snapshotted and distributed. Undeclared tokens sent to the Router remain isolated permanently; a later user cannot claim them as transaction-local surplus. +- There is no rescue function. Recoverability of accidental or forced balances is intentionally sacrificed to keep the pre-existing-balance invariant unconditional and ownerless. + +## Test Plan + +Create focused Foundry tests in `test/Router.t.sol` with registry, adapter, token, callback, and recipient mocks. Tests must cover both overloads and all branches. + +### Construction and API + +- Constructor rejects a zero or non-contract factory. +- The immutable getter returns the configured factory. +- The no-deadline overload succeeds under the same economic conditions as the deadline overload. +- Deadline equality succeeds; one second after the deadline reverts before any transfer. +- Both overloads reject attached native value at the ABI boundary. + +### Structural validation + +- Zero/non-contract `tokenIn`, empty calls, and empty outputs revert. +- Zero amounts, zero recipients, Router recipients, native output, and non-contract output tokens revert. +- Duplicate output tokens and recipients are accepted and aggregated correctly. +- An unregistered or zero adapter reverts. +- Calldata shorter than four bytes reverts. +- Signed and discount selectors succeed. +- Direct swap, adapter administration, arbitrary, fallback, and legacy stage selectors revert. +- Selector constants are pinned against the current LiquidLane interface shape. + +### Input routing + +- Each leg transfers directly from the caller to its selected adapter; the Router input balance remains unchanged. +- Multiple adapters and repeated calls to one adapter work in array order. +- Missing allowance and insufficient caller balance bubble/revert atomically. +- Fee-on-transfer input fails the exact adapter funding delta. +- A call that consumes less, more, or a different input token fails the post-call adapter baseline check. +- A failure on a later leg rolls back earlier adapter calls and transfers. +- Adapter revert data is reported with the correct index and target. + +### Output accounting + +- One token/one recipient settles exactly. +- One token split across several recipients uses the aggregate minimum and exact per-recipient amounts. +- Several output tokens settle independently. +- Output token equal to `tokenIn` is rejected before any transfer. +- Aggregate underproduction reverts the entire batch. +- Exact production leaves no surplus event. +- Overproduction pays exact outputs and returns the precise surplus to the caller. +- A fee-on-transfer output or surplus transfer reverts on recipient delta mismatch. +- Sender-side fee behavior reverts on final baseline mismatch. +- Tokens not declared as outputs are not paid or made claimable by a later call. + +### Pre-existing balance isolation + +- A pre-existing Router balance cannot satisfy an output minimum. +- Exact outputs and surplus leave the pre-existing balance unchanged. +- A later caller cannot sweep a prior caller's or forced token balance. +- A malicious registered adapter cannot reduce a declared pre-existing output balance without causing a revert. +- Forced ETH has no effect on ERC-20 accounting and cannot be withdrawn through Router. + +### Reentrancy and atomicity + +- A recipient callback attempting either overload reverts with the guard and rolls back the batch. +- A callback-capable input or output token cannot enter Router settlement. +- A registered malicious adapter cannot reenter Router. +- A payout failure after all adapter calls rolls back input transfers and adapter state. +- No event survives any reverted execution. + +### Integration and deployment + +- Add a mainline LiquidLane interface-shape test for both allowed selectors. +- Add an integration test with a factory mock exposing only `isEntity` to prove old-stage compatibility of the minimal interface. +- Add a deployment-script test confirming constructor validation and the immutable factory. +- Include Router in bytecode-size and gas snapshots according to repository conventions. + +## Deployment Design + +Deploy Router directly with one constructor argument: the chain's LiquidLane adapter factory. The contract has no proxy, initializer, owner, roles, storage configuration, or upgrade path. A new factory requires a new Router deployment. + +Add: + +- `src/Router.sol`; +- `src/interfaces/IRouter.sol`; +- the minimal `src/interfaces/IRegistry.sol` when implementing from the old stage base; +- `test/Router.t.sol`; +- `script/deploy/DeployRouter.s.sol`; and +- a deployment-script test under `test/deploy/` where that layout is present after synchronization with current main. + +The deployment script reads `LIQUID_LANE_ADAPTER_FACTORY`, deploys Router, asserts the immutable matches, and logs the Router and factory addresses. Production deployment must use the per-chain factory already used by current Reactor configuration. + +After deployment, backend and solver configuration must use the deployed Router address as: + +- `SignedSwap.caller`; +- `SignedSwap.recipient`; and +- the discount swap's explicit `recipient` argument. + +No adapter filler authorization is needed because V1 rejects the unsigned direct-swap selector. Users approve the input ERC-20 to Router and submit the Router transaction themselves. + +## Acceptance Criteria + +The feature is complete when: + +1. Both typed overloads implement the same atomic execution path and the deadline overload expires exactly as specified. +2. Every leg targets a factory entity and one of exactly two pinned selectors. +3. Every input leg is transferred directly from the caller, received exactly, and consumed exactly. +4. No declared output minimum can be satisfied by a pre-existing Router balance. +5. Every declared recipient receives exactly its amount, every declared-token surplus goes to the caller, and the Router returns to each declared token's starting balance. +6. Native currency and unsupported token behavior cannot silently participate in a successful batch. +7. Reentrancy, later-leg failure, adapter failure, and payout failure roll back the entire transaction. +8. The contract is ownerless, non-upgradeable, nonpayable, and has no rescue or arbitrary-call surface. +9. Unit, selector-shape, integration, deployment, formatting, size, and gas-snapshot checks pass under the repository's Foundry workflow. From 30c2a82bad15f6e74faf507ffc6069260266e411 Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 3 Aug 2026 16:58:39 -0700 Subject: [PATCH 02/11] docs: plan user-directed Router --- .../plans/2026-08-03-user-directed-router.md | 407 ++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-user-directed-router.md diff --git a/docs/superpowers/plans/2026-08-03-user-directed-router.md b/docs/superpowers/plans/2026-08-03-user-directed-router.md new file mode 100644 index 0000000..1d748fb --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-user-directed-router.md @@ -0,0 +1,407 @@ +# User-Directed Router Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an ownerless `Router` that atomically funds registered LiquidLane adapters from the caller, executes signed or discounted swap calldata, and distributes transaction-local ERC-20 output deltas. + +**Architecture:** `IRouter` fixes the typed batch ABI, allowed selectors, errors, and events. `Router` validates the entire batch, snapshots unique output-token balances, transfers each leg directly from `msg.sender` to its registered adapter, calls the adapter, verifies exact input consumption, enforces aggregate outputs, pays recipients, and returns surplus while preserving pre-existing balances. + +**Tech Stack:** Solidity 0.8.28, Foundry, OpenZeppelin `SafeERC20` and `ReentrancyGuardTransient`, forge-std. + +## Global Constraints + +- Contract name is exactly `Router` and it is deployed directly, without proxy, owner, roles, pause, rescue, or upgrade state. +- Input authorization is ordinary ERC-20 allowance to Router; do not add Permit2 or EIP-2612. +- ABI field order is `SwapCall(adapter, amountIn, data)` and `Output(token, recipient, amount)`. +- Expose both nonpayable overloads: `execute(tokenIn,calls,outputs)` and `execute(tokenIn,calls,outputs,deadline)`. +- Allow only selector `0x9a4568b6` (signed swap) and `0x8fa5c671` (discount swap). +- Validate every adapter through immutable `IRegistry(factory).isEntity(adapter)`. +- Use `call`, never `delegatecall`, and forward zero native value. +- Transfer every leg directly from `msg.sender` to its adapter; Router must never custody input. +- Support standard ERC-20 only; reject native, same-token output, fee-on-transfer behavior, empty economics, and zero values. +- Settle only transaction-local output deltas; never sweep or spend a pre-existing Router balance. +- Every failure reverts the complete batch. +- Target branch is `origin/stage`; preserve existing Reactor/Executor behavior. + +--- + +### Task 1: Pin the Router interface and structural validation + +**Files:** + +- Create: `src/interfaces/IRegistry.sol` +- Create: `src/interfaces/IRouter.sol` +- Create: `src/Router.sol` +- Create: `test/Router.t.sol` + +**Interfaces:** + +- Produces `IRegistry.isEntity(address) external view returns (bool)`. +- Produces `IRouter.SwapCall`, `IRouter.Output`, the two `execute` overloads, custom errors, and events. +- Produces `Router.LIQUID_LANE_ADAPTER_FACTORY()` and pre-execution validation shared by both overloads. + +- [ ] **Step 1: Write failing ABI and constructor tests** + +Create `test/Router.t.sol` with minimal registry/token/adapter mocks and assertions that pin the field order, immutable, selector constants, empty arrays, deadline boundary, zero/non-contract factory, zero/non-contract input token, same-token output, invalid recipients, zero amounts, unregistered adapters, short calldata, and unapproved selectors. The core fixtures are: + +```solidity +contract MockRegistry { + mapping(address => bool) public isEntity; + function setEntity(address entity, bool status) external { isEntity[entity] = status; } +} + +contract RouterTest is Test { + MockRegistry registry; + Router router; + + function setUp() public { + registry = new MockRegistry(); + router = new Router(address(registry)); + } + + function testDeadlineEqualityIsValid() public { + vm.warp(100); + vm.expectRevert(IRouter.EmptySwapCalls.selector); + router.execute(address(registry), new IRouter.SwapCall[](0), new IRouter.Output[](0), 100); + } + + function testExpiredDeadlineRevertsBeforeTokenInteraction() public { + vm.warp(101); + vm.expectRevert(abi.encodeWithSelector(IRouter.Expired.selector, 100)); + router.execute(address(registry), new IRouter.SwapCall[](0), new IRouter.Output[](0), 100); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +forge test --match-path test/Router.t.sol -vvv +``` + +Expected: compilation fails because `Router`, `IRouter`, and `IRegistry` do not exist. + +- [ ] **Step 3: Define the exact interfaces** + +Create `IRegistry.sol` with only the read method. Create `IRouter.sol` with: + +```solidity +interface IRouter { + struct SwapCall { address adapter; uint256 amountIn; bytes data; } + struct Output { address token; address recipient; uint256 amount; } + + error AdapterCallFailed(uint256 index, address adapter, bytes reason); + error BalanceIsolationViolation(address token, uint256 baseline, uint256 actual); + error EmptyOutputs(); + error EmptySwapCalls(); + error Expired(uint256 deadline); + error InputConsumptionMismatch(uint256 index, uint256 expectedBaseline, uint256 actual); + error InputTransferMismatch(uint256 index, uint256 expected, uint256 actual); + error InsufficientOutput(address token, uint256 required, uint256 produced); + error InvalidAdapter(uint256 index, address adapter); + error InvalidAmount(uint256 index); + error InvalidCalldata(uint256 index); + error InvalidOutputToken(uint256 index, address token); + error InvalidRecipient(uint256 index, address recipient); + error InvalidSelector(uint256 index, bytes4 selector); + error InvalidTokenIn(address token); + error OutputTransferMismatch(uint256 index, uint256 expected, uint256 actual); + error SurplusTransferMismatch(address token, uint256 expected, uint256 actual); + + event OutputTransferred(address indexed token, address indexed recipient, uint256 amount); + event SurplusTransferred(address indexed token, address indexed swapper, uint256 amount); + event Execute(address indexed swapper, address indexed tokenIn, uint256 totalAmountIn, uint256 swapCallCount, uint256 outputCount); + + function LIQUID_LANE_ADAPTER_FACTORY() external view returns (address); + function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) external; + function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs, uint256 deadline) external; +} +``` + +- [ ] **Step 4: Implement constructor, overload routing, and full prevalidation** + +Create `Router.sol` inheriting `IRouter, ReentrancyGuardTransient`. Constructor-reject a zero/non-contract factory. Route both overloads into `_execute`; the deadline overload checks `block.timestamp > deadline`. In `_validate`, require contract `tokenIn`, nonempty arrays, nonzero output/call amounts, output token code, `output.token != tokenIn`, nonzero recipient not Router, registered adapter, at least four calldata bytes, and one allowed selector: + +```solidity +bytes4 internal constant SIGNED_SWAP_SELECTOR = 0x9a4568b6; +bytes4 internal constant DISCOUNT_SWAP_SELECTOR = 0x8fa5c671; + +function _selector(bytes calldata data) internal pure returns (bytes4 selector) { + assembly ("memory-safe") { selector := calldataload(data.offset) } +} +``` + +Prevalidate every entry before any token transfer or adapter invocation. + +- [ ] **Step 5: Run structural tests and verify GREEN** + +Run: + +```bash +forge test --match-path test/Router.t.sol -vvv +forge fmt --check +``` + +Expected: constructor, ABI, selector, deadline, and structural-validation tests pass. + +- [ ] **Step 6: Commit the interface slice** + +```bash +git add src/interfaces/IRegistry.sol src/interfaces/IRouter.sol src/Router.sol test/Router.t.sol +git commit -m "feat: add typed Router interface" +``` + +--- + +### Task 2: Fund registered adapters and enforce exact leg consumption + +**Files:** + +- Modify: `src/Router.sol` +- Modify: `test/Router.t.sol` + +**Interfaces:** + +- Consumes the validated `IRouter.SwapCall[]` from Task 1. +- Produces `_executeCalls(address tokenIn, SwapCall[] calldata calls) returns (uint256 totalAmountIn)`. +- Guarantees input moves caller-to-adapter directly and each adapter returns to its pre-leg input balance. + +- [ ] **Step 1: Write failing direct-funding tests** + +Extend the mocks with a registered adapter that accepts the two selectors, consumes its prefunded input, transfers output to the Router, optionally reverts, and records calldata. Add tests for one leg, multiple adapters, call order, missing allowance, fee-on-transfer input, under-consumption, adapter revert data, and late-leg rollback: + +```solidity +function testTransfersEachInputDirectlyAndCallsInOrder() public { + IRouter.SwapCall[] memory calls = _twoCalls(4 ether, 6 ether); + IRouter.Output[] memory outputs = _oneOutput(10 ether, swapper); + + vm.prank(swapper); + router.execute(address(inputToken), calls, outputs, block.timestamp); + + assertEq(inputToken.balanceOf(address(router)), 0); + assertEq(adapter0.consumed(), 4 ether); + assertEq(adapter1.consumed(), 6 ether); +} +``` + +- [ ] **Step 2: Run input tests and verify RED** + +Run: + +```bash +forge test --match-path test/Router.t.sol --match-test 'testTransfers|testReverts.*Input|testLateLeg' -vvv +``` + +Expected: tests fail because calls are not funded or invoked. + +- [ ] **Step 3: Implement exact funding and calls** + +Use `SafeERC20` and balance deltas for every leg: + +```solidity +uint256 baseline = IERC20(tokenIn).balanceOf(call.adapter); +IERC20(tokenIn).safeTransferFrom(msg.sender, call.adapter, call.amountIn); +uint256 funded = IERC20(tokenIn).balanceOf(call.adapter); +if (funded != baseline + call.amountIn) { + revert InputTransferMismatch(i, baseline + call.amountIn, funded); +} +(bool success, bytes memory reason) = call.adapter.call(call.data); +if (!success) revert AdapterCallFailed(i, call.adapter, reason); +uint256 remaining = IERC20(tokenIn).balanceOf(call.adapter); +if (remaining != baseline) revert InputConsumptionMismatch(i, baseline, remaining); +totalAmountIn += call.amountIn; +``` + +Do not approve adapters, transfer input into Router, decode payload arguments, use returned adapter bytes, or allow per-leg failure. + +- [ ] **Step 4: Run input tests and verify GREEN** + +Run: + +```bash +forge test --match-path test/Router.t.sol --match-test 'testTransfers|testReverts.*Input|testLateLeg' -vvv +``` + +Expected: all direct-funding and atomic rollback tests pass. + +- [ ] **Step 5: Commit exact adapter execution** + +```bash +git add src/Router.sol test/Router.t.sol +git commit -m "feat: execute registered adapter swap calls" +``` + +--- + +### Task 3: Enforce output deltas, recipients, surplus, and reentrancy + +**Files:** + +- Modify: `src/Router.sol` +- Modify: `test/Router.t.sol` + +**Interfaces:** + +- Produces unique-token snapshot accounting inside `_execute`. +- Produces exact recipient receipts, surplus-to-caller, and final baseline restoration. +- Completes the atomic `execute` behavior and events. + +- [ ] **Step 1: Write failing settlement and attack tests** + +Cover duplicate output tokens/recipients, multiple tokens, aggregate underproduction, exact production, surplus, pre-existing balances, undeclared tokens, fee-on-transfer output, sender-side fee, malicious adapter balance reduction, recipient/token/adapter reentrancy, and event rollback: + +```solidity +function testPreexistingBalanceCannotSatisfyMinimumOrBecomeSurplus() public { + outputToken.mint(address(router), 100 ether); + adapter.setOutput(9 ether); + vm.expectRevert(abi.encodeWithSelector(IRouter.InsufficientOutput.selector, address(outputToken), 10 ether, 9 ether)); + vm.prank(swapper); + router.execute(address(inputToken), _oneCall(10 ether), _oneOutput(10 ether, swapper)); + assertEq(outputToken.balanceOf(address(router)), 100 ether); +} + +function testSurplusGoesToCallerAfterExactRecipientPayments() public { + adapter.setOutput(12 ether); + vm.prank(swapper); + router.execute(address(inputToken), _oneCall(10 ether), _oneOutput(10 ether, referrer)); + assertEq(outputToken.balanceOf(referrer), 10 ether); + assertEq(outputToken.balanceOf(swapper), 2 ether); +} +``` + +- [ ] **Step 2: Run settlement tests and verify RED** + +Run: + +```bash +forge test --match-path test/Router.t.sol --match-test 'testPreexisting|testSurplus|testReentr|testOutput|testDuplicate' -vvv +``` + +Expected: tests fail because output accounting and payouts are absent. + +- [ ] **Step 3: Implement grouped snapshots and aggregate minimums** + +Build fixed-size memory arrays with `outputs.length` capacity and a `uniqueCount`. For each output, linearly find or append its token, record the Router baseline exactly once, and checked-add its required amount. After adapter calls: + +```solidity +uint256 finalBalance = IERC20(tokens[i]).balanceOf(address(this)); +if (finalBalance < baselines[i]) { + revert BalanceIsolationViolation(tokens[i], baselines[i], finalBalance); +} +uint256 produced = finalBalance - baselines[i]; +if (produced < required[i]) revert InsufficientOutput(tokens[i], required[i], produced); +producedByToken[i] = produced; +``` + +- [ ] **Step 4: Implement exact payouts, surplus, and final restoration** + +For every declared output, snapshot recipient balance, safe-transfer, require an exact increase, and emit `OutputTransferred`. Then for each unique token transfer `produced - required` to `msg.sender`, check its exact receipt, emit `SurplusTransferred`, and require Router's final token balance equals its baseline. Emit `Execute` only after all final assertions. + +Keep both external overloads under the same `nonReentrant` guard. Do not call one guarded overload from the other; both call one unguarded internal `_execute`. + +- [ ] **Step 5: Run the complete Router test suite and verify GREEN** + +Run: + +```bash +forge test --match-path test/Router.t.sol -vvv +forge test --match-path test/Router.t.sol --fuzz-runs 10000 +forge fmt --check +``` + +Expected: all validation, accounting, rollback, fee-token, balance-isolation, and reentrancy tests pass. + +- [ ] **Step 6: Commit settlement** + +```bash +git add src/Router.sol test/Router.t.sol +git commit -m "feat: settle Router output deltas" +``` + +--- + +### Task 4: Add deployment integration and package documentation + +**Files:** + +- Create: `script/deploy/DeployRouter.s.sol` +- Modify: `README.md` +- Modify: `test/Router.t.sol` + +**Interfaces:** + +- Produces `DeployRouterScript.run() returns (Router)` using `LIQUID_LANE_ADAPTER_FACTORY`. +- Documents approval, typed execution, signed/discount selector restriction, and deployment. + +- [ ] **Step 1: Write a failing deployment-script assertion** + +Add a test that sets the environment value, runs the script, and verifies the immutable: + +```solidity +function testDeployRouterUsesFactoryEnvironment() public { + vm.setEnv("LIQUID_LANE_ADAPTER_FACTORY", vm.toString(address(registry))); + Router deployed = new DeployRouterScript().run(); + assertEq(deployed.LIQUID_LANE_ADAPTER_FACTORY(), address(registry)); +} +``` + +- [ ] **Step 2: Run the deployment test and verify RED** + +Run: + +```bash +forge test --match-path test/Router.t.sol --match-test testDeployRouterUsesFactoryEnvironment -vvv +``` + +Expected: compilation fails because `DeployRouterScript` does not exist. + +- [ ] **Step 3: Add the deployment script** + +Create a script matching existing style: + +```solidity +contract DeployRouterScript is Script { + function run() public returns (Router router) { + address factory = vm.envAddress("LIQUID_LANE_ADAPTER_FACTORY"); + vm.startBroadcast(); + router = new Router(factory); + vm.stopBroadcast(); + console2.log("Deployed Router:", address(router)); + } +} +``` + +- [ ] **Step 4: Document the exact user flow** + +Add Router to `README.md`: approve input ERC-20 to Router, obtain backend `/swap` transaction, submit typed deadline `execute`, and note that only registered signed/discount calls, standard ERC-20, and distinct token pairs are supported. Add a deployment command using `LIQUID_LANE_ADAPTER_FACTORY`. + +- [ ] **Step 5: Run package verification** + +Run from a Foundry workspace containing the stage package dependencies: + +```bash +forge fmt --check +forge build +forge test --match-path test/Router.t.sol --fuzz-runs 10000 +``` + +Expected: formatting, compilation, and all Router tests pass. If the sparse stage checkout cannot resolve its existing workspace-only remappings, run the same commands from the parent RFQ Foundry workspace with this worktree mounted as `rfq/reactor`; do not vendor or change dependencies merely to make the sparse branch standalone. + +- [ ] **Step 6: Commit deployment and docs** + +```bash +git add script/deploy/DeployRouter.s.sol README.md test/Router.t.sol +git commit -m "docs: add Router deployment flow" +``` + +- [ ] **Step 7: Review the final diff against stage** + +```bash +git diff --check origin/stage...HEAD +git diff --stat origin/stage...HEAD +git status --short +``` + +Expected: only Router interface/implementation/tests/deployment/docs plus approved spec and plan are changed; status is clean. From 878bdb0e669d9354bce6dd17d32271bdf4ae7904 Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 3 Aug 2026 17:15:30 -0700 Subject: [PATCH 03/11] feat: add user-directed Router --- README.md | 30 +- script/deploy/DeployRouter.s.sol | 19 ++ src/Router.sol | 233 +++++++++++++ src/interfaces/IRegistry.sol | 8 + src/interfaces/IRouter.sol | 51 +++ test/Router.t.sol | 542 +++++++++++++++++++++++++++++++ 6 files changed, 881 insertions(+), 2 deletions(-) create mode 100644 script/deploy/DeployRouter.s.sol create mode 100644 src/Router.sol create mode 100644 src/interfaces/IRegistry.sol create mode 100644 src/interfaces/IRouter.sol create mode 100644 test/Router.t.sol diff --git a/README.md b/README.md index 28ff4ee..6b41608 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,11 @@ # Reactor Contracts -This directory contains the core RFQ settlement contracts used by the Symbiotic instant redemption flow. The pair is intentionally small: +This directory contains the core RFQ settlement contracts used by the Symbiotic instant redemption flow: - `Reactor.sol` validates the signed order, pulls the input through Permit2, routes input into the instant redemption adapter, and enforces output delivery. - `Executor.sol` is an example role-gated execution surface that calls the Reactor, performs adapter swaps, runs any post-swap execution payload, and approves output transfers back to the Reactor. +- `Router.sol` is an ownerless, user-directed execution surface that directly funds registered LiquidLane adapters from the caller and settles transaction-local output deltas. > [!NOTE] > `Executor.sol` is not a protocol requirement. It is an example filler-side executor contract that demonstrates one way to integrate with `Reactor`. Fillers can deploy their own executor implementation as long as it satisfies the expected Reactor callback flow. @@ -14,6 +15,7 @@ This directory contains the core RFQ settlement contracts used by the Symbiotic - [Reactor.sol](src/Reactor.sol) - [Executor.sol](src/Executor.sol) +- [Router.sol](src/Router.sol) ## Flow @@ -24,6 +26,15 @@ This directory contains the core RFQ settlement contracts used by the Symbiotic 5. `Executor.execute(...)` performs the adapter swaps and any opaque execution payload. 6. `Reactor` enforces the requested outputs and emits the fill event used by the indexer. +## User-directed swap flow + +1. The user approves the input ERC-20 to `Router` using an ordinary ERC-20 allowance. +2. The user requests an unsigned transaction from the backend `/api/v1/swap` endpoint. +3. `Router.execute(tokenIn, calls, outputs, deadline)` transfers each leg directly from the user to its adapter, invokes the provided calldata, and pays the declared recipients. +4. Any transaction-local surplus is returned to the caller; balances that predate the call are never used for settlement. + +The Router supports standard ERC-20 tokens and distinct input/output tokens only. Every adapter must be registered by `LIQUID_LANE_ADAPTER_FACTORY`, and calldata must use either the signed-swap selector `0x9a4568b6` or discount-swap selector `0x8fa5c671`. The batch is atomic: a failed leg or unmet output reverts every transfer. + ## Test locally This folder is part of the root Foundry workspace, so run commands from the repository root: @@ -35,9 +46,10 @@ forge test --match-path rfq/reactor/test/Reactor.t.sol ## Deploy -The repository includes helper script for deploying the example executor: +The repository includes helper scripts for deploying the example executor and Router: - `rfq/reactor/script/deploy/DeployExecutor.s.sol` +- `rfq/reactor/script/deploy/DeployRouter.s.sol` Example `Executor` deployment: @@ -52,12 +64,26 @@ forge script rfq/reactor/script/deploy/DeployExecutor.s.sol:DeployExecutorScript --broadcast ``` +Example `Router` deployment: + +```bash +cd +LIQUID_LANE_ADAPTER_FACTORY=0x... \ +forge script rfq/reactor/script/deploy/DeployRouter.s.sol:DeployRouterScript \ + --rpc-url "$RPC_URL" \ + --account "$ACCOUNT" \ + --sender "$SENDER" \ + --broadcast +``` + ## Files to know - `src/Reactor.sol` - `src/Executor.sol` +- `src/Router.sol` - `src/interfaces` - `test/Reactor.t.sol` +- `test/Router.t.sol` ## Notes diff --git a/script/deploy/DeployRouter.s.sol b/script/deploy/DeployRouter.s.sol new file mode 100644 index 0000000..4cfa096 --- /dev/null +++ b/script/deploy/DeployRouter.s.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Script, console2} from "forge-std/Script.sol"; + +import {Router} from "../../src/Router.sol"; + +/// @notice Deploys the ownerless user-directed Router. +contract DeployRouterScript is Script { + function run() public returns (Router router) { + address liquidLaneAdapterFactory = vm.envAddress("LIQUID_LANE_ADAPTER_FACTORY"); + + vm.startBroadcast(); + router = new Router(liquidLaneAdapterFactory); + vm.stopBroadcast(); + + console2.log("Deployed Router:", address(router)); + } +} diff --git a/src/Router.sol b/src/Router.sol new file mode 100644 index 0000000..235055c --- /dev/null +++ b/src/Router.sol @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Symbiotic +pragma solidity 0.8.28; + +import {IRegistry} from "./interfaces/IRegistry.sol"; +import {IRouter} from "./interfaces/IRouter.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +/// @title Router +/// @notice Atomically funds registered adapters and settles transaction-local output balances. +/// @custom:security-contact security@symbiotic.fi +contract Router is IRouter, ReentrancyGuard { + using SafeERC20 for IERC20; + + bytes4 internal constant SIGNED_SWAP_SELECTOR = 0x9a4568b6; + bytes4 internal constant DISCOUNT_SWAP_SELECTOR = 0x8fa5c671; + + address public immutable LIQUID_LANE_ADAPTER_FACTORY; + + constructor(address liquidLaneAdapterFactory) { + if (liquidLaneAdapterFactory == address(0) || liquidLaneAdapterFactory.code.length == 0) { + revert InvalidFactory(liquidLaneAdapterFactory); + } + LIQUID_LANE_ADAPTER_FACTORY = liquidLaneAdapterFactory; + } + + /// @inheritdoc IRouter + function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) external nonReentrant { + _execute(tokenIn, calls, outputs); + } + + /// @inheritdoc IRouter + function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs, uint256 deadline) + external + nonReentrant + { + if (block.timestamp > deadline) revert Expired(deadline); + _execute(tokenIn, calls, outputs); + } + + function _execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) internal { + _validate(tokenIn, calls, outputs); + + (address[] memory tokens, uint256[] memory baselines, uint256[] memory required, uint256 uniqueCount) = + _snapshotOutputs(outputs); + uint256 totalAmountIn = _executeCalls(tokenIn, calls); + uint256[] memory produced = _measureOutputs(tokens, baselines, required, uniqueCount); + + _transferOutputs(outputs); + _transferSurplus(tokens, baselines, required, produced, uniqueCount); + + emit Execute(msg.sender, tokenIn, totalAmountIn, calls.length, outputs.length); + } + + function _executeCalls(address tokenIn, SwapCall[] calldata calls) internal returns (uint256 totalAmountIn) { + IERC20 inputToken = IERC20(tokenIn); + for (uint256 i; i < calls.length; ++i) { + SwapCall calldata swapCall = calls[i]; + uint256 senderBaseline = inputToken.balanceOf(msg.sender); + uint256 adapterBaseline = inputToken.balanceOf(swapCall.adapter); + + inputToken.safeTransferFrom(msg.sender, swapCall.adapter, swapCall.amountIn); + + uint256 adapterFunded = inputToken.balanceOf(swapCall.adapter); + uint256 adapterReceived = adapterFunded >= adapterBaseline ? adapterFunded - adapterBaseline : 0; + if (adapterReceived != swapCall.amountIn) { + revert InputTransferMismatch(i, swapCall.amountIn, adapterReceived); + } + + uint256 senderAfter = inputToken.balanceOf(msg.sender); + uint256 senderSpent = senderAfter <= senderBaseline ? senderBaseline - senderAfter : 0; + if (senderSpent != swapCall.amountIn) { + revert InputTransferMismatch(i, swapCall.amountIn, senderSpent); + } + + (bool success, bytes memory reason) = swapCall.adapter.call(swapCall.data); + if (!success) revert AdapterCallFailed(i, swapCall.adapter, reason); + + uint256 remaining = inputToken.balanceOf(swapCall.adapter); + if (remaining != adapterBaseline) { + revert InputConsumptionMismatch(i, adapterBaseline, remaining); + } + + totalAmountIn += swapCall.amountIn; + } + } + + function _transferOutputs(Output[] calldata outputs) internal { + for (uint256 i; i < outputs.length; ++i) { + Output calldata output = outputs[i]; + IERC20 token = IERC20(output.token); + uint256 routerBaseline = token.balanceOf(address(this)); + uint256 recipientBaseline = token.balanceOf(output.recipient); + + token.safeTransfer(output.recipient, output.amount); + + uint256 recipientAfter = token.balanceOf(output.recipient); + uint256 received = recipientAfter >= recipientBaseline ? recipientAfter - recipientBaseline : 0; + if (received != output.amount) revert OutputTransferMismatch(i, output.amount, received); + + uint256 routerAfter = token.balanceOf(address(this)); + uint256 spent = routerAfter <= routerBaseline ? routerBaseline - routerAfter : 0; + if (spent != output.amount) revert OutputTransferMismatch(i, output.amount, spent); + + emit OutputTransferred(output.token, output.recipient, output.amount); + } + } + + function _transferSurplus( + address[] memory tokens, + uint256[] memory baselines, + uint256[] memory required, + uint256[] memory produced, + uint256 uniqueCount + ) internal { + for (uint256 i; i < uniqueCount; ++i) { + uint256 surplus = produced[i] - required[i]; + IERC20 token = IERC20(tokens[i]); + if (surplus > 0) { + uint256 routerBaseline = token.balanceOf(address(this)); + uint256 swapperBaseline = token.balanceOf(msg.sender); + + token.safeTransfer(msg.sender, surplus); + + uint256 swapperAfter = token.balanceOf(msg.sender); + uint256 received = swapperAfter >= swapperBaseline ? swapperAfter - swapperBaseline : 0; + if (received != surplus) revert SurplusTransferMismatch(tokens[i], surplus, received); + + uint256 routerAfter = token.balanceOf(address(this)); + uint256 spent = routerAfter <= routerBaseline ? routerBaseline - routerAfter : 0; + if (spent != surplus) revert SurplusTransferMismatch(tokens[i], surplus, spent); + + emit SurplusTransferred(tokens[i], msg.sender, surplus); + } + + uint256 finalBalance = token.balanceOf(address(this)); + if (finalBalance != baselines[i]) { + revert BalanceIsolationViolation(tokens[i], baselines[i], finalBalance); + } + } + } + + function _snapshotOutputs(Output[] calldata outputs) + internal + view + returns (address[] memory tokens, uint256[] memory baselines, uint256[] memory required, uint256 uniqueCount) + { + tokens = new address[](outputs.length); + baselines = new uint256[](outputs.length); + required = new uint256[](outputs.length); + + for (uint256 i; i < outputs.length; ++i) { + address token = outputs[i].token; + uint256 tokenIndex = uniqueCount; + for (uint256 j; j < uniqueCount; ++j) { + if (tokens[j] == token) { + tokenIndex = j; + break; + } + } + + if (tokenIndex == uniqueCount) { + tokens[uniqueCount] = token; + baselines[uniqueCount] = IERC20(token).balanceOf(address(this)); + ++uniqueCount; + } + required[tokenIndex] += outputs[i].amount; + } + } + + function _measureOutputs( + address[] memory tokens, + uint256[] memory baselines, + uint256[] memory required, + uint256 uniqueCount + ) internal view returns (uint256[] memory produced) { + produced = new uint256[](uniqueCount); + for (uint256 i; i < uniqueCount; ++i) { + uint256 finalBalance = IERC20(tokens[i]).balanceOf(address(this)); + if (finalBalance < baselines[i]) { + revert BalanceIsolationViolation(tokens[i], baselines[i], finalBalance); + } + produced[i] = finalBalance - baselines[i]; + if (produced[i] < required[i]) { + revert InsufficientOutput(tokens[i], required[i], produced[i]); + } + } + } + + function _validate(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) internal view { + if (tokenIn == address(0) || tokenIn.code.length == 0) revert InvalidTokenIn(tokenIn); + if (calls.length == 0) revert EmptySwapCalls(); + if (outputs.length == 0) revert EmptyOutputs(); + + for (uint256 i; i < outputs.length; ++i) { + Output calldata output = outputs[i]; + if (output.token == address(0) || output.token == tokenIn || output.token.code.length == 0) { + revert InvalidOutputToken(i, output.token); + } + if (output.recipient == address(0) || output.recipient == address(this)) { + revert InvalidRecipient(i, output.recipient); + } + if (output.amount == 0) revert InvalidAmount(i); + } + + IRegistry registry = IRegistry(LIQUID_LANE_ADAPTER_FACTORY); + for (uint256 i; i < calls.length; ++i) { + SwapCall calldata swapCall = calls[i]; + if ( + swapCall.adapter == address(0) || swapCall.adapter.code.length == 0 + || !registry.isEntity(swapCall.adapter) + ) { + revert InvalidAdapter(i, swapCall.adapter); + } + if (swapCall.amountIn == 0) revert InvalidAmount(i); + if (swapCall.data.length < 4) revert InvalidCalldata(i); + + bytes4 selector = _selector(swapCall.data); + if (selector != SIGNED_SWAP_SELECTOR && selector != DISCOUNT_SWAP_SELECTOR) { + revert InvalidSelector(i, selector); + } + } + } + + function _selector(bytes calldata data) internal pure returns (bytes4 selector) { + assembly ("memory-safe") { + selector := calldataload(data.offset) + } + } +} diff --git a/src/interfaces/IRegistry.sol b/src/interfaces/IRegistry.sol new file mode 100644 index 0000000..ff64241 --- /dev/null +++ b/src/interfaces/IRegistry.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Symbiotic +pragma solidity ^0.8.0; + +/// @notice Minimal LiquidLane adapter-factory registry interface. +interface IRegistry { + function isEntity(address entity) external view returns (bool); +} diff --git a/src/interfaces/IRouter.sol b/src/interfaces/IRouter.sol new file mode 100644 index 0000000..b67021a --- /dev/null +++ b/src/interfaces/IRouter.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Symbiotic +pragma solidity ^0.8.0; + +/// @notice User-directed batch execution interface for registered LiquidLane adapters. +interface IRouter { + struct SwapCall { + address adapter; + uint256 amountIn; + bytes data; + } + + struct Output { + address token; + address recipient; + uint256 amount; + } + + error AdapterCallFailed(uint256 index, address adapter, bytes reason); + error BalanceIsolationViolation(address token, uint256 baseline, uint256 actual); + error EmptyOutputs(); + error EmptySwapCalls(); + error Expired(uint256 deadline); + error InputConsumptionMismatch(uint256 index, uint256 expectedBaseline, uint256 actual); + error InputTransferMismatch(uint256 index, uint256 expected, uint256 actual); + error InsufficientOutput(address token, uint256 required, uint256 produced); + error InvalidAdapter(uint256 index, address adapter); + error InvalidAmount(uint256 index); + error InvalidCalldata(uint256 index); + error InvalidFactory(address factory); + error InvalidOutputToken(uint256 index, address token); + error InvalidRecipient(uint256 index, address recipient); + error InvalidSelector(uint256 index, bytes4 selector); + error InvalidTokenIn(address token); + error OutputTransferMismatch(uint256 index, uint256 expected, uint256 actual); + error SurplusTransferMismatch(address token, uint256 expected, uint256 actual); + + event OutputTransferred(address indexed token, address indexed recipient, uint256 amount); + event SurplusTransferred(address indexed token, address indexed swapper, uint256 amount); + event Execute( + address indexed swapper, + address indexed tokenIn, + uint256 totalAmountIn, + uint256 swapCallCount, + uint256 outputCount + ); + + function LIQUID_LANE_ADAPTER_FACTORY() external view returns (address); + function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) external; + function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs, uint256 deadline) external; +} diff --git a/test/Router.t.sol b/test/Router.t.sol new file mode 100644 index 0000000..1284eab --- /dev/null +++ b/test/Router.t.sol @@ -0,0 +1,542 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Symbiotic +pragma solidity 0.8.28; + +import {Test} from "forge-std/Test.sol"; + +import {Router} from "../src/Router.sol"; +import {IRouter} from "../src/interfaces/IRouter.sol"; +import {DeployRouterScript} from "../script/deploy/DeployRouter.s.sol"; + +contract MockRegistry { + mapping(address entity => bool registered) public isEntity; + + function setEntity(address entity, bool registered) external { + isEntity[entity] = registered; + } +} + +contract MockERC20 { + string public name; + string public symbol; + uint8 public constant decimals = 18; + + uint256 public totalSupply; + uint256 public feeBps; + bool public senderPaysFee; + mapping(address account => uint256 balance) public balanceOf; + mapping(address owner => mapping(address spender => uint256 amount)) public allowance; + + constructor(string memory name_, string memory symbol_) { + name = name_; + symbol = symbol_; + } + + function setFee(uint256 feeBps_, bool senderPaysFee_) external { + feeBps = feeBps_; + senderPaysFee = senderPaysFee_; + } + + function mint(address account, uint256 amount) external { + balanceOf[account] += amount; + totalSupply += amount; + } + + function burn(address account, uint256 amount) external { + balanceOf[account] -= amount; + totalSupply -= amount; + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + return true; + } + + function transfer(address recipient, uint256 amount) external returns (bool) { + _transfer(msg.sender, recipient, amount); + return true; + } + + function transferFrom(address owner, address recipient, uint256 amount) external returns (bool) { + uint256 approved = allowance[owner][msg.sender]; + if (approved != type(uint256).max) allowance[owner][msg.sender] = approved - amount; + _transfer(owner, recipient, amount); + return true; + } + + function _transfer(address owner, address recipient, uint256 amount) internal { + uint256 fee = amount * feeBps / 10_000; + uint256 debit = senderPaysFee ? amount + fee : amount; + uint256 credit = senderPaysFee ? amount : amount - fee; + balanceOf[owner] -= debit; + balanceOf[recipient] += credit; + totalSupply -= fee; + } +} + +contract MockAdapter { + bytes4 internal constant SIGNED_SWAP_SELECTOR = 0x9a4568b6; + bytes4 internal constant DISCOUNT_SWAP_SELECTOR = 0x8fa5c671; + + MockERC20 public immutable inputToken; + MockERC20 public immutable outputToken; + address public immutable router; + uint256 public outputAmount; + uint256 public leaveInput; + uint256 public callCount; + bool public shouldRevert; + bool public reduceRouterBalance; + bool public reenter; + + constructor(MockERC20 inputToken_, MockERC20 outputToken_, address router_) { + inputToken = inputToken_; + outputToken = outputToken_; + router = router_; + } + + function configure(uint256 outputAmount_, uint256 leaveInput_) external { + outputAmount = outputAmount_; + leaveInput = leaveInput_; + } + + function setShouldRevert(bool status) external { + shouldRevert = status; + } + + function setReduceRouterBalance(bool status) external { + reduceRouterBalance = status; + } + + function setReenter(bool status) external { + reenter = status; + } + + fallback() external { + if (msg.sig != SIGNED_SWAP_SELECTOR && msg.sig != DISCOUNT_SWAP_SELECTOR) revert("selector"); + if (shouldRevert) revert("adapter failed"); + + ++callCount; + uint256 inputBalance = inputToken.balanceOf(address(this)); + if (inputBalance > leaveInput) inputToken.burn(address(this), inputBalance - leaveInput); + if (reduceRouterBalance) outputToken.burn(router, 1 ether); + if (outputAmount > 0) outputToken.mint(router, outputAmount); + + if (reenter) { + IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](0); + IRouter.Output[] memory outputs = new IRouter.Output[](0); + Router(router).execute(address(inputToken), calls, outputs); + } + } +} + +contract RouterTest is Test { + bytes4 internal constant SIGNED_SWAP_SELECTOR = 0x9a4568b6; + bytes4 internal constant DISCOUNT_SWAP_SELECTOR = 0x8fa5c671; + + address internal swapper = makeAddr("swapper"); + address internal recipient = makeAddr("recipient"); + MockRegistry internal registry; + MockERC20 internal inputToken; + MockERC20 internal outputToken; + MockERC20 internal secondOutputToken; + Router internal router; + MockAdapter internal adapter0; + MockAdapter internal adapter1; + + function setUp() public { + registry = new MockRegistry(); + router = new Router(address(registry)); + inputToken = new MockERC20("Input", "IN"); + outputToken = new MockERC20("Output", "OUT"); + secondOutputToken = new MockERC20("Second", "SECOND"); + adapter0 = new MockAdapter(inputToken, outputToken, address(router)); + adapter1 = new MockAdapter(inputToken, outputToken, address(router)); + registry.setEntity(address(adapter0), true); + registry.setEntity(address(adapter1), true); + inputToken.mint(swapper, 1_000 ether); + vm.prank(swapper); + inputToken.approve(address(router), type(uint256).max); + } + + function testConstructorRejectsZeroFactory() public { + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidFactory.selector, address(0))); + new Router(address(0)); + } + + function testConstructorRejectsNonContractFactory() public { + address notContract = makeAddr("notContract"); + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidFactory.selector, notContract)); + new Router(notContract); + } + + function testStoresRegistryFactory() public view { + assertEq(router.LIQUID_LANE_ADAPTER_FACTORY(), address(registry)); + } + + function testDeployRouterUsesFactoryEnvironment() public { + vm.setEnv("LIQUID_LANE_ADAPTER_FACTORY", vm.toString(address(registry))); + Router deployed = new DeployRouterScript().run(); + assertEq(deployed.LIQUID_LANE_ADAPTER_FACTORY(), address(registry)); + } + + function testDeadlineEqualityIsValid() public { + vm.warp(100); + vm.expectRevert(IRouter.EmptySwapCalls.selector); + router.execute(address(inputToken), new IRouter.SwapCall[](0), new IRouter.Output[](0), 100); + } + + function testExpiredDeadlineRevertsBeforeValidation() public { + vm.warp(101); + vm.expectRevert(abi.encodeWithSelector(IRouter.Expired.selector, 100)); + router.execute(address(0), new IRouter.SwapCall[](0), new IRouter.Output[](0), 100); + } + + function testRejectsEmptySwapCalls() public { + vm.expectRevert(IRouter.EmptySwapCalls.selector); + router.execute( + address(inputToken), new IRouter.SwapCall[](0), _oneOutput(address(outputToken), 1 ether, recipient) + ); + } + + function testRejectsEmptyOutputs() public { + vm.expectRevert(IRouter.EmptyOutputs.selector); + router.execute( + address(inputToken), _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), new IRouter.Output[](0) + ); + } + + function testRejectsInvalidInputToken() public { + address notContract = makeAddr("notToken"); + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidTokenIn.selector, notContract)); + router.execute( + notContract, + _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 1 ether, recipient) + ); + } + + function testRejectsSameInputAndOutputToken() public { + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidOutputToken.selector, 0, address(inputToken))); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(inputToken), 1 ether, recipient) + ); + } + + function testRejectsInvalidOutputToken() public { + address notContract = makeAddr("notOutputToken"); + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidOutputToken.selector, 0, notContract)); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(notContract, 1 ether, recipient) + ); + } + + function testRejectsInvalidRecipient() public { + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidRecipient.selector, 0, address(0))); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 1 ether, address(0)) + ); + } + + function testRejectsRouterAsRecipient() public { + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidRecipient.selector, 0, address(router))); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 1 ether, address(router)) + ); + } + + function testRejectsZeroOutputAmount() public { + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAmount.selector, 0)); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 0, recipient) + ); + } + + function testRejectsZeroCallAmount() public { + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAmount.selector, 0)); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 0, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 1 ether, recipient) + ); + } + + function testRejectsUnregisteredAdapter() public { + MockAdapter unregistered = new MockAdapter(inputToken, outputToken, address(router)); + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAdapter.selector, 0, address(unregistered))); + router.execute( + address(inputToken), + _oneCall(address(unregistered), 1 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 1 ether, recipient) + ); + } + + function testRejectsShortCalldata() public { + IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](1); + calls[0] = IRouter.SwapCall({adapter: address(adapter0), amountIn: 1 ether, data: hex"9a4568"}); + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidCalldata.selector, 0)); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + } + + function testRejectsUnapprovedSelector() public { + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidSelector.selector, 0, bytes4(0x12345678))); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 1 ether, 0x12345678), + _oneOutput(address(outputToken), 1 ether, recipient) + ); + } + + function testAcceptsDiscountSelector() public { + adapter0.configure(1 ether, 0); + vm.prank(swapper); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 1 ether, DISCOUNT_SWAP_SELECTOR), + _oneOutput(address(outputToken), 1 ether, recipient) + ); + assertEq(outputToken.balanceOf(recipient), 1 ether); + } + + function testTransfersEachInputDirectlyAndCallsAllAdapters() public { + adapter0.configure(4 ether, 0); + adapter1.configure(6 ether, 0); + IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](2); + calls[0] = IRouter.SwapCall({ + adapter: address(adapter0), amountIn: 4 ether, data: abi.encodePacked(SIGNED_SWAP_SELECTOR) + }); + calls[1] = IRouter.SwapCall({ + adapter: address(adapter1), amountIn: 6 ether, data: abi.encodePacked(DISCOUNT_SWAP_SELECTOR) + }); + + vm.prank(swapper); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 10 ether, recipient)); + + assertEq(inputToken.balanceOf(address(router)), 0); + assertEq(inputToken.balanceOf(address(adapter0)), 0); + assertEq(inputToken.balanceOf(address(adapter1)), 0); + assertEq(adapter0.callCount(), 1); + assertEq(adapter1.callCount(), 1); + assertEq(outputToken.balanceOf(recipient), 10 ether); + } + + function testRevertsWhenInputAllowanceIsMissing() public { + vm.prank(swapper); + inputToken.approve(address(router), 0); + adapter0.configure(1 ether, 0); + vm.expectRevert(); + vm.prank(swapper); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 1 ether, recipient) + ); + } + + function testRevertsFeeOnTransferInput() public { + inputToken.setFee(100, false); + adapter0.configure(1 ether, 0); + vm.expectRevert(abi.encodeWithSelector(IRouter.InputTransferMismatch.selector, 0, 1 ether, 0.99 ether)); + vm.prank(swapper); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 1 ether, recipient) + ); + } + + function testRevertsSenderFeeInput() public { + inputToken.setFee(100, true); + adapter0.configure(1 ether, 0); + vm.expectRevert(abi.encodeWithSelector(IRouter.InputTransferMismatch.selector, 0, 1 ether, 1.01 ether)); + vm.prank(swapper); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 1 ether, recipient) + ); + } + + function testRevertsWhenAdapterDoesNotConsumeExactLeg() public { + adapter0.configure(1 ether, 0.1 ether); + vm.expectRevert(abi.encodeWithSelector(IRouter.InputConsumptionMismatch.selector, 0, 0, 0.1 ether)); + vm.prank(swapper); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 1 ether, recipient) + ); + } + + function testWrapsAdapterRevertData() public { + adapter0.setShouldRevert(true); + bytes memory reason = abi.encodeWithSignature("Error(string)", "adapter failed"); + vm.expectRevert(abi.encodeWithSelector(IRouter.AdapterCallFailed.selector, 0, address(adapter0), reason)); + vm.prank(swapper); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 1 ether, recipient) + ); + } + + function testLateLegFailureRollsBackEarlierLeg() public { + adapter0.configure(4 ether, 0); + adapter1.setShouldRevert(true); + IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](2); + calls[0] = IRouter.SwapCall({ + adapter: address(adapter0), amountIn: 4 ether, data: abi.encodePacked(SIGNED_SWAP_SELECTOR) + }); + calls[1] = IRouter.SwapCall({ + adapter: address(adapter1), amountIn: 6 ether, data: abi.encodePacked(SIGNED_SWAP_SELECTOR) + }); + + vm.expectRevert(); + vm.prank(swapper); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 10 ether, recipient)); + + assertEq(inputToken.balanceOf(swapper), 1_000 ether); + assertEq(outputToken.balanceOf(address(router)), 0); + assertEq(adapter0.callCount(), 0); + } + + function testPreexistingBalanceCannotSatisfyMinimumOrBecomeSurplus() public { + outputToken.mint(address(router), 100 ether); + adapter0.configure(9 ether, 0); + vm.expectRevert( + abi.encodeWithSelector(IRouter.InsufficientOutput.selector, address(outputToken), 10 ether, 9 ether) + ); + vm.prank(swapper); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 10 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 10 ether, recipient) + ); + assertEq(outputToken.balanceOf(address(router)), 100 ether); + } + + function testSurplusGoesToCallerAfterExactRecipientPayments() public { + adapter0.configure(12 ether, 0); + vm.prank(swapper); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 10 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 10 ether, recipient) + ); + assertEq(outputToken.balanceOf(recipient), 10 ether); + assertEq(outputToken.balanceOf(swapper), 2 ether); + assertEq(outputToken.balanceOf(address(router)), 0); + } + + function testDuplicateOutputTokensUseOneAggregateMinimum() public { + adapter0.configure(12 ether, 0); + IRouter.Output[] memory outputs = new IRouter.Output[](2); + outputs[0] = IRouter.Output({token: address(outputToken), recipient: recipient, amount: 4 ether}); + outputs[1] = IRouter.Output({token: address(outputToken), recipient: swapper, amount: 6 ether}); + + vm.prank(swapper); + router.execute(address(inputToken), _oneCall(address(adapter0), 10 ether, SIGNED_SWAP_SELECTOR), outputs); + + assertEq(outputToken.balanceOf(recipient), 4 ether); + assertEq(outputToken.balanceOf(swapper), 8 ether); + assertEq(outputToken.balanceOf(address(router)), 0); + } + + function testMultipleOutputTokensSettleIndependently() public { + adapter0.configure(5 ether, 0); + secondOutputToken.mint(address(router), 100 ether); + secondOutputToken.mint(address(router), 7 ether); + IRouter.Output[] memory outputs = new IRouter.Output[](2); + outputs[0] = IRouter.Output({token: address(outputToken), recipient: recipient, amount: 5 ether}); + outputs[1] = IRouter.Output({token: address(secondOutputToken), recipient: recipient, amount: 7 ether}); + + vm.expectRevert( + abi.encodeWithSelector(IRouter.InsufficientOutput.selector, address(secondOutputToken), 7 ether, 0) + ); + vm.prank(swapper); + router.execute(address(inputToken), _oneCall(address(adapter0), 5 ether, SIGNED_SWAP_SELECTOR), outputs); + } + + function testFeeOnTransferOutputRevertsAndRollsBack() public { + outputToken.setFee(100, false); + adapter0.configure(10 ether, 0); + vm.expectRevert(abi.encodeWithSelector(IRouter.OutputTransferMismatch.selector, 0, 10 ether, 9.9 ether)); + vm.prank(swapper); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 10 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 10 ether, recipient) + ); + assertEq(outputToken.balanceOf(recipient), 0); + } + + function testSenderFeeOutputRevertsAndRollsBack() public { + outputToken.setFee(100, true); + adapter0.configure(11 ether, 0); + vm.expectRevert(abi.encodeWithSelector(IRouter.OutputTransferMismatch.selector, 0, 10 ether, 10.1 ether)); + vm.prank(swapper); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 10 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 10 ether, recipient) + ); + assertEq(outputToken.balanceOf(recipient), 0); + } + + function testAdapterCannotReducePreexistingOutputBalance() public { + outputToken.mint(address(router), 100 ether); + adapter0.configure(10 ether, 0); + adapter0.setReduceRouterBalance(true); + vm.expectRevert( + abi.encodeWithSelector(IRouter.InsufficientOutput.selector, address(outputToken), 10 ether, 9 ether) + ); + vm.prank(swapper); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 10 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 10 ether, recipient) + ); + assertEq(outputToken.balanceOf(address(router)), 100 ether); + } + + function testAdapterReentrancyRevertsWholeBatch() public { + adapter0.configure(10 ether, 0); + adapter0.setReenter(true); + vm.expectRevert(); + vm.prank(swapper); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 10 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 10 ether, recipient) + ); + assertEq(inputToken.balanceOf(swapper), 1_000 ether); + assertEq(outputToken.balanceOf(recipient), 0); + } + + function _oneCall(address adapter, uint256 amountIn, bytes4 selector) + internal + pure + returns (IRouter.SwapCall[] memory calls) + { + calls = new IRouter.SwapCall[](1); + calls[0] = IRouter.SwapCall({adapter: adapter, amountIn: amountIn, data: abi.encodePacked(selector)}); + } + + function _oneOutput(address token, uint256 amount, address to) + internal + pure + returns (IRouter.Output[] memory outputs) + { + outputs = new IRouter.Output[](1); + outputs[0] = IRouter.Output({token: token, recipient: to, amount: amount}); + } +} From 56c9ae09413229cde42f60f892dd4973abdba859 Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 3 Aug 2026 18:16:18 -0700 Subject: [PATCH 04/11] fix: authenticate Router swap legs --- README.md | 19 +- .../plans/2026-08-03-user-directed-router.md | 22 +- .../2026-08-03-user-directed-router-design.md | 92 ++- script/deploy/DeployRouter.s.sol | 2 +- src/Router.sol | 86 ++- .../ILiquidLaneAdapterAuthorization.sol | 10 + src/interfaces/IRouter.sol | 16 + test/Router.t.sol | 575 +++++++++++++++++- 8 files changed, 748 insertions(+), 74 deletions(-) create mode 100644 src/interfaces/ILiquidLaneAdapterAuthorization.sol diff --git a/README.md b/README.md index 6b41608..54e2321 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,21 @@ This directory contains the core RFQ settlement contracts used by the Symbiotic 1. The user approves the input ERC-20 to `Router` using an ordinary ERC-20 allowance. 2. The user requests an unsigned transaction from the backend `/api/v1/swap` endpoint. -3. `Router.execute(tokenIn, calls, outputs, deadline)` transfers each leg directly from the user to its adapter, invokes the provided calldata, and pays the declared recipients. -4. Any transaction-local surplus is returned to the caller; balances that predate the call are never used for settlement. +3. For every leg, a current adapter owner, market maker, or authorized filler signs the Router-specific authorization described below. +4. `Router.execute(tokenIn, calls, outputs, deadline)` validates every authorization before funding, transfers each leg directly from the user to its adapter, invokes the provided calldata, and pays the declared recipients. +5. Any transaction-local surplus is returned to the caller; balances that predate the call are never used for settlement. -The Router supports standard ERC-20 tokens and distinct input/output tokens only. Every adapter must be registered by `LIQUID_LANE_ADAPTER_FACTORY`, and calldata must use either the signed-swap selector `0x9a4568b6` or discount-swap selector `0x8fa5c671`. The batch is atomic: a failed leg or unmet output reverts every transfer. +The Router supports standard ERC-20 tokens and distinct input/output tokens only. Every adapter must be registered by `LIQUID_LANE_ADAPTER_FACTORY`, and calldata must use either the signed-swap selector `0x9a4568b6` or discount-swap selector `0x8fa5c671`. The batch is atomic: a failed authorization, leg, or unmet output reverts every transfer. + +Each `SwapCall` has the ABI tuple order `(adapter, amountIn, data, authSigner, authDeadline, authSignature)`. The signature uses EIP-712 domain name `Router`, version `1`, the active chain ID, and the deployed Router as verifying contract. Its exact primary type is: + +```text +SwapAuthorization(address swapper,address authSigner,address tokenIn,address adapter,uint256 amountIn,bytes32 dataHash,uint256 executionDeadline,uint256 authorizationDeadline) +``` + +`dataHash` is `keccak256(data)`. `swapper` is the transaction caller, `tokenIn` is the top-level input token, and `authSigner` is the signer supplied in the call. `executionDeadline` is the deadline-overload argument, or zero for the no-deadline overload. `authorizationDeadline` is `authDeadline`; it must be nonzero and not expired. The Router checks that `authSigner` is currently the adapter owner or market maker, or is currently authorized through `isFiller(marketMaker, authSigner)`, then verifies `authSignature` with OpenZeppelin `SignatureChecker` so EOA and ERC-1271 signers are supported. + +The Router does not add replay storage. The permitted LiquidLane signed-swap and discount-swap calls consume their own adapter nonces, which remain the authoritative replay protection. ## Test locally @@ -42,6 +53,7 @@ This folder is part of the root Foundry workspace, so run commands from the repo ```bash forge build forge test --match-path rfq/reactor/test/Reactor.t.sol +forge test --match-path rfq/reactor/test/Router.t.sol ``` ## Deploy @@ -89,3 +101,4 @@ forge script rfq/reactor/script/deploy/DeployRouter.s.sol:DeployRouterScript \ - `Executor` is role-gated through `CALLER_ROLE`. - `Reactor` uses Permit2 witness transfers and the instant redemption adapter as its execution primitives. +- `Router` uses ordinary ERC-20 allowance plus a current adapter-authorized EIP-712 signature for every leg. diff --git a/docs/superpowers/plans/2026-08-03-user-directed-router.md b/docs/superpowers/plans/2026-08-03-user-directed-router.md index 1d748fb..5c873ea 100644 --- a/docs/superpowers/plans/2026-08-03-user-directed-router.md +++ b/docs/superpowers/plans/2026-08-03-user-directed-router.md @@ -4,15 +4,18 @@ **Goal:** Add an ownerless `Router` that atomically funds registered LiquidLane adapters from the caller, executes signed or discounted swap calldata, and distributes transaction-local ERC-20 output deltas. -**Architecture:** `IRouter` fixes the typed batch ABI, allowed selectors, errors, and events. `Router` validates the entire batch, snapshots unique output-token balances, transfers each leg directly from `msg.sender` to its registered adapter, calls the adapter, verifies exact input consumption, enforces aggregate outputs, pays recipients, and returns surplus while preserving pre-existing balances. +**Architecture:** `IRouter` fixes the typed batch ABI, per-leg EIP-712 authorization, allowed selectors, errors, and events. `Router` validates the entire batch and every current adapter signer before funding, snapshots unique output-token balances, transfers each leg directly from `msg.sender` to its registered adapter, calls the adapter, verifies exact input consumption, enforces aggregate outputs, pays recipients, and returns surplus while preserving pre-existing balances. -**Tech Stack:** Solidity 0.8.28, Foundry, OpenZeppelin `SafeERC20` and `ReentrancyGuardTransient`, forge-std. +**Tech Stack:** Solidity 0.8.28, Foundry, OpenZeppelin `SafeERC20`, `EIP712`, `SignatureChecker`, and `ReentrancyGuard`, forge-std. ## Global Constraints - Contract name is exactly `Router` and it is deployed directly, without proxy, owner, roles, pause, rescue, or upgrade state. - Input authorization is ordinary ERC-20 allowance to Router; do not add Permit2 or EIP-2612. -- ABI field order is `SwapCall(adapter, amountIn, data)` and `Output(token, recipient, amount)`. +- ABI field order is `SwapCall(adapter, amountIn, data, authSigner, authDeadline, authSignature)` and `Output(token, recipient, amount)`. +- Every leg uses EIP-712 domain `Router` version `1` and the exact `SwapAuthorization(address swapper,address authSigner,address tokenIn,address adapter,uint256 amountIn,bytes32 dataHash,uint256 executionDeadline,uint256 authorizationDeadline)` primary type. +- Require a nonzero, unexpired `authDeadline`, current adapter owner/market-maker/filler authority, and a valid OpenZeppelin `SignatureChecker` result before funding any leg. +- Prevalidate the total input sum with checked arithmetic before adapter execution. Replay protection remains in the nonces consumed by the two allowed adapter selectors; do not add Router replay storage. - Expose both nonpayable overloads: `execute(tokenIn,calls,outputs)` and `execute(tokenIn,calls,outputs,deadline)`. - Allow only selector `0x9a4568b6` (signed swap) and `0x8fa5c671` (discount swap). - Validate every adapter through immutable `IRegistry(factory).isEntity(adapter)`. @@ -23,6 +26,8 @@ - Every failure reverts the complete batch. - Target branch is `origin/stage`; preserve existing Reactor/Executor behavior. +The authorization constraints above are the approved security amendment and supersede older task snippets below wherever they show the original three-field `SwapCall` or transient reentrancy guard. + --- ### Task 1: Pin the Router interface and structural validation @@ -89,7 +94,14 @@ Create `IRegistry.sol` with only the read method. Create `IRouter.sol` with: ```solidity interface IRouter { - struct SwapCall { address adapter; uint256 amountIn; bytes data; } + struct SwapCall { + address adapter; + uint256 amountIn; + bytes data; + address authSigner; + uint256 authDeadline; + bytes authSignature; + } struct Output { address token; address recipient; uint256 amount; } error AdapterCallFailed(uint256 index, address adapter, bytes reason); @@ -122,7 +134,7 @@ interface IRouter { - [ ] **Step 4: Implement constructor, overload routing, and full prevalidation** -Create `Router.sol` inheriting `IRouter, ReentrancyGuardTransient`. Constructor-reject a zero/non-contract factory. Route both overloads into `_execute`; the deadline overload checks `block.timestamp > deadline`. In `_validate`, require contract `tokenIn`, nonempty arrays, nonzero output/call amounts, output token code, `output.token != tokenIn`, nonzero recipient not Router, registered adapter, at least four calldata bytes, and one allowed selector: +Create `Router.sol` inheriting `IRouter, ReentrancyGuard`. Constructor-reject a zero/non-contract factory. Route both overloads into `_execute`; the deadline overload checks `block.timestamp > deadline`. In `_validate`, require contract `tokenIn`, nonempty arrays, nonzero output/call amounts, output token code, `output.token != tokenIn`, nonzero recipient not Router, registered adapter, at least four calldata bytes, and one allowed selector: ```solidity bytes4 internal constant SIGNED_SWAP_SELECTOR = 0x9a4568b6; diff --git a/docs/superpowers/specs/2026-08-03-user-directed-router-design.md b/docs/superpowers/specs/2026-08-03-user-directed-router-design.md index d26d37d..15bde63 100644 --- a/docs/superpowers/specs/2026-08-03-user-directed-router-design.md +++ b/docs/superpowers/specs/2026-08-03-user-directed-router-design.md @@ -1,13 +1,13 @@ # User-Directed Router Design **Date:** 2026-08-03 -**Status:** Approved design; implementation not started +**Status:** Implemented with the signer-authorization security amendment ## Summary -Add a standalone, non-upgradeable Solidity contract named `Router`. A swapper calls the Router directly after granting it an ordinary ERC-20 allowance. In one atomic transaction, the Router transfers each input leg directly from the swapper to a factory-registered LiquidLane adapter, invokes an authorized adapter swap selector with opaque calldata, verifies the output tokens received during this transaction, pays exact declared amounts to the declared recipients, and returns each declared token's surplus to the swapper. +Add a standalone, non-upgradeable Solidity contract named `Router`. A swapper calls the Router directly after granting it an ordinary ERC-20 allowance. In one atomic transaction, the Router first verifies a Router-specific EIP-712 authorization from a current adapter owner, market maker, or filler for every leg; transfers each input leg directly from the swapper to a factory-registered LiquidLane adapter; invokes an authorized adapter swap selector with opaque calldata; verifies the output tokens received during this transaction; pays exact declared amounts to the declared recipients; and returns each declared token's surplus to the swapper. -The Router is not a Reactor executor, does not validate RFQ orders, does not use Permit2, and does not retain user funds or approvals. Its security boundary is deliberately narrow: registered adapters, two permitted swap selectors, standard ERC-20 behavior, transaction-local balance deltas, and all-or-nothing execution. +The Router is not a Reactor executor, does not validate RFQ orders, does not use Permit2, and does not retain user funds or approvals. Its security boundary is deliberately narrow: registered adapters, current adapter-authorized signers, per-leg EIP-712 authorization, two permitted swap selectors, standard ERC-20 behavior, transaction-local balance deltas, and all-or-nothing execution. ## Branch and ABI Compatibility @@ -68,6 +68,9 @@ struct SwapCall { address adapter; uint256 amountIn; bytes data; + address authSigner; + uint256 authDeadline; + bytes authSignature; } struct Output { @@ -84,8 +87,11 @@ struct Output { | `adapter` | `address` | Factory-registered LiquidLane adapter that receives this leg's input and is called. | | `amountIn` | `uint256` | Exact amount of the common `tokenIn` transferred directly from `msg.sender` to `adapter` for this leg. Must be nonzero. | | `data` | `bytes` | Complete adapter calldata, including one permitted selector and all encoded quote data and signatures. | +| `authSigner` | `address` | Current adapter owner, market maker, or authorized filler that signs the Router authorization. | +| `authDeadline` | `uint256` | Nonzero, unexpired Router-authorization deadline. | +| `authSignature` | `bytes` | EIP-712 signature over the payer, signer, input token, complete leg, execution deadline, and authorization deadline. | -The Router treats all calldata after the first four selector bytes as opaque. It forwards the bytes unchanged and ignores successful return data. +The Router treats all calldata after the first four selector bytes as opaque. It forwards the bytes unchanged and ignores successful return data. `authSignature` is verified against EIP-712 domain `Router`, version `1`, the current chain ID, and this Router address. The exact primary type is `SwapAuthorization(address swapper,address authSigner,address tokenIn,address adapter,uint256 amountIn,bytes32 dataHash,uint256 executionDeadline,uint256 authorizationDeadline)`, where `dataHash = keccak256(data)`. `executionDeadline` is zero for the no-deadline overload and the exact top-level deadline otherwise. ### `Output` @@ -101,7 +107,7 @@ At least one `SwapCall` and one `Output` are required. A batch with no economic ## Adapter Trust and Call Validation -For every `SwapCall`, the Router performs all validation before making the adapter call: +For every `SwapCall`, the Router performs all validation and verifies every leg authorization before funding any leg or making an executable adapter call: 1. `adapter` is nonzero and `IRegistry(LIQUID_LANE_ADAPTER_FACTORY).isEntity(adapter)` returns true. 2. `amountIn` is nonzero. @@ -109,7 +115,10 @@ For every `SwapCall`, the Router performs all validation before making the adapt 4. The first four bytes are exactly one of the two permitted current-main selectors: - signed swap: `swap((address,address,uint256,uint256,address,address,uint256,uint48),bytes)`; - discount swap: `swap(((address,uint256,address,address,uint256,uint48),bytes,uint48),bytes,address,uint256)`. -5. No native value is attached to the adapter call. +5. `authDeadline` is nonzero and `block.timestamp <= authDeadline`. +6. `authSigner` is currently the adapter's `owner()` or `marketMaker()`, or `isFiller(marketMaker(), authSigner)` is true. +7. OpenZeppelin `SignatureChecker` accepts `authSignature` for the exact Router EIP-712 payload, supporting EOAs and ERC-1271 signers. +8. No native value is attached to the adapter call. All other selectors are rejected, including the unsigned direct-swap overload. The allowlist prevents a user from exercising adapter administration, nonce invalidation, acquisition, withdrawal, or fallback behavior under the Router's identity. @@ -128,7 +137,8 @@ Before transferring any token, the Router: - rejects empty `swapCalls` or `outputs`; - validates every output token, amount, and recipient; - validates every adapter, amount, calldata length, and selector; -- computes the sum of all leg inputs for the final event; and +- computes the sum of all leg inputs with checked arithmetic before adapter execution; +- validates every current adapter signer and Router authorization before funding the first leg; and - groups duplicate output tokens and sums their required amounts with checked arithmetic. Pre-validating the complete request avoids entering external execution with a structurally invalid later leg. @@ -184,27 +194,31 @@ Only after all final assertions pass does the Router emit its completion event. ## Core Invariants 1. **Registered targets only:** every external call target is a current entity of the immutable LiquidLane adapter factory. -2. **Two selectors only:** the Router can invoke only the current signed-swap and discount-swap entrypoints. -3. **No arbitrary execution:** the Router never calls a user-selected non-adapter target, never uses `delegatecall`, and never forwards native value. -4. **Caller-funded:** every leg pulls from `msg.sender`; no arbitrary payer field exists. -5. **Direct input routing:** input moves from the swapper directly to the adapter and never through the Router. -6. **Exact input per leg:** the adapter balance increases by exactly `amountIn` on funding and returns to the same baseline after execution. -7. **Router-directed outputs:** successful batches rely on the backend encoding the Router as adapter recipient; declared aggregate deltas must arrive at the Router. -8. **Aggregate minimums:** for each declared output token, transaction-local production is at least the sum of its output entries. -9. **Exact recipient receipts:** each recipient's balance increases by its declared amount. -10. **Surplus belongs to the swapper:** all transaction-local declared-token production beyond required outputs is transferred to `msg.sender`. -11. **Pre-existing balance isolation:** a call can neither spend nor withdraw balances present before that call. -12. **No successful custody:** after success, each declared token balance equals its pre-call snapshot. -13. **Atomicity:** no partial batch, partial output, stranded leg prefund, or allow-failure mode exists. -14. **Reentrancy exclusion:** neither token, adapter, nor recipient callbacks can enter either `execute` overload during execution. +2. **Current signer authority:** every leg is approved by its adapter's current owner, market maker, or authorized filler. +3. **Exact Router authorization:** each signature binds the Router domain and chain, caller, `authSigner`, top-level input token, adapter, amount, calldata hash, effective execution deadline, and nonzero authorization deadline. +4. **Two selectors only:** the Router can invoke only the current signed-swap and discount-swap entrypoints. +5. **No arbitrary execution:** the Router never calls a user-selected non-adapter target, never uses `delegatecall`, and never forwards native value. +6. **Caller-funded:** every leg pulls from `msg.sender`; no arbitrary payer field exists. +7. **Direct input routing:** input moves from the swapper directly to the adapter and never through the Router. +8. **Exact input per leg:** the adapter balance increases by exactly `amountIn` on funding and returns to the same baseline after execution. +9. **Router-directed outputs:** successful batches rely on the backend encoding the Router as adapter recipient; declared aggregate deltas must arrive at the Router. +10. **Aggregate minimums:** for each declared output token, transaction-local production is at least the sum of its output entries. +11. **Exact recipient receipts:** each recipient's balance increases by its declared amount. +12. **Surplus belongs to the swapper:** all transaction-local declared-token production beyond required outputs is transferred to `msg.sender`. +13. **Pre-existing balance isolation:** a call can neither spend nor withdraw balances present before that call. +14. **No successful custody:** after success, each declared token balance equals its pre-call snapshot. +15. **Atomicity:** no partial batch, partial output, stranded leg prefund, or allow-failure mode exists. +16. **Reentrancy exclusion:** neither token, adapter, nor recipient callbacks can enter either `execute` overload during execution. ## Reentrancy and External-Call Model -Both overloads share a single `nonReentrant` boundary, preferably the same transient-storage OpenZeppelin guard already used by current mainline Reactor under the Cancun EVM target. All validation and snapshots happen inside that boundary. +Both overloads share one OpenZeppelin `ReentrancyGuard` boundary. The stage compiler remains Solidity 0.8.28, so the Router deliberately does not use transient-storage reentrancy protection. All validation and snapshots happen inside that boundary. External interactions are limited to: - factory `isEntity` static calls; +- adapter `owner`, `marketMaker`, and `isFiller` static calls; +- optional ERC-1271 signature checks through OpenZeppelin `SignatureChecker`; - ERC-20 `balanceOf`, `transferFrom`, and `transfer` calls; and - zero-value calls to registered adapters with an allowed selector. @@ -226,6 +240,8 @@ The interface defines concise custom errors for these observable failure classes | `InsufficientOutput(token, required, produced)` | Aggregate transaction-local production is below the declared total. | | `InvalidAdapter(index, adapter)` | The target is zero or is not a factory entity. | | `InvalidAmount(index)` | A swap or output amount is zero. | +| `InvalidAuthorizationDeadline(index, deadline)` | A Router authorization has a zero or expired deadline. | +| `InvalidAuthorizationSignature(index, signer)` | The Router EIP-712 signature is invalid for the declared signer. | | `InvalidCalldata(index)` | Adapter calldata is shorter than one selector. | | `InvalidOutputToken(index, token)` | An output is native, zero, equal to `tokenIn`, or not an ERC-20 contract. | | `InvalidRecipient(index, recipient)` | A recipient is zero or the Router. | @@ -233,6 +249,7 @@ The interface defines concise custom errors for these observable failure classes | `InvalidTokenIn(token)` | `tokenIn` is zero or not a contract. | | `OutputTransferMismatch(index, expected, actual)` | A declared recipient did not receive exactly the requested amount. | | `SurplusTransferMismatch(token, expected, actual)` | The swapper did not receive the exact surplus. | +| `UnauthorizedAuthSigner(index, adapter, signer)` | The signer is not the adapter's current owner, market maker, or filler. | The reentrancy guard's standard custom error remains part of the observable surface. Arithmetic overflow uses Solidity's checked-arithmetic panic and is not remapped. @@ -262,7 +279,8 @@ V1 supports ordinary ERC-20 tokens whose balances change exactly by the requeste - The immutable factory correctly identifies authentic LiquidLane adapters. Factory compromise or registration of malicious adapters is outside the Router's local trust boundary. - Current LiquidLane signed-swap and discount-swap selectors retain their documented semantics. - The backend or solver encodes `recipient = Router`; signed swaps additionally encode `caller = Router`. Incorrect encoding normally fails aggregate output validation and reverts. -- The user authorizes the exact transaction calldata by submitting the transaction. There is no separate Router signature or relayer authorization in V1. +- The transaction caller authorizes the aggregate settlement by submitting the transaction, while every individual adapter leg also requires a current adapter-authorized Router EIP-712 signature. +- The Router has no authorization-ID or replay-storage mapping. Both permitted LiquidLane selectors consume adapter nonces, which remain authoritative for replay protection without unbounded Router storage. - Output protection is aggregate per token, not per leg. Cross-leg subsidy is accepted because the user receives the declared batch result. - Only tokens listed in `outputs` are snapshotted and distributed. Undeclared tokens sent to the Router remain isolated permanently; a later user cannot claim them as transaction-local surplus. - There is no rescue function. Recoverability of accidental or forced balances is intentionally sacrificed to keep the pre-existing-balance invariant unconditional and ownerless. @@ -300,6 +318,16 @@ Create focused Foundry tests in `test/Router.t.sol` with registry, adapter, toke - A failure on a later leg rolls back earlier adapter calls and transfers. - Adapter revert data is reported with the correct index and target. +### Router authorization + +- The exact `Router`/`1` EIP-712 domain and primary type hash are pinned. +- Owner, market-maker, filler, EOA, and ERC-1271 authorizations succeed. +- A zero or expired authorization deadline fails before funding; deadline equality succeeds. +- Copying a signed call to another payer or modifying the token, adapter calldata, amount, execution deadline, authorization deadline, signer, or signature fails before funding. +- Revoking owner, market-maker, or filler authority before execution makes the signer unauthorized. +- A malformed later authorization is rejected before the first adapter executes. +- Input-total overflow is rejected before any adapter execution. + ### Output accounting - One token/one recipient settles exactly. @@ -344,6 +372,7 @@ Add: - `src/Router.sol`; - `src/interfaces/IRouter.sol`; +- `src/interfaces/ILiquidLaneAdapterAuthorization.sol`; - the minimal `src/interfaces/IRegistry.sol` when implementing from the old stage base; - `test/Router.t.sol`; - `script/deploy/DeployRouter.s.sol`; and @@ -357,18 +386,19 @@ After deployment, backend and solver configuration must use the deployed Router - `SignedSwap.recipient`; and - the discount swap's explicit `recipient` argument. -No adapter filler authorization is needed because V1 rejects the unsigned direct-swap selector. Users approve the input ERC-20 to Router and submit the Router transaction themselves. +Every solver-produced leg also needs the current adapter-authorized Router signature described above. Users approve the input ERC-20 to Router and submit the Router transaction themselves. ## Acceptance Criteria The feature is complete when: 1. Both typed overloads implement the same atomic execution path and the deadline overload expires exactly as specified. -2. Every leg targets a factory entity and one of exactly two pinned selectors. -3. Every input leg is transferred directly from the caller, received exactly, and consumed exactly. -4. No declared output minimum can be satisfied by a pre-existing Router balance. -5. Every declared recipient receives exactly its amount, every declared-token surplus goes to the caller, and the Router returns to each declared token's starting balance. -6. Native currency and unsupported token behavior cannot silently participate in a successful batch. -7. Reentrancy, later-leg failure, adapter failure, and payout failure roll back the entire transaction. -8. The contract is ownerless, non-upgradeable, nonpayable, and has no rescue or arbitrary-call surface. -9. Unit, selector-shape, integration, deployment, formatting, size, and gas-snapshot checks pass under the repository's Foundry workflow. +2. Every leg targets a factory entity, uses one of exactly two pinned selectors, and has a valid unexpired authorization from its adapter's current owner, market maker, or filler. +3. The authorization binds the caller, signer, input token, adapter, amount, calldata hash, effective execution deadline, and authorization deadline before any leg is funded. +4. Every input leg is transferred directly from the caller, received exactly, and consumed exactly. +5. No declared output minimum can be satisfied by a pre-existing Router balance. +6. Every declared recipient receives exactly its amount, every declared-token surplus goes to the caller, and the Router returns to each declared token's starting balance. +7. Native currency and unsupported token behavior cannot silently participate in a successful batch. +8. Reentrancy, later-leg failure, adapter failure, and payout failure roll back the entire transaction. +9. The contract is ownerless, non-upgradeable, nonpayable, and has no rescue or arbitrary-call surface. +10. Unit, selector-shape, integration, deployment, formatting, size, and gas-snapshot checks pass under the repository's Foundry workflow. diff --git a/script/deploy/DeployRouter.s.sol b/script/deploy/DeployRouter.s.sol index 4cfa096..dded3f0 100644 --- a/script/deploy/DeployRouter.s.sol +++ b/script/deploy/DeployRouter.s.sol @@ -5,7 +5,7 @@ import {Script, console2} from "forge-std/Script.sol"; import {Router} from "../../src/Router.sol"; -/// @notice Deploys the ownerless user-directed Router. +/// @notice Deploys the ownerless, EIP-712-authenticated user-directed Router. contract DeployRouterScript is Script { function run() public returns (Router router) { address liquidLaneAdapterFactory = vm.envAddress("LIQUID_LANE_ADAPTER_FACTORY"); diff --git a/src/Router.sol b/src/Router.sol index 235055c..89bf362 100644 --- a/src/Router.sol +++ b/src/Router.sol @@ -2,25 +2,31 @@ // Copyright (c) 2026 Symbiotic pragma solidity 0.8.28; +import {ILiquidLaneAdapterAuthorization} from "./interfaces/ILiquidLaneAdapterAuthorization.sol"; import {IRegistry} from "./interfaces/IRegistry.sol"; import {IRouter} from "./interfaces/IRouter.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; /// @title Router /// @notice Atomically funds registered adapters and settles transaction-local output balances. /// @custom:security-contact security@symbiotic.fi -contract Router is IRouter, ReentrancyGuard { +contract Router is IRouter, EIP712, ReentrancyGuard { using SafeERC20 for IERC20; bytes4 internal constant SIGNED_SWAP_SELECTOR = 0x9a4568b6; bytes4 internal constant DISCOUNT_SWAP_SELECTOR = 0x8fa5c671; + bytes32 public constant SWAP_AUTHORIZATION_TYPEHASH = keccak256( + "SwapAuthorization(address swapper,address authSigner,address tokenIn,address adapter,uint256 amountIn,bytes32 dataHash,uint256 executionDeadline,uint256 authorizationDeadline)" + ); address public immutable LIQUID_LANE_ADAPTER_FACTORY; - constructor(address liquidLaneAdapterFactory) { + constructor(address liquidLaneAdapterFactory) EIP712("Router", "1") { if (liquidLaneAdapterFactory == address(0) || liquidLaneAdapterFactory.code.length == 0) { revert InvalidFactory(liquidLaneAdapterFactory); } @@ -29,7 +35,7 @@ contract Router is IRouter, ReentrancyGuard { /// @inheritdoc IRouter function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) external nonReentrant { - _execute(tokenIn, calls, outputs); + _execute(tokenIn, calls, outputs, 0); } /// @inheritdoc IRouter @@ -37,16 +43,19 @@ contract Router is IRouter, ReentrancyGuard { external nonReentrant { + // forge-lint: disable-next-line(block-timestamp) if (block.timestamp > deadline) revert Expired(deadline); - _execute(tokenIn, calls, outputs); + _execute(tokenIn, calls, outputs, deadline); } - function _execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) internal { - _validate(tokenIn, calls, outputs); + function _execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs, uint256 executionDeadline) + internal + { + uint256 totalAmountIn = _validate(tokenIn, calls, outputs, executionDeadline); (address[] memory tokens, uint256[] memory baselines, uint256[] memory required, uint256 uniqueCount) = _snapshotOutputs(outputs); - uint256 totalAmountIn = _executeCalls(tokenIn, calls); + _executeCalls(tokenIn, calls); uint256[] memory produced = _measureOutputs(tokens, baselines, required, uniqueCount); _transferOutputs(outputs); @@ -55,7 +64,7 @@ contract Router is IRouter, ReentrancyGuard { emit Execute(msg.sender, tokenIn, totalAmountIn, calls.length, outputs.length); } - function _executeCalls(address tokenIn, SwapCall[] calldata calls) internal returns (uint256 totalAmountIn) { + function _executeCalls(address tokenIn, SwapCall[] calldata calls) internal { IERC20 inputToken = IERC20(tokenIn); for (uint256 i; i < calls.length; ++i) { SwapCall calldata swapCall = calls[i]; @@ -83,8 +92,6 @@ contract Router is IRouter, ReentrancyGuard { if (remaining != adapterBaseline) { revert InputConsumptionMismatch(i, adapterBaseline, remaining); } - - totalAmountIn += swapCall.amountIn; } } @@ -190,7 +197,11 @@ contract Router is IRouter, ReentrancyGuard { } } - function _validate(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) internal view { + function _validate(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs, uint256 executionDeadline) + internal + view + returns (uint256 totalAmountIn) + { if (tokenIn == address(0) || tokenIn.code.length == 0) revert InvalidTokenIn(tokenIn); if (calls.length == 0) revert EmptySwapCalls(); if (outputs.length == 0) revert EmptyOutputs(); @@ -206,16 +217,13 @@ contract Router is IRouter, ReentrancyGuard { if (output.amount == 0) revert InvalidAmount(i); } - IRegistry registry = IRegistry(LIQUID_LANE_ADAPTER_FACTORY); for (uint256 i; i < calls.length; ++i) { SwapCall calldata swapCall = calls[i]; - if ( - swapCall.adapter == address(0) || swapCall.adapter.code.length == 0 - || !registry.isEntity(swapCall.adapter) - ) { + if (swapCall.adapter == address(0) || swapCall.adapter.code.length == 0) { revert InvalidAdapter(i, swapCall.adapter); } if (swapCall.amountIn == 0) revert InvalidAmount(i); + totalAmountIn += swapCall.amountIn; if (swapCall.data.length < 4) revert InvalidCalldata(i); bytes4 selector = _selector(swapCall.data); @@ -223,6 +231,52 @@ contract Router is IRouter, ReentrancyGuard { revert InvalidSelector(i, selector); } } + + IRegistry registry = IRegistry(LIQUID_LANE_ADAPTER_FACTORY); + for (uint256 i; i < calls.length; ++i) { + SwapCall calldata swapCall = calls[i]; + if (!registry.isEntity(swapCall.adapter)) revert InvalidAdapter(i, swapCall.adapter); + _validateAuthorization(tokenIn, swapCall, i, executionDeadline); + } + } + + function _validateAuthorization( + address tokenIn, + SwapCall calldata swapCall, + uint256 index, + uint256 executionDeadline + ) internal view { + // forge-lint: disable-next-line(block-timestamp) + if (swapCall.authDeadline == 0 || block.timestamp > swapCall.authDeadline) { + revert InvalidAuthorizationDeadline(index, swapCall.authDeadline); + } + + ILiquidLaneAdapterAuthorization adapter = ILiquidLaneAdapterAuthorization(swapCall.adapter); + address signer = swapCall.authSigner; + if (signer != adapter.owner()) { + address marketMaker = adapter.marketMaker(); + if (signer != marketMaker && !adapter.isFiller(marketMaker, signer)) { + revert UnauthorizedAuthSigner(index, swapCall.adapter, signer); + } + } + + bytes32 structHash = keccak256( + abi.encode( + SWAP_AUTHORIZATION_TYPEHASH, + msg.sender, + signer, + tokenIn, + swapCall.adapter, + swapCall.amountIn, + keccak256(swapCall.data), + executionDeadline, + swapCall.authDeadline + ) + ); + if (!SignatureChecker.isValidSignatureNowCalldata(signer, _hashTypedDataV4(structHash), swapCall.authSignature)) + { + revert InvalidAuthorizationSignature(index, signer); + } } function _selector(bytes calldata data) internal pure returns (bytes4 selector) { diff --git a/src/interfaces/ILiquidLaneAdapterAuthorization.sol b/src/interfaces/ILiquidLaneAdapterAuthorization.sol new file mode 100644 index 0000000..c351322 --- /dev/null +++ b/src/interfaces/ILiquidLaneAdapterAuthorization.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Symbiotic +pragma solidity ^0.8.0; + +/// @notice Minimal LiquidLane adapter interface used to validate current swap signers. +interface ILiquidLaneAdapterAuthorization { + function owner() external view returns (address); + function marketMaker() external view returns (address); + function isFiller(address marketMaker, address filler) external view returns (bool); +} diff --git a/src/interfaces/IRouter.sol b/src/interfaces/IRouter.sol index b67021a..fe5f1b6 100644 --- a/src/interfaces/IRouter.sol +++ b/src/interfaces/IRouter.sol @@ -4,12 +4,23 @@ pragma solidity ^0.8.0; /// @notice User-directed batch execution interface for registered LiquidLane adapters. interface IRouter { + /// @notice One authenticated adapter leg. + /// @param adapter Factory-registered LiquidLane adapter that receives the input and executes `data`. + /// @param amountIn Exact common input-token amount funded directly from the caller. + /// @param data Complete signed-swap or discount-swap adapter calldata. + /// @param authSigner Current adapter owner, market maker, or authorized filler that approved this Router leg. + /// @param authDeadline Nonzero Router-authorization expiry included in the signed payload. + /// @param authSignature EIP-712 signature over this leg, its payer, token, and effective execution deadline. struct SwapCall { address adapter; uint256 amountIn; bytes data; + address authSigner; + uint256 authDeadline; + bytes authSignature; } + /// @notice Exact output payment made after the batch meets its aggregate minimums. struct Output { address token; address recipient; @@ -26,6 +37,8 @@ interface IRouter { error InsufficientOutput(address token, uint256 required, uint256 produced); error InvalidAdapter(uint256 index, address adapter); error InvalidAmount(uint256 index); + error InvalidAuthorizationDeadline(uint256 index, uint256 deadline); + error InvalidAuthorizationSignature(uint256 index, address signer); error InvalidCalldata(uint256 index); error InvalidFactory(address factory); error InvalidOutputToken(uint256 index, address token); @@ -34,6 +47,7 @@ interface IRouter { error InvalidTokenIn(address token); error OutputTransferMismatch(uint256 index, uint256 expected, uint256 actual); error SurplusTransferMismatch(address token, uint256 expected, uint256 actual); + error UnauthorizedAuthSigner(uint256 index, address adapter, address signer); event OutputTransferred(address indexed token, address indexed recipient, uint256 amount); event SurplusTransferred(address indexed token, address indexed swapper, uint256 amount); @@ -46,6 +60,8 @@ interface IRouter { ); function LIQUID_LANE_ADAPTER_FACTORY() external view returns (address); + /// @notice Exact primary type hash for the Router `SwapAuthorization` EIP-712 payload. + function SWAP_AUTHORIZATION_TYPEHASH() external view returns (bytes32); function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) external; function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs, uint256 deadline) external; } diff --git a/test/Router.t.sol b/test/Router.t.sol index 1284eab..88860e7 100644 --- a/test/Router.t.sol +++ b/test/Router.t.sol @@ -4,6 +4,9 @@ pragma solidity 0.8.28; import {Test} from "forge-std/Test.sol"; +import {IERC5267} from "@openzeppelin/contracts/interfaces/IERC5267.sol"; +import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; + import {Router} from "../src/Router.sol"; import {IRouter} from "../src/interfaces/IRouter.sol"; import {DeployRouterScript} from "../script/deploy/DeployRouter.s.sol"; @@ -16,6 +19,23 @@ contract MockRegistry { } } +contract Mock1271Signer { + bytes32 public expectedDigest; + bytes32 public expectedSignatureHash; + + function setExpected(bytes32 digest, bytes calldata signature) external { + expectedDigest = digest; + expectedSignatureHash = keccak256(signature); + } + + function isValidSignature(bytes32 digest, bytes calldata signature) external view returns (bytes4) { + if (digest == expectedDigest && keccak256(signature) == expectedSignatureHash) { + return IERC1271.isValidSignature.selector; + } + return 0xffffffff; + } +} + contract MockERC20 { string public name; string public symbol; @@ -81,6 +101,9 @@ contract MockAdapter { MockERC20 public immutable inputToken; MockERC20 public immutable outputToken; address public immutable router; + address public owner; + address public marketMaker; + mapping(address maker => mapping(address filler => bool authorized)) public isFiller; uint256 public outputAmount; uint256 public leaveInput; uint256 public callCount; @@ -99,6 +122,18 @@ contract MockAdapter { leaveInput = leaveInput_; } + function setOwner(address owner_) external { + owner = owner_; + } + + function setMarketMaker(address marketMaker_) external { + marketMaker = marketMaker_; + } + + function setFiller(address maker, address filler, bool authorized) external { + isFiller[maker][filler] = authorized; + } + function setShouldRevert(bool status) external { shouldRevert = status; } @@ -132,9 +167,15 @@ contract MockAdapter { contract RouterTest is Test { bytes4 internal constant SIGNED_SWAP_SELECTOR = 0x9a4568b6; bytes4 internal constant DISCOUNT_SWAP_SELECTOR = 0x8fa5c671; + bytes32 internal constant DOMAIN_TYPEHASH = 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f; + bytes32 internal constant SWAP_AUTHORIZATION_TYPEHASH = + 0xc1a9681894ce21cd15802373fbf380e6fb5ea302fce47e912d119686bb4eb349; + uint256 internal constant AUTH_SIGNER_PRIVATE_KEY = 0xA11CE; + uint256 internal constant AUTHORIZATION_DEADLINE = type(uint256).max; address internal swapper = makeAddr("swapper"); address internal recipient = makeAddr("recipient"); + address internal authSigner; MockRegistry internal registry; MockERC20 internal inputToken; MockERC20 internal outputToken; @@ -144,6 +185,7 @@ contract RouterTest is Test { MockAdapter internal adapter1; function setUp() public { + authSigner = vm.addr(AUTH_SIGNER_PRIVATE_KEY); registry = new MockRegistry(); router = new Router(address(registry)); inputToken = new MockERC20("Input", "IN"); @@ -153,7 +195,9 @@ contract RouterTest is Test { adapter1 = new MockAdapter(inputToken, outputToken, address(router)); registry.setEntity(address(adapter0), true); registry.setEntity(address(adapter1), true); - inputToken.mint(swapper, 1_000 ether); + adapter0.setOwner(authSigner); + adapter1.setOwner(authSigner); + inputToken.mint(swapper, 1000 ether); vm.prank(swapper); inputToken.approve(address(router), type(uint256).max); } @@ -282,7 +326,16 @@ contract RouterTest is Test { function testRejectsShortCalldata() public { IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](1); - calls[0] = IRouter.SwapCall({adapter: address(adapter0), amountIn: 1 ether, data: hex"9a4568"}); + calls[0] = _signedCall( + swapper, + address(inputToken), + address(adapter0), + 1 ether, + hex"9a4568", + 0, + AUTHORIZATION_DEADLINE, + AUTH_SIGNER_PRIVATE_KEY + ); vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidCalldata.selector, 0)); router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); } @@ -307,16 +360,425 @@ contract RouterTest is Test { assertEq(outputToken.balanceOf(recipient), 1 ether); } + function testAcceptsAuthorizationFromAdapterMarketMaker() public { + adapter0.setOwner(makeAddr("otherOwner")); + adapter0.setMarketMaker(authSigner); + adapter0.configure(1 ether, 0); + + vm.prank(swapper); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 1 ether, recipient) + ); + + assertEq(outputToken.balanceOf(recipient), 1 ether); + } + + function testAcceptsAuthorizationFromAdapterFiller() public { + address marketMaker = makeAddr("marketMaker"); + adapter0.setOwner(makeAddr("otherOwner")); + adapter0.setMarketMaker(marketMaker); + adapter0.setFiller(marketMaker, authSigner, true); + adapter0.configure(1 ether, 0); + + vm.prank(swapper); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), + _oneOutput(address(outputToken), 1 ether, recipient) + ); + + assertEq(outputToken.balanceOf(recipient), 1 ether); + } + + function testAcceptsAuthorizationDeadlineEquality() public { + vm.warp(100); + IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](1); + calls[0] = _signedCall( + swapper, + address(inputToken), + address(adapter0), + 1 ether, + abi.encodePacked(SIGNED_SWAP_SELECTOR), + 100, + 100, + AUTH_SIGNER_PRIVATE_KEY + ); + adapter0.configure(1 ether, 0); + + vm.prank(swapper); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient), 100); + + assertEq(outputToken.balanceOf(recipient), 1 ether); + } + + function testAcceptsErc1271AdapterOwnerAuthorization() public { + Mock1271Signer contractSigner = new Mock1271Signer(); + bytes memory data = abi.encodePacked(SIGNED_SWAP_SELECTOR); + bytes memory signature = hex"cafe"; + bytes32 digest = _authorizationDigest( + swapper, + address(contractSigner), + address(inputToken), + address(adapter0), + 1 ether, + data, + 0, + AUTHORIZATION_DEADLINE + ); + contractSigner.setExpected(digest, signature); + adapter0.setOwner(address(contractSigner)); + adapter0.configure(1 ether, 0); + + IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](1); + calls[0] = IRouter.SwapCall({ + adapter: address(adapter0), + amountIn: 1 ether, + data: data, + authSigner: address(contractSigner), + authDeadline: AUTHORIZATION_DEADLINE, + authSignature: signature + }); + + vm.prank(swapper); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + + assertEq(outputToken.balanceOf(recipient), 1 ether); + } + + function testUsesExactRouterEip712Domain() public view { + ( + bytes1 fields, + string memory name, + string memory version, + uint256 chainId, + address verifyingContract, + bytes32 salt, + uint256[] memory extensions + ) = IERC5267(address(router)).eip712Domain(); + + assertEq(fields, hex"0f"); + assertEq(name, "Router"); + assertEq(version, "1"); + assertEq(chainId, block.chainid); + assertEq(verifyingContract, address(router)); + assertEq(salt, bytes32(0)); + assertEq(extensions.length, 0); + assertEq(router.SWAP_AUTHORIZATION_TYPEHASH(), SWAP_AUTHORIZATION_TYPEHASH); + } + + function testCopiedAuthorizationCannotUseAttackerAsPayer() public { + address attacker = makeAddr("attacker"); + inputToken.mint(attacker, 1 ether); + vm.prank(attacker); + inputToken.approve(address(router), 1 ether); + IRouter.SwapCall[] memory calls = _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR); + adapter0.setShouldRevert(true); + + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAuthorizationSignature.selector, 0, calls[0].authSigner)); + vm.prank(attacker); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + + assertEq(inputToken.balanceOf(attacker), 1 ether); + assertEq(inputToken.balanceOf(address(adapter0)), 0); + assertEq(adapter0.callCount(), 0); + } + + function testModifiedCalldataInvalidatesAuthorizationBeforeFunding() public { + IRouter.SwapCall[] memory calls = _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR); + calls[0].data = bytes.concat(calls[0].data, hex"01"); + adapter0.setShouldRevert(true); + + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAuthorizationSignature.selector, 0, calls[0].authSigner)); + vm.prank(swapper); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + + assertEq(inputToken.balanceOf(swapper), 1000 ether); + assertEq(inputToken.balanceOf(address(adapter0)), 0); + assertEq(adapter0.callCount(), 0); + } + + function testModifiedAmountInvalidatesAuthorizationBeforeFunding() public { + IRouter.SwapCall[] memory calls = _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR); + calls[0].amountIn = 2 ether; + adapter0.setShouldRevert(true); + + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAuthorizationSignature.selector, 0, calls[0].authSigner)); + vm.prank(swapper); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + + assertEq(inputToken.balanceOf(swapper), 1000 ether); + assertEq(inputToken.balanceOf(address(adapter0)), 0); + assertEq(adapter0.callCount(), 0); + } + + function testModifiedExecutionDeadlineInvalidatesAuthorizationBeforeFunding() public { + vm.warp(100); + IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](1); + calls[0] = _signedCall( + swapper, + address(inputToken), + address(adapter0), + 1 ether, + abi.encodePacked(SIGNED_SWAP_SELECTOR), + 200, + 300, + AUTH_SIGNER_PRIVATE_KEY + ); + adapter0.setShouldRevert(true); + + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAuthorizationSignature.selector, 0, calls[0].authSigner)); + vm.prank(swapper); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient), 201); + + assertEq(inputToken.balanceOf(swapper), 1000 ether); + assertEq(inputToken.balanceOf(address(adapter0)), 0); + assertEq(adapter0.callCount(), 0); + } + + function testModifiedAuthorizationDeadlineInvalidatesAuthorizationBeforeFunding() public { + vm.warp(100); + IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](1); + calls[0] = _signedCall( + swapper, + address(inputToken), + address(adapter0), + 1 ether, + abi.encodePacked(SIGNED_SWAP_SELECTOR), + 0, + 300, + AUTH_SIGNER_PRIVATE_KEY + ); + calls[0].authDeadline = 301; + adapter0.setShouldRevert(true); + + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAuthorizationSignature.selector, 0, calls[0].authSigner)); + vm.prank(swapper); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + + assertEq(inputToken.balanceOf(swapper), 1000 ether); + assertEq(inputToken.balanceOf(address(adapter0)), 0); + assertEq(adapter0.callCount(), 0); + } + + function testModifiedTokenInInvalidatesAuthorizationBeforeFunding() public { + IRouter.SwapCall[] memory calls = _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR); + adapter0.setShouldRevert(true); + + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAuthorizationSignature.selector, 0, calls[0].authSigner)); + vm.prank(swapper); + router.execute(address(outputToken), calls, _oneOutput(address(secondOutputToken), 1 ether, recipient)); + + assertEq(outputToken.balanceOf(swapper), 0); + assertEq(outputToken.balanceOf(address(adapter0)), 0); + assertEq(adapter0.callCount(), 0); + } + + function testModifiedSignatureFailsBeforeFunding() public { + IRouter.SwapCall[] memory calls = _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR); + calls[0].authSignature[0] = bytes1(uint8(calls[0].authSignature[0]) ^ 1); + adapter0.setShouldRevert(true); + + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAuthorizationSignature.selector, 0, calls[0].authSigner)); + vm.prank(swapper); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + + assertEq(inputToken.balanceOf(swapper), 1000 ether); + assertEq(inputToken.balanceOf(address(adapter0)), 0); + assertEq(adapter0.callCount(), 0); + } + + function testRejectsZeroAuthorizationDeadlineBeforeFunding() public { + IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](1); + calls[0] = _signedCall( + swapper, + address(inputToken), + address(adapter0), + 1 ether, + abi.encodePacked(SIGNED_SWAP_SELECTOR), + 0, + 0, + AUTH_SIGNER_PRIVATE_KEY + ); + adapter0.setShouldRevert(true); + + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAuthorizationDeadline.selector, 0, 0)); + vm.prank(swapper); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + + assertEq(inputToken.balanceOf(swapper), 1000 ether); + assertEq(inputToken.balanceOf(address(adapter0)), 0); + assertEq(adapter0.callCount(), 0); + } + + function testRejectsExpiredAuthorizationDeadlineBeforeFunding() public { + vm.warp(101); + IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](1); + calls[0] = _signedCall( + swapper, + address(inputToken), + address(adapter0), + 1 ether, + abi.encodePacked(SIGNED_SWAP_SELECTOR), + 0, + 100, + AUTH_SIGNER_PRIVATE_KEY + ); + adapter0.setShouldRevert(true); + + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAuthorizationDeadline.selector, 0, 100)); + vm.prank(swapper); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + + assertEq(inputToken.balanceOf(swapper), 1000 ether); + assertEq(inputToken.balanceOf(address(adapter0)), 0); + assertEq(adapter0.callCount(), 0); + } + + function testRejectsUnauthorizedAuthSignerBeforeFunding() public { + uint256 unauthorizedPrivateKey = 0xB0B; + IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](1); + calls[0] = _signedCall( + swapper, + address(inputToken), + address(adapter0), + 1 ether, + abi.encodePacked(SIGNED_SWAP_SELECTOR), + 0, + AUTHORIZATION_DEADLINE, + unauthorizedPrivateKey + ); + adapter0.setShouldRevert(true); + + vm.expectRevert( + abi.encodeWithSelector( + IRouter.UnauthorizedAuthSigner.selector, 0, address(adapter0), vm.addr(unauthorizedPrivateKey) + ) + ); + vm.prank(swapper); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + + assertEq(inputToken.balanceOf(swapper), 1000 ether); + assertEq(inputToken.balanceOf(address(adapter0)), 0); + assertEq(adapter0.callCount(), 0); + } + + function testRejectsRevokedAuthSignerBeforeFunding() public { + IRouter.SwapCall[] memory calls = _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR); + adapter0.setOwner(makeAddr("newOwner")); + adapter0.setShouldRevert(true); + + vm.expectRevert( + abi.encodeWithSelector(IRouter.UnauthorizedAuthSigner.selector, 0, address(adapter0), authSigner) + ); + vm.prank(swapper); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + + assertEq(inputToken.balanceOf(swapper), 1000 ether); + assertEq(inputToken.balanceOf(address(adapter0)), 0); + assertEq(adapter0.callCount(), 0); + } + + function testValidatesEveryAuthorizationBeforeFirstAdapterExecution() public { + uint256 unauthorizedPrivateKey = 0xB0B; + IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](2); + calls[0] = _signedCall( + swapper, + address(inputToken), + address(adapter0), + 1 ether, + abi.encodePacked(SIGNED_SWAP_SELECTOR), + 0, + AUTHORIZATION_DEADLINE, + AUTH_SIGNER_PRIVATE_KEY + ); + calls[1] = _signedCall( + swapper, + address(inputToken), + address(adapter1), + 1 ether, + abi.encodePacked(SIGNED_SWAP_SELECTOR), + 0, + AUTHORIZATION_DEADLINE, + unauthorizedPrivateKey + ); + adapter0.setShouldRevert(true); + + vm.expectRevert( + abi.encodeWithSelector( + IRouter.UnauthorizedAuthSigner.selector, 1, address(adapter1), vm.addr(unauthorizedPrivateKey) + ) + ); + vm.prank(swapper); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + + assertEq(inputToken.balanceOf(swapper), 1000 ether); + assertEq(inputToken.balanceOf(address(adapter0)), 0); + assertEq(inputToken.balanceOf(address(adapter1)), 0); + assertEq(adapter0.callCount(), 0); + assertEq(adapter1.callCount(), 0); + } + + function testInputTotalOverflowRevertsBeforeAdapterExecution() public { + inputToken.burn(swapper, 1000 ether); + inputToken.mint(swapper, type(uint256).max); + IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](2); + calls[0] = _signedCall( + swapper, + address(inputToken), + address(adapter0), + type(uint256).max, + abi.encodePacked(SIGNED_SWAP_SELECTOR), + 0, + AUTHORIZATION_DEADLINE, + AUTH_SIGNER_PRIVATE_KEY + ); + calls[1] = _signedCall( + swapper, + address(inputToken), + address(adapter1), + 1, + abi.encodePacked(SIGNED_SWAP_SELECTOR), + 0, + AUTHORIZATION_DEADLINE, + AUTH_SIGNER_PRIVATE_KEY + ); + adapter0.setShouldRevert(true); + + vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); + vm.prank(swapper); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1, recipient)); + + assertEq(inputToken.balanceOf(swapper), type(uint256).max); + assertEq(inputToken.balanceOf(address(adapter0)), 0); + assertEq(adapter0.callCount(), 0); + } + function testTransfersEachInputDirectlyAndCallsAllAdapters() public { adapter0.configure(4 ether, 0); adapter1.configure(6 ether, 0); IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](2); - calls[0] = IRouter.SwapCall({ - adapter: address(adapter0), amountIn: 4 ether, data: abi.encodePacked(SIGNED_SWAP_SELECTOR) - }); - calls[1] = IRouter.SwapCall({ - adapter: address(adapter1), amountIn: 6 ether, data: abi.encodePacked(DISCOUNT_SWAP_SELECTOR) - }); + calls[0] = _signedCall( + swapper, + address(inputToken), + address(adapter0), + 4 ether, + abi.encodePacked(SIGNED_SWAP_SELECTOR), + 0, + AUTHORIZATION_DEADLINE, + AUTH_SIGNER_PRIVATE_KEY + ); + calls[1] = _signedCall( + swapper, + address(inputToken), + address(adapter1), + 6 ether, + abi.encodePacked(DISCOUNT_SWAP_SELECTOR), + 0, + AUTHORIZATION_DEADLINE, + AUTH_SIGNER_PRIVATE_KEY + ); vm.prank(swapper); router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 10 ether, recipient)); @@ -393,18 +855,32 @@ contract RouterTest is Test { adapter0.configure(4 ether, 0); adapter1.setShouldRevert(true); IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](2); - calls[0] = IRouter.SwapCall({ - adapter: address(adapter0), amountIn: 4 ether, data: abi.encodePacked(SIGNED_SWAP_SELECTOR) - }); - calls[1] = IRouter.SwapCall({ - adapter: address(adapter1), amountIn: 6 ether, data: abi.encodePacked(SIGNED_SWAP_SELECTOR) - }); + calls[0] = _signedCall( + swapper, + address(inputToken), + address(adapter0), + 4 ether, + abi.encodePacked(SIGNED_SWAP_SELECTOR), + 0, + AUTHORIZATION_DEADLINE, + AUTH_SIGNER_PRIVATE_KEY + ); + calls[1] = _signedCall( + swapper, + address(inputToken), + address(adapter1), + 6 ether, + abi.encodePacked(SIGNED_SWAP_SELECTOR), + 0, + AUTHORIZATION_DEADLINE, + AUTH_SIGNER_PRIVATE_KEY + ); vm.expectRevert(); vm.prank(swapper); router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 10 ether, recipient)); - assertEq(inputToken.balanceOf(swapper), 1_000 ether); + assertEq(inputToken.balanceOf(swapper), 1000 ether); assertEq(outputToken.balanceOf(address(router)), 0); assertEq(adapter0.callCount(), 0); } @@ -518,17 +994,80 @@ contract RouterTest is Test { _oneCall(address(adapter0), 10 ether, SIGNED_SWAP_SELECTOR), _oneOutput(address(outputToken), 10 ether, recipient) ); - assertEq(inputToken.balanceOf(swapper), 1_000 ether); + assertEq(inputToken.balanceOf(swapper), 1000 ether); assertEq(outputToken.balanceOf(recipient), 0); } function _oneCall(address adapter, uint256 amountIn, bytes4 selector) internal - pure + view returns (IRouter.SwapCall[] memory calls) { calls = new IRouter.SwapCall[](1); - calls[0] = IRouter.SwapCall({adapter: adapter, amountIn: amountIn, data: abi.encodePacked(selector)}); + calls[0] = _signedCall( + swapper, + address(inputToken), + adapter, + amountIn, + abi.encodePacked(selector), + 0, + AUTHORIZATION_DEADLINE, + AUTH_SIGNER_PRIVATE_KEY + ); + } + + function _signedCall( + address intendedSwapper, + address tokenIn, + address adapter, + uint256 amountIn, + bytes memory data, + uint256 executionDeadline, + uint256 authorizationDeadline, + uint256 signerPrivateKey + ) internal view returns (IRouter.SwapCall memory swapCall) { + address signer = vm.addr(signerPrivateKey); + bytes32 digest = _authorizationDigest( + intendedSwapper, signer, tokenIn, adapter, amountIn, data, executionDeadline, authorizationDeadline + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, digest); + swapCall = IRouter.SwapCall({ + adapter: adapter, + amountIn: amountIn, + data: data, + authSigner: signer, + authDeadline: authorizationDeadline, + authSignature: abi.encodePacked(r, s, v) + }); + } + + function _authorizationDigest( + address intendedSwapper, + address signer, + address tokenIn, + address adapter, + uint256 amountIn, + bytes memory data, + uint256 executionDeadline, + uint256 authorizationDeadline + ) internal view returns (bytes32) { + bytes32 domainSeparator = keccak256( + abi.encode(DOMAIN_TYPEHASH, keccak256("Router"), keccak256("1"), block.chainid, address(router)) + ); + bytes32 structHash = keccak256( + abi.encode( + SWAP_AUTHORIZATION_TYPEHASH, + intendedSwapper, + signer, + tokenIn, + adapter, + amountIn, + keccak256(data), + executionDeadline, + authorizationDeadline + ) + ); + return keccak256(abi.encodePacked(hex"1901", domainSeparator, structHash)); } function _oneOutput(address token, uint256 amount, address to) From 1b80fe69d4b11b28678b027a7c9b187c07cb3e2b Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 3 Aug 2026 18:50:13 -0700 Subject: [PATCH 05/11] fix: restrict Router to signed swaps --- README.md | 4 +- .../plans/2026-08-03-user-directed-router.md | 13 +++---- .../2026-08-03-user-directed-router-design.md | 37 +++++++++---------- src/Router.sol | 3 +- src/interfaces/IRouter.sol | 2 +- test/Router.t.sol | 6 +-- 6 files changed, 30 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 54e2321..944e0bb 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ This directory contains the core RFQ settlement contracts used by the Symbiotic 4. `Router.execute(tokenIn, calls, outputs, deadline)` validates every authorization before funding, transfers each leg directly from the user to its adapter, invokes the provided calldata, and pays the declared recipients. 5. Any transaction-local surplus is returned to the caller; balances that predate the call are never used for settlement. -The Router supports standard ERC-20 tokens and distinct input/output tokens only. Every adapter must be registered by `LIQUID_LANE_ADAPTER_FACTORY`, and calldata must use either the signed-swap selector `0x9a4568b6` or discount-swap selector `0x8fa5c671`. The batch is atomic: a failed authorization, leg, or unmet output reverts every transfer. +The Router supports standard ERC-20 tokens and distinct input/output tokens only. Every adapter must be registered by `LIQUID_LANE_ADAPTER_FACTORY`, and calldata must use the signed-swap selector `0x9a4568b6`. Discount-swap calldata is rejected: a private discount may inform solver pricing, but the selected leg must be returned as a fresh signed swap bound to the Router. The batch is atomic: a failed authorization, leg, or unmet output reverts every transfer. Each `SwapCall` has the ABI tuple order `(adapter, amountIn, data, authSigner, authDeadline, authSignature)`. The signature uses EIP-712 domain name `Router`, version `1`, the active chain ID, and the deployed Router as verifying contract. Its exact primary type is: @@ -44,7 +44,7 @@ SwapAuthorization(address swapper,address authSigner,address tokenIn,address ada `dataHash` is `keccak256(data)`. `swapper` is the transaction caller, `tokenIn` is the top-level input token, and `authSigner` is the signer supplied in the call. `executionDeadline` is the deadline-overload argument, or zero for the no-deadline overload. `authorizationDeadline` is `authDeadline`; it must be nonzero and not expired. The Router checks that `authSigner` is currently the adapter owner or market maker, or is currently authorized through `isFiller(marketMaker, authSigner)`, then verifies `authSignature` with OpenZeppelin `SignatureChecker` so EOA and ERC-1271 signers are supported. -The Router does not add replay storage. The permitted LiquidLane signed-swap and discount-swap calls consume their own adapter nonces, which remain the authoritative replay protection. +The Router does not add replay storage. The permitted LiquidLane signed-swap call consumes its adapter nonce, which remains the authoritative replay protection. ## Test locally diff --git a/docs/superpowers/plans/2026-08-03-user-directed-router.md b/docs/superpowers/plans/2026-08-03-user-directed-router.md index 5c873ea..8c06fab 100644 --- a/docs/superpowers/plans/2026-08-03-user-directed-router.md +++ b/docs/superpowers/plans/2026-08-03-user-directed-router.md @@ -2,7 +2,7 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Add an ownerless `Router` that atomically funds registered LiquidLane adapters from the caller, executes signed or discounted swap calldata, and distributes transaction-local ERC-20 output deltas. +**Goal:** Add an ownerless `Router` that atomically funds registered LiquidLane adapters from the caller, executes signed-swap calldata, and distributes transaction-local ERC-20 output deltas. **Architecture:** `IRouter` fixes the typed batch ABI, per-leg EIP-712 authorization, allowed selectors, errors, and events. `Router` validates the entire batch and every current adapter signer before funding, snapshots unique output-token balances, transfers each leg directly from `msg.sender` to its registered adapter, calls the adapter, verifies exact input consumption, enforces aggregate outputs, pays recipients, and returns surplus while preserving pre-existing balances. @@ -15,9 +15,9 @@ - ABI field order is `SwapCall(adapter, amountIn, data, authSigner, authDeadline, authSignature)` and `Output(token, recipient, amount)`. - Every leg uses EIP-712 domain `Router` version `1` and the exact `SwapAuthorization(address swapper,address authSigner,address tokenIn,address adapter,uint256 amountIn,bytes32 dataHash,uint256 executionDeadline,uint256 authorizationDeadline)` primary type. - Require a nonzero, unexpired `authDeadline`, current adapter owner/market-maker/filler authority, and a valid OpenZeppelin `SignatureChecker` result before funding any leg. -- Prevalidate the total input sum with checked arithmetic before adapter execution. Replay protection remains in the nonces consumed by the two allowed adapter selectors; do not add Router replay storage. +- Prevalidate the total input sum with checked arithmetic before adapter execution. Replay protection remains in the nonce consumed by the allowed signed-swap selector; do not add Router replay storage. - Expose both nonpayable overloads: `execute(tokenIn,calls,outputs)` and `execute(tokenIn,calls,outputs,deadline)`. -- Allow only selector `0x9a4568b6` (signed swap) and `0x8fa5c671` (discount swap). +- Allow only selector `0x9a4568b6` (signed swap). Reject `0x8fa5c671` (discount swap): a private discount may inform pricing, but the selected leg must be rebuilt as a fresh signed swap bound to the Router. - Validate every adapter through immutable `IRegistry(factory).isEntity(adapter)`. - Use `call`, never `delegatecall`, and forward zero native value. - Transfer every leg directly from `msg.sender` to its adapter; Router must never custody input. @@ -138,7 +138,6 @@ Create `Router.sol` inheriting `IRouter, ReentrancyGuard`. Constructor-reject a ```solidity bytes4 internal constant SIGNED_SWAP_SELECTOR = 0x9a4568b6; -bytes4 internal constant DISCOUNT_SWAP_SELECTOR = 0x8fa5c671; function _selector(bytes calldata data) internal pure returns (bytes4 selector) { assembly ("memory-safe") { selector := calldataload(data.offset) } @@ -182,7 +181,7 @@ git commit -m "feat: add typed Router interface" - [ ] **Step 1: Write failing direct-funding tests** -Extend the mocks with a registered adapter that accepts the two selectors, consumes its prefunded input, transfers output to the Router, optionally reverts, and records calldata. Add tests for one leg, multiple adapters, call order, missing allowance, fee-on-transfer input, under-consumption, adapter revert data, and late-leg rollback: +Extend the mocks with a registered adapter that accepts the signed selector, consumes its prefunded input, transfers output to the Router, optionally reverts, and records calldata. Keep a discount-selector mock path only for the explicit Router-rejection regression. Add tests for one leg, multiple adapters, call order, missing allowance, fee-on-transfer input, under-consumption, adapter revert data, and late-leg rollback: ```solidity function testTransfersEachInputDirectlyAndCallsInOrder() public { @@ -345,7 +344,7 @@ git commit -m "feat: settle Router output deltas" **Interfaces:** - Produces `DeployRouterScript.run() returns (Router)` using `LIQUID_LANE_ADAPTER_FACTORY`. -- Documents approval, typed execution, signed/discount selector restriction, and deployment. +- Documents approval, typed execution, the signed-only selector restriction, and deployment. - [ ] **Step 1: Write a failing deployment-script assertion** @@ -387,7 +386,7 @@ contract DeployRouterScript is Script { - [ ] **Step 4: Document the exact user flow** -Add Router to `README.md`: approve input ERC-20 to Router, obtain backend `/swap` transaction, submit typed deadline `execute`, and note that only registered signed/discount calls, standard ERC-20, and distinct token pairs are supported. Add a deployment command using `LIQUID_LANE_ADAPTER_FACTORY`. +Add Router to `README.md`: approve input ERC-20 to Router, obtain backend `/swap` transaction, submit typed deadline `execute`, and note that only registered signed-swap calls, standard ERC-20, and distinct token pairs are supported. Document that discount calldata is rejected and must be rebuilt as a fresh signed swap. Add a deployment command using `LIQUID_LANE_ADAPTER_FACTORY`. - [ ] **Step 5: Run package verification** diff --git a/docs/superpowers/specs/2026-08-03-user-directed-router-design.md b/docs/superpowers/specs/2026-08-03-user-directed-router-design.md index 15bde63..e243514 100644 --- a/docs/superpowers/specs/2026-08-03-user-directed-router-design.md +++ b/docs/superpowers/specs/2026-08-03-user-directed-router-design.md @@ -1,13 +1,13 @@ # User-Directed Router Design **Date:** 2026-08-03 -**Status:** Implemented with the signer-authorization security amendment +**Status:** Implemented with the signed-only signer-authorization security amendment ## Summary Add a standalone, non-upgradeable Solidity contract named `Router`. A swapper calls the Router directly after granting it an ordinary ERC-20 allowance. In one atomic transaction, the Router first verifies a Router-specific EIP-712 authorization from a current adapter owner, market maker, or filler for every leg; transfers each input leg directly from the swapper to a factory-registered LiquidLane adapter; invokes an authorized adapter swap selector with opaque calldata; verifies the output tokens received during this transaction; pays exact declared amounts to the declared recipients; and returns each declared token's surplus to the swapper. -The Router is not a Reactor executor, does not validate RFQ orders, does not use Permit2, and does not retain user funds or approvals. Its security boundary is deliberately narrow: registered adapters, current adapter-authorized signers, per-leg EIP-712 authorization, two permitted swap selectors, standard ERC-20 behavior, transaction-local balance deltas, and all-or-nothing execution. +The Router is not a Reactor executor, does not validate RFQ orders, does not use Permit2, and does not retain user funds or approvals. Its security boundary is deliberately narrow: registered adapters, current adapter-authorized signers, per-leg EIP-712 authorization, one permitted signed-swap selector, standard ERC-20 behavior, transaction-local balance deltas, and all-or-nothing execution. ## Branch and ABI Compatibility @@ -16,7 +16,7 @@ The design is being documented on `codex/router`, which currently points at the - The Router stores an immutable LiquidLane adapter factory. - Every per-leg adapter is validated with `IRegistry(factory).isEntity(adapter)`. - The stage branch receives a minimal read-only `IRegistry` interface containing only `isEntity(address) external view returns (bool)`. -- Selector constants are pinned to the current LiquidLane signed-swap and discount-swap ABI and covered by selector-shape tests. They must not be inferred from the stale stage `IInstantRedemptionAdapter` overloads. +- The selector constant is pinned to the current LiquidLane signed-swap ABI and covered by selector-shape tests. It must not be inferred from the stale stage `IInstantRedemptionAdapter` overloads. - The legacy direct-swap selector and arbitrary adapter selectors are not accepted. This makes the Router source buildable from the old stage tree while preserving the current mainline deployment trust boundary. It does not make the Router compatible with a legacy deployment that has no factory registry or exposes different swap selectors. @@ -25,7 +25,7 @@ This makes the Router source buildable from the old stage tree while preserving - Give a user one typed entrypoint for a batch of LiquidLane swap legs with a single input token. - Pull each leg directly from `msg.sender` into its adapter; the Router never takes custody of input tokens. -- Allow backend- or solver-produced signed and discount adapter calldata without making the Router an arbitrary-call primitive. +- Allow backend- or solver-produced signed-swap adapter calldata without making the Router an arbitrary-call primitive. - Require all expected adapter outputs to arrive at the Router. - Enforce minimum output economically at the aggregate token level across the whole batch. - Pay exact amounts to one or more recipients and return declared-token surplus to `msg.sender`. @@ -112,17 +112,15 @@ For every `SwapCall`, the Router performs all validation and verifies every leg 1. `adapter` is nonzero and `IRegistry(LIQUID_LANE_ADAPTER_FACTORY).isEntity(adapter)` returns true. 2. `amountIn` is nonzero. 3. `data` contains at least four bytes. -4. The first four bytes are exactly one of the two permitted current-main selectors: - - signed swap: `swap((address,address,uint256,uint256,address,address,uint256,uint48),bytes)`; - - discount swap: `swap(((address,uint256,address,address,uint256,uint48),bytes,uint48),bytes,address,uint256)`. +4. The first four bytes are exactly the permitted current-main signed-swap selector: `swap((address,address,uint256,uint256,address,address,uint256,uint48),bytes)`. 5. `authDeadline` is nonzero and `block.timestamp <= authDeadline`. 6. `authSigner` is currently the adapter's `owner()` or `marketMaker()`, or `isFiller(marketMaker(), authSigner)` is true. 7. OpenZeppelin `SignatureChecker` accepts `authSignature` for the exact Router EIP-712 payload, supporting EOAs and ERC-1271 signers. 8. No native value is attached to the adapter call. -All other selectors are rejected, including the unsigned direct-swap overload. The allowlist prevents a user from exercising adapter administration, nonce invalidation, acquisition, withdrawal, or fallback behavior under the Router's identity. +All other selectors are rejected, including the discount-swap and unsigned direct-swap overloads. The discount signature does not bind its caller, recipient, or input amount and its nonce is reusable, so exposing that calldata would create a transferable bearer authorization outside the Router boundary. A private discount may inform solver pricing, but a selected leg must be rebuilt as a fresh signed swap. The allowlist prevents a user from exercising adapter administration, nonce invalidation, acquisition, withdrawal, or fallback behavior under the Router's identity. -The backend or solver must encode the Router as the collateral recipient in both permitted payloads. For a signed swap, it must also encode the Router as the signed `caller`. The adapter authoritatively verifies those fields and the signatures. The Router does not decode or rewrite them. +The backend or solver must encode the Router as both the signed `caller` and collateral recipient. The adapter authoritatively verifies those fields and the signature. The Router does not decode or rewrite them. Because the payload remains opaque, a single leg is not required to produce its own pro-rata share of an output. One leg may overproduce while another underproduces, provided the user-approved batch meets every aggregate token minimum. This cross-leg netting is intentional in V1. @@ -196,7 +194,7 @@ Only after all final assertions pass does the Router emit its completion event. 1. **Registered targets only:** every external call target is a current entity of the immutable LiquidLane adapter factory. 2. **Current signer authority:** every leg is approved by its adapter's current owner, market maker, or authorized filler. 3. **Exact Router authorization:** each signature binds the Router domain and chain, caller, `authSigner`, top-level input token, adapter, amount, calldata hash, effective execution deadline, and nonzero authorization deadline. -4. **Two selectors only:** the Router can invoke only the current signed-swap and discount-swap entrypoints. +4. **Signed selector only:** the Router can invoke only the current signed-swap entrypoint. 5. **No arbitrary execution:** the Router never calls a user-selected non-adapter target, never uses `delegatecall`, and never forwards native value. 6. **Caller-funded:** every leg pulls from `msg.sender`; no arbitrary payer field exists. 7. **Direct input routing:** input moves from the swapper directly to the adapter and never through the Router. @@ -245,7 +243,7 @@ The interface defines concise custom errors for these observable failure classes | `InvalidCalldata(index)` | Adapter calldata is shorter than one selector. | | `InvalidOutputToken(index, token)` | An output is native, zero, equal to `tokenIn`, or not an ERC-20 contract. | | `InvalidRecipient(index, recipient)` | A recipient is zero or the Router. | -| `InvalidSelector(index, selector)` | The adapter selector is not one of the two permitted selectors. | +| `InvalidSelector(index, selector)` | The adapter selector is not the permitted signed-swap selector. | | `InvalidTokenIn(token)` | `tokenIn` is zero or not a contract. | | `OutputTransferMismatch(index, expected, actual)` | A declared recipient did not receive exactly the requested amount. | | `SurplusTransferMismatch(token, expected, actual)` | The swapper did not receive the exact surplus. | @@ -277,10 +275,10 @@ V1 supports ordinary ERC-20 tokens whose balances change exactly by the requeste ## Security Assumptions and Explicit Trade-offs - The immutable factory correctly identifies authentic LiquidLane adapters. Factory compromise or registration of malicious adapters is outside the Router's local trust boundary. -- Current LiquidLane signed-swap and discount-swap selectors retain their documented semantics. -- The backend or solver encodes `recipient = Router`; signed swaps additionally encode `caller = Router`. Incorrect encoding normally fails aggregate output validation and reverts. +- The current LiquidLane signed-swap selector retains its documented semantics. +- The backend or solver encodes both `recipient = Router` and `caller = Router`. Incorrect encoding is rejected by the adapter or aggregate output validation. - The transaction caller authorizes the aggregate settlement by submitting the transaction, while every individual adapter leg also requires a current adapter-authorized Router EIP-712 signature. -- The Router has no authorization-ID or replay-storage mapping. Both permitted LiquidLane selectors consume adapter nonces, which remain authoritative for replay protection without unbounded Router storage. +- The Router has no authorization-ID or replay-storage mapping. The permitted LiquidLane signed-swap selector consumes its adapter nonce, which remains authoritative for replay protection without unbounded Router storage. - Output protection is aggregate per token, not per leg. Cross-leg subsidy is accepted because the user receives the declared batch result. - Only tokens listed in `outputs` are snapshotted and distributed. Undeclared tokens sent to the Router remain isolated permanently; a later user cannot claim them as transaction-local surplus. - There is no rescue function. Recoverability of accidental or forced balances is intentionally sacrificed to keep the pre-existing-balance invariant unconditional and ownerless. @@ -304,8 +302,8 @@ Create focused Foundry tests in `test/Router.t.sol` with registry, adapter, toke - Duplicate output tokens and recipients are accepted and aggregated correctly. - An unregistered or zero adapter reverts. - Calldata shorter than four bytes reverts. -- Signed and discount selectors succeed. -- Direct swap, adapter administration, arbitrary, fallback, and legacy stage selectors revert. +- The signed-swap selector succeeds. +- Discount swap, direct swap, adapter administration, arbitrary, fallback, and legacy stage selectors revert. - Selector constants are pinned against the current LiquidLane interface shape. ### Input routing @@ -359,7 +357,7 @@ Create focused Foundry tests in `test/Router.t.sol` with registry, adapter, toke ### Integration and deployment -- Add a mainline LiquidLane interface-shape test for both allowed selectors. +- Add a mainline LiquidLane interface-shape test for the allowed signed-swap selector and the rejected discount-swap selector. - Add an integration test with a factory mock exposing only `isEntity` to prove old-stage compatibility of the minimal interface. - Add a deployment-script test confirming constructor validation and the immutable factory. - Include Router in bytecode-size and gas snapshots according to repository conventions. @@ -383,8 +381,7 @@ The deployment script reads `LIQUID_LANE_ADAPTER_FACTORY`, deploys Router, asser After deployment, backend and solver configuration must use the deployed Router address as: - `SignedSwap.caller`; -- `SignedSwap.recipient`; and -- the discount swap's explicit `recipient` argument. +- `SignedSwap.recipient`. Every solver-produced leg also needs the current adapter-authorized Router signature described above. Users approve the input ERC-20 to Router and submit the Router transaction themselves. @@ -393,7 +390,7 @@ Every solver-produced leg also needs the current adapter-authorized Router signa The feature is complete when: 1. Both typed overloads implement the same atomic execution path and the deadline overload expires exactly as specified. -2. Every leg targets a factory entity, uses one of exactly two pinned selectors, and has a valid unexpired authorization from its adapter's current owner, market maker, or filler. +2. Every leg targets a factory entity, uses exactly the pinned signed-swap selector, and has a valid unexpired authorization from its adapter's current owner, market maker, or filler. 3. The authorization binds the caller, signer, input token, adapter, amount, calldata hash, effective execution deadline, and authorization deadline before any leg is funded. 4. Every input leg is transferred directly from the caller, received exactly, and consumed exactly. 5. No declared output minimum can be satisfied by a pre-existing Router balance. diff --git a/src/Router.sol b/src/Router.sol index 89bf362..68ea0ef 100644 --- a/src/Router.sol +++ b/src/Router.sol @@ -19,7 +19,6 @@ contract Router is IRouter, EIP712, ReentrancyGuard { using SafeERC20 for IERC20; bytes4 internal constant SIGNED_SWAP_SELECTOR = 0x9a4568b6; - bytes4 internal constant DISCOUNT_SWAP_SELECTOR = 0x8fa5c671; bytes32 public constant SWAP_AUTHORIZATION_TYPEHASH = keccak256( "SwapAuthorization(address swapper,address authSigner,address tokenIn,address adapter,uint256 amountIn,bytes32 dataHash,uint256 executionDeadline,uint256 authorizationDeadline)" ); @@ -227,7 +226,7 @@ contract Router is IRouter, EIP712, ReentrancyGuard { if (swapCall.data.length < 4) revert InvalidCalldata(i); bytes4 selector = _selector(swapCall.data); - if (selector != SIGNED_SWAP_SELECTOR && selector != DISCOUNT_SWAP_SELECTOR) { + if (selector != SIGNED_SWAP_SELECTOR) { revert InvalidSelector(i, selector); } } diff --git a/src/interfaces/IRouter.sol b/src/interfaces/IRouter.sol index fe5f1b6..97582d5 100644 --- a/src/interfaces/IRouter.sol +++ b/src/interfaces/IRouter.sol @@ -7,7 +7,7 @@ interface IRouter { /// @notice One authenticated adapter leg. /// @param adapter Factory-registered LiquidLane adapter that receives the input and executes `data`. /// @param amountIn Exact common input-token amount funded directly from the caller. - /// @param data Complete signed-swap or discount-swap adapter calldata. + /// @param data Complete signed-swap adapter calldata. /// @param authSigner Current adapter owner, market maker, or authorized filler that approved this Router leg. /// @param authDeadline Nonzero Router-authorization expiry included in the signed payload. /// @param authSignature EIP-712 signature over this leg, its payer, token, and effective execution deadline. diff --git a/test/Router.t.sol b/test/Router.t.sol index 88860e7..83c3e0d 100644 --- a/test/Router.t.sol +++ b/test/Router.t.sol @@ -349,15 +349,15 @@ contract RouterTest is Test { ); } - function testAcceptsDiscountSelector() public { + function testRejectsDiscountSelector() public { adapter0.configure(1 ether, 0); + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidSelector.selector, 0, DISCOUNT_SWAP_SELECTOR)); vm.prank(swapper); router.execute( address(inputToken), _oneCall(address(adapter0), 1 ether, DISCOUNT_SWAP_SELECTOR), _oneOutput(address(outputToken), 1 ether, recipient) ); - assertEq(outputToken.balanceOf(recipient), 1 ether); } function testAcceptsAuthorizationFromAdapterMarketMaker() public { @@ -774,7 +774,7 @@ contract RouterTest is Test { address(inputToken), address(adapter1), 6 ether, - abi.encodePacked(DISCOUNT_SWAP_SELECTOR), + abi.encodePacked(SIGNED_SWAP_SELECTOR), 0, AUTHORIZATION_DEADLINE, AUTH_SIGNER_PRIVATE_KEY From f0d3218e3f24b0d19a872bb0cfdbb3e51e2699c6 Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 3 Aug 2026 23:45:01 -0700 Subject: [PATCH 06/11] refactor: simplify Router inline execution --- README.md | 31 +- .../plans/2026-08-03-user-directed-router.md | 435 +------- .../2026-08-03-user-directed-router-design.md | 399 +------ script/deploy/DeployRouter.s.sol | 2 +- src/Router.sol | 246 +---- .../ILiquidLaneAdapterAuthorization.sol | 10 - src/interfaces/IRouter.sol | 43 +- test/Router.t.sol | 994 +++--------------- 8 files changed, 262 insertions(+), 1898 deletions(-) delete mode 100644 src/interfaces/ILiquidLaneAdapterAuthorization.sol diff --git a/README.md b/README.md index 944e0bb..de51e49 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This directory contains the core RFQ settlement contracts used by the Symbiotic - `Reactor.sol` validates the signed order, pulls the input through Permit2, routes input into the instant redemption adapter, and enforces output delivery. - `Executor.sol` is an example role-gated execution surface that calls the Reactor, performs adapter swaps, runs any post-swap execution payload, and approves output transfers back to the Reactor. -- `Router.sol` is an ownerless, user-directed execution surface that directly funds registered LiquidLane adapters from the caller and settles transaction-local output deltas. +- `Router.sol` is an ownerless, user-directed execution surface that directly funds registered LiquidLane adapters from the caller, executes their calldata inline, and transfers declared outputs. > [!NOTE] > `Executor.sol` is not a protocol requirement. It is an example filler-side executor contract that demonstrates one way to integrate with `Reactor`. Fillers can deploy their own executor implementation as long as it satisfies the expected Reactor callback flow. @@ -30,21 +30,28 @@ This directory contains the core RFQ settlement contracts used by the Symbiotic 1. The user approves the input ERC-20 to `Router` using an ordinary ERC-20 allowance. 2. The user requests an unsigned transaction from the backend `/api/v1/swap` endpoint. -3. For every leg, a current adapter owner, market maker, or authorized filler signs the Router-specific authorization described below. -4. `Router.execute(tokenIn, calls, outputs, deadline)` validates every authorization before funding, transfers each leg directly from the user to its adapter, invokes the provided calldata, and pays the declared recipients. -5. Any transaction-local surplus is returned to the caller; balances that predate the call are never used for settlement. +3. The backend returns one or more solver legs, each containing an adapter, an input amount, and complete signed-swap or discounted-swap calldata. +4. `Router.execute(tokenIn, calls, outputs, deadline)` transfers each leg directly from the user to its adapter, invokes the provided calldata inline, and then transfers the declared outputs to their recipients. -The Router supports standard ERC-20 tokens and distinct input/output tokens only. Every adapter must be registered by `LIQUID_LANE_ADAPTER_FACTORY`, and calldata must use the signed-swap selector `0x9a4568b6`. Discount-swap calldata is rejected: a private discount may inform solver pricing, but the selected leg must be returned as a fresh signed swap bound to the Router. The batch is atomic: a failed authorization, leg, or unmet output reverts every transfer. +Every adapter must be registered by `LIQUID_LANE_ADAPTER_FACTORY`. The Router does not inspect adapter calldata or add an outer authorization layer: signature, nonce, selector, and quote validation remain the adapter's responsibility. This permits both signed-swap and discounted-swap calldata. The calls and output transfers run in caller-supplied order and the entire batch is atomic. -Each `SwapCall` has the ABI tuple order `(adapter, amountIn, data, authSigner, authDeadline, authSignature)`. The signature uses EIP-712 domain name `Router`, version `1`, the active chain ID, and the deployed Router as verifying contract. Its exact primary type is: +The ABI tuple order is: -```text -SwapAuthorization(address swapper,address authSigner,address tokenIn,address adapter,uint256 amountIn,bytes32 dataHash,uint256 executionDeadline,uint256 authorizationDeadline) -``` +```solidity +struct SwapCall { + address adapter; + uint256 amountIn; + bytes data; +} -`dataHash` is `keccak256(data)`. `swapper` is the transaction caller, `tokenIn` is the top-level input token, and `authSigner` is the signer supplied in the call. `executionDeadline` is the deadline-overload argument, or zero for the no-deadline overload. `authorizationDeadline` is `authDeadline`; it must be nonzero and not expired. The Router checks that `authSigner` is currently the adapter owner or market maker, or is currently authorized through `isFiller(marketMaker, authSigner)`, then verifies `authSignature` with OpenZeppelin `SignatureChecker` so EOA and ERC-1271 signers are supported. +struct Output { + address token; + address recipient; + uint256 amount; +} +``` -The Router does not add replay storage. The permitted LiquidLane signed-swap call consumes its adapter nonce, which remains the authoritative replay protection. +The Router intentionally performs no array, amount, token, recipient, selector, input-consumption, output-delta, or surplus validation. ERC-20 transfers and adapter calls define success. Output entries are ordinary transfers from the Router's current balances, and undeclared surplus remains in the Router. Use the deadline overload when the user wants Router-level expiry. ## Test locally @@ -101,4 +108,4 @@ forge script rfq/reactor/script/deploy/DeployRouter.s.sol:DeployRouterScript \ - `Executor` is role-gated through `CALLER_ROLE`. - `Reactor` uses Permit2 witness transfers and the instant redemption adapter as its execution primitives. -- `Router` uses ordinary ERC-20 allowance plus a current adapter-authorized EIP-712 signature for every leg. +- `Router` uses ordinary ERC-20 allowance, validates only adapter registration and an optional deadline, and forwards adapter calldata unchanged. diff --git a/docs/superpowers/plans/2026-08-03-user-directed-router.md b/docs/superpowers/plans/2026-08-03-user-directed-router.md index 8c06fab..d0cef95 100644 --- a/docs/superpowers/plans/2026-08-03-user-directed-router.md +++ b/docs/superpowers/plans/2026-08-03-user-directed-router.md @@ -1,418 +1,41 @@ -# User-Directed Router Implementation Plan +# Inline-Execution Router Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +**Goal:** Implement the smallest ownerless Router that directly funds registered LiquidLane adapters, invokes solver-provided calldata inline, and transfers declared outputs atomically. -**Goal:** Add an ownerless `Router` that atomically funds registered LiquidLane adapters from the caller, executes signed-swap calldata, and distributes transaction-local ERC-20 output deltas. +**Tech stack:** Solidity 0.8.28, Foundry, OpenZeppelin `SafeERC20` and `ReentrancyGuard`. -**Architecture:** `IRouter` fixes the typed batch ABI, per-leg EIP-712 authorization, allowed selectors, errors, and events. `Router` validates the entire batch and every current adapter signer before funding, snapshots unique output-token balances, transfers each leg directly from `msg.sender` to its registered adapter, calls the adapter, verifies exact input consumption, enforces aggregate outputs, pays recipients, and returns surplus while preserving pre-existing balances. +## Constraints -**Tech Stack:** Solidity 0.8.28, Foundry, OpenZeppelin `SafeERC20`, `EIP712`, `SignatureChecker`, and `ReentrancyGuard`, forge-std. +- Contract name is exactly `Router`. +- `SwapCall` is exactly `(address adapter, uint256 amountIn, bytes data)`. +- `Output` is exactly `(address token, address recipient, uint256 amount)`. +- Input authorization is an ordinary ERC-20 allowance to the Router. +- Validate each adapter through immutable `IRegistry(factory).isEntity(adapter)` immediately before funding that leg. +- Transfer input directly from `msg.sender` to the adapter, then call the supplied calldata unchanged. +- Transfer outputs only after every adapter call succeeds. +- Keep both immediate and deadline overloads under one reentrancy guard. +- Do not add outer signatures, selector inspection, balance accounting, surplus handling, or structural request validation. -## Global Constraints +## Completed Work -- Contract name is exactly `Router` and it is deployed directly, without proxy, owner, roles, pause, rescue, or upgrade state. -- Input authorization is ordinary ERC-20 allowance to Router; do not add Permit2 or EIP-2612. -- ABI field order is `SwapCall(adapter, amountIn, data, authSigner, authDeadline, authSignature)` and `Output(token, recipient, amount)`. -- Every leg uses EIP-712 domain `Router` version `1` and the exact `SwapAuthorization(address swapper,address authSigner,address tokenIn,address adapter,uint256 amountIn,bytes32 dataHash,uint256 executionDeadline,uint256 authorizationDeadline)` primary type. -- Require a nonzero, unexpired `authDeadline`, current adapter owner/market-maker/filler authority, and a valid OpenZeppelin `SignatureChecker` result before funding any leg. -- Prevalidate the total input sum with checked arithmetic before adapter execution. Replay protection remains in the nonce consumed by the allowed signed-swap selector; do not add Router replay storage. -- Expose both nonpayable overloads: `execute(tokenIn,calls,outputs)` and `execute(tokenIn,calls,outputs,deadline)`. -- Allow only selector `0x9a4568b6` (signed swap). Reject `0x8fa5c671` (discount swap): a private discount may inform pricing, but the selected leg must be rebuilt as a fresh signed swap bound to the Router. -- Validate every adapter through immutable `IRegistry(factory).isEntity(adapter)`. -- Use `call`, never `delegatecall`, and forward zero native value. -- Transfer every leg directly from `msg.sender` to its adapter; Router must never custody input. -- Support standard ERC-20 only; reject native, same-token output, fee-on-transfer behavior, empty economics, and zero values. -- Settle only transaction-local output deltas; never sweep or spend a pre-existing Router balance. -- Every failure reverts the complete batch. -- Target branch is `origin/stage`; preserve existing Reactor/Executor behavior. +- [x] Replace the authenticated six-field call tuple with the three-field ABI and pin its raw function selector in tests. +- [x] Preserve zero/non-contract factory rejection and the immutable factory getter. +- [x] Implement inline per-leg registry checks, direct `safeTransferFrom`, and opaque adapter `call`. +- [x] Preserve adapter revert bytes in `AdapterCallFailed`. +- [x] Implement ordered `safeTransfer` output distribution. +- [x] Keep atomic rollback, shared reentrancy protection, and optional deadline semantics. +- [x] Remove EIP-712, `SignatureChecker`, adapter authorization interfaces, selector parsing, amount sums, input/output delta checks, isolation, surplus, and related errors/events. +- [x] Cover aggregated legs, arbitrary calldata, empty/zero entries, direct funding, output ordering, pre-existing balances, retained surplus, invalid adapters, adapter/output failures, rollback, reentrancy, deployment, deadlines, and fuzzed leg amounts. +- [x] Update Router documentation and deployment wording. -The authorization constraints above are the approved security amendment and supersede older task snippets below wherever they show the original three-field `SwapCall` or transient reentrancy guard. +## Verification ---- - -### Task 1: Pin the Router interface and structural validation - -**Files:** - -- Create: `src/interfaces/IRegistry.sol` -- Create: `src/interfaces/IRouter.sol` -- Create: `src/Router.sol` -- Create: `test/Router.t.sol` - -**Interfaces:** - -- Produces `IRegistry.isEntity(address) external view returns (bool)`. -- Produces `IRouter.SwapCall`, `IRouter.Output`, the two `execute` overloads, custom errors, and events. -- Produces `Router.LIQUID_LANE_ADAPTER_FACTORY()` and pre-execution validation shared by both overloads. - -- [ ] **Step 1: Write failing ABI and constructor tests** - -Create `test/Router.t.sol` with minimal registry/token/adapter mocks and assertions that pin the field order, immutable, selector constants, empty arrays, deadline boundary, zero/non-contract factory, zero/non-contract input token, same-token output, invalid recipients, zero amounts, unregistered adapters, short calldata, and unapproved selectors. The core fixtures are: - -```solidity -contract MockRegistry { - mapping(address => bool) public isEntity; - function setEntity(address entity, bool status) external { isEntity[entity] = status; } -} - -contract RouterTest is Test { - MockRegistry registry; - Router router; - - function setUp() public { - registry = new MockRegistry(); - router = new Router(address(registry)); - } - - function testDeadlineEqualityIsValid() public { - vm.warp(100); - vm.expectRevert(IRouter.EmptySwapCalls.selector); - router.execute(address(registry), new IRouter.SwapCall[](0), new IRouter.Output[](0), 100); - } - - function testExpiredDeadlineRevertsBeforeTokenInteraction() public { - vm.warp(101); - vm.expectRevert(abi.encodeWithSelector(IRouter.Expired.selector, 100)); - router.execute(address(registry), new IRouter.SwapCall[](0), new IRouter.Output[](0), 100); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify RED** - -Run: +Run from the full Foundry workspace: ```bash -forge test --match-path test/Router.t.sol -vvv -``` - -Expected: compilation fails because `Router`, `IRouter`, and `IRegistry` do not exist. - -- [ ] **Step 3: Define the exact interfaces** - -Create `IRegistry.sol` with only the read method. Create `IRouter.sol` with: - -```solidity -interface IRouter { - struct SwapCall { - address adapter; - uint256 amountIn; - bytes data; - address authSigner; - uint256 authDeadline; - bytes authSignature; - } - struct Output { address token; address recipient; uint256 amount; } - - error AdapterCallFailed(uint256 index, address adapter, bytes reason); - error BalanceIsolationViolation(address token, uint256 baseline, uint256 actual); - error EmptyOutputs(); - error EmptySwapCalls(); - error Expired(uint256 deadline); - error InputConsumptionMismatch(uint256 index, uint256 expectedBaseline, uint256 actual); - error InputTransferMismatch(uint256 index, uint256 expected, uint256 actual); - error InsufficientOutput(address token, uint256 required, uint256 produced); - error InvalidAdapter(uint256 index, address adapter); - error InvalidAmount(uint256 index); - error InvalidCalldata(uint256 index); - error InvalidOutputToken(uint256 index, address token); - error InvalidRecipient(uint256 index, address recipient); - error InvalidSelector(uint256 index, bytes4 selector); - error InvalidTokenIn(address token); - error OutputTransferMismatch(uint256 index, uint256 expected, uint256 actual); - error SurplusTransferMismatch(address token, uint256 expected, uint256 actual); - - event OutputTransferred(address indexed token, address indexed recipient, uint256 amount); - event SurplusTransferred(address indexed token, address indexed swapper, uint256 amount); - event Execute(address indexed swapper, address indexed tokenIn, uint256 totalAmountIn, uint256 swapCallCount, uint256 outputCount); - - function LIQUID_LANE_ADAPTER_FACTORY() external view returns (address); - function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) external; - function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs, uint256 deadline) external; -} -``` - -- [ ] **Step 4: Implement constructor, overload routing, and full prevalidation** - -Create `Router.sol` inheriting `IRouter, ReentrancyGuard`. Constructor-reject a zero/non-contract factory. Route both overloads into `_execute`; the deadline overload checks `block.timestamp > deadline`. In `_validate`, require contract `tokenIn`, nonempty arrays, nonzero output/call amounts, output token code, `output.token != tokenIn`, nonzero recipient not Router, registered adapter, at least four calldata bytes, and one allowed selector: - -```solidity -bytes4 internal constant SIGNED_SWAP_SELECTOR = 0x9a4568b6; - -function _selector(bytes calldata data) internal pure returns (bytes4 selector) { - assembly ("memory-safe") { selector := calldataload(data.offset) } -} -``` - -Prevalidate every entry before any token transfer or adapter invocation. - -- [ ] **Step 5: Run structural tests and verify GREEN** - -Run: - -```bash -forge test --match-path test/Router.t.sol -vvv forge fmt --check +FOUNDRY_PROFILE=pr forge test --match-path test/Router.t.sol +forge lint +forge build --sizes +forge coverage --match-path test/Router.t.sol ``` - -Expected: constructor, ABI, selector, deadline, and structural-validation tests pass. - -- [ ] **Step 6: Commit the interface slice** - -```bash -git add src/interfaces/IRegistry.sol src/interfaces/IRouter.sol src/Router.sol test/Router.t.sol -git commit -m "feat: add typed Router interface" -``` - ---- - -### Task 2: Fund registered adapters and enforce exact leg consumption - -**Files:** - -- Modify: `src/Router.sol` -- Modify: `test/Router.t.sol` - -**Interfaces:** - -- Consumes the validated `IRouter.SwapCall[]` from Task 1. -- Produces `_executeCalls(address tokenIn, SwapCall[] calldata calls) returns (uint256 totalAmountIn)`. -- Guarantees input moves caller-to-adapter directly and each adapter returns to its pre-leg input balance. - -- [ ] **Step 1: Write failing direct-funding tests** - -Extend the mocks with a registered adapter that accepts the signed selector, consumes its prefunded input, transfers output to the Router, optionally reverts, and records calldata. Keep a discount-selector mock path only for the explicit Router-rejection regression. Add tests for one leg, multiple adapters, call order, missing allowance, fee-on-transfer input, under-consumption, adapter revert data, and late-leg rollback: - -```solidity -function testTransfersEachInputDirectlyAndCallsInOrder() public { - IRouter.SwapCall[] memory calls = _twoCalls(4 ether, 6 ether); - IRouter.Output[] memory outputs = _oneOutput(10 ether, swapper); - - vm.prank(swapper); - router.execute(address(inputToken), calls, outputs, block.timestamp); - - assertEq(inputToken.balanceOf(address(router)), 0); - assertEq(adapter0.consumed(), 4 ether); - assertEq(adapter1.consumed(), 6 ether); -} -``` - -- [ ] **Step 2: Run input tests and verify RED** - -Run: - -```bash -forge test --match-path test/Router.t.sol --match-test 'testTransfers|testReverts.*Input|testLateLeg' -vvv -``` - -Expected: tests fail because calls are not funded or invoked. - -- [ ] **Step 3: Implement exact funding and calls** - -Use `SafeERC20` and balance deltas for every leg: - -```solidity -uint256 baseline = IERC20(tokenIn).balanceOf(call.adapter); -IERC20(tokenIn).safeTransferFrom(msg.sender, call.adapter, call.amountIn); -uint256 funded = IERC20(tokenIn).balanceOf(call.adapter); -if (funded != baseline + call.amountIn) { - revert InputTransferMismatch(i, baseline + call.amountIn, funded); -} -(bool success, bytes memory reason) = call.adapter.call(call.data); -if (!success) revert AdapterCallFailed(i, call.adapter, reason); -uint256 remaining = IERC20(tokenIn).balanceOf(call.adapter); -if (remaining != baseline) revert InputConsumptionMismatch(i, baseline, remaining); -totalAmountIn += call.amountIn; -``` - -Do not approve adapters, transfer input into Router, decode payload arguments, use returned adapter bytes, or allow per-leg failure. - -- [ ] **Step 4: Run input tests and verify GREEN** - -Run: - -```bash -forge test --match-path test/Router.t.sol --match-test 'testTransfers|testReverts.*Input|testLateLeg' -vvv -``` - -Expected: all direct-funding and atomic rollback tests pass. - -- [ ] **Step 5: Commit exact adapter execution** - -```bash -git add src/Router.sol test/Router.t.sol -git commit -m "feat: execute registered adapter swap calls" -``` - ---- - -### Task 3: Enforce output deltas, recipients, surplus, and reentrancy - -**Files:** - -- Modify: `src/Router.sol` -- Modify: `test/Router.t.sol` - -**Interfaces:** - -- Produces unique-token snapshot accounting inside `_execute`. -- Produces exact recipient receipts, surplus-to-caller, and final baseline restoration. -- Completes the atomic `execute` behavior and events. - -- [ ] **Step 1: Write failing settlement and attack tests** - -Cover duplicate output tokens/recipients, multiple tokens, aggregate underproduction, exact production, surplus, pre-existing balances, undeclared tokens, fee-on-transfer output, sender-side fee, malicious adapter balance reduction, recipient/token/adapter reentrancy, and event rollback: - -```solidity -function testPreexistingBalanceCannotSatisfyMinimumOrBecomeSurplus() public { - outputToken.mint(address(router), 100 ether); - adapter.setOutput(9 ether); - vm.expectRevert(abi.encodeWithSelector(IRouter.InsufficientOutput.selector, address(outputToken), 10 ether, 9 ether)); - vm.prank(swapper); - router.execute(address(inputToken), _oneCall(10 ether), _oneOutput(10 ether, swapper)); - assertEq(outputToken.balanceOf(address(router)), 100 ether); -} - -function testSurplusGoesToCallerAfterExactRecipientPayments() public { - adapter.setOutput(12 ether); - vm.prank(swapper); - router.execute(address(inputToken), _oneCall(10 ether), _oneOutput(10 ether, referrer)); - assertEq(outputToken.balanceOf(referrer), 10 ether); - assertEq(outputToken.balanceOf(swapper), 2 ether); -} -``` - -- [ ] **Step 2: Run settlement tests and verify RED** - -Run: - -```bash -forge test --match-path test/Router.t.sol --match-test 'testPreexisting|testSurplus|testReentr|testOutput|testDuplicate' -vvv -``` - -Expected: tests fail because output accounting and payouts are absent. - -- [ ] **Step 3: Implement grouped snapshots and aggregate minimums** - -Build fixed-size memory arrays with `outputs.length` capacity and a `uniqueCount`. For each output, linearly find or append its token, record the Router baseline exactly once, and checked-add its required amount. After adapter calls: - -```solidity -uint256 finalBalance = IERC20(tokens[i]).balanceOf(address(this)); -if (finalBalance < baselines[i]) { - revert BalanceIsolationViolation(tokens[i], baselines[i], finalBalance); -} -uint256 produced = finalBalance - baselines[i]; -if (produced < required[i]) revert InsufficientOutput(tokens[i], required[i], produced); -producedByToken[i] = produced; -``` - -- [ ] **Step 4: Implement exact payouts, surplus, and final restoration** - -For every declared output, snapshot recipient balance, safe-transfer, require an exact increase, and emit `OutputTransferred`. Then for each unique token transfer `produced - required` to `msg.sender`, check its exact receipt, emit `SurplusTransferred`, and require Router's final token balance equals its baseline. Emit `Execute` only after all final assertions. - -Keep both external overloads under the same `nonReentrant` guard. Do not call one guarded overload from the other; both call one unguarded internal `_execute`. - -- [ ] **Step 5: Run the complete Router test suite and verify GREEN** - -Run: - -```bash -forge test --match-path test/Router.t.sol -vvv -forge test --match-path test/Router.t.sol --fuzz-runs 10000 -forge fmt --check -``` - -Expected: all validation, accounting, rollback, fee-token, balance-isolation, and reentrancy tests pass. - -- [ ] **Step 6: Commit settlement** - -```bash -git add src/Router.sol test/Router.t.sol -git commit -m "feat: settle Router output deltas" -``` - ---- - -### Task 4: Add deployment integration and package documentation - -**Files:** - -- Create: `script/deploy/DeployRouter.s.sol` -- Modify: `README.md` -- Modify: `test/Router.t.sol` - -**Interfaces:** - -- Produces `DeployRouterScript.run() returns (Router)` using `LIQUID_LANE_ADAPTER_FACTORY`. -- Documents approval, typed execution, the signed-only selector restriction, and deployment. - -- [ ] **Step 1: Write a failing deployment-script assertion** - -Add a test that sets the environment value, runs the script, and verifies the immutable: - -```solidity -function testDeployRouterUsesFactoryEnvironment() public { - vm.setEnv("LIQUID_LANE_ADAPTER_FACTORY", vm.toString(address(registry))); - Router deployed = new DeployRouterScript().run(); - assertEq(deployed.LIQUID_LANE_ADAPTER_FACTORY(), address(registry)); -} -``` - -- [ ] **Step 2: Run the deployment test and verify RED** - -Run: - -```bash -forge test --match-path test/Router.t.sol --match-test testDeployRouterUsesFactoryEnvironment -vvv -``` - -Expected: compilation fails because `DeployRouterScript` does not exist. - -- [ ] **Step 3: Add the deployment script** - -Create a script matching existing style: - -```solidity -contract DeployRouterScript is Script { - function run() public returns (Router router) { - address factory = vm.envAddress("LIQUID_LANE_ADAPTER_FACTORY"); - vm.startBroadcast(); - router = new Router(factory); - vm.stopBroadcast(); - console2.log("Deployed Router:", address(router)); - } -} -``` - -- [ ] **Step 4: Document the exact user flow** - -Add Router to `README.md`: approve input ERC-20 to Router, obtain backend `/swap` transaction, submit typed deadline `execute`, and note that only registered signed-swap calls, standard ERC-20, and distinct token pairs are supported. Document that discount calldata is rejected and must be rebuilt as a fresh signed swap. Add a deployment command using `LIQUID_LANE_ADAPTER_FACTORY`. - -- [ ] **Step 5: Run package verification** - -Run from a Foundry workspace containing the stage package dependencies: - -```bash -forge fmt --check -forge build -forge test --match-path test/Router.t.sol --fuzz-runs 10000 -``` - -Expected: formatting, compilation, and all Router tests pass. If the sparse stage checkout cannot resolve its existing workspace-only remappings, run the same commands from the parent RFQ Foundry workspace with this worktree mounted as `rfq/reactor`; do not vendor or change dependencies merely to make the sparse branch standalone. - -- [ ] **Step 6: Commit deployment and docs** - -```bash -git add script/deploy/DeployRouter.s.sol README.md test/Router.t.sol -git commit -m "docs: add Router deployment flow" -``` - -- [ ] **Step 7: Review the final diff against stage** - -```bash -git diff --check origin/stage...HEAD -git diff --stat origin/stage...HEAD -git status --short -``` - -Expected: only Router interface/implementation/tests/deployment/docs plus approved spec and plan are changed; status is clean. diff --git a/docs/superpowers/specs/2026-08-03-user-directed-router-design.md b/docs/superpowers/specs/2026-08-03-user-directed-router-design.md index e243514..2d57c56 100644 --- a/docs/superpowers/specs/2026-08-03-user-directed-router-design.md +++ b/docs/superpowers/specs/2026-08-03-user-directed-router-design.md @@ -1,76 +1,21 @@ -# User-Directed Router Design +# Inline-Execution Router Design **Date:** 2026-08-03 -**Status:** Implemented with the signed-only signer-authorization security amendment +**Status:** Implemented ## Summary -Add a standalone, non-upgradeable Solidity contract named `Router`. A swapper calls the Router directly after granting it an ordinary ERC-20 allowance. In one atomic transaction, the Router first verifies a Router-specific EIP-712 authorization from a current adapter owner, market maker, or filler for every leg; transfers each input leg directly from the swapper to a factory-registered LiquidLane adapter; invokes an authorized adapter swap selector with opaque calldata; verifies the output tokens received during this transaction; pays exact declared amounts to the declared recipients; and returns each declared token's surplus to the swapper. +`Router` is a small, ownerless execution surface for user-directed RFQ swaps. The caller grants the Router an ordinary ERC-20 allowance and submits one or more solver-produced adapter legs. For every leg, the Router verifies that the target is registered by the immutable LiquidLane adapter factory, transfers that leg's input directly from the caller to the adapter, and invokes the supplied calldata inline. Once every call succeeds, it transfers each declared output from its own balance to the declared recipient. -The Router is not a Reactor executor, does not validate RFQ orders, does not use Permit2, and does not retain user funds or approvals. Its security boundary is deliberately narrow: registered adapters, current adapter-authorized signers, per-leg EIP-712 authorization, one permitted signed-swap selector, standard ERC-20 behavior, transaction-local balance deltas, and all-or-nothing execution. - -## Branch and ABI Compatibility - -The design is being documented on `codex/router`, which currently points at the old `origin/stage` commit `8687e48`. That tree contains the legacy single-adapter `IInstantRedemptionAdapter` interface and does not contain the current `IRegistry` interface. The implementation must nevertheless target the current mainline LiquidLane model: - -- The Router stores an immutable LiquidLane adapter factory. -- Every per-leg adapter is validated with `IRegistry(factory).isEntity(adapter)`. -- The stage branch receives a minimal read-only `IRegistry` interface containing only `isEntity(address) external view returns (bool)`. -- The selector constant is pinned to the current LiquidLane signed-swap ABI and covered by selector-shape tests. It must not be inferred from the stale stage `IInstantRedemptionAdapter` overloads. -- The legacy direct-swap selector and arbitrary adapter selectors are not accepted. - -This makes the Router source buildable from the old stage tree while preserving the current mainline deployment trust boundary. It does not make the Router compatible with a legacy deployment that has no factory registry or exposes different swap selectors. - -## Goals - -- Give a user one typed entrypoint for a batch of LiquidLane swap legs with a single input token. -- Pull each leg directly from `msg.sender` into its adapter; the Router never takes custody of input tokens. -- Allow backend- or solver-produced signed-swap adapter calldata without making the Router an arbitrary-call primitive. -- Require all expected adapter outputs to arrive at the Router. -- Enforce minimum output economically at the aggregate token level across the whole batch. -- Pay exact amounts to one or more recipients and return declared-token surplus to `msg.sender`. -- Make pre-existing Router balances unusable by the current or any later caller. -- Revert the entire batch on any validation, transfer, adapter, accounting, or payout failure. - -## Non-Goals - -- Reactor order execution or implementation of `IExecutor`. -- Permit2, EIP-2612 permits, relayed execution, or meta-transactions. -- Multiple input tokens in one batch. -- An output token equal to the common input token. -- Native input, native output, wrapping, or unwrapping. -- Direct, unsigned LiquidLane swaps. -- Arbitrary targets, arbitrary selectors, `delegatecall`, or calls carrying native value. -- Partial fills, partial success, or an "allow revert" flag. -- Per-leg output guarantees. V1 guarantees only the aggregate outputs declared for the batch. -- Support for fee-on-transfer, rebasing, ERC-777-style callback, or otherwise non-standard tokens. -- Upgradeability, governance, pausing, mutable adapter allowlists, rescue, or sweeping. +Multiple legs let the backend aggregate liquidity across chosen solvers. A caller selecting one solver supplies one leg; an aggregated quote supplies the selected legs and aggregate output instructions. ## Public API -The Router exposes two nonpayable overloads. Both are protected by the same reentrancy guard and execute the same internal flow. - -| Function | Semantics | -| --- | --- | -| `execute(address tokenIn, SwapCall[] swapCalls, Output[] outputs)` | Executes immediately with no Router-level expiry. Adapter-level signatures and deadlines still apply. | -| `execute(address tokenIn, SwapCall[] swapCalls, Output[] outputs, uint256 deadline)` | Executes only while `block.timestamp <= deadline`. Equality is valid; `block.timestamp > deadline` reverts before any token interaction. | - -Both functions return no value. Successful settlement is observable through token transfers and Router events. The caller is always the input payer, the surplus recipient, and the address reported as the swapper in events. - -The Router exposes `LIQUID_LANE_ADAPTER_FACTORY()` as a public immutable getter. - -## Data Structures - -The ABI field order is fixed as follows: - ```solidity struct SwapCall { address adapter; uint256 amountIn; bytes data; - address authSigner; - uint256 authDeadline; - bytes authSignature; } struct Output { @@ -78,324 +23,68 @@ struct Output { address recipient; uint256 amount; } -``` - -### `SwapCall` - -| Field | Type | Meaning | -| --- | --- | --- | -| `adapter` | `address` | Factory-registered LiquidLane adapter that receives this leg's input and is called. | -| `amountIn` | `uint256` | Exact amount of the common `tokenIn` transferred directly from `msg.sender` to `adapter` for this leg. Must be nonzero. | -| `data` | `bytes` | Complete adapter calldata, including one permitted selector and all encoded quote data and signatures. | -| `authSigner` | `address` | Current adapter owner, market maker, or authorized filler that signs the Router authorization. | -| `authDeadline` | `uint256` | Nonzero, unexpired Router-authorization deadline. | -| `authSignature` | `bytes` | EIP-712 signature over the payer, signer, input token, complete leg, execution deadline, and authorization deadline. | - -The Router treats all calldata after the first four selector bytes as opaque. It forwards the bytes unchanged and ignores successful return data. `authSignature` is verified against EIP-712 domain `Router`, version `1`, the current chain ID, and this Router address. The exact primary type is `SwapAuthorization(address swapper,address authSigner,address tokenIn,address adapter,uint256 amountIn,bytes32 dataHash,uint256 executionDeadline,uint256 authorizationDeadline)`, where `dataHash = keccak256(data)`. `executionDeadline` is zero for the no-deadline overload and the exact top-level deadline otherwise. - -### `Output` - -| Field | Type | Meaning | -| --- | --- | --- | -| `token` | `address` | Standard ERC-20 output token. The zero address and common `tokenIn` are invalid. | -| `recipient` | `address` | Final recipient. Must be neither the zero address nor the Router. | -| `amount` | `uint256` | Exact amount transferred to this entry's recipient after aggregate minimum validation. Must be nonzero. | - -Multiple entries may use the same token and may use the same recipient. For a token, the sum of all corresponding `Output.amount` values is both the batch's aggregate minimum for that token and the exact total allocated among declared recipients. Any transaction-local excess for that token goes to `msg.sender`. - -At least one `SwapCall` and one `Output` are required. A batch with no economic input or no declared economic output is rejected. - -## Adapter Trust and Call Validation - -For every `SwapCall`, the Router performs all validation and verifies every leg authorization before funding any leg or making an executable adapter call: - -1. `adapter` is nonzero and `IRegistry(LIQUID_LANE_ADAPTER_FACTORY).isEntity(adapter)` returns true. -2. `amountIn` is nonzero. -3. `data` contains at least four bytes. -4. The first four bytes are exactly the permitted current-main signed-swap selector: `swap((address,address,uint256,uint256,address,address,uint256,uint48),bytes)`. -5. `authDeadline` is nonzero and `block.timestamp <= authDeadline`. -6. `authSigner` is currently the adapter's `owner()` or `marketMaker()`, or `isFiller(marketMaker(), authSigner)` is true. -7. OpenZeppelin `SignatureChecker` accepts `authSignature` for the exact Router EIP-712 payload, supporting EOAs and ERC-1271 signers. -8. No native value is attached to the adapter call. - -All other selectors are rejected, including the discount-swap and unsigned direct-swap overloads. The discount signature does not bind its caller, recipient, or input amount and its nonce is reusable, so exposing that calldata would create a transferable bearer authorization outside the Router boundary. A private discount may inform solver pricing, but a selected leg must be rebuilt as a fresh signed swap. The allowlist prevents a user from exercising adapter administration, nonce invalidation, acquisition, withdrawal, or fallback behavior under the Router's identity. - -The backend or solver must encode the Router as both the signed `caller` and collateral recipient. The adapter authoritatively verifies those fields and the signature. The Router does not decode or rewrite them. - -Because the payload remains opaque, a single leg is not required to produce its own pro-rata share of an output. One leg may overproduce while another underproduces, provided the user-approved batch meets every aggregate token minimum. This cross-leg netting is intentional in V1. - -## Execution Flow - -### 1. Validate the request - -Before transferring any token, the Router: - -- applies the deadline check for the deadline overload; -- rejects zero or non-contract `tokenIn`; -- rejects empty `swapCalls` or `outputs`; -- validates every output token, amount, and recipient; -- validates every adapter, amount, calldata length, and selector; -- computes the sum of all leg inputs with checked arithmetic before adapter execution; -- validates every current adapter signer and Router authorization before funding the first leg; and -- groups duplicate output tokens and sums their required amounts with checked arithmetic. - -Pre-validating the complete request avoids entering external execution with a structurally invalid later leg. - -### 2. Snapshot declared output balances - -For every unique token appearing in `outputs`, the Router records its own ERC-20 balance before any input transfer or adapter call. These baselines define funds that predate the transaction and are never spendable by this execution. - -The caller must declare every output token expected from the adapters. A token delivered to the Router but absent from `outputs` is not observable through the V1 accounting set and remains permanently isolated in the Router. There is intentionally no rescue function that could turn such a mistake into a cross-user withdrawal primitive. - -### 3. Fund and execute each leg - -For each `SwapCall`, in array order, the Router: - -1. Records `tokenIn.balanceOf(adapter)` as the leg's adapter baseline. -2. Uses safe `transferFrom` to transfer exactly `amountIn` from `msg.sender` directly to the adapter. -3. Requires the adapter balance to equal `baseline + amountIn`. Any smaller or otherwise unexpected increase is rejected as unsupported token behavior. -4. Calls `adapter` with the opaque `data`, zero native value, and ordinary EVM `call`. `delegatecall` is never used. -5. If the call fails, reverts the whole batch with the leg index, adapter, and returned revert bytes. -6. Requires the adapter's `tokenIn` balance to equal its pre-transfer baseline after the call. - -The two balance equalities prove, for standard ERC-20 tokens, that this leg received and consumed exactly the top-level `amountIn` even though the Router does not decode the payload's token or amount fields. A payload naming another input token, consuming too little, or consuming from a pre-existing adapter balance fails this invariant. Because funding and calling occur in the same transaction and every failure reverts, no successful execution leaves a Router-funded prefund at an adapter. -### 4. Verify aggregate outputs +function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) external; -After all calls complete, the Router reads its final balance for every unique declared output token. - -- A final balance below the snapshot is a balance-isolation violation and reverts. -- `produced = finalBalance - snapshotBalance` is the only amount attributable to this transaction. -- `produced` must be at least the sum of declared output amounts for that token. -- Pre-existing balances never contribute to `produced` and therefore cannot satisfy a minimum. - -Adapter return values are not used for settlement. Router balance deltas are authoritative. +function execute( + address tokenIn, + SwapCall[] calldata calls, + Output[] calldata outputs, + uint256 deadline +) external; +``` -### 5. Pay exact outputs +Both overloads are nonpayable and protected by one reentrancy guard. The deadline overload permits execution while `block.timestamp <= deadline` and reverts when `block.timestamp > deadline`. -The Router processes `outputs` in caller-supplied order. For each entry it: +## Execution -1. Records the recipient's token balance. -2. Safely transfers exactly `Output.amount` from the Router to the recipient. -3. Requires the recipient's balance to have increased by exactly `Output.amount`. +For each `SwapCall`, in caller-supplied order: -This recipient-delta assertion gives `amount` received semantics, not merely `amount` sent semantics, and causes common fee-on-transfer outputs to revert atomically. +1. Require `IRegistry(LIQUID_LANE_ADAPTER_FACTORY).isEntity(adapter)`. +2. Call `tokenIn.safeTransferFrom(msg.sender, adapter, amountIn)`. +3. Call `adapter.call(data)` with zero native value. +4. If the adapter call fails, revert with `AdapterCallFailed(index, adapter, reason)`. -### 6. Return surplus and restore baselines +After every adapter call succeeds, process each `Output` in caller-supplied order with `IERC20(token).safeTransfer(recipient, amount)`. -For each unique output token, the Router computes `surplus = produced - required` after reserving all exact recipient payments. If nonzero, it transfers the surplus to `msg.sender` and requires the swapper's balance to increase by exactly the surplus. +Any revert rolls back the complete batch, including earlier transfers and adapter effects. -Finally, the Router requires its balance of every declared output token to equal the original snapshot. This final assertion catches sender-side transfer fees and proves that the current transaction neither consumed old funds nor retained newly produced declared outputs. +## Deliberately Minimal Validation -Only after all final assertions pass does the Router emit its completion event. Any failure at any point reverts the input transfers, adapter effects, output transfers, and events. +The Router validates only: -## Core Invariants +- the factory is a nonzero contract at construction; +- each adapter is currently registered; and +- the optional deadline has not expired. -1. **Registered targets only:** every external call target is a current entity of the immutable LiquidLane adapter factory. -2. **Current signer authority:** every leg is approved by its adapter's current owner, market maker, or authorized filler. -3. **Exact Router authorization:** each signature binds the Router domain and chain, caller, `authSigner`, top-level input token, adapter, amount, calldata hash, effective execution deadline, and nonzero authorization deadline. -4. **Signed selector only:** the Router can invoke only the current signed-swap entrypoint. -5. **No arbitrary execution:** the Router never calls a user-selected non-adapter target, never uses `delegatecall`, and never forwards native value. -6. **Caller-funded:** every leg pulls from `msg.sender`; no arbitrary payer field exists. -7. **Direct input routing:** input moves from the swapper directly to the adapter and never through the Router. -8. **Exact input per leg:** the adapter balance increases by exactly `amountIn` on funding and returns to the same baseline after execution. -9. **Router-directed outputs:** successful batches rely on the backend encoding the Router as adapter recipient; declared aggregate deltas must arrive at the Router. -10. **Aggregate minimums:** for each declared output token, transaction-local production is at least the sum of its output entries. -11. **Exact recipient receipts:** each recipient's balance increases by its declared amount. -12. **Surplus belongs to the swapper:** all transaction-local declared-token production beyond required outputs is transferred to `msg.sender`. -13. **Pre-existing balance isolation:** a call can neither spend nor withdraw balances present before that call. -14. **No successful custody:** after success, each declared token balance equals its pre-call snapshot. -15. **Atomicity:** no partial batch, partial output, stranded leg prefund, or allow-failure mode exists. -16. **Reentrancy exclusion:** neither token, adapter, nor recipient callbacks can enter either `execute` overload during execution. +It does not validate nonempty arrays, nonzero amounts, token or recipient addresses, adapter code, calldata length or selector, signatures, nonces, input consumption, output production, balance deltas, conservation, or surplus. Those checks remain with ERC-20 contracts, registered adapters, backend quote construction, and the caller's transaction review. -## Reentrancy and External-Call Model +Adapter calldata is opaque and forwarded unchanged. Signed-swap and discounted-swap payloads are both supported when their registered adapter accepts them. The Router has no EIP-712 domain, signature checker, replay state, selector allowlist, or adapter-specific interface. -Both overloads share one OpenZeppelin `ReentrancyGuard` boundary. The stage compiler remains Solidity 0.8.28, so the Router deliberately does not use transient-storage reentrancy protection. All validation and snapshots happen inside that boundary. +Output entries are ordinary transfers from the Router's current balances. Pre-existing balances may therefore satisfy an output, underproduction fails only if the token transfer fails, and undeclared surplus remains in the Router. -External interactions are limited to: +## Trust Boundary and Invariants -- factory `isEntity` static calls; -- adapter `owner`, `marketMaker`, and `isFiller` static calls; -- optional ERC-1271 signature checks through OpenZeppelin `SignatureChecker`; -- ERC-20 `balanceOf`, `transferFrom`, and `transfer` calls; and -- zero-value calls to registered adapters with an allowed selector. +1. Only entities registered by the immutable factory can be called. +2. Input is transferred directly from `msg.sender` to each adapter; it does not pass through the Router. +3. Calls and output transfers preserve caller-supplied ordering. +4. No native value or `delegatecall` is used. +5. The complete execution is atomic. +6. Reentrant entry into either overload is rejected. -There is no callback entrypoint, `receive`, payable function, approval, or arbitrary target call. A malicious recipient or callback-capable token may force a revert but cannot execute a second Router batch or consume another caller's snapshot. +Registered adapters are trusted to authenticate and consume their calldata correctly. Backend responses must bind each leg to the intended adapter and transaction semantics. Users must review `tokenIn`, all input amounts, adapter targets, calldata, outputs, and the optional deadline before signing. ## Errors -The interface defines concise custom errors for these observable failure classes: - -| Error | Condition | +| Error | Meaning | | --- | --- | -| `AdapterCallFailed(index, adapter, reason)` | An allowed adapter call reverted. | -| `BalanceIsolationViolation(token, baseline, actual)` | A declared Router token balance fell below its snapshot or failed to return to it. | -| `EmptyOutputs()` | No output was declared. | -| `EmptySwapCalls()` | No swap leg was supplied. | +| `InvalidFactory(factory)` | The constructor received zero or an address without code. | | `Expired(deadline)` | The deadline overload was called after its deadline. | -| `InputConsumptionMismatch(index, expectedBaseline, actual)` | A leg did not consume exactly the transferred input increment. | -| `InputTransferMismatch(index, expected, actual)` | Direct funding did not increase the adapter balance by exactly `amountIn`. | -| `InsufficientOutput(token, required, produced)` | Aggregate transaction-local production is below the declared total. | -| `InvalidAdapter(index, adapter)` | The target is zero or is not a factory entity. | -| `InvalidAmount(index)` | A swap or output amount is zero. | -| `InvalidAuthorizationDeadline(index, deadline)` | A Router authorization has a zero or expired deadline. | -| `InvalidAuthorizationSignature(index, signer)` | The Router EIP-712 signature is invalid for the declared signer. | -| `InvalidCalldata(index)` | Adapter calldata is shorter than one selector. | -| `InvalidOutputToken(index, token)` | An output is native, zero, equal to `tokenIn`, or not an ERC-20 contract. | -| `InvalidRecipient(index, recipient)` | A recipient is zero or the Router. | -| `InvalidSelector(index, selector)` | The adapter selector is not the permitted signed-swap selector. | -| `InvalidTokenIn(token)` | `tokenIn` is zero or not a contract. | -| `OutputTransferMismatch(index, expected, actual)` | A declared recipient did not receive exactly the requested amount. | -| `SurplusTransferMismatch(token, expected, actual)` | The swapper did not receive the exact surplus. | -| `UnauthorizedAuthSigner(index, adapter, signer)` | The signer is not the adapter's current owner, market maker, or filler. | - -The reentrancy guard's standard custom error remains part of the observable surface. Arithmetic overflow uses Solidity's checked-arithmetic panic and is not remapped. - -## Events - -The Router emits: - -- `OutputTransferred(token, recipient, amount)` after each exact recipient transfer; -- `SurplusTransferred(token, swapper, amount)` for each nonzero surplus; and -- `Execute(swapper, tokenIn, totalAmountIn, swapCallCount, outputCount)` once, after all transfers and final baseline assertions succeed. - -`swapper`, `tokenIn`, output `token`, and output `recipient` are indexed where Solidity's event topic limit permits. Failed batches emit no durable events. - -## Unsupported Token and Native Behavior - -V1 supports ordinary ERC-20 tokens whose balances change exactly by the requested transfer amount. - -- **Fee-on-transfer input:** rejected by the adapter funding delta check. -- **Fee-on-transfer output:** rejected by the recipient or surplus balance-delta check, or by the final Router baseline assertion. -- **Sender-side transfer fee:** rejected by the final Router baseline assertion. -- **Rebasing token:** unsupported. A rebase during external execution can invalidate snapshot arithmetic or make a delta appear to be swap production. Deployment and integration configuration must exclude rebasing assets even if a particular call happens to pass the checks. -- **Callback-capable token:** unsupported. The guard prevents reentrant settlement, but such a token may revert the batch or have balance semantics outside the V1 model. -- **Native currency:** both overloads are nonpayable, `address(0)` is rejected as an output token, there is no `receive` or payable fallback, and adapter calls always use zero value. ETH forced onto the contract is permanently inaccessible and never participates in accounting. - -## Security Assumptions and Explicit Trade-offs - -- The immutable factory correctly identifies authentic LiquidLane adapters. Factory compromise or registration of malicious adapters is outside the Router's local trust boundary. -- The current LiquidLane signed-swap selector retains its documented semantics. -- The backend or solver encodes both `recipient = Router` and `caller = Router`. Incorrect encoding is rejected by the adapter or aggregate output validation. -- The transaction caller authorizes the aggregate settlement by submitting the transaction, while every individual adapter leg also requires a current adapter-authorized Router EIP-712 signature. -- The Router has no authorization-ID or replay-storage mapping. The permitted LiquidLane signed-swap selector consumes its adapter nonce, which remains authoritative for replay protection without unbounded Router storage. -- Output protection is aggregate per token, not per leg. Cross-leg subsidy is accepted because the user receives the declared batch result. -- Only tokens listed in `outputs` are snapshotted and distributed. Undeclared tokens sent to the Router remain isolated permanently; a later user cannot claim them as transaction-local surplus. -- There is no rescue function. Recoverability of accidental or forced balances is intentionally sacrificed to keep the pre-existing-balance invariant unconditional and ownerless. - -## Test Plan - -Create focused Foundry tests in `test/Router.t.sol` with registry, adapter, token, callback, and recipient mocks. Tests must cover both overloads and all branches. - -### Construction and API - -- Constructor rejects a zero or non-contract factory. -- The immutable getter returns the configured factory. -- The no-deadline overload succeeds under the same economic conditions as the deadline overload. -- Deadline equality succeeds; one second after the deadline reverts before any transfer. -- Both overloads reject attached native value at the ABI boundary. - -### Structural validation - -- Zero/non-contract `tokenIn`, empty calls, and empty outputs revert. -- Zero amounts, zero recipients, Router recipients, native output, and non-contract output tokens revert. -- Duplicate output tokens and recipients are accepted and aggregated correctly. -- An unregistered or zero adapter reverts. -- Calldata shorter than four bytes reverts. -- The signed-swap selector succeeds. -- Discount swap, direct swap, adapter administration, arbitrary, fallback, and legacy stage selectors revert. -- Selector constants are pinned against the current LiquidLane interface shape. - -### Input routing - -- Each leg transfers directly from the caller to its selected adapter; the Router input balance remains unchanged. -- Multiple adapters and repeated calls to one adapter work in array order. -- Missing allowance and insufficient caller balance bubble/revert atomically. -- Fee-on-transfer input fails the exact adapter funding delta. -- A call that consumes less, more, or a different input token fails the post-call adapter baseline check. -- A failure on a later leg rolls back earlier adapter calls and transfers. -- Adapter revert data is reported with the correct index and target. - -### Router authorization - -- The exact `Router`/`1` EIP-712 domain and primary type hash are pinned. -- Owner, market-maker, filler, EOA, and ERC-1271 authorizations succeed. -- A zero or expired authorization deadline fails before funding; deadline equality succeeds. -- Copying a signed call to another payer or modifying the token, adapter calldata, amount, execution deadline, authorization deadline, signer, or signature fails before funding. -- Revoking owner, market-maker, or filler authority before execution makes the signer unauthorized. -- A malformed later authorization is rejected before the first adapter executes. -- Input-total overflow is rejected before any adapter execution. - -### Output accounting - -- One token/one recipient settles exactly. -- One token split across several recipients uses the aggregate minimum and exact per-recipient amounts. -- Several output tokens settle independently. -- Output token equal to `tokenIn` is rejected before any transfer. -- Aggregate underproduction reverts the entire batch. -- Exact production leaves no surplus event. -- Overproduction pays exact outputs and returns the precise surplus to the caller. -- A fee-on-transfer output or surplus transfer reverts on recipient delta mismatch. -- Sender-side fee behavior reverts on final baseline mismatch. -- Tokens not declared as outputs are not paid or made claimable by a later call. - -### Pre-existing balance isolation - -- A pre-existing Router balance cannot satisfy an output minimum. -- Exact outputs and surplus leave the pre-existing balance unchanged. -- A later caller cannot sweep a prior caller's or forced token balance. -- A malicious registered adapter cannot reduce a declared pre-existing output balance without causing a revert. -- Forced ETH has no effect on ERC-20 accounting and cannot be withdrawn through Router. - -### Reentrancy and atomicity - -- A recipient callback attempting either overload reverts with the guard and rolls back the batch. -- A callback-capable input or output token cannot enter Router settlement. -- A registered malicious adapter cannot reenter Router. -- A payout failure after all adapter calls rolls back input transfers and adapter state. -- No event survives any reverted execution. - -### Integration and deployment - -- Add a mainline LiquidLane interface-shape test for the allowed signed-swap selector and the rejected discount-swap selector. -- Add an integration test with a factory mock exposing only `isEntity` to prove old-stage compatibility of the minimal interface. -- Add a deployment-script test confirming constructor validation and the immutable factory. -- Include Router in bytecode-size and gas snapshots according to repository conventions. - -## Deployment Design - -Deploy Router directly with one constructor argument: the chain's LiquidLane adapter factory. The contract has no proxy, initializer, owner, roles, storage configuration, or upgrade path. A new factory requires a new Router deployment. - -Add: - -- `src/Router.sol`; -- `src/interfaces/IRouter.sol`; -- `src/interfaces/ILiquidLaneAdapterAuthorization.sol`; -- the minimal `src/interfaces/IRegistry.sol` when implementing from the old stage base; -- `test/Router.t.sol`; -- `script/deploy/DeployRouter.s.sol`; and -- a deployment-script test under `test/deploy/` where that layout is present after synchronization with current main. - -The deployment script reads `LIQUID_LANE_ADAPTER_FACTORY`, deploys Router, asserts the immutable matches, and logs the Router and factory addresses. Production deployment must use the per-chain factory already used by current Reactor configuration. - -After deployment, backend and solver configuration must use the deployed Router address as: - -- `SignedSwap.caller`; -- `SignedSwap.recipient`. - -Every solver-produced leg also needs the current adapter-authorized Router signature described above. Users approve the input ERC-20 to Router and submit the Router transaction themselves. +| `InvalidAdapter(index, adapter)` | The indexed call target is not currently registered. | +| `AdapterCallFailed(index, adapter, reason)` | The indexed adapter reverted; `reason` preserves its revert data. | -## Acceptance Criteria +ERC-20 failures use OpenZeppelin `SafeERC20` behavior and are not wrapped in Router-specific errors. -The feature is complete when: +## Deployment -1. Both typed overloads implement the same atomic execution path and the deadline overload expires exactly as specified. -2. Every leg targets a factory entity, uses exactly the pinned signed-swap selector, and has a valid unexpired authorization from its adapter's current owner, market maker, or filler. -3. The authorization binds the caller, signer, input token, adapter, amount, calldata hash, effective execution deadline, and authorization deadline before any leg is funded. -4. Every input leg is transferred directly from the caller, received exactly, and consumed exactly. -5. No declared output minimum can be satisfied by a pre-existing Router balance. -6. Every declared recipient receives exactly its amount, every declared-token surplus goes to the caller, and the Router returns to each declared token's starting balance. -7. Native currency and unsupported token behavior cannot silently participate in a successful batch. -8. Reentrancy, later-leg failure, adapter failure, and payout failure roll back the entire transaction. -9. The contract is ownerless, non-upgradeable, nonpayable, and has no rescue or arbitrary-call surface. -10. Unit, selector-shape, integration, deployment, formatting, size, and gas-snapshot checks pass under the repository's Foundry workflow. +The deployment script reads `LIQUID_LANE_ADAPTER_FACTORY` and deploys `Router` directly. There is no proxy, owner, role, pause, mutable allowlist, rescue, or upgrade mechanism. diff --git a/script/deploy/DeployRouter.s.sol b/script/deploy/DeployRouter.s.sol index dded3f0..4cfa096 100644 --- a/script/deploy/DeployRouter.s.sol +++ b/script/deploy/DeployRouter.s.sol @@ -5,7 +5,7 @@ import {Script, console2} from "forge-std/Script.sol"; import {Router} from "../../src/Router.sol"; -/// @notice Deploys the ownerless, EIP-712-authenticated user-directed Router. +/// @notice Deploys the ownerless user-directed Router. contract DeployRouterScript is Script { function run() public returns (Router router) { address liquidLaneAdapterFactory = vm.envAddress("LIQUID_LANE_ADAPTER_FACTORY"); diff --git a/src/Router.sol b/src/Router.sol index 68ea0ef..2239fed 100644 --- a/src/Router.sol +++ b/src/Router.sol @@ -2,30 +2,22 @@ // Copyright (c) 2026 Symbiotic pragma solidity 0.8.28; -import {ILiquidLaneAdapterAuthorization} from "./interfaces/ILiquidLaneAdapterAuthorization.sol"; import {IRegistry} from "./interfaces/IRegistry.sol"; import {IRouter} from "./interfaces/IRouter.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; -import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; -import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; /// @title Router -/// @notice Atomically funds registered adapters and settles transaction-local output balances. +/// @notice Atomically funds registered adapters and transfers their outputs to declared recipients. /// @custom:security-contact security@symbiotic.fi -contract Router is IRouter, EIP712, ReentrancyGuard { +contract Router is IRouter, ReentrancyGuard { using SafeERC20 for IERC20; - bytes4 internal constant SIGNED_SWAP_SELECTOR = 0x9a4568b6; - bytes32 public constant SWAP_AUTHORIZATION_TYPEHASH = keccak256( - "SwapAuthorization(address swapper,address authSigner,address tokenIn,address adapter,uint256 amountIn,bytes32 dataHash,uint256 executionDeadline,uint256 authorizationDeadline)" - ); - address public immutable LIQUID_LANE_ADAPTER_FACTORY; - constructor(address liquidLaneAdapterFactory) EIP712("Router", "1") { + constructor(address liquidLaneAdapterFactory) { if (liquidLaneAdapterFactory == address(0) || liquidLaneAdapterFactory.code.length == 0) { revert InvalidFactory(liquidLaneAdapterFactory); } @@ -34,7 +26,7 @@ contract Router is IRouter, EIP712, ReentrancyGuard { /// @inheritdoc IRouter function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) external nonReentrant { - _execute(tokenIn, calls, outputs, 0); + _execute(tokenIn, calls, outputs); } /// @inheritdoc IRouter @@ -44,243 +36,25 @@ contract Router is IRouter, EIP712, ReentrancyGuard { { // forge-lint: disable-next-line(block-timestamp) if (block.timestamp > deadline) revert Expired(deadline); - _execute(tokenIn, calls, outputs, deadline); + _execute(tokenIn, calls, outputs); } - function _execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs, uint256 executionDeadline) - internal - { - uint256 totalAmountIn = _validate(tokenIn, calls, outputs, executionDeadline); - - (address[] memory tokens, uint256[] memory baselines, uint256[] memory required, uint256 uniqueCount) = - _snapshotOutputs(outputs); - _executeCalls(tokenIn, calls); - uint256[] memory produced = _measureOutputs(tokens, baselines, required, uniqueCount); - - _transferOutputs(outputs); - _transferSurplus(tokens, baselines, required, produced, uniqueCount); - - emit Execute(msg.sender, tokenIn, totalAmountIn, calls.length, outputs.length); - } - - function _executeCalls(address tokenIn, SwapCall[] calldata calls) internal { + function _execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) internal { + IRegistry registry = IRegistry(LIQUID_LANE_ADAPTER_FACTORY); IERC20 inputToken = IERC20(tokenIn); + for (uint256 i; i < calls.length; ++i) { SwapCall calldata swapCall = calls[i]; - uint256 senderBaseline = inputToken.balanceOf(msg.sender); - uint256 adapterBaseline = inputToken.balanceOf(swapCall.adapter); + if (!registry.isEntity(swapCall.adapter)) revert InvalidAdapter(i, swapCall.adapter); inputToken.safeTransferFrom(msg.sender, swapCall.adapter, swapCall.amountIn); - - uint256 adapterFunded = inputToken.balanceOf(swapCall.adapter); - uint256 adapterReceived = adapterFunded >= adapterBaseline ? adapterFunded - adapterBaseline : 0; - if (adapterReceived != swapCall.amountIn) { - revert InputTransferMismatch(i, swapCall.amountIn, adapterReceived); - } - - uint256 senderAfter = inputToken.balanceOf(msg.sender); - uint256 senderSpent = senderAfter <= senderBaseline ? senderBaseline - senderAfter : 0; - if (senderSpent != swapCall.amountIn) { - revert InputTransferMismatch(i, swapCall.amountIn, senderSpent); - } - (bool success, bytes memory reason) = swapCall.adapter.call(swapCall.data); if (!success) revert AdapterCallFailed(i, swapCall.adapter, reason); - - uint256 remaining = inputToken.balanceOf(swapCall.adapter); - if (remaining != adapterBaseline) { - revert InputConsumptionMismatch(i, adapterBaseline, remaining); - } - } - } - - function _transferOutputs(Output[] calldata outputs) internal { - for (uint256 i; i < outputs.length; ++i) { - Output calldata output = outputs[i]; - IERC20 token = IERC20(output.token); - uint256 routerBaseline = token.balanceOf(address(this)); - uint256 recipientBaseline = token.balanceOf(output.recipient); - - token.safeTransfer(output.recipient, output.amount); - - uint256 recipientAfter = token.balanceOf(output.recipient); - uint256 received = recipientAfter >= recipientBaseline ? recipientAfter - recipientBaseline : 0; - if (received != output.amount) revert OutputTransferMismatch(i, output.amount, received); - - uint256 routerAfter = token.balanceOf(address(this)); - uint256 spent = routerAfter <= routerBaseline ? routerBaseline - routerAfter : 0; - if (spent != output.amount) revert OutputTransferMismatch(i, output.amount, spent); - - emit OutputTransferred(output.token, output.recipient, output.amount); - } - } - - function _transferSurplus( - address[] memory tokens, - uint256[] memory baselines, - uint256[] memory required, - uint256[] memory produced, - uint256 uniqueCount - ) internal { - for (uint256 i; i < uniqueCount; ++i) { - uint256 surplus = produced[i] - required[i]; - IERC20 token = IERC20(tokens[i]); - if (surplus > 0) { - uint256 routerBaseline = token.balanceOf(address(this)); - uint256 swapperBaseline = token.balanceOf(msg.sender); - - token.safeTransfer(msg.sender, surplus); - - uint256 swapperAfter = token.balanceOf(msg.sender); - uint256 received = swapperAfter >= swapperBaseline ? swapperAfter - swapperBaseline : 0; - if (received != surplus) revert SurplusTransferMismatch(tokens[i], surplus, received); - - uint256 routerAfter = token.balanceOf(address(this)); - uint256 spent = routerAfter <= routerBaseline ? routerBaseline - routerAfter : 0; - if (spent != surplus) revert SurplusTransferMismatch(tokens[i], surplus, spent); - - emit SurplusTransferred(tokens[i], msg.sender, surplus); - } - - uint256 finalBalance = token.balanceOf(address(this)); - if (finalBalance != baselines[i]) { - revert BalanceIsolationViolation(tokens[i], baselines[i], finalBalance); - } - } - } - - function _snapshotOutputs(Output[] calldata outputs) - internal - view - returns (address[] memory tokens, uint256[] memory baselines, uint256[] memory required, uint256 uniqueCount) - { - tokens = new address[](outputs.length); - baselines = new uint256[](outputs.length); - required = new uint256[](outputs.length); - - for (uint256 i; i < outputs.length; ++i) { - address token = outputs[i].token; - uint256 tokenIndex = uniqueCount; - for (uint256 j; j < uniqueCount; ++j) { - if (tokens[j] == token) { - tokenIndex = j; - break; - } - } - - if (tokenIndex == uniqueCount) { - tokens[uniqueCount] = token; - baselines[uniqueCount] = IERC20(token).balanceOf(address(this)); - ++uniqueCount; - } - required[tokenIndex] += outputs[i].amount; } - } - - function _measureOutputs( - address[] memory tokens, - uint256[] memory baselines, - uint256[] memory required, - uint256 uniqueCount - ) internal view returns (uint256[] memory produced) { - produced = new uint256[](uniqueCount); - for (uint256 i; i < uniqueCount; ++i) { - uint256 finalBalance = IERC20(tokens[i]).balanceOf(address(this)); - if (finalBalance < baselines[i]) { - revert BalanceIsolationViolation(tokens[i], baselines[i], finalBalance); - } - produced[i] = finalBalance - baselines[i]; - if (produced[i] < required[i]) { - revert InsufficientOutput(tokens[i], required[i], produced[i]); - } - } - } - - function _validate(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs, uint256 executionDeadline) - internal - view - returns (uint256 totalAmountIn) - { - if (tokenIn == address(0) || tokenIn.code.length == 0) revert InvalidTokenIn(tokenIn); - if (calls.length == 0) revert EmptySwapCalls(); - if (outputs.length == 0) revert EmptyOutputs(); for (uint256 i; i < outputs.length; ++i) { Output calldata output = outputs[i]; - if (output.token == address(0) || output.token == tokenIn || output.token.code.length == 0) { - revert InvalidOutputToken(i, output.token); - } - if (output.recipient == address(0) || output.recipient == address(this)) { - revert InvalidRecipient(i, output.recipient); - } - if (output.amount == 0) revert InvalidAmount(i); - } - - for (uint256 i; i < calls.length; ++i) { - SwapCall calldata swapCall = calls[i]; - if (swapCall.adapter == address(0) || swapCall.adapter.code.length == 0) { - revert InvalidAdapter(i, swapCall.adapter); - } - if (swapCall.amountIn == 0) revert InvalidAmount(i); - totalAmountIn += swapCall.amountIn; - if (swapCall.data.length < 4) revert InvalidCalldata(i); - - bytes4 selector = _selector(swapCall.data); - if (selector != SIGNED_SWAP_SELECTOR) { - revert InvalidSelector(i, selector); - } - } - - IRegistry registry = IRegistry(LIQUID_LANE_ADAPTER_FACTORY); - for (uint256 i; i < calls.length; ++i) { - SwapCall calldata swapCall = calls[i]; - if (!registry.isEntity(swapCall.adapter)) revert InvalidAdapter(i, swapCall.adapter); - _validateAuthorization(tokenIn, swapCall, i, executionDeadline); - } - } - - function _validateAuthorization( - address tokenIn, - SwapCall calldata swapCall, - uint256 index, - uint256 executionDeadline - ) internal view { - // forge-lint: disable-next-line(block-timestamp) - if (swapCall.authDeadline == 0 || block.timestamp > swapCall.authDeadline) { - revert InvalidAuthorizationDeadline(index, swapCall.authDeadline); - } - - ILiquidLaneAdapterAuthorization adapter = ILiquidLaneAdapterAuthorization(swapCall.adapter); - address signer = swapCall.authSigner; - if (signer != adapter.owner()) { - address marketMaker = adapter.marketMaker(); - if (signer != marketMaker && !adapter.isFiller(marketMaker, signer)) { - revert UnauthorizedAuthSigner(index, swapCall.adapter, signer); - } - } - - bytes32 structHash = keccak256( - abi.encode( - SWAP_AUTHORIZATION_TYPEHASH, - msg.sender, - signer, - tokenIn, - swapCall.adapter, - swapCall.amountIn, - keccak256(swapCall.data), - executionDeadline, - swapCall.authDeadline - ) - ); - if (!SignatureChecker.isValidSignatureNowCalldata(signer, _hashTypedDataV4(structHash), swapCall.authSignature)) - { - revert InvalidAuthorizationSignature(index, signer); - } - } - - function _selector(bytes calldata data) internal pure returns (bytes4 selector) { - assembly ("memory-safe") { - selector := calldataload(data.offset) + IERC20(output.token).safeTransfer(output.recipient, output.amount); } } } diff --git a/src/interfaces/ILiquidLaneAdapterAuthorization.sol b/src/interfaces/ILiquidLaneAdapterAuthorization.sol deleted file mode 100644 index c351322..0000000 --- a/src/interfaces/ILiquidLaneAdapterAuthorization.sol +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -// Copyright (c) 2026 Symbiotic -pragma solidity ^0.8.0; - -/// @notice Minimal LiquidLane adapter interface used to validate current swap signers. -interface ILiquidLaneAdapterAuthorization { - function owner() external view returns (address); - function marketMaker() external view returns (address); - function isFiller(address marketMaker, address filler) external view returns (bool); -} diff --git a/src/interfaces/IRouter.sol b/src/interfaces/IRouter.sol index 97582d5..63183d5 100644 --- a/src/interfaces/IRouter.sol +++ b/src/interfaces/IRouter.sol @@ -4,23 +4,17 @@ pragma solidity ^0.8.0; /// @notice User-directed batch execution interface for registered LiquidLane adapters. interface IRouter { - /// @notice One authenticated adapter leg. + /// @notice One adapter leg. /// @param adapter Factory-registered LiquidLane adapter that receives the input and executes `data`. - /// @param amountIn Exact common input-token amount funded directly from the caller. - /// @param data Complete signed-swap adapter calldata. - /// @param authSigner Current adapter owner, market maker, or authorized filler that approved this Router leg. - /// @param authDeadline Nonzero Router-authorization expiry included in the signed payload. - /// @param authSignature EIP-712 signature over this leg, its payer, token, and effective execution deadline. + /// @param amountIn Common input-token amount funded directly from the caller. + /// @param data Complete adapter calldata. struct SwapCall { address adapter; uint256 amountIn; bytes data; - address authSigner; - uint256 authDeadline; - bytes authSignature; } - /// @notice Exact output payment made after the batch meets its aggregate minimums. + /// @notice Output payment made after all adapter calls complete. struct Output { address token; address recipient; @@ -28,40 +22,11 @@ interface IRouter { } error AdapterCallFailed(uint256 index, address adapter, bytes reason); - error BalanceIsolationViolation(address token, uint256 baseline, uint256 actual); - error EmptyOutputs(); - error EmptySwapCalls(); error Expired(uint256 deadline); - error InputConsumptionMismatch(uint256 index, uint256 expectedBaseline, uint256 actual); - error InputTransferMismatch(uint256 index, uint256 expected, uint256 actual); - error InsufficientOutput(address token, uint256 required, uint256 produced); error InvalidAdapter(uint256 index, address adapter); - error InvalidAmount(uint256 index); - error InvalidAuthorizationDeadline(uint256 index, uint256 deadline); - error InvalidAuthorizationSignature(uint256 index, address signer); - error InvalidCalldata(uint256 index); error InvalidFactory(address factory); - error InvalidOutputToken(uint256 index, address token); - error InvalidRecipient(uint256 index, address recipient); - error InvalidSelector(uint256 index, bytes4 selector); - error InvalidTokenIn(address token); - error OutputTransferMismatch(uint256 index, uint256 expected, uint256 actual); - error SurplusTransferMismatch(address token, uint256 expected, uint256 actual); - error UnauthorizedAuthSigner(uint256 index, address adapter, address signer); - - event OutputTransferred(address indexed token, address indexed recipient, uint256 amount); - event SurplusTransferred(address indexed token, address indexed swapper, uint256 amount); - event Execute( - address indexed swapper, - address indexed tokenIn, - uint256 totalAmountIn, - uint256 swapCallCount, - uint256 outputCount - ); function LIQUID_LANE_ADAPTER_FACTORY() external view returns (address); - /// @notice Exact primary type hash for the Router `SwapAuthorization` EIP-712 payload. - function SWAP_AUTHORIZATION_TYPEHASH() external view returns (bytes32); function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) external; function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs, uint256 deadline) external; } diff --git a/test/Router.t.sol b/test/Router.t.sol index 83c3e0d..e62b525 100644 --- a/test/Router.t.sol +++ b/test/Router.t.sol @@ -4,13 +4,12 @@ pragma solidity 0.8.28; import {Test} from "forge-std/Test.sol"; -import {IERC5267} from "@openzeppelin/contracts/interfaces/IERC5267.sol"; -import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; - import {Router} from "../src/Router.sol"; import {IRouter} from "../src/interfaces/IRouter.sol"; import {DeployRouterScript} from "../script/deploy/DeployRouter.s.sol"; +error MockAdapterFailure(); + contract MockRegistry { mapping(address entity => bool registered) public isEntity; @@ -19,31 +18,13 @@ contract MockRegistry { } } -contract Mock1271Signer { - bytes32 public expectedDigest; - bytes32 public expectedSignatureHash; - - function setExpected(bytes32 digest, bytes calldata signature) external { - expectedDigest = digest; - expectedSignatureHash = keccak256(signature); - } - - function isValidSignature(bytes32 digest, bytes calldata signature) external view returns (bytes4) { - if (digest == expectedDigest && keccak256(signature) == expectedSignatureHash) { - return IERC1271.isValidSignature.selector; - } - return 0xffffffff; - } -} - contract MockERC20 { string public name; string public symbol; uint8 public constant decimals = 18; uint256 public totalSupply; - uint256 public feeBps; - bool public senderPaysFee; + bool public failTransfer; mapping(address account => uint256 balance) public balanceOf; mapping(address owner => mapping(address spender => uint256 amount)) public allowance; @@ -52,9 +33,8 @@ contract MockERC20 { symbol = symbol_; } - function setFee(uint256 feeBps_, bool senderPaysFee_) external { - feeBps = feeBps_; - senderPaysFee = senderPaysFee_; + function setFailTransfer(bool status) external { + failTransfer = status; } function mint(address account, uint256 amount) external { @@ -73,43 +53,34 @@ contract MockERC20 { } function transfer(address recipient, uint256 amount) external returns (bool) { - _transfer(msg.sender, recipient, amount); + if (failTransfer) return false; + balanceOf[msg.sender] -= amount; + balanceOf[recipient] += amount; return true; } function transferFrom(address owner, address recipient, uint256 amount) external returns (bool) { uint256 approved = allowance[owner][msg.sender]; if (approved != type(uint256).max) allowance[owner][msg.sender] = approved - amount; - _transfer(owner, recipient, amount); + balanceOf[owner] -= amount; + balanceOf[recipient] += amount; return true; } - - function _transfer(address owner, address recipient, uint256 amount) internal { - uint256 fee = amount * feeBps / 10_000; - uint256 debit = senderPaysFee ? amount + fee : amount; - uint256 credit = senderPaysFee ? amount : amount - fee; - balanceOf[owner] -= debit; - balanceOf[recipient] += credit; - totalSupply -= fee; - } } contract MockAdapter { - bytes4 internal constant SIGNED_SWAP_SELECTOR = 0x9a4568b6; - bytes4 internal constant DISCOUNT_SWAP_SELECTOR = 0x8fa5c671; - MockERC20 public immutable inputToken; MockERC20 public immutable outputToken; address public immutable router; - address public owner; - address public marketMaker; - mapping(address maker => mapping(address filler => bool authorized)) public isFiller; + uint256 public outputAmount; - uint256 public leaveInput; uint256 public callCount; - bool public shouldRevert; - bool public reduceRouterBalance; + uint256 public inputBalanceAtCall; + address public lastCaller; + bytes public lastData; + bool public consumeInput; bool public reenter; + bool public shouldRevert; constructor(MockERC20 inputToken_, MockERC20 outputToken_, address router_) { inputToken = inputToken_; @@ -117,86 +88,55 @@ contract MockAdapter { router = router_; } - function configure(uint256 outputAmount_, uint256 leaveInput_) external { + function configure(uint256 outputAmount_, bool consumeInput_, bool reenter_, bool shouldRevert_) external { outputAmount = outputAmount_; - leaveInput = leaveInput_; - } - - function setOwner(address owner_) external { - owner = owner_; - } - - function setMarketMaker(address marketMaker_) external { - marketMaker = marketMaker_; - } - - function setFiller(address maker, address filler, bool authorized) external { - isFiller[maker][filler] = authorized; - } - - function setShouldRevert(bool status) external { - shouldRevert = status; - } - - function setReduceRouterBalance(bool status) external { - reduceRouterBalance = status; - } - - function setReenter(bool status) external { - reenter = status; + consumeInput = consumeInput_; + reenter = reenter_; + shouldRevert = shouldRevert_; } fallback() external { - if (msg.sig != SIGNED_SWAP_SELECTOR && msg.sig != DISCOUNT_SWAP_SELECTOR) revert("selector"); - if (shouldRevert) revert("adapter failed"); + if (shouldRevert) revert MockAdapterFailure(); ++callCount; - uint256 inputBalance = inputToken.balanceOf(address(this)); - if (inputBalance > leaveInput) inputToken.burn(address(this), inputBalance - leaveInput); - if (reduceRouterBalance) outputToken.burn(router, 1 ether); - if (outputAmount > 0) outputToken.mint(router, outputAmount); + inputBalanceAtCall = inputToken.balanceOf(address(this)); + lastCaller = msg.sender; + lastData = msg.data; if (reenter) { - IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](0); - IRouter.Output[] memory outputs = new IRouter.Output[](0); - Router(router).execute(address(inputToken), calls, outputs); + Router(router).execute(address(inputToken), new IRouter.SwapCall[](0), new IRouter.Output[](0)); } + + if (consumeInput) { + uint256 inputBalance = inputToken.balanceOf(address(this)); + if (inputBalance != 0) inputToken.burn(address(this), inputBalance); + } + if (outputAmount != 0) outputToken.mint(router, outputAmount); } } contract RouterTest is Test { - bytes4 internal constant SIGNED_SWAP_SELECTOR = 0x9a4568b6; - bytes4 internal constant DISCOUNT_SWAP_SELECTOR = 0x8fa5c671; - bytes32 internal constant DOMAIN_TYPEHASH = 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f; - bytes32 internal constant SWAP_AUTHORIZATION_TYPEHASH = - 0xc1a9681894ce21cd15802373fbf380e6fb5ea302fce47e912d119686bb4eb349; - uint256 internal constant AUTH_SIGNER_PRIVATE_KEY = 0xA11CE; - uint256 internal constant AUTHORIZATION_DEADLINE = type(uint256).max; - address internal swapper = makeAddr("swapper"); address internal recipient = makeAddr("recipient"); - address internal authSigner; + address internal secondRecipient = makeAddr("secondRecipient"); + MockRegistry internal registry; MockERC20 internal inputToken; MockERC20 internal outputToken; - MockERC20 internal secondOutputToken; Router internal router; MockAdapter internal adapter0; MockAdapter internal adapter1; function setUp() public { - authSigner = vm.addr(AUTH_SIGNER_PRIVATE_KEY); registry = new MockRegistry(); router = new Router(address(registry)); inputToken = new MockERC20("Input", "IN"); outputToken = new MockERC20("Output", "OUT"); - secondOutputToken = new MockERC20("Second", "SECOND"); adapter0 = new MockAdapter(inputToken, outputToken, address(router)); adapter1 = new MockAdapter(inputToken, outputToken, address(router)); + registry.setEntity(address(adapter0), true); registry.setEntity(address(adapter1), true); - adapter0.setOwner(authSigner); - adapter1.setOwner(authSigner); inputToken.mint(swapper, 1000 ether); vm.prank(swapper); inputToken.approve(address(router), type(uint256).max); @@ -223,851 +163,227 @@ contract RouterTest is Test { assertEq(deployed.LIQUID_LANE_ADAPTER_FACTORY(), address(registry)); } - function testDeadlineEqualityIsValid() public { - vm.warp(100); - vm.expectRevert(IRouter.EmptySwapCalls.selector); - router.execute(address(inputToken), new IRouter.SwapCall[](0), new IRouter.Output[](0), 100); - } - - function testExpiredDeadlineRevertsBeforeValidation() public { - vm.warp(101); - vm.expectRevert(abi.encodeWithSelector(IRouter.Expired.selector, 100)); - router.execute(address(0), new IRouter.SwapCall[](0), new IRouter.Output[](0), 100); - } - - function testRejectsEmptySwapCalls() public { - vm.expectRevert(IRouter.EmptySwapCalls.selector); - router.execute( - address(inputToken), new IRouter.SwapCall[](0), _oneOutput(address(outputToken), 1 ether, recipient) - ); - } - - function testRejectsEmptyOutputs() public { - vm.expectRevert(IRouter.EmptyOutputs.selector); - router.execute( - address(inputToken), _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), new IRouter.Output[](0) - ); - } - - function testRejectsInvalidInputToken() public { - address notContract = makeAddr("notToken"); - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidTokenIn.selector, notContract)); - router.execute( - notContract, - _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(address(outputToken), 1 ether, recipient) - ); - } - - function testRejectsSameInputAndOutputToken() public { - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidOutputToken.selector, 0, address(inputToken))); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(address(inputToken), 1 ether, recipient) - ); - } - - function testRejectsInvalidOutputToken() public { - address notContract = makeAddr("notOutputToken"); - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidOutputToken.selector, 0, notContract)); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(notContract, 1 ether, recipient) - ); - } - - function testRejectsInvalidRecipient() public { - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidRecipient.selector, 0, address(0))); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(address(outputToken), 1 ether, address(0)) - ); - } - - function testRejectsRouterAsRecipient() public { - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidRecipient.selector, 0, address(router))); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(address(outputToken), 1 ether, address(router)) - ); - } - - function testRejectsZeroOutputAmount() public { - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAmount.selector, 0)); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(address(outputToken), 0, recipient) - ); - } - - function testRejectsZeroCallAmount() public { - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAmount.selector, 0)); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 0, SIGNED_SWAP_SELECTOR), - _oneOutput(address(outputToken), 1 ether, recipient) - ); - } - - function testRejectsUnregisteredAdapter() public { - MockAdapter unregistered = new MockAdapter(inputToken, outputToken, address(router)); - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAdapter.selector, 0, address(unregistered))); - router.execute( - address(inputToken), - _oneCall(address(unregistered), 1 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(address(outputToken), 1 ether, recipient) - ); - } - - function testRejectsShortCalldata() public { - IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](1); - calls[0] = _signedCall( - swapper, - address(inputToken), - address(adapter0), - 1 ether, - hex"9a4568", - 0, - AUTHORIZATION_DEADLINE, - AUTH_SIGNER_PRIVATE_KEY - ); - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidCalldata.selector, 0)); - router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); - } - - function testRejectsUnapprovedSelector() public { - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidSelector.selector, 0, bytes4(0x12345678))); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 1 ether, 0x12345678), - _oneOutput(address(outputToken), 1 ether, recipient) - ); - } - - function testRejectsDiscountSelector() public { - adapter0.configure(1 ether, 0); - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidSelector.selector, 0, DISCOUNT_SWAP_SELECTOR)); - vm.prank(swapper); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 1 ether, DISCOUNT_SWAP_SELECTOR), - _oneOutput(address(outputToken), 1 ether, recipient) - ); - } - - function testAcceptsAuthorizationFromAdapterMarketMaker() public { - adapter0.setOwner(makeAddr("otherOwner")); - adapter0.setMarketMaker(authSigner); - adapter0.configure(1 ether, 0); + function testSwapCallUsesThreeFieldAbiAndForwardsOpaqueCalldata() public { + bytes memory data = hex"deadbeef010203"; + IRouter.SwapCall[] memory calls = _oneCall(address(adapter0), 0, data); + IRouter.Output[] memory outputs = new IRouter.Output[](0); + bytes4 selector = bytes4(keccak256("execute(address,(address,uint256,bytes)[],(address,address,uint256)[])")); vm.prank(swapper); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(address(outputToken), 1 ether, recipient) - ); + (bool success,) = address(router).call(abi.encodeWithSelector(selector, address(inputToken), calls, outputs)); - assertEq(outputToken.balanceOf(recipient), 1 ether); + assertTrue(success); + assertEq(adapter0.callCount(), 1); + assertEq(adapter0.lastCaller(), address(router)); + assertEq(adapter0.lastData(), data); } - function testAcceptsAuthorizationFromAdapterFiller() public { - address marketMaker = makeAddr("marketMaker"); - adapter0.setOwner(makeAddr("otherOwner")); - adapter0.setMarketMaker(marketMaker); - adapter0.setFiller(marketMaker, authSigner, true); - adapter0.configure(1 ether, 0); - - vm.prank(swapper); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(address(outputToken), 1 ether, recipient) - ); - - assertEq(outputToken.balanceOf(recipient), 1 ether); + function testEmptyBatchSucceedsWithoutTokenValidation() public { + router.execute(address(0), new IRouter.SwapCall[](0), new IRouter.Output[](0)); } - function testAcceptsAuthorizationDeadlineEquality() public { - vm.warp(100); - IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](1); - calls[0] = _signedCall( - swapper, - address(inputToken), - address(adapter0), - 1 ether, - abi.encodePacked(SIGNED_SWAP_SELECTOR), - 100, - 100, - AUTH_SIGNER_PRIVATE_KEY - ); - adapter0.configure(1 ether, 0); - - vm.prank(swapper); - router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient), 100); - - assertEq(outputToken.balanceOf(recipient), 1 ether); + function testZeroOutputAmountAndRecipientAreNotPrevalidated() public { + IRouter.Output[] memory outputs = _oneOutput(address(inputToken), 0, address(0)); + router.execute(address(inputToken), new IRouter.SwapCall[](0), outputs); } - function testAcceptsErc1271AdapterOwnerAuthorization() public { - Mock1271Signer contractSigner = new Mock1271Signer(); - bytes memory data = abi.encodePacked(SIGNED_SWAP_SELECTOR); - bytes memory signature = hex"cafe"; - bytes32 digest = _authorizationDigest( - swapper, - address(contractSigner), - address(inputToken), - address(adapter0), - 1 ether, - data, - 0, - AUTHORIZATION_DEADLINE - ); - contractSigner.setExpected(digest, signature); - adapter0.setOwner(address(contractSigner)); - adapter0.configure(1 ether, 0); - - IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](1); - calls[0] = IRouter.SwapCall({ - adapter: address(adapter0), - amountIn: 1 ether, - data: data, - authSigner: address(contractSigner), - authDeadline: AUTHORIZATION_DEADLINE, - authSignature: signature - }); - - vm.prank(swapper); - router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); - - assertEq(outputToken.balanceOf(recipient), 1 ether); + function testDeadlineEqualityIsValid() public { + vm.warp(100); + router.execute(address(0), new IRouter.SwapCall[](0), new IRouter.Output[](0), 100); } - function testUsesExactRouterEip712Domain() public view { - ( - bytes1 fields, - string memory name, - string memory version, - uint256 chainId, - address verifyingContract, - bytes32 salt, - uint256[] memory extensions - ) = IERC5267(address(router)).eip712Domain(); - - assertEq(fields, hex"0f"); - assertEq(name, "Router"); - assertEq(version, "1"); - assertEq(chainId, block.chainid); - assertEq(verifyingContract, address(router)); - assertEq(salt, bytes32(0)); - assertEq(extensions.length, 0); - assertEq(router.SWAP_AUTHORIZATION_TYPEHASH(), SWAP_AUTHORIZATION_TYPEHASH); + function testExpiredDeadlineRevertsBeforeAnyOtherInteraction() public { + vm.warp(101); + vm.expectRevert(abi.encodeWithSelector(IRouter.Expired.selector, 100)); + router.execute(address(0), new IRouter.SwapCall[](0), new IRouter.Output[](0), 100); } - function testCopiedAuthorizationCannotUseAttackerAsPayer() public { - address attacker = makeAddr("attacker"); - inputToken.mint(attacker, 1 ether); - vm.prank(attacker); - inputToken.approve(address(router), 1 ether); - IRouter.SwapCall[] memory calls = _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR); - adapter0.setShouldRevert(true); + function testTransfersEachInputDirectlyAndAggregatesAdapterOutputs() public { + bytes memory firstData = hex"8fa5c6710102"; + bytes memory secondData = hex"9a4568b60304"; + adapter0.configure(4 ether, true, false, false); + adapter1.configure(6 ether, true, false, false); + IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](2); + calls[0] = _call(address(adapter0), 4 ether, firstData); + calls[1] = _call(address(adapter1), 6 ether, secondData); - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAuthorizationSignature.selector, 0, calls[0].authSigner)); - vm.prank(attacker); - router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + vm.prank(swapper); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 10 ether, recipient)); - assertEq(inputToken.balanceOf(attacker), 1 ether); - assertEq(inputToken.balanceOf(address(adapter0)), 0); - assertEq(adapter0.callCount(), 0); + assertEq(inputToken.balanceOf(address(router)), 0); + assertEq(adapter0.inputBalanceAtCall(), 4 ether); + assertEq(adapter1.inputBalanceAtCall(), 6 ether); + assertEq(adapter0.lastData(), firstData); + assertEq(adapter1.lastData(), secondData); + assertEq(outputToken.balanceOf(recipient), 10 ether); } - function testModifiedCalldataInvalidatesAuthorizationBeforeFunding() public { - IRouter.SwapCall[] memory calls = _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR); - calls[0].data = bytes.concat(calls[0].data, hex"01"); - adapter0.setShouldRevert(true); + function testTransfersOutputsInCallerSpecifiedOrder() public { + outputToken.mint(address(router), 10 ether); + IRouter.Output[] memory outputs = new IRouter.Output[](2); + outputs[0] = IRouter.Output({token: address(outputToken), recipient: recipient, amount: 4 ether}); + outputs[1] = IRouter.Output({token: address(outputToken), recipient: secondRecipient, amount: 6 ether}); - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAuthorizationSignature.selector, 0, calls[0].authSigner)); - vm.prank(swapper); - router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + router.execute(address(0), new IRouter.SwapCall[](0), outputs); - assertEq(inputToken.balanceOf(swapper), 1000 ether); - assertEq(inputToken.balanceOf(address(adapter0)), 0); - assertEq(adapter0.callCount(), 0); + assertEq(outputToken.balanceOf(recipient), 4 ether); + assertEq(outputToken.balanceOf(secondRecipient), 6 ether); } - function testModifiedAmountInvalidatesAuthorizationBeforeFunding() public { - IRouter.SwapCall[] memory calls = _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR); - calls[0].amountIn = 2 ether; - adapter0.setShouldRevert(true); + function testPreexistingRouterBalanceCanFundDeclaredOutput() public { + outputToken.mint(address(router), 3 ether); - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAuthorizationSignature.selector, 0, calls[0].authSigner)); - vm.prank(swapper); - router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + router.execute(address(0), new IRouter.SwapCall[](0), _oneOutput(address(outputToken), 3 ether, recipient)); - assertEq(inputToken.balanceOf(swapper), 1000 ether); - assertEq(inputToken.balanceOf(address(adapter0)), 0); - assertEq(adapter0.callCount(), 0); + assertEq(outputToken.balanceOf(recipient), 3 ether); + assertEq(outputToken.balanceOf(address(router)), 0); } - function testModifiedExecutionDeadlineInvalidatesAuthorizationBeforeFunding() public { - vm.warp(100); - IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](1); - calls[0] = _signedCall( - swapper, - address(inputToken), - address(adapter0), - 1 ether, - abi.encodePacked(SIGNED_SWAP_SELECTOR), - 200, - 300, - AUTH_SIGNER_PRIVATE_KEY - ); - adapter0.setShouldRevert(true); + function testUndeclaredSurplusRemainsInRouter() public { + adapter0.configure(12 ether, true, false, false); - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAuthorizationSignature.selector, 0, calls[0].authSigner)); vm.prank(swapper); - router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient), 201); - - assertEq(inputToken.balanceOf(swapper), 1000 ether); - assertEq(inputToken.balanceOf(address(adapter0)), 0); - assertEq(adapter0.callCount(), 0); - } - - function testModifiedAuthorizationDeadlineInvalidatesAuthorizationBeforeFunding() public { - vm.warp(100); - IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](1); - calls[0] = _signedCall( - swapper, + router.execute( address(inputToken), - address(adapter0), - 1 ether, - abi.encodePacked(SIGNED_SWAP_SELECTOR), - 0, - 300, - AUTH_SIGNER_PRIVATE_KEY + _oneCall(address(adapter0), 10 ether, hex"01"), + _oneOutput(address(outputToken), 10 ether, recipient) ); - calls[0].authDeadline = 301; - adapter0.setShouldRevert(true); - - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAuthorizationSignature.selector, 0, calls[0].authSigner)); - vm.prank(swapper); - router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); - assertEq(inputToken.balanceOf(swapper), 1000 ether); - assertEq(inputToken.balanceOf(address(adapter0)), 0); - assertEq(adapter0.callCount(), 0); - } - - function testModifiedTokenInInvalidatesAuthorizationBeforeFunding() public { - IRouter.SwapCall[] memory calls = _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR); - adapter0.setShouldRevert(true); - - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAuthorizationSignature.selector, 0, calls[0].authSigner)); - vm.prank(swapper); - router.execute(address(outputToken), calls, _oneOutput(address(secondOutputToken), 1 ether, recipient)); - - assertEq(outputToken.balanceOf(swapper), 0); - assertEq(outputToken.balanceOf(address(adapter0)), 0); - assertEq(adapter0.callCount(), 0); + assertEq(outputToken.balanceOf(recipient), 10 ether); + assertEq(outputToken.balanceOf(address(router)), 2 ether); } - function testModifiedSignatureFailsBeforeFunding() public { - IRouter.SwapCall[] memory calls = _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR); - calls[0].authSignature[0] = bytes1(uint8(calls[0].authSignature[0]) ^ 1); - adapter0.setShouldRevert(true); + function testDoesNotRequireAdapterToConsumeInput() public { + adapter0.configure(0, false, false, false); - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAuthorizationSignature.selector, 0, calls[0].authSigner)); vm.prank(swapper); - router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + router.execute(address(inputToken), _oneCall(address(adapter0), 7 ether, bytes("")), new IRouter.Output[](0)); - assertEq(inputToken.balanceOf(swapper), 1000 ether); - assertEq(inputToken.balanceOf(address(adapter0)), 0); - assertEq(adapter0.callCount(), 0); + assertEq(inputToken.balanceOf(address(adapter0)), 7 ether); + assertEq(adapter0.callCount(), 1); } - function testRejectsZeroAuthorizationDeadlineBeforeFunding() public { - IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](1); - calls[0] = _signedCall( - swapper, - address(inputToken), - address(adapter0), - 1 ether, - abi.encodePacked(SIGNED_SWAP_SELECTOR), - 0, - 0, - AUTH_SIGNER_PRIVATE_KEY - ); - adapter0.setShouldRevert(true); + function testRejectsUnregisteredAdapterBeforeFundingIt() public { + registry.setEntity(address(adapter0), false); + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAdapter.selector, 0, address(adapter0))); - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAuthorizationDeadline.selector, 0, 0)); vm.prank(swapper); - router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + router.execute(address(inputToken), _oneCall(address(adapter0), 1 ether, hex"00"), new IRouter.Output[](0)); assertEq(inputToken.balanceOf(swapper), 1000 ether); assertEq(inputToken.balanceOf(address(adapter0)), 0); - assertEq(adapter0.callCount(), 0); } - function testRejectsExpiredAuthorizationDeadlineBeforeFunding() public { - vm.warp(101); - IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](1); - calls[0] = _signedCall( - swapper, - address(inputToken), - address(adapter0), - 1 ether, - abi.encodePacked(SIGNED_SWAP_SELECTOR), - 0, - 100, - AUTH_SIGNER_PRIVATE_KEY - ); - adapter0.setShouldRevert(true); + function testLaterUnregisteredAdapterRollsBackEarlierLeg() public { + adapter0.configure(4 ether, true, false, false); + registry.setEntity(address(adapter1), false); + IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](2); + calls[0] = _call(address(adapter0), 4 ether, hex"01"); + calls[1] = _call(address(adapter1), 6 ether, hex"02"); + vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAdapter.selector, 1, address(adapter1))); - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAuthorizationDeadline.selector, 0, 100)); vm.prank(swapper); - router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + router.execute(address(inputToken), calls, new IRouter.Output[](0)); assertEq(inputToken.balanceOf(swapper), 1000 ether); - assertEq(inputToken.balanceOf(address(adapter0)), 0); assertEq(adapter0.callCount(), 0); - } - - function testRejectsUnauthorizedAuthSignerBeforeFunding() public { - uint256 unauthorizedPrivateKey = 0xB0B; - IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](1); - calls[0] = _signedCall( - swapper, - address(inputToken), - address(adapter0), - 1 ether, - abi.encodePacked(SIGNED_SWAP_SELECTOR), - 0, - AUTHORIZATION_DEADLINE, - unauthorizedPrivateKey - ); - adapter0.setShouldRevert(true); - - vm.expectRevert( - abi.encodeWithSelector( - IRouter.UnauthorizedAuthSigner.selector, 0, address(adapter0), vm.addr(unauthorizedPrivateKey) - ) - ); - vm.prank(swapper); - router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); - - assertEq(inputToken.balanceOf(swapper), 1000 ether); assertEq(inputToken.balanceOf(address(adapter0)), 0); - assertEq(adapter0.callCount(), 0); } - function testRejectsRevokedAuthSignerBeforeFunding() public { - IRouter.SwapCall[] memory calls = _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR); - adapter0.setOwner(makeAddr("newOwner")); - adapter0.setShouldRevert(true); + function testWrapsAdapterRevertData() public { + adapter0.configure(0, false, false, true); + bytes memory reason = abi.encodeWithSelector(MockAdapterFailure.selector); + vm.expectRevert(abi.encodeWithSelector(IRouter.AdapterCallFailed.selector, 0, address(adapter0), reason)); - vm.expectRevert( - abi.encodeWithSelector(IRouter.UnauthorizedAuthSigner.selector, 0, address(adapter0), authSigner) - ); vm.prank(swapper); - router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); - - assertEq(inputToken.balanceOf(swapper), 1000 ether); - assertEq(inputToken.balanceOf(address(adapter0)), 0); - assertEq(adapter0.callCount(), 0); + router.execute(address(inputToken), _oneCall(address(adapter0), 1 ether, hex"1234"), new IRouter.Output[](0)); } - function testValidatesEveryAuthorizationBeforeFirstAdapterExecution() public { - uint256 unauthorizedPrivateKey = 0xB0B; + function testLaterAdapterFailureRollsBackWholeBatch() public { + adapter0.configure(4 ether, true, false, false); + adapter1.configure(0, false, false, true); IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](2); - calls[0] = _signedCall( - swapper, - address(inputToken), - address(adapter0), - 1 ether, - abi.encodePacked(SIGNED_SWAP_SELECTOR), - 0, - AUTHORIZATION_DEADLINE, - AUTH_SIGNER_PRIVATE_KEY - ); - calls[1] = _signedCall( - swapper, - address(inputToken), - address(adapter1), - 1 ether, - abi.encodePacked(SIGNED_SWAP_SELECTOR), - 0, - AUTHORIZATION_DEADLINE, - unauthorizedPrivateKey - ); - adapter0.setShouldRevert(true); + calls[0] = _call(address(adapter0), 4 ether, hex"01"); + calls[1] = _call(address(adapter1), 6 ether, hex"02"); + vm.expectRevert(); - vm.expectRevert( - abi.encodeWithSelector( - IRouter.UnauthorizedAuthSigner.selector, 1, address(adapter1), vm.addr(unauthorizedPrivateKey) - ) - ); vm.prank(swapper); - router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1 ether, recipient)); + router.execute(address(inputToken), calls, new IRouter.Output[](0)); assertEq(inputToken.balanceOf(swapper), 1000 ether); - assertEq(inputToken.balanceOf(address(adapter0)), 0); - assertEq(inputToken.balanceOf(address(adapter1)), 0); assertEq(adapter0.callCount(), 0); - assertEq(adapter1.callCount(), 0); - } - - function testInputTotalOverflowRevertsBeforeAdapterExecution() public { - inputToken.burn(swapper, 1000 ether); - inputToken.mint(swapper, type(uint256).max); - IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](2); - calls[0] = _signedCall( - swapper, - address(inputToken), - address(adapter0), - type(uint256).max, - abi.encodePacked(SIGNED_SWAP_SELECTOR), - 0, - AUTHORIZATION_DEADLINE, - AUTH_SIGNER_PRIVATE_KEY - ); - calls[1] = _signedCall( - swapper, - address(inputToken), - address(adapter1), - 1, - abi.encodePacked(SIGNED_SWAP_SELECTOR), - 0, - AUTHORIZATION_DEADLINE, - AUTH_SIGNER_PRIVATE_KEY - ); - adapter0.setShouldRevert(true); - - vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); - vm.prank(swapper); - router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 1, recipient)); - - assertEq(inputToken.balanceOf(swapper), type(uint256).max); - assertEq(inputToken.balanceOf(address(adapter0)), 0); - assertEq(adapter0.callCount(), 0); - } - - function testTransfersEachInputDirectlyAndCallsAllAdapters() public { - adapter0.configure(4 ether, 0); - adapter1.configure(6 ether, 0); - IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](2); - calls[0] = _signedCall( - swapper, - address(inputToken), - address(adapter0), - 4 ether, - abi.encodePacked(SIGNED_SWAP_SELECTOR), - 0, - AUTHORIZATION_DEADLINE, - AUTH_SIGNER_PRIVATE_KEY - ); - calls[1] = _signedCall( - swapper, - address(inputToken), - address(adapter1), - 6 ether, - abi.encodePacked(SIGNED_SWAP_SELECTOR), - 0, - AUTHORIZATION_DEADLINE, - AUTH_SIGNER_PRIVATE_KEY - ); - - vm.prank(swapper); - router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 10 ether, recipient)); - - assertEq(inputToken.balanceOf(address(router)), 0); assertEq(inputToken.balanceOf(address(adapter0)), 0); assertEq(inputToken.balanceOf(address(adapter1)), 0); - assertEq(adapter0.callCount(), 1); - assertEq(adapter1.callCount(), 1); - assertEq(outputToken.balanceOf(recipient), 10 ether); } - function testRevertsWhenInputAllowanceIsMissing() public { - vm.prank(swapper); - inputToken.approve(address(router), 0); - adapter0.configure(1 ether, 0); + function testOutputTransferFailureRollsBackAdapterExecution() public { + adapter0.configure(10 ether, true, false, false); + outputToken.setFailTransfer(true); vm.expectRevert(); - vm.prank(swapper); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(address(outputToken), 1 ether, recipient) - ); - } - - function testRevertsFeeOnTransferInput() public { - inputToken.setFee(100, false); - adapter0.configure(1 ether, 0); - vm.expectRevert(abi.encodeWithSelector(IRouter.InputTransferMismatch.selector, 0, 1 ether, 0.99 ether)); - vm.prank(swapper); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(address(outputToken), 1 ether, recipient) - ); - } - - function testRevertsSenderFeeInput() public { - inputToken.setFee(100, true); - adapter0.configure(1 ether, 0); - vm.expectRevert(abi.encodeWithSelector(IRouter.InputTransferMismatch.selector, 0, 1 ether, 1.01 ether)); - vm.prank(swapper); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(address(outputToken), 1 ether, recipient) - ); - } - - function testRevertsWhenAdapterDoesNotConsumeExactLeg() public { - adapter0.configure(1 ether, 0.1 ether); - vm.expectRevert(abi.encodeWithSelector(IRouter.InputConsumptionMismatch.selector, 0, 0, 0.1 ether)); - vm.prank(swapper); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(address(outputToken), 1 ether, recipient) - ); - } - function testWrapsAdapterRevertData() public { - adapter0.setShouldRevert(true); - bytes memory reason = abi.encodeWithSignature("Error(string)", "adapter failed"); - vm.expectRevert(abi.encodeWithSelector(IRouter.AdapterCallFailed.selector, 0, address(adapter0), reason)); vm.prank(swapper); router.execute( address(inputToken), - _oneCall(address(adapter0), 1 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(address(outputToken), 1 ether, recipient) - ); - } - - function testLateLegFailureRollsBackEarlierLeg() public { - adapter0.configure(4 ether, 0); - adapter1.setShouldRevert(true); - IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](2); - calls[0] = _signedCall( - swapper, - address(inputToken), - address(adapter0), - 4 ether, - abi.encodePacked(SIGNED_SWAP_SELECTOR), - 0, - AUTHORIZATION_DEADLINE, - AUTH_SIGNER_PRIVATE_KEY - ); - calls[1] = _signedCall( - swapper, - address(inputToken), - address(adapter1), - 6 ether, - abi.encodePacked(SIGNED_SWAP_SELECTOR), - 0, - AUTHORIZATION_DEADLINE, - AUTH_SIGNER_PRIVATE_KEY + _oneCall(address(adapter0), 10 ether, hex"01"), + _oneOutput(address(outputToken), 10 ether, recipient) ); - vm.expectRevert(); - vm.prank(swapper); - router.execute(address(inputToken), calls, _oneOutput(address(outputToken), 10 ether, recipient)); - assertEq(inputToken.balanceOf(swapper), 1000 ether); - assertEq(outputToken.balanceOf(address(router)), 0); assertEq(adapter0.callCount(), 0); - } - - function testPreexistingBalanceCannotSatisfyMinimumOrBecomeSurplus() public { - outputToken.mint(address(router), 100 ether); - adapter0.configure(9 ether, 0); - vm.expectRevert( - abi.encodeWithSelector(IRouter.InsufficientOutput.selector, address(outputToken), 10 ether, 9 ether) - ); - vm.prank(swapper); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 10 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(address(outputToken), 10 ether, recipient) - ); - assertEq(outputToken.balanceOf(address(router)), 100 ether); - } - - function testSurplusGoesToCallerAfterExactRecipientPayments() public { - adapter0.configure(12 ether, 0); - vm.prank(swapper); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 10 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(address(outputToken), 10 ether, recipient) - ); - assertEq(outputToken.balanceOf(recipient), 10 ether); - assertEq(outputToken.balanceOf(swapper), 2 ether); - assertEq(outputToken.balanceOf(address(router)), 0); - } - - function testDuplicateOutputTokensUseOneAggregateMinimum() public { - adapter0.configure(12 ether, 0); - IRouter.Output[] memory outputs = new IRouter.Output[](2); - outputs[0] = IRouter.Output({token: address(outputToken), recipient: recipient, amount: 4 ether}); - outputs[1] = IRouter.Output({token: address(outputToken), recipient: swapper, amount: 6 ether}); - - vm.prank(swapper); - router.execute(address(inputToken), _oneCall(address(adapter0), 10 ether, SIGNED_SWAP_SELECTOR), outputs); - - assertEq(outputToken.balanceOf(recipient), 4 ether); - assertEq(outputToken.balanceOf(swapper), 8 ether); assertEq(outputToken.balanceOf(address(router)), 0); + assertEq(outputToken.balanceOf(recipient), 0); } - function testMultipleOutputTokensSettleIndependently() public { - adapter0.configure(5 ether, 0); - secondOutputToken.mint(address(router), 100 ether); - secondOutputToken.mint(address(router), 7 ether); - IRouter.Output[] memory outputs = new IRouter.Output[](2); - outputs[0] = IRouter.Output({token: address(outputToken), recipient: recipient, amount: 5 ether}); - outputs[1] = IRouter.Output({token: address(secondOutputToken), recipient: recipient, amount: 7 ether}); - + function testAdapterReentrancyRevertsWholeBatch() public { + adapter0.configure(10 ether, true, true, false); + bytes memory reentrancyReason = abi.encodeWithSignature("ReentrancyGuardReentrantCall()"); vm.expectRevert( - abi.encodeWithSelector(IRouter.InsufficientOutput.selector, address(secondOutputToken), 7 ether, 0) + abi.encodeWithSelector(IRouter.AdapterCallFailed.selector, 0, address(adapter0), reentrancyReason) ); - vm.prank(swapper); - router.execute(address(inputToken), _oneCall(address(adapter0), 5 ether, SIGNED_SWAP_SELECTOR), outputs); - } - function testFeeOnTransferOutputRevertsAndRollsBack() public { - outputToken.setFee(100, false); - adapter0.configure(10 ether, 0); - vm.expectRevert(abi.encodeWithSelector(IRouter.OutputTransferMismatch.selector, 0, 10 ether, 9.9 ether)); vm.prank(swapper); router.execute( address(inputToken), - _oneCall(address(adapter0), 10 ether, SIGNED_SWAP_SELECTOR), + _oneCall(address(adapter0), 10 ether, hex"01"), _oneOutput(address(outputToken), 10 ether, recipient) ); - assertEq(outputToken.balanceOf(recipient), 0); - } - function testSenderFeeOutputRevertsAndRollsBack() public { - outputToken.setFee(100, true); - adapter0.configure(11 ether, 0); - vm.expectRevert(abi.encodeWithSelector(IRouter.OutputTransferMismatch.selector, 0, 10 ether, 10.1 ether)); - vm.prank(swapper); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 10 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(address(outputToken), 10 ether, recipient) - ); + assertEq(inputToken.balanceOf(swapper), 1000 ether); + assertEq(adapter0.callCount(), 0); assertEq(outputToken.balanceOf(recipient), 0); } - function testAdapterCannotReducePreexistingOutputBalance() public { - outputToken.mint(address(router), 100 ether); - adapter0.configure(10 ether, 0); - adapter0.setReduceRouterBalance(true); - vm.expectRevert( - abi.encodeWithSelector(IRouter.InsufficientOutput.selector, address(outputToken), 10 ether, 9 ether) - ); - vm.prank(swapper); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 10 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(address(outputToken), 10 ether, recipient) - ); - assertEq(outputToken.balanceOf(address(router)), 100 ether); - } + function testFuzzAggregatesRegisteredLegs(uint96 rawAmount0, uint96 rawAmount1) public { + uint256 amount0 = bound(uint256(rawAmount0), 0, 100 ether); + uint256 amount1 = bound(uint256(rawAmount1), 0, 100 ether); + adapter0.configure(amount0, true, false, false); + adapter1.configure(amount1, true, false, false); + IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](2); + calls[0] = _call(address(adapter0), amount0, hex"8fa5c671"); + calls[1] = _call(address(adapter1), amount1, hex"9a4568b6"); - function testAdapterReentrancyRevertsWholeBatch() public { - adapter0.configure(10 ether, 0); - adapter0.setReenter(true); - vm.expectRevert(); vm.prank(swapper); - router.execute( - address(inputToken), - _oneCall(address(adapter0), 10 ether, SIGNED_SWAP_SELECTOR), - _oneOutput(address(outputToken), 10 ether, recipient) - ); - assertEq(inputToken.balanceOf(swapper), 1000 ether); - assertEq(outputToken.balanceOf(recipient), 0); + router.execute(address(inputToken), calls, _oneOutput(address(outputToken), amount0 + amount1, recipient)); + + assertEq(outputToken.balanceOf(recipient), amount0 + amount1); + assertEq(inputToken.balanceOf(address(router)), 0); } - function _oneCall(address adapter, uint256 amountIn, bytes4 selector) + function _oneCall(address adapter, uint256 amountIn, bytes memory data) internal - view + pure returns (IRouter.SwapCall[] memory calls) { calls = new IRouter.SwapCall[](1); - calls[0] = _signedCall( - swapper, - address(inputToken), - adapter, - amountIn, - abi.encodePacked(selector), - 0, - AUTHORIZATION_DEADLINE, - AUTH_SIGNER_PRIVATE_KEY - ); - } - - function _signedCall( - address intendedSwapper, - address tokenIn, - address adapter, - uint256 amountIn, - bytes memory data, - uint256 executionDeadline, - uint256 authorizationDeadline, - uint256 signerPrivateKey - ) internal view returns (IRouter.SwapCall memory swapCall) { - address signer = vm.addr(signerPrivateKey); - bytes32 digest = _authorizationDigest( - intendedSwapper, signer, tokenIn, adapter, amountIn, data, executionDeadline, authorizationDeadline - ); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, digest); - swapCall = IRouter.SwapCall({ - adapter: adapter, - amountIn: amountIn, - data: data, - authSigner: signer, - authDeadline: authorizationDeadline, - authSignature: abi.encodePacked(r, s, v) - }); + calls[0] = _call(adapter, amountIn, data); } - function _authorizationDigest( - address intendedSwapper, - address signer, - address tokenIn, - address adapter, - uint256 amountIn, - bytes memory data, - uint256 executionDeadline, - uint256 authorizationDeadline - ) internal view returns (bytes32) { - bytes32 domainSeparator = keccak256( - abi.encode(DOMAIN_TYPEHASH, keccak256("Router"), keccak256("1"), block.chainid, address(router)) - ); - bytes32 structHash = keccak256( - abi.encode( - SWAP_AUTHORIZATION_TYPEHASH, - intendedSwapper, - signer, - tokenIn, - adapter, - amountIn, - keccak256(data), - executionDeadline, - authorizationDeadline - ) - ); - return keccak256(abi.encodePacked(hex"1901", domainSeparator, structHash)); + function _call(address adapter, uint256 amountIn, bytes memory data) + internal + pure + returns (IRouter.SwapCall memory) + { + return IRouter.SwapCall({adapter: adapter, amountIn: amountIn, data: data}); } function _oneOutput(address token, uint256 amount, address to) From c0f0bb812e28df2dd644c197a986b232274c64d4 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 4 Aug 2026 12:05:41 -0700 Subject: [PATCH 07/11] docs: remove superpowers artifacts --- .../plans/2026-08-03-user-directed-router.md | 41 --------- .../2026-08-03-user-directed-router-design.md | 90 ------------------- 2 files changed, 131 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-03-user-directed-router.md delete mode 100644 docs/superpowers/specs/2026-08-03-user-directed-router-design.md diff --git a/docs/superpowers/plans/2026-08-03-user-directed-router.md b/docs/superpowers/plans/2026-08-03-user-directed-router.md deleted file mode 100644 index d0cef95..0000000 --- a/docs/superpowers/plans/2026-08-03-user-directed-router.md +++ /dev/null @@ -1,41 +0,0 @@ -# Inline-Execution Router Implementation Plan - -**Goal:** Implement the smallest ownerless Router that directly funds registered LiquidLane adapters, invokes solver-provided calldata inline, and transfers declared outputs atomically. - -**Tech stack:** Solidity 0.8.28, Foundry, OpenZeppelin `SafeERC20` and `ReentrancyGuard`. - -## Constraints - -- Contract name is exactly `Router`. -- `SwapCall` is exactly `(address adapter, uint256 amountIn, bytes data)`. -- `Output` is exactly `(address token, address recipient, uint256 amount)`. -- Input authorization is an ordinary ERC-20 allowance to the Router. -- Validate each adapter through immutable `IRegistry(factory).isEntity(adapter)` immediately before funding that leg. -- Transfer input directly from `msg.sender` to the adapter, then call the supplied calldata unchanged. -- Transfer outputs only after every adapter call succeeds. -- Keep both immediate and deadline overloads under one reentrancy guard. -- Do not add outer signatures, selector inspection, balance accounting, surplus handling, or structural request validation. - -## Completed Work - -- [x] Replace the authenticated six-field call tuple with the three-field ABI and pin its raw function selector in tests. -- [x] Preserve zero/non-contract factory rejection and the immutable factory getter. -- [x] Implement inline per-leg registry checks, direct `safeTransferFrom`, and opaque adapter `call`. -- [x] Preserve adapter revert bytes in `AdapterCallFailed`. -- [x] Implement ordered `safeTransfer` output distribution. -- [x] Keep atomic rollback, shared reentrancy protection, and optional deadline semantics. -- [x] Remove EIP-712, `SignatureChecker`, adapter authorization interfaces, selector parsing, amount sums, input/output delta checks, isolation, surplus, and related errors/events. -- [x] Cover aggregated legs, arbitrary calldata, empty/zero entries, direct funding, output ordering, pre-existing balances, retained surplus, invalid adapters, adapter/output failures, rollback, reentrancy, deployment, deadlines, and fuzzed leg amounts. -- [x] Update Router documentation and deployment wording. - -## Verification - -Run from the full Foundry workspace: - -```bash -forge fmt --check -FOUNDRY_PROFILE=pr forge test --match-path test/Router.t.sol -forge lint -forge build --sizes -forge coverage --match-path test/Router.t.sol -``` diff --git a/docs/superpowers/specs/2026-08-03-user-directed-router-design.md b/docs/superpowers/specs/2026-08-03-user-directed-router-design.md deleted file mode 100644 index 2d57c56..0000000 --- a/docs/superpowers/specs/2026-08-03-user-directed-router-design.md +++ /dev/null @@ -1,90 +0,0 @@ -# Inline-Execution Router Design - -**Date:** 2026-08-03 -**Status:** Implemented - -## Summary - -`Router` is a small, ownerless execution surface for user-directed RFQ swaps. The caller grants the Router an ordinary ERC-20 allowance and submits one or more solver-produced adapter legs. For every leg, the Router verifies that the target is registered by the immutable LiquidLane adapter factory, transfers that leg's input directly from the caller to the adapter, and invokes the supplied calldata inline. Once every call succeeds, it transfers each declared output from its own balance to the declared recipient. - -Multiple legs let the backend aggregate liquidity across chosen solvers. A caller selecting one solver supplies one leg; an aggregated quote supplies the selected legs and aggregate output instructions. - -## Public API - -```solidity -struct SwapCall { - address adapter; - uint256 amountIn; - bytes data; -} - -struct Output { - address token; - address recipient; - uint256 amount; -} - -function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) external; - -function execute( - address tokenIn, - SwapCall[] calldata calls, - Output[] calldata outputs, - uint256 deadline -) external; -``` - -Both overloads are nonpayable and protected by one reentrancy guard. The deadline overload permits execution while `block.timestamp <= deadline` and reverts when `block.timestamp > deadline`. - -## Execution - -For each `SwapCall`, in caller-supplied order: - -1. Require `IRegistry(LIQUID_LANE_ADAPTER_FACTORY).isEntity(adapter)`. -2. Call `tokenIn.safeTransferFrom(msg.sender, adapter, amountIn)`. -3. Call `adapter.call(data)` with zero native value. -4. If the adapter call fails, revert with `AdapterCallFailed(index, adapter, reason)`. - -After every adapter call succeeds, process each `Output` in caller-supplied order with `IERC20(token).safeTransfer(recipient, amount)`. - -Any revert rolls back the complete batch, including earlier transfers and adapter effects. - -## Deliberately Minimal Validation - -The Router validates only: - -- the factory is a nonzero contract at construction; -- each adapter is currently registered; and -- the optional deadline has not expired. - -It does not validate nonempty arrays, nonzero amounts, token or recipient addresses, adapter code, calldata length or selector, signatures, nonces, input consumption, output production, balance deltas, conservation, or surplus. Those checks remain with ERC-20 contracts, registered adapters, backend quote construction, and the caller's transaction review. - -Adapter calldata is opaque and forwarded unchanged. Signed-swap and discounted-swap payloads are both supported when their registered adapter accepts them. The Router has no EIP-712 domain, signature checker, replay state, selector allowlist, or adapter-specific interface. - -Output entries are ordinary transfers from the Router's current balances. Pre-existing balances may therefore satisfy an output, underproduction fails only if the token transfer fails, and undeclared surplus remains in the Router. - -## Trust Boundary and Invariants - -1. Only entities registered by the immutable factory can be called. -2. Input is transferred directly from `msg.sender` to each adapter; it does not pass through the Router. -3. Calls and output transfers preserve caller-supplied ordering. -4. No native value or `delegatecall` is used. -5. The complete execution is atomic. -6. Reentrant entry into either overload is rejected. - -Registered adapters are trusted to authenticate and consume their calldata correctly. Backend responses must bind each leg to the intended adapter and transaction semantics. Users must review `tokenIn`, all input amounts, adapter targets, calldata, outputs, and the optional deadline before signing. - -## Errors - -| Error | Meaning | -| --- | --- | -| `InvalidFactory(factory)` | The constructor received zero or an address without code. | -| `Expired(deadline)` | The deadline overload was called after its deadline. | -| `InvalidAdapter(index, adapter)` | The indexed call target is not currently registered. | -| `AdapterCallFailed(index, adapter, reason)` | The indexed adapter reverted; `reason` preserves its revert data. | - -ERC-20 failures use OpenZeppelin `SafeERC20` behavior and are not wrapped in Router-specific errors. - -## Deployment - -The deployment script reads `LIQUID_LANE_ADAPTER_FACTORY` and deploys `Router` directly. There is no proxy, owner, role, pause, mutable allowlist, rescue, or upgrade mechanism. From 1729f17ea95090b3e77b9dee8aae32c6b1128b15 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 4 Aug 2026 12:13:18 -0700 Subject: [PATCH 08/11] refactor: streamline Router execution --- src/Router.sol | 47 ++++++++++++++++--------------------- src/interfaces/IRouter.sol | 2 -- test/Router.t.sol | 48 ++++++++++++++++++++++++++++---------- 3 files changed, 56 insertions(+), 41 deletions(-) diff --git a/src/Router.sol b/src/Router.sol index 2239fed..cdec7b9 100644 --- a/src/Router.sol +++ b/src/Router.sol @@ -5,6 +5,7 @@ pragma solidity 0.8.28; import {IRegistry} from "./interfaces/IRegistry.sol"; import {IRouter} from "./interfaces/IRouter.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; @@ -15,46 +16,38 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol contract Router is IRouter, ReentrancyGuard { using SafeERC20 for IERC20; + /// @notice Factory registry used to validate LiquidLane adapter targets. address public immutable LIQUID_LANE_ADAPTER_FACTORY; constructor(address liquidLaneAdapterFactory) { - if (liquidLaneAdapterFactory == address(0) || liquidLaneAdapterFactory.code.length == 0) { - revert InvalidFactory(liquidLaneAdapterFactory); - } LIQUID_LANE_ADAPTER_FACTORY = liquidLaneAdapterFactory; } /// @inheritdoc IRouter - function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) external nonReentrant { - _execute(tokenIn, calls, outputs); - } - - /// @inheritdoc IRouter - function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs, uint256 deadline) - external - nonReentrant - { - // forge-lint: disable-next-line(block-timestamp) - if (block.timestamp > deadline) revert Expired(deadline); - _execute(tokenIn, calls, outputs); - } - - function _execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) internal { - IRegistry registry = IRegistry(LIQUID_LANE_ADAPTER_FACTORY); - IERC20 inputToken = IERC20(tokenIn); - - for (uint256 i; i < calls.length; ++i) { + function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) public nonReentrant { + uint256 callsLength = calls.length; + for (uint256 i; i < callsLength; ++i) { SwapCall calldata swapCall = calls[i]; - if (!registry.isEntity(swapCall.adapter)) revert InvalidAdapter(i, swapCall.adapter); + if (!IRegistry(LIQUID_LANE_ADAPTER_FACTORY).isEntity(swapCall.adapter)) { + revert InvalidAdapter(i, swapCall.adapter); + } - inputToken.safeTransferFrom(msg.sender, swapCall.adapter, swapCall.amountIn); - (bool success, bytes memory reason) = swapCall.adapter.call(swapCall.data); - if (!success) revert AdapterCallFailed(i, swapCall.adapter, reason); + IERC20(tokenIn).safeTransferFrom(msg.sender, swapCall.adapter, swapCall.amountIn); + Address.functionCall(swapCall.adapter, swapCall.data); } - for (uint256 i; i < outputs.length; ++i) { + uint256 outputsLength = outputs.length; + for (uint256 i; i < outputsLength; ++i) { Output calldata output = outputs[i]; IERC20(output.token).safeTransfer(output.recipient, output.amount); } } + + /// @inheritdoc IRouter + function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs, uint256 deadline) external { + if (block.timestamp > deadline) { + revert Expired(deadline); + } + execute(tokenIn, calls, outputs); + } } diff --git a/src/interfaces/IRouter.sol b/src/interfaces/IRouter.sol index 63183d5..8d08f8f 100644 --- a/src/interfaces/IRouter.sol +++ b/src/interfaces/IRouter.sol @@ -21,10 +21,8 @@ interface IRouter { uint256 amount; } - error AdapterCallFailed(uint256 index, address adapter, bytes reason); error Expired(uint256 deadline); error InvalidAdapter(uint256 index, address adapter); - error InvalidFactory(address factory); function LIQUID_LANE_ADAPTER_FACTORY() external view returns (address); function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) external; diff --git a/test/Router.t.sol b/test/Router.t.sol index e62b525..0329ec8 100644 --- a/test/Router.t.sol +++ b/test/Router.t.sol @@ -8,6 +8,8 @@ import {Router} from "../src/Router.sol"; import {IRouter} from "../src/interfaces/IRouter.sol"; import {DeployRouterScript} from "../script/deploy/DeployRouter.s.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; + error MockAdapterFailure(); contract MockRegistry { @@ -142,15 +144,17 @@ contract RouterTest is Test { inputToken.approve(address(router), type(uint256).max); } - function testConstructorRejectsZeroFactory() public { - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidFactory.selector, address(0))); - new Router(address(0)); + function testConstructorStoresZeroFactory() public { + Router zeroFactoryRouter = new Router(address(0)); + + assertEq(zeroFactoryRouter.LIQUID_LANE_ADAPTER_FACTORY(), address(0)); } - function testConstructorRejectsNonContractFactory() public { + function testConstructorStoresNonContractFactory() public { address notContract = makeAddr("notContract"); - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidFactory.selector, notContract)); - new Router(notContract); + Router nonContractFactoryRouter = new Router(notContract); + + assertEq(nonContractFactoryRouter.LIQUID_LANE_ADAPTER_FACTORY(), notContract); } function testStoresRegistryFactory() public view { @@ -290,15 +294,23 @@ contract RouterTest is Test { assertEq(inputToken.balanceOf(address(adapter0)), 0); } - function testWrapsAdapterRevertData() public { + function testBubblesAdapterRevertData() public { adapter0.configure(0, false, false, true); - bytes memory reason = abi.encodeWithSelector(MockAdapterFailure.selector); - vm.expectRevert(abi.encodeWithSelector(IRouter.AdapterCallFailed.selector, 0, address(adapter0), reason)); + vm.expectRevert(MockAdapterFailure.selector); vm.prank(swapper); router.execute(address(inputToken), _oneCall(address(adapter0), 1 ether, hex"1234"), new IRouter.Output[](0)); } + function testRegisteredNonContractAdapterReverts() public { + address nonContractAdapter = makeAddr("nonContractAdapter"); + registry.setEntity(nonContractAdapter, true); + vm.expectRevert(abi.encodeWithSelector(Address.AddressEmptyCode.selector, nonContractAdapter)); + + vm.prank(swapper); + router.execute(address(inputToken), _oneCall(nonContractAdapter, 1 ether, hex"1234"), new IRouter.Output[](0)); + } + function testLaterAdapterFailureRollsBackWholeBatch() public { adapter0.configure(4 ether, true, false, false); adapter1.configure(0, false, false, true); @@ -337,9 +349,7 @@ contract RouterTest is Test { function testAdapterReentrancyRevertsWholeBatch() public { adapter0.configure(10 ether, true, true, false); bytes memory reentrancyReason = abi.encodeWithSignature("ReentrancyGuardReentrantCall()"); - vm.expectRevert( - abi.encodeWithSelector(IRouter.AdapterCallFailed.selector, 0, address(adapter0), reentrancyReason) - ); + vm.expectRevert(reentrancyReason); vm.prank(swapper); router.execute( @@ -353,6 +363,20 @@ contract RouterTest is Test { assertEq(outputToken.balanceOf(recipient), 0); } + function testDeadlineExecutionRemainsReentrancyProtected() public { + adapter0.configure(10 ether, true, true, false); + bytes memory reentrancyReason = abi.encodeWithSignature("ReentrancyGuardReentrantCall()"); + vm.expectRevert(reentrancyReason); + + vm.prank(swapper); + router.execute( + address(inputToken), + _oneCall(address(adapter0), 10 ether, hex"01"), + _oneOutput(address(outputToken), 10 ether, recipient), + block.timestamp + ); + } + function testFuzzAggregatesRegisteredLegs(uint96 rawAmount0, uint96 rawAmount1) public { uint256 amount0 = bound(uint256(rawAmount0), 0, 100 ether); uint256 amount1 = bound(uint256(rawAmount1), 0, 100 ether); From c7a83d9a82a141e28137d1c4f83920f6dd6149a7 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 4 Aug 2026 12:30:39 -0700 Subject: [PATCH 09/11] refactor: simplify Router adapter errors --- src/Router.sol | 5 +++-- src/interfaces/IRouter.sol | 2 +- test/Router.t.sol | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Router.sol b/src/Router.sol index cdec7b9..e1f4957 100644 --- a/src/Router.sol +++ b/src/Router.sol @@ -14,6 +14,7 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol /// @notice Atomically funds registered adapters and transfers their outputs to declared recipients. /// @custom:security-contact security@symbiotic.fi contract Router is IRouter, ReentrancyGuard { + using Address for address; using SafeERC20 for IERC20; /// @notice Factory registry used to validate LiquidLane adapter targets. @@ -29,11 +30,11 @@ contract Router is IRouter, ReentrancyGuard { for (uint256 i; i < callsLength; ++i) { SwapCall calldata swapCall = calls[i]; if (!IRegistry(LIQUID_LANE_ADAPTER_FACTORY).isEntity(swapCall.adapter)) { - revert InvalidAdapter(i, swapCall.adapter); + revert InvalidAdapter(); } IERC20(tokenIn).safeTransferFrom(msg.sender, swapCall.adapter, swapCall.amountIn); - Address.functionCall(swapCall.adapter, swapCall.data); + swapCall.adapter.functionCall(swapCall.data); } uint256 outputsLength = outputs.length; diff --git a/src/interfaces/IRouter.sol b/src/interfaces/IRouter.sol index 8d08f8f..41d645b 100644 --- a/src/interfaces/IRouter.sol +++ b/src/interfaces/IRouter.sol @@ -22,7 +22,7 @@ interface IRouter { } error Expired(uint256 deadline); - error InvalidAdapter(uint256 index, address adapter); + error InvalidAdapter(); function LIQUID_LANE_ADAPTER_FACTORY() external view returns (address); function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs) external; diff --git a/test/Router.t.sol b/test/Router.t.sol index 0329ec8..576f222 100644 --- a/test/Router.t.sol +++ b/test/Router.t.sol @@ -269,7 +269,7 @@ contract RouterTest is Test { function testRejectsUnregisteredAdapterBeforeFundingIt() public { registry.setEntity(address(adapter0), false); - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAdapter.selector, 0, address(adapter0))); + vm.expectRevert(IRouter.InvalidAdapter.selector); vm.prank(swapper); router.execute(address(inputToken), _oneCall(address(adapter0), 1 ether, hex"00"), new IRouter.Output[](0)); @@ -284,7 +284,7 @@ contract RouterTest is Test { IRouter.SwapCall[] memory calls = new IRouter.SwapCall[](2); calls[0] = _call(address(adapter0), 4 ether, hex"01"); calls[1] = _call(address(adapter1), 6 ether, hex"02"); - vm.expectRevert(abi.encodeWithSelector(IRouter.InvalidAdapter.selector, 1, address(adapter1))); + vm.expectRevert(IRouter.InvalidAdapter.selector); vm.prank(swapper); router.execute(address(inputToken), calls, new IRouter.Output[](0)); From cf89b1689c6a7392e6368a5a43c4553645e2d556 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 4 Aug 2026 12:32:34 -0700 Subject: [PATCH 10/11] chore: remove Router security contact --- src/Router.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Router.sol b/src/Router.sol index e1f4957..9d36e17 100644 --- a/src/Router.sol +++ b/src/Router.sol @@ -12,7 +12,6 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol /// @title Router /// @notice Atomically funds registered adapters and transfers their outputs to declared recipients. -/// @custom:security-contact security@symbiotic.fi contract Router is IRouter, ReentrancyGuard { using Address for address; using SafeERC20 for IERC20; From 3103b71630561e29dfdb12c0ab7d88a4e3e5e79a Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 4 Aug 2026 12:39:12 -0700 Subject: [PATCH 11/11] refactor: remove Router error arguments --- src/Router.sol | 2 +- src/interfaces/IRouter.sol | 2 +- test/Router.t.sol | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Router.sol b/src/Router.sol index 9d36e17..b0b21d5 100644 --- a/src/Router.sol +++ b/src/Router.sol @@ -46,7 +46,7 @@ contract Router is IRouter, ReentrancyGuard { /// @inheritdoc IRouter function execute(address tokenIn, SwapCall[] calldata calls, Output[] calldata outputs, uint256 deadline) external { if (block.timestamp > deadline) { - revert Expired(deadline); + revert Expired(); } execute(tokenIn, calls, outputs); } diff --git a/src/interfaces/IRouter.sol b/src/interfaces/IRouter.sol index 41d645b..3442a5e 100644 --- a/src/interfaces/IRouter.sol +++ b/src/interfaces/IRouter.sol @@ -21,7 +21,7 @@ interface IRouter { uint256 amount; } - error Expired(uint256 deadline); + error Expired(); error InvalidAdapter(); function LIQUID_LANE_ADAPTER_FACTORY() external view returns (address); diff --git a/test/Router.t.sol b/test/Router.t.sol index 576f222..126a925 100644 --- a/test/Router.t.sol +++ b/test/Router.t.sol @@ -198,7 +198,7 @@ contract RouterTest is Test { function testExpiredDeadlineRevertsBeforeAnyOtherInteraction() public { vm.warp(101); - vm.expectRevert(abi.encodeWithSelector(IRouter.Expired.selector, 100)); + vm.expectRevert(IRouter.Expired.selector); router.execute(address(0), new IRouter.SwapCall[](0), new IRouter.Output[](0), 100); }