@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,704 @@
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
+
17
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
18
+ import {JBFees} from "@bananapus/core-v6/src/libraries/JBFees.sol";
19
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
20
+ import {MockPriceFeed} from "@bananapus/core-v6/test/mock/MockPriceFeed.sol";
21
+ import {MockERC20} from "@bananapus/core-v6/test/mock/MockERC20.sol";
22
+ import {REVLoans} from "../src/REVLoans.sol";
23
+ import {REVLoan} from "../src/structs/REVLoan.sol";
24
+ import {REVStageConfig, REVAutoIssuance} from "../src/structs/REVStageConfig.sol";
25
+ import {REVLoanSource} from "../src/structs/REVLoanSource.sol";
26
+ import {REVDescription} from "../src/structs/REVDescription.sol";
27
+ import {IREVLoans} from "./../src/interfaces/IREVLoans.sol";
28
+ import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
29
+ import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
30
+ import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
31
+ import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
32
+ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
33
+ import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
34
+ import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
35
+ import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
36
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
37
+
38
+ /// @notice A terminal mock that always reverts on pay(), used to simulate fee payment failure.
39
+ contract RevertingFeeTerminal is ERC165, IJBPayoutTerminal {
40
+ function pay(
41
+ uint256,
42
+ address,
43
+ uint256,
44
+ address,
45
+ uint256,
46
+ string calldata,
47
+ bytes calldata
48
+ )
49
+ external
50
+ payable
51
+ override
52
+ returns (uint256)
53
+ {
54
+ revert("Fee payment failed");
55
+ }
56
+
57
+ function accountingContextForTokenOf(uint256, address) external pure override returns (JBAccountingContext memory) {
58
+ return JBAccountingContext({
59
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
60
+ });
61
+ }
62
+
63
+ function accountingContextsOf(uint256) external pure override returns (JBAccountingContext[] memory) {
64
+ return new JBAccountingContext[](0);
65
+ }
66
+
67
+ function addAccountingContextsFor(uint256, JBAccountingContext[] calldata) external override {}
68
+ function addToBalanceOf(
69
+ uint256,
70
+ address,
71
+ uint256,
72
+ bool,
73
+ string calldata,
74
+ bytes calldata
75
+ )
76
+ external
77
+ payable
78
+ override
79
+ {}
80
+
81
+ function currentSurplusOf(
82
+ uint256,
83
+ JBAccountingContext[] memory,
84
+ uint256,
85
+ uint256
86
+ )
87
+ external
88
+ pure
89
+ override
90
+ returns (uint256)
91
+ {
92
+ return 0;
93
+ }
94
+
95
+ function migrateBalanceOf(uint256, address, IJBTerminal) external pure override returns (uint256) {
96
+ return 0;
97
+ }
98
+
99
+ function sendPayoutsOf(uint256, address, uint256, uint256, uint256) external pure override returns (uint256) {
100
+ return 0;
101
+ }
102
+
103
+ function useAllowanceOf(
104
+ uint256,
105
+ address,
106
+ uint256,
107
+ uint256,
108
+ uint256,
109
+ address payable,
110
+ address payable,
111
+ string calldata
112
+ )
113
+ external
114
+ pure
115
+ override
116
+ returns (uint256)
117
+ {
118
+ return 0;
119
+ }
120
+
121
+ function supportsInterface(bytes4 interfaceId) public view override(ERC165, IERC165) returns (bool) {
122
+ return interfaceId == type(IJBTerminal).interfaceId || interfaceId == type(IJBPayoutTerminal).interfaceId
123
+ || super.supportsInterface(interfaceId);
124
+ }
125
+
126
+ receive() external payable {}
127
+ }
128
+
129
+ struct FeeRecoveryProjectConfig {
130
+ REVConfig configuration;
131
+ JBTerminalConfig[] terminalConfigurations;
132
+ REVSuckerDeploymentConfig suckerDeploymentConfiguration;
133
+ }
134
+
135
+ /// @title REVLoansFeeRecovery
136
+ /// @notice Tests for the fee payment error recovery in REVLoans._addTo().
137
+ /// @dev When feeTerminal.pay() reverts, the borrower should receive the fee amount back
138
+ /// instead of losing it. For ERC-20 tokens, the dangling allowance must also be cleaned up.
139
+ contract REVLoansFeeRecovery is TestBaseWorkflow, JBTest {
140
+ bytes32 REV_DEPLOYER_SALT = "REVDeployer";
141
+ bytes32 ERC20_SALT = "REV_TOKEN";
142
+
143
+ REVDeployer REV_DEPLOYER;
144
+ JB721TiersHook EXAMPLE_HOOK;
145
+ IJB721TiersHookDeployer HOOK_DEPLOYER;
146
+ IJB721TiersHookStore HOOK_STORE;
147
+ IJBAddressRegistry ADDRESS_REGISTRY;
148
+ IREVLoans LOANS_CONTRACT;
149
+ MockERC20 TOKEN;
150
+ IJBSuckerRegistry SUCKER_REGISTRY;
151
+ CTPublisher PUBLISHER;
152
+ MockBuybackDataHook MOCK_BUYBACK;
153
+ RevertingFeeTerminal REVERTING_TERMINAL;
154
+
155
+ uint256 FEE_PROJECT_ID;
156
+ uint256 REVNET_ID;
157
+
158
+ address USER = makeAddr("user");
159
+ address private constant TRUSTED_FORWARDER = 0xB2b5841DBeF766d4b521221732F9B618fCf34A87;
160
+
161
+ function _getFeeProjectConfig() internal view returns (FeeRecoveryProjectConfig memory) {
162
+ uint8 decimals = 18;
163
+ uint256 decimalMultiplier = 10 ** decimals;
164
+
165
+ JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](2);
166
+ accountingContextsToAccept[0] = JBAccountingContext({
167
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
168
+ });
169
+ accountingContextsToAccept[1] =
170
+ JBAccountingContext({token: address(TOKEN), decimals: 6, currency: uint32(uint160(address(TOKEN)))});
171
+
172
+ JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
173
+ terminalConfigurations[0] =
174
+ JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: accountingContextsToAccept});
175
+
176
+ REVStageConfig[] memory stageConfigurations = new REVStageConfig[](1);
177
+ JBSplit[] memory splits = new JBSplit[](1);
178
+ splits[0].beneficiary = payable(multisig());
179
+ splits[0].percent = 10_000;
180
+
181
+ REVAutoIssuance[] memory issuanceConfs = new REVAutoIssuance[](1);
182
+ issuanceConfs[0] = REVAutoIssuance({
183
+ chainId: uint32(block.chainid), count: uint104(70_000 * decimalMultiplier), beneficiary: multisig()
184
+ });
185
+
186
+ stageConfigurations[0] = REVStageConfig({
187
+ startsAtOrAfter: uint40(block.timestamp),
188
+ autoIssuances: issuanceConfs,
189
+ splitPercent: 2000,
190
+ splits: splits,
191
+ initialIssuance: uint112(1000 * decimalMultiplier),
192
+ issuanceCutFrequency: 90 days,
193
+ issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
194
+ cashOutTaxRate: 6000,
195
+ extraMetadata: 0
196
+ });
197
+
198
+ REVLoanSource[] memory _loanSources = new REVLoanSource[](0);
199
+
200
+ REVConfig memory revnetConfiguration = REVConfig({
201
+ description: REVDescription(
202
+ "Revnet", "$REV", "ipfs://QmNRHT91HcDgMcenebYX7rJigt77cgNcosvuhX21wkF3tx", ERC20_SALT
203
+ ),
204
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
205
+ splitOperator: multisig(),
206
+ stageConfigurations: stageConfigurations
207
+ });
208
+
209
+ return FeeRecoveryProjectConfig({
210
+ configuration: revnetConfiguration,
211
+ terminalConfigurations: terminalConfigurations,
212
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
213
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256(abi.encodePacked("REV"))
214
+ })
215
+ });
216
+ }
217
+
218
+ function _getRevnetConfig() internal view returns (FeeRecoveryProjectConfig memory) {
219
+ uint8 decimals = 18;
220
+ uint256 decimalMultiplier = 10 ** decimals;
221
+
222
+ JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](2);
223
+ accountingContextsToAccept[0] = JBAccountingContext({
224
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
225
+ });
226
+ accountingContextsToAccept[1] =
227
+ JBAccountingContext({token: address(TOKEN), decimals: 6, currency: uint32(uint160(address(TOKEN)))});
228
+
229
+ JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
230
+ terminalConfigurations[0] =
231
+ JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: accountingContextsToAccept});
232
+
233
+ JBSplit[] memory splits = new JBSplit[](1);
234
+ splits[0].beneficiary = payable(multisig());
235
+ splits[0].percent = 10_000;
236
+
237
+ REVStageConfig[] memory stageConfigurations = new REVStageConfig[](1);
238
+ REVAutoIssuance[] memory issuanceConfs = new REVAutoIssuance[](1);
239
+ issuanceConfs[0] = REVAutoIssuance({
240
+ chainId: uint32(block.chainid), count: uint104(70_000 * decimalMultiplier), beneficiary: multisig()
241
+ });
242
+
243
+ stageConfigurations[0] = REVStageConfig({
244
+ startsAtOrAfter: uint40(block.timestamp),
245
+ autoIssuances: issuanceConfs,
246
+ splitPercent: 2000,
247
+ splits: splits,
248
+ initialIssuance: uint112(1000 * decimalMultiplier),
249
+ issuanceCutFrequency: 90 days,
250
+ issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
251
+ cashOutTaxRate: 6000,
252
+ extraMetadata: 0
253
+ });
254
+
255
+ REVLoanSource[] memory _loanSources = new REVLoanSource[](2);
256
+ _loanSources[0] = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
257
+ _loanSources[1] = REVLoanSource({token: address(TOKEN), terminal: jbMultiTerminal()});
258
+
259
+ REVConfig memory revnetConfiguration = REVConfig({
260
+ description: REVDescription(
261
+ "NANA", "$NANA", "ipfs://QmNRHT91HcDgMcenebYX7rJigt77cgNxosvuhX21wkF3tx", "NANA_TOKEN"
262
+ ),
263
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
264
+ splitOperator: multisig(),
265
+ stageConfigurations: stageConfigurations
266
+ });
267
+
268
+ return FeeRecoveryProjectConfig({
269
+ configuration: revnetConfiguration,
270
+ terminalConfigurations: terminalConfigurations,
271
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
272
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256(abi.encodePacked("NANA"))
273
+ })
274
+ });
275
+ }
276
+
277
+ function setUp() public override {
278
+ super.setUp();
279
+
280
+ FEE_PROJECT_ID = jbProjects().createFor(multisig());
281
+
282
+ SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
283
+ HOOK_STORE = new JB721TiersHookStore();
284
+ EXAMPLE_HOOK = new JB721TiersHook(jbDirectory(), jbPermissions(), jbRulesets(), HOOK_STORE, multisig());
285
+ ADDRESS_REGISTRY = new JBAddressRegistry();
286
+ HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
287
+ PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
288
+ MOCK_BUYBACK = new MockBuybackDataHook();
289
+ TOKEN = new MockERC20("1/2 ETH", "1/2");
290
+ REVERTING_TERMINAL = new RevertingFeeTerminal();
291
+
292
+ MockPriceFeed priceFeed = new MockPriceFeed(1e21, 6);
293
+ vm.prank(multisig());
294
+ jbPrices()
295
+ .addPriceFeedFor(0, uint32(uint160(address(TOKEN))), uint32(uint160(JBConstants.NATIVE_TOKEN)), priceFeed);
296
+
297
+ LOANS_CONTRACT = new REVLoans({
298
+ controller: jbController(),
299
+ projects: jbProjects(),
300
+ revId: FEE_PROJECT_ID,
301
+ owner: address(this),
302
+ permit2: permit2(),
303
+ trustedForwarder: TRUSTED_FORWARDER
304
+ });
305
+
306
+ REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
307
+ jbController(),
308
+ SUCKER_REGISTRY,
309
+ FEE_PROJECT_ID,
310
+ HOOK_DEPLOYER,
311
+ PUBLISHER,
312
+ IJBRulesetDataHook(address(MOCK_BUYBACK)),
313
+ address(LOANS_CONTRACT),
314
+ TRUSTED_FORWARDER
315
+ );
316
+
317
+ // Deploy fee project.
318
+ vm.prank(multisig());
319
+ jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
320
+
321
+ FeeRecoveryProjectConfig memory feeProjectConfig = _getFeeProjectConfig();
322
+ vm.prank(multisig());
323
+ REV_DEPLOYER.deployFor({
324
+ revnetId: FEE_PROJECT_ID,
325
+ configuration: feeProjectConfig.configuration,
326
+ terminalConfigurations: feeProjectConfig.terminalConfigurations,
327
+ suckerDeploymentConfiguration: feeProjectConfig.suckerDeploymentConfiguration
328
+ });
329
+
330
+ // Deploy revnet with loans enabled.
331
+ FeeRecoveryProjectConfig memory revnetConfig = _getRevnetConfig();
332
+ REVNET_ID = REV_DEPLOYER.deployFor({
333
+ revnetId: 0,
334
+ configuration: revnetConfig.configuration,
335
+ terminalConfigurations: revnetConfig.terminalConfigurations,
336
+ suckerDeploymentConfiguration: revnetConfig.suckerDeploymentConfiguration
337
+ });
338
+
339
+ vm.deal(USER, 1000e18);
340
+ }
341
+
342
+ // =========================================================================
343
+ // Helpers
344
+ // =========================================================================
345
+
346
+ /// @notice Mock loan permissions for a user.
347
+ function _mockLoanPermission(address user) internal {
348
+ mockExpect(
349
+ address(jbPermissions()),
350
+ abi.encodeCall(IJBPermissions.hasPermission, (address(LOANS_CONTRACT), user, REVNET_ID, 11, true, true)),
351
+ abi.encode(true)
352
+ );
353
+ }
354
+
355
+ /// @notice Make the directory return the reverting terminal as the fee terminal for the REV project.
356
+ function _mockRevertingFeeTerminal(address token) internal {
357
+ vm.mockCall(
358
+ address(jbDirectory()),
359
+ abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector, FEE_PROJECT_ID, token),
360
+ abi.encode(address(REVERTING_TERMINAL))
361
+ );
362
+ }
363
+
364
+ /// @notice Borrow against native ETH and return the borrower's balance change.
365
+ function _borrowNative(
366
+ address user,
367
+ uint256 ethAmount,
368
+ uint256 prepaidFee
369
+ )
370
+ internal
371
+ returns (uint256 loanId, uint256 borrowerBalanceBefore, uint256 borrowerBalanceAfter)
372
+ {
373
+ // Pay into revnet to get tokens.
374
+ vm.prank(user);
375
+ uint256 tokenCount =
376
+ jbMultiTerminal().pay{value: ethAmount}(REVNET_ID, JBConstants.NATIVE_TOKEN, ethAmount, user, 0, "", "");
377
+
378
+ _mockLoanPermission(user);
379
+
380
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
381
+
382
+ borrowerBalanceBefore = user.balance;
383
+
384
+ vm.prank(user);
385
+ (loanId,) = LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokenCount, payable(user), prepaidFee);
386
+
387
+ borrowerBalanceAfter = user.balance;
388
+ }
389
+
390
+ // =========================================================================
391
+ // Test: Normal fee payment succeeds (regression — confirm existing behavior)
392
+ // =========================================================================
393
+
394
+ /// @notice When the fee terminal is healthy, the REV fee is deducted from the borrower's payout.
395
+ function test_feePaymentSuccess_nativeToken() public {
396
+ (, uint256 balanceBefore, uint256 balanceAfter) = _borrowNative(USER, 10e18, 25);
397
+
398
+ uint256 received = balanceAfter - balanceBefore;
399
+
400
+ // The borrower should have received something (net of both source fee + REV fee).
401
+ assertGt(received, 0, "Borrower should receive ETH");
402
+
403
+ // No ETH should be stuck in the loans contract.
404
+ assertEq(address(LOANS_CONTRACT).balance, 0, "No ETH stuck in loans contract");
405
+ }
406
+
407
+ // =========================================================================
408
+ // Test: Fee terminal reverts with native ETH — borrower gets fee back
409
+ // =========================================================================
410
+
411
+ /// @notice When feeTerminal.pay() reverts, the borrower receives the REV fee amount back.
412
+ function test_feePaymentFailure_nativeToken_borrowerGetsMoreETH() public {
413
+ // Pay into revnet first so both borrow attempts start from identical state.
414
+ vm.prank(USER);
415
+ uint256 tokenCount =
416
+ jbMultiTerminal().pay{value: 10e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 10e18, USER, 0, "", "");
417
+
418
+ _mockLoanPermission(USER);
419
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
420
+
421
+ // Snapshot state before borrow.
422
+ uint256 snap = vm.snapshotState();
423
+
424
+ // Normal borrow.
425
+ uint256 balBefore = USER.balance;
426
+ vm.prank(USER);
427
+ LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokenCount, payable(USER), 25);
428
+ uint256 normalReceived = USER.balance - balBefore;
429
+
430
+ // Revert to snapshot — identical state.
431
+ vm.revertToState(snap);
432
+
433
+ // Mock the fee terminal to revert.
434
+ _mockRevertingFeeTerminal(JBConstants.NATIVE_TOKEN);
435
+ _mockLoanPermission(USER);
436
+
437
+ balBefore = USER.balance;
438
+ vm.prank(USER);
439
+ LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokenCount, payable(USER), 25);
440
+ uint256 failReceived = USER.balance - balBefore;
441
+
442
+ // The borrower with a failed fee terminal should receive MORE than the normal borrower,
443
+ // because the REV fee (1% of borrow amount) is returned to them.
444
+ assertGt(failReceived, normalReceived, "Failed-fee borrower should receive more ETH than normal borrower");
445
+
446
+ // No ETH should be stuck in the loans contract.
447
+ assertEq(address(LOANS_CONTRACT).balance, 0, "No ETH stuck in loans contract after fee failure");
448
+ }
449
+
450
+ // =========================================================================
451
+ // Test: Fee terminal reverts with native ETH — amount difference matches REV fee
452
+ // =========================================================================
453
+
454
+ /// @notice The extra ETH the borrower receives when the fee terminal reverts matches
455
+ /// the expected REV fee amount (1% of borrow amount).
456
+ function test_feePaymentFailure_nativeToken_exactFeeRecovery() public {
457
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
458
+
459
+ // Pay into revnet.
460
+ vm.prank(USER);
461
+ uint256 tokens =
462
+ jbMultiTerminal().pay{value: 10e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 10e18, USER, 0, "", "");
463
+
464
+ _mockLoanPermission(USER);
465
+
466
+ // Snapshot.
467
+ uint256 snap = vm.snapshotState();
468
+
469
+ // Normal borrow.
470
+ uint256 balBefore = USER.balance;
471
+ vm.prank(USER);
472
+ LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokens, payable(USER), 25);
473
+ uint256 normalReceived = USER.balance - balBefore;
474
+
475
+ // Get the actual borrow amount from the loan to compute expected REV fee.
476
+ // Loan ID = revnetId * 1e12 + loanNumber (first loan = 1).
477
+ REVLoan memory loan = LOANS_CONTRACT.loanOf(REVNET_ID * 1_000_000_000_000 + 1);
478
+ uint256 expectedRevFee =
479
+ JBFees.feeAmountFrom({amountBeforeFee: loan.amount, feePercent: LOANS_CONTRACT.REV_PREPAID_FEE_PERCENT()});
480
+
481
+ // Revert to snapshot.
482
+ vm.revertToState(snap);
483
+
484
+ // Mock fee terminal to revert.
485
+ _mockRevertingFeeTerminal(JBConstants.NATIVE_TOKEN);
486
+ _mockLoanPermission(USER);
487
+
488
+ balBefore = USER.balance;
489
+ vm.prank(USER);
490
+ LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokens, payable(USER), 25);
491
+ uint256 failReceived = USER.balance - balBefore;
492
+
493
+ // The difference should be the REV fee amount.
494
+ uint256 difference = failReceived - normalReceived;
495
+ assertEq(difference, expectedRevFee, "Difference should equal the REV fee amount");
496
+
497
+ // Verify no funds stuck.
498
+ assertEq(address(LOANS_CONTRACT).balance, 0, "No ETH stuck");
499
+ }
500
+
501
+ // =========================================================================
502
+ // Test: Fee terminal reverts with ERC-20 — allowance is cleaned up
503
+ // =========================================================================
504
+
505
+ /// @notice When feeTerminal.pay() reverts for an ERC-20 loan, the dangling allowance
506
+ /// to the fee terminal is removed via safeDecreaseAllowance.
507
+ function test_feePaymentFailure_erc20_allowanceCleaned() public {
508
+ // Mock the fee terminal to revert for the TOKEN.
509
+ _mockRevertingFeeTerminal(address(TOKEN));
510
+
511
+ // Fund user with ERC-20 tokens.
512
+ uint256 payAmount = 1_000_000; // 6 decimals
513
+ deal(address(TOKEN), USER, payAmount);
514
+
515
+ // Pay into revnet with ERC-20.
516
+ vm.startPrank(USER);
517
+ TOKEN.approve(address(jbMultiTerminal()), payAmount);
518
+ uint256 tokenCount = jbMultiTerminal().pay(REVNET_ID, address(TOKEN), payAmount, USER, 0, "", "");
519
+ vm.stopPrank();
520
+
521
+ _mockLoanPermission(USER);
522
+ REVLoanSource memory source = REVLoanSource({token: address(TOKEN), terminal: jbMultiTerminal()});
523
+
524
+ // Check allowance to reverting terminal BEFORE borrow.
525
+ uint256 allowanceBefore = TOKEN.allowance(address(LOANS_CONTRACT), address(REVERTING_TERMINAL));
526
+ assertEq(allowanceBefore, 0, "No pre-existing allowance");
527
+
528
+ vm.prank(USER);
529
+ LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokenCount, payable(USER), 25);
530
+
531
+ // After the borrow, the allowance to the reverting terminal should still be 0
532
+ // (the catch block decreased it).
533
+ uint256 allowanceAfter = TOKEN.allowance(address(LOANS_CONTRACT), address(REVERTING_TERMINAL));
534
+ assertEq(allowanceAfter, 0, "Allowance should be cleaned up after fee failure");
535
+
536
+ // No tokens stuck in the loans contract.
537
+ assertEq(TOKEN.balanceOf(address(LOANS_CONTRACT)), 0, "No ERC-20 stuck in loans contract");
538
+ }
539
+
540
+ // =========================================================================
541
+ // Test: Fee terminal reverts with ERC-20 — borrower gets fee back
542
+ // =========================================================================
543
+
544
+ /// @notice When feeTerminal.pay() reverts for an ERC-20 loan, the borrower receives
545
+ /// the fee amount that would have gone to the REV project.
546
+ function test_feePaymentFailure_erc20_borrowerGetsMoreTokens() public {
547
+ uint256 payAmount = 1_000_000; // 6 decimals
548
+ REVLoanSource memory source = REVLoanSource({token: address(TOKEN), terminal: jbMultiTerminal()});
549
+
550
+ // Pay into revnet with ERC-20.
551
+ deal(address(TOKEN), USER, payAmount);
552
+ vm.startPrank(USER);
553
+ TOKEN.approve(address(jbMultiTerminal()), payAmount);
554
+ uint256 tokens = jbMultiTerminal().pay(REVNET_ID, address(TOKEN), payAmount, USER, 0, "", "");
555
+ vm.stopPrank();
556
+
557
+ _mockLoanPermission(USER);
558
+
559
+ // Snapshot.
560
+ uint256 snap = vm.snapshotState();
561
+
562
+ // Normal borrow.
563
+ uint256 tokenBalBefore = TOKEN.balanceOf(USER);
564
+ vm.prank(USER);
565
+ LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokens, payable(USER), 25);
566
+ uint256 normalReceived = TOKEN.balanceOf(USER) - tokenBalBefore;
567
+
568
+ // Revert to snapshot.
569
+ vm.revertToState(snap);
570
+
571
+ // Mock fee terminal to revert.
572
+ _mockRevertingFeeTerminal(address(TOKEN));
573
+ _mockLoanPermission(USER);
574
+
575
+ tokenBalBefore = TOKEN.balanceOf(USER);
576
+ vm.prank(USER);
577
+ LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokens, payable(USER), 25);
578
+ uint256 failReceived = TOKEN.balanceOf(USER) - tokenBalBefore;
579
+
580
+ // Failed-fee borrower should receive more tokens.
581
+ assertGt(failReceived, normalReceived, "Failed-fee ERC-20 borrower should receive more tokens");
582
+ }
583
+
584
+ // =========================================================================
585
+ // Test: No fee terminal (address(0)) — revFeeAmount is zero, no try/catch
586
+ // =========================================================================
587
+
588
+ /// @notice When no fee terminal exists for the token, revFeeAmount is 0 and no fee is attempted.
589
+ function test_noFeeTerminal_borrowStillWorks() public {
590
+ // Mock the directory to return address(0) for the fee terminal.
591
+ vm.mockCall(
592
+ address(jbDirectory()),
593
+ abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector, FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN),
594
+ abi.encode(address(0))
595
+ );
596
+
597
+ // Borrow should still work — no fee is taken.
598
+ (, uint256 balanceBefore, uint256 balanceAfter) = _borrowNative(USER, 10e18, 25);
599
+ uint256 received = balanceAfter - balanceBefore;
600
+ assertGt(received, 0, "Borrower should receive ETH even without fee terminal");
601
+
602
+ // No ETH stuck.
603
+ assertEq(address(LOANS_CONTRACT).balance, 0, "No ETH stuck");
604
+ }
605
+
606
+ // =========================================================================
607
+ // Test: Multiple borrows with fee failure — no cumulative stuck funds
608
+ // =========================================================================
609
+
610
+ /// @notice After multiple borrows where the fee terminal reverts, no funds accumulate
611
+ /// in the loans contract.
612
+ function test_feePaymentFailure_multipleBorrows_noStuckFunds() public {
613
+ _mockRevertingFeeTerminal(JBConstants.NATIVE_TOKEN);
614
+
615
+ for (uint256 i; i < 3; i++) {
616
+ address borrower = makeAddr(string(abi.encodePacked("borrower", i)));
617
+ vm.deal(borrower, 100e18);
618
+
619
+ vm.prank(borrower);
620
+ uint256 tokens =
621
+ jbMultiTerminal().pay{value: 5e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 5e18, borrower, 0, "", "");
622
+
623
+ mockExpect(
624
+ address(jbPermissions()),
625
+ abi.encodeCall(
626
+ IJBPermissions.hasPermission, (address(LOANS_CONTRACT), borrower, REVNET_ID, 11, true, true)
627
+ ),
628
+ abi.encode(true)
629
+ );
630
+
631
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
632
+
633
+ vm.prank(borrower);
634
+ LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokens, payable(borrower), 25);
635
+ }
636
+
637
+ // After 3 borrows with fee failures, no ETH should be stuck.
638
+ assertEq(address(LOANS_CONTRACT).balance, 0, "No ETH stuck after multiple fee-failed borrows");
639
+ }
640
+
641
+ // =========================================================================
642
+ // Fuzz: Fee recovery always returns correct amount to borrower
643
+ // =========================================================================
644
+
645
+ /// @notice Fuzz test: regardless of the borrow amount, when the fee terminal reverts,
646
+ /// the borrower always receives the full netAmountPaidOut minus only the source fee.
647
+ function test_fuzz_feeRecovery_nativeToken(uint256 payAmount) public {
648
+ // Bound to reasonable range. Need enough to get a nonzero borrow.
649
+ payAmount = bound(payAmount, 1e16, 100e18);
650
+
651
+ _mockRevertingFeeTerminal(JBConstants.NATIVE_TOKEN);
652
+
653
+ address borrower = makeAddr("fuzzBorrower");
654
+ vm.deal(borrower, payAmount + 1e18);
655
+
656
+ vm.prank(borrower);
657
+ uint256 tokens = jbMultiTerminal().pay{value: payAmount}(
658
+ REVNET_ID, JBConstants.NATIVE_TOKEN, payAmount, borrower, 0, "", ""
659
+ );
660
+
661
+ uint256 borrowable =
662
+ LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
663
+
664
+ // Skip if not enough surplus to borrow.
665
+ if (borrowable == 0) return;
666
+
667
+ _mockLoanPermission(borrower);
668
+ REVLoanSource memory source = REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
669
+
670
+ uint256 balanceBefore = borrower.balance;
671
+ vm.prank(borrower);
672
+ LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokens, payable(borrower), 25);
673
+ uint256 received = borrower.balance - balanceBefore;
674
+
675
+ // The borrower should always receive something.
676
+ assertGt(received, 0, "Borrower should receive ETH in fuzz");
677
+
678
+ // No funds stuck.
679
+ assertEq(address(LOANS_CONTRACT).balance, 0, "No ETH stuck in fuzz");
680
+ }
681
+
682
+ // =========================================================================
683
+ // Test: Fee recovery on native token — ETH returned from failed call
684
+ // =========================================================================
685
+
686
+ /// @notice Verifies that when a native-token fee terminal call reverts, the ETH sent
687
+ /// with the call is returned to REVLoans and forwarded to the borrower.
688
+ /// The reverting terminal should NOT hold any ETH.
689
+ function test_feePaymentFailure_nativeToken_revertingTerminalHoldsNoETH() public {
690
+ _mockRevertingFeeTerminal(JBConstants.NATIVE_TOKEN);
691
+
692
+ uint256 revertingTerminalBalanceBefore = address(REVERTING_TERMINAL).balance;
693
+
694
+ _borrowNative(USER, 10e18, 25);
695
+
696
+ // The reverting terminal should not have received any ETH.
697
+ assertEq(
698
+ address(REVERTING_TERMINAL).balance, revertingTerminalBalanceBefore, "Reverting terminal should hold no ETH"
699
+ );
700
+
701
+ // No ETH stuck in loans contract.
702
+ assertEq(address(LOANS_CONTRACT).balance, 0, "No ETH stuck in loans contract");
703
+ }
704
+ }