@bananapus/router-terminal-v6 0.0.31 → 0.0.33
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/RISKS.md +6 -1
- package/package.json +4 -4
- package/src/JBPayRouteResolver.sol +21 -28
- package/src/JBRouterTerminal.sol +18 -28
- package/src/libraries/JBForwardingCheck.sol +44 -0
- package/test/RouterTerminal.t.sol +4 -3
- package/test/RouterTerminalCreditCashout.t.sol +4 -2
- package/test/audit/CodexNemesisPayHookReceiptDoS.t.sol +169 -0
- package/test/audit/DeployBuybackHookZero.t.sol +3 -1
- package/test/audit/HookDataEncoding.t.sol +154 -0
- package/test/audit/MultiHopCashOutCycle.t.sol +350 -0
- package/test/audit/Pass12M39.t.sol +202 -0
- package/test/audit/Pass13RouterFixes.t.sol +141 -0
- package/test/audit/RegistryForwardingLossyToken.t.sol +238 -0
- package/test/{codex → audit}/RouterRegistryReceiptMismatch.t.sol +8 -8
- package/test/fork/RouterTerminalFOTFork.t.sol +360 -0
- package/test/regression/CashOutLoopLimit.t.sol +85 -15
- /package/test/{codex → audit}/CashOutCircularPrimaryTerminal.t.sol +0 -0
- /package/test/{codex → audit}/CashOutFallbackPrefersRecursiveLoop.t.sol +0 -0
- /package/test/{codex → audit}/RegistrySelfLockDoS.t.sol +0 -0
- /package/test/{codex → audit}/V4HookedPoolIgnored.t.sol +0 -0
- /package/test/{codex → audit}/V4WethInputUsesStuckEth.t.sol +0 -0
package/RISKS.md
CHANGED
|
@@ -17,7 +17,7 @@ Pool discovery ranks candidates by instantaneous liquidity, so an attacker could
|
|
|
17
17
|
When a user provides `quoteForSwap` metadata, the quote may not match the auto-selected output token. Frontends should set `quoteForSwap` per the expected output token.
|
|
18
18
|
|
|
19
19
|
**Multi-hop cashout slippage cleared after first hop.** *(Minor)*
|
|
20
|
-
Only the final output matters; the outer function enforces end-to-end minimum via `minReclaimed`. Intermediate per-hop slippage checks are intentionally omitted.
|
|
20
|
+
Only the final output matters; the outer function enforces end-to-end minimum via `minReclaimed`. Intermediate per-hop slippage checks are intentionally omitted. Maximum 20 recursive cashout iterations allowed (`_MAX_CASHOUT_ITERATIONS`); beyond that the operation reverts.
|
|
21
21
|
|
|
22
22
|
**Zero oracle quote disables swap protection.** *(Minor)*
|
|
23
23
|
When the oracle returns zero (no liquidity), slippage tolerance becomes zero. The swap would fail anyway due to lack of liquidity, so this has no practical impact.
|
|
@@ -35,6 +35,11 @@ When payments flow through the registry, credits accrue to the registry address,
|
|
|
35
35
|
**Forwarder claim disables receipt check.** *(Minor)*
|
|
36
36
|
Forwarding terminals registered by project owners are trusted to handle receipts correctly, so receipt validation is skipped for these callers.
|
|
37
37
|
|
|
38
|
+
## Token Compatibility Risks
|
|
39
|
+
|
|
40
|
+
**Fee-on-transfer (FOT) tokens not supported for routed payments.** *(Medium)*
|
|
41
|
+
The `pay()` flow does not enforce an ERC-20 receipt check (balance-delta validation) on the destination terminal. This was intentionally removed because pay hooks attached to the destination terminal can legitimately consume tokens during `pay()`, making a balance-delta check produce false reverts for any project with active pay hooks. As a consequence, fee-on-transfer tokens will silently lose value during routing — the terminal receives fewer tokens than `amount` but the router cannot detect this. Projects using FOT tokens should route payments directly to the terminal, bypassing the router. The `addToBalanceOf()` flow retains receipt enforcement since it has no hooks.
|
|
42
|
+
|
|
38
43
|
## Minor Configuration Risks
|
|
39
44
|
|
|
40
45
|
**Unbounded quadratic candidate enumeration.** *(Minor)*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bananapus/router-terminal-v6",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.33",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -17,9 +17,9 @@
|
|
|
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/buyback-hook-v6": "^0.0.
|
|
21
|
-
"@bananapus/core-v6": "^0.0.
|
|
22
|
-
"@bananapus/permission-ids-v6": "^0.0.
|
|
20
|
+
"@bananapus/buyback-hook-v6": "^0.0.30",
|
|
21
|
+
"@bananapus/core-v6": "^0.0.36",
|
|
22
|
+
"@bananapus/permission-ids-v6": "^0.0.19",
|
|
23
23
|
"@openzeppelin/contracts": "^5.6.1",
|
|
24
24
|
"@uniswap/permit2": "github:Uniswap/permit2",
|
|
25
25
|
"@uniswap/v3-core": "github:Uniswap/v3-core#0.8",
|
|
@@ -10,7 +10,7 @@ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingCo
|
|
|
10
10
|
import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
|
|
11
11
|
import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {JBForwardingCheck} from "./libraries/JBForwardingCheck.sol";
|
|
14
14
|
import {IJBPayRoutePreviewer} from "./interfaces/IJBPayRoutePreviewer.sol";
|
|
15
15
|
import {IJBPayRouteResolver} from "./interfaces/IJBPayRouteResolver.sol";
|
|
16
16
|
import {IWETH9} from "./interfaces/IWETH9.sol";
|
|
@@ -274,9 +274,23 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
|
|
|
274
274
|
}
|
|
275
275
|
|
|
276
276
|
// Decode only the minimum token-count commitments needed to score the buyback-enhanced preview.
|
|
277
|
-
(
|
|
277
|
+
(,,,,,,,,,, uint256 minimumBeneficiaryTokenCount, uint256 minimumReservedTokenCount,) = abi.decode(
|
|
278
278
|
specification.metadata,
|
|
279
|
-
(
|
|
279
|
+
(
|
|
280
|
+
bool,
|
|
281
|
+
uint256,
|
|
282
|
+
uint256,
|
|
283
|
+
bool,
|
|
284
|
+
address,
|
|
285
|
+
uint256,
|
|
286
|
+
uint256,
|
|
287
|
+
int24,
|
|
288
|
+
uint128,
|
|
289
|
+
bytes32,
|
|
290
|
+
uint256,
|
|
291
|
+
uint256,
|
|
292
|
+
uint256
|
|
293
|
+
)
|
|
280
294
|
);
|
|
281
295
|
|
|
282
296
|
// Keep whichever decoded hook commitment implies the stronger user-visible preview outcome.
|
|
@@ -326,7 +340,9 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
|
|
|
326
340
|
return _normalizedTokenOf(tokenA) == _normalizedTokenOf(tokenB);
|
|
327
341
|
}
|
|
328
342
|
|
|
329
|
-
/// @notice Whether previewing through a terminal would
|
|
343
|
+
/// @notice Whether previewing through a terminal would cycle back into the router.
|
|
344
|
+
/// @dev Delegates to `JBForwardingCheck.isCircularTerminal` — shared with `JBRouterTerminal` so that
|
|
345
|
+
/// preview and execution use identical cycle-detection logic.
|
|
330
346
|
/// @param router The router whose preview path is being evaluated.
|
|
331
347
|
/// @param projectId The project whose forwarding terminal would be resolved.
|
|
332
348
|
/// @param terminal The terminal that would receive the previewed route.
|
|
@@ -340,30 +356,7 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
|
|
|
340
356
|
view
|
|
341
357
|
returns (bool isCircular)
|
|
342
358
|
{
|
|
343
|
-
|
|
344
|
-
// A bounded loop prevents infinite gas consumption from longer chains while catching realistic cycles.
|
|
345
|
-
IJBTerminal current = terminal;
|
|
346
|
-
for (uint256 i; i < 5; i++) {
|
|
347
|
-
// Treat routes back to the router as circular.
|
|
348
|
-
if (address(current) == address(router)) return true;
|
|
349
|
-
|
|
350
|
-
// Probe via staticcall so plain terminals degrade cleanly.
|
|
351
|
-
// slither-disable-next-line calls-loop
|
|
352
|
-
(bool success, bytes memory data) =
|
|
353
|
-
address(current).staticcall(abi.encodeCall(IJBForwardingTerminal.terminalOf, (projectId)));
|
|
354
|
-
|
|
355
|
-
// Non-forwarding terminals (call fails or returns zero) end the chain — not circular.
|
|
356
|
-
if (!success || data.length < 32) return false;
|
|
357
|
-
IJBTerminal forwardingTarget = abi.decode(data, (IJBTerminal));
|
|
358
|
-
if (address(forwardingTarget) == address(0)) return false;
|
|
359
|
-
|
|
360
|
-
// Follow the forwarding chain one more hop.
|
|
361
|
-
current = forwardingTarget;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// If we followed 5 hops without finding a non-forwarding terminal or the router,
|
|
365
|
-
// treat this as a suspicious deep chain and mark it as circular to be safe.
|
|
366
|
-
return true;
|
|
359
|
+
return JBForwardingCheck.isCircularTerminal({target: address(router), projectId: projectId, terminal: terminal});
|
|
367
360
|
}
|
|
368
361
|
|
|
369
362
|
/// @notice Normalize a token into the form the router uses for routing comparisons.
|
package/src/JBRouterTerminal.sol
CHANGED
|
@@ -44,6 +44,7 @@ import {IJBPayRoutePreviewer} from "./interfaces/IJBPayRoutePreviewer.sol";
|
|
|
44
44
|
import {IJBPayRouteResolver} from "./interfaces/IJBPayRouteResolver.sol";
|
|
45
45
|
import {IJBRouterTerminal} from "./interfaces/IJBRouterTerminal.sol";
|
|
46
46
|
import {IWETH9} from "./interfaces/IWETH9.sol";
|
|
47
|
+
import {JBForwardingCheck} from "./libraries/JBForwardingCheck.sol";
|
|
47
48
|
import {JBSwapLib} from "./libraries/JBSwapLib.sol";
|
|
48
49
|
import {JBPayRouteResolver} from "./JBPayRouteResolver.sol";
|
|
49
50
|
import {CashOutPathCandidates} from "./structs/CashOutPathCandidates.sol";
|
|
@@ -356,11 +357,6 @@ contract JBRouterTerminal is
|
|
|
356
357
|
// native.
|
|
357
358
|
uint256 payValue = _beforeTransferFor({to: address(destTerminal), token: token, amount: amount});
|
|
358
359
|
|
|
359
|
-
// Snapshot the destination terminal's ERC20 balance and forwarding status for receipt enforcement.
|
|
360
|
-
// Combines both checks into one call to avoid duplicate _isForwardingTerminal probes.
|
|
361
|
-
(uint256 terminalReceiptBaseline, bool isForwarding) =
|
|
362
|
-
_terminalReceiptBaselineOf({terminal: destTerminal, token: token, projectId: projectId});
|
|
363
|
-
|
|
364
360
|
// Execute the final payment on the destination terminal and bubble back the beneficiary token count it
|
|
365
361
|
// returned.
|
|
366
362
|
beneficiaryTokenCount = destTerminal.pay{value: payValue}({
|
|
@@ -375,14 +371,11 @@ contract JBRouterTerminal is
|
|
|
375
371
|
|
|
376
372
|
_afterTransferFor({destTerminal: destTerminal, token: token});
|
|
377
373
|
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
receiptBaseline: terminalReceiptBaseline,
|
|
384
|
-
isForwarding: isForwarding
|
|
385
|
-
});
|
|
374
|
+
// NOTE: No ERC-20 receipt enforcement here (unlike addToBalanceOf).
|
|
375
|
+
// Pay hooks attached to the destination terminal may legitimately consume tokens during
|
|
376
|
+
// pay(), making a balance-delta check produce false reverts. Fee-on-transfer (FOT) tokens
|
|
377
|
+
// are therefore NOT supported for routed payments — the terminal will receive fewer tokens
|
|
378
|
+
// than `amount` but the router cannot detect or prevent this. See RISKS.md for details.
|
|
386
379
|
}
|
|
387
380
|
|
|
388
381
|
/// @notice The Uniswap v3 pool callback where the token transfer is expected to happen.
|
|
@@ -436,7 +429,7 @@ contract JBRouterTerminal is
|
|
|
436
429
|
params: SwapParams({
|
|
437
430
|
zeroForOne: zeroForOne, amountSpecified: amountSpecified, sqrtPriceLimitX96: sqrtPriceLimitX96
|
|
438
431
|
}),
|
|
439
|
-
hookData: ""
|
|
432
|
+
hookData: address(key.hooks) != address(0) ? abi.encode(minAmountOut) : bytes("")
|
|
440
433
|
});
|
|
441
434
|
|
|
442
435
|
// Determine input/output amounts from the delta.
|
|
@@ -994,13 +987,16 @@ contract JBRouterTerminal is
|
|
|
994
987
|
terminal = DIRECTORY.primaryTerminalOf({projectId: projectId, token: token});
|
|
995
988
|
|
|
996
989
|
// Drop terminals that would route straight back into the router (circular).
|
|
997
|
-
if (
|
|
990
|
+
if (
|
|
991
|
+
address(terminal) == address(0)
|
|
992
|
+
|| JBForwardingCheck.isCircularTerminal({
|
|
993
|
+
target: address(this), projectId: projectId, terminal: terminal
|
|
994
|
+
})
|
|
995
|
+
) {
|
|
998
996
|
return IJBTerminal(address(0));
|
|
999
997
|
}
|
|
1000
998
|
|
|
1001
999
|
// Check if the terminal is a forwarding layer that routes back into this router.
|
|
1002
|
-
// Uses the same low-level staticcall pattern as _isForwardingTerminal — non-forwarding terminals degrade
|
|
1003
|
-
// cleanly into a no-op (success=false or empty data).
|
|
1004
1000
|
// slither-disable-next-line calls-loop
|
|
1005
1001
|
(bool ok, bytes memory data) =
|
|
1006
1002
|
address(terminal).staticcall(abi.encodeCall(IJBForwardingTerminal.terminalOf, (projectId)));
|
|
@@ -1031,13 +1027,6 @@ contract JBRouterTerminal is
|
|
|
1031
1027
|
}
|
|
1032
1028
|
}
|
|
1033
1029
|
|
|
1034
|
-
/// @notice Whether routing through a terminal would cycle back into the router.
|
|
1035
|
-
/// @param terminal The terminal that would receive the route.
|
|
1036
|
-
/// @return isCircular A flag indicating whether `terminal` is this router itself.
|
|
1037
|
-
function _isCircularTerminal(IJBTerminal terminal) internal view returns (bool isCircular) {
|
|
1038
|
-
return address(terminal) == address(this);
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
1030
|
/// @notice Accepts a token being paid in.
|
|
1042
1031
|
/// @param token The address of the token being paid in.
|
|
1043
1032
|
/// @param amount The amount of tokens being paid in.
|
|
@@ -1213,15 +1202,16 @@ contract JBRouterTerminal is
|
|
|
1213
1202
|
// Cash out the source project's tokens.
|
|
1214
1203
|
// Don't rely on the terminal return value here. Buyback-hook sell-side execution returns 0 reclaimAmount
|
|
1215
1204
|
// from nana-core, then transfers the real proceeds during the hook callback.
|
|
1216
|
-
// Pass
|
|
1217
|
-
//
|
|
1205
|
+
// Pass minTokensReclaimed=0 to the terminal because the buyback hook's sell-side delivers tokens via
|
|
1206
|
+
// callback (reclaimAmount=0 from the terminal's perspective), which would fail the terminal's own min
|
|
1207
|
+
// check. The router enforces the user's minimum via the balance-delta check below instead.
|
|
1218
1208
|
// slither-disable-next-line unused-return,calls-loop
|
|
1219
1209
|
cashOutTerminal.cashOutTokensOf({
|
|
1220
1210
|
holder: address(this),
|
|
1221
1211
|
projectId: sourceProjectId,
|
|
1222
1212
|
cashOutCount: amount,
|
|
1223
1213
|
tokenToReclaim: tokenToReclaim,
|
|
1224
|
-
minTokensReclaimed:
|
|
1214
|
+
minTokensReclaimed: 0,
|
|
1225
1215
|
beneficiary: payable(address(this)),
|
|
1226
1216
|
metadata: ""
|
|
1227
1217
|
});
|
|
@@ -2331,7 +2321,7 @@ contract JBRouterTerminal is
|
|
|
2331
2321
|
if (address(key.hooks) != address(0)) {
|
|
2332
2322
|
// Build the two-element lookback array: [_TWAP_WINDOW seconds ago, now].
|
|
2333
2323
|
uint32[] memory secondsAgos = new uint32[](2);
|
|
2334
|
-
secondsAgos[0] = _TWAP_WINDOW; // Start of the window (
|
|
2324
|
+
secondsAgos[0] = _TWAP_WINDOW; // Start of the window (120 seconds ago).
|
|
2335
2325
|
secondsAgos[1] = 0; // End of the window (current block).
|
|
2336
2326
|
|
|
2337
2327
|
// Ask the hook for cumulative tick data over the window. Silently catch if it doesn't support it.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.0;
|
|
3
|
+
|
|
4
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
5
|
+
import {IJBForwardingTerminal} from "../interfaces/IJBForwardingTerminal.sol";
|
|
6
|
+
|
|
7
|
+
/// @notice Shared circular-terminal detection used by both `JBRouterTerminal` (execution) and
|
|
8
|
+
/// `JBPayRouteResolver` (preview).
|
|
9
|
+
library JBForwardingCheck {
|
|
10
|
+
/// @notice Whether routing through `terminal` would cycle back to `target` within 5 hops.
|
|
11
|
+
/// @param target The address to detect cycles against (typically the router).
|
|
12
|
+
/// @param projectId The project whose forwarding chain is being followed.
|
|
13
|
+
/// @param terminal The starting terminal to check.
|
|
14
|
+
/// @return isCircular True if the terminal forwards (directly or transitively) back to `target`.
|
|
15
|
+
function isCircularTerminal(
|
|
16
|
+
address target,
|
|
17
|
+
uint256 projectId,
|
|
18
|
+
IJBTerminal terminal
|
|
19
|
+
)
|
|
20
|
+
internal
|
|
21
|
+
view
|
|
22
|
+
returns (bool isCircular)
|
|
23
|
+
{
|
|
24
|
+
IJBTerminal current = terminal;
|
|
25
|
+
for (uint256 i; i < 5; i++) {
|
|
26
|
+
if (address(current) == target) return true;
|
|
27
|
+
|
|
28
|
+
// Probe via staticcall so plain terminals degrade cleanly.
|
|
29
|
+
// slither-disable-next-line calls-loop
|
|
30
|
+
(bool success, bytes memory data) =
|
|
31
|
+
address(current).staticcall(abi.encodeCall(IJBForwardingTerminal.terminalOf, (projectId)));
|
|
32
|
+
|
|
33
|
+
// Non-forwarding terminals (call fails or returns zero) end the chain — not circular.
|
|
34
|
+
if (!success || data.length < 32) return false;
|
|
35
|
+
IJBTerminal forwardingTarget = abi.decode(data, (IJBTerminal));
|
|
36
|
+
if (address(forwardingTarget) == address(0)) return false;
|
|
37
|
+
|
|
38
|
+
current = forwardingTarget;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 5 hops without resolution — treat as circular to be safe.
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -2271,6 +2271,7 @@ contract RouterTerminalTest is Test {
|
|
|
2271
2271
|
false,
|
|
2272
2272
|
address(0),
|
|
2273
2273
|
uint256(0),
|
|
2274
|
+
uint256(0),
|
|
2274
2275
|
int24(0),
|
|
2275
2276
|
uint128(0),
|
|
2276
2277
|
PoolId.wrap(bytes32(0)),
|
|
@@ -2499,8 +2500,8 @@ contract RouterTerminalTest is Test {
|
|
|
2499
2500
|
);
|
|
2500
2501
|
}
|
|
2501
2502
|
|
|
2502
|
-
//
|
|
2503
|
-
//
|
|
2503
|
+
// The router now passes minTokensReclaimed=0 to the terminal and enforces the user's
|
|
2504
|
+
// minimum via the balance-delta check instead (to support buyback-hook sell-side flows).
|
|
2504
2505
|
vm.expectCall(
|
|
2505
2506
|
address(mockCashOutTerminal),
|
|
2506
2507
|
abi.encodeCall(
|
|
@@ -2510,7 +2511,7 @@ contract RouterTerminalTest is Test {
|
|
|
2510
2511
|
2, // sourceProjectId
|
|
2511
2512
|
100e18, // amount
|
|
2512
2513
|
JBConstants.NATIVE_TOKEN,
|
|
2513
|
-
|
|
2514
|
+
0, // router passes 0 and enforces via balance-delta
|
|
2514
2515
|
payable(address(routerTerminal)),
|
|
2515
2516
|
bytes("")
|
|
2516
2517
|
)
|
|
@@ -557,10 +557,12 @@ contract CreditCashoutSpoofingIntermediary is IJBPayerTracker {
|
|
|
557
557
|
uint256 result = routerTerminal.pay(destProjectId, JBConstants.NATIVE_TOKEN, 0, payer, 0, "", metadata);
|
|
558
558
|
|
|
559
559
|
assertEq(result, 200, "pay should return dest terminal token count");
|
|
560
|
+
// The router now passes minTokensReclaimed=0 to the terminal and enforces the user's
|
|
561
|
+
// minimum via the balance-delta check instead (to support buyback-hook sell-side flows).
|
|
560
562
|
assertEq(
|
|
561
563
|
cashOutTerminal.lastMinTokensReclaimed(),
|
|
562
|
-
|
|
563
|
-
"router should
|
|
564
|
+
0,
|
|
565
|
+
"router should pass 0 to the terminal and enforce via balance-delta"
|
|
564
566
|
);
|
|
565
567
|
assertEq(destTerminal.lastAmount(), 60e18, "dest terminal should receive the reclaimed amount");
|
|
566
568
|
assertEq(destTerminal.lastValue(), 60e18, "dest terminal should receive ETH value");
|
|
@@ -0,0 +1,169 @@
|
|
|
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 {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
8
|
+
import {IJBToken} from "@bananapus/core-v6/src/interfaces/IJBToken.sol";
|
|
9
|
+
import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
|
|
10
|
+
import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
|
|
11
|
+
import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
|
|
12
|
+
import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
|
|
13
|
+
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
|
14
|
+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
15
|
+
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.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
|
+
import {JBRouterTerminal} from "../../src/JBRouterTerminal.sol";
|
|
21
|
+
import {IWETH9} from "../../src/interfaces/IWETH9.sol";
|
|
22
|
+
|
|
23
|
+
contract CodexNemesisERC20 is ERC20 {
|
|
24
|
+
constructor() ERC20("Codex Nemesis Token", "CNT") {}
|
|
25
|
+
|
|
26
|
+
function mint(address account, uint256 amount) external {
|
|
27
|
+
_mint(account, amount);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
contract HookForwardingTerminal is IJBTerminal {
|
|
32
|
+
IERC20 public immutable TOKEN;
|
|
33
|
+
address public immutable HOOK;
|
|
34
|
+
uint256 public immutable HOOK_AMOUNT;
|
|
35
|
+
|
|
36
|
+
constructor(IERC20 token_, address hook_, uint256 hookAmount_) {
|
|
37
|
+
TOKEN = token_;
|
|
38
|
+
HOOK = hook_;
|
|
39
|
+
HOOK_AMOUNT = hookAmount_;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function accountingContextForTokenOf(uint256, address token_) external pure returns (JBAccountingContext memory) {
|
|
43
|
+
return JBAccountingContext({token: token_, decimals: 18, currency: 0});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function accountingContextsOf(uint256) external view returns (JBAccountingContext[] memory contexts) {
|
|
47
|
+
contexts = new JBAccountingContext[](1);
|
|
48
|
+
contexts[0] = JBAccountingContext({token: address(TOKEN), decimals: 18, currency: 0});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function currentSurplusOf(uint256, address[] calldata, uint256, uint256) external pure returns (uint256) {
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function previewPayFor(
|
|
56
|
+
uint256,
|
|
57
|
+
address,
|
|
58
|
+
uint256,
|
|
59
|
+
address,
|
|
60
|
+
bytes calldata
|
|
61
|
+
)
|
|
62
|
+
external
|
|
63
|
+
view
|
|
64
|
+
returns (
|
|
65
|
+
JBRuleset memory ruleset,
|
|
66
|
+
uint256 beneficiaryTokenCount,
|
|
67
|
+
uint256 terminalTokenAmount,
|
|
68
|
+
JBPayHookSpecification[] memory specs
|
|
69
|
+
)
|
|
70
|
+
{
|
|
71
|
+
specs = new JBPayHookSpecification[](1);
|
|
72
|
+
specs[0].amount = HOOK_AMOUNT;
|
|
73
|
+
beneficiaryTokenCount = 1;
|
|
74
|
+
terminalTokenAmount = HOOK_AMOUNT;
|
|
75
|
+
ruleset.id = 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function addAccountingContextsFor(uint256, JBAccountingContext[] calldata) external {}
|
|
79
|
+
|
|
80
|
+
function addToBalanceOf(uint256, address, uint256, bool, string calldata, bytes calldata) external payable {}
|
|
81
|
+
|
|
82
|
+
function migrateBalanceOf(uint256, address, IJBTerminal) external pure returns (uint256) {
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function pay(
|
|
87
|
+
uint256,
|
|
88
|
+
address,
|
|
89
|
+
uint256 amount,
|
|
90
|
+
address,
|
|
91
|
+
uint256,
|
|
92
|
+
string calldata,
|
|
93
|
+
bytes calldata
|
|
94
|
+
)
|
|
95
|
+
external
|
|
96
|
+
payable
|
|
97
|
+
returns (uint256)
|
|
98
|
+
{
|
|
99
|
+
require(TOKEN.transferFrom(msg.sender, address(this), amount));
|
|
100
|
+
require(TOKEN.transfer(HOOK, HOOK_AMOUNT));
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
|
|
105
|
+
return interfaceId == type(IJBTerminal).interfaceId || interfaceId == type(IERC165).interfaceId;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
contract CodexNemesisPayHookReceiptDoSTest is Test {
|
|
110
|
+
/// @notice After M-39 fix: pay() no longer enforces _enforceStandardTerminalReceipt, so a terminal
|
|
111
|
+
/// that forwards tokens to a pay hook should succeed rather than revert.
|
|
112
|
+
function test_routerAllowsErc20TerminalThatForwardsToPayHook() external {
|
|
113
|
+
uint256 projectId = 1;
|
|
114
|
+
address payer = address(0xA11CE);
|
|
115
|
+
address beneficiary = address(0xB0B);
|
|
116
|
+
address hook = address(0xCAFE);
|
|
117
|
+
uint256 amount = 100 ether;
|
|
118
|
+
uint256 hookAmount = 40 ether;
|
|
119
|
+
|
|
120
|
+
CodexNemesisERC20 token = new CodexNemesisERC20();
|
|
121
|
+
HookForwardingTerminal terminal = new HookForwardingTerminal(token, hook, hookAmount);
|
|
122
|
+
|
|
123
|
+
address directory = address(0xD1);
|
|
124
|
+
address tokens = address(0x70);
|
|
125
|
+
vm.etch(directory, hex"00");
|
|
126
|
+
vm.etch(tokens, hex"00");
|
|
127
|
+
|
|
128
|
+
IJBTerminal[] memory terminals = new IJBTerminal[](1);
|
|
129
|
+
terminals[0] = IJBTerminal(address(terminal));
|
|
130
|
+
|
|
131
|
+
vm.mockCall(directory, abi.encodeCall(IJBDirectory.terminalsOf, (projectId)), abi.encode(terminals));
|
|
132
|
+
vm.mockCall(
|
|
133
|
+
directory,
|
|
134
|
+
abi.encodeCall(IJBDirectory.primaryTerminalOf, (projectId, address(token))),
|
|
135
|
+
abi.encode(address(terminal))
|
|
136
|
+
);
|
|
137
|
+
vm.mockCall(tokens, abi.encodeCall(IJBTokens.projectIdOf, (IJBToken(address(token)))), abi.encode(uint256(0)));
|
|
138
|
+
|
|
139
|
+
JBRouterTerminal router = new JBRouterTerminal({
|
|
140
|
+
directory: IJBDirectory(directory),
|
|
141
|
+
tokens: IJBTokens(tokens),
|
|
142
|
+
permit2: IPermit2(address(0x22)),
|
|
143
|
+
weth: IWETH9(address(0x33)),
|
|
144
|
+
factory: IUniswapV3Factory(address(0x44)),
|
|
145
|
+
poolManager: IPoolManager(address(0)),
|
|
146
|
+
buybackHook: address(0),
|
|
147
|
+
univ4Hook: address(0),
|
|
148
|
+
trustedForwarder: address(0)
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
token.mint(payer, amount);
|
|
152
|
+
|
|
153
|
+
vm.startPrank(payer);
|
|
154
|
+
token.approve(address(router), amount);
|
|
155
|
+
// M-39 fix: pay() no longer reverts when hooks consume tokens — should succeed.
|
|
156
|
+
uint256 beneficiaryTokenCount = router.pay({
|
|
157
|
+
projectId: projectId,
|
|
158
|
+
token: address(token),
|
|
159
|
+
amount: amount,
|
|
160
|
+
beneficiary: beneficiary,
|
|
161
|
+
minReturnedTokens: 0,
|
|
162
|
+
memo: "",
|
|
163
|
+
metadata: ""
|
|
164
|
+
});
|
|
165
|
+
vm.stopPrank();
|
|
166
|
+
|
|
167
|
+
assertEq(beneficiaryTokenCount, 1, "beneficiary should receive 1 token from mock terminal");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -286,7 +286,8 @@ contract DeployBuybackHookZeroTest is Test {
|
|
|
286
286
|
reclaimToken = new AuditMockERC20();
|
|
287
287
|
nativeTerminal = new AuditMockPreviewDestTerminal(JBConstants.NATIVE_TOKEN, 100);
|
|
288
288
|
tokenBTerminal = new AuditMockPreviewDestTerminal(address(reclaimToken), 150);
|
|
289
|
-
|
|
289
|
+
vm.deal(address(this), 80);
|
|
290
|
+
nativeCashOut = new AuditMockCashOutTerminal{value: 80}(jbToken, JBConstants.NATIVE_TOKEN, 40, 40);
|
|
290
291
|
tokenBCashOut = new AuditMockCashOutTerminal(jbToken, address(reclaimToken), 50, 50);
|
|
291
292
|
|
|
292
293
|
reclaimToken.mint(address(tokenBCashOut), 50);
|
|
@@ -393,6 +394,7 @@ contract DeployBuybackHookZeroTest is Test {
|
|
|
393
394
|
false,
|
|
394
395
|
address(0),
|
|
395
396
|
uint256(0),
|
|
397
|
+
uint256(0),
|
|
396
398
|
int24(0),
|
|
397
399
|
uint128(0),
|
|
398
400
|
PoolId.wrap(bytes32(0)),
|
|
@@ -0,0 +1,154 @@
|
|
|
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 {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
|
|
8
|
+
import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
|
|
9
|
+
import {IUniswapV3Factory} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
|
|
10
|
+
import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol";
|
|
11
|
+
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
|
|
12
|
+
import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
|
|
13
|
+
import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
|
|
14
|
+
import {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
|
|
15
|
+
import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol";
|
|
16
|
+
|
|
17
|
+
import {JBRouterTerminal} from "../../src/JBRouterTerminal.sol";
|
|
18
|
+
import {IWETH9} from "../../src/interfaces/IWETH9.sol";
|
|
19
|
+
|
|
20
|
+
/// @notice Mock PoolManager that captures the hookData argument from swap() calls.
|
|
21
|
+
/// It returns a valid BalanceDelta so the unlock callback can run to completion.
|
|
22
|
+
contract CapturingPoolManager {
|
|
23
|
+
bytes public capturedHookData;
|
|
24
|
+
bool public swapCalled;
|
|
25
|
+
|
|
26
|
+
/// @notice Captures hookData and returns a delta representing a swap of 1000 in, 900 out (zeroForOne).
|
|
27
|
+
function swap(PoolKey memory, SwapParams memory, bytes calldata hookData) external returns (BalanceDelta) {
|
|
28
|
+
capturedHookData = hookData;
|
|
29
|
+
swapCalled = true;
|
|
30
|
+
// Return delta: amount0 = -1000 (input consumed), amount1 = +900 (output received)
|
|
31
|
+
// zeroForOne: input is currency0 (negative delta), output is currency1 (positive delta)
|
|
32
|
+
return toBalanceDelta(-1000, 900);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// @notice No-op settle for ERC-20 path.
|
|
36
|
+
function settle() external payable returns (uint256) {
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// @notice No-op sync for ERC-20 path.
|
|
41
|
+
function sync(Currency) external {}
|
|
42
|
+
|
|
43
|
+
/// @notice No-op take for output side.
|
|
44
|
+
function take(Currency, address, uint256) external {}
|
|
45
|
+
|
|
46
|
+
/// @notice Fallback so vm.etch and other calls don't revert.
|
|
47
|
+
fallback() external payable {}
|
|
48
|
+
receive() external payable {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// @notice When the V4 pool key has hooks != address(0), the hookData passed to PoolManager.swap()
|
|
52
|
+
/// must contain abi.encode(minAmountOut), not empty bytes. Otherwise hooks like JBUniswapV4Hook will revert.
|
|
53
|
+
contract HookDataEncodingTest is Test {
|
|
54
|
+
JBRouterTerminal internal router;
|
|
55
|
+
CapturingPoolManager internal poolManager;
|
|
56
|
+
|
|
57
|
+
// Use distinct non-zero addresses for ERC-20 tokens (avoid native-ETH paths for simplicity).
|
|
58
|
+
address internal tokenA = address(0xAAAA);
|
|
59
|
+
address internal tokenB = address(0xBBBB);
|
|
60
|
+
// Hook address — any non-zero address.
|
|
61
|
+
address internal hook = address(0xCC01);
|
|
62
|
+
|
|
63
|
+
function setUp() public {
|
|
64
|
+
poolManager = new CapturingPoolManager();
|
|
65
|
+
|
|
66
|
+
// Deploy the router with the capturing pool manager.
|
|
67
|
+
router = new JBRouterTerminal({
|
|
68
|
+
directory: IJBDirectory(address(1)),
|
|
69
|
+
tokens: IJBTokens(address(2)),
|
|
70
|
+
permit2: IPermit2(address(3)),
|
|
71
|
+
weth: IWETH9(address(4)),
|
|
72
|
+
factory: IUniswapV3Factory(address(5)),
|
|
73
|
+
poolManager: IPoolManager(address(poolManager)),
|
|
74
|
+
buybackHook: address(0),
|
|
75
|
+
univ4Hook: hook,
|
|
76
|
+
trustedForwarder: address(0)
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Mock the IERC20.transfer call that _settleV4 makes (safeTransfer to pool manager).
|
|
80
|
+
vm.mockCall(
|
|
81
|
+
tokenA,
|
|
82
|
+
abi.encodeWithSignature("transfer(address,uint256)", address(poolManager), uint256(1000)),
|
|
83
|
+
abi.encode(true)
|
|
84
|
+
);
|
|
85
|
+
// Mock the IERC20.transfer for output side _takeV4 (not actually called since take is no-op, but be safe).
|
|
86
|
+
vm.mockCall(
|
|
87
|
+
tokenB,
|
|
88
|
+
abi.encodeWithSignature("transfer(address,uint256)", address(router), uint256(900)),
|
|
89
|
+
abi.encode(true)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// @notice When a hooked V4 pool is used, hookData must contain abi.encode(minAmountOut).
|
|
94
|
+
function test_hookData_containsMinAmountOut_whenHooksConfigured() public {
|
|
95
|
+
// Build a pool key with hooks.
|
|
96
|
+
PoolKey memory key = PoolKey({
|
|
97
|
+
currency0: Currency.wrap(tokenA),
|
|
98
|
+
currency1: Currency.wrap(tokenB),
|
|
99
|
+
fee: 3000,
|
|
100
|
+
tickSpacing: 60,
|
|
101
|
+
hooks: IHooks(hook)
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
uint256 minAmountOut = 800;
|
|
105
|
+
bool zeroForOne = true;
|
|
106
|
+
int256 amountSpecified = -1000; // exact input of 1000
|
|
107
|
+
uint160 sqrtPriceLimitX96 = 4_295_128_740; // TickMath.MIN_SQRT_RATIO + 1
|
|
108
|
+
bool canUseExistingNativeBalance = false;
|
|
109
|
+
|
|
110
|
+
// Encode the callback data exactly as _executeV4Swap does.
|
|
111
|
+
bytes memory callbackData =
|
|
112
|
+
abi.encode(key, zeroForOne, amountSpecified, sqrtPriceLimitX96, minAmountOut, canUseExistingNativeBalance);
|
|
113
|
+
|
|
114
|
+
// Call unlockCallback as the PoolManager (required by the msg.sender check).
|
|
115
|
+
vm.prank(address(poolManager));
|
|
116
|
+
router.unlockCallback(callbackData);
|
|
117
|
+
|
|
118
|
+
// Verify swap was called.
|
|
119
|
+
assertTrue(poolManager.swapCalled(), "swap should have been called");
|
|
120
|
+
|
|
121
|
+
// The key assertion: hookData should contain abi.encode(minAmountOut), not be empty.
|
|
122
|
+
bytes memory expectedHookData = abi.encode(minAmountOut);
|
|
123
|
+
assertEq(poolManager.capturedHookData(), expectedHookData, "hookData must encode minAmountOut for hooked pools");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// @notice When a pool has no hooks (address(0)), hookData should remain empty.
|
|
127
|
+
function test_hookData_isEmpty_whenNoHooks() public {
|
|
128
|
+
// Build a pool key WITHOUT hooks.
|
|
129
|
+
PoolKey memory key = PoolKey({
|
|
130
|
+
currency0: Currency.wrap(tokenA),
|
|
131
|
+
currency1: Currency.wrap(tokenB),
|
|
132
|
+
fee: 3000,
|
|
133
|
+
tickSpacing: 60,
|
|
134
|
+
hooks: IHooks(address(0))
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
uint256 minAmountOut = 800;
|
|
138
|
+
bool zeroForOne = true;
|
|
139
|
+
int256 amountSpecified = -1000;
|
|
140
|
+
uint160 sqrtPriceLimitX96 = 4_295_128_740;
|
|
141
|
+
bool canUseExistingNativeBalance = false;
|
|
142
|
+
|
|
143
|
+
bytes memory callbackData =
|
|
144
|
+
abi.encode(key, zeroForOne, amountSpecified, sqrtPriceLimitX96, minAmountOut, canUseExistingNativeBalance);
|
|
145
|
+
|
|
146
|
+
vm.prank(address(poolManager));
|
|
147
|
+
router.unlockCallback(callbackData);
|
|
148
|
+
|
|
149
|
+
assertTrue(poolManager.swapCalled(), "swap should have been called");
|
|
150
|
+
|
|
151
|
+
// With no hooks, hookData should be empty.
|
|
152
|
+
assertEq(poolManager.capturedHookData(), "", "hookData must be empty when no hooks configured");
|
|
153
|
+
}
|
|
154
|
+
}
|