@rev-net/core-v6 0.0.12 → 0.0.13

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 (78) hide show
  1. package/AUDIT_INSTRUCTIONS.md +295 -0
  2. package/CHANGE_LOG.md +316 -0
  3. package/README.md +2 -2
  4. package/RISKS.md +180 -35
  5. package/SKILLS.md +1 -1
  6. package/USER_JOURNEYS.md +489 -0
  7. package/package.json +9 -9
  8. package/script/Deploy.s.sol +40 -6
  9. package/script/helpers/RevnetCoreDeploymentLib.sol +7 -1
  10. package/src/REVDeployer.sol +63 -47
  11. package/src/REVLoans.sol +51 -15
  12. package/src/interfaces/IREVDeployer.sol +0 -1
  13. package/src/structs/REV721TiersHookFlags.sol +1 -0
  14. package/src/structs/REVAutoIssuance.sol +1 -0
  15. package/src/structs/REVBaseline721HookConfig.sol +1 -0
  16. package/src/structs/REVConfig.sol +1 -0
  17. package/src/structs/REVCroptopAllowedPost.sol +1 -0
  18. package/src/structs/REVDeploy721TiersHookConfig.sol +1 -0
  19. package/src/structs/REVDescription.sol +1 -0
  20. package/src/structs/REVLoan.sol +1 -0
  21. package/src/structs/REVLoanSource.sol +1 -0
  22. package/src/structs/REVStageConfig.sol +1 -0
  23. package/src/structs/REVSuckerDeploymentConfig.sol +1 -0
  24. package/test/REV.integrations.t.sol +132 -12
  25. package/test/REVAutoIssuanceFuzz.t.sol +23 -3
  26. package/test/REVDeployerRegressions.t.sol +35 -4
  27. package/test/REVInvincibility.t.sol +58 -8
  28. package/test/REVInvincibilityHandler.sol +29 -0
  29. package/test/REVLifecycle.t.sol +28 -3
  30. package/test/REVLoans.invariants.t.sol +52 -5
  31. package/test/REVLoansAttacks.t.sol +43 -5
  32. package/test/REVLoansFeeRecovery.t.sol +50 -11
  33. package/test/REVLoansFindings.t.sol +27 -3
  34. package/test/REVLoansRegressions.t.sol +25 -3
  35. package/test/REVLoansSourceFeeRecovery.t.sol +491 -0
  36. package/test/REVLoansSourced.t.sol +56 -7
  37. package/test/REVLoansUnSourced.t.sol +49 -5
  38. package/test/TestBurnHeldTokens.t.sol +32 -5
  39. package/test/TestCEIPattern.t.sol +26 -2
  40. package/test/TestCashOutCallerValidation.t.sol +30 -4
  41. package/test/TestConversionDocumentation.t.sol +26 -5
  42. package/test/TestCrossCurrencyReclaim.t.sol +584 -0
  43. package/test/TestCrossSourceReallocation.t.sol +26 -2
  44. package/test/TestERC2771MetaTx.t.sol +557 -0
  45. package/test/TestEmptyBuybackSpecs.t.sol +23 -3
  46. package/test/TestFlashLoanSurplus.t.sol +28 -3
  47. package/test/TestHookArrayOOB.t.sol +24 -4
  48. package/test/TestLiquidationBehavior.t.sol +26 -3
  49. package/test/TestLoanSourceRotation.t.sol +525 -0
  50. package/test/TestLongTailEconomics.t.sol +651 -0
  51. package/test/TestLowFindings.t.sol +65 -2
  52. package/test/TestMixedFixes.t.sol +28 -3
  53. package/test/TestPermit2Signatures.t.sol +657 -0
  54. package/test/TestReallocationSandwich.t.sol +384 -0
  55. package/test/TestRevnetRegressions.t.sol +324 -0
  56. package/test/TestSplitWeightAdjustment.t.sol +24 -2
  57. package/test/TestSplitWeightE2E.t.sol +29 -2
  58. package/test/TestSplitWeightFork.t.sol +46 -7
  59. package/test/TestStageTransitionBorrowable.t.sol +24 -2
  60. package/test/TestSwapTerminalPermission.t.sol +23 -3
  61. package/test/TestUint112Overflow.t.sol +28 -2
  62. package/test/TestZeroRepayment.t.sol +26 -2
  63. package/test/fork/ForkTestBase.sol +46 -3
  64. package/test/fork/TestCashOutFork.t.sol +1 -1
  65. package/test/fork/TestLoanBorrowFork.t.sol +1 -0
  66. package/test/fork/TestLoanCrossRulesetFork.t.sol +3 -1
  67. package/test/fork/TestLoanLiquidationFork.t.sol +1 -0
  68. package/test/fork/TestLoanReallocateFork.t.sol +1 -0
  69. package/test/fork/TestLoanRepayFork.t.sol +1 -0
  70. package/test/fork/TestLoanTransferFork.t.sol +133 -0
  71. package/test/fork/TestSplitWeightFork.t.sol +3 -0
  72. package/test/helpers/REVEmpty721Config.sol +1 -0
  73. package/test/mock/MockBuybackDataHook.sol +1 -0
  74. package/test/regression/TestBurnPermissionRequired.t.sol +267 -0
  75. package/test/regression/TestCrossRevnetLiquidation.t.sol +228 -0
  76. package/test/regression/TestCumulativeLoanCounter.t.sol +27 -4
  77. package/test/regression/TestLiquidateGapHandling.t.sol +29 -4
  78. package/test/regression/TestZeroPriceFeed.t.sol +396 -0
@@ -0,0 +1,584 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.26;
3
+
4
+ // forge-lint: disable-next-line(unaliased-plain-import)
5
+ import "forge-std/Test.sol";
6
+ // forge-lint: disable-next-line(unaliased-plain-import)
7
+ import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
8
+ // forge-lint: disable-next-line(unaliased-plain-import)
9
+ import /* {*} from */ "./../src/REVDeployer.sol";
10
+ // forge-lint: disable-next-line(unaliased-plain-import)
11
+ import "@croptop/core-v6/src/CTPublisher.sol";
12
+ import {MockBuybackDataHook} from "./mock/MockBuybackDataHook.sol";
13
+ // forge-lint: disable-next-line(unaliased-plain-import)
14
+ import "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
15
+ // forge-lint: disable-next-line(unaliased-plain-import)
16
+ import "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
17
+ // forge-lint: disable-next-line(unaliased-plain-import)
18
+ import "@bananapus/suckers-v6/script/helpers/SuckerDeploymentLib.sol";
19
+ // forge-lint: disable-next-line(unaliased-plain-import)
20
+ import "@croptop/core-v6/script/helpers/CroptopDeploymentLib.sol";
21
+ // forge-lint: disable-next-line(unaliased-plain-import)
22
+ import "@bananapus/router-terminal-v6/script/helpers/RouterTerminalDeploymentLib.sol";
23
+
24
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
25
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
26
+ import {MockPriceFeed} from "@bananapus/core-v6/test/mock/MockPriceFeed.sol";
27
+ import {MockERC20} from "@bananapus/core-v6/test/mock/MockERC20.sol";
28
+ import {REVLoans} from "../src/REVLoans.sol";
29
+ import {REVStageConfig, REVAutoIssuance} from "../src/structs/REVStageConfig.sol";
30
+ import {REVDescription} from "../src/structs/REVDescription.sol";
31
+ import {IREVLoans} from "./../src/interfaces/IREVLoans.sol";
32
+ import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
33
+ import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
34
+ import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
35
+ import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
36
+ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
37
+ import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
38
+ import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
39
+ import {REVEmpty721Config} from "./helpers/REVEmpty721Config.sol";
40
+
41
+ /// @notice Cross-currency reclaim tests: verify cash-out behavior when a revnet's baseCurrency differs from the
42
+ /// terminal token currency, and when price feeds return various values.
43
+ contract TestCrossCurrencyReclaim is TestBaseWorkflow {
44
+ // forge-lint: disable-next-line(mixed-case-variable)
45
+ bytes32 REV_DEPLOYER_SALT = "REVDeployer";
46
+
47
+ // forge-lint: disable-next-line(mixed-case-variable)
48
+ REVDeployer REV_DEPLOYER;
49
+ // forge-lint: disable-next-line(mixed-case-variable)
50
+ JB721TiersHook EXAMPLE_HOOK;
51
+ // forge-lint: disable-next-line(mixed-case-variable)
52
+ IJB721TiersHookDeployer HOOK_DEPLOYER;
53
+ // forge-lint: disable-next-line(mixed-case-variable)
54
+ IJB721TiersHookStore HOOK_STORE;
55
+ // forge-lint: disable-next-line(mixed-case-variable)
56
+ IJBAddressRegistry ADDRESS_REGISTRY;
57
+ // forge-lint: disable-next-line(mixed-case-variable)
58
+ IREVLoans LOANS_CONTRACT;
59
+ // forge-lint: disable-next-line(mixed-case-variable)
60
+ MockERC20 TOKEN;
61
+ // forge-lint: disable-next-line(mixed-case-variable)
62
+ IJBSuckerRegistry SUCKER_REGISTRY;
63
+ // forge-lint: disable-next-line(mixed-case-variable)
64
+ CTPublisher PUBLISHER;
65
+ // forge-lint: disable-next-line(mixed-case-variable)
66
+ MockBuybackDataHook MOCK_BUYBACK;
67
+
68
+ // forge-lint: disable-next-line(mixed-case-variable)
69
+ uint256 FEE_PROJECT_ID;
70
+ // forge-lint: disable-next-line(mixed-case-variable)
71
+ uint256 REVNET_ID;
72
+
73
+ // forge-lint: disable-next-line(mixed-case-variable)
74
+ address USER1 = makeAddr("user1");
75
+ // forge-lint: disable-next-line(mixed-case-variable)
76
+ address USER2 = makeAddr("user2");
77
+
78
+ address private constant TRUSTED_FORWARDER = 0xB2b5841DBeF766d4b521221732F9B618fCf34A87;
79
+
80
+ function setUp() public override {
81
+ super.setUp();
82
+
83
+ FEE_PROJECT_ID = jbProjects().createFor(multisig());
84
+
85
+ SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
86
+ HOOK_STORE = new JB721TiersHookStore();
87
+ EXAMPLE_HOOK = new JB721TiersHook(
88
+ jbDirectory(), jbPermissions(), jbPrices(), jbRulesets(), HOOK_STORE, jbSplits(), multisig()
89
+ );
90
+ ADDRESS_REGISTRY = new JBAddressRegistry();
91
+ HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
92
+ PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
93
+ MOCK_BUYBACK = new MockBuybackDataHook();
94
+
95
+ // Deploy a 6-decimal ERC-20 token that will be used as a terminal token.
96
+ TOKEN = new MockERC20("USD Coin", "USDC");
97
+
98
+ LOANS_CONTRACT = new REVLoans({
99
+ controller: jbController(),
100
+ projects: jbProjects(),
101
+ revId: FEE_PROJECT_ID,
102
+ owner: address(this),
103
+ permit2: permit2(),
104
+ trustedForwarder: TRUSTED_FORWARDER
105
+ });
106
+
107
+ REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
108
+ jbController(),
109
+ SUCKER_REGISTRY,
110
+ FEE_PROJECT_ID,
111
+ HOOK_DEPLOYER,
112
+ PUBLISHER,
113
+ IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
114
+ address(LOANS_CONTRACT),
115
+ TRUSTED_FORWARDER
116
+ );
117
+
118
+ vm.prank(multisig());
119
+ jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
120
+
121
+ // Fund users.
122
+ vm.deal(USER1, 100e18);
123
+ vm.deal(USER2, 100e18);
124
+ }
125
+
126
+ /// @notice Deploy the fee project (required as revnet ID 1 to receive fees).
127
+ function _deployFeeProject() internal {
128
+ JBAccountingContext[] memory acc = new JBAccountingContext[](2);
129
+ acc[0] = JBAccountingContext({
130
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
131
+ });
132
+ acc[1] = JBAccountingContext({token: address(TOKEN), decimals: 6, currency: uint32(uint160(address(TOKEN)))});
133
+ JBTerminalConfig[] memory tc = new JBTerminalConfig[](1);
134
+ tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: acc});
135
+
136
+ JBSplit[] memory splits = new JBSplit[](1);
137
+ splits[0].beneficiary = payable(multisig());
138
+ splits[0].percent = 10_000;
139
+
140
+ REVStageConfig[] memory stages = new REVStageConfig[](1);
141
+ stages[0] = REVStageConfig({
142
+ startsAtOrAfter: uint40(block.timestamp),
143
+ autoIssuances: new REVAutoIssuance[](0),
144
+ splitPercent: 0,
145
+ splits: splits,
146
+ initialIssuance: uint112(1000e18),
147
+ issuanceCutFrequency: 90 days,
148
+ issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
149
+ cashOutTaxRate: 5000,
150
+ extraMetadata: 0
151
+ });
152
+
153
+ REVConfig memory cfg = REVConfig({
154
+ // forge-lint: disable-next-line(named-struct-fields)
155
+ description: REVDescription("Revnet", "$REV", "ipfs://fee", "REV_TOKEN"),
156
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
157
+ splitOperator: multisig(),
158
+ stageConfigurations: stages
159
+ });
160
+
161
+ vm.prank(multisig());
162
+ REV_DEPLOYER.deployFor({
163
+ revnetId: FEE_PROJECT_ID,
164
+ configuration: cfg,
165
+ terminalConfigurations: tc,
166
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
167
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256("FEE")
168
+ }),
169
+ tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
170
+ allowedPosts: REVEmpty721Config.emptyAllowedPosts()
171
+ });
172
+ }
173
+
174
+ /// @notice Deploy a revnet that uses ETH as baseCurrency and accepts both ETH and TOKEN.
175
+ function _deployEthBasedRevnet(uint16 cashOutTaxRate) internal returns (uint256 revnetId) {
176
+ JBAccountingContext[] memory acc = new JBAccountingContext[](2);
177
+ acc[0] = JBAccountingContext({
178
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
179
+ });
180
+ acc[1] = JBAccountingContext({token: address(TOKEN), decimals: 6, currency: uint32(uint160(address(TOKEN)))});
181
+ JBTerminalConfig[] memory tc = new JBTerminalConfig[](1);
182
+ tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: acc});
183
+
184
+ JBSplit[] memory splits = new JBSplit[](1);
185
+ splits[0].beneficiary = payable(multisig());
186
+ splits[0].percent = 10_000;
187
+
188
+ REVStageConfig[] memory stages = new REVStageConfig[](1);
189
+ stages[0] = REVStageConfig({
190
+ startsAtOrAfter: uint40(block.timestamp),
191
+ autoIssuances: new REVAutoIssuance[](0),
192
+ splitPercent: 0,
193
+ splits: splits,
194
+ initialIssuance: uint112(1000e18),
195
+ issuanceCutFrequency: 0,
196
+ issuanceCutPercent: 0,
197
+ cashOutTaxRate: cashOutTaxRate,
198
+ extraMetadata: 0
199
+ });
200
+
201
+ REVConfig memory cfg = REVConfig({
202
+ // forge-lint: disable-next-line(named-struct-fields)
203
+ description: REVDescription("CrossCurrency", "XCRCY", "ipfs://cross", "XCRCY_TOKEN"),
204
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
205
+ splitOperator: multisig(),
206
+ stageConfigurations: stages
207
+ });
208
+
209
+ (revnetId,) = REV_DEPLOYER.deployFor({
210
+ revnetId: 0,
211
+ configuration: cfg,
212
+ terminalConfigurations: tc,
213
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
214
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256("CROSS")
215
+ }),
216
+ tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
217
+ allowedPosts: REVEmpty721Config.emptyAllowedPosts()
218
+ });
219
+ }
220
+
221
+ //*********************************************************************//
222
+ // --- Cross-Currency Reclaim Tests --------------------------------- //
223
+ //*********************************************************************//
224
+
225
+ /// @notice Pay with ETH (matching baseCurrency), then cash out. Baseline: no cross-currency conversion needed.
226
+ function test_cashOut_sameAsBaseCurrency() public {
227
+ // Set up a price feed so the TOKEN is recognized.
228
+ MockPriceFeed priceFeed = new MockPriceFeed(2000e18, 18); // 1 ETH = 2000 TOKEN
229
+ vm.prank(multisig());
230
+ jbPrices()
231
+ .addPriceFeedFor(0, uint32(uint160(JBConstants.NATIVE_TOKEN)), uint32(uint160(address(TOKEN))), priceFeed);
232
+
233
+ _deployFeeProject();
234
+ REVNET_ID = _deployEthBasedRevnet(5000); // 50% cash out tax
235
+
236
+ // User1 pays 5 ETH.
237
+ vm.prank(USER1);
238
+ uint256 tokens = jbMultiTerminal().pay{value: 5e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 5e18, USER1, 0, "", "");
239
+ assertGt(tokens, 0, "should receive tokens");
240
+
241
+ // Cash out all tokens.
242
+ vm.prank(USER1);
243
+ uint256 reclaimed = jbMultiTerminal()
244
+ .cashOutTokensOf({
245
+ holder: USER1,
246
+ projectId: REVNET_ID,
247
+ cashOutCount: tokens,
248
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
249
+ minTokensReclaimed: 0,
250
+ beneficiary: payable(USER1),
251
+ metadata: ""
252
+ });
253
+
254
+ assertGt(reclaimed, 0, "should reclaim some ETH");
255
+ // With 50% tax and single holder cashing out everything, the bonding curve returns the full surplus.
256
+ // But the fee (2.5%) is deducted from the cash out count, so the reclaimed amount is less.
257
+ assertLe(reclaimed, 5e18, "should not exceed total paid in");
258
+ }
259
+
260
+ /// @notice Pay with TOKEN (different from baseCurrency=ETH), then cash out in TOKEN.
261
+ /// The surplus is aggregated cross-currency via price feeds.
262
+ function test_cashOut_crossCurrency_payWithToken_cashOutToken() public {
263
+ // Price feed: 1 TOKEN (6 dec) = 0.0005 ETH (18 dec). Meaning 2000 TOKEN = 1 ETH.
264
+ MockPriceFeed priceFeed = new MockPriceFeed(5e14, 18); // 0.0005 ETH per TOKEN unit
265
+ vm.prank(multisig());
266
+ jbPrices()
267
+ .addPriceFeedFor(0, uint32(uint160(address(TOKEN))), uint32(uint160(JBConstants.NATIVE_TOKEN)), priceFeed);
268
+
269
+ _deployFeeProject();
270
+ REVNET_ID = _deployEthBasedRevnet(5000); // 50% cash out tax
271
+
272
+ // Mint TOKEN to USER1 and approve.
273
+ uint256 tokenAmount = 10_000e6; // 10,000 USDC-like
274
+ TOKEN.mint(USER1, tokenAmount);
275
+ vm.prank(USER1);
276
+ TOKEN.approve(address(jbMultiTerminal()), tokenAmount);
277
+
278
+ // Pay with TOKEN.
279
+ vm.prank(USER1);
280
+ uint256 revTokens = jbMultiTerminal().pay(REVNET_ID, address(TOKEN), tokenAmount, USER1, 0, "", "");
281
+ assertGt(revTokens, 0, "should receive revnet tokens from TOKEN payment");
282
+
283
+ // Cash out in TOKEN.
284
+ vm.prank(USER1);
285
+ uint256 reclaimedToken = jbMultiTerminal()
286
+ .cashOutTokensOf({
287
+ holder: USER1,
288
+ projectId: REVNET_ID,
289
+ cashOutCount: revTokens,
290
+ tokenToReclaim: address(TOKEN),
291
+ minTokensReclaimed: 0,
292
+ beneficiary: payable(USER1),
293
+ metadata: ""
294
+ });
295
+
296
+ assertGt(reclaimedToken, 0, "should reclaim some TOKEN");
297
+ assertLe(reclaimedToken, tokenAmount, "should not exceed total TOKEN paid in");
298
+ }
299
+
300
+ /// @notice Pay with ETH, then try to cash out in TOKEN. When the cross-currency surplus (ETH converted to TOKEN
301
+ /// terms) exceeds the actual TOKEN balance in the terminal, the cash out reverts with
302
+ /// InadequateTerminalStoreBalance. This is correct behavior: you cannot withdraw more of a token than the
303
+ /// terminal actually holds, even if the aggregated surplus in that currency is higher.
304
+ function test_cashOut_crossCurrency_payEth_cashOutToken_insufficientBalance_reverts() public {
305
+ // Price feed: 1 ETH = 2000 TOKEN (6 dec units).
306
+ MockPriceFeed priceFeed = new MockPriceFeed(2000e6, 6); // 2000 TOKEN per ETH
307
+ vm.prank(multisig());
308
+ jbPrices()
309
+ .addPriceFeedFor(0, uint32(uint160(JBConstants.NATIVE_TOKEN)), uint32(uint160(address(TOKEN))), priceFeed);
310
+
311
+ _deployFeeProject();
312
+ REVNET_ID = _deployEthBasedRevnet(5000); // 50% cash out tax
313
+
314
+ // Seed the terminal with exactly enough TOKEN to just barely cover the surplus.
315
+ // After 5 ETH payment, the surplus in TOKEN terms includes both the TOKEN balance and the
316
+ // ETH balance converted to TOKEN (5 ETH * 2000 = 10,000 TOKEN), which when combined with
317
+ // fee overhead will exceed what we seed.
318
+ uint256 tokenSeed = 100e6; // Small seed -- deliberately insufficient.
319
+ TOKEN.mint(address(this), tokenSeed);
320
+ TOKEN.approve(address(jbMultiTerminal()), tokenSeed);
321
+ jbMultiTerminal().addToBalanceOf(REVNET_ID, address(TOKEN), tokenSeed, false, "", "");
322
+
323
+ // User1 pays 5 ETH.
324
+ vm.prank(USER1);
325
+ uint256 revTokens =
326
+ jbMultiTerminal().pay{value: 5e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 5e18, USER1, 0, "", "");
327
+ assertGt(revTokens, 0, "should receive revnet tokens");
328
+
329
+ // Trying to cash out in TOKEN should revert because the bonding curve reclaim amount
330
+ // (based on cross-currency total surplus) exceeds the actual TOKEN balance.
331
+ vm.prank(USER1);
332
+ vm.expectRevert();
333
+ jbMultiTerminal()
334
+ .cashOutTokensOf({
335
+ holder: USER1,
336
+ projectId: REVNET_ID,
337
+ cashOutCount: revTokens,
338
+ tokenToReclaim: address(TOKEN),
339
+ minTokensReclaimed: 0,
340
+ beneficiary: payable(USER1),
341
+ metadata: ""
342
+ });
343
+ }
344
+
345
+ /// @notice Pay with TOKEN (so the surplus is in TOKEN), then pay with ETH too.
346
+ /// Cash out in TOKEN should succeed because the TOKEN balance in the terminal is sufficient
347
+ /// to cover the reclaim amount.
348
+ function test_cashOut_crossCurrency_payBothThenCashOutInToken() public {
349
+ // Price feed: TOKEN -> ETH. 1 TOKEN (6 dec) = 0.0005 ETH. So 2000 TOKEN = 1 ETH.
350
+ MockPriceFeed priceFeed = new MockPriceFeed(5e14, 18);
351
+ vm.prank(multisig());
352
+ jbPrices()
353
+ .addPriceFeedFor(0, uint32(uint160(address(TOKEN))), uint32(uint160(JBConstants.NATIVE_TOKEN)), priceFeed);
354
+
355
+ _deployFeeProject();
356
+ REVNET_ID = _deployEthBasedRevnet(5000); // 50% cash out tax
357
+
358
+ // User1 pays with TOKEN (provides actual TOKEN liquidity to the terminal).
359
+ uint256 tokenPayment = 100_000e6; // 100,000 TOKEN = 50 ETH equivalent.
360
+ TOKEN.mint(USER1, tokenPayment);
361
+ vm.prank(USER1);
362
+ TOKEN.approve(address(jbMultiTerminal()), tokenPayment);
363
+ vm.prank(USER1);
364
+ uint256 revTokens = jbMultiTerminal().pay(REVNET_ID, address(TOKEN), tokenPayment, USER1, 0, "", "");
365
+ assertGt(revTokens, 0, "should receive revnet tokens from TOKEN payment");
366
+
367
+ // Cash out a small portion in TOKEN. Since the TOKEN was paid in, the terminal has the balance.
368
+ uint256 cashOutCount = revTokens / 10; // Only cash out 10% to ensure balance sufficiency.
369
+ vm.prank(USER1);
370
+ uint256 reclaimedToken = jbMultiTerminal()
371
+ .cashOutTokensOf({
372
+ holder: USER1,
373
+ projectId: REVNET_ID,
374
+ cashOutCount: cashOutCount,
375
+ tokenToReclaim: address(TOKEN),
376
+ minTokensReclaimed: 0,
377
+ beneficiary: payable(USER1),
378
+ metadata: ""
379
+ });
380
+
381
+ assertGt(reclaimedToken, 0, "should reclaim some TOKEN");
382
+ assertLe(reclaimedToken, tokenPayment, "should not exceed total TOKEN paid in");
383
+ }
384
+
385
+ /// @notice Pay with both ETH and TOKEN from two users, then one cashes out.
386
+ /// Ensures both token types generate tokens and the cross-currency surplus contributes to reclaim.
387
+ function test_cashOut_crossCurrency_mixedPayments() public {
388
+ // Price feed: TOKEN -> ETH (for surplus aggregation).
389
+ // 1 TOKEN (6 dec) = 0.0005 ETH (18 dec). So 2000 TOKEN = 1 ETH.
390
+ MockPriceFeed priceFeed = new MockPriceFeed(5e14, 18);
391
+ vm.prank(multisig());
392
+ jbPrices()
393
+ .addPriceFeedFor(0, uint32(uint160(address(TOKEN))), uint32(uint160(JBConstants.NATIVE_TOKEN)), priceFeed);
394
+
395
+ _deployFeeProject();
396
+ REVNET_ID = _deployEthBasedRevnet(5000); // 50% cash out tax
397
+
398
+ // User1 pays 5 ETH.
399
+ vm.prank(USER1);
400
+ uint256 tokens1 =
401
+ jbMultiTerminal().pay{value: 5e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 5e18, USER1, 0, "", "");
402
+ assertGt(tokens1, 0, "ETH payment should mint tokens");
403
+
404
+ // User2 pays 10,000 TOKEN (= 5 ETH at the feed rate).
405
+ uint256 tokenAmount = 10_000e6;
406
+ TOKEN.mint(USER2, tokenAmount);
407
+ vm.prank(USER2);
408
+ TOKEN.approve(address(jbMultiTerminal()), tokenAmount);
409
+ vm.prank(USER2);
410
+ uint256 tokens2 = jbMultiTerminal().pay(REVNET_ID, address(TOKEN), tokenAmount, USER2, 0, "", "");
411
+ assertGt(tokens2, 0, "TOKEN payment should mint tokens");
412
+
413
+ // Both payments should have contributed to the surplus.
414
+ // When User1 cashes out in ETH, the reclaimed amount should reflect the total surplus
415
+ // including the TOKEN contribution (converted via price feed).
416
+ vm.prank(USER1);
417
+ uint256 reclaimed = jbMultiTerminal()
418
+ .cashOutTokensOf({
419
+ holder: USER1,
420
+ projectId: REVNET_ID,
421
+ cashOutCount: tokens1,
422
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
423
+ minTokensReclaimed: 0,
424
+ beneficiary: payable(USER1),
425
+ metadata: ""
426
+ });
427
+ assertGt(reclaimed, 0, "should reclaim ETH from mixed-currency surplus");
428
+
429
+ // The reclaimed amount should be less than the original payment (due to bonding curve tax).
430
+ assertLt(reclaimed, 5e18, "with 50% tax and other holders, reclaim should be less than paid");
431
+ }
432
+
433
+ /// @notice Rounding sanity: a tiny payment (1 wei) should not produce disproportionate reclaim.
434
+ function test_cashOut_crossCurrency_tinyPayment_noRoundingExploit() public {
435
+ // Price feed: TOKEN -> ETH.
436
+ MockPriceFeed priceFeed = new MockPriceFeed(5e14, 18);
437
+ vm.prank(multisig());
438
+ jbPrices()
439
+ .addPriceFeedFor(0, uint32(uint160(address(TOKEN))), uint32(uint160(JBConstants.NATIVE_TOKEN)), priceFeed);
440
+
441
+ _deployFeeProject();
442
+ REVNET_ID = _deployEthBasedRevnet(5000); // 50% tax
443
+
444
+ // User1 pays 1 wei of ETH.
445
+ vm.prank(USER1);
446
+ uint256 tokens = jbMultiTerminal().pay{value: 1}(REVNET_ID, JBConstants.NATIVE_TOKEN, 1, USER1, 0, "", "");
447
+
448
+ if (tokens == 0) {
449
+ // No tokens minted for dust payment -- this is acceptable.
450
+ return;
451
+ }
452
+
453
+ // Cash out should not return more than 1 wei.
454
+ vm.prank(USER1);
455
+ uint256 reclaimed = jbMultiTerminal()
456
+ .cashOutTokensOf({
457
+ holder: USER1,
458
+ projectId: REVNET_ID,
459
+ cashOutCount: tokens,
460
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
461
+ minTokensReclaimed: 0,
462
+ beneficiary: payable(USER1),
463
+ metadata: ""
464
+ });
465
+
466
+ assertLe(reclaimed, 1, "tiny payment should not yield more than original amount");
467
+ }
468
+
469
+ /// @notice With a very high price feed (1 TOKEN = 1,000,000 ETH), verify the system handles extreme conversion
470
+ /// without overflow or unexpected results.
471
+ function test_cashOut_crossCurrency_extremeHighPriceFeed() public {
472
+ // 1 TOKEN unit (6 dec) = 1,000,000 ETH (18 dec). This is an extreme price.
473
+ MockPriceFeed priceFeed = new MockPriceFeed(1_000_000e18, 18);
474
+ vm.prank(multisig());
475
+ jbPrices()
476
+ .addPriceFeedFor(0, uint32(uint160(address(TOKEN))), uint32(uint160(JBConstants.NATIVE_TOKEN)), priceFeed);
477
+
478
+ _deployFeeProject();
479
+ REVNET_ID = _deployEthBasedRevnet(5000);
480
+
481
+ // Pay a small amount of TOKEN.
482
+ uint256 tokenAmount = 1e6; // 1 TOKEN
483
+ TOKEN.mint(USER1, tokenAmount);
484
+ vm.prank(USER1);
485
+ TOKEN.approve(address(jbMultiTerminal()), tokenAmount);
486
+ vm.prank(USER1);
487
+ uint256 revTokens = jbMultiTerminal().pay(REVNET_ID, address(TOKEN), tokenAmount, USER1, 0, "", "");
488
+ assertGt(revTokens, 0, "should mint tokens even with extreme price feed");
489
+
490
+ // Cash out in TOKEN.
491
+ vm.prank(USER1);
492
+ uint256 reclaimed = jbMultiTerminal()
493
+ .cashOutTokensOf({
494
+ holder: USER1,
495
+ projectId: REVNET_ID,
496
+ cashOutCount: revTokens,
497
+ tokenToReclaim: address(TOKEN),
498
+ minTokensReclaimed: 0,
499
+ beneficiary: payable(USER1),
500
+ metadata: ""
501
+ });
502
+
503
+ // Should reclaim some TOKEN (bounded by the original payment amount).
504
+ assertLe(reclaimed, tokenAmount, "should not exceed original TOKEN payment");
505
+ }
506
+
507
+ /// @notice With a low price feed (1 TOKEN = 0.000001 ETH), verify cash out works.
508
+ /// Below a certain threshold, the price-to-surplus conversion can cause arithmetic issues.
509
+ function test_cashOut_crossCurrency_lowPriceFeed() public {
510
+ // 1 TOKEN unit (6 dec) = 0.000001 ETH (1e12 wei). Low value token.
511
+ MockPriceFeed priceFeed = new MockPriceFeed(1e12, 18);
512
+ vm.prank(multisig());
513
+ jbPrices()
514
+ .addPriceFeedFor(0, uint32(uint160(address(TOKEN))), uint32(uint160(JBConstants.NATIVE_TOKEN)), priceFeed);
515
+
516
+ _deployFeeProject();
517
+ REVNET_ID = _deployEthBasedRevnet(3000); // 30% cash out tax
518
+
519
+ // Pay a large amount of TOKEN.
520
+ uint256 tokenAmount = 1_000_000e6; // 1M TOKEN
521
+ TOKEN.mint(USER1, tokenAmount);
522
+ vm.prank(USER1);
523
+ TOKEN.approve(address(jbMultiTerminal()), tokenAmount);
524
+ vm.prank(USER1);
525
+ uint256 revTokens = jbMultiTerminal().pay(REVNET_ID, address(TOKEN), tokenAmount, USER1, 0, "", "");
526
+
527
+ if (revTokens == 0) {
528
+ // Token is so cheap that zero tokens were minted. Valid behavior.
529
+ return;
530
+ }
531
+
532
+ // Cash out in TOKEN.
533
+ vm.prank(USER1);
534
+ uint256 reclaimed = jbMultiTerminal()
535
+ .cashOutTokensOf({
536
+ holder: USER1,
537
+ projectId: REVNET_ID,
538
+ cashOutCount: revTokens,
539
+ tokenToReclaim: address(TOKEN),
540
+ minTokensReclaimed: 0,
541
+ beneficiary: payable(USER1),
542
+ metadata: ""
543
+ });
544
+
545
+ assertLe(reclaimed, tokenAmount, "should not exceed total TOKEN paid");
546
+ }
547
+
548
+ /// @notice Cash out with zero tax rate. Single holder should reclaim the full surplus minus fees.
549
+ function test_cashOut_crossCurrency_zeroTax() public {
550
+ // Price feed: TOKEN -> ETH.
551
+ MockPriceFeed priceFeed = new MockPriceFeed(5e14, 18); // 0.0005 ETH per TOKEN
552
+ vm.prank(multisig());
553
+ jbPrices()
554
+ .addPriceFeedFor(0, uint32(uint160(address(TOKEN))), uint32(uint160(JBConstants.NATIVE_TOKEN)), priceFeed);
555
+
556
+ _deployFeeProject();
557
+ REVNET_ID = _deployEthBasedRevnet(0); // 0% cash out tax
558
+
559
+ // User1 pays 10 ETH.
560
+ vm.prank(USER1);
561
+ uint256 tokens =
562
+ jbMultiTerminal().pay{value: 10e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 10e18, USER1, 0, "", "");
563
+
564
+ // Record terminal balance before cash out.
565
+ uint256 balanceBefore =
566
+ jbTerminalStore().balanceOf(address(jbMultiTerminal()), REVNET_ID, JBConstants.NATIVE_TOKEN);
567
+
568
+ // Cash out all tokens. With 0% tax + sole holder, the bonding curve returns full surplus.
569
+ vm.prank(USER1);
570
+ uint256 reclaimed = jbMultiTerminal()
571
+ .cashOutTokensOf({
572
+ holder: USER1,
573
+ projectId: REVNET_ID,
574
+ cashOutCount: tokens,
575
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
576
+ minTokensReclaimed: 0,
577
+ beneficiary: payable(USER1),
578
+ metadata: ""
579
+ });
580
+
581
+ // With 0% cash out tax, no fee is charged on cash outs (per REVDeployer.beforeCashOutRecordedWith).
582
+ assertEq(reclaimed, balanceBefore, "0% tax, single holder should reclaim full balance");
583
+ }
584
+ }
@@ -1,16 +1,25 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity 0.8.26;
3
3
 
4
+ // forge-lint: disable-next-line(unaliased-plain-import)
4
5
  import "forge-std/Test.sol";
6
+ // forge-lint: disable-next-line(unaliased-plain-import)
5
7
  import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
6
- import /* {*} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
8
+ // import /* {*} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
9
+ // forge-lint: disable-next-line(unaliased-plain-import)
7
10
  import /* {*} from */ "./../src/REVDeployer.sol";
11
+ // forge-lint: disable-next-line(unaliased-plain-import)
8
12
  import "@croptop/core-v6/src/CTPublisher.sol";
9
13
  import {MockBuybackDataHook} from "./mock/MockBuybackDataHook.sol";
14
+ // forge-lint: disable-next-line(unaliased-plain-import)
10
15
  import "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
16
+ // forge-lint: disable-next-line(unaliased-plain-import)
11
17
  import "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
18
+ // forge-lint: disable-next-line(unaliased-plain-import)
12
19
  import "@bananapus/suckers-v6/script/helpers/SuckerDeploymentLib.sol";
20
+ // forge-lint: disable-next-line(unaliased-plain-import)
13
21
  import "@croptop/core-v6/script/helpers/CroptopDeploymentLib.sol";
22
+ // forge-lint: disable-next-line(unaliased-plain-import)
14
23
  import "@bananapus/router-terminal-v6/script/helpers/RouterTerminalDeploymentLib.sol";
15
24
  import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
16
25
  import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
@@ -30,26 +39,39 @@ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStor
30
39
  import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
31
40
  import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
32
41
  import {REVEmpty721Config} from "./helpers/REVEmpty721Config.sol";
33
- import {REVCroptopAllowedPost} from "../src/structs/REVCroptopAllowedPost.sol";
34
42
 
35
43
  /// @notice Tests for PR #13: cross-source reallocation prevention.
36
44
  contract TestCrossSourceReallocation is TestBaseWorkflow {
45
+ // forge-lint: disable-next-line(mixed-case-variable)
37
46
  bytes32 REV_DEPLOYER_SALT = "REVDeployer";
38
47
 
48
+ // forge-lint: disable-next-line(mixed-case-variable)
39
49
  REVDeployer REV_DEPLOYER;
50
+ // forge-lint: disable-next-line(mixed-case-variable)
40
51
  JB721TiersHook EXAMPLE_HOOK;
52
+ // forge-lint: disable-next-line(mixed-case-variable)
41
53
  IJB721TiersHookDeployer HOOK_DEPLOYER;
54
+ // forge-lint: disable-next-line(mixed-case-variable)
42
55
  IJB721TiersHookStore HOOK_STORE;
56
+ // forge-lint: disable-next-line(mixed-case-variable)
43
57
  IJBAddressRegistry ADDRESS_REGISTRY;
58
+ // forge-lint: disable-next-line(mixed-case-variable)
44
59
  IREVLoans LOANS_CONTRACT;
60
+ // forge-lint: disable-next-line(mixed-case-variable)
45
61
  MockERC20 TOKEN;
62
+ // forge-lint: disable-next-line(mixed-case-variable)
46
63
  IJBSuckerRegistry SUCKER_REGISTRY;
64
+ // forge-lint: disable-next-line(mixed-case-variable)
47
65
  CTPublisher PUBLISHER;
66
+ // forge-lint: disable-next-line(mixed-case-variable)
48
67
  MockBuybackDataHook MOCK_BUYBACK;
49
68
 
69
+ // forge-lint: disable-next-line(mixed-case-variable)
50
70
  uint256 FEE_PROJECT_ID;
71
+ // forge-lint: disable-next-line(mixed-case-variable)
51
72
  uint256 REVNET_ID;
52
73
 
74
+ // forge-lint: disable-next-line(mixed-case-variable)
53
75
  address USER = makeAddr("user");
54
76
 
55
77
  address private constant TRUSTED_FORWARDER = 0xB2b5841DBeF766d4b521221732F9B618fCf34A87;
@@ -122,6 +144,7 @@ contract TestCrossSourceReallocation is TestBaseWorkflow {
122
144
  extraMetadata: 0
123
145
  });
124
146
  REVConfig memory cfg = REVConfig({
147
+ // forge-lint: disable-next-line(named-struct-fields)
125
148
  description: REVDescription("Revnet", "$REV", "ipfs://test", "REV_TOKEN"),
126
149
  baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
127
150
  splitOperator: multisig(),
@@ -168,6 +191,7 @@ contract TestCrossSourceReallocation is TestBaseWorkflow {
168
191
  REVLoanSource[] memory ls = new REVLoanSource[](1);
169
192
  ls[0] = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
170
193
  REVConfig memory cfg = REVConfig({
194
+ // forge-lint: disable-next-line(named-struct-fields)
171
195
  description: REVDescription("NANA", "$NANA", "ipfs://test2", "NANA_TOKEN"),
172
196
  baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
173
197
  splitOperator: multisig(),