@rev-net/core-v6 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -29
- package/SKILLS.md +189 -101
- package/package.json +1 -1
- package/src/REVDeployer.sol +9 -4
- package/src/REVLoans.sol +5 -1
- package/test/REVAutoIssuanceFuzz.t.sol +5 -5
- package/test/REVDeployerAuditRegressions.t.sol +20 -20
- package/test/REVInvincibility.t.sol +48 -48
- package/test/REVLoans.invariants.t.sol +1 -1
- package/test/REVLoansAttacks.t.sol +11 -11
- package/test/REVLoansAuditRegressions.t.sol +12 -12
- package/test/REVLoansSourced.t.sol +1 -1
- package/test/TestEmptyBuybackSpecs.t.sol +237 -0
- package/test/TestPR12_FlashLoanSurplus.t.sol +1 -1
- package/test/TestPR16_ZeroRepayment.t.sol +1 -1
- package/test/TestPR21_Uint112Overflow.t.sol +1 -1
- package/test/TestPR22_HookArrayOOB.t.sol +1 -1
- package/test/TestPR27_CEIPattern.t.sol +2 -2
- package/test/TestStageTransitionBorrowable.t.sol +241 -0
- package/test/helpers/MaliciousContracts.sol +2 -2
- package/test/mock/MockBuybackDataHookMintPath.sol +61 -0
- package/REVNET_SECURITY_CHECKLIST.md +0 -164
|
@@ -29,7 +29,7 @@ import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/
|
|
|
29
29
|
|
|
30
30
|
/// @notice Fuzz tests for REVDeployer multi-stage auto-issuance.
|
|
31
31
|
/// Tests stage ID computation consistency and multi-stage claiming behavior.
|
|
32
|
-
///
|
|
32
|
+
/// Stage IDs use block.timestamp + i which may mismatch actual ruleset IDs.
|
|
33
33
|
contract REVAutoIssuanceFuzz_Local is TestBaseWorkflow, JBTest {
|
|
34
34
|
bytes32 REV_DEPLOYER_SALT = "REVDeployer";
|
|
35
35
|
|
|
@@ -245,13 +245,13 @@ contract REVAutoIssuanceFuzz_Local is TestBaseWorkflow, JBTest {
|
|
|
245
245
|
REV_DEPLOYER.autoIssueFor(revnetId, stageIds[1], multisig());
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
// ─────────────────
|
|
248
|
+
// ───────────────── Stage ID vs Ruleset ID comparison
|
|
249
249
|
// ─────────────────────
|
|
250
250
|
|
|
251
|
-
/// @notice
|
|
251
|
+
/// @notice Compare stored stageIds with actual ruleset IDs.
|
|
252
252
|
/// Stage IDs use block.timestamp + i during deployment.
|
|
253
253
|
/// If actual ruleset IDs differ (e.g., on cross-chain deployment), auto-issuance breaks.
|
|
254
|
-
function
|
|
254
|
+
function test_stageId_vs_rulesetId_comparison() external {
|
|
255
255
|
(uint256 revnetId, uint256[] memory stageIds) = _deployMultiStageRevnet(3);
|
|
256
256
|
|
|
257
257
|
// Get the actual rulesets from the controller.
|
|
@@ -263,7 +263,7 @@ contract REVAutoIssuanceFuzz_Local is TestBaseWorkflow, JBTest {
|
|
|
263
263
|
// For stage 1, the stored key is block.timestamp + 1.
|
|
264
264
|
// The actual ruleset ID depends on when JBRulesets creates it.
|
|
265
265
|
// If the ruleset hasn't started yet, getRulesetOf will use the queued ruleset.
|
|
266
|
-
//
|
|
266
|
+
// The actual ID may differ from block.timestamp + 1.
|
|
267
267
|
uint256 stage1StoredKey = stageIds[1]; // block.timestamp + 1
|
|
268
268
|
uint256 storedAmount = REV_DEPLOYER.amountToAutoIssue(revnetId, stage1StoredKey, multisig());
|
|
269
269
|
assertGt(storedAmount, 0, "Auto-issuance stored at block.timestamp + 1");
|
|
@@ -28,8 +28,8 @@ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStor
|
|
|
28
28
|
import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
|
|
29
29
|
import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
|
|
30
30
|
|
|
31
|
-
/// @notice
|
|
32
|
-
contract
|
|
31
|
+
/// @notice Regression tests for REVDeployer.
|
|
32
|
+
contract REVDeployerRegressions_Local is TestBaseWorkflow, JBTest {
|
|
33
33
|
using JBRulesetMetadataResolver for JBRuleset;
|
|
34
34
|
|
|
35
35
|
bytes32 REV_DEPLOYER_SALT = "REVDeployer";
|
|
@@ -87,13 +87,13 @@ contract REVDeployerAuditRegressions_Local is TestBaseWorkflow, JBTest {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
//*********************************************************************//
|
|
90
|
-
// ---
|
|
90
|
+
// --- REVDeployer.beforePayRecordedWith Array OOB Regression ------- //
|
|
91
91
|
//*********************************************************************//
|
|
92
92
|
|
|
93
|
-
/// @notice Tests that the
|
|
93
|
+
/// @notice Tests that the array OOB pattern manifests when only buybackHook is present.
|
|
94
94
|
/// @dev REVDeployer line 258: hookSpecifications[1] = buybackHookSpecifications[0]
|
|
95
95
|
/// always writes to index [1], even when the array has size 1 (no tiered721Hook).
|
|
96
|
-
function
|
|
96
|
+
function test_arrayOOB_onlyBuybackHook() public pure {
|
|
97
97
|
// Simulate: usesTiered721Hook=false, usesBuybackHook=true
|
|
98
98
|
bool usesTiered721Hook = false;
|
|
99
99
|
bool usesBuybackHook = true;
|
|
@@ -107,18 +107,18 @@ contract REVDeployerAuditRegressions_Local is TestBaseWorkflow, JBTest {
|
|
|
107
107
|
// Index [1] WOULD be written by the bug, but that's OOB
|
|
108
108
|
// Verify the pattern: writing to index 1 of a size-1 array should revert
|
|
109
109
|
bool wouldRevert = (!usesTiered721Hook && usesBuybackHook);
|
|
110
|
-
assertTrue(wouldRevert, "
|
|
110
|
+
assertTrue(wouldRevert, "this combination triggers the OOB bug");
|
|
111
111
|
|
|
112
112
|
// Verify the safe index: the buyback hook should go at index 0 when no tiered hook
|
|
113
113
|
uint256 correctIndex = usesTiered721Hook ? 1 : 0;
|
|
114
|
-
assertEq(correctIndex, 0, "
|
|
114
|
+
assertEq(correctIndex, 0, "buyback hook should use index 0 when no tiered hook");
|
|
115
115
|
|
|
116
116
|
// Write to the correct index (no revert)
|
|
117
117
|
specs[correctIndex] = JBPayHookSpecification({hook: IJBPayHook(address(0xbeef)), amount: 1 ether, metadata: ""});
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
/// @notice Verify both hooks present works fine (no OOB).
|
|
121
|
-
function
|
|
121
|
+
function test_noOOB_bothHooksPresent() public pure {
|
|
122
122
|
bool usesTiered721Hook = true;
|
|
123
123
|
bool usesBuybackHook = true;
|
|
124
124
|
|
|
@@ -131,13 +131,13 @@ contract REVDeployerAuditRegressions_Local is TestBaseWorkflow, JBTest {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
//*********************************************************************//
|
|
134
|
-
// ---
|
|
134
|
+
// --- hasMintPermissionFor returns false for random addresses ------- //
|
|
135
135
|
//*********************************************************************//
|
|
136
136
|
|
|
137
137
|
/// @notice Tests that calling hasMintPermissionFor returns false for random addresses.
|
|
138
138
|
/// @dev With the buyback hook removed, hasMintPermissionFor should return false
|
|
139
139
|
/// for addresses that are not the loans contract or a sucker.
|
|
140
|
-
function
|
|
140
|
+
function test_hasMintPermissionFor_noBuybackHook() public {
|
|
141
141
|
// Deploy a revnet WITHOUT a buyback hook
|
|
142
142
|
JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](1);
|
|
143
143
|
accountingContextsToAccept[0] = JBAccountingContext({
|
|
@@ -191,17 +191,17 @@ contract REVDeployerAuditRegressions_Local is TestBaseWorkflow, JBTest {
|
|
|
191
191
|
// With buyback hook removed, hasMintPermissionFor should return false
|
|
192
192
|
// for addresses that are not the loans contract or a sucker.
|
|
193
193
|
bool hasPerm = REV_DEPLOYER.hasMintPermissionFor(revnetId, currentRuleset, someRandomAddr);
|
|
194
|
-
assertFalse(hasPerm, "
|
|
194
|
+
assertFalse(hasPerm, "random address should not have mint permission");
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
//*********************************************************************//
|
|
198
|
-
// ---
|
|
198
|
+
// --- Auto-Issuance Stage ID Mismatch ------------------------------ //
|
|
199
199
|
//*********************************************************************//
|
|
200
200
|
|
|
201
201
|
/// @notice Tests that auto-issuance stage IDs are computed correctly for multi-stage revnets.
|
|
202
|
-
/// @dev
|
|
202
|
+
/// @dev The stage ID is computed as `block.timestamp + i` which only works if stages
|
|
203
203
|
/// are deployed in a specific order at a specific time.
|
|
204
|
-
function
|
|
204
|
+
function test_autoIssuanceStageIdMismatch() public {
|
|
205
205
|
JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](1);
|
|
206
206
|
accountingContextsToAccept[0] = JBAccountingContext({
|
|
207
207
|
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
@@ -241,7 +241,7 @@ contract REVDeployerAuditRegressions_Local is TestBaseWorkflow, JBTest {
|
|
|
241
241
|
});
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
// Stage 1: also has auto-issuance — this is where
|
|
244
|
+
// Stage 1: also has auto-issuance — this is where the mismatch manifests
|
|
245
245
|
{
|
|
246
246
|
REVAutoIssuance[] memory issuanceConfs = new REVAutoIssuance[](1);
|
|
247
247
|
issuanceConfs[0] = REVAutoIssuance({
|
|
@@ -294,7 +294,7 @@ contract REVDeployerAuditRegressions_Local is TestBaseWorkflow, JBTest {
|
|
|
294
294
|
// Verify the revnet was deployed
|
|
295
295
|
assertGt(revnetId, 0, "revnet should be deployed");
|
|
296
296
|
|
|
297
|
-
// The
|
|
297
|
+
// The bug: auto-issuance for stage 1 is stored at key (block.timestamp + 1),
|
|
298
298
|
// but the actual ruleset ID for stage 1 is the timestamp when that stage's ruleset
|
|
299
299
|
// was queued. These may not match.
|
|
300
300
|
// We verify the auto-issuance amounts are stored and can be queried.
|
|
@@ -303,12 +303,12 @@ contract REVDeployerAuditRegressions_Local is TestBaseWorkflow, JBTest {
|
|
|
303
303
|
// Stage 0 auto-issuance should be stored at block.timestamp
|
|
304
304
|
assertEq(stage0Amount, 50_000 * decimalMultiplier, "Stage 0 auto-issuance should be stored at block.timestamp");
|
|
305
305
|
|
|
306
|
-
// Stage 1 auto-issuance is stored at (block.timestamp + 1)
|
|
306
|
+
// Stage 1 auto-issuance is stored at (block.timestamp + 1)
|
|
307
307
|
uint256 stage1Amount = REV_DEPLOYER.amountToAutoIssue(revnetId, block.timestamp + 1, multisig());
|
|
308
308
|
assertEq(
|
|
309
309
|
stage1Amount,
|
|
310
310
|
30_000 * decimalMultiplier,
|
|
311
|
-
"
|
|
311
|
+
"Stage 1 auto-issuance stored at block.timestamp + 1 (may not match ruleset ID)"
|
|
312
312
|
);
|
|
313
313
|
|
|
314
314
|
// Now check the actual ruleset IDs to demonstrate the mismatch
|
|
@@ -316,12 +316,12 @@ contract REVDeployerAuditRegressions_Local is TestBaseWorkflow, JBTest {
|
|
|
316
316
|
if (rulesets.length >= 2) {
|
|
317
317
|
uint256 actualStage1RulesetId = rulesets[1].id;
|
|
318
318
|
|
|
319
|
-
// The
|
|
319
|
+
// The mismatch: the storage key (block.timestamp + 1) likely != the actual ruleset ID
|
|
320
320
|
// If they don't match, auto-issuance tokens for stage 1 become unclaimable
|
|
321
321
|
if (actualStage1RulesetId != block.timestamp + 1) {
|
|
322
322
|
// Verify the amount at the ACTUAL ruleset ID is 0 (the mismatch)
|
|
323
323
|
uint256 amountAtActualId = REV_DEPLOYER.amountToAutoIssue(revnetId, actualStage1RulesetId, multisig());
|
|
324
|
-
assertEq(amountAtActualId, 0, "
|
|
324
|
+
assertEq(amountAtActualId, 0, "auto-issuance at actual ruleset ID is 0 (mismatch)");
|
|
325
325
|
}
|
|
326
326
|
}
|
|
327
327
|
}
|
|
@@ -51,9 +51,9 @@ struct InvincibilityProjectConfig {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
// =========================================================================
|
|
54
|
-
// Section A + B:
|
|
54
|
+
// Section A + B: Property Verification & Economic Tests
|
|
55
55
|
// =========================================================================
|
|
56
|
-
contract
|
|
56
|
+
contract REVInvincibility_PropertyTests is TestBaseWorkflow, JBTest {
|
|
57
57
|
using JBRulesetMetadataResolver for JBRuleset;
|
|
58
58
|
|
|
59
59
|
bytes32 REV_DEPLOYER_SALT = "REVDeployer";
|
|
@@ -285,21 +285,21 @@ contract REVInvincibility_FixVerify is TestBaseWorkflow, JBTest {
|
|
|
285
285
|
}
|
|
286
286
|
|
|
287
287
|
// =====================================================================
|
|
288
|
-
// SECTION A: Critical
|
|
288
|
+
// SECTION A: Critical Property Verification (8 tests)
|
|
289
289
|
// =====================================================================
|
|
290
290
|
|
|
291
|
-
/// @notice
|
|
291
|
+
/// @notice Borrow with collateral > uint112.max silently truncates loan.amount.
|
|
292
292
|
/// @dev Verifies the truncation pattern: uint112(overflowValue) wraps.
|
|
293
|
-
function
|
|
293
|
+
function test_fixVerify_uint112Truncation() public {
|
|
294
294
|
// Prove the truncation math: uint112(max+1) wraps to 0
|
|
295
295
|
uint256 overflowValue = uint256(type(uint112).max) + 1;
|
|
296
296
|
uint112 truncated = uint112(overflowValue);
|
|
297
|
-
assertEq(truncated, 0, "
|
|
297
|
+
assertEq(truncated, 0, "uint112 truncation wraps max+1 to 0");
|
|
298
298
|
|
|
299
299
|
// Prove a more realistic overflow: max + 1000 wraps to 999
|
|
300
300
|
uint256 slightlyOver = uint256(type(uint112).max) + 1000;
|
|
301
301
|
truncated = uint112(slightlyOver);
|
|
302
|
-
assertEq(truncated, 999, "
|
|
302
|
+
assertEq(truncated, 999, "uint112 truncation wraps to low bits");
|
|
303
303
|
|
|
304
304
|
// Verify normal operation stays within bounds
|
|
305
305
|
uint256 payAmount = 100e18;
|
|
@@ -309,35 +309,35 @@ contract REVInvincibility_FixVerify is TestBaseWorkflow, JBTest {
|
|
|
309
309
|
|
|
310
310
|
uint256 borrowable =
|
|
311
311
|
LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
|
|
312
|
-
assertLt(borrowable, type(uint112).max, "
|
|
313
|
-
assertLt(tokens, type(uint112).max, "
|
|
312
|
+
assertLt(borrowable, type(uint112).max, "normal borrowable within uint112");
|
|
313
|
+
assertLt(tokens, type(uint112).max, "normal token count within uint112");
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
-
/// @notice
|
|
316
|
+
/// @notice Array OOB when only buyback hook present (no tiered721Hook).
|
|
317
317
|
/// @dev hookSpecifications[1] is written but array size is 1.
|
|
318
|
-
function
|
|
318
|
+
function test_fixVerify_arrayOOB_noBuybackWithBuyback() public pure {
|
|
319
319
|
bool usesTiered721Hook = false;
|
|
320
320
|
bool usesBuybackHook = true;
|
|
321
321
|
|
|
322
322
|
uint256 arraySize = (usesTiered721Hook ? 1 : 0) + (usesBuybackHook ? 1 : 0);
|
|
323
|
-
assertEq(arraySize, 1, "
|
|
323
|
+
assertEq(arraySize, 1, "array size is 1");
|
|
324
324
|
|
|
325
325
|
// The bug: code writes to hookSpecifications[1] (OOB for size-1 array)
|
|
326
326
|
// The fix: should write to index 0 when no tiered721Hook
|
|
327
327
|
bool wouldOOB = (!usesTiered721Hook && usesBuybackHook);
|
|
328
|
-
assertTrue(wouldOOB, "
|
|
328
|
+
assertTrue(wouldOOB, "this config triggers the OOB write at index [1]");
|
|
329
329
|
|
|
330
330
|
uint256 correctIndex = usesTiered721Hook ? 1 : 0;
|
|
331
|
-
assertEq(correctIndex, 0, "
|
|
331
|
+
assertEq(correctIndex, 0, "buyback hook should use index 0");
|
|
332
332
|
|
|
333
333
|
// Verify safe write
|
|
334
334
|
JBPayHookSpecification[] memory specs = new JBPayHookSpecification[](arraySize);
|
|
335
335
|
specs[correctIndex] = JBPayHookSpecification({hook: IJBPayHook(address(0xbeef)), amount: 1 ether, metadata: ""});
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
-
/// @notice
|
|
338
|
+
/// @notice Reentrancy — _adjust calls terminal.pay() BEFORE writing loan state.
|
|
339
339
|
/// @dev Lines 910 (external call) vs 922-923 (state writes). CEI violation.
|
|
340
|
-
function
|
|
340
|
+
function test_fixVerify_reentrancyDoubleBorrow() public {
|
|
341
341
|
// Create a legitimate loan to confirm the system works
|
|
342
342
|
uint256 payAmount = 10e18;
|
|
343
343
|
vm.prank(USER);
|
|
@@ -366,25 +366,25 @@ contract REVInvincibility_FixVerify is TestBaseWorkflow, JBTest {
|
|
|
366
366
|
// (We can't actually execute the attack through real contracts because
|
|
367
367
|
// the fee terminal is the legitimate JBMultiTerminal, but the pattern
|
|
368
368
|
// is confirmed by code inspection)
|
|
369
|
-
assertTrue(true, "
|
|
369
|
+
assertTrue(true, "CEI pattern verified at lines 910 vs 922-923");
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
-
/// @notice
|
|
372
|
+
/// @notice hasMintPermissionFor returns false for random addresses.
|
|
373
373
|
/// @dev With the buyback hook removed, hasMintPermissionFor should return false
|
|
374
374
|
/// for addresses that are not the loans contract or a sucker.
|
|
375
|
-
function
|
|
375
|
+
function test_fixVerify_hasMintPermission_noBuyback() public {
|
|
376
376
|
// The fee project was deployed without buyback hook in our setup
|
|
377
377
|
JBRuleset memory currentRuleset = jbRulesets().currentOf(FEE_PROJECT_ID);
|
|
378
378
|
|
|
379
379
|
// hasMintPermissionFor should return false for random addresses
|
|
380
380
|
address randomAddr = address(0x12345);
|
|
381
381
|
bool hasPerm = REV_DEPLOYER.hasMintPermissionFor(FEE_PROJECT_ID, currentRuleset, randomAddr);
|
|
382
|
-
assertFalse(hasPerm, "
|
|
382
|
+
assertFalse(hasPerm, "random address should not have mint permission");
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
-
/// @notice
|
|
385
|
+
/// @notice Zero-supply cash out no longer drains surplus (fixed in v6).
|
|
386
386
|
/// @dev JBCashOuts.cashOutFrom now returns 0 when cashOutCount == 0.
|
|
387
|
-
function
|
|
387
|
+
function test_fixVerify_zeroSupplyCashOutDrain() public pure {
|
|
388
388
|
uint256 surplus = 100e18;
|
|
389
389
|
uint256 cashOutCount = 0;
|
|
390
390
|
uint256 totalSupply = 0;
|
|
@@ -393,17 +393,17 @@ contract REVInvincibility_FixVerify is TestBaseWorkflow, JBTest {
|
|
|
393
393
|
uint256 reclaimable = JBCashOuts.cashOutFrom(surplus, cashOutCount, totalSupply, cashOutTaxRate);
|
|
394
394
|
|
|
395
395
|
// Fixed in v6: cashing out 0 tokens always returns 0
|
|
396
|
-
assertEq(reclaimable, 0, "
|
|
396
|
+
assertEq(reclaimable, 0, "zero cash out returns nothing");
|
|
397
397
|
|
|
398
398
|
// Normal case: with supply, cashing out 0 still returns 0
|
|
399
399
|
uint256 normalReclaimable = JBCashOuts.cashOutFrom(surplus, 0, 1000e18, cashOutTaxRate);
|
|
400
400
|
assertEq(normalReclaimable, 0, "Normal: cashing out 0 of non-zero supply returns 0");
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
-
/// @notice
|
|
403
|
+
/// @notice Broken fee terminal + broken addToBalanceOf fallback bricks cash-outs.
|
|
404
404
|
/// @dev afterCashOutRecordedWith: try feeTerminal.pay() catch { addToBalanceOf() }
|
|
405
405
|
/// If BOTH revert, the entire cash-out transaction reverts.
|
|
406
|
-
function
|
|
406
|
+
function test_fixVerify_brokenFeeTerminalBricksCashOuts() public {
|
|
407
407
|
BrokenFeeTerminal brokenTerminal = new BrokenFeeTerminal();
|
|
408
408
|
|
|
409
409
|
// The vulnerability pattern:
|
|
@@ -429,10 +429,10 @@ contract REVInvincibility_FixVerify is TestBaseWorkflow, JBTest {
|
|
|
429
429
|
brokenTerminal.addToBalanceOf(0, address(0), 0, false, "", "");
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
-
/// @notice
|
|
432
|
+
/// @notice Auto-issuance stored at block.timestamp+i, not actual ruleset IDs.
|
|
433
433
|
/// @dev _makeRulesetConfigurations stores at block.timestamp+i but autoIssueFor
|
|
434
434
|
/// queries by actual ruleset ID. If they mismatch, tokens are unclaimable.
|
|
435
|
-
function
|
|
435
|
+
function test_fixVerify_autoIssuanceStageIdMismatch() public {
|
|
436
436
|
// Deploy a multi-stage revnet with auto-issuance on multiple stages
|
|
437
437
|
JBAccountingContext[] memory ctx = new JBAccountingContext[](1);
|
|
438
438
|
ctx[0] = JBAccountingContext({
|
|
@@ -495,13 +495,13 @@ contract REVInvincibility_FixVerify is TestBaseWorkflow, JBTest {
|
|
|
495
495
|
|
|
496
496
|
// Stage 0 auto-issuance stored at block.timestamp
|
|
497
497
|
uint256 stage0Amount = REV_DEPLOYER.amountToAutoIssue(h5RevnetId, block.timestamp, multisig());
|
|
498
|
-
assertEq(stage0Amount, 50_000e18, "
|
|
498
|
+
assertEq(stage0Amount, 50_000e18, "Stage 0 auto-issuance stored at block.timestamp");
|
|
499
499
|
|
|
500
|
-
// Stage 1 auto-issuance stored at block.timestamp + 1 (the
|
|
500
|
+
// Stage 1 auto-issuance stored at block.timestamp + 1 (the stage ID mismatch bug)
|
|
501
501
|
uint256 stage1Amount = REV_DEPLOYER.amountToAutoIssue(h5RevnetId, block.timestamp + 1, multisig());
|
|
502
|
-
assertEq(stage1Amount, 30_000e18, "
|
|
502
|
+
assertEq(stage1Amount, 30_000e18, "Stage 1 auto-issuance stored at block.timestamp + 1");
|
|
503
503
|
|
|
504
|
-
// The
|
|
504
|
+
// The bug: stages are stored at (block.timestamp + i), not at the actual ruleset IDs.
|
|
505
505
|
// In the test environment, stages queued in the same block happen to have sequential IDs
|
|
506
506
|
// (block.timestamp, block.timestamp+1), so the storage keys coincidentally match.
|
|
507
507
|
// However, if deployment happens at a different time than block.timestamp, or if stages
|
|
@@ -515,20 +515,20 @@ contract REVInvincibility_FixVerify is TestBaseWorkflow, JBTest {
|
|
|
515
515
|
// Document the storage keys used vs what autoIssueFor expects
|
|
516
516
|
// autoIssueFor calls with the CURRENT ruleset's ID (from currentOf).
|
|
517
517
|
// If the ruleset ID != block.timestamp+i, the amount at that key is 0.
|
|
518
|
-
emit log_named_uint("
|
|
519
|
-
emit log_named_uint("
|
|
520
|
-
emit log_named_uint("
|
|
518
|
+
emit log_named_uint("Storage key for stage 1", block.timestamp + 1);
|
|
519
|
+
emit log_named_uint("Actual ruleset[0].id (most recent)", rulesets[0].id);
|
|
520
|
+
emit log_named_uint("Actual ruleset[1].id (first)", rulesets[1].id);
|
|
521
521
|
|
|
522
522
|
// The fragility: stage 1 issuance is ONLY accessible at (block.timestamp + 1).
|
|
523
523
|
// Any other key returns 0.
|
|
524
524
|
uint256 wrongKey = block.timestamp + 100;
|
|
525
525
|
uint256 amountAtWrongKey = REV_DEPLOYER.amountToAutoIssue(h5RevnetId, wrongKey, multisig());
|
|
526
|
-
assertEq(amountAtWrongKey, 0, "
|
|
526
|
+
assertEq(amountAtWrongKey, 0, "auto-issuance unreachable at wrong key");
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
-
/// @notice
|
|
529
|
+
/// @notice Unvalidated source terminal — unbounded _loanSourcesOf array growth.
|
|
530
530
|
/// @dev borrowFrom accepts any terminal in REVLoanSource without validation.
|
|
531
|
-
function
|
|
531
|
+
function test_fixVerify_unvalidatedSourceTerminal() public {
|
|
532
532
|
// The vulnerability: REVLoans._addTo (line 788-791) registers ANY terminal
|
|
533
533
|
// as a loan source without validating it's an actual project terminal:
|
|
534
534
|
// if (!isLoanSourceOf[revnetId][loan.source.terminal][loan.source.token]) {
|
|
@@ -555,12 +555,12 @@ contract REVInvincibility_FixVerify is TestBaseWorkflow, JBTest {
|
|
|
555
555
|
assertEq(sourcesAfter.length, 1, "One loan source registered after first borrow");
|
|
556
556
|
assertEq(address(sourcesAfter[0].terminal), address(jbMultiTerminal()), "Source should be multi terminal");
|
|
557
557
|
|
|
558
|
-
//
|
|
558
|
+
// The vulnerability is that _addTo registers ANY terminal passed in REVLoanSource.
|
|
559
559
|
// There's no validation that the terminal is actually a terminal for the project.
|
|
560
560
|
// This means an attacker could register fake terminals, growing the array unboundedly.
|
|
561
561
|
assertTrue(
|
|
562
562
|
LOANS_CONTRACT.isLoanSourceOf(REVNET_ID, jbMultiTerminal(), JBConstants.NATIVE_TOKEN),
|
|
563
|
-
"
|
|
563
|
+
"source registered without terminal validation"
|
|
564
564
|
);
|
|
565
565
|
}
|
|
566
566
|
|
|
@@ -674,7 +674,7 @@ contract REVInvincibility_FixVerify is TestBaseWorkflow, JBTest {
|
|
|
674
674
|
}
|
|
675
675
|
|
|
676
676
|
/// @notice Flash loan surplus inflation: addToBalance → borrow at inflated rate.
|
|
677
|
-
/// @dev
|
|
677
|
+
/// @dev Surplus is read live, so an addToBalance before borrow inflates it.
|
|
678
678
|
function test_econ_flashLoanSurplusInflation() public {
|
|
679
679
|
// Step 1: Pay to get tokens
|
|
680
680
|
uint256 payAmount = 5e18;
|
|
@@ -694,14 +694,14 @@ contract REVInvincibility_FixVerify is TestBaseWorkflow, JBTest {
|
|
|
694
694
|
uint256 borrowableAfter =
|
|
695
695
|
LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
|
|
696
696
|
|
|
697
|
-
//
|
|
698
|
-
assertTrue(borrowableAfter > borrowableBefore, "
|
|
697
|
+
// The borrowable amount increases because surplus grew but totalSupply didn't
|
|
698
|
+
assertTrue(borrowableAfter > borrowableBefore, "surplus inflation increases borrowable amount");
|
|
699
699
|
|
|
700
700
|
// Quantify the inflation factor
|
|
701
701
|
if (borrowableBefore > 0) {
|
|
702
702
|
uint256 inflationFactor = mulDiv(borrowableAfter, 1e18, borrowableBefore);
|
|
703
|
-
assertTrue(inflationFactor > 1e18, "
|
|
704
|
-
emit log_named_uint("
|
|
703
|
+
assertTrue(inflationFactor > 1e18, "inflation factor > 1x");
|
|
704
|
+
emit log_named_uint("inflation factor (1e18=1x)", inflationFactor);
|
|
705
705
|
}
|
|
706
706
|
}
|
|
707
707
|
|
|
@@ -858,11 +858,11 @@ contract REVInvincibility_FixVerify is TestBaseWorkflow, JBTest {
|
|
|
858
858
|
}
|
|
859
859
|
}
|
|
860
860
|
|
|
861
|
-
/// @notice
|
|
861
|
+
/// @notice Double fee — REVDeployer not registered as feeless.
|
|
862
862
|
/// @dev Cash-out fee goes to REVDeployer (afterCashOutRecordedWith) which pays fee terminal.
|
|
863
863
|
/// But the JBMultiTerminal's useAllowanceOf already took a protocol fee,
|
|
864
864
|
/// so the fee payment to the fee terminal is a second fee on the same funds.
|
|
865
|
-
function
|
|
865
|
+
function test_econ_doubleFee() public {
|
|
866
866
|
// Pay into revnet
|
|
867
867
|
vm.prank(USER);
|
|
868
868
|
uint256 tokens =
|
|
@@ -896,7 +896,7 @@ contract REVInvincibility_FixVerify is TestBaseWorkflow, JBTest {
|
|
|
896
896
|
}) returns (
|
|
897
897
|
uint256 reclaimAmount
|
|
898
898
|
) {
|
|
899
|
-
// The
|
|
899
|
+
// The double fee means the fee project gets more than expected
|
|
900
900
|
// because both the terminal fee AND the revnet fee route to it
|
|
901
901
|
uint256 feeBalanceAfter;
|
|
902
902
|
{
|
|
@@ -914,7 +914,7 @@ contract REVInvincibility_FixVerify is TestBaseWorkflow, JBTest {
|
|
|
914
914
|
emit log_named_uint("Reclaim amount", reclaimAmount);
|
|
915
915
|
} catch {
|
|
916
916
|
// Cash out may fail (e.g., if fee terminal isn't set up) — document the failure
|
|
917
|
-
emit log("
|
|
917
|
+
emit log("Cash-out reverted (may be due to fee terminal setup)");
|
|
918
918
|
}
|
|
919
919
|
}
|
|
920
920
|
}
|
|
@@ -583,7 +583,7 @@ contract InvariantREVLoansTests is StdInvariant, TestBaseWorkflow, JBTest {
|
|
|
583
583
|
return LOANS_CONTRACT.totalBorrowedFrom(_revnetId, jbMultiTerminal(), JBConstants.NATIVE_TOKEN);
|
|
584
584
|
}
|
|
585
585
|
|
|
586
|
-
/// @notice INV-RL-3: loan.amount <= type(uint112).max for all active loans
|
|
586
|
+
/// @notice INV-RL-3: loan.amount <= type(uint112).max for all active loans.
|
|
587
587
|
/// @dev Verifies that no loan amount exceeds the uint112 storage boundary.
|
|
588
588
|
function invariant_C_LoanAmountFitsUint112() public view {
|
|
589
589
|
if (PAY_HANDLER.RUNS() == 0) return;
|
|
@@ -35,7 +35,7 @@ import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
|
|
|
35
35
|
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
36
36
|
|
|
37
37
|
/// @notice A malicious terminal that re-enters REVLoans during fee payment in _adjust().
|
|
38
|
-
/// @dev
|
|
38
|
+
/// @dev Reentrancy during pay() callback in _adjust.
|
|
39
39
|
contract ReentrantTerminal is ERC165, IJBPayoutTerminal {
|
|
40
40
|
IREVLoans public loans;
|
|
41
41
|
uint256 public revnetId;
|
|
@@ -174,7 +174,7 @@ struct AttackProjectConfig {
|
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
/// @title REVLoansAttacks
|
|
177
|
-
/// @notice Attack tests for REVLoans covering
|
|
177
|
+
/// @notice Attack tests for REVLoans covering uint112 truncation, reentrancy,
|
|
178
178
|
/// collateral race conditions, liquidation edge cases, and fuzz testing.
|
|
179
179
|
contract REVLoansAttacks is TestBaseWorkflow, JBTest {
|
|
180
180
|
bytes32 REV_DEPLOYER_SALT = "REVDeployer";
|
|
@@ -412,10 +412,10 @@ contract REVLoansAttacks is TestBaseWorkflow, JBTest {
|
|
|
412
412
|
}
|
|
413
413
|
|
|
414
414
|
// =========================================================================
|
|
415
|
-
//
|
|
415
|
+
// uint112 truncation — loan amount silently wraps
|
|
416
416
|
// =========================================================================
|
|
417
417
|
/// @notice Verify that borrowing an amount > uint112.max is properly handled.
|
|
418
|
-
/// @dev
|
|
418
|
+
/// @dev The _adjust function casts newBorrowAmount to uint112 without overflow checks.
|
|
419
419
|
/// If borrowAmount exceeds uint112.max, it silently truncates. This test verifies the behavior.
|
|
420
420
|
function test_uint112Truncation_loanAmountSilentlyTruncates() public {
|
|
421
421
|
// uint112.max = 5192296858534827628530496329220095
|
|
@@ -449,10 +449,10 @@ contract REVLoansAttacks is TestBaseWorkflow, JBTest {
|
|
|
449
449
|
}
|
|
450
450
|
|
|
451
451
|
// =========================================================================
|
|
452
|
-
//
|
|
452
|
+
// collateral > uint112.max wraps
|
|
453
453
|
// =========================================================================
|
|
454
454
|
/// @notice Verify that collateral > uint112.max would be truncated in the loan struct.
|
|
455
|
-
/// @dev
|
|
455
|
+
/// @dev loan.collateral = uint112(newCollateralCount) truncates silently.
|
|
456
456
|
function test_uint112Truncation_collateralTruncates() public {
|
|
457
457
|
// Verify the truncation math
|
|
458
458
|
uint256 maxCollateral = type(uint112).max;
|
|
@@ -476,10 +476,10 @@ contract REVLoansAttacks is TestBaseWorkflow, JBTest {
|
|
|
476
476
|
}
|
|
477
477
|
|
|
478
478
|
// =========================================================================
|
|
479
|
-
//
|
|
479
|
+
// reentrancy — _adjust calls terminal.pay() which could re-enter
|
|
480
480
|
// =========================================================================
|
|
481
481
|
/// @notice Verify that reentrancy during _adjust's fee payment is handled.
|
|
482
|
-
/// @dev
|
|
482
|
+
/// @dev The _adjust function calls loan.source.terminal.pay() to pay fees.
|
|
483
483
|
/// A malicious terminal could use this callback to re-enter borrowFrom().
|
|
484
484
|
/// Since Solidity 0.8.23 doesn't have native reentrancy guards on REVLoans,
|
|
485
485
|
/// the state (loan.amount, loan.collateral) is written AFTER the external call.
|
|
@@ -508,14 +508,14 @@ contract REVLoansAttacks is TestBaseWorkflow, JBTest {
|
|
|
508
508
|
// This is a checks-effects-interactions violation.
|
|
509
509
|
// The loan amount and collateral are read from storage during _borrowAmountFrom,
|
|
510
510
|
// so a re-entrant call would see stale values.
|
|
511
|
-
assertTrue(true, "
|
|
511
|
+
assertTrue(true, "reentrancy window confirmed between terminal.pay() and state writes");
|
|
512
512
|
}
|
|
513
513
|
|
|
514
514
|
// =========================================================================
|
|
515
|
-
//
|
|
515
|
+
// re-enter repayLoan during fee payment
|
|
516
516
|
// =========================================================================
|
|
517
517
|
/// @notice Verify that reentering repayLoan during _adjust's fee payment is handled.
|
|
518
|
-
/// @dev
|
|
518
|
+
/// @dev Malicious terminal calls repayLoan() during fee payment.
|
|
519
519
|
function test_reentrancy_adjustRepayReenter() public {
|
|
520
520
|
// Similar to above, but the re-entrant call targets repayLoan instead of borrowFrom.
|
|
521
521
|
// The concern is that during _adjust → terminal.pay(), a call to repayLoan
|
|
@@ -32,7 +32,7 @@ import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/
|
|
|
32
32
|
import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
|
|
33
33
|
|
|
34
34
|
/// @notice A fake terminal that tracks whether useAllowanceOf was called.
|
|
35
|
-
/// @dev
|
|
35
|
+
/// @dev REVLoans.borrowFrom does not validate source terminal registration.
|
|
36
36
|
contract FakeTerminal is ERC165, IJBPayoutTerminal {
|
|
37
37
|
bool public useAllowanceCalled;
|
|
38
38
|
uint256 public lastProjectId;
|
|
@@ -128,8 +128,8 @@ contract FakeTerminal is ERC165, IJBPayoutTerminal {
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
/// @notice
|
|
132
|
-
contract
|
|
131
|
+
/// @notice Regression tests for REVLoans unvalidated source terminal.
|
|
132
|
+
contract REVLoansRegressions_Local is TestBaseWorkflow, JBTest {
|
|
133
133
|
bytes32 REV_DEPLOYER_SALT = "REVDeployer";
|
|
134
134
|
bytes32 ERC20_SALT = "REV_TOKEN";
|
|
135
135
|
|
|
@@ -239,14 +239,14 @@ contract REVLoansAuditRegressions_Local is TestBaseWorkflow, JBTest {
|
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
//*********************************************************************//
|
|
242
|
-
// ---
|
|
242
|
+
// --- Unvalidated Source Terminal in REVLoans ---------------------- //
|
|
243
243
|
//*********************************************************************//
|
|
244
244
|
|
|
245
|
-
/// @notice Demonstrates
|
|
245
|
+
/// @notice Demonstrates that borrowFrom accepts any terminal without validating
|
|
246
246
|
/// it is registered in the JBDirectory for the project.
|
|
247
|
-
/// @dev The fake terminal's useAllowanceOf is called,
|
|
247
|
+
/// @dev The fake terminal's useAllowanceOf is called, showing no directory check occurs.
|
|
248
248
|
/// In production, a malicious terminal could return fake amounts or misroute funds.
|
|
249
|
-
function
|
|
249
|
+
function test_unvalidatedSourceTerminal() public {
|
|
250
250
|
// Step 1: User pays into the revnet to get tokens (collateral)
|
|
251
251
|
vm.prank(USER);
|
|
252
252
|
uint256 tokens = jbMultiTerminal().pay{value: 1e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 1e18, USER, 0, "", "");
|
|
@@ -266,7 +266,7 @@ contract REVLoansAuditRegressions_Local is TestBaseWorkflow, JBTest {
|
|
|
266
266
|
assertFalse(found, "fake terminal should NOT be in the directory");
|
|
267
267
|
|
|
268
268
|
// Step 3: Try to borrow using the fake terminal as the source
|
|
269
|
-
//
|
|
269
|
+
// Vulnerability: REVLoans.borrowFrom does NOT check if the terminal
|
|
270
270
|
// is registered in the directory before calling useAllowanceOf on it.
|
|
271
271
|
REVLoanSource memory fakeSource =
|
|
272
272
|
REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: IJBPayoutTerminal(address(fakeTerminal))});
|
|
@@ -275,7 +275,7 @@ contract REVLoansAuditRegressions_Local is TestBaseWorkflow, JBTest {
|
|
|
275
275
|
LOANS_CONTRACT.borrowableAmountFrom(REVNET_ID, tokens, 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
|
|
276
276
|
assertGt(borrowable, 0, "should have borrowable amount");
|
|
277
277
|
|
|
278
|
-
//
|
|
278
|
+
// Use vm.expectCall to verify the fake terminal's useAllowanceOf
|
|
279
279
|
// is called. This works even if the outer call reverts, because expectCall
|
|
280
280
|
// records the call was made regardless.
|
|
281
281
|
// The code calls accountingContextForTokenOf first, then useAllowanceOf.
|
|
@@ -287,7 +287,7 @@ contract REVLoansAuditRegressions_Local is TestBaseWorkflow, JBTest {
|
|
|
287
287
|
);
|
|
288
288
|
vm.expectCall(address(fakeTerminal), abi.encodeWithSelector(IJBPayoutTerminal.useAllowanceOf.selector));
|
|
289
289
|
|
|
290
|
-
// The borrow will reach the fake terminal (
|
|
290
|
+
// The borrow will reach the fake terminal (showing no validation),
|
|
291
291
|
// but will revert downstream when trying to transfer 0 - fees (underflow).
|
|
292
292
|
vm.prank(USER);
|
|
293
293
|
vm.expectRevert();
|
|
@@ -296,11 +296,11 @@ contract REVLoansAuditRegressions_Local is TestBaseWorkflow, JBTest {
|
|
|
296
296
|
// If we reach here, both vm.expectCall checks passed:
|
|
297
297
|
// 1. accountingContextForTokenOf was called on the fake terminal
|
|
298
298
|
// 2. useAllowanceOf was called on the fake terminal
|
|
299
|
-
// This
|
|
299
|
+
// This shows no directory validation before calling the source terminal
|
|
300
300
|
}
|
|
301
301
|
|
|
302
302
|
/// @notice Verify that the configured loan source (real terminal) is properly registered.
|
|
303
|
-
function
|
|
303
|
+
function test_configuredSourceIsRegistered() public {
|
|
304
304
|
// The real terminal should be in the directory
|
|
305
305
|
IJBTerminal[] memory terminals = jbDirectory().terminalsOf(REVNET_ID);
|
|
306
306
|
bool found = false;
|
|
@@ -831,7 +831,7 @@ contract REVLoansSourcedTests is TestBaseWorkflow, JBTest {
|
|
|
831
831
|
|
|
832
832
|
uint256 amountDiff = borrowableFromNewCollateral > loan.amount ? 0 : loan.amount - borrowableFromNewCollateral;
|
|
833
833
|
|
|
834
|
-
// Skip fuzz runs where both repay amount and collateral return are zero
|
|
834
|
+
// Skip fuzz runs where both repay amount and collateral return are zero.
|
|
835
835
|
vm.assume(amountDiff > 0 || collateralReturned > 0);
|
|
836
836
|
|
|
837
837
|
uint256 maxAmountPaidDown = loan.amount;
|