@rev-net/core-v6 0.0.4 → 0.0.6

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,237 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.26;
3
+
4
+ import "forge-std/Test.sol";
5
+ import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
6
+ import /* {*} from */ "./../src/REVDeployer.sol";
7
+ import "@croptop/core-v6/src/CTPublisher.sol";
8
+ import {MockBuybackDataHookMintPath} from "./mock/MockBuybackDataHookMintPath.sol";
9
+ import {MockBuybackDataHook} from "./mock/MockBuybackDataHook.sol";
10
+ import "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
11
+ import "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
12
+ import "@bananapus/suckers-v6/script/helpers/SuckerDeploymentLib.sol";
13
+ import "@croptop/core-v6/script/helpers/CroptopDeploymentLib.sol";
14
+ import "@bananapus/router-terminal-v6/script/helpers/RouterTerminalDeploymentLib.sol";
15
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
16
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
17
+ import {REVLoans} from "../src/REVLoans.sol";
18
+ import {REVStageConfig, REVAutoIssuance} from "../src/structs/REVStageConfig.sol";
19
+ import {REVLoanSource} from "../src/structs/REVLoanSource.sol";
20
+ import {REVDescription} from "../src/structs/REVDescription.sol";
21
+ import {IREVLoans} from "./../src/interfaces/IREVLoans.sol";
22
+ import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
23
+ import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
24
+ import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
25
+ import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
26
+ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
27
+ import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
28
+ import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
29
+ import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforePayRecordedContext.sol";
30
+ import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
31
+ import {JBTokenAmount} from "@bananapus/core-v6/src/structs/JBTokenAmount.sol";
32
+
33
+ /// @notice Regression tests for the empty buyback hook specifications fix.
34
+ /// When JBBuybackHook determines minting is cheaper than swapping, it returns an empty
35
+ /// hookSpecifications array. Before the fix, REVDeployer.beforePayRecordedWith would
36
+ /// Panic(0x32) (array out-of-bounds) when accessing buybackHookSpecifications[0].
37
+ contract TestEmptyBuybackSpecs is TestBaseWorkflow, JBTest {
38
+ bytes32 REV_DEPLOYER_SALT = "REVDeployer";
39
+
40
+ REVDeployer REV_DEPLOYER;
41
+ JB721TiersHook EXAMPLE_HOOK;
42
+ IJB721TiersHookDeployer HOOK_DEPLOYER;
43
+ IJB721TiersHookStore HOOK_STORE;
44
+ IJBAddressRegistry ADDRESS_REGISTRY;
45
+ IREVLoans LOANS_CONTRACT;
46
+ IJBSuckerRegistry SUCKER_REGISTRY;
47
+ CTPublisher PUBLISHER;
48
+ MockBuybackDataHookMintPath MOCK_BUYBACK_MINT_PATH;
49
+
50
+ uint256 FEE_PROJECT_ID;
51
+
52
+ address private constant TRUSTED_FORWARDER = 0xB2b5841DBeF766d4b521221732F9B618fCf34A87;
53
+ address USER = makeAddr("user");
54
+
55
+ function setUp() public override {
56
+ super.setUp();
57
+ FEE_PROJECT_ID = jbProjects().createFor(multisig());
58
+ SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
59
+ HOOK_STORE = new JB721TiersHookStore();
60
+ EXAMPLE_HOOK = new JB721TiersHook(jbDirectory(), jbPermissions(), jbRulesets(), HOOK_STORE, multisig());
61
+ ADDRESS_REGISTRY = new JBAddressRegistry();
62
+ HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
63
+ PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
64
+ MOCK_BUYBACK_MINT_PATH = new MockBuybackDataHookMintPath();
65
+ LOANS_CONTRACT = new REVLoans({
66
+ controller: jbController(),
67
+ projects: jbProjects(),
68
+ revId: FEE_PROJECT_ID,
69
+ owner: address(this),
70
+ permit2: permit2(),
71
+ trustedForwarder: TRUSTED_FORWARDER
72
+ });
73
+ REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
74
+ jbController(),
75
+ SUCKER_REGISTRY,
76
+ FEE_PROJECT_ID,
77
+ HOOK_DEPLOYER,
78
+ PUBLISHER,
79
+ IJBRulesetDataHook(address(MOCK_BUYBACK_MINT_PATH)),
80
+ address(LOANS_CONTRACT),
81
+ TRUSTED_FORWARDER
82
+ );
83
+ vm.prank(multisig());
84
+ jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
85
+ }
86
+
87
+ function _buildMinimalConfig()
88
+ internal
89
+ view
90
+ returns (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc)
91
+ {
92
+ JBAccountingContext[] memory acc = new JBAccountingContext[](1);
93
+ acc[0] = JBAccountingContext({
94
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
95
+ });
96
+ tc = new JBTerminalConfig[](1);
97
+ tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: acc});
98
+
99
+ REVStageConfig[] memory stages = new REVStageConfig[](1);
100
+ JBSplit[] memory splits = new JBSplit[](1);
101
+ splits[0].beneficiary = payable(multisig());
102
+ splits[0].percent = 10_000;
103
+ stages[0] = REVStageConfig({
104
+ startsAtOrAfter: uint40(block.timestamp),
105
+ autoIssuances: new REVAutoIssuance[](0),
106
+ splitPercent: 0,
107
+ splits: splits,
108
+ initialIssuance: uint112(1000e18),
109
+ issuanceCutFrequency: 0,
110
+ issuanceCutPercent: 0,
111
+ cashOutTaxRate: 5000,
112
+ extraMetadata: 0
113
+ });
114
+
115
+ cfg = REVConfig({
116
+ description: REVDescription("Test", "TST", "ipfs://test", "TEST_SALT"),
117
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
118
+ splitOperator: multisig(),
119
+ stageConfigurations: stages
120
+ });
121
+
122
+ sdc = REVSuckerDeploymentConfig({
123
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256(abi.encodePacked("TEST"))
124
+ });
125
+ }
126
+
127
+ function _deployFeeAndRevnet() internal returns (uint256 revnetId) {
128
+ (REVConfig memory feeCfg, JBTerminalConfig[] memory feeTc, REVSuckerDeploymentConfig memory feeSdc) =
129
+ _buildMinimalConfig();
130
+
131
+ vm.prank(multisig());
132
+ REV_DEPLOYER.deployFor({
133
+ revnetId: FEE_PROJECT_ID,
134
+ configuration: feeCfg,
135
+ terminalConfigurations: feeTc,
136
+ suckerDeploymentConfiguration: feeSdc
137
+ });
138
+
139
+ (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc) =
140
+ _buildMinimalConfig();
141
+ cfg.description = REVDescription("Test2", "TS2", "ipfs://test2", "TEST_SALT_2");
142
+
143
+ revnetId = REV_DEPLOYER.deployFor({
144
+ revnetId: 0, configuration: cfg, terminalConfigurations: tc, suckerDeploymentConfiguration: sdc
145
+ });
146
+ }
147
+
148
+ /// @notice REGRESSION: Payment to revnet must succeed when buyback hook returns empty specs (mint path).
149
+ /// Before the fix, this would Panic(0x32) due to accessing buybackHookSpecifications[0] on an empty array.
150
+ function test_payRevnet_emptyBuybackSpecs_succeeds() public {
151
+ uint256 revnetId = _deployFeeAndRevnet();
152
+
153
+ vm.deal(USER, 1 ether);
154
+ vm.prank(USER);
155
+ jbMultiTerminal().pay{value: 1 ether}({
156
+ projectId: revnetId,
157
+ token: JBConstants.NATIVE_TOKEN,
158
+ amount: 1 ether,
159
+ beneficiary: USER,
160
+ minReturnedTokens: 0,
161
+ memo: "payment with mint path buyback",
162
+ metadata: ""
163
+ });
164
+
165
+ uint256 balance = jbTokens().totalBalanceOf(USER, revnetId);
166
+ assertGt(balance, 0, "Should have received tokens when buyback hook takes mint path");
167
+ }
168
+
169
+ /// @notice Payment with various amounts should work when buyback hook returns empty specs.
170
+ function test_payRevnet_emptyBuybackSpecs_variousAmounts(uint96 amount) public {
171
+ vm.assume(amount > 0.001 ether && amount < 100 ether);
172
+ uint256 revnetId = _deployFeeAndRevnet();
173
+
174
+ vm.deal(USER, amount);
175
+ vm.prank(USER);
176
+ jbMultiTerminal().pay{value: amount}({
177
+ projectId: revnetId,
178
+ token: JBConstants.NATIVE_TOKEN,
179
+ amount: amount,
180
+ beneficiary: USER,
181
+ minReturnedTokens: 0,
182
+ memo: "",
183
+ metadata: ""
184
+ });
185
+
186
+ uint256 balance = jbTokens().totalBalanceOf(USER, revnetId);
187
+ assertGt(balance, 0, "Should have received tokens for any valid amount");
188
+ }
189
+
190
+ /// @notice Multiple sequential payments should work with empty buyback specs.
191
+ function test_payRevnet_emptyBuybackSpecs_multiplePayments() public {
192
+ uint256 revnetId = _deployFeeAndRevnet();
193
+
194
+ for (uint256 i; i < 5; i++) {
195
+ address payer = makeAddr(string(abi.encodePacked("payer", i)));
196
+ vm.deal(payer, 1 ether);
197
+ vm.prank(payer);
198
+ jbMultiTerminal().pay{value: 1 ether}({
199
+ projectId: revnetId,
200
+ token: JBConstants.NATIVE_TOKEN,
201
+ amount: 1 ether,
202
+ beneficiary: payer,
203
+ minReturnedTokens: 0,
204
+ memo: "",
205
+ metadata: ""
206
+ });
207
+ assertGt(jbTokens().totalBalanceOf(payer, revnetId), 0, "Each payer should receive tokens");
208
+ }
209
+ }
210
+
211
+ /// @notice Verify beforePayRecordedWith returns empty hookSpecifications when buyback returns empty.
212
+ function test_beforePayRecordedWith_emptyBuybackSpecs_returnsEmptyArray() public {
213
+ uint256 revnetId = _deployFeeAndRevnet();
214
+
215
+ JBBeforePayRecordedContext memory context = JBBeforePayRecordedContext({
216
+ terminal: address(jbMultiTerminal()),
217
+ payer: USER,
218
+ amount: JBTokenAmount({
219
+ token: JBConstants.NATIVE_TOKEN,
220
+ value: 1 ether,
221
+ decimals: 18,
222
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
223
+ }),
224
+ projectId: revnetId,
225
+ rulesetId: 0,
226
+ beneficiary: USER,
227
+ weight: 1000e18,
228
+ reservedPercent: 0,
229
+ metadata: ""
230
+ });
231
+
232
+ (uint256 weight, JBPayHookSpecification[] memory specs) = REV_DEPLOYER.beforePayRecordedWith(context);
233
+
234
+ assertEq(weight, context.weight, "Weight should pass through from buyback hook");
235
+ assertEq(specs.length, 0, "Should return empty specs when buyback hook returns empty and no 721 hook");
236
+ }
237
+ }
@@ -30,7 +30,7 @@ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStor
30
30
  import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
31
31
  import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
32
32
 
33
- /// @notice Tests proving that flash loan surplus manipulation is economically unprofitable (PR #12).
33
+ /// @notice Tests showing that flash loan surplus manipulation is economically unprofitable.
34
34
  contract TestPR12_FlashLoanSurplus is TestBaseWorkflow, JBTest {
35
35
  bytes32 REV_DEPLOYER_SALT = "REVDeployer";
36
36
  bytes32 ERC20_SALT = "REV_TOKEN";
@@ -227,7 +227,7 @@ contract TestPR16_ZeroRepayment is TestBaseWorkflow, JBTest {
227
227
 
228
228
  // Try to repay with collateralCountToReturn = 0 and some maxRepayBorrowAmount.
229
229
  // Since surplus was inflated, newBorrowAmount > loan.amount, which reverts with
230
- // REVLoans_NewBorrowAmountGreaterThanLoanAmount. This proves zero-repayment is blocked.
230
+ // REVLoans_NewBorrowAmountGreaterThanLoanAmount. This shows zero-repayment is blocked.
231
231
  vm.prank(USER);
232
232
  vm.expectRevert(); // Will revert with either NothingToRepay or NewBorrowAmountGreaterThanLoanAmount
233
233
  LOANS_CONTRACT.repayLoan{value: 0}(
@@ -32,7 +32,7 @@ import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressReg
32
32
  import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
33
33
 
34
34
  /// @title TestPR21_Uint112Overflow
35
- /// @notice Tests for PR #21 — C-1 uint112 truncation fix in REVLoans._adjust()
35
+ /// @notice Tests for uint112 truncation fix in REVLoans._adjust()
36
36
  contract TestPR21_Uint112Overflow is TestBaseWorkflow, JBTest {
37
37
  bytes32 REV_DEPLOYER_SALT = "REVDeployer";
38
38
  bytes32 ERC20_SALT = "REV_TOKEN";
@@ -32,7 +32,7 @@ import {JBTokenAmount} from "@bananapus/core-v6/src/structs/JBTokenAmount.sol";
32
32
  import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
33
33
 
34
34
  /// @notice Tests for PR #22: fix/c2-hook-array-oob
35
- /// Verifies that the fix for the C-2 hook array out-of-bounds bug works correctly.
35
+ /// Verifies that the fix for the hook array out-of-bounds bug works correctly.
36
36
  /// The bug: `hookSpecifications[1] = buybackHookSpecifications[0]` would revert with OOB
37
37
  /// when there is no tiered 721 hook (array size is 1, not 2).
38
38
  /// The fix: `hookSpecifications[usesTiered721Hook ? 1 : 0] = buybackHookSpecifications[0]`.
@@ -60,9 +60,9 @@ contract ReentrantBorrower {
60
60
  }
61
61
 
62
62
  /// @title TestPR27_CEIPattern
63
- /// @notice Tests for PR #27 — C-3 CEI pattern fix in REVLoans._adjust()
63
+ /// @notice Tests for CEI pattern fix in REVLoans._adjust()
64
64
  ///
65
- /// SOURCE VERIFICATION (confirmed by reading _addTo/_removeFrom/_addCollateralTo/_returnCollateralFrom):
65
+ /// Source context (_addTo/_removeFrom/_addCollateralTo/_returnCollateralFrom):
66
66
  /// - _addTo(REVLoan memory, ..., uint256 addedBorrowAmount, ...) — memory copy, uses delta param
67
67
  /// - _removeFrom(REVLoan memory, ..., uint256 repaidBorrowAmount) — memory copy, uses delta param
68
68
  /// - _addCollateralTo(uint256 revnetId, uint256 amount) — no loan reference at all
@@ -0,0 +1,241 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.26;
3
+
4
+ import "forge-std/Test.sol";
5
+ import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
6
+ import /* {*} from */ "./../src/REVDeployer.sol";
7
+ import "@croptop/core-v6/src/CTPublisher.sol";
8
+ import {MockBuybackDataHook} from "./mock/MockBuybackDataHook.sol";
9
+ import "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
10
+ import "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
11
+ import "@bananapus/suckers-v6/script/helpers/SuckerDeploymentLib.sol";
12
+ import "@croptop/core-v6/script/helpers/CroptopDeploymentLib.sol";
13
+ import "@bananapus/router-terminal-v6/script/helpers/RouterTerminalDeploymentLib.sol";
14
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
15
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
16
+ import {REVLoans} from "../src/REVLoans.sol";
17
+ import {REVStageConfig, REVAutoIssuance} from "../src/structs/REVStageConfig.sol";
18
+ import {REVLoanSource} from "../src/structs/REVLoanSource.sol";
19
+ import {REVDescription} from "../src/structs/REVDescription.sol";
20
+ import {IREVLoans} from "./../src/interfaces/IREVLoans.sol";
21
+ import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
22
+ import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
23
+ import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
24
+ import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
25
+ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
26
+ import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
27
+ import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
28
+
29
+ /// @notice Documents and verifies that stage transitions change the borrowable amount for the same collateral.
30
+ /// This is by design: loan value tracks the current bonding curve parameters (cashOutTaxRate),
31
+ /// just as cash-out value does.
32
+ contract TestStageTransitionBorrowable is TestBaseWorkflow, JBTest {
33
+ bytes32 REV_DEPLOYER_SALT = "REVDeployer";
34
+
35
+ REVDeployer REV_DEPLOYER;
36
+ JB721TiersHook EXAMPLE_HOOK;
37
+ IJB721TiersHookDeployer HOOK_DEPLOYER;
38
+ IJB721TiersHookStore HOOK_STORE;
39
+ IJBAddressRegistry ADDRESS_REGISTRY;
40
+ IREVLoans LOANS_CONTRACT;
41
+ IJBSuckerRegistry SUCKER_REGISTRY;
42
+ CTPublisher PUBLISHER;
43
+ MockBuybackDataHook MOCK_BUYBACK;
44
+
45
+ uint256 FEE_PROJECT_ID;
46
+ uint256 REVNET_ID;
47
+
48
+ address USER = makeAddr("user");
49
+
50
+ address private constant TRUSTED_FORWARDER = 0xB2b5841DBeF766d4b521221732F9B618fCf34A87;
51
+
52
+ /// @notice Stage 1 starts now with 60% cashOutTaxRate, stage 2 starts after 30 days with 20% cashOutTaxRate.
53
+ function _buildConfig()
54
+ internal
55
+ view
56
+ returns (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc)
57
+ {
58
+ JBAccountingContext[] memory acc = new JBAccountingContext[](1);
59
+ acc[0] = JBAccountingContext({
60
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
61
+ });
62
+ tc = new JBTerminalConfig[](1);
63
+ tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: acc});
64
+
65
+ REVStageConfig[] memory stages = new REVStageConfig[](2);
66
+ JBSplit[] memory splits = new JBSplit[](1);
67
+ splits[0].beneficiary = payable(multisig());
68
+ splits[0].percent = 10_000;
69
+
70
+ // Stage 1: high cashOutTaxRate (60%)
71
+ stages[0] = REVStageConfig({
72
+ startsAtOrAfter: uint40(block.timestamp),
73
+ autoIssuances: new REVAutoIssuance[](0),
74
+ splitPercent: 0,
75
+ splits: splits,
76
+ initialIssuance: uint112(1000e18),
77
+ issuanceCutFrequency: 0,
78
+ issuanceCutPercent: 0,
79
+ cashOutTaxRate: 6000, // 60%
80
+ extraMetadata: 0
81
+ });
82
+
83
+ // Stage 2: low cashOutTaxRate (20%) — starts after 30 days
84
+ stages[1] = REVStageConfig({
85
+ startsAtOrAfter: uint40(block.timestamp + 30 days),
86
+ autoIssuances: new REVAutoIssuance[](0),
87
+ splitPercent: 0,
88
+ splits: splits,
89
+ initialIssuance: uint112(1000e18),
90
+ issuanceCutFrequency: 0,
91
+ issuanceCutPercent: 0,
92
+ cashOutTaxRate: 2000, // 20%
93
+ extraMetadata: 0
94
+ });
95
+
96
+ cfg = REVConfig({
97
+ description: REVDescription("StageTest", "STG", "ipfs://test", "STG_SALT"),
98
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
99
+ splitOperator: multisig(),
100
+ stageConfigurations: stages
101
+ });
102
+
103
+ sdc = REVSuckerDeploymentConfig({
104
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256(abi.encodePacked("STG"))
105
+ });
106
+ }
107
+
108
+ function setUp() public override {
109
+ super.setUp();
110
+ FEE_PROJECT_ID = jbProjects().createFor(multisig());
111
+ SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
112
+ HOOK_STORE = new JB721TiersHookStore();
113
+ EXAMPLE_HOOK = new JB721TiersHook(jbDirectory(), jbPermissions(), jbRulesets(), HOOK_STORE, multisig());
114
+ ADDRESS_REGISTRY = new JBAddressRegistry();
115
+ HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
116
+ PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
117
+ MOCK_BUYBACK = new MockBuybackDataHook();
118
+ LOANS_CONTRACT = new REVLoans({
119
+ controller: jbController(),
120
+ projects: jbProjects(),
121
+ revId: FEE_PROJECT_ID,
122
+ owner: address(this),
123
+ permit2: permit2(),
124
+ trustedForwarder: TRUSTED_FORWARDER
125
+ });
126
+ REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
127
+ jbController(),
128
+ SUCKER_REGISTRY,
129
+ FEE_PROJECT_ID,
130
+ HOOK_DEPLOYER,
131
+ PUBLISHER,
132
+ IJBRulesetDataHook(address(MOCK_BUYBACK)),
133
+ address(LOANS_CONTRACT),
134
+ TRUSTED_FORWARDER
135
+ );
136
+ vm.prank(multisig());
137
+ jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
138
+
139
+ // Deploy the fee project first.
140
+ (REVConfig memory feeCfg, JBTerminalConfig[] memory feeTc, REVSuckerDeploymentConfig memory feeSdc) =
141
+ _buildConfig();
142
+ vm.prank(multisig());
143
+ REV_DEPLOYER.deployFor({
144
+ revnetId: FEE_PROJECT_ID,
145
+ configuration: feeCfg,
146
+ terminalConfigurations: feeTc,
147
+ suckerDeploymentConfiguration: feeSdc
148
+ });
149
+
150
+ // Deploy the test revnet.
151
+ (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc) = _buildConfig();
152
+ cfg.description = REVDescription("StageTest2", "ST2", "ipfs://test2", "STG_SALT_2");
153
+ REVNET_ID = REV_DEPLOYER.deployFor({
154
+ revnetId: 0, configuration: cfg, terminalConfigurations: tc, suckerDeploymentConfiguration: sdc
155
+ });
156
+
157
+ vm.deal(USER, 100 ether);
158
+ }
159
+
160
+ /// @notice BY DESIGN: Borrowable amount increases when transitioning to a stage with lower cashOutTaxRate.
161
+ /// This documents that loan value tracks the current bonding curve, just as cash-out value does.
162
+ /// @dev The bonding curve only applies a tax discount when cashOutCount < totalSupply,
163
+ /// so we need multiple payers to see the effect.
164
+ function test_borrowableAmount_increasesWhenCashOutTaxRateDecreases() public {
165
+ // Two payers so the bonding curve tax rate has a visible effect (count < supply).
166
+ address otherPayer = makeAddr("otherPayer");
167
+ vm.deal(otherPayer, 10 ether);
168
+ vm.prank(otherPayer);
169
+ jbMultiTerminal().pay{value: 10 ether}({
170
+ projectId: REVNET_ID,
171
+ token: JBConstants.NATIVE_TOKEN,
172
+ amount: 10 ether,
173
+ beneficiary: otherPayer,
174
+ minReturnedTokens: 0,
175
+ memo: "",
176
+ metadata: ""
177
+ });
178
+
179
+ vm.prank(USER);
180
+ uint256 tokens = jbMultiTerminal().pay{value: 10 ether}({
181
+ projectId: REVNET_ID,
182
+ token: JBConstants.NATIVE_TOKEN,
183
+ amount: 10 ether,
184
+ beneficiary: USER,
185
+ minReturnedTokens: 0,
186
+ memo: "",
187
+ metadata: ""
188
+ });
189
+ assertGt(tokens, 0, "Should receive tokens");
190
+
191
+ // Check borrowable amount during stage 1 (60% cashOutTaxRate).
192
+ uint256 borrowableStage1 =
193
+ LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
194
+ assertGt(borrowableStage1, 0, "Borrowable amount should be positive in stage 1");
195
+
196
+ // Warp to stage 2 (20% cashOutTaxRate).
197
+ vm.warp(block.timestamp + 31 days);
198
+
199
+ // Check borrowable amount during stage 2.
200
+ uint256 borrowableStage2 =
201
+ LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
202
+
203
+ // Borrowable amount should be HIGHER with a lower cashOutTaxRate — by design.
204
+ assertGt(borrowableStage2, borrowableStage1, "Borrowable amount should increase with lower cashOutTaxRate");
205
+ }
206
+
207
+ /// @notice Verifies that the bonding curve formula applies the tax rate correctly when count < supply.
208
+ function test_borrowableAmount_taxRateReducesPartialCashOut() public {
209
+ // Two payers so USER holds a fraction of total supply.
210
+ address otherPayer = makeAddr("otherPayer");
211
+ vm.deal(otherPayer, 10 ether);
212
+ vm.prank(otherPayer);
213
+ jbMultiTerminal().pay{value: 10 ether}({
214
+ projectId: REVNET_ID,
215
+ token: JBConstants.NATIVE_TOKEN,
216
+ amount: 10 ether,
217
+ beneficiary: otherPayer,
218
+ minReturnedTokens: 0,
219
+ memo: "",
220
+ metadata: ""
221
+ });
222
+
223
+ vm.prank(USER);
224
+ uint256 tokens = jbMultiTerminal().pay{value: 10 ether}({
225
+ projectId: REVNET_ID,
226
+ token: JBConstants.NATIVE_TOKEN,
227
+ amount: 10 ether,
228
+ beneficiary: USER,
229
+ minReturnedTokens: 0,
230
+ memo: "",
231
+ metadata: ""
232
+ });
233
+
234
+ // With 60% tax rate and ~50% of supply, borrowable should be meaningfully less than pro-rata share.
235
+ uint256 borrowable =
236
+ LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
237
+ assertGt(borrowable, 0, "Borrowable amount should be positive");
238
+ // Pro-rata share would be ~10 ether (half of 20 ether surplus). With 60% tax, it should be less.
239
+ assertLt(borrowable, 10 ether, "Borrowable should be less than pro-rata share due to tax rate");
240
+ }
241
+ }
@@ -11,7 +11,7 @@ import {IREVLoans} from "../../src/interfaces/IREVLoans.sol";
11
11
  import {REVLoanSource} from "../../src/structs/REVLoanSource.sol";
12
12
 
13
13
  /// @notice A terminal that reverts on both pay() and addToBalanceOf().
14
- /// @dev Used to prove H-2: if the fee terminal breaks, cash-outs brick because
14
+ /// @dev If the fee terminal breaks, cash-outs brick because
15
15
  /// afterCashOutRecordedWith's fallback addToBalanceOf also reverts.
16
16
  contract BrokenFeeTerminal is ERC165, IJBPayoutTerminal {
17
17
  bool public payReverts = true;
@@ -119,7 +119,7 @@ contract BrokenFeeTerminal is ERC165, IJBPayoutTerminal {
119
119
  }
120
120
 
121
121
  /// @notice A terminal that attempts to addToBalance + borrow in a single tx.
122
- /// @dev Used to prove M-11: flash loan surplus inflation via live surplus read.
122
+ /// @dev Flash loan surplus inflation via live surplus read.
123
123
  contract SurplusInflator is ERC165, IJBPayoutTerminal {
124
124
  IREVLoans public loans;
125
125
  uint256 public revnetId;
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.26;
3
+
4
+ import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
5
+ import {IJBPayHook} from "@bananapus/core-v6/src/interfaces/IJBPayHook.sol";
6
+ import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforePayRecordedContext.sol";
7
+ import {JBBeforeCashOutRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforeCashOutRecordedContext.sol";
8
+ import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
9
+ import {JBCashOutHookSpecification} from "@bananapus/core-v6/src/structs/JBCashOutHookSpecification.sol";
10
+ import {JBAfterPayRecordedContext} from "@bananapus/core-v6/src/structs/JBAfterPayRecordedContext.sol";
11
+ import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
12
+ import {IUniswapV3Pool} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
13
+ import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
14
+
15
+ /// @notice Mock buyback hook that simulates the "mint path" — returns EMPTY hookSpecifications.
16
+ /// This is what the real JBBuybackHook does when direct minting is cheaper than swapping
17
+ /// (i.e., tokenCountWithoutHook >= minimumSwapAmountOut).
18
+ contract MockBuybackDataHookMintPath is IJBRulesetDataHook, IJBPayHook {
19
+ function beforePayRecordedWith(JBBeforePayRecordedContext calldata context)
20
+ external
21
+ view
22
+ override
23
+ returns (uint256 weight, JBPayHookSpecification[] memory hookSpecifications)
24
+ {
25
+ weight = context.weight;
26
+ // Return EMPTY hookSpecifications — simulating the mint path where no swap is needed.
27
+ hookSpecifications = new JBPayHookSpecification[](0);
28
+ }
29
+
30
+ function beforeCashOutRecordedWith(JBBeforeCashOutRecordedContext calldata context)
31
+ external
32
+ view
33
+ override
34
+ returns (
35
+ uint256 cashOutTaxRate,
36
+ uint256 cashOutCount,
37
+ uint256 totalSupply,
38
+ JBCashOutHookSpecification[] memory hookSpecifications
39
+ )
40
+ {
41
+ cashOutTaxRate = context.cashOutTaxRate;
42
+ cashOutCount = context.cashOutCount;
43
+ totalSupply = context.totalSupply;
44
+ hookSpecifications = new JBCashOutHookSpecification[](0);
45
+ }
46
+
47
+ function hasMintPermissionFor(uint256, JBRuleset calldata, address) external pure override returns (bool) {
48
+ return false;
49
+ }
50
+
51
+ function afterPayRecordedWith(JBAfterPayRecordedContext calldata) external payable override {}
52
+
53
+ function setPoolFor(uint256, uint24, uint256, address) external pure returns (IUniswapV3Pool) {
54
+ return IUniswapV3Pool(address(0));
55
+ }
56
+
57
+ function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
58
+ return interfaceId == type(IJBRulesetDataHook).interfaceId || interfaceId == type(IJBPayHook).interfaceId
59
+ || interfaceId == type(IERC165).interfaceId;
60
+ }
61
+ }