@rev-net/core-v6 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -0
  3. package/REVNET_SECURITY_CHECKLIST.md +164 -0
  4. package/SECURITY.md +68 -0
  5. package/SKILLS.md +166 -0
  6. package/deployments/revnet-core-v5/arbitrum/REVDeployer.json +2821 -0
  7. package/deployments/revnet-core-v5/arbitrum/REVLoans.json +2260 -0
  8. package/deployments/revnet-core-v5/arbitrum_sepolia/REVDeployer.json +2821 -0
  9. package/deployments/revnet-core-v5/arbitrum_sepolia/REVLoans.json +2260 -0
  10. package/deployments/revnet-core-v5/base/REVDeployer.json +2825 -0
  11. package/deployments/revnet-core-v5/base/REVLoans.json +2264 -0
  12. package/deployments/revnet-core-v5/base_sepolia/REVDeployer.json +2825 -0
  13. package/deployments/revnet-core-v5/base_sepolia/REVLoans.json +2264 -0
  14. package/deployments/revnet-core-v5/ethereum/REVDeployer.json +2825 -0
  15. package/deployments/revnet-core-v5/ethereum/REVLoans.json +2264 -0
  16. package/deployments/revnet-core-v5/optimism/REVDeployer.json +2821 -0
  17. package/deployments/revnet-core-v5/optimism/REVLoans.json +2260 -0
  18. package/deployments/revnet-core-v5/optimism_sepolia/REVDeployer.json +2825 -0
  19. package/deployments/revnet-core-v5/optimism_sepolia/REVLoans.json +2264 -0
  20. package/deployments/revnet-core-v5/sepolia/REVDeployer.json +2825 -0
  21. package/deployments/revnet-core-v5/sepolia/REVLoans.json +2264 -0
  22. package/docs/book.css +13 -0
  23. package/docs/book.toml +13 -0
  24. package/docs/solidity.min.js +74 -0
  25. package/docs/src/README.md +88 -0
  26. package/docs/src/SUMMARY.md +20 -0
  27. package/docs/src/src/README.md +7 -0
  28. package/docs/src/src/REVDeployer.sol/contract.REVDeployer.md +968 -0
  29. package/docs/src/src/REVLoans.sol/contract.REVLoans.md +1047 -0
  30. package/docs/src/src/interfaces/IREVDeployer.sol/interface.IREVDeployer.md +243 -0
  31. package/docs/src/src/interfaces/IREVLoans.sol/interface.IREVLoans.md +296 -0
  32. package/docs/src/src/interfaces/README.md +5 -0
  33. package/docs/src/src/structs/README.md +14 -0
  34. package/docs/src/src/structs/REVAutoIssuance.sol/struct.REVAutoIssuance.md +19 -0
  35. package/docs/src/src/structs/REVBuybackHookConfig.sol/struct.REVBuybackHookConfig.md +19 -0
  36. package/docs/src/src/structs/REVBuybackPoolConfig.sol/struct.REVBuybackPoolConfig.md +21 -0
  37. package/docs/src/src/structs/REVConfig.sol/struct.REVConfig.md +35 -0
  38. package/docs/src/src/structs/REVCroptopAllowedPost.sol/struct.REVCroptopAllowedPost.md +28 -0
  39. package/docs/src/src/structs/REVDeploy721TiersHookConfig.sol/struct.REVDeploy721TiersHookConfig.md +34 -0
  40. package/docs/src/src/structs/REVDescription.sol/struct.REVDescription.md +23 -0
  41. package/docs/src/src/structs/REVLoan.sol/struct.REVLoan.md +28 -0
  42. package/docs/src/src/structs/REVLoanSource.sol/struct.REVLoanSource.md +16 -0
  43. package/docs/src/src/structs/REVStageConfig.sol/struct.REVStageConfig.md +44 -0
  44. package/docs/src/src/structs/REVSuckerDeploymentConfig.sol/struct.REVSuckerDeploymentConfig.md +16 -0
  45. package/foundry.lock +11 -0
  46. package/foundry.toml +23 -0
  47. package/package.json +31 -0
  48. package/remappings.txt +1 -0
  49. package/script/Deploy.s.sol +350 -0
  50. package/script/helpers/RevnetCoreDeploymentLib.sol +72 -0
  51. package/slither-ci.config.json +10 -0
  52. package/sphinx.lock +507 -0
  53. package/src/REVDeployer.sol +1257 -0
  54. package/src/REVLoans.sol +1333 -0
  55. package/src/interfaces/IREVDeployer.sol +198 -0
  56. package/src/interfaces/IREVLoans.sol +241 -0
  57. package/src/structs/REVAutoIssuance.sol +11 -0
  58. package/src/structs/REVConfig.sol +17 -0
  59. package/src/structs/REVCroptopAllowedPost.sol +20 -0
  60. package/src/structs/REVDeploy721TiersHookConfig.sol +25 -0
  61. package/src/structs/REVDescription.sol +14 -0
  62. package/src/structs/REVLoan.sol +19 -0
  63. package/src/structs/REVLoanSource.sol +11 -0
  64. package/src/structs/REVStageConfig.sol +34 -0
  65. package/src/structs/REVSuckerDeploymentConfig.sol +11 -0
  66. package/test/REV.integrations.t.sol +420 -0
  67. package/test/REVAutoIssuanceFuzz.t.sol +276 -0
  68. package/test/REVDeployerAuditRegressions.t.sol +328 -0
  69. package/test/REVInvincibility.t.sol +1275 -0
  70. package/test/REVInvincibilityHandler.sol +357 -0
  71. package/test/REVLifecycle.t.sol +364 -0
  72. package/test/REVLoans.invariants.t.sol +642 -0
  73. package/test/REVLoansAttacks.t.sol +739 -0
  74. package/test/REVLoansAuditRegressions.t.sol +314 -0
  75. package/test/REVLoansFeeRecovery.t.sol +704 -0
  76. package/test/REVLoansSourced.t.sol +1732 -0
  77. package/test/REVLoansUnSourced.t.sol +331 -0
  78. package/test/TestPR09_ConversionDocumentation.t.sol +304 -0
  79. package/test/TestPR10_LiquidationBehavior.t.sol +340 -0
  80. package/test/TestPR11_LowFindings.t.sol +571 -0
  81. package/test/TestPR12_FlashLoanSurplus.t.sol +305 -0
  82. package/test/TestPR13_CrossSourceReallocation.t.sol +302 -0
  83. package/test/TestPR15_CashOutCallerValidation.t.sol +320 -0
  84. package/test/TestPR16_ZeroRepayment.t.sol +297 -0
  85. package/test/TestPR21_Uint112Overflow.t.sol +251 -0
  86. package/test/TestPR22_HookArrayOOB.t.sol +221 -0
  87. package/test/TestPR26_BurnHeldTokens.t.sol +331 -0
  88. package/test/TestPR27_CEIPattern.t.sol +448 -0
  89. package/test/TestPR29_SwapTerminalPermission.t.sol +206 -0
  90. package/test/TestPR32_MixedFixes.t.sol +529 -0
  91. package/test/helpers/MaliciousContracts.sol +233 -0
  92. package/test/mock/MockBuybackDataHook.sol +61 -0
@@ -0,0 +1,448 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.23;
3
+
4
+ import "forge-std/Test.sol";
5
+ import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
6
+ import /* {*} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
7
+ import /* {*} from */ "./../src/REVDeployer.sol";
8
+ import "@croptop/core-v6/src/CTPublisher.sol";
9
+ import {MockBuybackDataHook} from "./mock/MockBuybackDataHook.sol";
10
+
11
+ import "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
12
+ import "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
13
+ import "@bananapus/suckers-v6/script/helpers/SuckerDeploymentLib.sol";
14
+ import "@croptop/core-v6/script/helpers/CroptopDeploymentLib.sol";
15
+ import "@bananapus/swap-terminal-v6/script/helpers/SwapTerminalDeploymentLib.sol";
16
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
17
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
18
+ import {MockPriceFeed} from "@bananapus/core-v6/test/mock/MockPriceFeed.sol";
19
+ import {MockERC20} from "@bananapus/core-v6/test/mock/MockERC20.sol";
20
+ import {REVLoans} from "../src/REVLoans.sol";
21
+ import {REVLoan} from "../src/structs/REVLoan.sol";
22
+ import {REVStageConfig, REVAutoIssuance} from "../src/structs/REVStageConfig.sol";
23
+ import {REVLoanSource} from "../src/structs/REVLoanSource.sol";
24
+ import {REVDescription} from "../src/structs/REVDescription.sol";
25
+ import {IREVLoans} from "./../src/interfaces/IREVLoans.sol";
26
+ import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
27
+ import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
28
+ import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
29
+ import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
30
+ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
31
+ import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
32
+ import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
33
+
34
+ /// @notice Contract that reenters REVLoans when it receives ETH during a borrow payout.
35
+ /// Records the loan state it observes during reentrancy to verify CEI correctness.
36
+ contract ReentrantBorrower {
37
+ IREVLoans public loans;
38
+ uint256 public targetLoanId;
39
+ uint256 public observedAmount;
40
+ uint256 public observedCollateral;
41
+ bool public reentered;
42
+
43
+ constructor(IREVLoans _loans) {
44
+ loans = _loans;
45
+ }
46
+
47
+ function setTarget(uint256 _loanId) external {
48
+ targetLoanId = _loanId;
49
+ }
50
+
51
+ receive() external payable {
52
+ if (!reentered) {
53
+ reentered = true;
54
+ // During ETH receipt, read loan state. With CEI, state should already be finalized.
55
+ REVLoan memory loan = loans.loanOf(targetLoanId);
56
+ observedAmount = loan.amount;
57
+ observedCollateral = loan.collateral;
58
+ }
59
+ }
60
+ }
61
+
62
+ /// @title TestPR27_CEIPattern
63
+ /// @notice Tests for PR #27 — C-3 CEI pattern fix in REVLoans._adjust()
64
+ ///
65
+ /// SOURCE VERIFICATION (confirmed by reading _addTo/_removeFrom/_addCollateralTo/_returnCollateralFrom):
66
+ /// - _addTo(REVLoan memory, ..., uint256 addedBorrowAmount, ...) — memory copy, uses delta param
67
+ /// - _removeFrom(REVLoan memory, ..., uint256 repaidBorrowAmount) — memory copy, uses delta param
68
+ /// - _addCollateralTo(uint256 revnetId, uint256 amount) — no loan reference at all
69
+ /// - _returnCollateralFrom(uint256 revnetId, uint256 collateralCount, ...) — no loan reference
70
+ /// None of the four helpers read loan.amount or loan.collateral — they all use pre-computed deltas.
71
+ /// The CEI fix writes loan.amount and loan.collateral BEFORE calling any of these helpers.
72
+ contract TestPR27_CEIPattern is TestBaseWorkflow, JBTest {
73
+ bytes32 REV_DEPLOYER_SALT = "REVDeployer";
74
+
75
+ REVDeployer REV_DEPLOYER;
76
+ JB721TiersHook EXAMPLE_HOOK;
77
+ IJB721TiersHookDeployer HOOK_DEPLOYER;
78
+ IJB721TiersHookStore HOOK_STORE;
79
+ IJBAddressRegistry ADDRESS_REGISTRY;
80
+ IREVLoans LOANS_CONTRACT;
81
+ MockERC20 TOKEN;
82
+ IJBSuckerRegistry SUCKER_REGISTRY;
83
+ CTPublisher PUBLISHER;
84
+ MockBuybackDataHook MOCK_BUYBACK;
85
+
86
+ uint256 FEE_PROJECT_ID;
87
+ uint256 REVNET_ID;
88
+
89
+ address USER = makeAddr("user");
90
+ address private constant TRUSTED_FORWARDER = 0xB2b5841DBeF766d4b521221732F9B618fCf34A87;
91
+
92
+ function setUp() public override {
93
+ super.setUp();
94
+ FEE_PROJECT_ID = jbProjects().createFor(multisig());
95
+ SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
96
+ HOOK_STORE = new JB721TiersHookStore();
97
+ EXAMPLE_HOOK = new JB721TiersHook(jbDirectory(), jbPermissions(), jbRulesets(), HOOK_STORE, multisig());
98
+ ADDRESS_REGISTRY = new JBAddressRegistry();
99
+ HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
100
+ PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
101
+ MOCK_BUYBACK = new MockBuybackDataHook();
102
+ TOKEN = new MockERC20("1/2 ETH", "1/2");
103
+ MockPriceFeed priceFeed = new MockPriceFeed(1e21, 6);
104
+ vm.prank(multisig());
105
+ jbPrices()
106
+ .addPriceFeedFor(0, uint32(uint160(address(TOKEN))), uint32(uint160(JBConstants.NATIVE_TOKEN)), priceFeed);
107
+ LOANS_CONTRACT = new REVLoans({
108
+ controller: jbController(),
109
+ projects: jbProjects(),
110
+ revId: FEE_PROJECT_ID,
111
+ owner: address(this),
112
+ permit2: permit2(),
113
+ trustedForwarder: TRUSTED_FORWARDER
114
+ });
115
+ REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
116
+ jbController(),
117
+ SUCKER_REGISTRY,
118
+ FEE_PROJECT_ID,
119
+ HOOK_DEPLOYER,
120
+ PUBLISHER,
121
+ IJBRulesetDataHook(address(MOCK_BUYBACK)),
122
+ address(LOANS_CONTRACT),
123
+ TRUSTED_FORWARDER
124
+ );
125
+ vm.prank(multisig());
126
+ jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
127
+ _deployFeeProject();
128
+ _deployRevnet();
129
+ vm.deal(USER, 1000e18);
130
+ }
131
+
132
+ function _deployFeeProject() internal {
133
+ JBAccountingContext[] memory acc = new JBAccountingContext[](2);
134
+ acc[0] = JBAccountingContext({
135
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
136
+ });
137
+ acc[1] = JBAccountingContext({token: address(TOKEN), decimals: 6, currency: uint32(uint160(address(TOKEN)))});
138
+ JBTerminalConfig[] memory tc = new JBTerminalConfig[](1);
139
+ tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: acc});
140
+ REVStageConfig[] memory stages = new REVStageConfig[](1);
141
+ JBSplit[] memory splits = new JBSplit[](1);
142
+ splits[0].beneficiary = payable(multisig());
143
+ splits[0].percent = 10_000;
144
+ REVAutoIssuance[] memory ai = new REVAutoIssuance[](1);
145
+ ai[0] = REVAutoIssuance({chainId: uint32(block.chainid), count: uint104(70_000e18), beneficiary: multisig()});
146
+ stages[0] = REVStageConfig({
147
+ startsAtOrAfter: uint40(block.timestamp),
148
+ autoIssuances: ai,
149
+ splitPercent: 2000,
150
+ splits: splits,
151
+ initialIssuance: uint112(1000e18),
152
+ issuanceCutFrequency: 90 days,
153
+ issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
154
+ cashOutTaxRate: 6000,
155
+ extraMetadata: 0
156
+ });
157
+ REVConfig memory cfg = REVConfig({
158
+ description: REVDescription("Revnet", "$REV", "ipfs://test", "REV_TOKEN"),
159
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
160
+ splitOperator: multisig(),
161
+ stageConfigurations: stages
162
+ });
163
+ vm.prank(multisig());
164
+ REV_DEPLOYER.deployFor({
165
+ revnetId: FEE_PROJECT_ID,
166
+ configuration: cfg,
167
+ terminalConfigurations: tc,
168
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
169
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256("FEE")
170
+ })
171
+ });
172
+ }
173
+
174
+ function _deployRevnet() internal {
175
+ JBAccountingContext[] memory acc = new JBAccountingContext[](2);
176
+ acc[0] = JBAccountingContext({
177
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
178
+ });
179
+ acc[1] = JBAccountingContext({token: address(TOKEN), decimals: 6, currency: uint32(uint160(address(TOKEN)))});
180
+ JBTerminalConfig[] memory tc = new JBTerminalConfig[](1);
181
+ tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: acc});
182
+ REVStageConfig[] memory stages = new REVStageConfig[](1);
183
+ JBSplit[] memory splits = new JBSplit[](1);
184
+ splits[0].beneficiary = payable(multisig());
185
+ splits[0].percent = 10_000;
186
+ REVAutoIssuance[] memory ai = new REVAutoIssuance[](1);
187
+ ai[0] = REVAutoIssuance({chainId: uint32(block.chainid), count: uint104(70_000e18), beneficiary: multisig()});
188
+ stages[0] = REVStageConfig({
189
+ startsAtOrAfter: uint40(block.timestamp),
190
+ autoIssuances: ai,
191
+ splitPercent: 2000,
192
+ splits: splits,
193
+ initialIssuance: uint112(1000e18),
194
+ issuanceCutFrequency: 90 days,
195
+ issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
196
+ cashOutTaxRate: 6000,
197
+ extraMetadata: 0
198
+ });
199
+ REVLoanSource[] memory ls = new REVLoanSource[](1);
200
+ ls[0] = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
201
+ REVConfig memory cfg = REVConfig({
202
+ description: REVDescription("NANA", "$NANA", "ipfs://test2", "NANA_TOKEN"),
203
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
204
+ splitOperator: multisig(),
205
+ stageConfigurations: stages
206
+ });
207
+ REVNET_ID = REV_DEPLOYER.deployFor({
208
+ revnetId: 0,
209
+ configuration: cfg,
210
+ terminalConfigurations: tc,
211
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
212
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256("NANA")
213
+ })
214
+ });
215
+ }
216
+
217
+ function _setupLoan(
218
+ address user,
219
+ uint256 ethAmount,
220
+ uint256 prepaidFee
221
+ )
222
+ internal
223
+ returns (uint256 loanId, uint256 tokenCount, uint256 borrowAmount)
224
+ {
225
+ vm.prank(user);
226
+ tokenCount =
227
+ jbMultiTerminal().pay{value: ethAmount}(REVNET_ID, JBConstants.NATIVE_TOKEN, ethAmount, user, 0, "", "");
228
+ borrowAmount =
229
+ LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokenCount, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
230
+ if (borrowAmount == 0) return (0, tokenCount, 0);
231
+ mockExpect(
232
+ address(jbPermissions()),
233
+ abi.encodeCall(IJBPermissions.hasPermission, (address(LOANS_CONTRACT), user, REVNET_ID, 11, true, true)),
234
+ abi.encode(true)
235
+ );
236
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
237
+ vm.prank(user);
238
+ (loanId,) = LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokenCount, payable(user), prepaidFee);
239
+ }
240
+
241
+ /// @notice After borrowing, loan.amount and loan.collateral are set correctly (CEI: state written before external
242
+ /// calls).
243
+ function test_normalBorrow_stateConsistent() public {
244
+ (uint256 loanId,, uint256 borrowAmount) = _setupLoan(USER, 10e18, 25);
245
+ assertTrue(borrowAmount > 0, "Should borrow nonzero");
246
+
247
+ REVLoan memory loan = LOANS_CONTRACT.loanOf(loanId);
248
+ // The amount should reflect the actual borrow minus any fee
249
+ assertTrue(loan.amount > 0, "Loan amount should be positive");
250
+ assertTrue(loan.collateral > 0, "Loan collateral should be positive");
251
+ }
252
+
253
+ /// @notice Repay a loan and verify state is consistent afterwards.
254
+ function test_repayLoan_stateConsistent() public {
255
+ (uint256 loanId, uint256 tokenCount, uint256 borrowAmount) = _setupLoan(USER, 10e18, 500);
256
+ assertTrue(borrowAmount > 0, "Should borrow nonzero");
257
+
258
+ REVLoan memory loan = LOANS_CONTRACT.loanOf(loanId);
259
+
260
+ // Immediately repay — within prepaid duration so no source fee
261
+ vm.prank(USER);
262
+ LOANS_CONTRACT.repayLoan{value: loan.amount}({
263
+ loanId: loanId,
264
+ maxRepayBorrowAmount: loan.amount,
265
+ collateralCountToReturn: loan.collateral,
266
+ beneficiary: payable(USER),
267
+ allowance: JBSingleAllowance({sigDeadline: 0, amount: 0, expiration: 0, nonce: 0, signature: ""})
268
+ });
269
+
270
+ // After repayment, total collateral should be 0
271
+ uint256 totalCollateral = LOANS_CONTRACT.totalCollateralOf(REVNET_ID);
272
+ assertEq(totalCollateral, 0, "All collateral should be returned after full repay");
273
+ }
274
+
275
+ /// @notice Multiple sequential borrows produce correct aggregate state.
276
+ function test_multipleBorrows_stateAccumulates() public {
277
+ vm.deal(USER, 2000e18);
278
+
279
+ // First borrow
280
+ (uint256 loanId1,, uint256 borrow1) = _setupLoan(USER, 10e18, 25);
281
+ assertTrue(borrow1 > 0, "First borrow should succeed");
282
+
283
+ REVLoan memory loan1 = LOANS_CONTRACT.loanOf(loanId1);
284
+ uint256 collateral1 = loan1.collateral;
285
+
286
+ // Second borrow (need more tokens)
287
+ vm.prank(USER);
288
+ uint256 tokens2 =
289
+ jbMultiTerminal().pay{value: 10e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 10e18, USER, 0, "", "");
290
+ uint256 borrowable2 =
291
+ LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokens2, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
292
+ if (borrowable2 > 0) {
293
+ mockExpect(
294
+ address(jbPermissions()),
295
+ abi.encodeCall(
296
+ IJBPermissions.hasPermission, (address(LOANS_CONTRACT), USER, REVNET_ID, 11, true, true)
297
+ ),
298
+ abi.encode(true)
299
+ );
300
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
301
+ vm.prank(USER);
302
+ LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokens2, payable(USER), 25);
303
+ }
304
+
305
+ // Total collateral should equal sum of both loans' collateral
306
+ uint256 totalCollateral = LOANS_CONTRACT.totalCollateralOf(REVNET_ID);
307
+ assertTrue(totalCollateral >= collateral1, "Total collateral should include both loans");
308
+ }
309
+
310
+ /// @notice A reentrant beneficiary reads loan state during ETH receipt.
311
+ /// With CEI, the loan state is already finalized when external calls execute.
312
+ function test_reentrantBeneficiary_seesUpdatedState() public {
313
+ ReentrantBorrower attacker = new ReentrantBorrower(LOANS_CONTRACT);
314
+ vm.deal(address(attacker), 100e18);
315
+
316
+ // Pay into revnet as the attacker contract to get tokens.
317
+ vm.prank(address(attacker));
318
+ uint256 tokens = jbMultiTerminal().pay{value: 10e18}(
319
+ REVNET_ID, JBConstants.NATIVE_TOKEN, 10e18, address(attacker), 0, "", ""
320
+ );
321
+
322
+ uint256 borrowable =
323
+ LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
324
+ vm.assume(borrowable > 0);
325
+
326
+ // Mock BURN permission for attacker.
327
+ mockExpect(
328
+ address(jbPermissions()),
329
+ abi.encodeCall(
330
+ IJBPermissions.hasPermission, (address(LOANS_CONTRACT), address(attacker), REVNET_ID, 11, true, true)
331
+ ),
332
+ abi.encode(true)
333
+ );
334
+
335
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
336
+
337
+ // Pre-compute the loanId so the attacker can read it during reentrancy.
338
+ // loanId = revnetId * 1_000_000_000_000 + (numberOfLoansFor + 1)
339
+ uint256 expectedLoanId = REVNET_ID * 1_000_000_000_000 + (LOANS_CONTRACT.numberOfLoansFor(REVNET_ID) + 1);
340
+ attacker.setTarget(expectedLoanId);
341
+
342
+ // Borrow with attacker as beneficiary — attacker's receive() will fire when ETH arrives.
343
+ vm.prank(address(attacker));
344
+ (uint256 loanId,) = LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokens, payable(address(attacker)), 25);
345
+
346
+ assertEq(loanId, expectedLoanId, "LoanId should match pre-computed value");
347
+
348
+ // Verify loan state is finalized.
349
+ REVLoan memory loan = LOANS_CONTRACT.loanOf(loanId);
350
+ assertGt(loan.amount, 0, "Loan should have amount");
351
+ assertGt(loan.collateral, 0, "Loan should have collateral");
352
+
353
+ // The attacker's receive() fired during the ETH transfer. With CEI, it should have
354
+ // observed the correct (finalized) loan state.
355
+ if (attacker.reentered()) {
356
+ assertEq(attacker.observedAmount(), loan.amount, "Reentrant read should see finalized loan amount");
357
+ assertEq(
358
+ attacker.observedCollateral(), loan.collateral, "Reentrant read should see finalized loan collateral"
359
+ );
360
+ }
361
+ }
362
+
363
+ /// @notice Verify atomic consistency: loan state matches global accounting after every operation.
364
+ /// If _adjust wrote state AFTER external calls (old code), a reentrant observer between
365
+ /// the external calls and the state write could see totalBorrowedFrom updated but loan.amount stale.
366
+ function test_CEI_atomicConsistency_borrowAndRepay() public {
367
+ vm.deal(USER, 2000e18);
368
+
369
+ // Borrow.
370
+ (uint256 loanId,, uint256 borrowAmount) = _setupLoan(USER, 10e18, 25);
371
+ assertTrue(borrowAmount > 0, "Should borrow nonzero");
372
+
373
+ REVLoan memory loan = LOANS_CONTRACT.loanOf(loanId);
374
+
375
+ // Verify loan.amount matches what totalBorrowedFrom tracks.
376
+ uint256 totalBorrowed = LOANS_CONTRACT.totalBorrowedFrom(REVNET_ID, jbMultiTerminal(), JBConstants.NATIVE_TOKEN);
377
+ assertEq(totalBorrowed, loan.amount, "totalBorrowedFrom should equal loan.amount after single borrow");
378
+
379
+ // Verify collateral accounting.
380
+ uint256 totalCollateral = LOANS_CONTRACT.totalCollateralOf(REVNET_ID);
381
+ assertEq(totalCollateral, loan.collateral, "totalCollateralOf should equal loan.collateral after single borrow");
382
+
383
+ // Repay fully.
384
+ vm.prank(USER);
385
+ LOANS_CONTRACT.repayLoan{value: loan.amount * 2}({
386
+ loanId: loanId,
387
+ maxRepayBorrowAmount: loan.amount * 2,
388
+ collateralCountToReturn: loan.collateral,
389
+ beneficiary: payable(USER),
390
+ allowance: JBSingleAllowance({sigDeadline: 0, amount: 0, expiration: 0, nonce: 0, signature: ""})
391
+ });
392
+
393
+ // After full repay, both should be zero atomically.
394
+ uint256 totalBorrowedAfter =
395
+ LOANS_CONTRACT.totalBorrowedFrom(REVNET_ID, jbMultiTerminal(), JBConstants.NATIVE_TOKEN);
396
+ uint256 totalCollateralAfter = LOANS_CONTRACT.totalCollateralOf(REVNET_ID);
397
+ assertEq(totalBorrowedAfter, 0, "totalBorrowedFrom should be 0 after full repay");
398
+ assertEq(totalCollateralAfter, 0, "totalCollateralOf should be 0 after full repay");
399
+ }
400
+
401
+ /// @notice Rapid sequential borrows and repays can't create inconsistent state.
402
+ /// Exercises _adjust's CEI pattern under repeated state transitions.
403
+ function test_CEI_rapidBorrowRepaySequence() public {
404
+ vm.deal(USER, 5000e18);
405
+
406
+ for (uint256 i; i < 3; i++) {
407
+ // Borrow.
408
+ vm.prank(USER);
409
+ uint256 tokens =
410
+ jbMultiTerminal().pay{value: 10e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 10e18, USER, 0, "", "");
411
+
412
+ uint256 borrowable =
413
+ LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
414
+ if (borrowable == 0) continue;
415
+
416
+ mockExpect(
417
+ address(jbPermissions()),
418
+ abi.encodeCall(
419
+ IJBPermissions.hasPermission, (address(LOANS_CONTRACT), USER, REVNET_ID, 11, true, true)
420
+ ),
421
+ abi.encode(true)
422
+ );
423
+
424
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
425
+
426
+ vm.prank(USER);
427
+ (uint256 loanId,) = LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokens, payable(USER), 25);
428
+
429
+ REVLoan memory loan = LOANS_CONTRACT.loanOf(loanId);
430
+
431
+ // Immediately repay.
432
+ vm.prank(USER);
433
+ LOANS_CONTRACT.repayLoan{value: loan.amount * 2}({
434
+ loanId: loanId,
435
+ maxRepayBorrowAmount: loan.amount * 2,
436
+ collateralCountToReturn: loan.collateral,
437
+ beneficiary: payable(USER),
438
+ allowance: JBSingleAllowance({sigDeadline: 0, amount: 0, expiration: 0, nonce: 0, signature: ""})
439
+ });
440
+ }
441
+
442
+ // After all borrows repaid, accounting should be clean.
443
+ uint256 totalBorrowed = LOANS_CONTRACT.totalBorrowedFrom(REVNET_ID, jbMultiTerminal(), JBConstants.NATIVE_TOKEN);
444
+ uint256 totalCollateral = LOANS_CONTRACT.totalCollateralOf(REVNET_ID);
445
+ assertEq(totalBorrowed, 0, "totalBorrowedFrom should be 0 after all repaid");
446
+ assertEq(totalCollateral, 0, "totalCollateralOf should be 0 after all repaid");
447
+ }
448
+ }
@@ -0,0 +1,206 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.23;
3
+
4
+ import "forge-std/Test.sol";
5
+ import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
6
+ import /* {*} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
7
+ import /* {*} from */ "./../src/REVDeployer.sol";
8
+ import "@croptop/core-v6/src/CTPublisher.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/swap-terminal-v6/script/helpers/SwapTerminalDeploymentLib.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 {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
18
+ import {REVLoans} from "../src/REVLoans.sol";
19
+ import {REVStageConfig, REVAutoIssuance} from "../src/structs/REVStageConfig.sol";
20
+ import {REVLoanSource} from "../src/structs/REVLoanSource.sol";
21
+ import {REVDescription} from "../src/structs/REVDescription.sol";
22
+ import {IREVLoans} from "./../src/interfaces/IREVLoans.sol";
23
+ import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
24
+ import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
25
+ import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
26
+ import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
27
+ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
28
+ import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
29
+ import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
30
+
31
+ /// @notice Tests for PR #29: fix/l27-swap-terminal-permission
32
+ /// Verifies that ADD_SWAP_TERMINAL_POOL (permission ID 26) is included in the default
33
+ /// split operator permissions. The fix adds this as the 7th default permission.
34
+ contract TestPR29_SwapTerminalPermission is TestBaseWorkflow, JBTest {
35
+ bytes32 REV_DEPLOYER_SALT = "REVDeployer";
36
+
37
+ REVDeployer REV_DEPLOYER;
38
+ JB721TiersHook EXAMPLE_HOOK;
39
+ IJB721TiersHookDeployer HOOK_DEPLOYER;
40
+ IJB721TiersHookStore HOOK_STORE;
41
+ IJBAddressRegistry ADDRESS_REGISTRY;
42
+ IREVLoans LOANS_CONTRACT;
43
+ IJBSuckerRegistry SUCKER_REGISTRY;
44
+ CTPublisher PUBLISHER;
45
+ MockBuybackDataHook MOCK_BUYBACK;
46
+
47
+ uint256 FEE_PROJECT_ID;
48
+ uint256 TEST_REVNET_ID;
49
+
50
+ address private constant TRUSTED_FORWARDER = 0xB2b5841DBeF766d4b521221732F9B618fCf34A87;
51
+
52
+ function setUp() public override {
53
+ super.setUp();
54
+ FEE_PROJECT_ID = jbProjects().createFor(multisig());
55
+ SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
56
+ HOOK_STORE = new JB721TiersHookStore();
57
+ EXAMPLE_HOOK = new JB721TiersHook(jbDirectory(), jbPermissions(), jbRulesets(), HOOK_STORE, multisig());
58
+ ADDRESS_REGISTRY = new JBAddressRegistry();
59
+ HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
60
+ PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
61
+ MOCK_BUYBACK = new MockBuybackDataHook();
62
+ LOANS_CONTRACT = new REVLoans({
63
+ controller: jbController(),
64
+ projects: jbProjects(),
65
+ revId: FEE_PROJECT_ID,
66
+ owner: address(this),
67
+ permit2: permit2(),
68
+ trustedForwarder: TRUSTED_FORWARDER
69
+ });
70
+ REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
71
+ jbController(),
72
+ SUCKER_REGISTRY,
73
+ FEE_PROJECT_ID,
74
+ HOOK_DEPLOYER,
75
+ PUBLISHER,
76
+ IJBRulesetDataHook(address(MOCK_BUYBACK)),
77
+ address(LOANS_CONTRACT),
78
+ TRUSTED_FORWARDER
79
+ );
80
+ vm.prank(multisig());
81
+ jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
82
+
83
+ // Deploy the fee project as a revnet
84
+ (REVConfig memory feeCfg, JBTerminalConfig[] memory feeTc, REVSuckerDeploymentConfig memory feeSdc) =
85
+ _buildConfig("FeeProject", "FEE", "FEE_SALT");
86
+
87
+ vm.prank(multisig());
88
+ REV_DEPLOYER.deployFor({
89
+ revnetId: FEE_PROJECT_ID,
90
+ configuration: feeCfg,
91
+ terminalConfigurations: feeTc,
92
+ suckerDeploymentConfiguration: feeSdc
93
+ });
94
+
95
+ // Deploy the test revnet
96
+ (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc) =
97
+ _buildConfig("TestRevnet", "TST", "TST_SALT");
98
+
99
+ TEST_REVNET_ID = REV_DEPLOYER.deployFor({
100
+ revnetId: 0, configuration: cfg, terminalConfigurations: tc, suckerDeploymentConfiguration: sdc
101
+ });
102
+ }
103
+
104
+ function _buildConfig(
105
+ string memory name,
106
+ string memory ticker,
107
+ bytes32 salt
108
+ )
109
+ internal
110
+ view
111
+ returns (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc)
112
+ {
113
+ JBAccountingContext[] memory acc = new JBAccountingContext[](1);
114
+ acc[0] = JBAccountingContext({
115
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
116
+ });
117
+ tc = new JBTerminalConfig[](1);
118
+ tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: acc});
119
+
120
+ REVStageConfig[] memory stages = new REVStageConfig[](1);
121
+ JBSplit[] memory splits = new JBSplit[](1);
122
+ splits[0].beneficiary = payable(multisig());
123
+ splits[0].percent = 10_000;
124
+ stages[0] = REVStageConfig({
125
+ startsAtOrAfter: uint40(block.timestamp),
126
+ autoIssuances: new REVAutoIssuance[](0),
127
+ splitPercent: 0,
128
+ splits: splits,
129
+ initialIssuance: uint112(1000e18),
130
+ issuanceCutFrequency: 0,
131
+ issuanceCutPercent: 0,
132
+ cashOutTaxRate: 5000,
133
+ extraMetadata: 0
134
+ });
135
+
136
+ cfg = REVConfig({
137
+ description: REVDescription(name, ticker, "ipfs://test", salt),
138
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
139
+ splitOperator: multisig(),
140
+ stageConfigurations: stages
141
+ });
142
+
143
+ sdc = REVSuckerDeploymentConfig({
144
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256(abi.encodePacked("TEST"))
145
+ });
146
+ }
147
+
148
+ /// @notice Verify the split operator has SET_BUYBACK_HOOK and SET_SWAP_TERMINAL permissions.
149
+ function test_splitOperator_hasRegistryPermissions() public view {
150
+ bool hasBuybackHook = jbPermissions()
151
+ .hasPermission({
152
+ operator: multisig(),
153
+ account: address(REV_DEPLOYER),
154
+ projectId: TEST_REVNET_ID,
155
+ permissionId: JBPermissionIds.SET_BUYBACK_HOOK,
156
+ includeRoot: false,
157
+ includeWildcardProjectId: false
158
+ });
159
+ assertTrue(hasBuybackHook, "Split operator should have SET_BUYBACK_HOOK permission");
160
+
161
+ bool hasSwapTerminal = jbPermissions()
162
+ .hasPermission({
163
+ operator: multisig(),
164
+ account: address(REV_DEPLOYER),
165
+ projectId: TEST_REVNET_ID,
166
+ permissionId: JBPermissionIds.SET_SWAP_TERMINAL,
167
+ includeRoot: false,
168
+ includeWildcardProjectId: false
169
+ });
170
+ assertTrue(hasSwapTerminal, "Split operator should have SET_SWAP_TERMINAL permission");
171
+ }
172
+
173
+ /// @notice Verify all 9 default permissions are present for the split operator.
174
+ function test_allDefaultPermissionsPresent() public view {
175
+ // All 9 default permissions that should be granted
176
+ uint256[9] memory expectedPermissions = [
177
+ uint256(JBPermissionIds.SET_SPLIT_GROUPS),
178
+ uint256(JBPermissionIds.SET_BUYBACK_POOL),
179
+ uint256(JBPermissionIds.SET_BUYBACK_TWAP),
180
+ uint256(JBPermissionIds.SET_PROJECT_URI),
181
+ uint256(JBPermissionIds.ADD_PRICE_FEED),
182
+ uint256(JBPermissionIds.SUCKER_SAFETY),
183
+ uint256(JBPermissionIds.ADD_SWAP_TERMINAL_POOL),
184
+ uint256(JBPermissionIds.SET_BUYBACK_HOOK),
185
+ uint256(JBPermissionIds.SET_SWAP_TERMINAL)
186
+ ];
187
+
188
+ for (uint256 i = 0; i < expectedPermissions.length; i++) {
189
+ bool hasPermission = jbPermissions()
190
+ .hasPermission({
191
+ operator: multisig(),
192
+ account: address(REV_DEPLOYER),
193
+ projectId: TEST_REVNET_ID,
194
+ permissionId: expectedPermissions[i],
195
+ includeRoot: false,
196
+ includeWildcardProjectId: false
197
+ });
198
+ assertTrue(
199
+ hasPermission,
200
+ string.concat(
201
+ "Missing permission at index ", vm.toString(i), " (ID ", vm.toString(expectedPermissions[i]), ")"
202
+ )
203
+ );
204
+ }
205
+ }
206
+ }