@rev-net/core-v6 0.0.11 → 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 (81) hide show
  1. package/ADMINISTRATION.md +7 -7
  2. package/ARCHITECTURE.md +11 -11
  3. package/AUDIT_INSTRUCTIONS.md +295 -0
  4. package/CHANGE_LOG.md +316 -0
  5. package/README.md +9 -6
  6. package/RISKS.md +180 -35
  7. package/SKILLS.md +9 -11
  8. package/STYLE_GUIDE.md +14 -1
  9. package/USER_JOURNEYS.md +489 -0
  10. package/package.json +9 -9
  11. package/script/Deploy.s.sol +124 -40
  12. package/script/helpers/RevnetCoreDeploymentLib.sol +19 -6
  13. package/src/REVDeployer.sol +183 -175
  14. package/src/REVLoans.sol +65 -28
  15. package/src/interfaces/IREVDeployer.sol +25 -23
  16. package/src/structs/REV721TiersHookFlags.sol +1 -0
  17. package/src/structs/REVAutoIssuance.sol +1 -0
  18. package/src/structs/REVBaseline721HookConfig.sol +1 -0
  19. package/src/structs/REVConfig.sol +1 -0
  20. package/src/structs/REVCroptopAllowedPost.sol +1 -0
  21. package/src/structs/REVDeploy721TiersHookConfig.sol +13 -14
  22. package/src/structs/REVDescription.sol +1 -0
  23. package/src/structs/REVLoan.sol +1 -0
  24. package/src/structs/REVLoanSource.sol +1 -0
  25. package/src/structs/REVStageConfig.sol +1 -0
  26. package/src/structs/REVSuckerDeploymentConfig.sol +1 -0
  27. package/test/REV.integrations.t.sol +148 -19
  28. package/test/REVAutoIssuanceFuzz.t.sol +31 -6
  29. package/test/REVDeployerRegressions.t.sol +47 -9
  30. package/test/REVInvincibility.t.sol +83 -19
  31. package/test/REVInvincibilityHandler.sol +29 -0
  32. package/test/REVLifecycle.t.sol +36 -6
  33. package/test/REVLoans.invariants.t.sol +64 -10
  34. package/test/REVLoansAttacks.t.sol +54 -9
  35. package/test/REVLoansFeeRecovery.t.sol +61 -15
  36. package/test/REVLoansFindings.t.sol +42 -9
  37. package/test/REVLoansRegressions.t.sol +33 -6
  38. package/test/REVLoansSourceFeeRecovery.t.sol +491 -0
  39. package/test/REVLoansSourced.t.sol +79 -17
  40. package/test/REVLoansUnSourced.t.sol +61 -10
  41. package/test/TestBurnHeldTokens.t.sol +47 -11
  42. package/test/TestCEIPattern.t.sol +37 -6
  43. package/test/TestCashOutCallerValidation.t.sol +41 -8
  44. package/test/TestConversionDocumentation.t.sol +50 -13
  45. package/test/TestCrossCurrencyReclaim.t.sol +584 -0
  46. package/test/TestCrossSourceReallocation.t.sol +37 -6
  47. package/test/TestERC2771MetaTx.t.sol +557 -0
  48. package/test/TestEmptyBuybackSpecs.t.sol +45 -10
  49. package/test/TestFlashLoanSurplus.t.sol +39 -7
  50. package/test/TestHookArrayOOB.t.sol +42 -13
  51. package/test/TestLiquidationBehavior.t.sol +37 -7
  52. package/test/TestLoanSourceRotation.t.sol +525 -0
  53. package/test/TestLongTailEconomics.t.sol +651 -0
  54. package/test/TestLowFindings.t.sol +80 -8
  55. package/test/TestMixedFixes.t.sol +43 -9
  56. package/test/TestPermit2Signatures.t.sol +657 -0
  57. package/test/TestReallocationSandwich.t.sol +384 -0
  58. package/test/TestRevnetRegressions.t.sol +324 -0
  59. package/test/TestSplitWeightAdjustment.t.sol +52 -13
  60. package/test/TestSplitWeightE2E.t.sol +53 -18
  61. package/test/TestSplitWeightFork.t.sol +66 -21
  62. package/test/TestStageTransitionBorrowable.t.sol +38 -6
  63. package/test/TestSwapTerminalPermission.t.sol +37 -7
  64. package/test/TestUint112Overflow.t.sol +39 -6
  65. package/test/TestZeroRepayment.t.sol +37 -6
  66. package/test/fork/ForkTestBase.sol +66 -17
  67. package/test/fork/TestCashOutFork.t.sol +9 -3
  68. package/test/fork/TestLoanBorrowFork.t.sol +1 -0
  69. package/test/fork/TestLoanCrossRulesetFork.t.sol +11 -3
  70. package/test/fork/TestLoanLiquidationFork.t.sol +1 -0
  71. package/test/fork/TestLoanReallocateFork.t.sol +1 -0
  72. package/test/fork/TestLoanRepayFork.t.sol +1 -0
  73. package/test/fork/TestLoanTransferFork.t.sol +133 -0
  74. package/test/fork/TestSplitWeightFork.t.sol +3 -0
  75. package/test/helpers/REVEmpty721Config.sol +46 -0
  76. package/test/mock/MockBuybackDataHook.sol +1 -0
  77. package/test/regression/TestBurnPermissionRequired.t.sol +267 -0
  78. package/test/regression/TestCrossRevnetLiquidation.t.sol +228 -0
  79. package/test/regression/TestCumulativeLoanCounter.t.sol +38 -8
  80. package/test/regression/TestLiquidateGapHandling.t.sol +40 -8
  81. package/test/regression/TestZeroPriceFeed.t.sol +396 -0
@@ -0,0 +1,657 @@
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
+
14
+ // forge-lint: disable-next-line(unaliased-plain-import)
15
+ import "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
16
+ // forge-lint: disable-next-line(unaliased-plain-import)
17
+ import "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
18
+ // forge-lint: disable-next-line(unaliased-plain-import)
19
+ import "@bananapus/suckers-v6/script/helpers/SuckerDeploymentLib.sol";
20
+ // forge-lint: disable-next-line(unaliased-plain-import)
21
+ import "@croptop/core-v6/script/helpers/CroptopDeploymentLib.sol";
22
+ // forge-lint: disable-next-line(unaliased-plain-import)
23
+ import "@bananapus/router-terminal-v6/script/helpers/RouterTerminalDeploymentLib.sol";
24
+
25
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
26
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
27
+ import {JBSingleAllowance} from "@bananapus/core-v6/src/structs/JBSingleAllowance.sol";
28
+ import {MockPriceFeed} from "@bananapus/core-v6/test/mock/MockPriceFeed.sol";
29
+ import {MockERC20} from "@bananapus/core-v6/test/mock/MockERC20.sol";
30
+ import {REVLoans} from "../src/REVLoans.sol";
31
+ import {REVLoan} from "../src/structs/REVLoan.sol";
32
+ import {REVStageConfig, REVAutoIssuance} from "../src/structs/REVStageConfig.sol";
33
+ import {REVLoanSource} from "../src/structs/REVLoanSource.sol";
34
+ import {REVDescription} from "../src/structs/REVDescription.sol";
35
+ import {IREVLoans} from "./../src/interfaces/IREVLoans.sol";
36
+ import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
37
+ import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
38
+ import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
39
+ import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
40
+ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
41
+ import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
42
+ import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
43
+ import {REVEmpty721Config} from "./helpers/REVEmpty721Config.sol";
44
+ import {IAllowanceTransfer} from "@uniswap/permit2/src/interfaces/IAllowanceTransfer.sol";
45
+ import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
46
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
47
+ import {JBFees} from "@bananapus/core-v6/src/libraries/JBFees.sol";
48
+
49
+ struct Permit2ProjectConfig {
50
+ REVConfig configuration;
51
+ JBTerminalConfig[] terminalConfigurations;
52
+ REVSuckerDeploymentConfig suckerDeploymentConfiguration;
53
+ }
54
+
55
+ /// @title TestPermit2Signatures
56
+ /// @notice Tests that REVLoans.repayLoan() works correctly with real Permit2 signatures,
57
+ /// including happy-path repayment, expired signatures, and wrong-signer scenarios.
58
+ contract TestPermit2Signatures is TestBaseWorkflow {
59
+ // forge-lint: disable-next-line(mixed-case-variable)
60
+ bytes32 REV_DEPLOYER_SALT = "REVDeployer";
61
+ // forge-lint: disable-next-line(mixed-case-variable)
62
+ bytes32 ERC20_SALT = "REV_TOKEN";
63
+
64
+ // forge-lint: disable-next-line(mixed-case-variable)
65
+ REVDeployer REV_DEPLOYER;
66
+ // forge-lint: disable-next-line(mixed-case-variable)
67
+ JB721TiersHook EXAMPLE_HOOK;
68
+ // forge-lint: disable-next-line(mixed-case-variable)
69
+ IJB721TiersHookDeployer HOOK_DEPLOYER;
70
+ // forge-lint: disable-next-line(mixed-case-variable)
71
+ IJB721TiersHookStore HOOK_STORE;
72
+ // forge-lint: disable-next-line(mixed-case-variable)
73
+ IJBAddressRegistry ADDRESS_REGISTRY;
74
+ // forge-lint: disable-next-line(mixed-case-variable)
75
+ IREVLoans LOANS_CONTRACT;
76
+ // forge-lint: disable-next-line(mixed-case-variable)
77
+ MockERC20 TOKEN;
78
+ // forge-lint: disable-next-line(mixed-case-variable)
79
+ IJBSuckerRegistry SUCKER_REGISTRY;
80
+ // forge-lint: disable-next-line(mixed-case-variable)
81
+ CTPublisher PUBLISHER;
82
+ // forge-lint: disable-next-line(mixed-case-variable)
83
+ MockBuybackDataHook MOCK_BUYBACK;
84
+
85
+ // forge-lint: disable-next-line(mixed-case-variable)
86
+ uint256 FEE_PROJECT_ID;
87
+ // forge-lint: disable-next-line(mixed-case-variable)
88
+ uint256 REVNET_ID;
89
+
90
+ address private constant TRUSTED_FORWARDER = 0xB2b5841DBeF766d4b521221732F9B618fCf34A87;
91
+
92
+ // Permit2 signature type hashes (from nana-core-v6 TestPermit2Terminal).
93
+ bytes32 public constant _PERMIT_DETAILS_TYPEHASH =
94
+ keccak256("PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)");
95
+
96
+ bytes32 public constant _PERMIT_SINGLE_TYPEHASH = keccak256(
97
+ "PermitSingle(PermitDetails details,address spender,uint256 sigDeadline)PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)"
98
+ );
99
+
100
+ // Permit2 domain separator, set in setUp.
101
+ // forge-lint: disable-next-line(mixed-case-variable)
102
+ bytes32 DOMAIN_SEPARATOR;
103
+
104
+ // Private key and derived address for signing.
105
+ uint256 private signerPrivateKey = 0x12341234;
106
+ address private signer;
107
+
108
+ function _getFeeProjectConfig() internal view returns (Permit2ProjectConfig memory) {
109
+ string memory name = "Revnet";
110
+ string memory symbol = "$REV";
111
+ string memory projectUri = "ipfs://QmNRHT91HcDgMcenebYX7rJigt77cgNcosvuhX21wkF3tx";
112
+ uint8 decimals = 18;
113
+ uint256 decimalMultiplier = 10 ** decimals;
114
+
115
+ JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](2);
116
+ accountingContextsToAccept[0] = JBAccountingContext({
117
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
118
+ });
119
+ accountingContextsToAccept[1] =
120
+ JBAccountingContext({token: address(TOKEN), decimals: 6, currency: uint32(uint160(address(TOKEN)))});
121
+
122
+ JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
123
+ terminalConfigurations[0] =
124
+ JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: accountingContextsToAccept});
125
+
126
+ REVStageConfig[] memory stageConfigurations = new REVStageConfig[](1);
127
+ JBSplit[] memory splits = new JBSplit[](1);
128
+ splits[0].beneficiary = payable(multisig());
129
+ splits[0].percent = 10_000;
130
+
131
+ REVAutoIssuance[] memory issuanceConfs = new REVAutoIssuance[](1);
132
+ issuanceConfs[0] = REVAutoIssuance({
133
+ // forge-lint: disable-next-line(unsafe-typecast)
134
+ chainId: uint32(block.chainid),
135
+ // forge-lint: disable-next-line(unsafe-typecast)
136
+ count: uint104(70_000 * decimalMultiplier),
137
+ beneficiary: multisig()
138
+ });
139
+
140
+ stageConfigurations[0] = REVStageConfig({
141
+ startsAtOrAfter: uint40(block.timestamp),
142
+ autoIssuances: issuanceConfs,
143
+ splitPercent: 2000,
144
+ splits: splits,
145
+ // forge-lint: disable-next-line(unsafe-typecast)
146
+ initialIssuance: uint112(1000 * decimalMultiplier),
147
+ issuanceCutFrequency: 90 days,
148
+ issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
149
+ cashOutTaxRate: 6000,
150
+ extraMetadata: 0
151
+ });
152
+
153
+ REVConfig memory revnetConfiguration = REVConfig({
154
+ // forge-lint: disable-next-line(named-struct-fields)
155
+ description: REVDescription(name, symbol, projectUri, ERC20_SALT),
156
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
157
+ splitOperator: multisig(),
158
+ stageConfigurations: stageConfigurations
159
+ });
160
+
161
+ return Permit2ProjectConfig({
162
+ configuration: revnetConfiguration,
163
+ terminalConfigurations: terminalConfigurations,
164
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
165
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256(abi.encodePacked("REV"))
166
+ })
167
+ });
168
+ }
169
+
170
+ function _getRevnetConfig() internal view returns (Permit2ProjectConfig memory) {
171
+ string memory name = "NANA";
172
+ string memory symbol = "$NANA";
173
+ string memory projectUri = "ipfs://QmNRHT91HcDgMcenebYX7rJigt77cgNxosvuhX21wkF3tx";
174
+ uint8 decimals = 18;
175
+ uint256 decimalMultiplier = 10 ** decimals;
176
+
177
+ JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](2);
178
+ accountingContextsToAccept[0] = JBAccountingContext({
179
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
180
+ });
181
+ accountingContextsToAccept[1] =
182
+ JBAccountingContext({token: address(TOKEN), decimals: 6, currency: uint32(uint160(address(TOKEN)))});
183
+
184
+ JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
185
+ terminalConfigurations[0] =
186
+ JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: accountingContextsToAccept});
187
+
188
+ JBSplit[] memory splits = new JBSplit[](1);
189
+ splits[0].beneficiary = payable(multisig());
190
+ splits[0].percent = 10_000;
191
+
192
+ REVStageConfig[] memory stageConfigurations = new REVStageConfig[](1);
193
+ REVAutoIssuance[] memory issuanceConfs = new REVAutoIssuance[](1);
194
+ issuanceConfs[0] = REVAutoIssuance({
195
+ // forge-lint: disable-next-line(unsafe-typecast)
196
+ chainId: uint32(block.chainid),
197
+ // forge-lint: disable-next-line(unsafe-typecast)
198
+ count: uint104(70_000 * decimalMultiplier),
199
+ beneficiary: multisig()
200
+ });
201
+
202
+ stageConfigurations[0] = REVStageConfig({
203
+ startsAtOrAfter: uint40(block.timestamp),
204
+ autoIssuances: issuanceConfs,
205
+ splitPercent: 2000,
206
+ splits: splits,
207
+ // forge-lint: disable-next-line(unsafe-typecast)
208
+ initialIssuance: uint112(1000 * decimalMultiplier),
209
+ issuanceCutFrequency: 90 days,
210
+ issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
211
+ cashOutTaxRate: 6000,
212
+ extraMetadata: 0
213
+ });
214
+
215
+ REVConfig memory revnetConfiguration = REVConfig({
216
+ // forge-lint: disable-next-line(named-struct-fields)
217
+ description: REVDescription(name, symbol, projectUri, "NANA_TOKEN"),
218
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
219
+ splitOperator: multisig(),
220
+ stageConfigurations: stageConfigurations
221
+ });
222
+
223
+ return Permit2ProjectConfig({
224
+ configuration: revnetConfiguration,
225
+ terminalConfigurations: terminalConfigurations,
226
+ suckerDeploymentConfiguration: REVSuckerDeploymentConfig({
227
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: keccak256(abi.encodePacked("NANA"))
228
+ })
229
+ });
230
+ }
231
+
232
+ function setUp() public override {
233
+ super.setUp();
234
+
235
+ signer = vm.addr(signerPrivateKey);
236
+
237
+ FEE_PROJECT_ID = jbProjects().createFor(multisig());
238
+
239
+ SUCKER_REGISTRY = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
240
+ HOOK_STORE = new JB721TiersHookStore();
241
+ EXAMPLE_HOOK = new JB721TiersHook(
242
+ jbDirectory(), jbPermissions(), jbPrices(), jbRulesets(), HOOK_STORE, jbSplits(), multisig()
243
+ );
244
+ ADDRESS_REGISTRY = new JBAddressRegistry();
245
+ HOOK_DEPLOYER = new JB721TiersHookDeployer(EXAMPLE_HOOK, HOOK_STORE, ADDRESS_REGISTRY, multisig());
246
+ PUBLISHER = new CTPublisher(jbDirectory(), jbPermissions(), FEE_PROJECT_ID, multisig());
247
+ MOCK_BUYBACK = new MockBuybackDataHook();
248
+ TOKEN = new MockERC20("1/2 ETH", "1/2");
249
+
250
+ MockPriceFeed priceFeed = new MockPriceFeed(1e21, 6);
251
+ vm.prank(multisig());
252
+ jbPrices()
253
+ .addPriceFeedFor(0, uint32(uint160(address(TOKEN))), uint32(uint160(JBConstants.NATIVE_TOKEN)), priceFeed);
254
+
255
+ LOANS_CONTRACT = new REVLoans({
256
+ controller: jbController(),
257
+ projects: jbProjects(),
258
+ revId: FEE_PROJECT_ID,
259
+ owner: address(this),
260
+ permit2: permit2(),
261
+ trustedForwarder: TRUSTED_FORWARDER
262
+ });
263
+
264
+ REV_DEPLOYER = new REVDeployer{salt: REV_DEPLOYER_SALT}(
265
+ jbController(),
266
+ SUCKER_REGISTRY,
267
+ FEE_PROJECT_ID,
268
+ HOOK_DEPLOYER,
269
+ PUBLISHER,
270
+ IJBBuybackHookRegistry(address(MOCK_BUYBACK)),
271
+ address(LOANS_CONTRACT),
272
+ TRUSTED_FORWARDER
273
+ );
274
+
275
+ // Approve the basic deployer to configure the project.
276
+ vm.prank(address(multisig()));
277
+ jbProjects().approve(address(REV_DEPLOYER), FEE_PROJECT_ID);
278
+
279
+ // Deploy fee project.
280
+ Permit2ProjectConfig memory feeProjectConfig = _getFeeProjectConfig();
281
+ vm.prank(address(multisig()));
282
+ REV_DEPLOYER.deployFor({
283
+ revnetId: FEE_PROJECT_ID,
284
+ configuration: feeProjectConfig.configuration,
285
+ terminalConfigurations: feeProjectConfig.terminalConfigurations,
286
+ suckerDeploymentConfiguration: feeProjectConfig.suckerDeploymentConfiguration,
287
+ tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
288
+ allowedPosts: REVEmpty721Config.emptyAllowedPosts()
289
+ });
290
+
291
+ // Deploy second revnet with loans enabled.
292
+ Permit2ProjectConfig memory revnetConfig = _getRevnetConfig();
293
+ (REVNET_ID,) = REV_DEPLOYER.deployFor({
294
+ revnetId: 0,
295
+ configuration: revnetConfig.configuration,
296
+ terminalConfigurations: revnetConfig.terminalConfigurations,
297
+ suckerDeploymentConfiguration: revnetConfig.suckerDeploymentConfiguration,
298
+ tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
299
+ allowedPosts: REVEmpty721Config.emptyAllowedPosts()
300
+ });
301
+
302
+ // Set the Permit2 domain separator.
303
+ DOMAIN_SEPARATOR = permit2().DOMAIN_SEPARATOR();
304
+
305
+ // Fund the signer.
306
+ vm.deal(signer, 1000e18);
307
+ }
308
+
309
+ // =========================================================================
310
+ // Permit2 signature helpers (from nana-core-v6/test/TestPermit2Terminal.sol)
311
+ // =========================================================================
312
+
313
+ function _getPermitSignatureRaw(
314
+ IAllowanceTransfer.PermitSingle memory permitSingle,
315
+ uint256 privateKey,
316
+ bytes32 domainSeparator
317
+ )
318
+ internal
319
+ pure
320
+ returns (uint8 v, bytes32 r, bytes32 s)
321
+ {
322
+ bytes32 permitHash = keccak256(abi.encode(_PERMIT_DETAILS_TYPEHASH, permitSingle.details));
323
+
324
+ bytes32 msgHash = keccak256(
325
+ abi.encodePacked(
326
+ "\x19\x01",
327
+ domainSeparator,
328
+ keccak256(
329
+ abi.encode(_PERMIT_SINGLE_TYPEHASH, permitHash, permitSingle.spender, permitSingle.sigDeadline)
330
+ )
331
+ )
332
+ );
333
+
334
+ (v, r, s) = vm.sign(privateKey, msgHash);
335
+ }
336
+
337
+ function _getPermitSignature(
338
+ IAllowanceTransfer.PermitSingle memory permitSingle,
339
+ uint256 privateKey,
340
+ bytes32 domainSeparator
341
+ )
342
+ internal
343
+ pure
344
+ returns (bytes memory sig)
345
+ {
346
+ (uint8 v, bytes32 r, bytes32 s) = _getPermitSignatureRaw(permitSingle, privateKey, domainSeparator);
347
+ return bytes.concat(r, s, bytes1(v));
348
+ }
349
+
350
+ // =========================================================================
351
+ // Helper: create a loan from ERC20 tokens (not native)
352
+ // =========================================================================
353
+
354
+ function _setupERC20Loan(
355
+ address user,
356
+ uint256 tokenAmount,
357
+ uint256 prepaidFee
358
+ )
359
+ internal
360
+ returns (uint256 loanId, uint256 tokenCount, uint256 borrowAmount)
361
+ {
362
+ // Deal the user ERC20 tokens and approve the terminal.
363
+ deal(address(TOKEN), user, tokenAmount);
364
+ vm.prank(user);
365
+ TOKEN.approve(address(jbMultiTerminal()), tokenAmount);
366
+
367
+ // Pay into revnet to get project tokens.
368
+ vm.prank(user);
369
+ tokenCount = jbMultiTerminal().pay(REVNET_ID, address(TOKEN), tokenAmount, user, 0, "", "");
370
+
371
+ // Check borrowable amount.
372
+ borrowAmount = LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokenCount, 6, uint32(uint160(address(TOKEN))));
373
+
374
+ if (borrowAmount == 0) return (0, tokenCount, 0);
375
+
376
+ // Mock permission for loans contract to burn tokens.
377
+ mockExpect(
378
+ address(jbPermissions()),
379
+ abi.encodeCall(IJBPermissions.hasPermission, (address(LOANS_CONTRACT), user, REVNET_ID, 11, true, true)),
380
+ abi.encode(true)
381
+ );
382
+
383
+ REVLoanSource memory source = REVLoanSource({token: address(TOKEN), terminal: jbMultiTerminal()});
384
+
385
+ vm.prank(user);
386
+ (loanId,) = LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokenCount, payable(user), prepaidFee);
387
+ }
388
+
389
+ // =========================================================================
390
+ // Test: repay loan with a real Permit2 signature
391
+ // =========================================================================
392
+
393
+ /// @notice Repays a loan using a real Permit2 signature instead of direct ERC20 approval.
394
+ /// @dev Verifies end-to-end Permit2 flow: sign -> repayLoan -> funds transferred.
395
+ function test_permit2_realSignature_repayLoan() public {
396
+ // Create a loan from the signer's ERC20 tokens.
397
+ uint256 payAmount = 100e6; // 100 USDC-like tokens (6 decimals).
398
+ (uint256 loanId, uint256 tokenCount, uint256 borrowAmount) =
399
+ _setupERC20Loan(signer, payAmount, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
400
+
401
+ // Skip if borrowable amount was zero.
402
+ vm.assume(borrowAmount > 0);
403
+ vm.assume(tokenCount > 0);
404
+ vm.assume(loanId > 0);
405
+
406
+ // Get loan details.
407
+ REVLoan memory loan = LOANS_CONTRACT.loanOf(loanId);
408
+ assertTrue(loan.amount > 0, "Loan amount should be non-zero");
409
+
410
+ // Calculate repay amount (loan amount + source fee for immediate repay).
411
+ uint256 sourceFee = LOANS_CONTRACT.determineSourceFeeAmount(loan, loan.amount);
412
+ uint256 totalRepay = loan.amount + sourceFee;
413
+
414
+ // Give the signer enough ERC20 tokens to repay.
415
+ deal(address(TOKEN), signer, totalRepay * 2);
416
+
417
+ // Approve Permit2 contract to spend the signer's tokens (step 1 of Permit2 flow).
418
+ vm.prank(signer);
419
+ TOKEN.approve(address(permit2()), type(uint256).max);
420
+
421
+ // Build and sign a Permit2 allowance for REVLoans to spend tokens.
422
+ uint48 expiration = uint48(block.timestamp + 1 hours);
423
+ uint256 sigDeadline = block.timestamp + 1 hours;
424
+ // forge-lint: disable-next-line(unsafe-typecast)
425
+ uint160 permitAmount = uint160(totalRepay * 2);
426
+
427
+ IAllowanceTransfer.PermitSingle memory permitSingle = IAllowanceTransfer.PermitSingle({
428
+ details: IAllowanceTransfer.PermitDetails({
429
+ token: address(TOKEN), amount: permitAmount, expiration: expiration, nonce: 0
430
+ }),
431
+ spender: address(LOANS_CONTRACT),
432
+ sigDeadline: sigDeadline
433
+ });
434
+
435
+ bytes memory sig = _getPermitSignature(permitSingle, signerPrivateKey, DOMAIN_SEPARATOR);
436
+
437
+ // Build the JBSingleAllowance with the real signature.
438
+ JBSingleAllowance memory allowance = JBSingleAllowance({
439
+ sigDeadline: sigDeadline, amount: permitAmount, expiration: expiration, nonce: uint48(0), signature: sig
440
+ });
441
+
442
+ // Record balances before repayment.
443
+ uint256 signerBalanceBefore = TOKEN.balanceOf(signer);
444
+ uint256 totalCollateralBefore = LOANS_CONTRACT.totalCollateralOf(REVNET_ID);
445
+
446
+ // Repay the loan using the Permit2 signature (no direct ERC20 approval to LOANS_CONTRACT).
447
+ vm.prank(signer);
448
+ LOANS_CONTRACT.repayLoan({
449
+ loanId: loanId,
450
+ maxRepayBorrowAmount: totalRepay * 2,
451
+ collateralCountToReturn: loan.collateral,
452
+ beneficiary: payable(signer),
453
+ allowance: allowance
454
+ });
455
+
456
+ // Verify repayment succeeded: signer spent tokens.
457
+ uint256 signerBalanceAfter = TOKEN.balanceOf(signer);
458
+ assertTrue(signerBalanceAfter < signerBalanceBefore, "Signer should have spent tokens on repayment");
459
+
460
+ // Verify collateral was returned.
461
+ uint256 totalCollateralAfter = LOANS_CONTRACT.totalCollateralOf(REVNET_ID);
462
+ assertEq(totalCollateralAfter, totalCollateralBefore - loan.collateral, "Collateral should be returned");
463
+ }
464
+
465
+ // =========================================================================
466
+ // Test: expired Permit2 signature reverts
467
+ // =========================================================================
468
+
469
+ /// @notice Verifies that an expired Permit2 signature causes the repayLoan call to revert.
470
+ /// @dev The Permit2 contract rejects signatures whose sigDeadline has passed.
471
+ function test_permit2_expiredSignature_reverts() public {
472
+ // Create a loan.
473
+ uint256 payAmount = 100e6;
474
+ (uint256 loanId, uint256 tokenCount, uint256 borrowAmount) =
475
+ _setupERC20Loan(signer, payAmount, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
476
+
477
+ vm.assume(borrowAmount > 0);
478
+ vm.assume(tokenCount > 0);
479
+ vm.assume(loanId > 0);
480
+
481
+ REVLoan memory loan = LOANS_CONTRACT.loanOf(loanId);
482
+ uint256 sourceFee = LOANS_CONTRACT.determineSourceFeeAmount(loan, loan.amount);
483
+ uint256 totalRepay = loan.amount + sourceFee;
484
+
485
+ // Give tokens and approve Permit2.
486
+ deal(address(TOKEN), signer, totalRepay * 2);
487
+ vm.prank(signer);
488
+ TOKEN.approve(address(permit2()), type(uint256).max);
489
+
490
+ // Sign with a deadline that is already expired.
491
+ uint256 expiredDeadline = block.timestamp - 1;
492
+ uint48 expiration = uint48(block.timestamp + 1 hours);
493
+ // forge-lint: disable-next-line(unsafe-typecast)
494
+ uint160 permitAmount = uint160(totalRepay * 2);
495
+
496
+ IAllowanceTransfer.PermitSingle memory permitSingle = IAllowanceTransfer.PermitSingle({
497
+ details: IAllowanceTransfer.PermitDetails({
498
+ token: address(TOKEN), amount: permitAmount, expiration: expiration, nonce: 0
499
+ }),
500
+ spender: address(LOANS_CONTRACT),
501
+ sigDeadline: expiredDeadline
502
+ });
503
+
504
+ bytes memory sig = _getPermitSignature(permitSingle, signerPrivateKey, DOMAIN_SEPARATOR);
505
+
506
+ JBSingleAllowance memory allowance = JBSingleAllowance({
507
+ sigDeadline: expiredDeadline, amount: permitAmount, expiration: expiration, nonce: uint48(0), signature: sig
508
+ });
509
+
510
+ // The Permit2 try-catch in _acceptFundsFor swallows the permit error,
511
+ // so the permit call itself does not revert. However, the subsequent _transferFrom
512
+ // will attempt PERMIT2.transferFrom which requires the allowance to have been set.
513
+ // Since the expired permit did not set the allowance, _transferFrom will revert.
514
+ vm.prank(signer);
515
+ vm.expectRevert();
516
+ LOANS_CONTRACT.repayLoan({
517
+ loanId: loanId,
518
+ maxRepayBorrowAmount: totalRepay * 2,
519
+ collateralCountToReturn: loan.collateral,
520
+ beneficiary: payable(signer),
521
+ allowance: allowance
522
+ });
523
+ }
524
+
525
+ // =========================================================================
526
+ // Test: wrong signer — signature signed by A, call from B
527
+ // =========================================================================
528
+
529
+ /// @notice Verifies that a Permit2 signature signed by one key cannot be used by a different address.
530
+ /// @dev The permit2.permit() call checks that the signature matches the `owner` parameter,
531
+ /// which is _msgSender() in _acceptFundsFor. If they do not match, the permit fails,
532
+ /// and the subsequent transfer also fails since no allowance was set.
533
+ function test_permit2_wrongSigner_reverts() public {
534
+ // Create a loan owned by the signer.
535
+ uint256 payAmount = 100e6;
536
+ (uint256 loanId, uint256 tokenCount, uint256 borrowAmount) =
537
+ _setupERC20Loan(signer, payAmount, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
538
+
539
+ vm.assume(borrowAmount > 0);
540
+ vm.assume(tokenCount > 0);
541
+ vm.assume(loanId > 0);
542
+
543
+ REVLoan memory loan = LOANS_CONTRACT.loanOf(loanId);
544
+ uint256 sourceFee = LOANS_CONTRACT.determineSourceFeeAmount(loan, loan.amount);
545
+ uint256 totalRepay = loan.amount + sourceFee;
546
+
547
+ // Use a completely different private key for signing.
548
+ uint256 wrongPrivateKey = 0xDEADBEEF;
549
+ address wrongSigner = vm.addr(wrongPrivateKey);
550
+
551
+ // Transfer the loan NFT to the wrongSigner so they can call repayLoan.
552
+ vm.prank(signer);
553
+ REVLoans(payable(address(LOANS_CONTRACT))).transferFrom(signer, wrongSigner, loanId);
554
+
555
+ // Give tokens to the wrongSigner and approve Permit2.
556
+ deal(address(TOKEN), wrongSigner, totalRepay * 2);
557
+ vm.prank(wrongSigner);
558
+ TOKEN.approve(address(permit2()), type(uint256).max);
559
+
560
+ // Sign the permit with the ORIGINAL signer's key (not wrongSigner's key).
561
+ uint48 expiration = uint48(block.timestamp + 1 hours);
562
+ uint256 sigDeadline = block.timestamp + 1 hours;
563
+ // forge-lint: disable-next-line(unsafe-typecast)
564
+ uint160 permitAmount = uint160(totalRepay * 2);
565
+
566
+ IAllowanceTransfer.PermitSingle memory permitSingle = IAllowanceTransfer.PermitSingle({
567
+ details: IAllowanceTransfer.PermitDetails({
568
+ token: address(TOKEN), amount: permitAmount, expiration: expiration, nonce: 0
569
+ }),
570
+ spender: address(LOANS_CONTRACT),
571
+ sigDeadline: sigDeadline
572
+ });
573
+
574
+ // Sign with the original signer's key, but call will come from wrongSigner.
575
+ bytes memory sig = _getPermitSignature(permitSingle, signerPrivateKey, DOMAIN_SEPARATOR);
576
+
577
+ JBSingleAllowance memory allowance = JBSingleAllowance({
578
+ sigDeadline: sigDeadline, amount: permitAmount, expiration: expiration, nonce: uint48(0), signature: sig
579
+ });
580
+
581
+ // The permit2.permit() call will fail because the signature was signed for
582
+ // the original signer's address, but _msgSender() is wrongSigner.
583
+ // The try-catch swallows this, then _transferFrom fails since no allowance was set.
584
+ vm.prank(wrongSigner);
585
+ vm.expectRevert();
586
+ LOANS_CONTRACT.repayLoan({
587
+ loanId: loanId,
588
+ maxRepayBorrowAmount: totalRepay * 2,
589
+ collateralCountToReturn: loan.collateral,
590
+ beneficiary: payable(wrongSigner),
591
+ allowance: allowance
592
+ });
593
+ }
594
+
595
+ // =========================================================================
596
+ // Test: repay with correct signer matches permit owner
597
+ // =========================================================================
598
+
599
+ /// @notice Verifies that a properly signed Permit2 allowance where the signer matches
600
+ /// _msgSender() succeeds, while the same signature fails when called from a
601
+ /// different address. This validates the signer-caller binding.
602
+ function test_permit2_signerMatchesCaller() public {
603
+ // Create a loan from the signer.
604
+ uint256 payAmount = 100e6;
605
+ (uint256 loanId, uint256 tokenCount, uint256 borrowAmount) =
606
+ _setupERC20Loan(signer, payAmount, LOANS_CONTRACT.MIN_PREPAID_FEE_PERCENT());
607
+
608
+ vm.assume(borrowAmount > 0);
609
+ vm.assume(tokenCount > 0);
610
+ vm.assume(loanId > 0);
611
+
612
+ REVLoan memory loan = LOANS_CONTRACT.loanOf(loanId);
613
+ uint256 sourceFee = LOANS_CONTRACT.determineSourceFeeAmount(loan, loan.amount);
614
+ uint256 totalRepay = loan.amount + sourceFee;
615
+
616
+ // Give signer enough tokens to repay.
617
+ deal(address(TOKEN), signer, totalRepay * 2);
618
+
619
+ // Approve Permit2 to spend tokens.
620
+ vm.prank(signer);
621
+ TOKEN.approve(address(permit2()), type(uint256).max);
622
+
623
+ // Build the Permit2 signature where spender = LOANS_CONTRACT, signed by signer.
624
+ uint48 expiration = uint48(block.timestamp + 1 hours);
625
+ uint256 sigDeadline = block.timestamp + 1 hours;
626
+ // forge-lint: disable-next-line(unsafe-typecast)
627
+ uint160 permitAmount = uint160(totalRepay * 2);
628
+
629
+ IAllowanceTransfer.PermitSingle memory permitSingle = IAllowanceTransfer.PermitSingle({
630
+ details: IAllowanceTransfer.PermitDetails({
631
+ token: address(TOKEN), amount: permitAmount, expiration: expiration, nonce: 0
632
+ }),
633
+ spender: address(LOANS_CONTRACT),
634
+ sigDeadline: sigDeadline
635
+ });
636
+
637
+ bytes memory sig = _getPermitSignature(permitSingle, signerPrivateKey, DOMAIN_SEPARATOR);
638
+
639
+ JBSingleAllowance memory allowance = JBSingleAllowance({
640
+ sigDeadline: sigDeadline, amount: permitAmount, expiration: expiration, nonce: uint48(0), signature: sig
641
+ });
642
+
643
+ // When called by the signer (who signed the permit), it should succeed.
644
+ vm.prank(signer);
645
+ LOANS_CONTRACT.repayLoan({
646
+ loanId: loanId,
647
+ maxRepayBorrowAmount: totalRepay * 2,
648
+ collateralCountToReturn: loan.collateral,
649
+ beneficiary: payable(signer),
650
+ allowance: allowance
651
+ });
652
+
653
+ // Verify collateral was returned.
654
+ uint256 totalCollateral = LOANS_CONTRACT.totalCollateralOf(REVNET_ID);
655
+ assertEq(totalCollateral, 0, "All collateral should be returned after full repay");
656
+ }
657
+ }