Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,11 @@ Note that some of the commands in the middle of the series are unused. These gap

`UNWRAP_WETH_EXACT` takes `(recipient, amount)` and unwraps an exact amount of the contract's WETH, reverting if the balance is insufficient.

`V2_SWAP_EXACT_OUT` is all-or-nothing: it measures what the recipient actually receives and reverts with `V2TooLittleReceived` if that is less than `amountOut`, even by 1 wei. A token anywhere on the path that delivers less than the amount transferred, whether through a transfer fee, withholding, or share-based rounding such as stETH (which typically delivers 1-2 wei less), therefore makes the route revert. Swap such tokens with `V2_SWAP_EXACT_IN` instead.

`V4_SWAP` takes an encoded v4 action plan `(bytes actions, bytes[] params)` and runs it through the router's `V4Router`. Exact-output swaps are all-or-nothing and revert on a shortfall. An exact-output swap paid from the router's own balance (`payerIsUser = false`, or a native input) spends only what the route actually costs, so whatever the plan pre-funded beyond that stays in the router. Such a plan must end with a full-balance return such as `SWEEP` for each input currency, since any balance left in the router can be swept by anyone. A hook that funds the swap's input itself is the extreme case: the route costs nothing, so the entire pre-funded amount stays in the router and must be swept back.

`RESOLVE` takes `(address resolver, bytes context)` and performs a bounded, read-only `staticcall` to `resolver.resolveAmount(context)` (see `IAmountResolver`), storing the returned value in a transient register. A later command in the same plan can then use that value as an amount by passing the `Constants.USE_RESOLVED_AMOUNT` sentinel (`type(uint128).max`, the top of the `uint128` v4 amount range) in place of a literal. This is supported by the v2/v3 swap amount fields, the `amountIn`/`amountOut` of every v4 swap action inside `V4_SWAP`, and `TRANSFER`. This lets a route obtain an amount that is only known onchain at execution time (for example, a live lending-position debt) instead of baking an offchain guess into calldata. The register is transaction-scoped: it survives across commands and into an `EXECUTE_SUB_PLAN`, and is cleared when the top-level `execute` returns. Because the resolver is invoked via `staticcall` and at most one word of returndata is copied, a resolver can neither mutate state nor grief the router with a return bomb. A resolver that reverts, returns fewer than 32 bytes, or returns zero fails the command under the usual `FLAG_ALLOW_REVERT` semantics: zero is not an amount any command can act on, and the v4 swap helpers would read it as `OPEN_DELTA`. Every failure clears the register, and a command that consumes the sentinel while the register is empty reverts with `ResolvedAmountUnset`, so a tolerated RESOLVE failure can only be followed by consumers inside an `EXECUTE_SUB_PLAN` that also allows revert.
`RESOLVE` takes `(address resolver, bytes context)` and performs a bounded, read-only `staticcall` to `resolver.resolveAmount(context)` (see `IAmountResolver`), storing the returned value in a transient register. A later command in the same plan can then use that value as an amount by passing the `Constants.USE_RESOLVED_AMOUNT` sentinel (`type(uint128).max`, the top of the `uint128` v4 amount range) in place of a literal. This is supported by every exact amount the router moves: the `amountIn`/`amountOut` of the v2/v3 swap commands and of every v4 swap action inside `V4_SWAP`, the `amount` of the v4 `SETTLE` and `TAKE` actions, the `amount` of `PERMIT2_TRANSFER_FROM` and of each `PERMIT2_TRANSFER_FROM_BATCH` detail, the value of `TRANSFER`, the `amount` of `WRAP_ETH` and `UNWRAP_WETH_EXACT`, and the `inputAmount` of `ACROSS_V4_DEPOSIT_V3`. Minimums, caps and thresholds (`amountOutMin`, `amountInMax`, `SWEEP`'s `amountMin`, `UNWRAP_WETH`'s `amountMin`, `BALANCE_CHECK_ERC20`'s `minBalance`, and the v4 `SETTLE_ALL`, `TAKE_ALL` and `CLEAR_OR_TAKE` bounds), portions (`PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, v4 `TAKE_PORTION`) and signed Permit2 permit amounts never read the sentinel. For `ACROSS_V4_DEPOSIT_V3` only the input is resolved: `outputAmount` stays the literal set when the route was built, so as with a `CONTRACT_BALANCE` input, leave enough room between the expected input and `outputAmount` for the relayer fee, or a lower-than-expected input can leave the deposit unfilled until `fillDeadline`. This lets a route obtain an amount that is only known onchain at execution time (for example, a live lending-position debt) instead of baking an offchain guess into calldata. One register is shared by a plan and all of its sub-plans, and every consumer reads the latest value written: a sub-plan starts with its parent's value, and a `RESOLVE` inside a sub-plan that succeeds replaces the parent's value for the parent's later commands. If an `EXECUTE_SUB_PLAN` with `FLAG_ALLOW_REVERT` fails, all of the sub-plan's writes are undone with it, its `RESOLVE` included, so the parent keeps the value it had before the sub-plan ran. The register is cleared when the top-level `execute` returns. Because the resolver is invoked via `staticcall` and at most one word of returndata is copied, a resolver can neither mutate state nor grief the router with a return bomb. A resolver that reverts, returns fewer than 32 bytes, or returns zero fails the command under the usual `FLAG_ALLOW_REVERT` semantics: zero is not an amount any command can act on, and the v4 swap helpers would read it as `OPEN_DELTA`. Every failure clears the register (unless it happens inside a sub-plan that then reverts, which undoes the clear along with everything else the sub-plan did), and a command that consumes the sentinel while the register is empty reverts with `ResolvedAmountUnset`, so a tolerated RESOLVE failure can only be followed by consumers inside an `EXECUTE_SUB_PLAN` that also allows revert.

`V4_PROTOCOL_FEE_UPDATE` takes `(PoolKey key)` and `V3_PROTOCOL_FEE_UPDATE` takes `(address pool)`. Each pokes the protocol fee adapter that governance installed as the controller (the v4 `PoolManager.protocolFeeController()`, or the v3 factory `owner()`), read onchain so the router holds no adapter address, which pushes that pool's resolved protocol fee into pool state. Newly created pools initialize with no protocol fee, so a route can activate it in the same transaction as the first swap rather than waiting on the offchain keeper. The poke follows the same failure semantics as the other call-based commands: a revert from the adapter surfaces as `ExecutionFailed` unless `FLAG_ALLOW_REVERT` is set. Where the protocol is deployed but no adapter is installed, the call targets `address(0)` and is a no-op. Reading the controller is not covered by `FLAG_ALLOW_REVERT`: on a chain without a v4 `PoolManager` (or v3 factory) the command reverts the whole `execute`, so only include it on chains where that protocol is deployed.

Expand Down
Binary file not shown.
Binary file not shown.
18 changes: 12 additions & 6 deletions contracts/base/Dispatcher.sol
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ import {IAmountResolver} from '../interfaces/IAmountResolver.sol';
import {IV4FeeAdapter} from '../interfaces/external/IV4FeeAdapter.sol';
import {IV3FeeAdapter} from '../interfaces/external/IV3FeeAdapter.sol';
import {IUniswapV3Factory} from '@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol';
import {IProtocolFees} from '@uniswap/v4-core/src/interfaces/IProtocolFees.sol';
import {Lock} from './Lock.sol';
import {ERC20} from 'solmate/src/tokens/ERC20.sol';
import {IAllowanceTransfer} from 'permit2/src/interfaces/IAllowanceTransfer.sol';
import {SafeCast160} from 'permit2/src/libraries/SafeCast160.sol';
import {CalldataDecoder} from '@uniswap/v4-periphery/src/libraries/CalldataDecoder.sol';
import {PoolKey} from '@uniswap/v4-core/src/types/PoolKey.sol';
import {IPoolManager} from '@uniswap/v4-core/src/interfaces/IPoolManager.sol';
Expand Down Expand Up @@ -136,7 +136,12 @@ abstract contract Dispatcher is
recipient := calldataload(add(inputs.offset, 0x20))
amount := calldataload(add(inputs.offset, 0x40))
}
permit2TransferFrom(token, msgSender(), _mapRecipient(recipient), amount);
permit2TransferFrom(
token,
msgSender(),
_mapRecipient(recipient),
SafeCast160.toUint160(ResolvedAmount.map(amount))
);
} else if (command == Commands.PERMIT2_PERMIT_BATCH) {
IAllowanceTransfer.PermitBatch calldata permitBatch = decodePermitBatch(inputs);
bytes calldata data = inputs.toBytes(1);
Expand Down Expand Up @@ -284,7 +289,7 @@ abstract contract Dispatcher is
recipient := calldataload(inputs.offset)
amount := calldataload(add(inputs.offset, 0x20))
}
Payments.wrapETH(_mapRecipient(recipient), amount);
Payments.wrapETH(_mapRecipient(recipient), ResolvedAmount.map(amount));
} else if (command == Commands.UNWRAP_WETH) {
checkInputLength(inputs, 0x40);
// equivalent: abi.decode(inputs, (address, uint256))
Expand Down Expand Up @@ -325,7 +330,7 @@ abstract contract Dispatcher is
recipient := calldataload(inputs.offset)
amount := calldataload(add(inputs.offset, 0x20))
}
Payments.unwrapWETH9Exact(_mapRecipient(recipient), amount);
Payments.unwrapWETH9Exact(_mapRecipient(recipient), ResolvedAmount.map(amount));
}
}
} else {
Expand Down Expand Up @@ -389,7 +394,7 @@ abstract contract Dispatcher is
// PoolManager as its protocolFeeController and exposes a permissionless poke that pushes the
// resolved fee into pool state. Reading the controller onchain means the router never holds an
// adapter address and follows any future controller change.
(success, output) = IProtocolFees(address(poolManager)).protocolFeeController()
(success, output) = poolManager.protocolFeeController()
.call(abi.encodeCall(IV4FeeAdapter.triggerFeeUpdate, (poolKey)));
} else if (command == Commands.V3_PROTOCOL_FEE_UPDATE) {
checkInputLength(inputs, 0x20);
Expand Down Expand Up @@ -442,7 +447,8 @@ abstract contract Dispatcher is
/// that reverts, returns fewer than 32 bytes, or returns zero fails the command, composing with
/// FLAG_ALLOW_REVERT like the other call-based commands: zero is not an amount any command can act
/// on, and the v4 swap helpers would read it as OPEN_DELTA. Every failure clears the register, so
/// no later command can consume a value this RESOLVE did not produce.
/// no later command can consume a value this RESOLVE did not produce. A failure inside a sub-plan that
/// then reverts is undone with the rest of the sub-plan, so the earlier value returns as if it never ran.
function _resolve(address resolver, bytes calldata context) private returns (bool ok) {
bytes memory callData = abi.encodeWithSelector(IAmountResolver.resolveAmount.selector, context);
uint256 result;
Expand Down
13 changes: 1 addition & 12 deletions contracts/base/RouteSigner.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,11 @@ pragma solidity ^0.8.24;

import {EIP712} from '@openzeppelin/contracts/utils/cryptography/EIP712.sol';
import {ECDSA} from '@openzeppelin/contracts/utils/cryptography/ECDSA.sol';
import {ROUTE_SIGNER_SLOT, ROUTE_INTENT_SLOT, ROUTE_DATA_SLOT} from '../libraries/TransientSlots.sol';

/// @title RouteSigner
/// @notice Contract for managing signed execution context using transient storage
abstract contract RouteSigner is EIP712 {
/// @notice Transient storage slot for the route signer address
/// @dev Must equal TransientSlots.ROUTE_SIGNER; inline assembly only accepts a literal here
bytes32 internal constant ROUTE_SIGNER_SLOT = 0x0000000000000000000000000000000000000000000000000000000000000003;

/// @notice Transient storage slot for the route intent
/// @dev Must equal TransientSlots.ROUTE_INTENT
bytes32 internal constant ROUTE_INTENT_SLOT = 0x0000000000000000000000000000000000000000000000000000000000000004;

/// @notice Transient storage slot for the route data
/// @dev Must equal TransientSlots.ROUTE_DATA
bytes32 internal constant ROUTE_DATA_SLOT = 0x0000000000000000000000000000000000000000000000000000000000000005;

/// @notice EIP712 typehash for signed execution
bytes32 internal constant EXECUTE_SIGNED_TYPEHASH = keccak256(
'ExecuteSigned(bytes commands,bytes[] inputs,bytes32 intent,bytes32 data,address sender,bytes32 nonce,uint256 deadline)'
Expand Down
2 changes: 1 addition & 1 deletion contracts/libraries/Commands.sol
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ library Commands {
bytes1 internal constant FLAG_ALLOW_REVERT = 0x80;
bytes1 internal constant COMMAND_TYPE_MASK = 0x7f;

// Command Types. Maximum supported command at this moment is 0x3f.
// Command types are 7 bits (COMMAND_TYPE_MASK); 0x40-0x5f is the highest assigned range, reserved for 3rd party integrations.
// The commands are executed in nested if blocks to minimise gas consumption

// Command Types where value<=0x07, executed in the first nested-if block
Expand Down
5 changes: 4 additions & 1 deletion contracts/libraries/Constants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ library Constants {
/// @dev Sentinel placed in a command's amount field to consume the ResolvedAmount register written by a prior
/// RESOLVE command. It must fit uint128: v4 swap amounts are uint128 and the ABI decoder rejects a larger word
/// before the router sees it, the same constraint that makes OPEN_DELTA zero. The top of that range is the literal
/// amount given up. The amountInMaximum and amountOutMinimum caps are not amount fields and keep their meaning.
/// amount given up. It is read by every exact amount the router moves: the v2/v3 and v4 swap amountIn/amountOut,
/// the v4 SETTLE and TAKE amounts, the PERMIT2_TRANSFER_FROM and PERMIT2_TRANSFER_FROM_BATCH amounts, the
/// TRANSFER value, the WRAP_ETH and UNWRAP_WETH_EXACT amounts, and the ACROSS_V4_DEPOSIT_V3 inputAmount.
/// Minimums, caps, thresholds, portions and signed permit amounts keep their literal meaning.
uint256 internal constant USE_RESOLVED_AMOUNT = type(uint128).max;
}
6 changes: 2 additions & 4 deletions contracts/libraries/Locker.sol
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.24;

import {LOCKER_SLOT} from './TransientSlots.sol';

/// @notice A library to implement a reentrancy lock in transient storage.
/// @dev Instead of storing a boolean, the locker's address is stored to allow the contract to know who locked the contract
library Locker {
// The slot holding the locker state, transiently. Must equal TransientSlots.LOCKER; inline assembly only accepts
// a literal here.
bytes32 constant LOCKER_SLOT = 0x0000000000000000000000000000000000000000000000000000000000000001;

function set(address locker) internal {
// The locker is always msg.sender or address(0) so does not need to be cleaned
assembly ('memory-safe') {
Expand Down
6 changes: 2 additions & 4 deletions contracts/libraries/MaxInputAmount.sol
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.24;

import {MAX_AMOUNT_IN_SLOT} from './TransientSlots.sol';

/// @notice A library used to store the maximum desired amount of input tokens for exact output swaps; used for checking slippage
library MaxInputAmount {
// The slot holding the the maximum desired amount of input tokens, transiently. Must equal
// TransientSlots.MAX_AMOUNT_IN; inline assembly only accepts a literal here.
bytes32 constant MAX_AMOUNT_IN_SLOT = 0x0000000000000000000000000000000000000000000000000000000000000002;

function set(uint256 maxAmountIn) internal {
assembly ('memory-safe') {
tstore(MAX_AMOUNT_IN_SLOT, maxAmountIn)
Expand Down
6 changes: 2 additions & 4 deletions contracts/libraries/NestedUnlock.sol
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.24;

import {NESTED_UNLOCK_SLOT} from './TransientSlots.sol';

/// @notice A library to record, in transient storage, that the caller has opted into executing a
/// route inside a PoolManager unlock opened by another contract.
library NestedUnlock {
// The slot holding the opt-in state, transiently. Must equal TransientSlots.NESTED_UNLOCK; inline assembly only
// accepts a literal here.
bytes32 constant NESTED_UNLOCK_SLOT = 0x0000000000000000000000000000000000000000000000000000000000000006;

function set(bool permitted) internal {
assembly ('memory-safe') {
tstore(NESTED_UNLOCK_SLOT, permitted)
Expand Down
16 changes: 8 additions & 8 deletions contracts/libraries/ResolvedAmount.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@
pragma solidity ^0.8.24;

import {Constants} from './Constants.sol';
import {RESOLVED_AMOUNT_SLOT} from './TransientSlots.sol';

/// @notice A transient register holding an amount resolved onchain by a RESOLVE command, for a later
/// command to consume via the Constants.USE_RESOLVED_AMOUNT sentinel in one of its amount fields.
/// @dev The register is transaction-scoped: RESOLVE writes it, subsequent commands (including those in
/// an EXECUTE_SUB_PLAN) read it, and the top-level execute clears it on exit so it never leaks into a
/// later execute in the same transaction. Zero means empty: RESOLVE clears the register whenever it
/// fails, and a resolver returning zero counts as a failure, so no command acts on a value no RESOLVE produced.
/// @dev One register is shared by a plan and all of its sub-plans, and every consumer reads the latest value
/// written. A sub-plan starts with its parent's value, and a RESOLVE inside a sub-plan that succeeds replaces the
/// parent's value for the parent's later commands. A sub-plan that reverts under FLAG_ALLOW_REVERT has all of its
/// writes undone, its RESOLVE included, so the parent keeps the value it had before the sub-plan ran. The
/// top-level execute clears the register on exit so it never leaks into a later execute in the same transaction.
/// Zero means empty: RESOLVE clears the register whenever it fails, and a resolver returning zero counts as a
/// failure, so no command acts on a value no RESOLVE produced.
library ResolvedAmount {
/// @notice Thrown when a command consumes the USE_RESOLVED_AMOUNT sentinel while the register is empty
error ResolvedAmountUnset();

// The slot holding the resolved amount, transiently. Must equal TransientSlots.RESOLVED_AMOUNT; inline assembly
// only accepts a literal here.
bytes32 constant RESOLVED_AMOUNT_SLOT = 0x0000000000000000000000000000000000000000000000000000000000000007;

function set(uint256 amount) internal {
assembly ('memory-safe') {
tstore(RESOLVED_AMOUNT_SLOT, amount)
Expand Down
Loading
Loading