@bananapus/router-terminal-v6 0.0.1

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.
@@ -0,0 +1,865 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.26;
3
+
4
+ import "forge-std/Test.sol";
5
+
6
+ import {IJBCashOutTerminal} from "@bananapus/core-v6/src/interfaces/IJBCashOutTerminal.sol";
7
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
8
+ import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
9
+ import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
10
+ import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
11
+ import {IJBToken} from "@bananapus/core-v6/src/interfaces/IJBToken.sol";
12
+ import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
13
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
14
+ import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol";
15
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
16
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
17
+ import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
18
+ import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
19
+ import {IUniswapV3Factory} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
20
+ import {IUniswapV3Pool} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
21
+ import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
22
+ import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol";
23
+ import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
24
+ import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol";
25
+ import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
26
+ import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol";
27
+
28
+ import {JBRouterTerminal} from "../src/JBRouterTerminal.sol";
29
+ import {IJBRouterTerminal} from "../src/interfaces/IJBRouterTerminal.sol";
30
+ import {PoolInfo} from "../src/structs/PoolInfo.sol";
31
+ import {IWETH9} from "../src/interfaces/IWETH9.sol";
32
+
33
+ /// @notice A harness that exposes internal functions for testing.
34
+ contract RouterTerminalHarness is JBRouterTerminal {
35
+ constructor(
36
+ IJBDirectory directory,
37
+ IJBPermissions permissions,
38
+ IJBProjects projects,
39
+ IJBTokens tokens,
40
+ IPermit2 permit2,
41
+ address owner,
42
+ IWETH9 weth,
43
+ IUniswapV3Factory factory,
44
+ IPoolManager poolManager,
45
+ address trustedForwarder
46
+ )
47
+ JBRouterTerminal(
48
+ directory, permissions, projects, tokens, permit2, owner, weth, factory, poolManager, trustedForwarder
49
+ )
50
+ {}
51
+
52
+ function exposed_resolveTokenOut(
53
+ uint256 projectId,
54
+ address tokenIn,
55
+ bytes calldata metadata
56
+ )
57
+ external
58
+ view
59
+ returns (address tokenOut, IJBTerminal destTerminal)
60
+ {
61
+ return _resolveTokenOut(projectId, tokenIn, metadata);
62
+ }
63
+
64
+ function exposed_discoverPool(
65
+ address normalizedTokenIn,
66
+ address normalizedTokenOut
67
+ )
68
+ external
69
+ view
70
+ returns (PoolInfo memory)
71
+ {
72
+ return _discoverPool(normalizedTokenIn, normalizedTokenOut);
73
+ }
74
+ }
75
+
76
+ contract RouterTerminalTest is Test {
77
+ using PoolIdLibrary for PoolKey;
78
+
79
+ RouterTerminalHarness routerTerminal;
80
+
81
+ // Mocked dependencies
82
+ IJBDirectory mockDirectory;
83
+ IJBPermissions mockPermissions;
84
+ IJBProjects mockProjects;
85
+ IJBTokens mockTokens;
86
+ IPermit2 mockPermit2;
87
+ IWETH9 mockWeth;
88
+ IUniswapV3Factory mockFactory;
89
+ IPoolManager mockPoolManager;
90
+
91
+ address terminalOwner;
92
+
93
+ function setUp() public {
94
+ mockDirectory = IJBDirectory(makeAddr("mockDirectory"));
95
+ vm.etch(address(mockDirectory), hex"00");
96
+ mockPermissions = IJBPermissions(makeAddr("mockPermissions"));
97
+ vm.etch(address(mockPermissions), hex"00");
98
+ mockProjects = IJBProjects(makeAddr("mockProjects"));
99
+ vm.etch(address(mockProjects), hex"00");
100
+ mockTokens = IJBTokens(makeAddr("mockTokens"));
101
+ vm.etch(address(mockTokens), hex"00");
102
+ mockPermit2 = IPermit2(makeAddr("mockPermit2"));
103
+ vm.etch(address(mockPermit2), hex"00");
104
+ mockWeth = IWETH9(makeAddr("mockWeth"));
105
+ vm.etch(address(mockWeth), hex"00");
106
+ mockFactory = IUniswapV3Factory(makeAddr("mockFactory"));
107
+ vm.etch(address(mockFactory), hex"00");
108
+ mockPoolManager = IPoolManager(makeAddr("mockPoolManager"));
109
+ vm.etch(address(mockPoolManager), hex"00");
110
+
111
+ terminalOwner = makeAddr("terminalOwner");
112
+
113
+ routerTerminal = new RouterTerminalHarness(
114
+ mockDirectory,
115
+ mockPermissions,
116
+ mockProjects,
117
+ mockTokens,
118
+ mockPermit2,
119
+ terminalOwner,
120
+ mockWeth,
121
+ mockFactory,
122
+ mockPoolManager,
123
+ address(0)
124
+ );
125
+ }
126
+
127
+ //*********************************************************************//
128
+ // -------------------- accounting context tests -------------------- //
129
+ //*********************************************************************//
130
+
131
+ function test_accountingContext_dynamic() public {
132
+ address token = makeAddr("someToken");
133
+ JBAccountingContext memory ctx = routerTerminal.accountingContextForTokenOf(1, token);
134
+ assertEq(ctx.token, token);
135
+ assertEq(ctx.decimals, 18);
136
+ assertEq(ctx.currency, uint32(uint160(token)));
137
+ }
138
+
139
+ function test_accountingContexts_empty() public {
140
+ JBAccountingContext[] memory ctxs = routerTerminal.accountingContextsOf(1);
141
+ assertEq(ctxs.length, 0);
142
+ }
143
+
144
+ function test_currentSurplus_zero() public {
145
+ assertEq(routerTerminal.currentSurplusOf(1, new JBAccountingContext[](0), 18, 1), 0);
146
+ }
147
+
148
+ //*********************************************************************//
149
+ // -------------------- resolve token out tests --------------------- //
150
+ //*********************************************************************//
151
+
152
+ function test_resolveTokenOut_directAcceptance() public {
153
+ uint256 projectId = 1;
154
+ address tokenIn = makeAddr("tokenIn");
155
+ address mockTerminal = makeAddr("destTerminal");
156
+ vm.etch(mockTerminal, hex"00");
157
+
158
+ // Project accepts tokenIn directly.
159
+ vm.mockCall(
160
+ address(mockDirectory),
161
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (projectId, tokenIn)),
162
+ abi.encode(mockTerminal)
163
+ );
164
+
165
+ (address tokenOut, IJBTerminal destTerminal) =
166
+ routerTerminal.exposed_resolveTokenOut(projectId, tokenIn, "");
167
+
168
+ assertEq(tokenOut, tokenIn);
169
+ assertEq(address(destTerminal), mockTerminal);
170
+ }
171
+
172
+ function test_resolveTokenOut_metadataOverride() public {
173
+ uint256 projectId = 1;
174
+ address tokenIn = makeAddr("tokenIn");
175
+ address desiredTokenOut = makeAddr("desiredOut");
176
+ address mockTerminal = makeAddr("destTerminal");
177
+ vm.etch(mockTerminal, hex"00");
178
+
179
+ // Build metadata with routeTokenOut.
180
+ bytes4 metadataId = JBMetadataResolver.getId("routeTokenOut", address(routerTerminal));
181
+ bytes memory metadata = JBMetadataResolver.addToMetadata("", metadataId, abi.encode(desiredTokenOut));
182
+
183
+ // Mock: project accepts the desired token.
184
+ vm.mockCall(
185
+ address(mockDirectory),
186
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (projectId, desiredTokenOut)),
187
+ abi.encode(mockTerminal)
188
+ );
189
+
190
+ (address tokenOut, IJBTerminal destTerminal) =
191
+ routerTerminal.exposed_resolveTokenOut(projectId, tokenIn, metadata);
192
+
193
+ assertEq(tokenOut, desiredTokenOut);
194
+ assertEq(address(destTerminal), mockTerminal);
195
+ }
196
+
197
+ function test_resolveTokenOut_discoversAcceptedToken() public {
198
+ uint256 projectId = 1;
199
+ address tokenIn = makeAddr("tokenIn");
200
+ address acceptedToken = makeAddr("acceptedToken");
201
+ address mockTerminal = makeAddr("destTerminal");
202
+ address mockPool = makeAddr("mockPool");
203
+ vm.etch(mockTerminal, hex"00");
204
+ vm.etch(mockPool, hex"00");
205
+
206
+ // Project doesn't accept tokenIn directly.
207
+ vm.mockCall(
208
+ address(mockDirectory),
209
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (projectId, tokenIn)),
210
+ abi.encode(address(0))
211
+ );
212
+
213
+ // Set up terminals with accounting contexts.
214
+ IJBTerminal[] memory terminals = new IJBTerminal[](1);
215
+ terminals[0] = IJBTerminal(mockTerminal);
216
+ vm.mockCall(
217
+ address(mockDirectory), abi.encodeCall(IJBDirectory.terminalsOf, (projectId)), abi.encode(terminals)
218
+ );
219
+
220
+ JBAccountingContext[] memory contexts = new JBAccountingContext[](1);
221
+ contexts[0] =
222
+ JBAccountingContext({token: acceptedToken, decimals: 18, currency: uint32(uint160(acceptedToken))});
223
+ vm.mockCall(
224
+ mockTerminal, abi.encodeCall(IJBTerminal.accountingContextsOf, (projectId)), abi.encode(contexts)
225
+ );
226
+
227
+ // Mock V3 pool discovery: pool exists at 0.3% fee tier with liquidity.
228
+ vm.mockCall(
229
+ address(mockFactory),
230
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenIn, acceptedToken, 3000)),
231
+ abi.encode(mockPool)
232
+ );
233
+ vm.mockCall(mockPool, abi.encodeWithSignature("liquidity()"), abi.encode(uint128(1000e18)));
234
+
235
+ // Mock no V3 pools at other fee tiers.
236
+ vm.mockCall(
237
+ address(mockFactory),
238
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenIn, acceptedToken, 500)),
239
+ abi.encode(address(0))
240
+ );
241
+ vm.mockCall(
242
+ address(mockFactory),
243
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenIn, acceptedToken, 10_000)),
244
+ abi.encode(address(0))
245
+ );
246
+ vm.mockCall(
247
+ address(mockFactory),
248
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenIn, acceptedToken, 100)),
249
+ abi.encode(address(0))
250
+ );
251
+
252
+ // Mock V4 — no pools found (extsload returns 0 for all).
253
+ _mockV4NoPools(tokenIn, acceptedToken);
254
+
255
+ (address tokenOut, IJBTerminal destTerminal) =
256
+ routerTerminal.exposed_resolveTokenOut(projectId, tokenIn, "");
257
+
258
+ assertEq(tokenOut, acceptedToken);
259
+ assertEq(address(destTerminal), mockTerminal);
260
+ }
261
+
262
+ function test_resolveTokenOut_revertsNoRoute() public {
263
+ uint256 projectId = 1;
264
+ address tokenIn = makeAddr("tokenIn");
265
+
266
+ // Project doesn't accept tokenIn.
267
+ vm.mockCall(
268
+ address(mockDirectory),
269
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (projectId, tokenIn)),
270
+ abi.encode(address(0))
271
+ );
272
+
273
+ // No terminals.
274
+ IJBTerminal[] memory terminals = new IJBTerminal[](0);
275
+ vm.mockCall(
276
+ address(mockDirectory), abi.encodeCall(IJBDirectory.terminalsOf, (projectId)), abi.encode(terminals)
277
+ );
278
+
279
+ vm.expectRevert(
280
+ abi.encodeWithSelector(IJBRouterTerminal.JBRouterTerminal_NoRouteFound.selector, projectId, tokenIn)
281
+ );
282
+ routerTerminal.exposed_resolveTokenOut(projectId, tokenIn, "");
283
+ }
284
+
285
+ //*********************************************************************//
286
+ // ----------------------- pay direct forward ----------------------- //
287
+ //*********************************************************************//
288
+
289
+ function test_pay_directForward() public {
290
+ uint256 projectId = 1;
291
+ address tokenIn = makeAddr("tokenIn");
292
+ uint256 amount = 1000;
293
+ address beneficiary = makeAddr("beneficiary");
294
+ address payer = makeAddr("payer");
295
+ address mockTerminal = makeAddr("destTerminal");
296
+ vm.etch(mockTerminal, hex"00");
297
+ vm.etch(tokenIn, hex"00");
298
+
299
+ // Not a JB token.
300
+ vm.mockCall(
301
+ address(mockTokens),
302
+ abi.encodeWithSelector(IJBTokens.projectIdOf.selector),
303
+ abi.encode(uint256(0))
304
+ );
305
+
306
+ // Project accepts tokenIn directly.
307
+ vm.mockCall(
308
+ address(mockDirectory),
309
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (projectId, tokenIn)),
310
+ abi.encode(mockTerminal)
311
+ );
312
+
313
+ // Mock token transfer from payer.
314
+ vm.mockCall(
315
+ tokenIn, abi.encodeCall(IERC20.allowance, (payer, address(routerTerminal))), abi.encode(amount)
316
+ );
317
+ vm.mockCall(
318
+ tokenIn, abi.encodeCall(IERC20.transferFrom, (payer, address(routerTerminal), amount)), abi.encode(true)
319
+ );
320
+
321
+ // Mock safeIncreaseAllowance: allowance check + approve.
322
+ vm.mockCall(
323
+ tokenIn,
324
+ abi.encodeCall(IERC20.allowance, (address(routerTerminal), mockTerminal)),
325
+ abi.encode(uint256(0))
326
+ );
327
+ vm.mockCall(tokenIn, abi.encodeCall(IERC20.approve, (mockTerminal, amount)), abi.encode(true));
328
+
329
+ // Mock dest terminal pay.
330
+ vm.mockCall(mockTerminal, abi.encodeWithSelector(IJBTerminal.pay.selector), abi.encode(uint256(100)));
331
+
332
+ vm.prank(payer);
333
+ uint256 result = routerTerminal.pay(projectId, tokenIn, amount, beneficiary, 0, "", "");
334
+ assertEq(result, 100);
335
+ }
336
+
337
+ //*********************************************************************//
338
+ // -------------------- pay with native tokens ---------------------- //
339
+ //*********************************************************************//
340
+
341
+ function test_pay_nativeTokenDirectForward() public {
342
+ uint256 projectId = 1;
343
+ uint256 amount = 1 ether;
344
+ address beneficiary = makeAddr("beneficiary");
345
+ address payer = makeAddr("payer");
346
+ address mockTerminal = makeAddr("destTerminal");
347
+ vm.etch(mockTerminal, hex"00");
348
+
349
+ // Project accepts NATIVE_TOKEN directly.
350
+ vm.mockCall(
351
+ address(mockDirectory),
352
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (projectId, JBConstants.NATIVE_TOKEN)),
353
+ abi.encode(mockTerminal)
354
+ );
355
+
356
+ // Mock dest terminal pay (should receive msg.value).
357
+ vm.mockCall(mockTerminal, abi.encodeWithSelector(IJBTerminal.pay.selector), abi.encode(uint256(50)));
358
+
359
+ vm.deal(payer, amount);
360
+ vm.prank(payer);
361
+ uint256 result =
362
+ routerTerminal.pay{value: amount}(projectId, JBConstants.NATIVE_TOKEN, amount, beneficiary, 0, "", "");
363
+ assertEq(result, 50);
364
+ }
365
+
366
+ //*********************************************************************//
367
+ // ----------------------- callback tests --------------------------- //
368
+ //*********************************************************************//
369
+
370
+ function test_callback_factoryVerified() public {
371
+ address tokenIn = makeAddr("tokenIn");
372
+ address tokenOut = makeAddr("tokenOut");
373
+ address realPool = makeAddr("realPool");
374
+ vm.etch(realPool, hex"00");
375
+ vm.etch(tokenIn, hex"00");
376
+
377
+ // The pool reports fee 3000.
378
+ vm.mockCall(realPool, abi.encodeWithSignature("fee()"), abi.encode(uint24(3000)));
379
+
380
+ // Factory confirms this pool.
381
+ vm.mockCall(
382
+ address(mockFactory),
383
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenIn, tokenOut, 3000)),
384
+ abi.encode(realPool)
385
+ );
386
+
387
+ // Mock token transfer.
388
+ vm.mockCall(tokenIn, abi.encodeCall(IERC20.transfer, (realPool, 100)), abi.encode(true));
389
+
390
+ bytes memory data = abi.encode(uint256(1), tokenIn, tokenOut);
391
+
392
+ // Call from the real pool — should succeed.
393
+ vm.prank(realPool);
394
+ routerTerminal.uniswapV3SwapCallback(int256(-200), int256(100), data);
395
+ }
396
+
397
+ function test_callback_rejectsUnverified() public {
398
+ address tokenIn = makeAddr("tokenIn");
399
+ address tokenOut = makeAddr("tokenOut");
400
+ address fakePool = makeAddr("fakePool");
401
+ address realPool = makeAddr("realPool");
402
+ vm.etch(fakePool, hex"00");
403
+
404
+ // Fake pool reports fee 3000.
405
+ vm.mockCall(fakePool, abi.encodeWithSignature("fee()"), abi.encode(uint24(3000)));
406
+
407
+ // Factory returns a different pool address.
408
+ vm.mockCall(
409
+ address(mockFactory),
410
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenIn, tokenOut, 3000)),
411
+ abi.encode(realPool)
412
+ );
413
+
414
+ bytes memory data = abi.encode(uint256(1), tokenIn, tokenOut);
415
+
416
+ vm.prank(fakePool);
417
+ vm.expectRevert(
418
+ abi.encodeWithSelector(IJBRouterTerminal.JBRouterTerminal_CallerNotPool.selector, fakePool)
419
+ );
420
+ routerTerminal.uniswapV3SwapCallback(int256(-200), int256(100), data);
421
+ }
422
+
423
+ //*********************************************************************//
424
+ // -------------------- addToBalanceOf tests ------------------------ //
425
+ //*********************************************************************//
426
+
427
+ function test_addToBalanceOf_directForward() public {
428
+ uint256 projectId = 1;
429
+ address tokenIn = makeAddr("tokenIn");
430
+ uint256 amount = 500;
431
+ address payer = makeAddr("payer");
432
+ address mockTerminal = makeAddr("destTerminal");
433
+ vm.etch(mockTerminal, hex"00");
434
+ vm.etch(tokenIn, hex"00");
435
+
436
+ // Not a JB token.
437
+ vm.mockCall(
438
+ address(mockTokens),
439
+ abi.encodeWithSelector(IJBTokens.projectIdOf.selector),
440
+ abi.encode(uint256(0))
441
+ );
442
+
443
+ // Project accepts tokenIn.
444
+ vm.mockCall(
445
+ address(mockDirectory),
446
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (projectId, tokenIn)),
447
+ abi.encode(mockTerminal)
448
+ );
449
+
450
+ // Mock token transfer.
451
+ vm.mockCall(
452
+ tokenIn, abi.encodeCall(IERC20.allowance, (payer, address(routerTerminal))), abi.encode(amount)
453
+ );
454
+ vm.mockCall(
455
+ tokenIn, abi.encodeCall(IERC20.transferFrom, (payer, address(routerTerminal), amount)), abi.encode(true)
456
+ );
457
+
458
+ // Mock safeIncreaseAllowance.
459
+ vm.mockCall(
460
+ tokenIn,
461
+ abi.encodeCall(IERC20.allowance, (address(routerTerminal), mockTerminal)),
462
+ abi.encode(uint256(0))
463
+ );
464
+ vm.mockCall(tokenIn, abi.encodeCall(IERC20.approve, (mockTerminal, amount)), abi.encode(true));
465
+
466
+ // Mock dest terminal addToBalanceOf.
467
+ vm.mockCall(mockTerminal, abi.encodeWithSelector(IJBTerminal.addToBalanceOf.selector), abi.encode());
468
+
469
+ vm.prank(payer);
470
+ routerTerminal.addToBalanceOf(projectId, tokenIn, amount, false, "", "");
471
+ }
472
+
473
+ //*********************************************************************//
474
+ // -------------------- discover pool tests ------------------------- //
475
+ //*********************************************************************//
476
+
477
+ function test_discoverPool_findsBestLiquidity() public {
478
+ address tokenA = makeAddr("tokenA");
479
+ address tokenB = makeAddr("tokenB");
480
+ address pool3000 = makeAddr("pool3000");
481
+ address pool500 = makeAddr("pool500");
482
+ vm.etch(pool3000, hex"00");
483
+ vm.etch(pool500, hex"00");
484
+
485
+ // Pool at 0.3% has lower liquidity.
486
+ vm.mockCall(
487
+ address(mockFactory),
488
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenA, tokenB, 3000)),
489
+ abi.encode(pool3000)
490
+ );
491
+ vm.mockCall(pool3000, abi.encodeWithSignature("liquidity()"), abi.encode(uint128(100e18)));
492
+
493
+ // Pool at 0.05% has higher liquidity.
494
+ vm.mockCall(
495
+ address(mockFactory),
496
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenA, tokenB, 500)),
497
+ abi.encode(pool500)
498
+ );
499
+ vm.mockCall(pool500, abi.encodeWithSignature("liquidity()"), abi.encode(uint128(500e18)));
500
+
501
+ // No pools at other tiers.
502
+ vm.mockCall(
503
+ address(mockFactory),
504
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenA, tokenB, 10_000)),
505
+ abi.encode(address(0))
506
+ );
507
+ vm.mockCall(
508
+ address(mockFactory),
509
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenA, tokenB, 100)),
510
+ abi.encode(address(0))
511
+ );
512
+
513
+ // Mock V4 — no pools.
514
+ _mockV4NoPools(tokenA, tokenB);
515
+
516
+ PoolInfo memory result = routerTerminal.exposed_discoverPool(tokenA, tokenB);
517
+ assertFalse(result.isV4);
518
+ assertEq(address(result.v3Pool), pool500);
519
+ }
520
+
521
+ function test_discoverPool_revertsNoPool() public {
522
+ address tokenA = makeAddr("tokenA");
523
+ address tokenB = makeAddr("tokenB");
524
+
525
+ // No V3 pools at any tier.
526
+ vm.mockCall(
527
+ address(mockFactory), abi.encodeWithSelector(IUniswapV3Factory.getPool.selector), abi.encode(address(0))
528
+ );
529
+
530
+ // No V4 pools.
531
+ _mockV4NoPools(tokenA, tokenB);
532
+
533
+ vm.expectRevert(
534
+ abi.encodeWithSelector(IJBRouterTerminal.JBRouterTerminal_NoPoolFound.selector, tokenA, tokenB)
535
+ );
536
+ routerTerminal.exposed_discoverPool(tokenA, tokenB);
537
+ }
538
+
539
+ //*********************************************************************//
540
+ // -------------------- supports interface tests -------------------- //
541
+ //*********************************************************************//
542
+
543
+ function test_supportsInterface() public {
544
+ assertTrue(routerTerminal.supportsInterface(type(IJBTerminal).interfaceId));
545
+ assertTrue(routerTerminal.supportsInterface(type(IERC165).interfaceId));
546
+ }
547
+
548
+ //*********************************************************************//
549
+ // ----------------------- no-op tests ------------------------------ //
550
+ //*********************************************************************//
551
+
552
+ function test_migrateBalanceOf_returnsZero() public {
553
+ assertEq(
554
+ routerTerminal.migrateBalanceOf(1, makeAddr("token"), IJBTerminal(makeAddr("terminal"))),
555
+ 0
556
+ );
557
+ }
558
+
559
+ function test_addAccountingContextsFor_noOp() public {
560
+ // Should not revert.
561
+ routerTerminal.addAccountingContextsFor(1, new JBAccountingContext[](0));
562
+ }
563
+
564
+ //*********************************************************************//
565
+ // ----------------------- V4 pool discovery tests ------------------ //
566
+ //*********************************************************************//
567
+
568
+ function test_discoverPool_v4WinsOverV3() public {
569
+ address tokenA = makeAddr("tokenA");
570
+ address tokenB = makeAddr("tokenB");
571
+ address v3Pool = makeAddr("v3Pool");
572
+ vm.etch(v3Pool, hex"00");
573
+
574
+ // V3 pool with moderate liquidity.
575
+ vm.mockCall(
576
+ address(mockFactory),
577
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenA, tokenB, 3000)),
578
+ abi.encode(v3Pool)
579
+ );
580
+ vm.mockCall(v3Pool, abi.encodeWithSignature("liquidity()"), abi.encode(uint128(100e18)));
581
+
582
+ // No other V3 pools.
583
+ vm.mockCall(
584
+ address(mockFactory),
585
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenA, tokenB, 500)),
586
+ abi.encode(address(0))
587
+ );
588
+ vm.mockCall(
589
+ address(mockFactory),
590
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenA, tokenB, 10_000)),
591
+ abi.encode(address(0))
592
+ );
593
+ vm.mockCall(
594
+ address(mockFactory),
595
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenA, tokenB, 100)),
596
+ abi.encode(address(0))
597
+ );
598
+
599
+ // V4 pool with higher liquidity at 0.3%/60 tick spacing.
600
+ // Sort currencies.
601
+ (address sorted0, address sorted1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
602
+ PoolKey memory v4Key = PoolKey({
603
+ currency0: Currency.wrap(sorted0),
604
+ currency1: Currency.wrap(sorted1),
605
+ fee: 3000,
606
+ tickSpacing: int24(60),
607
+ hooks: IHooks(address(0))
608
+ });
609
+ PoolId v4Id = v4Key.toId();
610
+
611
+ // Mock getSlot0 via extsload — pool exists (sqrtPriceX96 != 0).
612
+ _mockV4PoolExists(v4Id, uint160(79228162514264337593543950336), 500e18);
613
+
614
+ // Mock other V4 fee tiers as non-existent.
615
+ _mockV4PoolNotExists(sorted0, sorted1, 500, int24(10));
616
+ _mockV4PoolNotExists(sorted0, sorted1, 10_000, int24(200));
617
+ _mockV4PoolNotExists(sorted0, sorted1, 100, int24(1));
618
+
619
+ PoolInfo memory result = routerTerminal.exposed_discoverPool(tokenA, tokenB);
620
+ assertTrue(result.isV4);
621
+ assertEq(Currency.unwrap(result.v4Key.currency0), sorted0);
622
+ assertEq(Currency.unwrap(result.v4Key.currency1), sorted1);
623
+ assertEq(result.v4Key.fee, 3000);
624
+ }
625
+
626
+ function test_discoverPool_v3WinsOverV4() public {
627
+ address tokenA = makeAddr("tokenA");
628
+ address tokenB = makeAddr("tokenB");
629
+ address v3Pool = makeAddr("v3Pool");
630
+ vm.etch(v3Pool, hex"00");
631
+
632
+ // V3 pool with high liquidity.
633
+ vm.mockCall(
634
+ address(mockFactory),
635
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenA, tokenB, 3000)),
636
+ abi.encode(v3Pool)
637
+ );
638
+ vm.mockCall(v3Pool, abi.encodeWithSignature("liquidity()"), abi.encode(uint128(1000e18)));
639
+
640
+ // No other V3 pools.
641
+ vm.mockCall(
642
+ address(mockFactory),
643
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenA, tokenB, 500)),
644
+ abi.encode(address(0))
645
+ );
646
+ vm.mockCall(
647
+ address(mockFactory),
648
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenA, tokenB, 10_000)),
649
+ abi.encode(address(0))
650
+ );
651
+ vm.mockCall(
652
+ address(mockFactory),
653
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenA, tokenB, 100)),
654
+ abi.encode(address(0))
655
+ );
656
+
657
+ // V4 pool with lower liquidity.
658
+ (address sorted0, address sorted1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
659
+ PoolKey memory v4Key = PoolKey({
660
+ currency0: Currency.wrap(sorted0),
661
+ currency1: Currency.wrap(sorted1),
662
+ fee: 3000,
663
+ tickSpacing: int24(60),
664
+ hooks: IHooks(address(0))
665
+ });
666
+ PoolId v4Id = v4Key.toId();
667
+
668
+ _mockV4PoolExists(v4Id, uint160(79228162514264337593543950336), 50e18);
669
+
670
+ _mockV4PoolNotExists(sorted0, sorted1, 500, int24(10));
671
+ _mockV4PoolNotExists(sorted0, sorted1, 10_000, int24(200));
672
+ _mockV4PoolNotExists(sorted0, sorted1, 100, int24(1));
673
+
674
+ PoolInfo memory result = routerTerminal.exposed_discoverPool(tokenA, tokenB);
675
+ assertFalse(result.isV4);
676
+ assertEq(address(result.v3Pool), v3Pool);
677
+ }
678
+
679
+ function test_discoverPool_v4OnlyNoV3() public {
680
+ address tokenA = makeAddr("tokenA");
681
+ address tokenB = makeAddr("tokenB");
682
+
683
+ // No V3 pools.
684
+ vm.mockCall(
685
+ address(mockFactory), abi.encodeWithSelector(IUniswapV3Factory.getPool.selector), abi.encode(address(0))
686
+ );
687
+
688
+ // V4 pool exists.
689
+ (address sorted0, address sorted1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
690
+ PoolKey memory v4Key = PoolKey({
691
+ currency0: Currency.wrap(sorted0),
692
+ currency1: Currency.wrap(sorted1),
693
+ fee: 500,
694
+ tickSpacing: int24(10),
695
+ hooks: IHooks(address(0))
696
+ });
697
+ PoolId v4Id = v4Key.toId();
698
+
699
+ // First fee tier (3000/60) doesn't exist.
700
+ _mockV4PoolNotExists(sorted0, sorted1, 3000, int24(60));
701
+ // Second fee tier (500/10) exists.
702
+ _mockV4PoolExists(v4Id, uint160(79228162514264337593543950336), 200e18);
703
+ // Other tiers don't exist.
704
+ _mockV4PoolNotExists(sorted0, sorted1, 10_000, int24(200));
705
+ _mockV4PoolNotExists(sorted0, sorted1, 100, int24(1));
706
+
707
+ PoolInfo memory result = routerTerminal.exposed_discoverPool(tokenA, tokenB);
708
+ assertTrue(result.isV4);
709
+ assertEq(result.v4Key.fee, 500);
710
+ assertEq(result.v4Key.tickSpacing, int24(10));
711
+ }
712
+
713
+ function test_discoverPool_noPoolManager() public {
714
+ // Deploy a router with address(0) as PoolManager.
715
+ RouterTerminalHarness noV4Router = new RouterTerminalHarness(
716
+ mockDirectory,
717
+ mockPermissions,
718
+ mockProjects,
719
+ mockTokens,
720
+ mockPermit2,
721
+ terminalOwner,
722
+ mockWeth,
723
+ mockFactory,
724
+ IPoolManager(address(0)),
725
+ address(0)
726
+ );
727
+
728
+ address tokenA = makeAddr("tokenA");
729
+ address tokenB = makeAddr("tokenB");
730
+ address v3Pool = makeAddr("v3Pool");
731
+ vm.etch(v3Pool, hex"00");
732
+
733
+ // V3 pool exists.
734
+ vm.mockCall(
735
+ address(mockFactory),
736
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenA, tokenB, 3000)),
737
+ abi.encode(v3Pool)
738
+ );
739
+ vm.mockCall(v3Pool, abi.encodeWithSignature("liquidity()"), abi.encode(uint128(100e18)));
740
+
741
+ vm.mockCall(
742
+ address(mockFactory),
743
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenA, tokenB, 500)),
744
+ abi.encode(address(0))
745
+ );
746
+ vm.mockCall(
747
+ address(mockFactory),
748
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenA, tokenB, 10_000)),
749
+ abi.encode(address(0))
750
+ );
751
+ vm.mockCall(
752
+ address(mockFactory),
753
+ abi.encodeCall(IUniswapV3Factory.getPool, (tokenA, tokenB, 100)),
754
+ abi.encode(address(0))
755
+ );
756
+
757
+ // V4 is skipped (POOL_MANAGER = address(0)), should find V3 pool.
758
+ PoolInfo memory result = noV4Router.exposed_discoverPool(tokenA, tokenB);
759
+ assertFalse(result.isV4);
760
+ assertEq(address(result.v3Pool), v3Pool);
761
+ }
762
+
763
+ //*********************************************************************//
764
+ // -------------------- V4 unlock callback test --------------------- //
765
+ //*********************************************************************//
766
+
767
+ function test_unlockCallback_rejectsNonPoolManager() public {
768
+ address notPoolManager = makeAddr("notPoolManager");
769
+
770
+ vm.prank(notPoolManager);
771
+ vm.expectRevert(
772
+ abi.encodeWithSelector(IJBRouterTerminal.JBRouterTerminal_CallerNotPoolManager.selector, notPoolManager)
773
+ );
774
+ routerTerminal.unlockCallback("");
775
+ }
776
+
777
+ //*********************************************************************//
778
+ // -------------------- V4 spot quote test -------------------------- //
779
+ //*********************************************************************//
780
+
781
+ function test_discoverBestPool_returnsV4() public {
782
+ address tokenA = makeAddr("tokenA");
783
+ address tokenB = makeAddr("tokenB");
784
+
785
+ // No V3 pools.
786
+ vm.mockCall(
787
+ address(mockFactory), abi.encodeWithSelector(IUniswapV3Factory.getPool.selector), abi.encode(address(0))
788
+ );
789
+
790
+ // V4 pool exists at 3000/60.
791
+ (address sorted0, address sorted1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
792
+ PoolKey memory v4Key = PoolKey({
793
+ currency0: Currency.wrap(sorted0),
794
+ currency1: Currency.wrap(sorted1),
795
+ fee: 3000,
796
+ tickSpacing: int24(60),
797
+ hooks: IHooks(address(0))
798
+ });
799
+ PoolId v4Id = v4Key.toId();
800
+ _mockV4PoolExists(v4Id, uint160(79228162514264337593543950336), 300e18);
801
+
802
+ _mockV4PoolNotExists(sorted0, sorted1, 500, int24(10));
803
+ _mockV4PoolNotExists(sorted0, sorted1, 10_000, int24(200));
804
+ _mockV4PoolNotExists(sorted0, sorted1, 100, int24(1));
805
+
806
+ PoolInfo memory result = routerTerminal.discoverBestPool(tokenA, tokenB);
807
+ assertTrue(result.isV4);
808
+ assertEq(result.v4Key.fee, 3000);
809
+ }
810
+
811
+ //*********************************************************************//
812
+ // ----------------------- V4 mock helpers -------------------------- //
813
+ //*********************************************************************//
814
+
815
+ /// @notice Mock V4 pool as existing with given sqrtPriceX96 and liquidity.
816
+ function _mockV4PoolExists(PoolId id, uint160 sqrtPriceX96, uint256 liquidity) internal {
817
+ // StateLibrary uses extsload to read pool state.
818
+ // Slot0 is at the pool state slot.
819
+ bytes32 stateSlot = keccak256(abi.encodePacked(PoolId.unwrap(id), bytes32(uint256(6))));
820
+
821
+ // Pack slot0: sqrtPriceX96 (160 bits) | tick (24 bits) | protocolFee (24 bits) | lpFee (24 bits)
822
+ bytes32 slot0Data = bytes32(uint256(sqrtPriceX96));
823
+ vm.mockCall(
824
+ address(mockPoolManager),
825
+ abi.encodeWithSignature("extsload(bytes32)", stateSlot),
826
+ abi.encode(slot0Data)
827
+ );
828
+
829
+ // Liquidity is at stateSlot + 3.
830
+ bytes32 liquiditySlot = bytes32(uint256(stateSlot) + 3);
831
+ vm.mockCall(
832
+ address(mockPoolManager),
833
+ abi.encodeWithSignature("extsload(bytes32)", liquiditySlot),
834
+ abi.encode(bytes32(liquidity))
835
+ );
836
+ }
837
+
838
+ /// @notice Mock a V4 pool as non-existent (sqrtPriceX96 = 0).
839
+ function _mockV4PoolNotExists(address sorted0, address sorted1, uint24 fee, int24 tickSpacing) internal {
840
+ PoolKey memory key = PoolKey({
841
+ currency0: Currency.wrap(sorted0),
842
+ currency1: Currency.wrap(sorted1),
843
+ fee: fee,
844
+ tickSpacing: tickSpacing,
845
+ hooks: IHooks(address(0))
846
+ });
847
+ PoolId id = key.toId();
848
+ bytes32 stateSlot = keccak256(abi.encodePacked(PoolId.unwrap(id), bytes32(uint256(6))));
849
+
850
+ vm.mockCall(
851
+ address(mockPoolManager),
852
+ abi.encodeWithSignature("extsload(bytes32)", stateSlot),
853
+ abi.encode(bytes32(0))
854
+ );
855
+ }
856
+
857
+ /// @notice Mock all V4 pools as non-existent for a token pair.
858
+ function _mockV4NoPools(address tokenA, address tokenB) internal {
859
+ (address sorted0, address sorted1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
860
+ _mockV4PoolNotExists(sorted0, sorted1, 3000, int24(60));
861
+ _mockV4PoolNotExists(sorted0, sorted1, 500, int24(10));
862
+ _mockV4PoolNotExists(sorted0, sorted1, 10_000, int24(200));
863
+ _mockV4PoolNotExists(sorted0, sorted1, 100, int24(1));
864
+ }
865
+ }