Skip to content

Commit 3586b44

Browse files
authored
Pay the native currency from the contract that holds it (#154)
Fixes #121. `CurrencySettler.settle` pays the native currency from the balance of the calling contract while ignoring the `payer` argument, so a hook settling for someone else spends its own balance. Settling the native currency now requires `payer` to be the calling contract. `LimitOrderHook` serves no pool holding the native currency. The placer settles the currency their order sells, which the hook cannot take from them, so an order was free to place and paid out on cancel. `BaseCustomAccounting` and `BaseCustomCurve` now name the calling contract as the payer for the native currency, which is what they already did in effect.
2 parents 87811cb + 2aedccf commit 3586b44

5 files changed

Lines changed: 126 additions & 5 deletions

File tree

src/base/BaseCustomAccounting.sol

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,15 @@ abstract contract BaseCustomAccounting is BaseHook, IHookEvents, IUnlockCallback
265265

266266
// Handle each currency amount based on its sign after applying the liquidity modification
267267
if (principalDelta.amount0() < 0) {
268-
// If amount0 is negative, send tokens from the sender to the pool
269-
key.currency0.settle(poolManager, data.sender, uint256(int256(-principalDelta.amount0())), false);
268+
// If amount0 is negative, send tokens from the sender to the pool. The native currency is paid
269+
// from this contract, which holds the sender's value for the length of the call
270+
key.currency0
271+
.settle(
272+
poolManager,
273+
key.currency0.isAddressZero() ? address(this) : data.sender,
274+
uint256(int256(-principalDelta.amount0())),
275+
false
276+
);
270277
} else {
271278
// If amount0 is positive, send tokens from the pool to the sender
272279
key.currency0.take(poolManager, data.sender, uint256(int256(principalDelta.amount0())), false);

src/base/BaseCustomCurve.sol

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,15 @@ abstract contract BaseCustomCurve is BaseCustomAccounting {
244244

245245
// Add liquidity if amount0 is positive
246246
if (data.amount0 > 0) {
247-
// First settle (send) tokens from user to pool
248-
key.currency0.settle(poolManager, data.sender, uint256(int256(data.amount0)), false);
247+
// First settle (send) tokens from user to pool. The native currency is paid from this
248+
// contract, which holds the sender's value for the length of the call
249+
key.currency0
250+
.settle(
251+
poolManager,
252+
key.currency0.isAddressZero() ? address(this) : data.sender,
253+
uint256(int256(data.amount0)),
254+
false
255+
);
249256
// Take (mint) ERC-6909 tokens to be received by this hook
250257
key.currency0.take(poolManager, address(this), uint256(int256(data.amount0)), true);
251258
// Record the amount so that it can be then encoded into the delta

src/general/LimitOrderHook.sol

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ library OrderIdLibrary {
5656
* fees earned while its liquidity was in the order and to none of those earned before it. Amounts are truncated
5757
* in the order's favour, so a negligible residual can remain in the hook.
5858
*
59+
* NOTE: Native currency orders are not supported.
60+
*
5961
* IMPORTANT: Uniswap V4 does not call a hook's own callbacks when that hook is the caller, so {_afterSwap}
6062
* does not run for a swap this hook makes itself. A subclass that swaps internally MUST call
6163
* {_fillCrossedOrders} afterwards, or the tick recorded for the pool falls behind the price and the next
@@ -175,6 +177,9 @@ abstract contract LimitOrderHook is BaseHook, IUnlockCallback {
175177
/// @dev Limit order is not filled.
176178
error NotFilled();
177179

180+
/// @dev Limit order was placed in a pool holding the native currency.
181+
error NativeCurrencyUnsupported();
182+
178183
/**
179184
* @dev Emitted when an `owner` places a limit order with the given `orderId`, in the pool identified by `key`,
180185
* at the given `tickLower`, `zeroForOne` indicating the direction of the order, and `liquidity` the amount of liquidity
@@ -259,6 +264,8 @@ abstract contract LimitOrderHook is BaseHook, IUnlockCallback {
259264
{
260265
if (liquidity == 0) revert ZeroLiquidity();
261266

267+
if (key.currency0.isAddressZero()) revert NativeCurrencyUnsupported();
268+
262269
OrderInfo storage orderInfo;
263270

264271
// get the order id

src/utils/CurrencySettler.sol

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,16 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol
2121
library CurrencySettler {
2222
using SafeERC20 for IERC20;
2323

24+
/// @dev The native currency was settled on behalf of a `payer` other than the contract paying it.
25+
error InvalidNativePayer(address payer);
26+
2427
/**
2528
* @notice Settle (pay) a currency to the `PoolManager`
2629
* @param currency Currency to settle
2730
* @param poolManager `PoolManager` to settle to
28-
* @param payer Address of the payer, which can be the hook itself or an external address.
31+
* @param payer Address of the payer, which can be the hook itself or an external address. The native
32+
* currency is paid from the balance of the calling contract, so `payer` must be that contract when
33+
* `currency` is native and `burn` is false, otherwise the call reverts with {InvalidNativePayer}.
2934
* @param amount Amount to send
3035
* @param burn If true, burn the ERC-6909 token, otherwise transfer ERC-20 to the `PoolManager`
3136
*/
@@ -38,6 +43,10 @@ library CurrencySettler {
3843
if (burn) {
3944
poolManager.burn(payer, currency.toId(), amount);
4045
} else if (currency.isAddressZero()) {
46+
// the value is paid from the balance of the calling contract, so settling for another payer
47+
// would spend currency that payer never provided
48+
if (payer != address(this)) revert InvalidNativePayer(payer);
49+
4150
poolManager.sync(currency);
4251
poolManager.settle{value: amount}();
4352
} else {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.26;
3+
4+
import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol";
5+
import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol";
6+
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
7+
import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
8+
import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
9+
import {IERC20Minimal} from "@uniswap/v4-core/src/interfaces/external/IERC20Minimal.sol";
10+
// Internal imports
11+
import {LimitOrderHook, OrderIdLibrary} from "src/general/LimitOrderHook.sol";
12+
import {LimitOrderHookMock} from "src/mocks/general/LimitOrderHookMock.sol";
13+
import {CurrencySettler} from "src/utils/CurrencySettler.sol";
14+
import {HookTest} from "../utils/HookTest.sol";
15+
16+
/// @dev Calls {CurrencySettler-settle} directly, to reach the library from outside a hook.
17+
contract CurrencySettlerMock {
18+
function settleNative(IPoolManager poolManager, address payer, uint256 amount) external {
19+
CurrencySettler.settle(Currency.wrap(address(0)), poolManager, payer, amount, false);
20+
}
21+
}
22+
23+
contract LimitOrderHookNativeTest is HookTest {
24+
LimitOrderHookMock hook;
25+
26+
address user = makeAddr("user");
27+
address swapper = makeAddr("swapper");
28+
address attacker = makeAddr("attacker");
29+
30+
int24 tickSpacing;
31+
32+
function setUp() public {
33+
deployFreshManagerAndRouters();
34+
deployMintAndApprove2Currencies();
35+
36+
hook = LimitOrderHookMock(address(uint160(Hooks.AFTER_INITIALIZE_FLAG | Hooks.AFTER_SWAP_FLAG)));
37+
deployCodeTo(
38+
"src/mocks/general/LimitOrderHookMock.sol:LimitOrderHookMock", abi.encode(address(manager)), address(hook)
39+
);
40+
41+
(nativeKey,) = initPool(Currency.wrap(address(0)), currency1, IHooks(address(hook)), 3000, SQRT_PRICE_1_1);
42+
tickSpacing = nativeKey.tickSpacing;
43+
44+
address[3] memory holders = [user, swapper, attacker];
45+
for (uint256 i = 0; i < holders.length; i++) {
46+
deal(holders[i], 1e24);
47+
IERC20Minimal(Currency.unwrap(currency1)).transfer(holders[i], 1e30);
48+
49+
vm.startPrank(holders[i]);
50+
IERC20Minimal(Currency.unwrap(currency1)).approve(address(hook), type(uint256).max);
51+
IERC20Minimal(Currency.unwrap(currency1)).approve(address(swapRouter), type(uint256).max);
52+
IERC20Minimal(Currency.unwrap(currency1)).approve(address(modifyLiquidityRouter), type(uint256).max);
53+
vm.stopPrank();
54+
}
55+
56+
deal(address(this), 1e24);
57+
}
58+
59+
/// @dev The placer settles the currency their order sells, and the native currency is paid from the
60+
/// hook's balance rather than theirs. The hook serves no pool holding it, in either direction, so
61+
/// whatever balance it holds stays where it is.
62+
function test_placeOrder_native_reverts() public {
63+
uint256 seeded = 5 ether;
64+
vm.deal(address(hook), seeded);
65+
66+
vm.prank(attacker);
67+
vm.expectRevert(LimitOrderHook.NativeCurrencyUnsupported.selector);
68+
hook.placeOrder(nativeKey, tickSpacing, true, 1e18);
69+
70+
vm.prank(attacker);
71+
vm.expectRevert(LimitOrderHook.NativeCurrencyUnsupported.selector);
72+
hook.placeOrder(nativeKey, -tickSpacing, false, 1e18);
73+
74+
assertEq(address(hook).balance, seeded, "no placement should reach the hook's balance");
75+
assertEq(rawOrderIdOf(nativeKey, tickSpacing, true), 0, "no sell order should exist");
76+
assertEq(rawOrderIdOf(nativeKey, -tickSpacing, false), 0, "no buy order should exist");
77+
}
78+
79+
/// @dev The library pays the native currency from the balance of the calling contract, so settling it
80+
/// for anyone else would spend a balance that payer never provided.
81+
function test_settle_nativeForAnotherPayer_reverts() public {
82+
CurrencySettlerMock settler = new CurrencySettlerMock();
83+
84+
vm.expectRevert(abi.encodeWithSelector(CurrencySettler.InvalidNativePayer.selector, attacker));
85+
settler.settleNative(manager, attacker, 1 ether);
86+
}
87+
88+
function rawOrderIdOf(PoolKey memory poolKey, int24 tickLower, bool zeroForOne) internal view returns (uint232) {
89+
return OrderIdLibrary.OrderId.unwrap(hook.getOrderId(poolKey, tickLower, zeroForOne));
90+
}
91+
}

0 commit comments

Comments
 (0)