@rev-net/core-v6 0.0.7 → 0.0.9

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.
Files changed (59) hide show
  1. package/ADMINISTRATION.md +186 -0
  2. package/ARCHITECTURE.md +87 -0
  3. package/README.md +4 -2
  4. package/RISKS.md +49 -0
  5. package/SKILLS.md +22 -2
  6. package/STYLE_GUIDE.md +482 -0
  7. package/foundry.toml +6 -6
  8. package/package.json +13 -10
  9. package/script/Deploy.s.sol +3 -2
  10. package/src/REVDeployer.sol +129 -72
  11. package/src/REVLoans.sol +174 -165
  12. package/src/interfaces/IREVDeployer.sol +111 -72
  13. package/src/interfaces/IREVLoans.sol +116 -76
  14. package/src/structs/REV721TiersHookFlags.sol +14 -0
  15. package/src/structs/REVBaseline721HookConfig.sol +27 -0
  16. package/src/structs/REVDeploy721TiersHookConfig.sol +2 -2
  17. package/test/REV.integrations.t.sol +4 -3
  18. package/test/REVAutoIssuanceFuzz.t.sol +12 -8
  19. package/test/REVDeployerAuditRegressions.t.sol +4 -3
  20. package/test/REVInvincibility.t.sol +8 -6
  21. package/test/REVInvincibilityHandler.sol +1 -0
  22. package/test/REVLifecycle.t.sol +4 -3
  23. package/test/REVLoans.invariants.t.sol +5 -3
  24. package/test/REVLoansAttacks.t.sol +4 -3
  25. package/test/REVLoansAuditRegressions.t.sol +13 -24
  26. package/test/REVLoansFeeRecovery.t.sol +4 -3
  27. package/test/REVLoansSourced.t.sol +4 -3
  28. package/test/REVLoansUnSourced.t.sol +4 -3
  29. package/test/REVLoans_AuditFindings.t.sol +644 -0
  30. package/test/TestEmptyBuybackSpecs.t.sol +4 -3
  31. package/test/TestPR09_ConversionDocumentation.t.sol +4 -3
  32. package/test/TestPR10_LiquidationBehavior.t.sol +4 -3
  33. package/test/TestPR11_LowFindings.t.sol +4 -3
  34. package/test/TestPR12_FlashLoanSurplus.t.sol +4 -3
  35. package/test/TestPR13_CrossSourceReallocation.t.sol +4 -3
  36. package/test/TestPR15_CashOutCallerValidation.t.sol +4 -3
  37. package/test/TestPR16_ZeroRepayment.t.sol +4 -3
  38. package/test/TestPR21_Uint112Overflow.t.sol +4 -3
  39. package/test/TestPR22_HookArrayOOB.t.sol +4 -3
  40. package/test/TestPR26_BurnHeldTokens.t.sol +4 -3
  41. package/test/TestPR27_CEIPattern.t.sol +4 -3
  42. package/test/TestPR29_SwapTerminalPermission.t.sol +4 -3
  43. package/test/TestPR32_MixedFixes.t.sol +4 -3
  44. package/test/TestSplitWeightAdjustment.t.sol +445 -0
  45. package/test/TestSplitWeightE2E.t.sol +528 -0
  46. package/test/TestSplitWeightFork.t.sol +821 -0
  47. package/test/TestStageTransitionBorrowable.t.sol +4 -3
  48. package/test/fork/ForkTestBase.sol +617 -0
  49. package/test/fork/TestCashOutFork.t.sol +245 -0
  50. package/test/fork/TestLoanBorrowFork.t.sol +163 -0
  51. package/test/fork/TestLoanLiquidationFork.t.sol +129 -0
  52. package/test/fork/TestLoanReallocateFork.t.sol +103 -0
  53. package/test/fork/TestLoanRepayFork.t.sol +184 -0
  54. package/test/fork/TestSplitWeightFork.t.sol +186 -0
  55. package/test/mock/MockBuybackDataHook.sol +11 -4
  56. package/test/mock/MockBuybackDataHookMintPath.sol +11 -3
  57. package/test/regression/TestI20_CumulativeLoanCounter.t.sol +6 -5
  58. package/test/regression/TestL27_LiquidateGapHandling.t.sol +7 -6
  59. package/SECURITY.md +0 -68
@@ -0,0 +1,184 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.26;
3
+
4
+ import "./ForkTestBase.sol";
5
+
6
+ /// @notice Fork tests for REVLoans.repayLoan() with real Uniswap V4 buyback hook.
7
+ ///
8
+ /// Covers: full repay, partial repay, source fee after prepaid, no-fee within prepaid, expired revert.
9
+ ///
10
+ /// Run with: FOUNDRY_PROFILE=fork forge test --match-contract TestLoanRepayFork -vvv
11
+ contract TestLoanRepayFork is ForkTestBase {
12
+ uint256 revnetId;
13
+ uint256 loanId;
14
+ REVLoan loan;
15
+ uint256 borrowerTokens;
16
+
17
+ function setUp() public override {
18
+ super.setUp();
19
+
20
+ string memory rpcUrl = vm.envOr("RPC_ETHEREUM_MAINNET", string(""));
21
+ if (bytes(rpcUrl).length == 0) return;
22
+
23
+ // Deploy fee project + revnet.
24
+ _deployFeeProject(5000);
25
+ revnetId = _deployRevnet(5000);
26
+ _setupPool(revnetId, 10_000 ether);
27
+
28
+ // Pay to create surplus.
29
+ _payRevnet(revnetId, PAYER, 10 ether);
30
+ _payRevnet(revnetId, BORROWER, 5 ether);
31
+
32
+ borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
33
+
34
+ // Create a loan with min prepaid fee.
35
+ (loanId, loan) = _createLoan(revnetId, BORROWER, borrowerTokens, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
36
+ }
37
+
38
+ /// @notice Full repay: return all collateral, burn loan NFT.
39
+ function test_fork_repay_full() public onlyFork {
40
+ uint256 totalCollateralBefore = LOANS_CONTRACT.totalCollateralOf(revnetId);
41
+ uint256 totalBorrowedBefore =
42
+ LOANS_CONTRACT.totalBorrowedFrom(revnetId, jbMultiTerminal(), JBConstants.NATIVE_TOKEN);
43
+
44
+ // Fund borrower to repay (they need more ETH than they got from the loan due to fees).
45
+ vm.deal(BORROWER, 100 ether);
46
+
47
+ JBSingleAllowance memory allowance;
48
+
49
+ vm.prank(BORROWER);
50
+ LOANS_CONTRACT.repayLoan{value: loan.amount * 2}({
51
+ loanId: loanId,
52
+ maxRepayBorrowAmount: loan.amount * 2,
53
+ collateralCountToReturn: loan.collateral,
54
+ beneficiary: payable(BORROWER),
55
+ allowance: allowance
56
+ });
57
+
58
+ // Collateral re-minted to borrower.
59
+ assertEq(
60
+ jbTokens().totalBalanceOf(BORROWER, revnetId), borrowerTokens, "collateral should be returned to borrower"
61
+ );
62
+
63
+ // Loan NFT burned.
64
+ vm.expectRevert();
65
+ _loanOwnerOf(loanId);
66
+
67
+ // Tracking decreased.
68
+ assertEq(
69
+ LOANS_CONTRACT.totalCollateralOf(revnetId),
70
+ totalCollateralBefore - loan.collateral,
71
+ "totalCollateralOf should decrease"
72
+ );
73
+ assertLt(
74
+ LOANS_CONTRACT.totalBorrowedFrom(revnetId, jbMultiTerminal(), JBConstants.NATIVE_TOKEN),
75
+ totalBorrowedBefore,
76
+ "totalBorrowedFrom should decrease"
77
+ );
78
+ }
79
+
80
+ /// @notice Partial repay: return half the collateral, old loan burned, new loan minted.
81
+ function test_fork_repay_partial() public onlyFork {
82
+ uint256 halfCollateral = loan.collateral / 2;
83
+
84
+ vm.deal(BORROWER, 100 ether);
85
+
86
+ JBSingleAllowance memory allowance;
87
+
88
+ vm.prank(BORROWER);
89
+ (uint256 newLoanId, REVLoan memory newLoan) = LOANS_CONTRACT.repayLoan{value: loan.amount * 2}({
90
+ loanId: loanId,
91
+ maxRepayBorrowAmount: loan.amount * 2,
92
+ collateralCountToReturn: halfCollateral,
93
+ beneficiary: payable(BORROWER),
94
+ allowance: allowance
95
+ });
96
+
97
+ // Some collateral re-minted.
98
+ uint256 returnedTokens = jbTokens().totalBalanceOf(BORROWER, revnetId);
99
+ assertEq(returnedTokens, halfCollateral, "half collateral should be returned");
100
+
101
+ // Old loan burned.
102
+ vm.expectRevert();
103
+ _loanOwnerOf(loanId);
104
+
105
+ // New loan created with reduced collateral.
106
+ assertGt(newLoanId, 0, "new loan should be created");
107
+ assertEq(newLoan.collateral, loan.collateral - halfCollateral, "new loan collateral should be reduced");
108
+ assertLt(newLoan.amount, loan.amount, "new loan amount should be less");
109
+
110
+ // New loan NFT owned by borrower.
111
+ assertEq(_loanOwnerOf(newLoanId), BORROWER, "new loan NFT should be owned by borrower");
112
+ }
113
+
114
+ /// @notice After prepaid duration, source fee is charged on repayment.
115
+ function test_fork_repay_withSourceFee() public onlyFork {
116
+ // Warp past the prepaid duration but before 10 years.
117
+ vm.warp(block.timestamp + loan.prepaidDuration + 1 days);
118
+
119
+ vm.deal(BORROWER, 100 ether);
120
+
121
+ // Record ETH spent for repayment.
122
+ uint256 borrowerEthBefore = BORROWER.balance;
123
+
124
+ JBSingleAllowance memory allowance;
125
+
126
+ vm.prank(BORROWER);
127
+ LOANS_CONTRACT.repayLoan{value: loan.amount * 3}({
128
+ loanId: loanId,
129
+ maxRepayBorrowAmount: loan.amount * 3,
130
+ collateralCountToReturn: loan.collateral,
131
+ beneficiary: payable(BORROWER),
132
+ allowance: allowance
133
+ });
134
+
135
+ uint256 ethSpent = borrowerEthBefore - BORROWER.balance;
136
+
137
+ // Total cost should be more than the loan principal (due to source fee).
138
+ assertGt(ethSpent, loan.amount, "repay cost should exceed loan amount due to source fee");
139
+ }
140
+
141
+ /// @notice Repay immediately (within prepaid duration) -> no source fee.
142
+ function test_fork_repay_withinPrepaidNofee() public onlyFork {
143
+ // Don't warp — we're within prepaid duration.
144
+ vm.deal(BORROWER, 100 ether);
145
+
146
+ uint256 borrowerEthBefore = BORROWER.balance;
147
+
148
+ JBSingleAllowance memory allowance;
149
+
150
+ vm.prank(BORROWER);
151
+ LOANS_CONTRACT.repayLoan{value: loan.amount * 2}({
152
+ loanId: loanId,
153
+ maxRepayBorrowAmount: loan.amount * 2,
154
+ collateralCountToReturn: loan.collateral,
155
+ beneficiary: payable(BORROWER),
156
+ allowance: allowance
157
+ });
158
+
159
+ uint256 ethSpent = borrowerEthBefore - BORROWER.balance;
160
+
161
+ // Within prepaid period, cost should be exactly the loan amount (no additional source fee).
162
+ assertEq(ethSpent, loan.amount, "repay within prepaid should cost exactly loan amount");
163
+ }
164
+
165
+ /// @notice Repay after 10 years should revert (loan expired).
166
+ function test_fork_repay_expiredReverts() public onlyFork {
167
+ // Warp past the 10-year liquidation duration.
168
+ vm.warp(block.timestamp + LOANS_CONTRACT.LOAN_LIQUIDATION_DURATION());
169
+
170
+ vm.deal(BORROWER, 100 ether);
171
+
172
+ JBSingleAllowance memory allowance;
173
+
174
+ vm.prank(BORROWER);
175
+ vm.expectRevert();
176
+ LOANS_CONTRACT.repayLoan{value: loan.amount * 3}({
177
+ loanId: loanId,
178
+ maxRepayBorrowAmount: loan.amount * 3,
179
+ collateralCountToReturn: loan.collateral,
180
+ beneficiary: payable(BORROWER),
181
+ allowance: allowance
182
+ });
183
+ }
184
+ }
@@ -0,0 +1,186 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.26;
3
+
4
+ import "./ForkTestBase.sol";
5
+
6
+ /// @notice Fork tests verifying that revnet 721 tier splits + real Uniswap V4 buyback hook produce correct token
7
+ /// issuance in both the swap path (AMM buyback) and the mint path (direct minting).
8
+ ///
9
+ /// Requires: RPC_ETHEREUM_MAINNET env var for mainnet fork (real PoolManager).
10
+ ///
11
+ /// Run with: FOUNDRY_PROFILE=fork forge test --match-contract TestSplitWeightFork -vvv --skip "script/*"
12
+ contract TestSplitWeightFork is ForkTestBase {
13
+ // ───────────────────────── Tests
14
+ // ─────────────────────────
15
+
16
+ /// @notice SWAP PATH: Pool offers good rate -> buyback hook swaps on AMM instead of minting.
17
+ /// With 30% tier split, the buyback should swap with 0.7 ETH worth.
18
+ /// Terminal mints 0 tokens (weight=0), buyback hook mints via controller after swap.
19
+ function test_fork_swapPath_splitWithBuyback() public onlyFork {
20
+ _deployFeeProject(5000);
21
+ (uint256 revnetId, IJB721TiersHook hook) = _deployRevnetWith721(5000);
22
+
23
+ address projectToken = address(jbTokens().tokenOf(revnetId));
24
+ require(projectToken != address(0), "project token not deployed");
25
+
26
+ bool projectTokenIs0 = projectToken < WETH_ADDR;
27
+
28
+ address token0 = projectTokenIs0 ? projectToken : WETH_ADDR;
29
+ address token1 = projectTokenIs0 ? WETH_ADDR : projectToken;
30
+
31
+ PoolKey memory key = PoolKey({
32
+ currency0: Currency.wrap(token0),
33
+ currency1: Currency.wrap(token1),
34
+ fee: REV_DEPLOYER.DEFAULT_BUYBACK_POOL_FEE(),
35
+ tickSpacing: REV_DEPLOYER.DEFAULT_BUYBACK_TICK_SPACING(),
36
+ hooks: IHooks(address(0))
37
+ });
38
+
39
+ int24 initTick;
40
+ if (projectTokenIs0) {
41
+ initTick = -76_020;
42
+ } else {
43
+ initTick = 76_020;
44
+ }
45
+
46
+ uint160 sqrtPrice = TickMath.getSqrtPriceAtTick(initTick);
47
+ poolManager.initialize(key, sqrtPrice);
48
+
49
+ uint256 projectLiq = 10_000_000e18;
50
+ uint256 wethLiq = 5000e18;
51
+
52
+ vm.prank(address(jbController()));
53
+ jbTokens().mintFor(address(liqHelper), revnetId, projectLiq);
54
+ vm.deal(address(liqHelper), wethLiq);
55
+ vm.prank(address(liqHelper));
56
+ IWETH9(WETH_ADDR).deposit{value: wethLiq}();
57
+
58
+ vm.startPrank(address(liqHelper));
59
+ IERC20(projectToken).approve(address(poolManager), type(uint256).max);
60
+ IERC20(WETH_ADDR).approve(address(poolManager), type(uint256).max);
61
+ vm.stopPrank();
62
+
63
+ int256 liquidityDelta = int256(wethLiq / 4);
64
+ vm.prank(address(liqHelper));
65
+ liqHelper.addLiquidity(key, TICK_LOWER, TICK_UPPER, liquidityDelta);
66
+
67
+ _mockOracle(liquidityDelta, initTick, uint32(REV_DEPLOYER.DEFAULT_BUYBACK_TWAP_WINDOW()));
68
+
69
+ uint256 twapWindow = REV_DEPLOYER.DEFAULT_BUYBACK_TWAP_WINDOW();
70
+ vm.prank(multisig());
71
+ BUYBACK_HOOK.setPoolFor({
72
+ projectId: revnetId, poolKey: key, twapWindow: twapWindow, terminalToken: JBConstants.NATIVE_TOKEN
73
+ });
74
+
75
+ address metadataTarget = hook.METADATA_ID_TARGET();
76
+ bytes memory metadata = _buildPayMetadataWithQuote({
77
+ hookMetadataTarget: metadataTarget, amountToSwapWith: 0.7 ether, minimumSwapAmountOut: 1
78
+ });
79
+
80
+ vm.prank(PAYER);
81
+ uint256 terminalTokensReturned = jbMultiTerminal().pay{value: 1 ether}({
82
+ projectId: revnetId,
83
+ token: JBConstants.NATIVE_TOKEN,
84
+ amount: 1 ether,
85
+ beneficiary: PAYER,
86
+ minReturnedTokens: 0,
87
+ memo: "Fork: swap path with splits",
88
+ metadata: metadata
89
+ });
90
+
91
+ assertGt(terminalTokensReturned, 700e18, "swap path: should get more tokens than minting (pool rate better)");
92
+ }
93
+
94
+ /// @notice MINT PATH: Pool offers bad rate -> buyback decides minting is better.
95
+ /// With 30% tier split, REVDeployer scales weight from 1000e18 to 700e18.
96
+ /// Terminal mints 700 tokens.
97
+ function test_fork_mintPath_splitWithBuyback() public onlyFork {
98
+ _deployFeeProject(5000);
99
+ (uint256 revnetId, IJB721TiersHook hook) = _deployRevnetWith721(5000);
100
+ _setupPool(revnetId, 10_000 ether);
101
+
102
+ address metadataTarget = hook.METADATA_ID_TARGET();
103
+ bytes memory metadata = _buildPayMetadataNoQuote(metadataTarget);
104
+
105
+ vm.prank(PAYER);
106
+ uint256 tokensReceived = jbMultiTerminal().pay{value: 1 ether}({
107
+ projectId: revnetId,
108
+ token: JBConstants.NATIVE_TOKEN,
109
+ amount: 1 ether,
110
+ beneficiary: PAYER,
111
+ minReturnedTokens: 0,
112
+ memo: "Fork: mint path with splits",
113
+ metadata: metadata
114
+ });
115
+
116
+ uint256 expectedTokens = 700e18;
117
+ assertEq(tokensReceived, expectedTokens, "mint path: should receive 700 tokens (weight scaled for 30% split)");
118
+ }
119
+
120
+ /// @notice MINT PATH without splits: baseline confirming 1000 tokens for 1 ETH.
121
+ function test_fork_mintPath_noSplits_fullTokens() public onlyFork {
122
+ _deployFeeProject(5000);
123
+ (uint256 revnetId,) = _deployRevnetWith721(5000);
124
+ _setupPool(revnetId, 10_000 ether);
125
+
126
+ vm.prank(PAYER);
127
+ uint256 tokensReceived = jbMultiTerminal().pay{value: 1 ether}({
128
+ projectId: revnetId,
129
+ token: JBConstants.NATIVE_TOKEN,
130
+ amount: 1 ether,
131
+ beneficiary: PAYER,
132
+ minReturnedTokens: 0,
133
+ memo: "Fork: no split baseline",
134
+ metadata: ""
135
+ });
136
+
137
+ uint256 expectedTokens = 1000e18;
138
+ assertEq(tokensReceived, expectedTokens, "no splits: should receive 1000 tokens");
139
+ }
140
+
141
+ /// @notice Invariant: tokens / projectAmount rate is identical with and without splits.
142
+ function test_fork_invariant_tokenPerEthConsistent() public onlyFork {
143
+ _deployFeeProject(5000);
144
+
145
+ // --- Revnet 1: with 721 splits (30%) ---
146
+ (uint256 revnetId1, IJB721TiersHook hook1) = _deployRevnetWith721(5000);
147
+ _setupPool(revnetId1, 10_000 ether);
148
+
149
+ address metadataTarget1 = hook1.METADATA_ID_TARGET();
150
+ bytes memory metadata1 = _buildPayMetadataNoQuote(metadataTarget1);
151
+
152
+ vm.prank(PAYER);
153
+ uint256 tokens1 = jbMultiTerminal().pay{value: 1 ether}({
154
+ projectId: revnetId1,
155
+ token: JBConstants.NATIVE_TOKEN,
156
+ amount: 1 ether,
157
+ beneficiary: PAYER,
158
+ minReturnedTokens: 0,
159
+ memo: "invariant: with splits",
160
+ metadata: metadata1
161
+ });
162
+
163
+ // --- Revnet 2: no splits (plain payment, no tier metadata) ---
164
+ uint256 revnetId2 = _deployRevnet(5000);
165
+ _setupPool(revnetId2, 10_000 ether);
166
+
167
+ vm.prank(PAYER);
168
+ uint256 tokens2 = jbMultiTerminal().pay{value: 1 ether}({
169
+ projectId: revnetId2,
170
+ token: JBConstants.NATIVE_TOKEN,
171
+ amount: 1 ether,
172
+ beneficiary: PAYER,
173
+ minReturnedTokens: 0,
174
+ memo: "invariant: no splits",
175
+ metadata: ""
176
+ });
177
+
178
+ uint256 projectAmount1 = 0.7 ether;
179
+ uint256 projectAmount2 = 1 ether;
180
+
181
+ uint256 rate1 = (tokens1 * 1e18) / projectAmount1;
182
+ uint256 rate2 = (tokens2 * 1e18) / projectAmount2;
183
+
184
+ assertEq(rate1, rate2, "token-per-ETH rate should be identical with and without splits");
185
+ }
186
+ }
@@ -4,14 +4,15 @@ pragma solidity 0.8.26;
4
4
  import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
5
5
  import {IJBPayHook} from "@bananapus/core-v6/src/interfaces/IJBPayHook.sol";
6
6
  import {IJBBuybackHook} from "@bananapus/buyback-hook-v6/src/interfaces/IJBBuybackHook.sol";
7
+ import {IWETH9} from "@bananapus/buyback-hook-v6/src/interfaces/external/IWETH9.sol";
7
8
  import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforePayRecordedContext.sol";
8
9
  import {JBBeforeCashOutRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforeCashOutRecordedContext.sol";
9
10
  import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
10
11
  import {JBCashOutHookSpecification} from "@bananapus/core-v6/src/structs/JBCashOutHookSpecification.sol";
11
12
  import {JBAfterPayRecordedContext} from "@bananapus/core-v6/src/structs/JBAfterPayRecordedContext.sol";
12
13
  import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
13
- import {IUniswapV3Pool} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
14
14
  import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
15
+ import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
15
16
 
16
17
  /// @notice A minimal mock buyback data hook for tests. Returns the default weight and a no-op pay hook specification.
17
18
  contract MockBuybackDataHook is IJBRulesetDataHook, IJBPayHook {
@@ -49,11 +50,17 @@ contract MockBuybackDataHook is IJBRulesetDataHook, IJBPayHook {
49
50
 
50
51
  function afterPayRecordedWith(JBAfterPayRecordedContext calldata) external payable override {}
51
52
 
52
- /// @notice No-op pool configuration for tests.
53
- function setPoolFor(uint256, uint24, uint256, address) external pure returns (IUniswapV3Pool) {
54
- return IUniswapV3Pool(address(0));
53
+ /// @notice Returns a dummy wrapped native token address for tests.
54
+ function WRAPPED_NATIVE_TOKEN() external pure returns (IWETH9) {
55
+ return IWETH9(address(1));
55
56
  }
56
57
 
58
+ /// @notice No-op pool configuration for tests (PoolKey overload).
59
+ function setPoolFor(uint256, PoolKey calldata, uint256, address) external pure {}
60
+
61
+ /// @notice No-op pool configuration for tests (simplified overload).
62
+ function setPoolFor(uint256, uint24, int24, uint256, address) external pure {}
63
+
57
64
  function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
58
65
  return interfaceId == type(IJBRulesetDataHook).interfaceId || interfaceId == type(IJBPayHook).interfaceId
59
66
  || interfaceId == type(IERC165).interfaceId;
@@ -3,14 +3,15 @@ pragma solidity 0.8.26;
3
3
 
4
4
  import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
5
5
  import {IJBPayHook} from "@bananapus/core-v6/src/interfaces/IJBPayHook.sol";
6
+ import {IWETH9} from "@bananapus/buyback-hook-v6/src/interfaces/external/IWETH9.sol";
6
7
  import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforePayRecordedContext.sol";
7
8
  import {JBBeforeCashOutRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforeCashOutRecordedContext.sol";
8
9
  import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
9
10
  import {JBCashOutHookSpecification} from "@bananapus/core-v6/src/structs/JBCashOutHookSpecification.sol";
10
11
  import {JBAfterPayRecordedContext} from "@bananapus/core-v6/src/structs/JBAfterPayRecordedContext.sol";
11
12
  import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
12
- import {IUniswapV3Pool} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
13
13
  import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
14
+ import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
14
15
 
15
16
  /// @notice Mock buyback hook that simulates the "mint path" — returns EMPTY hookSpecifications.
16
17
  /// This is what the real JBBuybackHook does when direct minting is cheaper than swapping
@@ -50,10 +51,17 @@ contract MockBuybackDataHookMintPath is IJBRulesetDataHook, IJBPayHook {
50
51
 
51
52
  function afterPayRecordedWith(JBAfterPayRecordedContext calldata) external payable override {}
52
53
 
53
- function setPoolFor(uint256, uint24, uint256, address) external pure returns (IUniswapV3Pool) {
54
- return IUniswapV3Pool(address(0));
54
+ /// @notice Returns a dummy wrapped native token address for tests.
55
+ function WRAPPED_NATIVE_TOKEN() external pure returns (IWETH9) {
56
+ return IWETH9(address(1));
55
57
  }
56
58
 
59
+ /// @notice No-op pool configuration for tests (PoolKey overload).
60
+ function setPoolFor(uint256, PoolKey calldata, uint256, address) external pure {}
61
+
62
+ /// @notice No-op pool configuration for tests (simplified overload).
63
+ function setPoolFor(uint256, uint24, int24, uint256, address) external pure {}
64
+
57
65
  function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
58
66
  return interfaceId == type(IJBRulesetDataHook).interfaceId || interfaceId == type(IJBPayHook).interfaceId
59
67
  || interfaceId == type(IERC165).interfaceId;
@@ -31,11 +31,11 @@ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStor
31
31
  import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
32
32
  import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
33
33
 
34
- /// @notice Regression test for I-20: totalLoansBorrowedFor is a cumulative counter, not an active loan count.
34
+ /// @notice totalLoansBorrowedFor is a cumulative counter, not an active loan count.
35
35
  /// @dev The rename from numberOfLoansFor to totalLoansBorrowedFor clarifies that the counter only increments
36
36
  /// and never decrements. Repaying or liquidating a loan does NOT reduce the counter. This test verifies that
37
37
  /// the counter remains at its high-water mark after loans are fully repaid and after loans are liquidated.
38
- contract TestI20_CumulativeLoanCounter is TestBaseWorkflow, JBTest {
38
+ contract TestI20_CumulativeLoanCounter is TestBaseWorkflow {
39
39
  bytes32 REV_DEPLOYER_SALT = "REVDeployer";
40
40
 
41
41
  REVDeployer REV_DEPLOYER;
@@ -62,7 +62,8 @@ contract TestI20_CumulativeLoanCounter is TestBaseWorkflow, JBTest {
62
62
  FEE_PROJECT_ID = jbProjects().createFor(multisig());
63
63
  SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
64
64
  HOOK_STORE = new JB721TiersHookStore();
65
- EXAMPLE_HOOK = new JB721TiersHook(jbDirectory(), jbPermissions(), jbRulesets(), HOOK_STORE, multisig());
65
+ EXAMPLE_HOOK =
66
+ new JB721TiersHook(jbDirectory(), jbPermissions(), jbRulesets(), HOOK_STORE, jbSplits(), multisig());
66
67
  ADDRESS_REGISTRY = new JBAddressRegistry();
67
68
  HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
68
69
  PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
@@ -87,7 +88,7 @@ contract TestI20_CumulativeLoanCounter is TestBaseWorkflow, JBTest {
87
88
  FEE_PROJECT_ID,
88
89
  HOOK_DEPLOYER,
89
90
  PUBLISHER,
90
- IJBRulesetDataHook(address(MOCK_BUYBACK)),
91
+ IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
91
92
  address(LOANS_CONTRACT),
92
93
  TRUSTED_FORWARDER
93
94
  );
@@ -200,7 +201,7 @@ contract TestI20_CumulativeLoanCounter is TestBaseWorkflow, JBTest {
200
201
 
201
202
  /// @notice Verifies totalLoansBorrowedFor never decrements after loan repayment.
202
203
  /// @dev Creates 3 loans, fully repays 2, then verifies the counter stays at 3 (not 1).
203
- /// This confirms the I-20 rename correctly reflects cumulative semantics.
204
+ /// This confirms that the rename correctly reflects cumulative semantics.
204
205
  function test_I20_counterNeverDecrementsAfterRepayment() public {
205
206
  // Counter starts at 0
206
207
  assertEq(LOANS_CONTRACT.totalLoansBorrowedFor(REVNET_ID), 0, "Counter should start at 0");
@@ -32,11 +32,11 @@ import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressReg
32
32
  import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
33
33
  import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
34
34
 
35
- /// @notice Regression test for L-27: liquidateExpiredLoansFrom halts on deleted loan gaps.
35
+ /// @notice liquidateExpiredLoansFrom halts on deleted loan gaps.
36
36
  /// @dev Before the fix, the function used `break` when encountering a deleted loan (createdAt == 0),
37
37
  /// which stopped the entire iteration. Expired loans after the gap were never liquidated.
38
38
  /// After the fix, `continue` is used instead, so the loop skips gaps and keeps processing.
39
- contract TestL27_LiquidateGapHandling is TestBaseWorkflow, JBTest {
39
+ contract TestL27_LiquidateGapHandling is TestBaseWorkflow {
40
40
  bytes32 REV_DEPLOYER_SALT = "REVDeployer";
41
41
 
42
42
  REVDeployer REV_DEPLOYER;
@@ -64,7 +64,8 @@ contract TestL27_LiquidateGapHandling is TestBaseWorkflow, JBTest {
64
64
  FEE_PROJECT_ID = jbProjects().createFor(multisig());
65
65
  SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
66
66
  HOOK_STORE = new JB721TiersHookStore();
67
- EXAMPLE_HOOK = new JB721TiersHook(jbDirectory(), jbPermissions(), jbRulesets(), HOOK_STORE, multisig());
67
+ EXAMPLE_HOOK =
68
+ new JB721TiersHook(jbDirectory(), jbPermissions(), jbRulesets(), HOOK_STORE, jbSplits(), multisig());
68
69
  ADDRESS_REGISTRY = new JBAddressRegistry();
69
70
  HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
70
71
  PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
@@ -88,7 +89,7 @@ contract TestL27_LiquidateGapHandling is TestBaseWorkflow, JBTest {
88
89
  FEE_PROJECT_ID,
89
90
  HOOK_DEPLOYER,
90
91
  PUBLISHER,
91
- IJBRulesetDataHook(address(MOCK_BUYBACK)),
92
+ IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
92
93
  address(LOANS_CONTRACT),
93
94
  TRUSTED_FORWARDER
94
95
  );
@@ -201,7 +202,7 @@ contract TestL27_LiquidateGapHandling is TestBaseWorkflow, JBTest {
201
202
  (loanId,) = LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokenCount, payable(user), 25);
202
203
  }
203
204
 
204
- /// @notice Regression test for L-27: liquidation should continue past deleted loan gaps.
205
+ /// @notice Liquidation should continue past deleted loan gaps.
205
206
  /// @dev Steps:
206
207
  /// 1. Create 3 loans (loan numbers 1, 2, 3)
207
208
  /// 2. Fully repay loan 2, which deletes it (createdAt == 0), creating a gap
@@ -268,7 +269,7 @@ contract TestL27_LiquidateGapHandling is TestBaseWorkflow, JBTest {
268
269
  REVLoan memory liquidatedLoan1 = LOANS_CONTRACT.loanOf(loanId1);
269
270
  assertEq(liquidatedLoan1.createdAt, 0, "Loan 1 should be liquidated (data deleted)");
270
271
 
271
- // Loan 3 should ALSO be liquidated -- this is the critical assertion for L-27.
272
+ // Loan 3 should ALSO be liquidated -- this is the critical assertion.
272
273
  // Before the fix, this would fail because the `break` at loan 2's gap stopped iteration.
273
274
  REVLoan memory liquidatedLoan3 = LOANS_CONTRACT.loanOf(loanId3);
274
275
  assertEq(liquidatedLoan3.createdAt, 0, "Loan 3 should be liquidated despite gap at loan 2");
package/SECURITY.md DELETED
@@ -1,68 +0,0 @@
1
- # Security Considerations
2
-
3
- ## [INTEROP-6] Cross-Chain Accounting Mismatch: NATIVE_TOKEN on Non-ETH Chains
4
-
5
- **Severity:** Medium
6
- **Status:** Acknowledged — by design, not fixable without oracle dependencies
7
-
8
- ### Description
9
-
10
- When a revnet expands to a chain where the native token is not ETH (e.g., Celo where native = CELO), using `JBConstants.NATIVE_TOKEN` as the terminal accounting context and sucker token mapping creates a semantic mismatch. The protocol treats CELO payments as ETH-equivalent.
11
-
12
- ### What the Matching Hash Covers
13
-
14
- The hash computed in `REVDeployer._makeRulesetConfigurations()` ensures both sides of a cross-chain deployment agree on:
15
- - `baseCurrency`, `loans`, `name`, `ticker`, `salt`
16
- - Per stage: timing, splits, issuance, cash-out tax
17
- - Per auto-issuance: chainId, beneficiary, count
18
-
19
- ### What the Matching Hash Does NOT Cover
20
-
21
- - Terminal configurations (which tokens are accepted)
22
- - Accounting contexts (token address, decimals, currency)
23
- - Sucker token mappings (localToken → remoteToken)
24
-
25
- Two deployments can produce identical hashes while one accepts ETH-native and the other accepts CELO-native. The hash is a safety check for economic parameter alignment, not a guarantee of asset compatibility.
26
-
27
- ### Impact on Revnets
28
-
29
- 1. **Issuance mispricing** — A revnet with `baseCurrency = ETH` that accepts `NATIVE_TOKEN` on Celo prices CELO payments as ETH (1:1 without a price feed), massively overvaluing them.
30
- 2. **Surplus fragmentation** — Cash-out bonding curve on each chain only sees that chain's surplus. Token holders must bridge to the chain with more surplus for fair cash-out values.
31
- 3. **Cash-out arbitrage** — Different effective valuations across chains let arbitrageurs buy tokens cheaply on one chain and cash out on another.
32
-
33
- ### Recommended Configuration for Non-ETH Chains
34
-
35
- When deploying a revnet to Celo or other non-ETH-native chains:
36
-
37
- ```solidity
38
- // DO: Use WETH ERC20 as accounting context
39
- accountingContextsToAccept[0] = JBAccountingContext({
40
- token: WETH_ADDRESS, // e.g., 0xD221812... on Celo
41
- decimals: 18,
42
- currency: ETH_CURRENCY
43
- });
44
-
45
- // DO: Map WETH → WETH in sucker token mappings
46
- tokenMappings[0] = JBTokenMapping({
47
- localToken: WETH_ADDRESS,
48
- remoteToken: WETH_ADDRESS,
49
- minGas: 200_000,
50
- minBridgeAmount: 0.01 ether
51
- });
52
-
53
- // DON'T: Use NATIVE_TOKEN on non-ETH chains
54
- // This maps CELO → ETH which are different assets
55
- tokenMappings[0] = JBTokenMapping({
56
- localToken: JBConstants.NATIVE_TOKEN, // = CELO on Celo
57
- remoteToken: JBConstants.NATIVE_TOKEN, // = ETH on Ethereum
58
- ...
59
- });
60
- ```
61
-
62
- ### Safe Chains
63
-
64
- OP Stack L2s where native token IS ETH: Ethereum, Optimism, Base, Arbitrum.
65
-
66
- ### Affected Chains
67
-
68
- Any chain where native token ≠ ETH: Celo (CELO), Polygon (MATIC), Avalanche (AVAX), BNB Chain (BNB).