Skip to content
Open
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
34 changes: 34 additions & 0 deletions contracts/SwapProxy.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.24;

import {ERC20} from 'solmate/src/tokens/ERC20.sol';
import {SafeTransferLib} from 'solmate/src/utils/SafeTransferLib.sol';
import {IUniversalRouter} from './interfaces/IUniversalRouter.sol';
import {ISwapProxy} from './interfaces/ISwapProxy.sol';

/// @title SwapProxy
/// @notice Enables 2-tx swap flow (approve + swap) without Permit2 signed messages
/// @dev Transfers tokens from the user directly into the Universal Router (UR), then
/// executes UR commands with payerIsUser=false so the router uses its own balance.
/// This contract is to help with token-inputs, ETH-input actions should be sent directly to the UR.
/// IMPORTANT: All swap commands MUST use payerIsUser=false.
/// All recipient addresses MUST be the user's explicit address, NOT MSG_SENDER,
/// because MSG_SENDER resolves to this proxy contract within the UR execution context.
contract SwapProxy is ISwapProxy {
using SafeTransferLib for ERC20;

/// @inheritdoc ISwapProxy
function execute(
IUniversalRouter router,
address token,
uint256 amount,
bytes calldata commands,
bytes[] calldata inputs,
uint256 deadline
) external {
// Note: Solmate's SafeTransferLib does not check that the token address contains code.
// Transfer calls to empty addresses silently succeed.
ERC20(token).safeTransferFrom(msg.sender, address(router), amount);
router.execute(commands, inputs, deadline);
}
}
24 changes: 24 additions & 0 deletions contracts/interfaces/ISwapProxy.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.24;

import {IUniversalRouter} from './IUniversalRouter.sol';

/// @title ISwapProxy
/// @notice Interface for the SwapProxy contract that enables 2-tx swap flow without Permit2
interface ISwapProxy {
/// @notice Pull ERC20 tokens from msg.sender into the Universal Router, then execute commands
/// @param router The Universal Router to execute commands on
/// @param token The ERC20 token to pull from the caller
/// @param amount The amount of tokens to transfer into the UR
/// @param commands The encoded UR commands to execute
/// @param inputs The encoded inputs for each command
/// @param deadline The transaction deadline
function execute(
IUniversalRouter router,
address token,
uint256 amount,
bytes calldata commands,
bytes[] calldata inputs,
uint256 deadline
) external;
}
15 changes: 15 additions & 0 deletions script/DeploySwapProxy.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;

import 'forge-std/console2.sol';
import 'forge-std/Script.sol';
import {SwapProxy} from 'contracts/SwapProxy.sol';

contract DeploySwapProxy is Script {
function run() external returns (SwapProxy swapProxy) {
vm.startBroadcast();
swapProxy = new SwapProxy();
console2.log('SwapProxy Deployed:', address(swapProxy));
vm.stopBroadcast();
}
}
205 changes: 205 additions & 0 deletions test/foundry-tests/SwapProxy.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.15;

import 'forge-std/Test.sol';
import {IPermit2} from 'permit2/src/interfaces/IPermit2.sol';
import {ERC20} from 'solmate/src/tokens/ERC20.sol';
import {IUniswapV2Factory} from '@uniswap/v2-core/contracts/interfaces/IUniswapV2Factory.sol';
import {IUniswapV2Pair} from '@uniswap/v2-core/contracts/interfaces/IUniswapV2Pair.sol';
import {UniversalRouter} from '../../contracts/UniversalRouter.sol';
import {SwapProxy} from '../../contracts/SwapProxy.sol';
import {IUniversalRouter} from '../../contracts/interfaces/IUniversalRouter.sol';
import {Commands} from '../../contracts/libraries/Commands.sol';
import {RouterParameters} from '../../contracts/types/RouterParameters.sol';
import {MockERC20} from './mock/MockERC20.sol';

contract SwapProxyTest is Test {
uint256 constant AMOUNT = 1 ether;
uint256 constant BALANCE = 100000 ether;
IUniswapV2Factory constant FACTORY = IUniswapV2Factory(0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f);
ERC20 constant WETH9 = ERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
IPermit2 constant PERMIT2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3);

address USER = address(0xBEEF);

UniversalRouter router;
SwapProxy proxy;
MockERC20 tokenA;
MockERC20 tokenB;

function setUp() public {
vm.createSelectFork(vm.envString('FORK_URL'), 20010000);

RouterParameters memory params = RouterParameters({
permit2: address(PERMIT2),
weth9: address(WETH9),
v2Factory: address(FACTORY),
v3Factory: address(0),
pairInitCodeHash: bytes32(0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f),
poolInitCodeHash: bytes32(0),
v4PoolManager: address(0),
v3NFTPositionManager: address(0),
v4PositionManager: address(0),
spokePool: address(0)
});
router = new UniversalRouter(params);
proxy = new SwapProxy();

// Create mock tokens and V2 pair
tokenA = new MockERC20();
tokenB = new MockERC20();

// Ensure tokenA < tokenB for consistent ordering
if (address(tokenA) > address(tokenB)) {
(tokenA, tokenB) = (tokenB, tokenA);
}

address pair = FACTORY.createPair(address(tokenA), address(tokenB));
tokenA.mint(pair, 100 ether);
tokenB.mint(pair, 100 ether);
IUniswapV2Pair(pair).sync();

// Fund user and approve proxy (NOT Permit2)
vm.startPrank(USER);
tokenA.mint(USER, BALANCE);
tokenB.mint(USER, BALANCE);
ERC20(address(tokenA)).approve(address(proxy), type(uint256).max);
ERC20(address(tokenB)).approve(address(proxy), type(uint256).max);
}

function testExactInputViaProxy() public {
bytes memory commands = abi.encodePacked(bytes1(uint8(Commands.V2_SWAP_EXACT_IN)));
address[] memory path = new address[](2);
path[0] = address(tokenA);
path[1] = address(tokenB);
bytes[] memory inputs = new bytes[](1);
// payerIsUser=false, recipient=USER (explicit address, not MSG_SENDER)
inputs[0] = abi.encode(USER, AMOUNT, 0, path, false);

uint256 balanceBefore = tokenB.balanceOf(USER);

proxy.execute(
IUniversalRouter(address(router)), address(tokenA), AMOUNT, commands, inputs, block.timestamp + 1000
);

assertEq(tokenA.balanceOf(USER), BALANCE - AMOUNT);
assertGt(tokenB.balanceOf(USER), balanceBefore);
}

function testExactOutputViaProxyWithSweep() public {
uint256 maxIn = 2 ether; // overfund to test sweep
uint256 desiredOut = AMOUNT;

bytes memory commands =
abi.encodePacked(bytes1(uint8(Commands.V2_SWAP_EXACT_OUT)), bytes1(uint8(Commands.SWEEP)));
address[] memory path = new address[](2);
path[0] = address(tokenA);
path[1] = address(tokenB);
bytes[] memory inputs = new bytes[](2);
// payerIsUser=false, recipient=USER
inputs[0] = abi.encode(USER, desiredOut, maxIn, path, false);
// sweep leftover tokenA back to USER
inputs[1] = abi.encode(address(tokenA), USER, 0);

uint256 tokenABefore = tokenA.balanceOf(USER);
uint256 tokenBBefore = tokenB.balanceOf(USER);

proxy.execute(
IUniversalRouter(address(router)), address(tokenA), maxIn, commands, inputs, block.timestamp + 1000
);

// User received exact output
assertGe(tokenB.balanceOf(USER), tokenBBefore + desiredOut);
// User paid less than maxIn (excess swept back)
assertGt(tokenA.balanceOf(USER), tokenABefore - maxIn);
// No tokens stuck in the router
assertEq(tokenA.balanceOf(address(router)), 0);
}

function testRevertOnInsufficientApproval() public {
// Revoke approval
ERC20(address(tokenA)).approve(address(proxy), 0);

bytes memory commands = abi.encodePacked(bytes1(uint8(Commands.V2_SWAP_EXACT_IN)));
address[] memory path = new address[](2);
path[0] = address(tokenA);
path[1] = address(tokenB);
bytes[] memory inputs = new bytes[](1);
inputs[0] = abi.encode(USER, AMOUNT, 0, path, false);

vm.expectRevert();
proxy.execute(
IUniversalRouter(address(router)), address(tokenA), AMOUNT, commands, inputs, block.timestamp + 1000
);
}

function testRevertOnExpiredDeadline() public {
bytes memory commands = abi.encodePacked(bytes1(uint8(Commands.V2_SWAP_EXACT_IN)));
address[] memory path = new address[](2);
path[0] = address(tokenA);
path[1] = address(tokenB);
bytes[] memory inputs = new bytes[](1);
inputs[0] = abi.encode(USER, AMOUNT, 0, path, false);

vm.expectRevert(IUniversalRouter.TransactionDeadlinePassed.selector);
proxy.execute(IUniversalRouter(address(router)), address(tokenA), AMOUNT, commands, inputs, block.timestamp - 1);
}

function testNoTokensStuckInProxy() public {
bytes memory commands = abi.encodePacked(bytes1(uint8(Commands.V2_SWAP_EXACT_IN)));
address[] memory path = new address[](2);
path[0] = address(tokenA);
path[1] = address(tokenB);
bytes[] memory inputs = new bytes[](1);
inputs[0] = abi.encode(USER, AMOUNT, 0, path, false);

proxy.execute(
IUniversalRouter(address(router)), address(tokenA), AMOUNT, commands, inputs, block.timestamp + 1000
);

// Proxy should never hold tokens
assertEq(tokenA.balanceOf(address(proxy)), 0);
assertEq(tokenB.balanceOf(address(proxy)), 0);
// Router should not hold tokens either
assertEq(tokenA.balanceOf(address(router)), 0);
}

function testGasComparisonDirectVsProxy() public {
// Setup: also approve Permit2 for direct comparison
ERC20(address(tokenA)).approve(address(PERMIT2), type(uint256).max);
PERMIT2.approve(address(tokenA), address(router), type(uint160).max, type(uint48).max);

address[] memory path = new address[](2);
path[0] = address(tokenA);
path[1] = address(tokenB);

// Gas via proxy
bytes memory proxyCommands = abi.encodePacked(bytes1(uint8(Commands.V2_SWAP_EXACT_IN)));
bytes[] memory proxyInputs = new bytes[](1);
proxyInputs[0] = abi.encode(USER, AMOUNT, 0, path, false);

uint256 gasBefore = gasleft();
proxy.execute(
IUniversalRouter(address(router)),
address(tokenA),
AMOUNT,
proxyCommands,
proxyInputs,
block.timestamp + 1000
);
uint256 gasProxy = gasBefore - gasleft();

// Gas via direct Permit2
bytes memory directCommands = abi.encodePacked(bytes1(uint8(Commands.V2_SWAP_EXACT_IN)));
bytes[] memory directInputs = new bytes[](1);
directInputs[0] = abi.encode(USER, AMOUNT, 0, path, true);

gasBefore = gasleft();
router.execute(directCommands, directInputs);
uint256 gasDirect = gasBefore - gasleft();

emit log_named_uint('Gas via proxy', gasProxy);
emit log_named_uint('Gas via direct Permit2', gasDirect);
emit log_named_uint('Gas overhead', gasProxy > gasDirect ? gasProxy - gasDirect : 0);
}
}
Loading