@bananapus/core-v6 0.0.6 → 0.0.8
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 +1 -1
- package/SKILLS.md +1 -1
- package/package.json +1 -1
- package/src/JBRulesets.sol +21 -18
- package/src/interfaces/IJBRulesets.sol +4 -2
- package/src/libraries/JBMetadataResolver.sol +3 -3
- package/test/AuditExploits.t.sol +73 -79
- package/test/FlashLoanAttacks.t.sol +6 -6
- package/test/TestCashOutTimingEdge.sol +5 -5
- package/test/TestDurationUnderflow.sol +1 -1
- package/test/TestFees.sol +3 -3
- package/test/TestMigrationHeldFees.sol +4 -4
- package/test/TestRulesetQueuingStress.sol +4 -4
- package/test/TestWeightCacheStaleAfterRejection.sol +289 -0
- package/test/formal/BondingCurveProperties.t.sol +2 -2
- package/test/invariants/handlers/RulesetsHandler.sol +1 -1
- package/test/units/static/JBFundAccessLimits/TestFundAccessLimitsEdge.sol +1 -1
- package/test/units/static/JBMetadataResolver/TestMetadataResolverM20M21.sol +8 -8
- package/test/units/static/JBPrices/TestPrices.sol +1 -1
- package/test/units/static/JBRulesets/TestRulesets.sol +2 -2
- package/test/units/static/JBRulesets/TestUpdateRulesetWeightCache.sol +2 -2
package/README.md
CHANGED
package/SKILLS.md
CHANGED
package/package.json
CHANGED
package/src/JBRulesets.sol
CHANGED
|
@@ -840,50 +840,53 @@ contract JBRulesets is JBControlled, IJBRulesets {
|
|
|
840
840
|
return _getStructFor({projectId: projectId, rulesetId: rulesetId});
|
|
841
841
|
}
|
|
842
842
|
|
|
843
|
-
/// @notice Cache the value of the ruleset weight.
|
|
843
|
+
/// @notice Cache the value of the ruleset weight for a specific ruleset.
|
|
844
|
+
/// @dev The caller should pass the ruleset ID that `currentOf()` actually uses. When a queued ruleset is rejected
|
|
845
|
+
/// by an approval hook, `currentOf()` falls back to the base ruleset — callers should pass that base ruleset's
|
|
846
|
+
/// ID,
|
|
847
|
+
/// not the rejected latest.
|
|
844
848
|
/// @param projectId The ID of the project having its ruleset weight cached.
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
//
|
|
848
|
-
JBRuleset memory
|
|
849
|
-
_getStructFor({projectId: projectId, rulesetId: latestRulesetIdOf[projectId]});
|
|
849
|
+
/// @param rulesetId The ID of the ruleset to update the cache for.
|
|
850
|
+
function updateRulesetWeightCache(uint256 projectId, uint256 rulesetId) external override {
|
|
851
|
+
// Get the target ruleset.
|
|
852
|
+
JBRuleset memory targetRuleset = _getStructFor({projectId: projectId, rulesetId: rulesetId});
|
|
850
853
|
|
|
851
|
-
// Nothing to cache if the
|
|
854
|
+
// Nothing to cache if the target ruleset doesn't have a duration or a weight cut percent.
|
|
852
855
|
// slither-disable-next-line incorrect-equality
|
|
853
|
-
if (
|
|
856
|
+
if (targetRuleset.duration == 0 || targetRuleset.weightCutPercent == 0) return;
|
|
854
857
|
|
|
855
858
|
// Get a reference to the current cache.
|
|
856
|
-
JBRulesetWeightCache storage cache = _weightCacheOf[projectId][
|
|
859
|
+
JBRulesetWeightCache storage cache = _weightCacheOf[projectId][targetRuleset.id];
|
|
857
860
|
|
|
858
861
|
// Determine the largest start timestamp the cache can be filled to.
|
|
859
862
|
// Cap the advance to the cache lookup threshold per call to stay within the iteration limit in
|
|
860
863
|
// deriveWeightFrom.
|
|
861
864
|
// Multiple calls are needed to advance the cache for large cycle gaps.
|
|
862
|
-
uint256 maxStart =
|
|
863
|
-
|
|
865
|
+
uint256 maxStart = targetRuleset.start + (cache.weightCutMultiple + _WEIGHT_CUT_MULTIPLE_CACHE_LOOKUP_THRESHOLD)
|
|
866
|
+
* targetRuleset.duration;
|
|
864
867
|
|
|
865
868
|
// Determine the start timestamp to derive a weight from for the cache.
|
|
866
869
|
uint256 start = block.timestamp < maxStart ? block.timestamp : maxStart;
|
|
867
870
|
|
|
868
871
|
// The difference between the start of the latest queued ruleset and the start of the ruleset we're caching the
|
|
869
872
|
// weight of.
|
|
870
|
-
uint256 startDistance = start -
|
|
873
|
+
uint256 startDistance = start - targetRuleset.start;
|
|
871
874
|
|
|
872
875
|
// Calculate the weight cut multiple.
|
|
873
876
|
uint168 weightCutMultiple;
|
|
874
877
|
unchecked {
|
|
875
|
-
weightCutMultiple = uint168(startDistance /
|
|
878
|
+
weightCutMultiple = uint168(startDistance / targetRuleset.duration);
|
|
876
879
|
}
|
|
877
880
|
|
|
878
881
|
// Store the new values.
|
|
879
882
|
cache.weight = uint112(
|
|
880
883
|
deriveWeightFrom({
|
|
881
884
|
projectId: projectId,
|
|
882
|
-
baseRulesetStart:
|
|
883
|
-
baseRulesetDuration:
|
|
884
|
-
baseRulesetWeight:
|
|
885
|
-
baseRulesetWeightCutPercent:
|
|
886
|
-
baseRulesetCacheId:
|
|
885
|
+
baseRulesetStart: targetRuleset.start,
|
|
886
|
+
baseRulesetDuration: targetRuleset.duration,
|
|
887
|
+
baseRulesetWeight: targetRuleset.weight,
|
|
888
|
+
baseRulesetWeightCutPercent: targetRuleset.weightCutPercent,
|
|
889
|
+
baseRulesetCacheId: targetRuleset.id,
|
|
887
890
|
start: start
|
|
888
891
|
})
|
|
889
892
|
);
|
|
@@ -145,7 +145,9 @@ interface IJBRulesets {
|
|
|
145
145
|
external
|
|
146
146
|
returns (JBRuleset memory ruleset);
|
|
147
147
|
|
|
148
|
-
/// @notice Updates the weight cache for a
|
|
148
|
+
/// @notice Updates the weight cache for a specific ruleset to allow efficient weight derivation over many cycles.
|
|
149
149
|
/// @param projectId The ID of the project to update the weight cache for.
|
|
150
|
-
|
|
150
|
+
/// @param rulesetId The ID of the ruleset to update the cache for. This should be the ruleset that currentOf()
|
|
151
|
+
/// actually uses, which may differ from latestRulesetIdOf if the latest was rejected by an approval hook.
|
|
152
|
+
function updateRulesetWeightCache(uint256 projectId, uint256 rulesetId) external;
|
|
151
153
|
}
|
|
@@ -115,7 +115,7 @@ library JBMetadataResolver {
|
|
|
115
115
|
|
|
116
116
|
// Add the new entry after the last entry of the table, the new offset is the last offset + the number of words
|
|
117
117
|
// taken by the last data
|
|
118
|
-
//
|
|
118
|
+
// Compute in uint256 first — casting directly to uint8 silently wraps offsets > 255.
|
|
119
119
|
uint256 newOffset = lastOffset + numberOfWordslastData;
|
|
120
120
|
if (newOffset > 255) revert JBMetadataResolver_MetadataTooLong();
|
|
121
121
|
newMetadata = abi.encodePacked(newMetadata, idToAdd, bytes1(uint8(newOffset)));
|
|
@@ -282,7 +282,7 @@ library JBMetadataResolver {
|
|
|
282
282
|
assembly ("memory-safe") {
|
|
283
283
|
let length := sub(end, start)
|
|
284
284
|
|
|
285
|
-
//
|
|
285
|
+
// Allocate memory at freemem — round up to 32-byte boundary so subsequent
|
|
286
286
|
// allocations do not overlap the tail of this buffer.
|
|
287
287
|
slicedBytes := mload(0x40)
|
|
288
288
|
mstore(0x40, and(add(add(add(slicedBytes, length), 0x20), 0x1f), not(0x1f)))
|
|
@@ -296,7 +296,7 @@ library JBMetadataResolver {
|
|
|
296
296
|
// same for the out array
|
|
297
297
|
let sliceBytesStartOfData := add(slicedBytes, 0x20)
|
|
298
298
|
|
|
299
|
-
//
|
|
299
|
+
// Copy data — bound is `length` not `end`. Using `end` (an absolute source offset)
|
|
300
300
|
// would over-copy by `start` bytes past the allocated buffer.
|
|
301
301
|
for { let i := 0 } lt(i, length) { i := add(i, 0x20) } {
|
|
302
302
|
mstore(add(sliceBytesStartOfData, i), mload(add(startBytes, i)))
|
package/test/AuditExploits.t.sol
CHANGED
|
@@ -4,9 +4,9 @@ pragma solidity ^0.8.6;
|
|
|
4
4
|
import /* {*} from */ "./helpers/TestBaseWorkflow.sol";
|
|
5
5
|
import {JBCashOuts} from "../src/libraries/JBCashOuts.sol";
|
|
6
6
|
|
|
7
|
-
/// @notice
|
|
8
|
-
/// @dev These tests target specific edge cases and potential vulnerabilities
|
|
9
|
-
contract
|
|
7
|
+
/// @notice Tests for edge case scenarios in Juicebox V5.
|
|
8
|
+
/// @dev These tests target specific edge cases and potential vulnerabilities.
|
|
9
|
+
contract EdgeCases_Local is TestBaseWorkflow {
|
|
10
10
|
//*********************************************************************//
|
|
11
11
|
// ----------------------------- storage ----------------------------- //
|
|
12
12
|
//*********************************************************************//
|
|
@@ -292,7 +292,7 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
292
292
|
}
|
|
293
293
|
|
|
294
294
|
//*********************************************************************//
|
|
295
|
-
// --- Test 2: REVDeployer beforePayRecordedWith OOB
|
|
295
|
+
// --- Test 2: REVDeployer beforePayRecordedWith OOB ---------------- //
|
|
296
296
|
//*********************************************************************//
|
|
297
297
|
|
|
298
298
|
/// @notice Demonstrates that REVDeployer.beforePayRecordedWith writes to index [1] of the
|
|
@@ -937,14 +937,14 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
937
937
|
}
|
|
938
938
|
|
|
939
939
|
//*********************************************************************//
|
|
940
|
-
// ======
|
|
940
|
+
// ====== EDGE CASE TESTS ============================================ //
|
|
941
941
|
//*********************************************************************//
|
|
942
942
|
|
|
943
943
|
//*********************************************************************//
|
|
944
|
-
// ---
|
|
944
|
+
// --- Zero-Supply Cash Out Drains Entire Surplus ------------------- //
|
|
945
945
|
//*********************************************************************//
|
|
946
946
|
|
|
947
|
-
/// @notice
|
|
947
|
+
/// @notice When totalSupply == 0 and surplus > 0, calling cashOutTokensOf with
|
|
948
948
|
/// cashOutCount = 0 returns the ENTIRE surplus without burning any tokens.
|
|
949
949
|
/// @dev Attack flow:
|
|
950
950
|
/// 1. Project is funded via addToBalanceOf (no tokens minted)
|
|
@@ -952,7 +952,7 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
952
952
|
/// 3. JBCashOuts.cashOutFrom: 0 >= 0 → true → returns full surplus
|
|
953
953
|
/// 4. JBMultiTerminal: cashOutCount != 0 is false → no burn
|
|
954
954
|
/// 5. Attacker receives full surplus minus fee
|
|
955
|
-
function
|
|
955
|
+
function test_zeroSupplyCashOut_drainsEntireSurplus() public {
|
|
956
956
|
// --- Setup: create a project with 0% cash out tax ---
|
|
957
957
|
uint256 projectId = _launchProject({cashOutTaxRate: 0, reservedPercent: 0});
|
|
958
958
|
|
|
@@ -982,7 +982,7 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
982
982
|
// After fix: cashOutCount == 0 → early return 0
|
|
983
983
|
uint256 reclaimFromLibrary =
|
|
984
984
|
JBCashOuts.cashOutFrom({surplus: fundAmount, cashOutCount: 0, totalSupply: 0, cashOutTaxRate: 0});
|
|
985
|
-
assertEq(reclaimFromLibrary, 0, "
|
|
985
|
+
assertEq(reclaimFromLibrary, 0, "cashOutFrom(surplus, 0, 0, rate) returns 0");
|
|
986
986
|
|
|
987
987
|
// --- Verify the exploit is now blocked ---
|
|
988
988
|
address attacker = makeAddr("attacker");
|
|
@@ -1006,22 +1006,22 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
1006
1006
|
uint256 ethReceived = attackerEthAfter - attackerEthBefore;
|
|
1007
1007
|
|
|
1008
1008
|
// --- Verify the exploit is blocked ---
|
|
1009
|
-
assertEq(reclaimAmount, 0, "
|
|
1010
|
-
assertEq(ethReceived, 0, "
|
|
1009
|
+
assertEq(reclaimAmount, 0, "attacker receives nothing when cashing out 0 tokens");
|
|
1010
|
+
assertEq(ethReceived, 0, "no ETH transferred to attacker");
|
|
1011
1011
|
|
|
1012
1012
|
// Terminal balance should be unchanged — not drained.
|
|
1013
1013
|
uint256 terminalBalanceAfter =
|
|
1014
1014
|
jbTerminalStore().balanceOf(address(_terminal), projectId, JBConstants.NATIVE_TOKEN);
|
|
1015
|
-
assertEq(terminalBalanceAfter, fundAmount, "
|
|
1015
|
+
assertEq(terminalBalanceAfter, fundAmount, "terminal balance preserved");
|
|
1016
1016
|
|
|
1017
1017
|
// Attacker never had any tokens.
|
|
1018
1018
|
uint256 attackerTokens = _tokens.totalBalanceOf(attacker, projectId);
|
|
1019
1019
|
assertEq(attackerTokens, 0, "attacker never held any tokens");
|
|
1020
1020
|
}
|
|
1021
1021
|
|
|
1022
|
-
/// @notice
|
|
1022
|
+
/// @notice Same scenario but with a cash out tax rate.
|
|
1023
1023
|
/// Even with a tax, the attacker drains surplus (fee goes to project #1).
|
|
1024
|
-
function
|
|
1024
|
+
function test_zeroSupplyCashOut_withTaxRate() public {
|
|
1025
1025
|
// 50% cash out tax rate
|
|
1026
1026
|
uint256 projectId = _launchProject({cashOutTaxRate: 5000, reservedPercent: 0});
|
|
1027
1027
|
|
|
@@ -1043,7 +1043,7 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
1043
1043
|
// After fix: cashOutCount == 0 → early return 0, regardless of tax rate.
|
|
1044
1044
|
uint256 reclaimFromLibrary =
|
|
1045
1045
|
JBCashOuts.cashOutFrom({surplus: fundAmount, cashOutCount: 0, totalSupply: 0, cashOutTaxRate: 5000});
|
|
1046
|
-
assertEq(reclaimFromLibrary, 0, "
|
|
1046
|
+
assertEq(reclaimFromLibrary, 0, "0 returned even with 50% tax when cashOutCount=0");
|
|
1047
1047
|
|
|
1048
1048
|
// Verify exploit is blocked.
|
|
1049
1049
|
address attacker = makeAddr("attacker");
|
|
@@ -1063,12 +1063,12 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
1063
1063
|
uint256 ethReceived = attacker.balance - attackerEthBefore;
|
|
1064
1064
|
|
|
1065
1065
|
// After fix: attacker gets nothing.
|
|
1066
|
-
assertEq(ethReceived, 0, "
|
|
1067
|
-
assertEq(reclaimAmount, 0, "
|
|
1066
|
+
assertEq(ethReceived, 0, "attacker receives nothing with 0 tokens burned");
|
|
1067
|
+
assertEq(reclaimAmount, 0, "reclaim is 0");
|
|
1068
1068
|
}
|
|
1069
1069
|
|
|
1070
|
-
/// @notice
|
|
1071
|
-
function
|
|
1070
|
+
/// @notice After all tokens are burned, calling cashOut with 0 returns nothing.
|
|
1071
|
+
function test_zeroSupplyCashOut_afterAllTokensBurned() public {
|
|
1072
1072
|
uint256 projectId = _launchProject({cashOutTaxRate: 0, reservedPercent: 0});
|
|
1073
1073
|
|
|
1074
1074
|
vm.prank(_projectOwner);
|
|
@@ -1119,18 +1119,18 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
1119
1119
|
});
|
|
1120
1120
|
|
|
1121
1121
|
// After fix: cashing out 0 tokens returns 0, treasury is safe.
|
|
1122
|
-
assertEq(stolen, 0, "
|
|
1123
|
-
assertEq(attacker.balance, 0, "
|
|
1122
|
+
assertEq(stolen, 0, "cannot extract funds after tokens were burned");
|
|
1123
|
+
assertEq(attacker.balance, 0, "no ETH received");
|
|
1124
1124
|
}
|
|
1125
1125
|
|
|
1126
1126
|
//*********************************************************************//
|
|
1127
|
-
// ---
|
|
1127
|
+
// --- REVDeployer.beforePayRecordedWith Array OOB (Enhanced)-------- //
|
|
1128
1128
|
//*********************************************************************//
|
|
1129
1129
|
|
|
1130
|
-
/// @notice
|
|
1130
|
+
/// @notice Demonstrates the exact Solidity panic that occurs in REVDeployer when
|
|
1131
1131
|
/// usesTiered721Hook=false and usesBuybackHook=true.
|
|
1132
1132
|
/// This mirrors REVDeployer.sol lines 248-258 exactly.
|
|
1133
|
-
function
|
|
1133
|
+
function test_beforePayRecordedWith_fullOOBPattern() public {
|
|
1134
1134
|
// Simulate all 4 combinations and verify only the problematic one reverts.
|
|
1135
1135
|
bool[4] memory tiered = [false, true, false, true];
|
|
1136
1136
|
bool[4] memory buyback = [false, false, true, true];
|
|
@@ -1165,13 +1165,13 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
1165
1165
|
}
|
|
1166
1166
|
|
|
1167
1167
|
//*********************************************************************//
|
|
1168
|
-
// ---
|
|
1168
|
+
// --- REVDeployer.hasMintPermissionFor address(0) call ------------- //
|
|
1169
1169
|
//*********************************************************************//
|
|
1170
1170
|
|
|
1171
|
-
/// @notice
|
|
1171
|
+
/// @notice Demonstrates that calling a function on address(0) reverts.
|
|
1172
1172
|
/// This mirrors REVDeployer.sol line 353 where buybackHook.hasMintPermissionFor(...)
|
|
1173
1173
|
/// is called when buybackHookOf[revnetId] == address(0).
|
|
1174
|
-
function
|
|
1174
|
+
function test_hasMintPermissionFor_callOnAddressZero() public {
|
|
1175
1175
|
// The bug is in the short-circuit evaluation of:
|
|
1176
1176
|
// addr == loansOf[revnetId] // false (addr is some real address)
|
|
1177
1177
|
// || addr == address(buybackHook) // false (buybackHook is address(0), addr is not)
|
|
@@ -1206,22 +1206,22 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
1206
1206
|
// The call succeeds (returns false from empty contract) but has no return data.
|
|
1207
1207
|
// Solidity's interface-based call would revert because it expects abi-encoded bool.
|
|
1208
1208
|
if (success && data.length < 32) {
|
|
1209
|
-
revert("
|
|
1209
|
+
revert("confirmed: call to address(0) returns empty data, ABI decode reverts");
|
|
1210
1210
|
}
|
|
1211
1211
|
if (!success) {
|
|
1212
|
-
revert("
|
|
1212
|
+
revert("confirmed: call to address(0) reverted directly");
|
|
1213
1213
|
}
|
|
1214
1214
|
}
|
|
1215
1215
|
|
|
1216
1216
|
//*********************************************************************//
|
|
1217
|
-
// ---
|
|
1217
|
+
// --- REVLoans uint112 Truncation Pattern --------------------------- //
|
|
1218
1218
|
//*********************************************************************//
|
|
1219
1219
|
|
|
1220
|
-
/// @notice
|
|
1220
|
+
/// @notice Demonstrates silent uint112 truncation.
|
|
1221
1221
|
/// This mirrors REVLoans.sol lines 922-923 where:
|
|
1222
1222
|
/// loan.amount = uint112(newBorrowAmount);
|
|
1223
1223
|
/// loan.collateral = uint112(newCollateralCount);
|
|
1224
|
-
function
|
|
1224
|
+
function test_uint112SilentTruncation() public pure {
|
|
1225
1225
|
// Values that exceed uint112.max
|
|
1226
1226
|
uint256 hugeCollateral = uint256(type(uint112).max) + 1000 ether;
|
|
1227
1227
|
uint256 hugeBorrow = uint256(type(uint112).max) + 500 ether;
|
|
@@ -1249,14 +1249,14 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
1249
1249
|
// If attacker repays truncatedBorrow (tiny), they get back truncatedCollateral (tiny).
|
|
1250
1250
|
// But they already received hugeBorrow worth of funds from the surplus.
|
|
1251
1251
|
// Net theft = hugeBorrow - truncatedBorrow
|
|
1252
|
-
assertGt(borrowProfit, 999 ether, "
|
|
1252
|
+
assertGt(borrowProfit, 999 ether, "attacker extracts >999 ETH from truncation of 1000 ETH overflow");
|
|
1253
1253
|
}
|
|
1254
1254
|
|
|
1255
1255
|
//*********************************************************************//
|
|
1256
|
-
// ---
|
|
1256
|
+
// --- REVLoans Reentrancy Pattern ----------------------------------- //
|
|
1257
1257
|
//*********************************************************************//
|
|
1258
1258
|
|
|
1259
|
-
/// @notice
|
|
1259
|
+
/// @notice Demonstrates the CEI violation pattern in REVLoans._adjust.
|
|
1260
1260
|
/// The contract makes external calls (useAllowanceOf, feeTerminal.pay, _transferFrom)
|
|
1261
1261
|
/// BEFORE writing loan.amount and loan.collateral at lines 921-923.
|
|
1262
1262
|
///
|
|
@@ -1264,7 +1264,7 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
1264
1264
|
///
|
|
1265
1265
|
/// @dev The actual exploit requires a full REVLoans deployment which is in revnet-core-v5.
|
|
1266
1266
|
/// This test demonstrates the PATTERN: external call before state write.
|
|
1267
|
-
function
|
|
1267
|
+
function test_reentrancyPattern_externalCallBeforeStateWrite() public {
|
|
1268
1268
|
// Deploy a mock "victim" contract that follows the vulnerable pattern.
|
|
1269
1269
|
ReentrancyVictim victim = new ReentrancyVictim();
|
|
1270
1270
|
|
|
@@ -1280,26 +1280,22 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
1280
1280
|
attacker.attack();
|
|
1281
1281
|
|
|
1282
1282
|
// The attacker extracted more than they should have.
|
|
1283
|
-
assertGt(
|
|
1284
|
-
address(attacker).balance,
|
|
1285
|
-
10 ether,
|
|
1286
|
-
"EXPLOIT: attacker extracted more than single borrow allows (re-entered)"
|
|
1287
|
-
);
|
|
1283
|
+
assertGt(address(attacker).balance, 10 ether, "attacker extracted more than single borrow allows (re-entered)");
|
|
1288
1284
|
|
|
1289
1285
|
// The victim was drained below expected.
|
|
1290
1286
|
assertLt(address(victim).balance, 10 ether, "victim was over-drained");
|
|
1291
1287
|
}
|
|
1292
1288
|
|
|
1293
1289
|
//*********************************************************************//
|
|
1294
|
-
// ---
|
|
1290
|
+
// --- Held Fee Reentrancy ----------------------------------------- //
|
|
1295
1291
|
//*********************************************************************//
|
|
1296
1292
|
|
|
1297
|
-
/// @notice
|
|
1293
|
+
/// @notice Verifies that the held fee reentrancy fix works correctly.
|
|
1298
1294
|
/// A reentrant fee terminal calls back into processHeldFeesOf during pay().
|
|
1299
1295
|
/// Before the fix, the same fee would be processed twice. After the fix,
|
|
1300
1296
|
/// the index is advanced before the external call (CEI pattern), so the
|
|
1301
1297
|
/// reentrant call processes zero fees.
|
|
1302
|
-
function
|
|
1298
|
+
function test_heldFeeReentrancy_blocked() public {
|
|
1303
1299
|
// --- Step 1: Launch fee project (project #1) with the standard terminal ---
|
|
1304
1300
|
JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
|
|
1305
1301
|
JBAccountingContext[] memory tokensToAccept = new JBAccountingContext[](1);
|
|
@@ -1466,9 +1462,7 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
1466
1462
|
// With the fix (CEI): the reentrant call sees the index already advanced,
|
|
1467
1463
|
// so it only processes the remaining fees (not the same fee again).
|
|
1468
1464
|
// Total pay calls should be exactly 2 (one per held fee), not 3+ (which would indicate double-processing).
|
|
1469
|
-
assertEq(
|
|
1470
|
-
reentrantTerminal.payCallCount(), 2, "M-1 FIX: exactly 2 pay calls (no double-processing from reentrancy)"
|
|
1471
|
-
);
|
|
1465
|
+
assertEq(reentrantTerminal.payCallCount(), 2, "exactly 2 pay calls (no double-processing from reentrancy)");
|
|
1472
1466
|
|
|
1473
1467
|
// Held fees should all be consumed.
|
|
1474
1468
|
JBFee[] memory remainingFees = _terminal.heldFeesOf(projectId, JBConstants.NATIVE_TOKEN, 100);
|
|
@@ -1476,7 +1470,7 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
1476
1470
|
}
|
|
1477
1471
|
|
|
1478
1472
|
/// @notice Verify processHeldFeesOf correctly handles partial unlock (some locked, some unlocked).
|
|
1479
|
-
function
|
|
1473
|
+
function test_partialLockedFees() public {
|
|
1480
1474
|
// Launch fee project.
|
|
1481
1475
|
JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
|
|
1482
1476
|
JBAccountingContext[] memory tokensToAccept = new JBAccountingContext[](1);
|
|
@@ -1637,14 +1631,14 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
1637
1631
|
}
|
|
1638
1632
|
|
|
1639
1633
|
//*********************************************************************//
|
|
1640
|
-
// ---
|
|
1634
|
+
// --- Approval Hook Revert DoS ------------------------------------ //
|
|
1641
1635
|
//*********************************************************************//
|
|
1642
1636
|
|
|
1643
|
-
/// @notice
|
|
1637
|
+
/// @notice A malicious approval hook that reverts on approvalStatusOf() causes
|
|
1644
1638
|
/// currentOf() to revert when the NEXT ruleset (queued or auto-cycled) needs
|
|
1645
1639
|
/// approval from the hook. This permanently freezes the project.
|
|
1646
1640
|
/// Affects JBRulesets (NOT fixed).
|
|
1647
|
-
function
|
|
1641
|
+
function test_approvalHookRevertDoS() public {
|
|
1648
1642
|
// Deploy a malicious approval hook that always reverts
|
|
1649
1643
|
MaliciousApprovalHook maliciousHook = new MaliciousApprovalHook();
|
|
1650
1644
|
|
|
@@ -1759,30 +1753,30 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
1759
1753
|
// currentOf no longer reverts — it treats the approval as Failed and falls back
|
|
1760
1754
|
// to the most recent approved ruleset. The project is NOT frozen.
|
|
1761
1755
|
JBRuleset memory currentRuleset = jbRulesets().currentOf(projectId);
|
|
1762
|
-
assertGt(currentRuleset.id, 0, "
|
|
1756
|
+
assertGt(currentRuleset.id, 0, "project is not frozen, currentOf succeeds");
|
|
1763
1757
|
}
|
|
1764
1758
|
|
|
1765
1759
|
//*********************************************************************//
|
|
1766
|
-
// ---
|
|
1760
|
+
// --- V5.1 Regression: Verify Fix Works ----------------------------- //
|
|
1767
1761
|
//*********************************************************************//
|
|
1768
1762
|
|
|
1769
|
-
/// @notice Verify that the
|
|
1770
|
-
/// but the V5.1 rulesets should handle rulesets correctly (
|
|
1771
|
-
/// bug from the rulesets fix -
|
|
1772
|
-
/// @dev Confirms the
|
|
1773
|
-
function
|
|
1774
|
-
//
|
|
1763
|
+
/// @notice Verify that the zero-supply cash-out exploit is still present in V5.0
|
|
1764
|
+
/// but the V5.1 rulesets should handle rulesets correctly (the zero-supply bug
|
|
1765
|
+
/// is a different bug from the rulesets fix - it is in JBCashOuts library).
|
|
1766
|
+
/// @dev Confirms the fix: cashing out 0 tokens returns 0, not the full surplus.
|
|
1767
|
+
function test_v51_zeroSupplyCashOut_fixedInLibrary() public pure {
|
|
1768
|
+
// The bug was in JBCashOuts.cashOutFrom: when cashOutCount=0 and totalSupply=0,
|
|
1775
1769
|
// the function returned the full surplus instead of 0.
|
|
1776
1770
|
// The fix adds an early return of 0 when cashOutCount == 0.
|
|
1777
1771
|
uint256 reclaimFromLibrary =
|
|
1778
1772
|
JBCashOuts.cashOutFrom({surplus: 10 ether, cashOutCount: 0, totalSupply: 0, cashOutTaxRate: 0});
|
|
1779
1773
|
|
|
1780
1774
|
// Cashing out 0 tokens should return 0, not the full surplus.
|
|
1781
|
-
assertEq(reclaimFromLibrary, 0, "
|
|
1775
|
+
assertEq(reclaimFromLibrary, 0, "cashOutFrom(surplus, 0, 0, rate) should return 0");
|
|
1782
1776
|
}
|
|
1783
1777
|
|
|
1784
|
-
/// @notice Verify
|
|
1785
|
-
function
|
|
1778
|
+
/// @notice Verify the zero-supply bug does not manifest when totalSupply > 0 (normal operation).
|
|
1779
|
+
function test_v51_normalOperation_cashOut_safe() public pure {
|
|
1786
1780
|
// With non-zero supply and non-zero cashOutCount, the bonding curve works correctly
|
|
1787
1781
|
uint256 reclaimable =
|
|
1788
1782
|
JBCashOuts.cashOutFrom({surplus: 10 ether, cashOutCount: 500e18, totalSupply: 1000e18, cashOutTaxRate: 0});
|
|
@@ -1792,11 +1786,11 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
1792
1786
|
}
|
|
1793
1787
|
|
|
1794
1788
|
//*********************************************************************//
|
|
1795
|
-
// ---
|
|
1789
|
+
// --- Held Fees Storage Cleanup ------------------------------------ //
|
|
1796
1790
|
//*********************************************************************//
|
|
1797
1791
|
|
|
1798
1792
|
/// @notice Verifies that processHeldFeesOf deletes processed entries and resets array+index when done.
|
|
1799
|
-
function
|
|
1793
|
+
function test_heldFeesStorageCleanup() public {
|
|
1800
1794
|
// Launch fee project.
|
|
1801
1795
|
JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
|
|
1802
1796
|
JBAccountingContext[] memory tokensToAccept = new JBAccountingContext[](1);
|
|
@@ -1930,7 +1924,7 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
1930
1924
|
|
|
1931
1925
|
// After processing all, the array should be fully reset (not just emptied).
|
|
1932
1926
|
JBFee[] memory remaining = _terminal.heldFeesOf(projectId, JBConstants.NATIVE_TOKEN, 100);
|
|
1933
|
-
assertEq(remaining.length, 0, "
|
|
1927
|
+
assertEq(remaining.length, 0, "all fees processed and array cleaned");
|
|
1934
1928
|
|
|
1935
1929
|
// Creating new fees after cleanup should work from index 0 (array was reset).
|
|
1936
1930
|
vm.deal(address(this), 2 ether);
|
|
@@ -1957,16 +1951,16 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
1957
1951
|
|
|
1958
1952
|
// Should have 1 new fee (not 4 = 3 deleted + 1 new).
|
|
1959
1953
|
JBFee[] memory newFees = _terminal.heldFeesOf(projectId, JBConstants.NATIVE_TOKEN, 100);
|
|
1960
|
-
assertEq(newFees.length, 1, "
|
|
1954
|
+
assertEq(newFees.length, 1, "new fee after array reset works correctly");
|
|
1961
1955
|
}
|
|
1962
1956
|
|
|
1963
1957
|
//*********************************************************************//
|
|
1964
|
-
// ---
|
|
1958
|
+
// --- Ruleset Start Time After Base Ruleset ------------------------ //
|
|
1965
1959
|
//*********************************************************************//
|
|
1966
1960
|
|
|
1967
1961
|
/// @notice Verifies that a queued ruleset's start time is bumped to at least
|
|
1968
|
-
/// the base ruleset's start time
|
|
1969
|
-
function
|
|
1962
|
+
/// the base ruleset's start time.
|
|
1963
|
+
function test_rulesetStartsAfterBaseRuleset() public {
|
|
1970
1964
|
// Launch fee project.
|
|
1971
1965
|
JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
|
|
1972
1966
|
JBAccountingContext[] memory tokensToAccept = new JBAccountingContext[](1);
|
|
@@ -2026,7 +2020,7 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
2026
2020
|
assertGt(firstStart, 0, "first ruleset should have a start time");
|
|
2027
2021
|
|
|
2028
2022
|
// Queue a second ruleset with mustStartAtOrAfter=0 (meaning "as soon as possible").
|
|
2029
|
-
// Without the
|
|
2023
|
+
// Without the fix, this could theoretically derive a start before the base ruleset.
|
|
2030
2024
|
JBRulesetConfig[] memory secondConfig = new JBRulesetConfig[](1);
|
|
2031
2025
|
secondConfig[0].mustStartAtOrAfter = 0; // Key: asking for earliest possible
|
|
2032
2026
|
secondConfig[0].duration = 30 days;
|
|
@@ -2041,15 +2035,15 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
2041
2035
|
// Get the queued ruleset.
|
|
2042
2036
|
(JBRuleset memory queued,) = jbRulesets().latestQueuedOf(projectId);
|
|
2043
2037
|
|
|
2044
|
-
//
|
|
2045
|
-
assertGe(queued.start, firstStart, "
|
|
2038
|
+
// Fix: the queued ruleset must start at or after the base ruleset's start.
|
|
2039
|
+
assertGe(queued.start, firstStart, "queued ruleset must start at or after base ruleset");
|
|
2046
2040
|
|
|
2047
2041
|
// It should start at the next cycle boundary (firstStart + duration).
|
|
2048
2042
|
assertEq(queued.start, firstStart + 30 days, "queued ruleset starts at next cycle boundary");
|
|
2049
2043
|
}
|
|
2050
2044
|
|
|
2051
2045
|
//*********************************************************************//
|
|
2052
|
-
// ---
|
|
2046
|
+
// --- Pending Reserves Inflate cashOutWeight (property test)-------- //
|
|
2053
2047
|
//*********************************************************************//
|
|
2054
2048
|
|
|
2055
2049
|
//*********************************************************************//
|
|
@@ -2507,13 +2501,13 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
2507
2501
|
}
|
|
2508
2502
|
|
|
2509
2503
|
//*********************************************************************//
|
|
2510
|
-
// ---
|
|
2504
|
+
// --- Pending Reserves Inflate cashOutWeight (property test)-------- //
|
|
2511
2505
|
//*********************************************************************//
|
|
2512
2506
|
|
|
2513
2507
|
/// @notice Property: cashOut reclaim with pending reserves <= cashOut reclaim after reserves distributed.
|
|
2514
2508
|
/// @dev When there are pending reserved tokens that haven't been distributed, they should
|
|
2515
2509
|
/// not inflate the denominator used for cash-out calculations.
|
|
2516
|
-
function
|
|
2510
|
+
function test_pendingReserves_cashOutProperty() public {
|
|
2517
2511
|
// Launch project with 50% reserved rate
|
|
2518
2512
|
uint256 projectId = _launchProject({cashOutTaxRate: 0, reservedPercent: 5000});
|
|
2519
2513
|
|
|
@@ -2529,7 +2523,7 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
2529
2523
|
token: JBConstants.NATIVE_TOKEN,
|
|
2530
2524
|
beneficiary: _beneficiary,
|
|
2531
2525
|
minReturnedTokens: 0,
|
|
2532
|
-
memo: "
|
|
2526
|
+
memo: "test",
|
|
2533
2527
|
metadata: new bytes(0)
|
|
2534
2528
|
});
|
|
2535
2529
|
|
|
@@ -2589,7 +2583,7 @@ contract AuditExploits_Local is TestBaseWorkflow {
|
|
|
2589
2583
|
// share decreases, so reclaimable should decrease or stay the same.
|
|
2590
2584
|
// With 0% cashOutTaxRate and linear redemption: reclaimable = surplus * tokens / totalSupply
|
|
2591
2585
|
// After reserves: totalSupply is larger, so reclaimable is smaller.
|
|
2592
|
-
assertLe(reclaimAfter, reclaimBefore, "
|
|
2586
|
+
assertLe(reclaimAfter, reclaimBefore, "reclaim after reserves distributed <= reclaim before");
|
|
2593
2587
|
}
|
|
2594
2588
|
}
|
|
2595
2589
|
|
|
@@ -2638,7 +2632,7 @@ contract ReentrancyAttacker {
|
|
|
2638
2632
|
}
|
|
2639
2633
|
}
|
|
2640
2634
|
|
|
2641
|
-
/// @notice Malicious fee terminal that reenters processHeldFeesOf during pay()
|
|
2635
|
+
/// @notice Malicious fee terminal that reenters processHeldFeesOf during pay().
|
|
2642
2636
|
contract ReentrantFeeTerminal is ERC165 {
|
|
2643
2637
|
IJBMultiTerminal public immutable victim;
|
|
2644
2638
|
uint256 public immutable targetProjectId;
|
|
@@ -2694,7 +2688,7 @@ contract ReentrantFeeTerminal is ERC165 {
|
|
|
2694
2688
|
receive() external payable {}
|
|
2695
2689
|
}
|
|
2696
2690
|
|
|
2697
|
-
/// @notice Malicious approval hook that always reverts
|
|
2691
|
+
/// @notice Malicious approval hook that always reverts.
|
|
2698
2692
|
contract MaliciousApprovalHook is ERC165, IJBRulesetApprovalHook {
|
|
2699
2693
|
function DURATION() external pure override returns (uint256) {
|
|
2700
2694
|
return 1 days;
|
|
@@ -262,15 +262,15 @@ contract FlashLoanAttacks_Local is TestBaseWorkflow {
|
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
// ═══════════════════════════════════════════════════════════════════
|
|
265
|
-
// Test 5:
|
|
265
|
+
// Test 5: Regression — cashOut(0) with totalSupply==0 must return 0
|
|
266
266
|
// ═══════════════════════════════════════════════════════════════════
|
|
267
267
|
|
|
268
|
-
/// @notice
|
|
268
|
+
/// @notice Regression test: cashOut(0) with totalSupply==0 previously returned the entire surplus.
|
|
269
269
|
/// @dev In V5, `cashOutCount >= totalSupply` (0 >= 0) was true and returned the full surplus before
|
|
270
270
|
/// checking for zero cashOutCount. Fixed since V5.1: `JBCashOuts.cashOutFrom` returns 0 when
|
|
271
271
|
/// cashOutCount==0 (line 31) before reaching the `cashOutCount >= totalSupply` check (line 37).
|
|
272
272
|
/// This test verifies the fix holds.
|
|
273
|
-
function
|
|
273
|
+
function test_variant_addToBalance_zeroCashOut() public {
|
|
274
274
|
// Add to balance when no tokens exist
|
|
275
275
|
vm.deal(address(0xD000), 5 ether);
|
|
276
276
|
vm.prank(address(0xD000));
|
|
@@ -297,7 +297,7 @@ contract FlashLoanAttacks_Local is TestBaseWorkflow {
|
|
|
297
297
|
metadata: new bytes(0)
|
|
298
298
|
});
|
|
299
299
|
|
|
300
|
-
assertEq(reclaimAmount, 0, "
|
|
300
|
+
assertEq(reclaimAmount, 0, "Regression: cashOut(0) must return 0");
|
|
301
301
|
}
|
|
302
302
|
|
|
303
303
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -350,11 +350,11 @@ contract FlashLoanAttacks_Local is TestBaseWorkflow {
|
|
|
350
350
|
}
|
|
351
351
|
|
|
352
352
|
// ═══════════════════════════════════════════════════════════════════
|
|
353
|
-
// Test 8:
|
|
353
|
+
// Test 8: Reserved token inflation — cashOut timing
|
|
354
354
|
// ═══════════════════════════════════════════════════════════════════
|
|
355
355
|
|
|
356
356
|
function test_reservedTokenInflation_cashOutTiming() public {
|
|
357
|
-
// Launch a project with 20% reserved to test
|
|
357
|
+
// Launch a project with 20% reserved to test inflation
|
|
358
358
|
JBRulesetConfig[] memory rulesetConfig = new JBRulesetConfig[](1);
|
|
359
359
|
rulesetConfig[0].mustStartAtOrAfter = 0;
|
|
360
360
|
rulesetConfig[0].duration = 0;
|
|
@@ -4,7 +4,7 @@ pragma solidity ^0.8.6;
|
|
|
4
4
|
import /* {*} from */ "./helpers/TestBaseWorkflow.sol";
|
|
5
5
|
import {JBCashOuts} from "../src/libraries/JBCashOuts.sol";
|
|
6
6
|
|
|
7
|
-
/// @notice Edge case tests for cross-ruleset cash outs and pending reserves inflation
|
|
7
|
+
/// @notice Edge case tests for cross-ruleset cash outs and pending reserves inflation.
|
|
8
8
|
/// Demonstrates that pending reserved tokens inflate totalSupply in cash-out calculations,
|
|
9
9
|
/// systematically undervaluing cash-outs until reserves are distributed.
|
|
10
10
|
contract TestCashOutTimingEdge_Local is TestBaseWorkflow {
|
|
@@ -92,7 +92,7 @@ contract TestCashOutTimingEdge_Local is TestBaseWorkflow {
|
|
|
92
92
|
_controller.deployERC20For(_projectId, "Test", "TST", bytes32(0));
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
/// @notice
|
|
95
|
+
/// @notice Pending reserves inflate totalSupply, reducing cash-out value.
|
|
96
96
|
/// When reserves exist but haven't been distributed, totalTokenSupplyWithReservedTokensOf
|
|
97
97
|
/// includes them in the denominator, making each token worth less.
|
|
98
98
|
function test_pendingReserves_inflateSupply_reduceCashOut() external {
|
|
@@ -134,8 +134,8 @@ contract TestCashOutTimingEdge_Local is TestBaseWorkflow {
|
|
|
134
134
|
surplus: surplus, cashOutCount: payerTokens, totalSupply: circulatingSupply, cashOutTaxRate: 5000
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
-
//
|
|
138
|
-
assertLt(reclaimWithInflation, reclaimWithoutInflation, "
|
|
137
|
+
// Cash-out with pending reserves is LESS than without.
|
|
138
|
+
assertLt(reclaimWithInflation, reclaimWithoutInflation, "Pending reserves reduce cash-out value");
|
|
139
139
|
|
|
140
140
|
// Quantify the impact.
|
|
141
141
|
uint256 lostValue = reclaimWithoutInflation - reclaimWithInflation;
|
|
@@ -177,7 +177,7 @@ contract TestCashOutTimingEdge_Local is TestBaseWorkflow {
|
|
|
177
177
|
assertEq(totalAfter, totalBefore, "Total supply unchanged after distribution");
|
|
178
178
|
|
|
179
179
|
// Cash-out value should be the same since total didn't change.
|
|
180
|
-
//
|
|
180
|
+
// One approach: don't include pending reserves in totalSupply.
|
|
181
181
|
uint256 reclaimAfter = JBCashOuts.cashOutFrom({
|
|
182
182
|
surplus: surplus, cashOutCount: payerTokens, totalSupply: totalAfter, cashOutTaxRate: 5000
|
|
183
183
|
});
|
|
@@ -21,7 +21,7 @@ contract JBRulesetsHarness is JBRulesets {
|
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
/// @notice Tests for
|
|
24
|
+
/// @notice Tests for duration underflow fix in `_simulateCycledRulesetBasedOn`.
|
|
25
25
|
///
|
|
26
26
|
/// The fix guards against arithmetic underflow when `baseRuleset.duration >= block.timestamp`.
|
|
27
27
|
/// Without the fix, `block.timestamp - baseRuleset.duration + 1` wraps around to ~2^256,
|
package/test/TestFees.sol
CHANGED
|
@@ -481,7 +481,7 @@ contract TestFees_Local is TestBaseWorkflow {
|
|
|
481
481
|
assertEq(_persistingFee.length, 1);
|
|
482
482
|
}
|
|
483
483
|
|
|
484
|
-
function
|
|
484
|
+
function test_doubleFeeEdgeCase() external {
|
|
485
485
|
vm.skip(true);
|
|
486
486
|
|
|
487
487
|
// Setup: Pay the zero project so the terminal has balance of 1 eth from another project.
|
|
@@ -537,10 +537,10 @@ contract TestFees_Local is TestBaseWorkflow {
|
|
|
537
537
|
// Check the unlock timestamp
|
|
538
538
|
assertEq(_checkOgFee[0].unlockTimestamp, block.timestamp + 2_419_200);
|
|
539
539
|
|
|
540
|
-
//
|
|
540
|
+
// The fee amount is 1 ETH
|
|
541
541
|
assertEq(_checkOgFee[0].amount, _nativeDistLimit);
|
|
542
542
|
|
|
543
|
-
//
|
|
543
|
+
// The project's balance in terminal store will be zero.
|
|
544
544
|
uint256 project2BalanceStore =
|
|
545
545
|
jbTerminalStore().balanceOf(address(_terminal), _projectId, JBConstants.NATIVE_TOKEN);
|
|
546
546
|
assertEq(project2BalanceStore, 0);
|
|
@@ -3,7 +3,7 @@ pragma solidity ^0.8.6;
|
|
|
3
3
|
|
|
4
4
|
import /* {*} from */ "./helpers/TestBaseWorkflow.sol";
|
|
5
5
|
|
|
6
|
-
/// @notice Confirms
|
|
6
|
+
/// @notice Confirms held fees are stranded when a terminal migrates.
|
|
7
7
|
/// The migration calls addToBalanceOf with shouldReturnHeldFees: false,
|
|
8
8
|
/// leaving held fees in the old terminal with no balance to process them.
|
|
9
9
|
contract TestMigrationHeldFees_Local is TestBaseWorkflow {
|
|
@@ -116,7 +116,7 @@ contract TestMigrationHeldFees_Local is TestBaseWorkflow {
|
|
|
116
116
|
});
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
/// @notice
|
|
119
|
+
/// @notice Pay → distribute payouts (holds fees) → migrate → fees stranded.
|
|
120
120
|
function test_migration_heldFeesStranded() external {
|
|
121
121
|
// Step 1: Pay the project.
|
|
122
122
|
_terminal.pay{value: PAY_AMOUNT}({
|
|
@@ -156,7 +156,7 @@ contract TestMigrationHeldFees_Local is TestBaseWorkflow {
|
|
|
156
156
|
|
|
157
157
|
// Held fees still exist in old terminal.
|
|
158
158
|
JBFee[] memory feesAfterMigration = _terminal.heldFeesOf(_projectId, JBConstants.NATIVE_TOKEN, 100);
|
|
159
|
-
assertEq(feesAfterMigration.length, heldFees.length, "
|
|
159
|
+
assertEq(feesAfterMigration.length, heldFees.length, "Held fees remain in old terminal");
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
/// @notice Process held fees FIRST, then migrate — correct approach.
|
|
@@ -244,6 +244,6 @@ contract TestMigrationHeldFees_Local is TestBaseWorkflow {
|
|
|
244
244
|
// The fee processing attempted but the terminal has no actual ETH.
|
|
245
245
|
// The _recordAddedBalanceFor in the catch block inflates the store balance
|
|
246
246
|
// without actual funds — this is a phantom balance.
|
|
247
|
-
// This documents the
|
|
247
|
+
// This documents the issue: either fees are lost OR phantom balances are created.
|
|
248
248
|
}
|
|
249
249
|
}
|
|
@@ -22,7 +22,7 @@ contract MockApprovalHookConfigurable is IJBRulesetApprovalHook {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/// @notice Mock approval hook that always reverts (for DoS testing
|
|
25
|
+
/// @notice Mock approval hook that always reverts (for DoS testing).
|
|
26
26
|
contract RevertingApprovalHook is IJBRulesetApprovalHook {
|
|
27
27
|
uint256 public constant override DURATION = 3 days;
|
|
28
28
|
|
|
@@ -296,10 +296,10 @@ contract MockApprovalHookConfigurable is IJBRulesetApprovalHook {
|
|
|
296
296
|
assertGt(current.cycleNumber, 1, "Should have rolled over multiple cycles");
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
/// @notice
|
|
299
|
+
/// @notice Reverting approval hook no longer causes DoS.
|
|
300
300
|
/// The try/catch in _approvalStatusOf catches the revert and returns Failed status,
|
|
301
301
|
/// so currentOf succeeds and falls back to the previous ruleset.
|
|
302
|
-
function
|
|
302
|
+
function test_approvalHook_revert_causesDoS() external {
|
|
303
303
|
RevertingApprovalHook revertHook = new RevertingApprovalHook();
|
|
304
304
|
|
|
305
305
|
uint256 pid = _launchProject(FOURTEEN_DAYS, INITIAL_WEIGHT, 0, IJBRulesetApprovalHook(address(revertHook)));
|
|
@@ -310,7 +310,7 @@ contract MockApprovalHookConfigurable is IJBRulesetApprovalHook {
|
|
|
310
310
|
// Warp past current cycle so the queued one is checked.
|
|
311
311
|
vm.warp(block.timestamp + FOURTEEN_DAYS);
|
|
312
312
|
|
|
313
|
-
//
|
|
313
|
+
// The reverting hook is caught by try/catch, treated as Failed.
|
|
314
314
|
// currentOf succeeds and falls back to the original ruleset (weight not doubled).
|
|
315
315
|
JBRuleset memory current = _rulesets.currentOf(pid);
|
|
316
316
|
assertGt(current.id, 0, "currentOf should succeed, not revert");
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity >=0.8.6;
|
|
3
|
+
|
|
4
|
+
import /* {*} from */ "./helpers/TestBaseWorkflow.sol";
|
|
5
|
+
|
|
6
|
+
/// @notice Mock approval hook that ALWAYS rejects queued rulesets.
|
|
7
|
+
contract AlwaysRejectApprovalHook is IJBRulesetApprovalHook {
|
|
8
|
+
function DURATION() external pure override returns (uint256) {
|
|
9
|
+
return 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function approvalStatusOf(uint256, JBRuleset memory) external pure override returns (JBApprovalStatus) {
|
|
13
|
+
return JBApprovalStatus.Failed;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
|
|
17
|
+
return interfaceId == type(IJBRulesetApprovalHook).interfaceId || interfaceId == type(IERC165).interfaceId;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// @notice Regression tests for the weight cache stale-after-rejection fix.
|
|
22
|
+
///
|
|
23
|
+
/// Before the fix, updateRulesetWeightCache() only updated the cache for latestRulesetIdOf.
|
|
24
|
+
/// When a queued ruleset was rejected by an approval hook, currentOf() would fall back to
|
|
25
|
+
/// the base ruleset, but that base ruleset's cache could never be updated — causing
|
|
26
|
+
/// deriveWeightFrom() to revert with WeightCacheRequired after >20,000 cycles.
|
|
27
|
+
///
|
|
28
|
+
/// The fix adds a rulesetId parameter to updateRulesetWeightCache() so callers can
|
|
29
|
+
/// specify exactly which ruleset's cache to update — typically the one currentOf() uses.
|
|
30
|
+
contract TestWeightCacheStaleAfterRejection is TestBaseWorkflow {
|
|
31
|
+
IJBController private _controller;
|
|
32
|
+
IJBRulesets private _rulesets;
|
|
33
|
+
address private _projectOwner;
|
|
34
|
+
AlwaysRejectApprovalHook private _rejectHook;
|
|
35
|
+
|
|
36
|
+
uint256 private _projectId;
|
|
37
|
+
|
|
38
|
+
function setUp() public override {
|
|
39
|
+
super.setUp();
|
|
40
|
+
_controller = jbController();
|
|
41
|
+
_rulesets = jbRulesets();
|
|
42
|
+
_projectOwner = multisig();
|
|
43
|
+
_rejectHook = new AlwaysRejectApprovalHook();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// @notice Launch a project with a 1-second duration ruleset, weight decay, and an always-reject approval hook.
|
|
47
|
+
function _launchProject() internal returns (uint256 projectId) {
|
|
48
|
+
JBRulesetConfig[] memory rulesetConfigurations = new JBRulesetConfig[](1);
|
|
49
|
+
|
|
50
|
+
JBRulesetMetadata memory metadata = JBRulesetMetadata({
|
|
51
|
+
reservedPercent: 0,
|
|
52
|
+
cashOutTaxRate: 0,
|
|
53
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
54
|
+
pausePay: false,
|
|
55
|
+
pauseCreditTransfers: false,
|
|
56
|
+
allowOwnerMinting: false,
|
|
57
|
+
allowSetCustomToken: false,
|
|
58
|
+
allowTerminalMigration: false,
|
|
59
|
+
allowSetTerminals: false,
|
|
60
|
+
ownerMustSendPayouts: false,
|
|
61
|
+
allowSetController: false,
|
|
62
|
+
allowAddAccountingContext: true,
|
|
63
|
+
allowAddPriceFeed: false,
|
|
64
|
+
holdFees: false,
|
|
65
|
+
useTotalSurplusForCashOuts: true,
|
|
66
|
+
useDataHookForPay: false,
|
|
67
|
+
useDataHookForCashOut: false,
|
|
68
|
+
dataHook: address(0),
|
|
69
|
+
metadata: 0
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
rulesetConfigurations[0] = JBRulesetConfig({
|
|
73
|
+
mustStartAtOrAfter: 0,
|
|
74
|
+
duration: 1, // 1 second — cycles very fast
|
|
75
|
+
weight: 1000e18,
|
|
76
|
+
weightCutPercent: 1, // Non-zero so weight decays each cycle
|
|
77
|
+
approvalHook: _rejectHook, // Will reject all subsequent rulesets
|
|
78
|
+
metadata: metadata,
|
|
79
|
+
splitGroups: new JBSplitGroup[](0),
|
|
80
|
+
fundAccessLimitGroups: new JBFundAccessLimitGroup[](0)
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
vm.prank(_projectOwner);
|
|
84
|
+
projectId = _controller.launchProjectFor({
|
|
85
|
+
owner: _projectOwner,
|
|
86
|
+
projectUri: "ipfs://test",
|
|
87
|
+
rulesetConfigurations: rulesetConfigurations,
|
|
88
|
+
terminalConfigurations: new JBTerminalConfig[](0),
|
|
89
|
+
memo: ""
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// @notice Helper to build a new ruleset config for queuing (will be rejected).
|
|
94
|
+
function _buildRejectedConfig() internal pure returns (JBRulesetConfig[] memory newConfigs) {
|
|
95
|
+
newConfigs = new JBRulesetConfig[](1);
|
|
96
|
+
newConfigs[0] = JBRulesetConfig({
|
|
97
|
+
mustStartAtOrAfter: 0,
|
|
98
|
+
duration: 1,
|
|
99
|
+
weight: 500e18, // Specific weight, not 1 (inherit), to avoid deriveWeightFrom during queuing
|
|
100
|
+
weightCutPercent: 1,
|
|
101
|
+
approvalHook: IJBRulesetApprovalHook(address(0)),
|
|
102
|
+
metadata: JBRulesetMetadata({
|
|
103
|
+
reservedPercent: 0,
|
|
104
|
+
cashOutTaxRate: 0,
|
|
105
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
106
|
+
pausePay: false,
|
|
107
|
+
pauseCreditTransfers: false,
|
|
108
|
+
allowOwnerMinting: false,
|
|
109
|
+
allowSetCustomToken: false,
|
|
110
|
+
allowTerminalMigration: false,
|
|
111
|
+
allowSetTerminals: false,
|
|
112
|
+
ownerMustSendPayouts: false,
|
|
113
|
+
allowSetController: false,
|
|
114
|
+
allowAddAccountingContext: true,
|
|
115
|
+
allowAddPriceFeed: false,
|
|
116
|
+
holdFees: false,
|
|
117
|
+
useTotalSurplusForCashOuts: true,
|
|
118
|
+
useDataHookForPay: false,
|
|
119
|
+
useDataHookForCashOut: false,
|
|
120
|
+
dataHook: address(0),
|
|
121
|
+
metadata: 0
|
|
122
|
+
}),
|
|
123
|
+
splitGroups: new JBSplitGroup[](0),
|
|
124
|
+
fundAccessLimitGroups: new JBFundAccessLimitGroup[](0)
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// @notice REGRESSION: After queuing a rejected ruleset and warping >20k cycles,
|
|
129
|
+
/// updateRulesetWeightCache() should update the active base ruleset's cache (not just the
|
|
130
|
+
/// rejected latest), allowing currentOf() to succeed.
|
|
131
|
+
function test_weightCache_fixedAfterApprovalRejection() public {
|
|
132
|
+
_projectId = _launchProject();
|
|
133
|
+
|
|
134
|
+
// Verify the project works initially.
|
|
135
|
+
JBRuleset memory initial = _rulesets.currentOf(_projectId);
|
|
136
|
+
assertGt(initial.weight, 0, "Initial weight should be set");
|
|
137
|
+
|
|
138
|
+
// Queue a new ruleset (B). It will be rejected by A's approval hook.
|
|
139
|
+
vm.prank(_projectOwner);
|
|
140
|
+
_controller.queueRulesetsOf({projectId: _projectId, rulesetConfigurations: _buildRejectedConfig(), memo: ""});
|
|
141
|
+
|
|
142
|
+
// Verify B is now the latest but A is still the current (B is rejected).
|
|
143
|
+
uint256 latestId = _rulesets.latestRulesetIdOf(_projectId);
|
|
144
|
+
assertGt(latestId, initial.id, "Latest should be B");
|
|
145
|
+
JBRuleset memory afterQueue = _rulesets.currentOf(_projectId);
|
|
146
|
+
assertEq(afterQueue.id, initial.id, "Current should still be A (B is rejected)");
|
|
147
|
+
|
|
148
|
+
// Warp beyond the 20,000-cycle cache threshold.
|
|
149
|
+
vm.warp(block.timestamp + 20_001);
|
|
150
|
+
|
|
151
|
+
// Before the fix, updateRulesetWeightCache only accepted projectId and always updated
|
|
152
|
+
// latestRulesetIdOf — which is the rejected B. A's cache could never be updated.
|
|
153
|
+
// After the fix, the caller passes the rulesetId of the active base ruleset (A).
|
|
154
|
+
_rulesets.updateRulesetWeightCache(_projectId, initial.id);
|
|
155
|
+
|
|
156
|
+
// Now currentOf() should succeed because A's cache is populated.
|
|
157
|
+
JBRuleset memory afterFix = _rulesets.currentOf(_projectId);
|
|
158
|
+
assertEq(afterFix.id, initial.id, "Should still use ruleset A");
|
|
159
|
+
// Weight should be less than initial (decayed over 20k+ cycles).
|
|
160
|
+
assertLt(afterFix.weight, initial.weight, "Weight should have decayed");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// @notice Multiple cache updates work correctly when the latest is rejected.
|
|
164
|
+
/// Verifies that progressive caching (multiple updateRulesetWeightCache calls) also
|
|
165
|
+
/// works for the base ruleset, not just the latest.
|
|
166
|
+
function test_weightCache_progressiveCachingForRejectedLatest() public {
|
|
167
|
+
_projectId = _launchProject();
|
|
168
|
+
|
|
169
|
+
// Capture the base ruleset (A) ID before warping — after the warp, currentOf() would revert.
|
|
170
|
+
uint256 baseRulesetId = _rulesets.currentOf(_projectId).id;
|
|
171
|
+
|
|
172
|
+
// Queue a rejected ruleset.
|
|
173
|
+
vm.prank(_projectOwner);
|
|
174
|
+
_controller.queueRulesetsOf({projectId: _projectId, rulesetConfigurations: _buildRejectedConfig(), memo: ""});
|
|
175
|
+
|
|
176
|
+
// Warp far into the future (50k cycles — needs 3 cache update calls).
|
|
177
|
+
vm.warp(block.timestamp + 50_001);
|
|
178
|
+
|
|
179
|
+
// First cache update covers up to 20k cycles.
|
|
180
|
+
_rulesets.updateRulesetWeightCache(_projectId, baseRulesetId);
|
|
181
|
+
// Second covers up to 40k.
|
|
182
|
+
_rulesets.updateRulesetWeightCache(_projectId, baseRulesetId);
|
|
183
|
+
// Third covers up to 50k.
|
|
184
|
+
_rulesets.updateRulesetWeightCache(_projectId, baseRulesetId);
|
|
185
|
+
|
|
186
|
+
// currentOf() should now work.
|
|
187
|
+
JBRuleset memory current = _rulesets.currentOf(_projectId);
|
|
188
|
+
assertGt(current.cycleNumber, 50_000, "Should be past 50k cycles");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// @notice With the fix applied, payments and cashouts continue to work even after
|
|
192
|
+
/// a rejected ruleset + large cycle gap.
|
|
193
|
+
function test_weightCache_terminalOperationsWorkAfterFix() public {
|
|
194
|
+
// Set up terminal for the project.
|
|
195
|
+
JBAccountingContext[] memory accountingContexts = new JBAccountingContext[](1);
|
|
196
|
+
accountingContexts[0] = JBAccountingContext({
|
|
197
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
JBTerminalConfig[] memory terminalConfigs = new JBTerminalConfig[](1);
|
|
201
|
+
terminalConfigs[0] =
|
|
202
|
+
JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: accountingContexts});
|
|
203
|
+
|
|
204
|
+
// Launch with terminals.
|
|
205
|
+
JBRulesetConfig[] memory rulesetConfigurations = new JBRulesetConfig[](1);
|
|
206
|
+
rulesetConfigurations[0] = JBRulesetConfig({
|
|
207
|
+
mustStartAtOrAfter: 0,
|
|
208
|
+
duration: 1,
|
|
209
|
+
weight: 1000e18,
|
|
210
|
+
weightCutPercent: 1,
|
|
211
|
+
approvalHook: _rejectHook,
|
|
212
|
+
metadata: JBRulesetMetadata({
|
|
213
|
+
reservedPercent: 0,
|
|
214
|
+
cashOutTaxRate: 0,
|
|
215
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
216
|
+
pausePay: false,
|
|
217
|
+
pauseCreditTransfers: false,
|
|
218
|
+
allowOwnerMinting: false,
|
|
219
|
+
allowSetCustomToken: false,
|
|
220
|
+
allowTerminalMigration: false,
|
|
221
|
+
allowSetTerminals: false,
|
|
222
|
+
ownerMustSendPayouts: false,
|
|
223
|
+
allowSetController: false,
|
|
224
|
+
allowAddAccountingContext: true,
|
|
225
|
+
allowAddPriceFeed: false,
|
|
226
|
+
holdFees: false,
|
|
227
|
+
useTotalSurplusForCashOuts: true,
|
|
228
|
+
useDataHookForPay: false,
|
|
229
|
+
useDataHookForCashOut: false,
|
|
230
|
+
dataHook: address(0),
|
|
231
|
+
metadata: 0
|
|
232
|
+
}),
|
|
233
|
+
splitGroups: new JBSplitGroup[](0),
|
|
234
|
+
fundAccessLimitGroups: new JBFundAccessLimitGroup[](0)
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
vm.prank(_projectOwner);
|
|
238
|
+
_projectId = _controller.launchProjectFor({
|
|
239
|
+
owner: _projectOwner,
|
|
240
|
+
projectUri: "ipfs://test",
|
|
241
|
+
rulesetConfigurations: rulesetConfigurations,
|
|
242
|
+
terminalConfigurations: terminalConfigs,
|
|
243
|
+
memo: ""
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Make a payment so there are funds in the terminal.
|
|
247
|
+
address payer = makeAddr("payer");
|
|
248
|
+
vm.deal(payer, 10 ether);
|
|
249
|
+
vm.prank(payer);
|
|
250
|
+
jbMultiTerminal().pay{value: 5 ether}({
|
|
251
|
+
projectId: _projectId,
|
|
252
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
253
|
+
amount: 5 ether,
|
|
254
|
+
beneficiary: payer,
|
|
255
|
+
minReturnedTokens: 0,
|
|
256
|
+
memo: "",
|
|
257
|
+
metadata: ""
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Capture the base ruleset (A) ID before queuing a rejected one.
|
|
261
|
+
uint256 baseRulesetId = _rulesets.currentOf(_projectId).id;
|
|
262
|
+
|
|
263
|
+
// Queue a rejected ruleset.
|
|
264
|
+
vm.prank(_projectOwner);
|
|
265
|
+
_controller.queueRulesetsOf({projectId: _projectId, rulesetConfigurations: _buildRejectedConfig(), memo: ""});
|
|
266
|
+
|
|
267
|
+
// Warp beyond 20k cycles.
|
|
268
|
+
vm.warp(block.timestamp + 20_001);
|
|
269
|
+
|
|
270
|
+
// Update the cache for the active base ruleset (A), not the rejected latest (B).
|
|
271
|
+
_rulesets.updateRulesetWeightCache(_projectId, baseRulesetId);
|
|
272
|
+
|
|
273
|
+
// Payments should succeed after cache update.
|
|
274
|
+
vm.deal(payer, 1 ether);
|
|
275
|
+
vm.prank(payer);
|
|
276
|
+
jbMultiTerminal().pay{value: 1 ether}({
|
|
277
|
+
projectId: _projectId,
|
|
278
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
279
|
+
amount: 1 ether,
|
|
280
|
+
beneficiary: payer,
|
|
281
|
+
minReturnedTokens: 0,
|
|
282
|
+
memo: "",
|
|
283
|
+
metadata: ""
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
uint256 tokenBalance = jbTokens().totalBalanceOf(payer, _projectId);
|
|
287
|
+
assertGt(tokenBalance, 0, "Payer should have tokens after fix");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -192,7 +192,7 @@ contract BondingCurveProperties is Test {
|
|
|
192
192
|
uint256 secondResult = JBCashOuts.cashOutFrom(remainingSurplus, b, remainingSupply, cashOutTaxRate);
|
|
193
193
|
|
|
194
194
|
// NOTE: Strict subadditivity (firstResult + secondResult <= singleResult) was proven to be
|
|
195
|
-
// violated due to mulDiv rounding accumulation
|
|
195
|
+
// violated due to mulDiv rounding accumulation.
|
|
196
196
|
// The violation is bounded by rounding precision and is economically insignificant (~0.00001%).
|
|
197
197
|
// We verify the weaker property: the excess is bounded by rounding tolerance.
|
|
198
198
|
if (firstResult + secondResult > singleResult) {
|
|
@@ -228,7 +228,7 @@ contract BondingCurveProperties is Test {
|
|
|
228
228
|
|
|
229
229
|
uint256 secondResult = JBCashOuts.cashOutFrom(remainingSurplus, b, remainingSupply, cashOutTaxRate);
|
|
230
230
|
|
|
231
|
-
// NOTE: Strict subadditivity violated due to mulDiv rounding
|
|
231
|
+
// NOTE: Strict subadditivity violated due to mulDiv rounding.
|
|
232
232
|
// Verify the weaker property: excess bounded by rounding tolerance (< 0.01%).
|
|
233
233
|
if (firstResult + secondResult > singleResult) {
|
|
234
234
|
uint256 excess = (firstResult + secondResult) - singleResult;
|
|
@@ -106,6 +106,6 @@ contract RulesetsHandler is Test {
|
|
|
106
106
|
|
|
107
107
|
/// @notice Update the weight cache for the project.
|
|
108
108
|
function updateWeightCache() public {
|
|
109
|
-
try rulesets.updateRulesetWeightCache(projectId) {} catch {}
|
|
109
|
+
try rulesets.updateRulesetWeightCache(projectId, rulesets.latestRulesetIdOf(projectId)) {} catch {}
|
|
110
110
|
}
|
|
111
111
|
}
|
|
@@ -52,7 +52,7 @@ contract TestFundAccessLimitsEdge_Local is JBTest {
|
|
|
52
52
|
// Query all limits — should have 2 entries now (both with currency=1, amount=1000).
|
|
53
53
|
JBCurrencyAmount[] memory allLimits = limits.payoutLimitsOf(PROJECT_ID, RULESET_ID, TERMINAL, TOKEN);
|
|
54
54
|
|
|
55
|
-
//
|
|
55
|
+
// Two entries instead of one.
|
|
56
56
|
assertEq(allLimits.length, 2, "BUG: Limits accumulated instead of being replaced");
|
|
57
57
|
assertEq(allLimits[0].amount, 1000, "First accumulated limit");
|
|
58
58
|
assertEq(allLimits[1].amount, 1000, "Second accumulated limit");
|
|
@@ -62,7 +62,7 @@ contract M20M21Harness {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/// @notice Tests for
|
|
65
|
+
/// @notice Tests for _sliceBytes over-copy and addToMetadata offset overflow.
|
|
66
66
|
contract TestMetadataResolverM20M21 is JBTest {
|
|
67
67
|
M20M21Harness harness;
|
|
68
68
|
|
|
@@ -71,13 +71,13 @@ contract TestMetadataResolverM20M21 is JBTest {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
//*********************************************************************//
|
|
74
|
-
// ---
|
|
74
|
+
// --- _sliceBytes memory over-copy --------------------------------- //
|
|
75
75
|
//*********************************************************************//
|
|
76
76
|
|
|
77
77
|
/// @notice Verifies that addToMetadata preserves all entries when adding to metadata with
|
|
78
78
|
/// multiple existing entries. The _sliceBytes call at line 129 uses start > 0, which on
|
|
79
79
|
/// the buggy code would over-copy and corrupt subsequent memory operations.
|
|
80
|
-
function
|
|
80
|
+
function test_addToMetadataPreservesAllEntries() external view {
|
|
81
81
|
bytes4 id1 = bytes4(0x11111111);
|
|
82
82
|
bytes4 id2 = bytes4(0x22222222);
|
|
83
83
|
bytes4 id3 = bytes4(0x33333333);
|
|
@@ -116,7 +116,7 @@ contract TestMetadataResolverM20M21 is JBTest {
|
|
|
116
116
|
|
|
117
117
|
/// @notice Test with 4 sequential addToMetadata calls, each exercising _sliceBytes.
|
|
118
118
|
/// More entries = more start > 0 cases = more chances for memory corruption.
|
|
119
|
-
function
|
|
119
|
+
function test_multipleSequentialAdds() external view {
|
|
120
120
|
bytes4[5] memory ids =
|
|
121
121
|
[bytes4(0x11111111), bytes4(0x22222222), bytes4(0x33333333), bytes4(0x44444444), bytes4(0x55555555)];
|
|
122
122
|
uint256[5] memory vals = [uint256(100), uint256(200), uint256(300), uint256(400), uint256(500)];
|
|
@@ -144,7 +144,7 @@ contract TestMetadataResolverM20M21 is JBTest {
|
|
|
144
144
|
/// @notice Verify that getDataFor returns the exact expected length for non-first entries.
|
|
145
145
|
/// On buggy code, _sliceBytes copies more than needed, but the returned length should still
|
|
146
146
|
/// be correct. This test verifies both length and content.
|
|
147
|
-
function
|
|
147
|
+
function test_getDataForReturnsCorrectLength() external view {
|
|
148
148
|
bytes4 id1 = bytes4(0xAAAAAAAA);
|
|
149
149
|
bytes4 id2 = bytes4(0xBBBBBBBB);
|
|
150
150
|
|
|
@@ -172,14 +172,14 @@ contract TestMetadataResolverM20M21 is JBTest {
|
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
//*********************************************************************//
|
|
175
|
-
// ---
|
|
175
|
+
// --- addToMetadata offset overflow -------------------------------- //
|
|
176
176
|
//*********************************************************************//
|
|
177
177
|
|
|
178
178
|
/// @notice Verifies that addToMetadata reverts when the new offset would exceed 255.
|
|
179
179
|
/// Uses 6 entries (table = 1 word, 30 bytes) with data totaling 253 words → total 255 words.
|
|
180
180
|
/// Adding a 7th entry via addToMetadata forces the table from 1→2 words, incrementing all
|
|
181
181
|
/// offsets by 1. This pushes newOffset from 255 to 256, triggering the overflow check.
|
|
182
|
-
function
|
|
182
|
+
function test_addToMetadataRevertsOnOffsetOverflow() external {
|
|
183
183
|
// 6 entries: table = ceil(6*5/32) = 1 word. firstOffset = 2.
|
|
184
184
|
// 5 entries × 42 words + 1 entry × 43 words = 253 words of data.
|
|
185
185
|
// Total = 1 (reserved) + 1 (table) + 253 (data) = 255 words.
|
|
@@ -213,7 +213,7 @@ contract TestMetadataResolverM20M21 is JBTest {
|
|
|
213
213
|
/// @notice Verifies that addToMetadata works when the offset is exactly at the boundary (255).
|
|
214
214
|
/// Same 6-entry table expansion setup but with 1 less data word (total 254 words),
|
|
215
215
|
/// so the expanded offset is exactly 255 — the maximum valid value.
|
|
216
|
-
function
|
|
216
|
+
function test_addToMetadataSucceedsAtBoundary() external view {
|
|
217
217
|
// 6 entries × 42 words each = 252 words of data.
|
|
218
218
|
// Total = 1 (reserved) + 1 (table) + 252 (data) = 254 words.
|
|
219
219
|
bytes4[] memory ids = new bytes4[](6);
|
|
@@ -238,7 +238,7 @@ contract TestPrices_Local is JBPricesSetup {
|
|
|
238
238
|
address(directory), abi.encodeCall(IJBDirectory.controllerOf, (PROJECT_ID)), abi.encode(address(this))
|
|
239
239
|
);
|
|
240
240
|
|
|
241
|
-
//
|
|
241
|
+
// This reverts because default feed blocks project-specific feeds.
|
|
242
242
|
vm.expectRevert(abi.encodeWithSelector(JBPrices.JBPrices_PriceFeedAlreadyExists.selector, defaultFeed));
|
|
243
243
|
_prices.addPriceFeedFor(PROJECT_ID, _pricingCurrency, _unitCurrency, projectFeed);
|
|
244
244
|
}
|
|
@@ -550,10 +550,10 @@ contract TestJBRulesetsUnits_Local is JBTest {
|
|
|
550
550
|
// Update the weight cache incrementally — each call advances by at most 20,000 cycles.
|
|
551
551
|
// With 20,000 cycles and 10% weight cut per cycle, weight reaches 0 well before 20,000 cycles.
|
|
552
552
|
// First call: advances cache by up to 20,000 cycles (weight decays to 0).
|
|
553
|
-
_rulesets.updateRulesetWeightCache(_projectId);
|
|
553
|
+
_rulesets.updateRulesetWeightCache(_projectId, _rulesets.latestRulesetIdOf(_projectId));
|
|
554
554
|
|
|
555
555
|
// Second call during the same block should mirror the previous (cache already covers current time).
|
|
556
|
-
_rulesets.updateRulesetWeightCache(_projectId);
|
|
556
|
+
_rulesets.updateRulesetWeightCache(_projectId, _rulesets.latestRulesetIdOf(_projectId));
|
|
557
557
|
}
|
|
558
558
|
|
|
559
559
|
function test_QueueForApprovalHookDNSupportInterface() external {
|
|
@@ -48,7 +48,7 @@ contract TestUpdateRulesetWeightCache_Local is JBRulesetsSetup {
|
|
|
48
48
|
function test_WhenLatestRulesetOfProjectDurationOrWeightCutPercentEQZero() external {
|
|
49
49
|
// it will return without updating
|
|
50
50
|
|
|
51
|
-
_rulesets.updateRulesetWeightCache(_projectId);
|
|
51
|
+
_rulesets.updateRulesetWeightCache(_projectId, _rulesets.latestRulesetIdOf(_projectId));
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
function test_WhenLatestRulesetHasProperDurationAndWeightCutPercent() external {
|
|
@@ -86,6 +86,6 @@ contract TestUpdateRulesetWeightCache_Local is JBRulesetsSetup {
|
|
|
86
86
|
mustStartAtOrAfter: _mustStartAt
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
_rulesets.updateRulesetWeightCache(_projectId);
|
|
89
|
+
_rulesets.updateRulesetWeightCache(_projectId, _rulesets.latestRulesetIdOf(_projectId));
|
|
90
90
|
}
|
|
91
91
|
}
|