@bananapus/router-terminal-v6 0.0.20 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ARCHITECTURE.md CHANGED
@@ -63,7 +63,7 @@ Payer → JBRouterTerminal.pay(projectId, token, amount)
63
63
  │ │
64
64
  │ ├─ If native ETH input: wrap any remaining raw ETH (partial fills)
65
65
  │ ├─ If native ETH output: unwrap WETH → ETH (WETH.withdraw)
66
- │ └─ Return leftover input tokens via _resolveRefundTo (checks msg.sender's IJBPayerTracker.originalPayer() via try-catch, falls back to beneficiary/msgSender)
66
+ │ └─ Return leftover input tokens via _resolveRefundWithBackupRecipient (checks msg.sender's IJBPayerTracker.originalPayer() via try-catch, falls back to beneficiary for pay() or _msgSender() for addToBalanceOf())
67
67
 
68
68
  ├─ Approve destination terminal for output tokens (or set msg.value for native)
69
69
  └─ Forward to destTerminal.pay() → return beneficiary token count
@@ -104,7 +104,7 @@ terminal source for loan sizing, debt normalization, or any other decimals-depen
104
104
 
105
105
  **Why the registry pattern.** `JBRouterTerminalRegistry` lets project owners lock a specific `JBRouterTerminal` instance for their project and manage Permit2 approvals in one place. This provides a stable entry point: if the router implementation is upgraded, the registry can be pointed to the new instance without changing the project's directory entry. It also gates which router terminals are allowed, preventing untrusted implementations from being set.
106
106
 
107
- **Why `IJBPayerTracker` is a separate interface.** The router terminal needs to know the original payer when called through an intermediary so it can return leftover tokens from partial swap fills to the right address. Rather than coupling the router to `IJBRouterTerminalRegistry` specifically, the refund resolution logic (`_resolveRefundTo`) queries `IJBPayerTracker(msg.sender).originalPayer()` via a try-catch. This means any contract that implements `IJBPayerTracker` -- not just the registry -- can act as a forwarding intermediary. The registry inherits `IJBPayerTracker` through `IJBRouterTerminalRegistry`, keeping backward compatibility while opening the door for other intermediary patterns.
107
+ **Why `IJBPayerTracker` is a separate interface.** The router terminal needs to know the original payer when called through an intermediary so it can return leftover tokens from partial swap fills to the right address. Rather than coupling the router to `IJBRouterTerminalRegistry` specifically, the refund resolution logic (`_resolveRefundWithBackupRecipient`) queries `IJBPayerTracker(msg.sender).originalPayer()` via a try-catch. This means any contract that implements `IJBPayerTracker` -- not just the registry -- can act as a forwarding intermediary. The registry inherits `IJBPayerTracker` through `IJBRouterTerminalRegistry`, keeping backward compatibility while opening the door for other intermediary patterns.
108
108
 
109
109
  **Why liquidity-based pool selection instead of quote comparison.** Comparing actual output quotes across V3 and V4 would require executing (or simulating) swaps on both — expensive on-chain and complex for V4 where swaps must go through `PoolManager.unlock()`. Comparing in-range liquidity is a single `liquidity()` or `getLiquidity()` read per pool, is gas-cheap, and strongly correlates with execution quality for typical swap sizes.
110
110
 
package/CHANGE_LOG.md CHANGED
@@ -157,6 +157,13 @@ In v6, when a Permit2 allowance call fails during `_acceptFundsFor()`, an event
157
157
 
158
158
  ## 3. Event Changes
159
159
 
160
+ ### 3.0 Indexer Notes
161
+
162
+ This repo is the direct replacement for v5 swap-terminal indexing:
163
+ - all registry event families moved from `JBSwapTerminalRegistry_*` to `JBRouterTerminalRegistry_*`;
164
+ - registry events now include `caller`;
165
+ - route discovery is dynamic, so do not assume one fixed output token or one manually-registered default pool per project.
166
+
160
167
  ### 3.1 Registry Events Renamed
161
168
  All events were renamed from `JBSwapTerminalRegistry_*` to `JBRouterTerminalRegistry_*`.
162
169
 
package/RISKS.md CHANGED
@@ -7,7 +7,7 @@
7
7
  - **JBDirectory.** Terminal resolution trusts `DIRECTORY.primaryTerminalOf()` and `DIRECTORY.terminalsOf()`. A compromised directory can redirect funds.
8
8
  - **PERMIT2.** Used as fallback for token transfers. Permit2 approvals can be exploited if users have stale allowances.
9
9
  - **Owner (Ownable).** Contract owner has no fund access but controls the registry terminal allowlist and default.
10
- - **`IJBPayerTracker` implementers.** `_resolveRefundTo` in the router terminal queries `IJBPayerTracker(msg.sender).originalPayer()` via try-catch. Any contract that is the `msg.sender` and implements `IJBPayerTracker` can direct leftover refunds to an arbitrary address. This is safe when the caller is a trusted intermediary (e.g. the registry), but a malicious `msg.sender` implementing `IJBPayerTracker` could redirect refunds. The risk is bounded: the caller must have already supplied the funds being routed, so it can only redirect leftovers from its own payment.
10
+ - **`IJBPayerTracker` implementers.** `_resolveRefundWithBackupRecipient` in the router terminal queries `IJBPayerTracker(msg.sender).originalPayer()` via try-catch. Any contract that is the `msg.sender` and implements `IJBPayerTracker` can direct leftover refunds to an arbitrary address. This is safe when the caller is a trusted intermediary (e.g. the registry), but a malicious `msg.sender` implementing `IJBPayerTracker` could redirect refunds. The risk is bounded: the caller must have already supplied the funds being routed, so it can only redirect leftovers from its own payment.
11
11
 
12
12
  ## 2. Economic / Manipulation Risks
13
13
 
@@ -78,7 +78,7 @@ Verified in `RouterTerminalReentrancy.t.sol`: re-entrant calls via both `pay()`
78
78
 
79
79
  ### 8.2 Router trusts `originalPayer()` from any `msg.sender` that implements it
80
80
 
81
- `_resolveRefundTo` calls `IJBPayerTracker(msg.sender).originalPayer()` in a try-catch. If the call succeeds and returns a non-zero address, leftover tokens from partial swap fills are sent to that address instead of the beneficiary or `_msgSender()`. The router does not verify that `msg.sender` is the registry or any specific contract -- it trusts any caller that implements the interface. This is accepted because: (1) the caller (`msg.sender`) is the entity that supplied the funds, so redirecting its own leftovers is a legitimate operation, (2) if the call reverts or returns `address(0)`, the router falls back to the normal beneficiary/`_msgSender()` logic, and (3) decoupling from the registry allows other intermediary contracts (e.g. batch payers, aggregators) to participate in refund routing without requiring changes to the router terminal.
81
+ `_resolveRefundWithBackupRecipient` calls `IJBPayerTracker(msg.sender).originalPayer()` in a try-catch. If the call succeeds and returns a non-zero address, leftover tokens from partial swap fills are sent to that address instead of the beneficiary or `_msgSender()`. The router does not verify that `msg.sender` is the registry or any specific contract -- it trusts any caller that implements the interface. This is accepted because: (1) the caller (`msg.sender`) is the entity that supplied the funds, so redirecting its own leftovers is a legitimate operation, (2) if the call reverts or returns `address(0)`, the router falls back to the normal beneficiary/`_msgSender()` logic, and (3) decoupling from the registry allows other intermediary contracts (e.g. batch payers, aggregators) to participate in refund routing without requiring changes to the router terminal.
82
82
 
83
83
  ### 8.3 Cashout loop slippage is first-hop only (accepted trade-off)
84
84
 
package/SKILLS.md CHANGED
@@ -60,7 +60,7 @@ If `tokenIn` is a JB project token, a cashout loop runs first (up to 20 iteratio
60
60
 
61
61
  ## Routing Architecture
62
62
 
63
- The router uses `_route` (mutative) and `_previewRoute` (view) as the top-level entry points. Both follow the same path: detect JB project tokens and `_cashOutLoop` if needed, then `_resolveTokenOut` to pick the output token, then `_convert` to execute the conversion (no-op, wrap/unwrap, or `_handleSwap`). Swap execution goes through `_pickPoolAndQuote` (discover pool, get TWAP or spot quote with sigmoid slippage), then dispatches to V3 (`uniswapV3SwapCallback`) or V4 (`unlockCallback`). Leftover input tokens from partial fills are returned to the `beneficiary` (for `pay()`) or `_msgSender()` (for `addToBalanceOf()`).
63
+ The router uses `_route` (mutative) and `_previewRoute` (view) as the top-level entry points. Both follow the same path: detect JB project tokens and `_cashOutLoop` if needed, then `_resolveTokenOut` to pick the output token, then `_convert` to execute the conversion (no-op, wrap/unwrap, or `_handleSwap`). Swap execution goes through `_pickPoolAndQuote` (discover pool, get TWAP or spot quote with sigmoid slippage), then dispatches to V3 (`uniswapV3SwapCallback`) or V4 (`unlockCallback`). Leftover input tokens from partial fills are returned via `_resolveRefundWithBackupRecipient`. When the caller (`msg.sender`) implements `IJBPayerTracker` and `originalPayer()` returns a non-zero address, leftovers go to that address. Otherwise, leftovers go to `beneficiary` (for `pay()`) or `_msgSender()` (for `addToBalanceOf()`).
64
64
 
65
65
  ## Integration Points
66
66
 
@@ -201,7 +201,7 @@ The router uses `_route` (mutative) and `_previewRoute` (view) as the top-level
201
201
  - The `JBRouterTerminalRegistry` handles token custody during delegation -- it transfers tokens from the payer to itself, then approves and forwards to the underlying terminal.
202
202
  - `_msgSender()` (ERC-2771) is used instead of `msg.sender` for meta-transaction compatibility in both contracts.
203
203
  - The `JBSwapLib` library contains slippage tolerance math (sigmoid formula), price impact estimation, and V3-compatible `sqrtPriceLimitX96` calculation. It does not contain swap execution logic.
204
- - **Leftover handling**: After a swap, leftover input tokens (from partial fills where the price limit was hit) are returned via `_resolveRefundTo`. When the caller (`msg.sender`) implements `IJBPayerTracker` and `originalPayer()` returns a non-zero address, leftovers go to that address. Otherwise they go to the `beneficiary` (for `pay()`) or `_msgSender()` (for `addToBalanceOf()`). For native token inputs, any remaining raw ETH is wrapped to WETH first so the leftover check catches it.
204
+ - **Leftover handling**: After a swap, leftover input tokens (from partial fills where the price limit was hit) are returned via `_resolveRefundWithBackupRecipient`. When the caller (`msg.sender`) implements `IJBPayerTracker` and `originalPayer()` returns a non-zero address, leftovers go to that address. Otherwise, leftovers go to `beneficiary` (for `pay()`) or `_msgSender()` (for `addToBalanceOf()`). For native token inputs, any remaining raw ETH is wrapped to WETH first so the leftover check catches it.
205
205
  - **`IJBPayerTracker` decoupling**: The router terminal does not import or depend on `IJBRouterTerminalRegistry` for refund resolution. It queries `IJBPayerTracker(msg.sender).originalPayer()` via try-catch. Any intermediary contract that implements `IJBPayerTracker` can forward calls to the router and have leftovers returned to the original payer.
206
206
  - **Credit cashouts**: When using `cashOutSource` metadata, the payer must have granted `TRANSFER_CREDITS` permission (ID 13) to the router terminal for the source project. The router calls `TOKENS.transferCreditsFrom()` to pull credits.
207
207
  - **Cashout loop depth**: The `_cashOutLoop` iterates through JB project token chains with a cap of 20 iterations (`_MAX_CASHOUT_ITERATIONS`). Exceeding this limit reverts with `JBRouterTerminal_CashOutLoopLimit()`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/router-terminal-v6",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,8 +17,8 @@
17
17
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'nana-router-terminal-v6'"
18
18
  },
19
19
  "dependencies": {
20
- "@bananapus/address-registry-v6": "^0.0.15",
21
- "@bananapus/core-v6": "^0.0.27",
20
+ "@bananapus/address-registry-v6": "^0.0.16",
21
+ "@bananapus/core-v6": "^0.0.28",
22
22
  "@bananapus/permission-ids-v6": "^0.0.14",
23
23
  "@openzeppelin/contracts": "^5.6.1",
24
24
  "@uniswap/permit2": "github:Uniswap/permit2",
@@ -224,7 +224,7 @@ contract JBRouterTerminal is
224
224
  tokenIn: token,
225
225
  amount: _acceptFundsFor({token: token, amount: amount, metadata: metadata}),
226
226
  metadata: metadata,
227
- refundTo: _resolveRefundTo(payable(_msgSender()))
227
+ refundTo: _resolveRefundWithBackupRecipient(payable(_msgSender()))
228
228
  });
229
229
 
230
230
  uint256 payValue = _beforeTransferFor({to: address(destTerminal), token: token, amount: amount});
@@ -275,7 +275,7 @@ contract JBRouterTerminal is
275
275
  tokenIn: token,
276
276
  amount: _acceptFundsFor({token: token, amount: amount, metadata: metadata}),
277
277
  metadata: metadata,
278
- refundTo: _resolveRefundTo(payable(beneficiary))
278
+ refundTo: _resolveRefundWithBackupRecipient(payable(beneficiary))
279
279
  });
280
280
 
281
281
  uint256 payValue = _beforeTransferFor({to: address(destTerminal), token: token, amount: amount});
@@ -518,7 +518,7 @@ contract JBRouterTerminal is
518
518
  /// go to the true payer rather than the intermediary.
519
519
  /// @param fallback_ The default refund address to use when no original payer is available.
520
520
  /// @return The address to refund partial-fill leftovers to.
521
- function _resolveRefundTo(address payable fallback_) internal view returns (address payable) {
521
+ function _resolveRefundWithBackupRecipient(address payable fallback_) internal view returns (address payable) {
522
522
  // Only attempt the call if msg.sender is a contract (EOAs have no code and would revert).
523
523
  if (msg.sender.code.length > 0) {
524
524
  // Check if the caller implements IJBPayerTracker and has an original payer set.
@@ -0,0 +1,424 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+
6
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
7
+ import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
8
+ import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
9
+ import {IJBRulesetApprovalHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetApprovalHook.sol";
10
+ import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
11
+ import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
12
+ import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol";
13
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
14
+ import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
15
+ import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
16
+ import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
17
+ import {IUniswapV3Factory} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
18
+ import {IUniswapV3Pool} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
19
+ import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
20
+
21
+ import {JBRouterTerminal} from "../../src/JBRouterTerminal.sol";
22
+ import {IWETH9} from "../../src/interfaces/IWETH9.sol";
23
+
24
+ contract AuditLeftoverMockERC20 {
25
+ mapping(address => uint256) public balanceOf;
26
+ mapping(address => mapping(address => uint256)) public allowance;
27
+
28
+ function mint(address to, uint256 amount) external {
29
+ balanceOf[to] += amount;
30
+ }
31
+
32
+ function approve(address spender, uint256 amount) external returns (bool) {
33
+ allowance[msg.sender][spender] = amount;
34
+ return true;
35
+ }
36
+
37
+ function transfer(address to, uint256 amount) external returns (bool) {
38
+ balanceOf[msg.sender] -= amount;
39
+ balanceOf[to] += amount;
40
+ return true;
41
+ }
42
+
43
+ function transferFrom(address from, address to, uint256 amount) external returns (bool) {
44
+ uint256 allowed = allowance[from][msg.sender];
45
+ if (allowed != type(uint256).max) allowance[from][msg.sender] = allowed - amount;
46
+ balanceOf[from] -= amount;
47
+ balanceOf[to] += amount;
48
+ return true;
49
+ }
50
+ }
51
+
52
+ contract AuditLeftoverMockWETH is IWETH9 {
53
+ uint256 public totalSupply;
54
+ mapping(address => uint256) public balanceOf;
55
+ mapping(address => mapping(address => uint256)) public allowance;
56
+
57
+ function approve(address spender, uint256 amount) external returns (bool) {
58
+ allowance[msg.sender][spender] = amount;
59
+ return true;
60
+ }
61
+
62
+ function transfer(address to, uint256 amount) external returns (bool) {
63
+ balanceOf[msg.sender] -= amount;
64
+ balanceOf[to] += amount;
65
+ return true;
66
+ }
67
+
68
+ function transferFrom(address from, address to, uint256 amount) external returns (bool) {
69
+ uint256 allowed = allowance[from][msg.sender];
70
+ if (allowed != type(uint256).max) allowance[from][msg.sender] = allowed - amount;
71
+ balanceOf[from] -= amount;
72
+ balanceOf[to] += amount;
73
+ return true;
74
+ }
75
+
76
+ function deposit() external payable override {
77
+ balanceOf[msg.sender] += msg.value;
78
+ totalSupply += msg.value;
79
+ }
80
+
81
+ function withdraw(uint256 amount) external override {
82
+ balanceOf[msg.sender] -= amount;
83
+ totalSupply -= amount;
84
+ payable(msg.sender).transfer(amount);
85
+ }
86
+
87
+ receive() external payable {}
88
+ }
89
+
90
+ contract AuditLeftoverDestTerminal is IJBTerminal {
91
+ uint256 public lastAmount;
92
+
93
+ function addAccountingContextsFor(uint256, JBAccountingContext[] calldata) external override {}
94
+
95
+ function addToBalanceOf(
96
+ uint256,
97
+ address,
98
+ uint256 amount,
99
+ bool,
100
+ string calldata,
101
+ bytes calldata
102
+ )
103
+ external
104
+ payable
105
+ override
106
+ {
107
+ lastAmount = amount;
108
+ }
109
+
110
+ function accountingContextForTokenOf(
111
+ uint256,
112
+ address token
113
+ )
114
+ external
115
+ pure
116
+ override
117
+ returns (JBAccountingContext memory)
118
+ {
119
+ return JBAccountingContext({token: token, decimals: 18, currency: uint32(uint160(token))});
120
+ }
121
+
122
+ function accountingContextsOf(uint256) external pure override returns (JBAccountingContext[] memory contexts) {
123
+ contexts = new JBAccountingContext[](1);
124
+ contexts[0] = JBAccountingContext({token: address(1), decimals: 18, currency: 1});
125
+ }
126
+
127
+ function currentSurplusOf(uint256, address[] calldata, uint256, uint256) external pure override returns (uint256) {}
128
+
129
+ function migrateBalanceOf(uint256, address, IJBTerminal) external pure override returns (uint256) {}
130
+
131
+ function pay(
132
+ uint256,
133
+ address,
134
+ uint256 amount,
135
+ address,
136
+ uint256,
137
+ string calldata,
138
+ bytes calldata
139
+ )
140
+ external
141
+ payable
142
+ override
143
+ returns (uint256)
144
+ {
145
+ lastAmount = amount;
146
+ return amount;
147
+ }
148
+
149
+ function previewPayFor(
150
+ uint256,
151
+ address,
152
+ uint256 amount,
153
+ address,
154
+ bytes calldata
155
+ )
156
+ external
157
+ pure
158
+ override
159
+ returns (JBRuleset memory, uint256, uint256, JBPayHookSpecification[] memory hookSpecifications)
160
+ {
161
+ hookSpecifications = new JBPayHookSpecification[](0);
162
+ return (JBRuleset(0, 0, 0, 0, 0, 0, 0, IJBRulesetApprovalHook(address(0)), 0), amount, 0, hookSpecifications);
163
+ }
164
+
165
+ function supportsInterface(bytes4) external pure override returns (bool) {
166
+ return true;
167
+ }
168
+ }
169
+
170
+ contract AuditLeftoverPartialFillPool is IUniswapV3Pool {
171
+ address internal immutable _token0;
172
+ address internal immutable _token1;
173
+ AuditLeftoverMockERC20 internal immutable _outputToken;
174
+ uint24 public immutable override fee = 3000;
175
+ uint128 public immutable override liquidity = 1_000_000;
176
+
177
+ uint256 public immutable amountInUsed;
178
+ uint256 public immutable amountOutGiven;
179
+
180
+ constructor(
181
+ address token0_,
182
+ address token1_,
183
+ AuditLeftoverMockERC20 outputToken_,
184
+ uint256 amountInUsed_,
185
+ uint256 amountOutGiven_
186
+ ) {
187
+ _token0 = token0_;
188
+ _token1 = token1_;
189
+ _outputToken = outputToken_;
190
+ amountInUsed = amountInUsed_;
191
+ amountOutGiven = amountOutGiven_;
192
+ }
193
+
194
+ function swap(
195
+ address recipient,
196
+ bool zeroForOne,
197
+ int256,
198
+ uint160,
199
+ bytes calldata data
200
+ )
201
+ external
202
+ override
203
+ returns (int256 amount0, int256 amount1)
204
+ {
205
+ if (zeroForOne) {
206
+ JBRouterTerminal(payable(msg.sender))
207
+ .uniswapV3SwapCallback(int256(amountInUsed), -int256(amountOutGiven), data);
208
+ _outputToken.mint(recipient, amountOutGiven);
209
+ return (int256(amountInUsed), -int256(amountOutGiven));
210
+ }
211
+
212
+ JBRouterTerminal(payable(msg.sender)).uniswapV3SwapCallback(-int256(amountOutGiven), int256(amountInUsed), data);
213
+ _outputToken.mint(recipient, amountOutGiven);
214
+ return (-int256(amountOutGiven), int256(amountInUsed));
215
+ }
216
+
217
+ function token0() external view override returns (address) {
218
+ return _token0;
219
+ }
220
+
221
+ function token1() external view override returns (address) {
222
+ return _token1;
223
+ }
224
+
225
+ function tickSpacing() external pure override returns (int24) {
226
+ return 1;
227
+ }
228
+
229
+ function slot0() external pure override returns (uint160, int24, uint16, uint16, uint16, uint8, bool) {
230
+ return (0, 0, 0, 0, 0, 0, false);
231
+ }
232
+
233
+ function feeGrowthGlobal0X128() external pure override returns (uint256) {}
234
+ function feeGrowthGlobal1X128() external pure override returns (uint256) {}
235
+ function protocolFees() external pure override returns (uint128, uint128) {}
236
+ function ticks(int24)
237
+ external
238
+ pure
239
+ override
240
+ returns (uint128, int128, uint256, uint256, int56, uint160, uint32, bool)
241
+ {}
242
+ function tickBitmap(int16) external pure override returns (uint256) {}
243
+ function positions(bytes32) external pure override returns (uint128, uint256, uint256, uint128, uint128) {}
244
+ function observations(uint256) external pure override returns (uint32, int56, uint160, bool) {}
245
+ function observe(uint32[] calldata) external pure override returns (int56[] memory, uint160[] memory) {}
246
+ function snapshotCumulativesInside(int24, int24) external pure override returns (int56, uint160, uint32) {}
247
+ function increaseObservationCardinalityNext(uint16) external override {}
248
+ function mint(address, int24, int24, uint128, bytes calldata) external pure override returns (uint256, uint256) {}
249
+ function collect(address, int24, int24, uint128, uint128) external pure override returns (uint128, uint128) {}
250
+ function burn(int24, int24, uint128) external pure override returns (uint256, uint256) {}
251
+ function flash(address, uint256, uint256, bytes calldata) external override {}
252
+ function initialize(uint160) external override {}
253
+ function setFeeProtocol(uint8, uint8) external override {}
254
+ function collectProtocol(address, uint128, uint128) external pure override returns (uint128, uint128) {}
255
+
256
+ function factory() external pure override returns (address) {
257
+ return address(0);
258
+ }
259
+
260
+ function maxLiquidityPerTick() external pure override returns (uint128) {
261
+ return type(uint128).max;
262
+ }
263
+ }
264
+
265
+ contract LeftoverRefundTest is Test {
266
+ JBRouterTerminal internal router;
267
+ IJBDirectory internal directory;
268
+ IJBPermissions internal permissions;
269
+ IJBProjects internal projects;
270
+ IJBTokens internal tokens;
271
+ IPermit2 internal permit2;
272
+ IUniswapV3Factory internal factory;
273
+ IWETH9 internal weth;
274
+
275
+ uint256 internal constant PROJECT_ID = 1;
276
+
277
+ function setUp() public {
278
+ directory = IJBDirectory(makeAddr("directory"));
279
+ permissions = IJBPermissions(makeAddr("permissions"));
280
+ projects = IJBProjects(makeAddr("projects"));
281
+ tokens = IJBTokens(makeAddr("tokens"));
282
+ permit2 = IPermit2(makeAddr("permit2"));
283
+ factory = IUniswapV3Factory(makeAddr("factory"));
284
+ weth = IWETH9(address(new AuditLeftoverMockWETH()));
285
+
286
+ vm.etch(address(directory), hex"00");
287
+ vm.etch(address(permissions), hex"00");
288
+ vm.etch(address(projects), hex"00");
289
+ vm.etch(address(tokens), hex"00");
290
+ vm.etch(address(permit2), hex"00");
291
+ vm.etch(address(factory), hex"00");
292
+
293
+ router = new JBRouterTerminal(
294
+ directory,
295
+ permissions,
296
+ projects,
297
+ tokens,
298
+ permit2,
299
+ makeAddr("owner"),
300
+ weth,
301
+ factory,
302
+ IPoolManager(address(0)),
303
+ address(0)
304
+ );
305
+ }
306
+
307
+ /// @notice Partial-fill leftovers from pay() are refunded to the beneficiary.
308
+ function test_payPartialFillRefundsBeneficiary() public {
309
+ AuditLeftoverMockERC20 tokenIn = new AuditLeftoverMockERC20();
310
+ AuditLeftoverMockERC20 tokenOut = new AuditLeftoverMockERC20();
311
+ AuditLeftoverDestTerminal destTerminal = new AuditLeftoverDestTerminal();
312
+ AuditLeftoverPartialFillPool pool = _deployPool(tokenIn, tokenOut, 600 ether, 100 ether);
313
+
314
+ _mockSimpleSwapRoute(tokenIn, tokenOut, destTerminal, pool);
315
+
316
+ address alice = makeAddr("alice");
317
+ address bob = makeAddr("bob");
318
+
319
+ tokenIn.mint(alice, 1000 ether);
320
+ vm.prank(alice);
321
+ tokenIn.approve(address(router), type(uint256).max);
322
+
323
+ vm.prank(alice);
324
+ router.pay(PROJECT_ID, address(tokenIn), 1000 ether, bob, 0, "", _metadata(tokenOut));
325
+
326
+ assertEq(destTerminal.lastAmount(), 100 ether, "destination only receives filled output");
327
+ assertEq(tokenIn.balanceOf(bob), 400 ether, "beneficiary receives leftover input");
328
+ assertEq(tokenIn.balanceOf(alice), 0, "payer receives nothing extra");
329
+ }
330
+
331
+ function test_addToBalanceRefundAlsoLeaksPreexistingRouterBalance() public {
332
+ AuditLeftoverMockERC20 tokenIn = new AuditLeftoverMockERC20();
333
+ AuditLeftoverMockERC20 tokenOut = new AuditLeftoverMockERC20();
334
+ AuditLeftoverDestTerminal destTerminal = new AuditLeftoverDestTerminal();
335
+ AuditLeftoverPartialFillPool pool = _deployPool(tokenIn, tokenOut, 600 ether, 100 ether);
336
+
337
+ _mockSimpleSwapRoute(tokenIn, tokenOut, destTerminal, pool);
338
+
339
+ address donor = makeAddr("donor");
340
+ address attacker = makeAddr("attacker");
341
+
342
+ tokenIn.mint(donor, 50 ether);
343
+ vm.prank(donor);
344
+ tokenIn.transfer(address(router), 50 ether);
345
+
346
+ tokenIn.mint(attacker, 1000 ether);
347
+ vm.prank(attacker);
348
+ tokenIn.approve(address(router), type(uint256).max);
349
+
350
+ vm.prank(attacker);
351
+ router.addToBalanceOf(PROJECT_ID, address(tokenIn), 1000 ether, false, "", _metadata(tokenOut));
352
+
353
+ assertEq(destTerminal.lastAmount(), 100 ether, "destination only receives filled output");
354
+ assertEq(
355
+ tokenIn.balanceOf(attacker), 450 ether, "attacker captures both leftover and preexisting router balance"
356
+ );
357
+ assertEq(tokenIn.balanceOf(address(router)), 0, "router balance is fully drained by the refund");
358
+ }
359
+
360
+ function _deployPool(
361
+ AuditLeftoverMockERC20 tokenIn,
362
+ AuditLeftoverMockERC20 tokenOut,
363
+ uint256 amountInUsed,
364
+ uint256 amountOutGiven
365
+ )
366
+ internal
367
+ returns (AuditLeftoverPartialFillPool pool)
368
+ {
369
+ (address token0, address token1) = address(tokenIn) < address(tokenOut)
370
+ ? (address(tokenIn), address(tokenOut))
371
+ : (address(tokenOut), address(tokenIn));
372
+ pool = new AuditLeftoverPartialFillPool(token0, token1, tokenOut, amountInUsed, amountOutGiven);
373
+ }
374
+
375
+ function _metadata(AuditLeftoverMockERC20 tokenOut) internal view returns (bytes memory metadata) {
376
+ metadata = JBMetadataResolver.addToMetadata(
377
+ "", JBMetadataResolver.getId("routeTokenOut", address(router)), abi.encode(address(tokenOut))
378
+ );
379
+ metadata = JBMetadataResolver.addToMetadata(
380
+ metadata, JBMetadataResolver.getId("quoteForSwap", address(router)), abi.encode(100 ether)
381
+ );
382
+ }
383
+
384
+ function _mockSimpleSwapRoute(
385
+ AuditLeftoverMockERC20 tokenIn,
386
+ AuditLeftoverMockERC20 tokenOut,
387
+ AuditLeftoverDestTerminal destTerminal,
388
+ AuditLeftoverPartialFillPool pool
389
+ )
390
+ internal
391
+ {
392
+ vm.mockCall(address(tokens), abi.encodeWithSelector(IJBTokens.projectIdOf.selector), abi.encode(uint256(0)));
393
+ vm.mockCall(
394
+ address(directory),
395
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (PROJECT_ID, address(tokenIn))),
396
+ abi.encode(address(0))
397
+ );
398
+ vm.mockCall(
399
+ address(directory),
400
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (PROJECT_ID, address(tokenOut))),
401
+ abi.encode(address(destTerminal))
402
+ );
403
+ vm.mockCall(
404
+ address(factory),
405
+ abi.encodeCall(IUniswapV3Factory.getPool, (address(tokenIn), address(tokenOut), uint24(3000))),
406
+ abi.encode(address(pool))
407
+ );
408
+ vm.mockCall(
409
+ address(factory),
410
+ abi.encodeCall(IUniswapV3Factory.getPool, (address(tokenIn), address(tokenOut), uint24(500))),
411
+ abi.encode(address(0))
412
+ );
413
+ vm.mockCall(
414
+ address(factory),
415
+ abi.encodeCall(IUniswapV3Factory.getPool, (address(tokenIn), address(tokenOut), uint24(10_000))),
416
+ abi.encode(address(0))
417
+ );
418
+ vm.mockCall(
419
+ address(factory),
420
+ abi.encodeCall(IUniswapV3Factory.getPool, (address(tokenIn), address(tokenOut), uint24(100))),
421
+ abi.encode(address(0))
422
+ );
423
+ }
424
+ }
@@ -28,9 +28,9 @@ contract PayerTrackerRefundHarness is JBRouterTerminal {
28
28
  )
29
29
  {}
30
30
 
31
- /// @notice Public wrapper so tests can call `_resolveRefundTo` directly.
31
+ /// @notice Public wrapper so tests can call `_resolveRefundWithBackupRecipient` directly.
32
32
  function resolveRefundTo(address payable fallback_) external view returns (address payable) {
33
- return _resolveRefundTo(fallback_);
33
+ return _resolveRefundWithBackupRecipient(fallback_);
34
34
  }
35
35
  }
36
36
 
@@ -0,0 +1,104 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+
6
+ import {JBRouterTerminal} from "../../src/JBRouterTerminal.sol";
7
+ import {IWETH9} from "../../src/interfaces/IWETH9.sol";
8
+ import {IJBPayerTracker} from "../../src/interfaces/IJBPayerTracker.sol";
9
+
10
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
11
+ import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
12
+ import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
13
+ import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
14
+
15
+ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
16
+ import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
17
+ import {IUniswapV3Factory} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
18
+ import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
19
+
20
+ contract RefundToBeneficiaryTest is Test {
21
+ uint256 internal constant LEFTOVER = 40 ether;
22
+
23
+ HarnessRouterTerminal internal router;
24
+ MockERC20 internal token;
25
+
26
+ address internal payer = makeAddr("payer");
27
+ address internal beneficiary = makeAddr("beneficiary");
28
+
29
+ function setUp() public {
30
+ token = new MockERC20("Input", "IN");
31
+ router = new HarnessRouterTerminal();
32
+
33
+ token.mint(address(router), LEFTOVER);
34
+ }
35
+
36
+ function test_directCallerRefundsToBeneficiary() public {
37
+ vm.prank(payer);
38
+ router.simulatePayLeftoverRefund(address(token), beneficiary, LEFTOVER);
39
+
40
+ assertEq(token.balanceOf(beneficiary), LEFTOVER, "beneficiary receives leftover");
41
+ assertEq(token.balanceOf(payer), 0, "payer receives nothing");
42
+ }
43
+
44
+ function test_registryStyleCallerRefundsToOriginalPayer() public {
45
+ MockPayerTracker intermediary = new MockPayerTracker(payer);
46
+
47
+ vm.prank(address(intermediary));
48
+ router.simulatePayLeftoverRefund(address(token), beneficiary, LEFTOVER);
49
+
50
+ assertEq(token.balanceOf(payer), LEFTOVER, "intermediary path refunds payer");
51
+ assertEq(token.balanceOf(beneficiary), 0, "beneficiary receives nothing");
52
+ }
53
+ }
54
+
55
+ contract HarnessRouterTerminal is JBRouterTerminal {
56
+ constructor()
57
+ JBRouterTerminal(
58
+ IJBDirectory(address(0)),
59
+ IJBPermissions(address(0)),
60
+ IJBProjects(address(0)),
61
+ IJBTokens(address(0)),
62
+ IPermit2(address(0)),
63
+ address(this),
64
+ IWETH9(address(new MockWETH())),
65
+ IUniswapV3Factory(address(0)),
66
+ IPoolManager(address(0)),
67
+ address(0)
68
+ )
69
+ {}
70
+
71
+ function simulatePayLeftoverRefund(address token, address beneficiary, uint256 leftover) external {
72
+ address payable refundTo = _resolveRefundWithBackupRecipient(payable(beneficiary));
73
+ _transferFrom({from: address(this), to: refundTo, token: token, amount: leftover});
74
+ }
75
+ }
76
+
77
+ contract MockPayerTracker is IJBPayerTracker {
78
+ address public override originalPayer;
79
+
80
+ constructor(address payer) {
81
+ originalPayer = payer;
82
+ }
83
+ }
84
+
85
+ contract MockERC20 is ERC20 {
86
+ constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
87
+
88
+ function mint(address account, uint256 amount) external {
89
+ _mint(account, amount);
90
+ }
91
+ }
92
+
93
+ contract MockWETH is MockERC20, IWETH9 {
94
+ constructor() MockERC20("Wrapped ETH", "WETH") {}
95
+
96
+ function deposit() external payable {
97
+ _mint(msg.sender, msg.value);
98
+ }
99
+
100
+ function withdraw(uint256 wad) external {
101
+ _burn(msg.sender, wad);
102
+ payable(msg.sender).transfer(wad);
103
+ }
104
+ }
@@ -285,7 +285,7 @@ contract AuditPartialFillPool is IUniswapV3Pool {
285
285
  }
286
286
  }
287
287
 
288
- contract CodexRegistryAddToBalancePartialFillTest is Test {
288
+ contract RegistryAddToBalancePartialFillTest is Test {
289
289
  IJBDirectory directory;
290
290
  IJBPermissions permissions;
291
291
  IJBProjects projects;