@rev-net/core-v6 0.0.35 → 0.0.36
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/RISKS.md +8 -0
- package/package.json +1 -1
- package/src/REVDeployer.sol +19 -10
- package/src/REVLoans.sol +18 -6
- package/src/REVOwner.sol +3 -1
- package/test/audit/CodexREVOwnerRemoteSurplusCurrencyMismatch.t.sol +142 -0
- package/test/audit/SupportsInterfaceTest.t.sol +51 -0
- package/test/audit/TestFeeAllowanceLeak.t.sol +197 -0
- package/test/audit/TestLoansAndDeployerFixes.t.sol +576 -0
package/RISKS.md
CHANGED
|
@@ -87,3 +87,11 @@ The model assumes that attempts to inflate surplus through donations are not pro
|
|
|
87
87
|
### 8.5 Omnichain terminal expansion inherits remote-chain trust
|
|
88
88
|
|
|
89
89
|
A project that expands to a new chain can register additional terminals on that chain. Because borrowability calculations aggregate surplus from all registered terminals across all chains, a compromised or misconfigured terminal on a remote chain can corrupt the project's surplus accounting globally. This is mitigated by including terminal addresses in the `encodedConfigurationHash` — cross-chain expansions via suckers must use the exact same terminal address as the host chain. Terminal addresses are deterministic across chains (same CREATE2 deployment), so this prevents expansions from silently using a different terminal. Project operators should still treat each chain expansion as a trust-boundary decision since bridge integrity and network assumptions remain outside protocol control.
|
|
90
|
+
|
|
91
|
+
### 8.6 Cross-chain surplus staleness
|
|
92
|
+
|
|
93
|
+
`REVLoans._borrowableAmountFrom` and `REVOwner.beforeCashOutRecordedWith` add `remoteSurplusOf()` and `remoteTotalSupplyOf()` to local values. These remote values update only when `toRemote()` is called on the peer chain -- no heartbeat or staleness check. Stale data can inflate per-token borrowable amounts when remote supply has grown since the last bridge message. Primary safeguard: borrowable is capped at `localSurplus` (REVLoans line 386-387), preventing extraction beyond what the local terminal holds.
|
|
94
|
+
|
|
95
|
+
### 8.7 REVLoans CEI violation in `_adjust`
|
|
96
|
+
|
|
97
|
+
In `REVLoans._adjust`, `totalCollateralOf[revnetId]` is incremented after external calls (`useAllowanceOf`, fee payment). A reentrant `borrowFrom` would see a lower `totalCollateralOf`. This is documented inline (lines 1128-1132) and requires an adversarial pay hook on the revnet's own terminal -- a trust-level configuration that is not realistic in standard deployments.
|
package/package.json
CHANGED
package/src/REVDeployer.sol
CHANGED
|
@@ -798,7 +798,7 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
|
|
|
798
798
|
if (shouldDeployNewRevnet) {
|
|
799
799
|
// If we're deploying a new revnet, launch a Juicebox project for it.
|
|
800
800
|
// Sanity check that we deployed the `revnetId` that we expected to deploy.
|
|
801
|
-
// slither-disable-next-line reentrancy-benign,reentrancy-events
|
|
801
|
+
// slither-disable-next-line incorrect-equality,reentrancy-benign,reentrancy-events
|
|
802
802
|
assert(
|
|
803
803
|
CONTROLLER.launchProjectFor({
|
|
804
804
|
owner: address(this),
|
|
@@ -970,6 +970,12 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
|
|
|
970
970
|
JBFundAccessLimitGroup[] memory fundAccessLimitGroups =
|
|
971
971
|
_makeLoanFundAccessLimits({terminalConfigurations: terminalConfigurations});
|
|
972
972
|
|
|
973
|
+
// Track the previous stage's effective start time for ordering validation.
|
|
974
|
+
// When stage 0 uses `startsAtOrAfter == 0`, the effective value is `block.timestamp`.
|
|
975
|
+
// Subsequent stages must be validated against this normalized value, not the raw calldata,
|
|
976
|
+
// so that cross-chain deployments can reproduce the same encoded configuration hash.
|
|
977
|
+
uint256 previousStageStart;
|
|
978
|
+
|
|
973
979
|
// Iterate through each stage to set up its ruleset.
|
|
974
980
|
for (uint256 i; i < configuration.stageConfigurations.length;) {
|
|
975
981
|
// Set the stage being iterated on.
|
|
@@ -981,12 +987,19 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
|
|
|
981
987
|
revert REVDeployer_MustHaveSplits();
|
|
982
988
|
}
|
|
983
989
|
|
|
990
|
+
// Compute the effective start time for this stage.
|
|
991
|
+
uint256 effectiveStart = (i == 0 && stageConfiguration.startsAtOrAfter == 0)
|
|
992
|
+
? block.timestamp
|
|
993
|
+
: stageConfiguration.startsAtOrAfter;
|
|
994
|
+
|
|
984
995
|
// If the stage's start time is not after the previous stage's start time, revert.
|
|
985
|
-
if (i > 0 &&
|
|
986
|
-
{
|
|
996
|
+
if (i > 0 && effectiveStart <= previousStageStart) {
|
|
987
997
|
revert REVDeployer_StageTimesMustIncrease();
|
|
988
998
|
}
|
|
989
999
|
|
|
1000
|
+
// Store for the next iteration's ordering check.
|
|
1001
|
+
previousStageStart = effectiveStart;
|
|
1002
|
+
|
|
990
1003
|
// Make sure the revnet doesn't prevent cashouts all together.
|
|
991
1004
|
if (stageConfiguration.cashOutTaxRate >= JBConstants.MAX_CASH_OUT_TAX_RATE) {
|
|
992
1005
|
revert REVDeployer_CashOutsCantBeTurnedOffCompletely(
|
|
@@ -1004,13 +1017,9 @@ contract REVDeployer is ERC2771Context, IREVDeployer, IERC721Receiver {
|
|
|
1004
1017
|
// Add the stage's properties to the byte-encoded configuration.
|
|
1005
1018
|
encodedConfiguration = abi.encode(
|
|
1006
1019
|
encodedConfiguration,
|
|
1007
|
-
//
|
|
1008
|
-
//
|
|
1009
|
-
|
|
1010
|
-
// same start time.
|
|
1011
|
-
(i == 0 && stageConfiguration.startsAtOrAfter == 0)
|
|
1012
|
-
? block.timestamp
|
|
1013
|
-
: stageConfiguration.startsAtOrAfter,
|
|
1020
|
+
// Use the effective start time (normalized from 0 to block.timestamp for the first stage).
|
|
1021
|
+
// Cross-chain deployments reproduce the hash by specifying the origin chain's timestamp.
|
|
1022
|
+
effectiveStart,
|
|
1014
1023
|
stageConfiguration.splitPercent,
|
|
1015
1024
|
stageConfiguration.initialIssuance,
|
|
1016
1025
|
stageConfiguration.issuanceCutFrequency,
|
package/src/REVLoans.sol
CHANGED
|
@@ -543,17 +543,18 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
|
|
|
543
543
|
// Get a reference to the token being iterated on.
|
|
544
544
|
REVLoanSource storage source = sources[i];
|
|
545
545
|
|
|
546
|
-
// Get a reference to the accounting context for the source.
|
|
547
|
-
// slither-disable-next-line calls-loop
|
|
548
|
-
JBAccountingContext memory accountingContext =
|
|
549
|
-
source.terminal.accountingContextForTokenOf({projectId: revnetId, token: source.token});
|
|
550
|
-
|
|
551
546
|
// Get a reference to the amount of tokens loaned out.
|
|
552
547
|
uint256 tokensLoaned = totalBorrowedFrom[revnetId][source.terminal][source.token];
|
|
553
548
|
|
|
554
|
-
// Skip if no tokens are loaned from this source.
|
|
549
|
+
// Skip if no tokens are loaned from this source. Checked before the external call below to avoid
|
|
550
|
+
// reverting on stale sources whose terminals may no longer support this token.
|
|
555
551
|
if (tokensLoaned == 0) continue;
|
|
556
552
|
|
|
553
|
+
// Get a reference to the accounting context for the source.
|
|
554
|
+
// slither-disable-next-line calls-loop
|
|
555
|
+
JBAccountingContext memory accountingContext =
|
|
556
|
+
source.terminal.accountingContextForTokenOf({projectId: revnetId, token: source.token});
|
|
557
|
+
|
|
557
558
|
// Normalize the token amount from the source's decimals to the target decimals.
|
|
558
559
|
uint256 normalizedTokens;
|
|
559
560
|
if (accountingContext.decimals > decimals) {
|
|
@@ -1225,6 +1226,14 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
|
|
|
1225
1226
|
return 0;
|
|
1226
1227
|
}
|
|
1227
1228
|
|
|
1229
|
+
/// @notice Clears any token allowance granted by `_beforeTransferTo`.
|
|
1230
|
+
/// @param to The address that was granted the allowance.
|
|
1231
|
+
/// @param token The token whose allowance should be cleared.
|
|
1232
|
+
function _afterTransferTo(address to, address token) internal {
|
|
1233
|
+
if (token == JBConstants.NATIVE_TOKEN) return;
|
|
1234
|
+
IERC20(token).forceApprove({spender: to, value: 0});
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1228
1237
|
/// @notice Reallocates collateral from a loan by making a new loan based on the original, with reduced collateral.
|
|
1229
1238
|
/// @param loanId The ID of the loan to reallocate collateral from.
|
|
1230
1239
|
/// @param revnetId The ID of the revnet the loan is from.
|
|
@@ -1334,6 +1343,8 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
|
|
|
1334
1343
|
memo: "",
|
|
1335
1344
|
metadata: bytes(abi.encodePacked(REV_ID))
|
|
1336
1345
|
});
|
|
1346
|
+
|
|
1347
|
+
_afterTransferTo({to: address(loan.source.terminal), token: loan.source.token});
|
|
1337
1348
|
}
|
|
1338
1349
|
|
|
1339
1350
|
/// @notice Pays down a loan.
|
|
@@ -1532,6 +1543,7 @@ contract REVLoans is ERC721, ERC2771Context, JBPermissioned, Ownable, IREVLoans
|
|
|
1532
1543
|
metadata: bytes(abi.encodePacked(metadataProjectId))
|
|
1533
1544
|
}) {
|
|
1534
1545
|
success = true;
|
|
1546
|
+
_afterTransferTo({to: address(terminal), token: token});
|
|
1535
1547
|
} catch (bytes memory) {
|
|
1536
1548
|
if (token != JBConstants.NATIVE_TOKEN) {
|
|
1537
1549
|
IERC20(token).safeDecreaseAllowance({spender: address(terminal), requestedDecrease: amount});
|
package/src/REVOwner.sol
CHANGED
|
@@ -16,6 +16,7 @@ import {JBCashOutHookSpecification} from "@bananapus/core-v6/src/structs/JBCashO
|
|
|
16
16
|
import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
|
|
17
17
|
import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
|
|
18
18
|
import {IJBSuckerRegistry} from "@bananapus/suckers-v6/src/interfaces/IJBSuckerRegistry.sol";
|
|
19
|
+
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
19
20
|
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
20
21
|
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
|
|
21
22
|
import {mulDiv} from "@prb/math/src/Common.sol";
|
|
@@ -458,7 +459,8 @@ contract REVOwner is IJBRulesetDataHook, IJBCashOutHook {
|
|
|
458
459
|
/// @dev See `IERC165.supportsInterface`.
|
|
459
460
|
/// @return A flag indicating if the provided interface ID is supported.
|
|
460
461
|
function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
|
|
461
|
-
return interfaceId == type(
|
|
462
|
+
return interfaceId == type(IERC165).interfaceId || interfaceId == type(IJBRulesetDataHook).interfaceId
|
|
463
|
+
|| interfaceId == type(IJBCashOutHook).interfaceId;
|
|
462
464
|
}
|
|
463
465
|
|
|
464
466
|
//*********************************************************************//
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import "forge-std/Test.sol";
|
|
5
|
+
import "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
|
|
6
|
+
import {IJBBuybackHookRegistry} from "@bananapus/buyback-hook-v6/src/interfaces/IJBBuybackHookRegistry.sol";
|
|
7
|
+
import {IJBCashOutHook} from "@bananapus/core-v6/src/interfaces/IJBCashOutHook.sol";
|
|
8
|
+
import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
|
|
9
|
+
import {JBBeforeCashOutRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforeCashOutRecordedContext.sol";
|
|
10
|
+
import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforePayRecordedContext.sol";
|
|
11
|
+
import {JBCashOutHookSpecification} from "@bananapus/core-v6/src/structs/JBCashOutHookSpecification.sol";
|
|
12
|
+
import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
|
|
13
|
+
import {JBTokenAmount} from "@bananapus/core-v6/src/structs/JBTokenAmount.sol";
|
|
14
|
+
import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
|
|
15
|
+
import {IJBSuckerRegistry} from "@bananapus/suckers-v6/src/interfaces/IJBSuckerRegistry.sol";
|
|
16
|
+
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
17
|
+
import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
|
|
18
|
+
|
|
19
|
+
import {REVOwner} from "../../src/REVOwner.sol";
|
|
20
|
+
|
|
21
|
+
contract CurrencyAwareSuckerRegistry {
|
|
22
|
+
uint256 public expectedCurrency;
|
|
23
|
+
uint256 public remoteSupply;
|
|
24
|
+
uint256 public remoteSurplus;
|
|
25
|
+
|
|
26
|
+
function setRemoteValues(uint256 currency, uint256 supply, uint256 surplus) external {
|
|
27
|
+
expectedCurrency = currency;
|
|
28
|
+
remoteSupply = supply;
|
|
29
|
+
remoteSurplus = surplus;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isSuckerOf(uint256, address) external pure returns (bool) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function remoteTotalSupplyOf(uint256) external view returns (uint256) {
|
|
37
|
+
return remoteSupply;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function remoteSurplusOf(uint256, uint256, uint256 currency) external view returns (uint256) {
|
|
41
|
+
return currency == expectedCurrency ? remoteSurplus : 0;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
contract EchoBuybackRegistry is IJBRulesetDataHook {
|
|
46
|
+
function beforeCashOutRecordedWith(JBBeforeCashOutRecordedContext calldata context)
|
|
47
|
+
external
|
|
48
|
+
pure
|
|
49
|
+
returns (
|
|
50
|
+
uint256 cashOutTaxRate,
|
|
51
|
+
uint256 cashOutCount,
|
|
52
|
+
uint256 totalSupply,
|
|
53
|
+
uint256 effectiveSurplusValue,
|
|
54
|
+
JBCashOutHookSpecification[] memory hookSpecifications
|
|
55
|
+
)
|
|
56
|
+
{
|
|
57
|
+
cashOutTaxRate = context.cashOutTaxRate;
|
|
58
|
+
cashOutCount = context.cashOutCount;
|
|
59
|
+
totalSupply = context.totalSupply;
|
|
60
|
+
effectiveSurplusValue = context.surplus.value;
|
|
61
|
+
hookSpecifications = new JBCashOutHookSpecification[](0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function beforePayRecordedWith(JBBeforePayRecordedContext calldata context)
|
|
65
|
+
external
|
|
66
|
+
pure
|
|
67
|
+
returns (uint256 weight, JBPayHookSpecification[] memory hookSpecifications)
|
|
68
|
+
{
|
|
69
|
+
weight = context.weight;
|
|
70
|
+
hookSpecifications = new JBPayHookSpecification[](0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hasMintPermissionFor(uint256, JBRuleset calldata, address) external pure returns (bool) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function setPoolFor(uint256, PoolKey calldata, uint256, address) external pure {}
|
|
78
|
+
function setPoolFor(uint256, uint24, int24, uint256, address) external pure {}
|
|
79
|
+
function initializePoolFor(uint256, uint24, int24, uint256, address, uint160) external pure {}
|
|
80
|
+
|
|
81
|
+
function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
|
|
82
|
+
return interfaceId == type(IJBRulesetDataHook).interfaceId || interfaceId == type(IERC165).interfaceId;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
contract CodexREVOwnerRemoteSurplusCurrencyMismatchTest is TestBaseWorkflow {
|
|
87
|
+
REVOwner internal ownerHook;
|
|
88
|
+
CurrencyAwareSuckerRegistry internal suckerRegistry;
|
|
89
|
+
EchoBuybackRegistry internal buybackRegistry;
|
|
90
|
+
|
|
91
|
+
uint32 internal constant ETH_CURRENCY = 1;
|
|
92
|
+
|
|
93
|
+
function setUp() public override {
|
|
94
|
+
super.setUp();
|
|
95
|
+
|
|
96
|
+
suckerRegistry = new CurrencyAwareSuckerRegistry();
|
|
97
|
+
buybackRegistry = new EchoBuybackRegistry();
|
|
98
|
+
|
|
99
|
+
ownerHook = new REVOwner(
|
|
100
|
+
IJBBuybackHookRegistry(address(buybackRegistry)),
|
|
101
|
+
jbDirectory(),
|
|
102
|
+
999_999,
|
|
103
|
+
IJBSuckerRegistry(address(suckerRegistry)),
|
|
104
|
+
address(0),
|
|
105
|
+
address(0)
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function test_beforeCashOutRecordedWith_usesTokenAddressInsteadOfCurrencyForRemoteSurplus() public {
|
|
110
|
+
suckerRegistry.setRemoteValues(ETH_CURRENCY, 500 ether, 900 ether);
|
|
111
|
+
|
|
112
|
+
address usdToken = address(0xBEEF);
|
|
113
|
+
|
|
114
|
+
JBBeforeCashOutRecordedContext memory context = JBBeforeCashOutRecordedContext({
|
|
115
|
+
terminal: address(jbMultiTerminal()),
|
|
116
|
+
holder: address(0xCAFE),
|
|
117
|
+
projectId: 1,
|
|
118
|
+
rulesetId: 0,
|
|
119
|
+
cashOutCount: 100 ether,
|
|
120
|
+
totalSupply: 1000 ether,
|
|
121
|
+
surplus: JBTokenAmount({token: usdToken, value: 100 ether, decimals: 18, currency: ETH_CURRENCY}),
|
|
122
|
+
useTotalSurplus: true,
|
|
123
|
+
cashOutTaxRate: 0,
|
|
124
|
+
beneficiaryIsFeeless: false,
|
|
125
|
+
metadata: ""
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
(,, uint256 returnedSupply, uint256 returnedSurplus,) = ownerHook.beforeCashOutRecordedWith(context);
|
|
129
|
+
|
|
130
|
+
assertEq(returnedSupply, 1500 ether, "remote supply should still be included");
|
|
131
|
+
assertEq(
|
|
132
|
+
returnedSurplus,
|
|
133
|
+
100 ether,
|
|
134
|
+
"remote surplus is incorrectly dropped because REVOwner keys by token address instead of currency"
|
|
135
|
+
);
|
|
136
|
+
assertEq(
|
|
137
|
+
suckerRegistry.remoteSurplusOf(1, 18, ETH_CURRENCY),
|
|
138
|
+
900 ether,
|
|
139
|
+
"registry confirms surplus exists for the requested currency"
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {Test} from "forge-std/Test.sol";
|
|
5
|
+
import {IJBBuybackHookRegistry} from "@bananapus/buyback-hook-v6/src/interfaces/IJBBuybackHookRegistry.sol";
|
|
6
|
+
import {IJBCashOutHook} from "@bananapus/core-v6/src/interfaces/IJBCashOutHook.sol";
|
|
7
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
8
|
+
import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
|
|
9
|
+
import {IJBSuckerRegistry} from "@bananapus/suckers-v6/src/interfaces/IJBSuckerRegistry.sol";
|
|
10
|
+
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
11
|
+
|
|
12
|
+
import {REVOwner} from "../../src/REVOwner.sol";
|
|
13
|
+
|
|
14
|
+
/// @notice Regression test for missing IERC165 support: REVOwner.supportsInterface omits IERC165.
|
|
15
|
+
contract AuditFixL17Test is Test {
|
|
16
|
+
REVOwner revOwner;
|
|
17
|
+
|
|
18
|
+
function setUp() public {
|
|
19
|
+
revOwner = new REVOwner(
|
|
20
|
+
IJBBuybackHookRegistry(makeAddr("buybackHook")),
|
|
21
|
+
IJBDirectory(makeAddr("directory")),
|
|
22
|
+
1, // feeRevnetId
|
|
23
|
+
IJBSuckerRegistry(makeAddr("suckerRegistry")),
|
|
24
|
+
makeAddr("loans"),
|
|
25
|
+
makeAddr("hiddenTokens")
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// @notice supportsInterface returns true for IERC165 (0x01ffc9a7).
|
|
30
|
+
function test_supportsInterface_IERC165() public view {
|
|
31
|
+
assertTrue(revOwner.supportsInterface(type(IERC165).interfaceId), "should support IERC165");
|
|
32
|
+
assertEq(type(IERC165).interfaceId, bytes4(0x01ffc9a7), "IERC165 interface ID should be 0x01ffc9a7");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// @notice supportsInterface returns true for IJBRulesetDataHook.
|
|
36
|
+
function test_supportsInterface_IJBRulesetDataHook() public view {
|
|
37
|
+
assertTrue(
|
|
38
|
+
revOwner.supportsInterface(type(IJBRulesetDataHook).interfaceId), "should support IJBRulesetDataHook"
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// @notice supportsInterface returns true for IJBCashOutHook.
|
|
43
|
+
function test_supportsInterface_IJBCashOutHook() public view {
|
|
44
|
+
assertTrue(revOwner.supportsInterface(type(IJBCashOutHook).interfaceId), "should support IJBCashOutHook");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// @notice supportsInterface returns false for an unsupported interface.
|
|
48
|
+
function test_supportsInterface_unsupported() public view {
|
|
49
|
+
assertFalse(revOwner.supportsInterface(bytes4(0xdeadbeef)), "should not support random interface");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
|
|
5
|
+
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
6
|
+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
7
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
8
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
9
|
+
import {IJBPayoutTerminal} from "@bananapus/core-v6/src/interfaces/IJBPayoutTerminal.sol";
|
|
10
|
+
import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
|
|
11
|
+
import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
12
|
+
import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
|
|
13
|
+
import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
|
|
14
|
+
import {REVLoanSource} from "../../src/structs/REVLoanSource.sol";
|
|
15
|
+
import {REVLoansFeeRecovery} from "../REVLoansFeeRecovery.t.sol";
|
|
16
|
+
|
|
17
|
+
contract StickyAllowanceFeeTerminal is ERC165, IJBPayoutTerminal {
|
|
18
|
+
IERC20 public immutable token;
|
|
19
|
+
address public immutable loans;
|
|
20
|
+
address public thief;
|
|
21
|
+
uint256 public stealAmount;
|
|
22
|
+
|
|
23
|
+
constructor(IERC20 _token, address _loans) {
|
|
24
|
+
token = _token;
|
|
25
|
+
loans = _loans;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function configureSteal(address _thief, uint256 _stealAmount) external {
|
|
29
|
+
thief = _thief;
|
|
30
|
+
stealAmount = _stealAmount;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function pay(
|
|
34
|
+
uint256,
|
|
35
|
+
address,
|
|
36
|
+
uint256,
|
|
37
|
+
address,
|
|
38
|
+
uint256,
|
|
39
|
+
string calldata,
|
|
40
|
+
bytes calldata
|
|
41
|
+
)
|
|
42
|
+
external
|
|
43
|
+
payable
|
|
44
|
+
override
|
|
45
|
+
returns (uint256)
|
|
46
|
+
{
|
|
47
|
+
uint256 amount = stealAmount;
|
|
48
|
+
if (amount != 0) {
|
|
49
|
+
stealAmount = 0;
|
|
50
|
+
token.transferFrom(loans, thief, amount);
|
|
51
|
+
}
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function accountingContextForTokenOf(uint256, address) external view override returns (JBAccountingContext memory) {
|
|
56
|
+
return JBAccountingContext({token: address(token), decimals: 6, currency: uint32(uint160(address(token)))});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function accountingContextsOf(uint256) external pure override returns (JBAccountingContext[] memory) {
|
|
60
|
+
return new JBAccountingContext[](0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function addAccountingContextsFor(uint256, JBAccountingContext[] calldata) external override {}
|
|
64
|
+
|
|
65
|
+
function addToBalanceOf(
|
|
66
|
+
uint256,
|
|
67
|
+
address,
|
|
68
|
+
uint256,
|
|
69
|
+
bool,
|
|
70
|
+
string calldata,
|
|
71
|
+
bytes calldata
|
|
72
|
+
)
|
|
73
|
+
external
|
|
74
|
+
payable
|
|
75
|
+
override
|
|
76
|
+
{}
|
|
77
|
+
|
|
78
|
+
function currentSurplusOf(uint256, address[] calldata, uint256, uint256) external pure override returns (uint256) {
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function migrateBalanceOf(uint256, address, IJBTerminal) external pure override returns (uint256) {
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sendPayoutsOf(uint256, address, uint256, uint256, uint256) external pure override returns (uint256) {
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function useAllowanceOf(
|
|
91
|
+
uint256,
|
|
92
|
+
address,
|
|
93
|
+
uint256,
|
|
94
|
+
uint256,
|
|
95
|
+
uint256,
|
|
96
|
+
address payable,
|
|
97
|
+
address payable,
|
|
98
|
+
string calldata
|
|
99
|
+
)
|
|
100
|
+
external
|
|
101
|
+
pure
|
|
102
|
+
override
|
|
103
|
+
returns (uint256)
|
|
104
|
+
{
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function previewPayFor(
|
|
109
|
+
uint256,
|
|
110
|
+
address,
|
|
111
|
+
uint256,
|
|
112
|
+
address,
|
|
113
|
+
bytes calldata
|
|
114
|
+
)
|
|
115
|
+
external
|
|
116
|
+
pure
|
|
117
|
+
override
|
|
118
|
+
returns (JBRuleset memory, uint256, uint256, JBPayHookSpecification[] memory)
|
|
119
|
+
{
|
|
120
|
+
JBRuleset memory ruleset;
|
|
121
|
+
return (ruleset, 0, 0, new JBPayHookSpecification[](0));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function supportsInterface(bytes4 interfaceId) public view override(ERC165, IERC165) returns (bool) {
|
|
125
|
+
return interfaceId == type(IJBTerminal).interfaceId || interfaceId == type(IJBPayoutTerminal).interfaceId
|
|
126
|
+
|| super.supportsInterface(interfaceId);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
contract TestFeeAllowanceLeak is REVLoansFeeRecovery {
|
|
131
|
+
StickyAllowanceFeeTerminal internal stickyFeeTerminal;
|
|
132
|
+
address internal attacker = makeAddr("attacker");
|
|
133
|
+
|
|
134
|
+
function _stickyFeeTerminal() internal returns (StickyAllowanceFeeTerminal) {
|
|
135
|
+
if (address(stickyFeeTerminal) == address(0)) {
|
|
136
|
+
stickyFeeTerminal = new StickyAllowanceFeeTerminal(TOKEN, address(LOANS_CONTRACT));
|
|
137
|
+
}
|
|
138
|
+
return stickyFeeTerminal;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// @notice Verifies that stale allowance is cleared — the original exploit no longer works.
|
|
142
|
+
/// @dev Previously, a sticky fee terminal could accumulate reusable allowance across borrows.
|
|
143
|
+
/// After the fix (_afterTransferTo clears allowance on success), the allowance is zero.
|
|
144
|
+
function test_feeTerminalCannotHarvestStaleAllowanceAfterFix() public {
|
|
145
|
+
StickyAllowanceFeeTerminal terminal = _stickyFeeTerminal();
|
|
146
|
+
|
|
147
|
+
vm.mockCall(
|
|
148
|
+
address(jbDirectory()),
|
|
149
|
+
abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector, FEE_PROJECT_ID, address(TOKEN)),
|
|
150
|
+
abi.encode(address(terminal))
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
REVLoanSource memory source = REVLoanSource({token: address(TOKEN), terminal: jbMultiTerminal()});
|
|
154
|
+
uint256 payAmount = 1_000_000;
|
|
155
|
+
|
|
156
|
+
deal(address(TOKEN), USER, payAmount * 2);
|
|
157
|
+
|
|
158
|
+
vm.startPrank(USER);
|
|
159
|
+
TOKEN.approve(address(jbMultiTerminal()), payAmount * 2);
|
|
160
|
+
uint256 firstTokenCount = jbMultiTerminal().pay(REVNET_ID, address(TOKEN), payAmount, USER, 0, "", "");
|
|
161
|
+
vm.stopPrank();
|
|
162
|
+
|
|
163
|
+
_mockLoanPermission(USER);
|
|
164
|
+
vm.prank(USER);
|
|
165
|
+
LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, firstTokenCount, payable(USER), 25, USER);
|
|
166
|
+
|
|
167
|
+
// Allowance is now cleared after successful fee payment.
|
|
168
|
+
uint256 allowanceAfterBorrow = TOKEN.allowance(address(LOANS_CONTRACT), address(stickyFeeTerminal));
|
|
169
|
+
assertEq(allowanceAfterBorrow, 0, "no stale allowance after successful borrow");
|
|
170
|
+
|
|
171
|
+
// The uncollected fee is still parked in REVLoans (terminal didn't pull it),
|
|
172
|
+
// but there's no allowance for the terminal to steal it later.
|
|
173
|
+
uint256 loansBalance = TOKEN.balanceOf(address(LOANS_CONTRACT));
|
|
174
|
+
assertGt(loansBalance, 0, "uncollected fee is parked in REVLoans");
|
|
175
|
+
|
|
176
|
+
// Second borrow — terminal tries to steal but can't because allowance is 0.
|
|
177
|
+
vm.prank(USER);
|
|
178
|
+
uint256 secondTokenCount = jbMultiTerminal().pay(REVNET_ID, address(TOKEN), payAmount, USER, 0, "", "");
|
|
179
|
+
|
|
180
|
+
terminal.configureSteal(attacker, loansBalance);
|
|
181
|
+
|
|
182
|
+
_mockLoanPermission(USER);
|
|
183
|
+
vm.prank(USER);
|
|
184
|
+
LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, secondTokenCount, payable(USER), 25, USER);
|
|
185
|
+
|
|
186
|
+
// The attacker gets nothing — the steal attempt fails silently (transferFrom reverts,
|
|
187
|
+
// caught by _tryPayFee's try-catch).
|
|
188
|
+
assertEq(TOKEN.balanceOf(attacker), 0, "attacker cannot drain stale allowance");
|
|
189
|
+
|
|
190
|
+
// And the current borrow also leaves zero allowance.
|
|
191
|
+
assertEq(
|
|
192
|
+
TOKEN.allowance(address(LOANS_CONTRACT), address(terminal)),
|
|
193
|
+
0,
|
|
194
|
+
"no fresh stale allowance after second borrow"
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
/// @title TestLoansAndDeployerFixes
|
|
5
|
+
/// @notice Regression tests for approval cleanup, stale source skip, and stage ordering.
|
|
6
|
+
|
|
7
|
+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
8
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
9
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
10
|
+
import {IJBPayoutTerminal} from "@bananapus/core-v6/src/interfaces/IJBPayoutTerminal.sol";
|
|
11
|
+
import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
|
|
12
|
+
import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
|
|
13
|
+
import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
14
|
+
import {JBSingleAllowance} from "@bananapus/core-v6/src/structs/JBSingleAllowance.sol";
|
|
15
|
+
import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
|
|
16
|
+
import {JBTerminalConfig} from "@bananapus/core-v6/src/structs/JBTerminalConfig.sol";
|
|
17
|
+
import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
|
|
18
|
+
import {REVLoanSource} from "../../src/structs/REVLoanSource.sol";
|
|
19
|
+
import {REVLoan} from "../../src/structs/REVLoan.sol";
|
|
20
|
+
import {REVStageConfig, REVAutoIssuance} from "../../src/structs/REVStageConfig.sol";
|
|
21
|
+
import {REVConfig} from "../../src/structs/REVConfig.sol";
|
|
22
|
+
import {REVDescription} from "../../src/structs/REVDescription.sol";
|
|
23
|
+
import {REVSuckerDeploymentConfig} from "../../src/structs/REVSuckerDeploymentConfig.sol";
|
|
24
|
+
import {REVDeployer} from "../../src/REVDeployer.sol";
|
|
25
|
+
import {REVLoans} from "../../src/REVLoans.sol";
|
|
26
|
+
import {REVEmpty721Config} from "../helpers/REVEmpty721Config.sol";
|
|
27
|
+
import {REVLoansFeeRecovery, FeeRecoveryProjectConfig} from "../REVLoansFeeRecovery.t.sol";
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Main test contract — extends REVLoansFeeRecovery to reuse full setup.
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
contract TestLoansAndDeployerFixes is REVLoansFeeRecovery {
|
|
34
|
+
// ========================================================================
|
|
35
|
+
// Helpers
|
|
36
|
+
// ========================================================================
|
|
37
|
+
|
|
38
|
+
/// @notice Mock the burn-tokens permission without requiring it to be called.
|
|
39
|
+
/// Use this instead of _mockLoanPermission when the borrow may revert before
|
|
40
|
+
/// the burn is reached, or in other cases where vm.expectCall would cause a
|
|
41
|
+
/// spurious failure.
|
|
42
|
+
function _mockLoanPermissionNoExpect(address user) internal {
|
|
43
|
+
vm.mockCall(
|
|
44
|
+
address(jbPermissions()),
|
|
45
|
+
abi.encodeCall(IJBPermissions.hasPermission, (address(LOANS_CONTRACT), user, REVNET_ID, 11, true, true)),
|
|
46
|
+
abi.encode(true)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// @notice Mock the repay-loan permission without requiring it to be called.
|
|
51
|
+
/// repayLoan calls _requirePermissionFrom(loanOwner, ..., REPAY_LOAN), but when
|
|
52
|
+
/// sender == loanOwner the hasPermission call is skipped entirely. We still mock
|
|
53
|
+
/// the call in case the path changes, but do not set expectCall.
|
|
54
|
+
function _mockRepayPermissionNoExpect(address user) internal {
|
|
55
|
+
vm.mockCall(
|
|
56
|
+
address(jbPermissions()),
|
|
57
|
+
abi.encodeCall(IJBPermissions.hasPermission, (user, user, REVNET_ID, 12, true, true)),
|
|
58
|
+
abi.encode(true)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ========================================================================
|
|
63
|
+
// Stale ERC20 Approval Cleanup
|
|
64
|
+
// ========================================================================
|
|
65
|
+
// After _tryPayFee (in _addTo) or _removeFrom, the ERC20 allowance from
|
|
66
|
+
// the LOANS_CONTRACT to the terminal must be zero. The _afterTransferTo
|
|
67
|
+
// call now force-approves to 0 after successful transfers, and the catch
|
|
68
|
+
// block in _tryPayFee uses safeDecreaseAllowance on failure.
|
|
69
|
+
// ========================================================================
|
|
70
|
+
|
|
71
|
+
/// @notice After an ERC20 borrow, the allowance from LOANS_CONTRACT to the terminal is 0.
|
|
72
|
+
function test_erc20BorrowLeavesZeroAllowanceToTerminal() public {
|
|
73
|
+
uint256 payAmount = 1_000_000; // 6 decimals for TOKEN
|
|
74
|
+
deal(address(TOKEN), USER, payAmount);
|
|
75
|
+
|
|
76
|
+
// Pay into revnet with ERC-20.
|
|
77
|
+
vm.startPrank(USER);
|
|
78
|
+
TOKEN.approve(address(jbMultiTerminal()), payAmount);
|
|
79
|
+
uint256 tokenCount = jbMultiTerminal().pay(REVNET_ID, address(TOKEN), payAmount, USER, 0, "", "");
|
|
80
|
+
vm.stopPrank();
|
|
81
|
+
|
|
82
|
+
_mockLoanPermission(USER);
|
|
83
|
+
REVLoanSource memory source = REVLoanSource({token: address(TOKEN), terminal: jbMultiTerminal()});
|
|
84
|
+
|
|
85
|
+
vm.prank(USER);
|
|
86
|
+
LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokenCount, payable(USER), 25, USER);
|
|
87
|
+
|
|
88
|
+
// Allowance to the terminal must be zero after successful borrow.
|
|
89
|
+
assertEq(
|
|
90
|
+
IERC20(address(TOKEN)).allowance(address(LOANS_CONTRACT), address(jbMultiTerminal())),
|
|
91
|
+
0,
|
|
92
|
+
"Stale allowance to terminal after ERC20 borrow"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// @notice After a native ETH borrow, verify no stale state (native token has no allowance concept, but
|
|
97
|
+
/// the loans contract balance must be zero).
|
|
98
|
+
function test_nativeBorrowLeavesNoFundsStuck() public {
|
|
99
|
+
(, uint256 balanceBefore, uint256 balanceAfter) = _borrowNative(USER, 10e18, 25);
|
|
100
|
+
|
|
101
|
+
uint256 received = balanceAfter - balanceBefore;
|
|
102
|
+
assertGt(received, 0, "Borrower should receive ETH");
|
|
103
|
+
|
|
104
|
+
// No ETH stuck in the loans contract.
|
|
105
|
+
assertEq(address(LOANS_CONTRACT).balance, 0, "No ETH stuck in loans contract after native borrow");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// @notice After an ERC20 borrow where the fee terminal reverts, the allowance from
|
|
109
|
+
/// LOANS_CONTRACT to the fee terminal is also 0 (catch block cleans it up).
|
|
110
|
+
function test_erc20BorrowWithRevertingFeeTerminalCleansAllowance() public {
|
|
111
|
+
// Mock the fee terminal to revert for TOKEN.
|
|
112
|
+
_mockRevertingFeeTerminal(address(TOKEN));
|
|
113
|
+
|
|
114
|
+
uint256 payAmount = 1_000_000;
|
|
115
|
+
deal(address(TOKEN), USER, payAmount);
|
|
116
|
+
|
|
117
|
+
vm.startPrank(USER);
|
|
118
|
+
TOKEN.approve(address(jbMultiTerminal()), payAmount);
|
|
119
|
+
uint256 tokenCount = jbMultiTerminal().pay(REVNET_ID, address(TOKEN), payAmount, USER, 0, "", "");
|
|
120
|
+
vm.stopPrank();
|
|
121
|
+
|
|
122
|
+
_mockLoanPermission(USER);
|
|
123
|
+
REVLoanSource memory source = REVLoanSource({token: address(TOKEN), terminal: jbMultiTerminal()});
|
|
124
|
+
|
|
125
|
+
vm.prank(USER);
|
|
126
|
+
LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokenCount, payable(USER), 25, USER);
|
|
127
|
+
|
|
128
|
+
// Allowance to the REVERTING terminal must be 0 (catch block cleaned it up).
|
|
129
|
+
assertEq(
|
|
130
|
+
IERC20(address(TOKEN)).allowance(address(LOANS_CONTRACT), address(REVERTING_TERMINAL)),
|
|
131
|
+
0,
|
|
132
|
+
"Stale allowance to reverting fee terminal after borrow"
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// Allowance to the regular terminal must also be 0.
|
|
136
|
+
assertEq(
|
|
137
|
+
IERC20(address(TOKEN)).allowance(address(LOANS_CONTRACT), address(jbMultiTerminal())),
|
|
138
|
+
0,
|
|
139
|
+
"Stale allowance to terminal after borrow with reverting fee terminal"
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// No tokens stuck.
|
|
143
|
+
assertEq(IERC20(address(TOKEN)).balanceOf(address(LOANS_CONTRACT)), 0, "No ERC20 stuck in loans contract");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/// @notice After a full ERC20 loan repayment (_removeFrom path), the allowance to the terminal is 0.
|
|
147
|
+
function test_erc20RepaymentLeavesZeroAllowance() public {
|
|
148
|
+
uint256 payAmount = 1_000_000;
|
|
149
|
+
deal(address(TOKEN), USER, payAmount * 2); // Extra for repayment
|
|
150
|
+
|
|
151
|
+
// Pay into revnet.
|
|
152
|
+
vm.startPrank(USER);
|
|
153
|
+
TOKEN.approve(address(jbMultiTerminal()), payAmount);
|
|
154
|
+
uint256 tokenCount = jbMultiTerminal().pay(REVNET_ID, address(TOKEN), payAmount, USER, 0, "", "");
|
|
155
|
+
vm.stopPrank();
|
|
156
|
+
|
|
157
|
+
_mockLoanPermission(USER);
|
|
158
|
+
REVLoanSource memory source = REVLoanSource({token: address(TOKEN), terminal: jbMultiTerminal()});
|
|
159
|
+
|
|
160
|
+
// Borrow.
|
|
161
|
+
vm.prank(USER);
|
|
162
|
+
(uint256 loanId,) = LOANS_CONTRACT.borrowFrom(REVNET_ID, source, 0, tokenCount, payable(USER), 25, USER);
|
|
163
|
+
|
|
164
|
+
// Read loan details to get repay amount.
|
|
165
|
+
REVLoan memory loan = LOANS_CONTRACT.loanOf(loanId);
|
|
166
|
+
|
|
167
|
+
// Mock repay permission (no expectCall — sender == loanOwner so hasPermission is skipped).
|
|
168
|
+
_mockRepayPermissionNoExpect(USER);
|
|
169
|
+
|
|
170
|
+
// Approve the loans contract to pull tokens for repayment via permit2 or direct transfer.
|
|
171
|
+
uint256 maxRepay = loan.amount * 2; // Generous max to cover fees.
|
|
172
|
+
deal(address(TOKEN), USER, maxRepay);
|
|
173
|
+
vm.startPrank(USER);
|
|
174
|
+
TOKEN.approve(address(LOANS_CONTRACT), maxRepay);
|
|
175
|
+
LOANS_CONTRACT.repayLoan({
|
|
176
|
+
loanId: loanId,
|
|
177
|
+
maxRepayBorrowAmount: maxRepay,
|
|
178
|
+
collateralCountToReturn: loan.collateral,
|
|
179
|
+
beneficiary: payable(USER),
|
|
180
|
+
allowance: JBSingleAllowance({sigDeadline: 0, amount: 0, expiration: 0, nonce: 0, signature: ""})
|
|
181
|
+
});
|
|
182
|
+
vm.stopPrank();
|
|
183
|
+
|
|
184
|
+
// After repayment (_removeFrom), allowance to terminal must be 0.
|
|
185
|
+
assertEq(
|
|
186
|
+
IERC20(address(TOKEN)).allowance(address(LOANS_CONTRACT), address(jbMultiTerminal())),
|
|
187
|
+
0,
|
|
188
|
+
"Stale allowance to terminal after ERC20 repayment"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ========================================================================
|
|
193
|
+
// Stale Loan Source DoS Prevention
|
|
194
|
+
// ========================================================================
|
|
195
|
+
// _totalBorrowedFrom must skip sources with zero balance (totalBorrowedFrom == 0)
|
|
196
|
+
// BEFORE calling accountingContextForTokenOf on the terminal. This prevents DoS
|
|
197
|
+
// when a stale terminal starts reverting.
|
|
198
|
+
// ========================================================================
|
|
199
|
+
|
|
200
|
+
/// @notice After fully repaying a loan, if the source terminal starts reverting on
|
|
201
|
+
/// accountingContextForTokenOf, subsequent borrows from other sources still work.
|
|
202
|
+
function test_staleLoanSourceDoesNotBlockNewBorrows() public {
|
|
203
|
+
// Step 1: Borrow from native ETH source.
|
|
204
|
+
vm.prank(USER);
|
|
205
|
+
uint256 nativeTokens =
|
|
206
|
+
jbMultiTerminal().pay{value: 10e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 10e18, USER, 0, "", "");
|
|
207
|
+
|
|
208
|
+
_mockLoanPermission(USER);
|
|
209
|
+
REVLoanSource memory nativeSource =
|
|
210
|
+
REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
|
|
211
|
+
|
|
212
|
+
vm.prank(USER);
|
|
213
|
+
(uint256 loanId,) = LOANS_CONTRACT.borrowFrom(REVNET_ID, nativeSource, 0, nativeTokens, payable(USER), 25, USER);
|
|
214
|
+
|
|
215
|
+
// Step 2: Fully repay the native loan so totalBorrowedFrom goes to 0.
|
|
216
|
+
REVLoan memory loan = LOANS_CONTRACT.loanOf(loanId);
|
|
217
|
+
|
|
218
|
+
// Mock repay permission (no expectCall — sender == loanOwner so hasPermission is skipped).
|
|
219
|
+
_mockRepayPermissionNoExpect(USER);
|
|
220
|
+
|
|
221
|
+
uint256 maxRepay = loan.amount * 2;
|
|
222
|
+
vm.deal(USER, USER.balance + maxRepay);
|
|
223
|
+
vm.prank(USER);
|
|
224
|
+
LOANS_CONTRACT.repayLoan{value: maxRepay}({
|
|
225
|
+
loanId: loanId,
|
|
226
|
+
maxRepayBorrowAmount: maxRepay,
|
|
227
|
+
collateralCountToReturn: loan.collateral,
|
|
228
|
+
beneficiary: payable(USER),
|
|
229
|
+
allowance: JBSingleAllowance({sigDeadline: 0, amount: 0, expiration: 0, nonce: 0, signature: ""})
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Confirm the totalBorrowedFrom for native source is now 0.
|
|
233
|
+
assertEq(
|
|
234
|
+
LOANS_CONTRACT.totalBorrowedFrom(REVNET_ID, jbMultiTerminal(), JBConstants.NATIVE_TOKEN),
|
|
235
|
+
0,
|
|
236
|
+
"totalBorrowedFrom should be 0 after full repay"
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Step 3: Mock the terminal to revert on accountingContextForTokenOf for native token.
|
|
240
|
+
// This simulates a stale terminal that has been removed or broken.
|
|
241
|
+
vm.mockCallRevert(
|
|
242
|
+
address(jbMultiTerminal()),
|
|
243
|
+
abi.encodeWithSelector(
|
|
244
|
+
IJBTerminal.accountingContextForTokenOf.selector, REVNET_ID, JBConstants.NATIVE_TOKEN
|
|
245
|
+
),
|
|
246
|
+
"terminal removed"
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// Step 4: Pay into revnet with ERC20 and borrow from ERC20 source.
|
|
250
|
+
// The _totalBorrowedFrom loop should skip the native source (balance is 0)
|
|
251
|
+
// without calling accountingContextForTokenOf on it.
|
|
252
|
+
uint256 payAmount = 1_000_000;
|
|
253
|
+
deal(address(TOKEN), USER, payAmount);
|
|
254
|
+
|
|
255
|
+
// We need to clear the mock for ERC20-related calls on the terminal.
|
|
256
|
+
// The mock only targets native token, so ERC20 calls should still work.
|
|
257
|
+
vm.startPrank(USER);
|
|
258
|
+
TOKEN.approve(address(jbMultiTerminal()), payAmount);
|
|
259
|
+
uint256 erc20Tokens = jbMultiTerminal().pay(REVNET_ID, address(TOKEN), payAmount, USER, 0, "", "");
|
|
260
|
+
vm.stopPrank();
|
|
261
|
+
|
|
262
|
+
_mockLoanPermission(USER);
|
|
263
|
+
REVLoanSource memory erc20Source = REVLoanSource({token: address(TOKEN), terminal: jbMultiTerminal()});
|
|
264
|
+
|
|
265
|
+
// This should NOT revert despite the native source terminal reverting on accountingContextForTokenOf.
|
|
266
|
+
vm.prank(USER);
|
|
267
|
+
LOANS_CONTRACT.borrowFrom(REVNET_ID, erc20Source, 0, erc20Tokens, payable(USER), 25, USER);
|
|
268
|
+
|
|
269
|
+
// If we got here, the zero-balance source was successfully skipped.
|
|
270
|
+
assertGt(
|
|
271
|
+
LOANS_CONTRACT.totalBorrowedFrom(REVNET_ID, jbMultiTerminal(), address(TOKEN)),
|
|
272
|
+
0,
|
|
273
|
+
"ERC20 borrow should succeed despite stale native source"
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/// @notice Verify that _totalBorrowedFrom correctly counts non-zero sources.
|
|
278
|
+
function test_nonZeroSourcesStillCounted() public {
|
|
279
|
+
// Borrow from native source (leave it outstanding).
|
|
280
|
+
vm.prank(USER);
|
|
281
|
+
uint256 nativeTokens =
|
|
282
|
+
jbMultiTerminal().pay{value: 10e18}(REVNET_ID, JBConstants.NATIVE_TOKEN, 10e18, USER, 0, "", "");
|
|
283
|
+
|
|
284
|
+
_mockLoanPermission(USER);
|
|
285
|
+
REVLoanSource memory nativeSource =
|
|
286
|
+
REVLoanSource({token: JBConstants.NATIVE_TOKEN, terminal: jbMultiTerminal()});
|
|
287
|
+
|
|
288
|
+
vm.prank(USER);
|
|
289
|
+
LOANS_CONTRACT.borrowFrom(REVNET_ID, nativeSource, 0, nativeTokens, payable(USER), 25, USER);
|
|
290
|
+
|
|
291
|
+
// Confirm totalBorrowedFrom is non-zero.
|
|
292
|
+
assertGt(
|
|
293
|
+
LOANS_CONTRACT.totalBorrowedFrom(REVNET_ID, jbMultiTerminal(), JBConstants.NATIVE_TOKEN),
|
|
294
|
+
0,
|
|
295
|
+
"totalBorrowedFrom should be non-zero for outstanding loan"
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
// Now borrow from ERC20 source as well — _totalBorrowedFrom should read both.
|
|
299
|
+
uint256 payAmount = 1_000_000;
|
|
300
|
+
deal(address(TOKEN), USER, payAmount);
|
|
301
|
+
vm.startPrank(USER);
|
|
302
|
+
TOKEN.approve(address(jbMultiTerminal()), payAmount);
|
|
303
|
+
uint256 erc20Tokens = jbMultiTerminal().pay(REVNET_ID, address(TOKEN), payAmount, USER, 0, "", "");
|
|
304
|
+
vm.stopPrank();
|
|
305
|
+
|
|
306
|
+
_mockLoanPermission(USER);
|
|
307
|
+
REVLoanSource memory erc20Source = REVLoanSource({token: address(TOKEN), terminal: jbMultiTerminal()});
|
|
308
|
+
|
|
309
|
+
// This call internally invokes _totalBorrowedFrom which reads both sources.
|
|
310
|
+
// If it incorrectly skips non-zero sources, the borrowable amount calculation would be wrong.
|
|
311
|
+
vm.prank(USER);
|
|
312
|
+
LOANS_CONTRACT.borrowFrom(REVNET_ID, erc20Source, 0, erc20Tokens, payable(USER), 25, USER);
|
|
313
|
+
|
|
314
|
+
assertGt(
|
|
315
|
+
LOANS_CONTRACT.totalBorrowedFrom(REVNET_ID, jbMultiTerminal(), address(TOKEN)),
|
|
316
|
+
0,
|
|
317
|
+
"ERC20 borrow should record totalBorrowedFrom"
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ========================================================================
|
|
322
|
+
// Cross-Chain startsAtOrAfter Normalization
|
|
323
|
+
// ========================================================================
|
|
324
|
+
// When stage 0 has startsAtOrAfter=0, it is normalized to block.timestamp.
|
|
325
|
+
// Stage 1 must have startsAtOrAfter > block.timestamp (the normalized value),
|
|
326
|
+
// otherwise REVDeployer_StageTimesMustIncrease is reverted.
|
|
327
|
+
// ========================================================================
|
|
328
|
+
|
|
329
|
+
/// @notice Deploying a revnet where stage 0 startsAtOrAfter=0 and stage 1 startsAtOrAfter=1
|
|
330
|
+
/// (less than block.timestamp) must revert with REVDeployer_StageTimesMustIncrease.
|
|
331
|
+
function test_stageTimesRevertWhenStage1BeforeBlockTimestamp() public {
|
|
332
|
+
REVStageConfig[] memory stageConfigurations = new REVStageConfig[](2);
|
|
333
|
+
uint256 decimalMultiplier = 10 ** 18;
|
|
334
|
+
|
|
335
|
+
JBSplit[] memory splits = new JBSplit[](1);
|
|
336
|
+
splits[0].beneficiary = payable(multisig());
|
|
337
|
+
splits[0].percent = 10_000;
|
|
338
|
+
|
|
339
|
+
REVAutoIssuance[] memory issuanceConfs = new REVAutoIssuance[](1);
|
|
340
|
+
issuanceConfs[0] = REVAutoIssuance({
|
|
341
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
342
|
+
chainId: uint32(block.chainid),
|
|
343
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
344
|
+
count: uint104(70_000 * decimalMultiplier),
|
|
345
|
+
beneficiary: multisig()
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// Stage 0: startsAtOrAfter = 0 (normalized to block.timestamp).
|
|
349
|
+
stageConfigurations[0] = REVStageConfig({
|
|
350
|
+
startsAtOrAfter: 0, // Normalized to block.timestamp
|
|
351
|
+
autoIssuances: issuanceConfs,
|
|
352
|
+
splitPercent: 2000,
|
|
353
|
+
splits: splits,
|
|
354
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
355
|
+
initialIssuance: uint112(1000 * decimalMultiplier),
|
|
356
|
+
issuanceCutFrequency: 90 days,
|
|
357
|
+
issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
|
|
358
|
+
cashOutTaxRate: 6000,
|
|
359
|
+
extraMetadata: 0
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Stage 1: startsAtOrAfter = 1 (definitely < block.timestamp).
|
|
363
|
+
stageConfigurations[1] = REVStageConfig({
|
|
364
|
+
startsAtOrAfter: 1, // 1 < block.timestamp, should fail
|
|
365
|
+
autoIssuances: issuanceConfs,
|
|
366
|
+
splitPercent: 2000,
|
|
367
|
+
splits: splits,
|
|
368
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
369
|
+
initialIssuance: uint112(500 * decimalMultiplier),
|
|
370
|
+
issuanceCutFrequency: 90 days,
|
|
371
|
+
issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
|
|
372
|
+
cashOutTaxRate: 6000,
|
|
373
|
+
extraMetadata: 0
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](1);
|
|
377
|
+
accountingContextsToAccept[0] = JBAccountingContext({
|
|
378
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
|
|
382
|
+
terminalConfigurations[0] =
|
|
383
|
+
JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: accountingContextsToAccept});
|
|
384
|
+
|
|
385
|
+
REVConfig memory config = REVConfig({
|
|
386
|
+
description: REVDescription({
|
|
387
|
+
name: "StageOrderTest", ticker: "$SOT", uri: "ipfs://test", salt: "STAGE_ORDER_SALT_REVERT"
|
|
388
|
+
}),
|
|
389
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
390
|
+
splitOperator: multisig(),
|
|
391
|
+
stageConfigurations: stageConfigurations
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
REVSuckerDeploymentConfig memory suckerConfig = REVSuckerDeploymentConfig({
|
|
395
|
+
deployerConfigurations: new JBSuckerDeployerConfig[](0),
|
|
396
|
+
salt: keccak256(abi.encodePacked("STAGE_ORDER_REVERT"))
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// This should revert because stage 1 start (1) < block.timestamp (the normalized stage 0 start).
|
|
400
|
+
vm.expectRevert(abi.encodeWithSelector(REVDeployer.REVDeployer_StageTimesMustIncrease.selector));
|
|
401
|
+
REV_DEPLOYER.deployFor({
|
|
402
|
+
revnetId: 0,
|
|
403
|
+
configuration: config,
|
|
404
|
+
terminalConfigurations: terminalConfigurations,
|
|
405
|
+
suckerDeploymentConfiguration: suckerConfig,
|
|
406
|
+
tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
|
|
407
|
+
allowedPosts: REVEmpty721Config.emptyAllowedPosts()
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/// @notice Deploying with stage 0 startsAtOrAfter=0 and stage 1 startsAtOrAfter > block.timestamp
|
|
412
|
+
/// should succeed.
|
|
413
|
+
function test_stageTimesSucceedWhenStage1AfterBlockTimestamp() public {
|
|
414
|
+
REVStageConfig[] memory stageConfigurations = new REVStageConfig[](2);
|
|
415
|
+
uint256 decimalMultiplier = 10 ** 18;
|
|
416
|
+
|
|
417
|
+
JBSplit[] memory splits = new JBSplit[](1);
|
|
418
|
+
splits[0].beneficiary = payable(multisig());
|
|
419
|
+
splits[0].percent = 10_000;
|
|
420
|
+
|
|
421
|
+
REVAutoIssuance[] memory issuanceConfs = new REVAutoIssuance[](1);
|
|
422
|
+
issuanceConfs[0] = REVAutoIssuance({
|
|
423
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
424
|
+
chainId: uint32(block.chainid),
|
|
425
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
426
|
+
count: uint104(70_000 * decimalMultiplier),
|
|
427
|
+
beneficiary: multisig()
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// Stage 0: startsAtOrAfter = 0 (normalized to block.timestamp).
|
|
431
|
+
stageConfigurations[0] = REVStageConfig({
|
|
432
|
+
startsAtOrAfter: 0,
|
|
433
|
+
autoIssuances: issuanceConfs,
|
|
434
|
+
splitPercent: 2000,
|
|
435
|
+
splits: splits,
|
|
436
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
437
|
+
initialIssuance: uint112(1000 * decimalMultiplier),
|
|
438
|
+
issuanceCutFrequency: 90 days,
|
|
439
|
+
issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
|
|
440
|
+
cashOutTaxRate: 6000,
|
|
441
|
+
extraMetadata: 0
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// Stage 1: startsAtOrAfter = block.timestamp + 100 (after normalized stage 0 start).
|
|
445
|
+
stageConfigurations[1] = REVStageConfig({
|
|
446
|
+
startsAtOrAfter: uint40(block.timestamp + 100),
|
|
447
|
+
autoIssuances: issuanceConfs,
|
|
448
|
+
splitPercent: 2000,
|
|
449
|
+
splits: splits,
|
|
450
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
451
|
+
initialIssuance: uint112(500 * decimalMultiplier),
|
|
452
|
+
issuanceCutFrequency: 90 days,
|
|
453
|
+
issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
|
|
454
|
+
cashOutTaxRate: 6000,
|
|
455
|
+
extraMetadata: 0
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](1);
|
|
459
|
+
accountingContextsToAccept[0] = JBAccountingContext({
|
|
460
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
|
|
464
|
+
terminalConfigurations[0] =
|
|
465
|
+
JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: accountingContextsToAccept});
|
|
466
|
+
|
|
467
|
+
REVConfig memory config = REVConfig({
|
|
468
|
+
description: REVDescription({
|
|
469
|
+
name: "StageOrderTestOK", ticker: "$SOTOK", uri: "ipfs://test", salt: "STAGE_ORDER_SALT_SUCCESS"
|
|
470
|
+
}),
|
|
471
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
472
|
+
splitOperator: multisig(),
|
|
473
|
+
stageConfigurations: stageConfigurations
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
REVSuckerDeploymentConfig memory suckerConfig = REVSuckerDeploymentConfig({
|
|
477
|
+
deployerConfigurations: new JBSuckerDeployerConfig[](0),
|
|
478
|
+
salt: keccak256(abi.encodePacked("STAGE_ORDER_SUCCESS"))
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// This should succeed because stage 1 start > block.timestamp (the normalized stage 0 start).
|
|
482
|
+
(uint256 newRevnetId,) = REV_DEPLOYER.deployFor({
|
|
483
|
+
revnetId: 0,
|
|
484
|
+
configuration: config,
|
|
485
|
+
terminalConfigurations: terminalConfigurations,
|
|
486
|
+
suckerDeploymentConfiguration: suckerConfig,
|
|
487
|
+
tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
|
|
488
|
+
allowedPosts: REVEmpty721Config.emptyAllowedPosts()
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
assertGt(newRevnetId, 0, "Deployment should succeed and return a valid revnet ID");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/// @notice Stage 1 startsAtOrAfter == block.timestamp (equal to normalized stage 0) must also revert
|
|
495
|
+
/// because the check is strictly greater-than.
|
|
496
|
+
function test_stageTimesRevertWhenStage1EqualsBlockTimestamp() public {
|
|
497
|
+
REVStageConfig[] memory stageConfigurations = new REVStageConfig[](2);
|
|
498
|
+
uint256 decimalMultiplier = 10 ** 18;
|
|
499
|
+
|
|
500
|
+
JBSplit[] memory splits = new JBSplit[](1);
|
|
501
|
+
splits[0].beneficiary = payable(multisig());
|
|
502
|
+
splits[0].percent = 10_000;
|
|
503
|
+
|
|
504
|
+
REVAutoIssuance[] memory issuanceConfs = new REVAutoIssuance[](1);
|
|
505
|
+
issuanceConfs[0] = REVAutoIssuance({
|
|
506
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
507
|
+
chainId: uint32(block.chainid),
|
|
508
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
509
|
+
count: uint104(70_000 * decimalMultiplier),
|
|
510
|
+
beneficiary: multisig()
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// Stage 0: startsAtOrAfter = 0 (normalized to block.timestamp).
|
|
514
|
+
stageConfigurations[0] = REVStageConfig({
|
|
515
|
+
startsAtOrAfter: 0,
|
|
516
|
+
autoIssuances: issuanceConfs,
|
|
517
|
+
splitPercent: 2000,
|
|
518
|
+
splits: splits,
|
|
519
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
520
|
+
initialIssuance: uint112(1000 * decimalMultiplier),
|
|
521
|
+
issuanceCutFrequency: 90 days,
|
|
522
|
+
issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
|
|
523
|
+
cashOutTaxRate: 6000,
|
|
524
|
+
extraMetadata: 0
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Stage 1: startsAtOrAfter = block.timestamp (same as normalized stage 0, must fail).
|
|
528
|
+
stageConfigurations[1] = REVStageConfig({
|
|
529
|
+
startsAtOrAfter: uint40(block.timestamp),
|
|
530
|
+
autoIssuances: issuanceConfs,
|
|
531
|
+
splitPercent: 2000,
|
|
532
|
+
splits: splits,
|
|
533
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
534
|
+
initialIssuance: uint112(500 * decimalMultiplier),
|
|
535
|
+
issuanceCutFrequency: 90 days,
|
|
536
|
+
issuanceCutPercent: JBConstants.MAX_WEIGHT_CUT_PERCENT / 2,
|
|
537
|
+
cashOutTaxRate: 6000,
|
|
538
|
+
extraMetadata: 0
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
JBAccountingContext[] memory accountingContextsToAccept = new JBAccountingContext[](1);
|
|
542
|
+
accountingContextsToAccept[0] = JBAccountingContext({
|
|
543
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
|
|
547
|
+
terminalConfigurations[0] =
|
|
548
|
+
JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: accountingContextsToAccept});
|
|
549
|
+
|
|
550
|
+
REVConfig memory config = REVConfig({
|
|
551
|
+
description: REVDescription({
|
|
552
|
+
name: "StageOrderEqual", ticker: "$SOTEQ", uri: "ipfs://test", salt: "STAGE_ORDER_SALT_EQUAL"
|
|
553
|
+
}),
|
|
554
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
555
|
+
splitOperator: multisig(),
|
|
556
|
+
stageConfigurations: stageConfigurations
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
REVSuckerDeploymentConfig memory suckerConfig = REVSuckerDeploymentConfig({
|
|
560
|
+
deployerConfigurations: new JBSuckerDeployerConfig[](0),
|
|
561
|
+
salt: keccak256(abi.encodePacked("STAGE_ORDER_EQUAL"))
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// This should revert because stage 1 start == block.timestamp == normalized stage 0 start.
|
|
565
|
+
// The check is `effectiveStart <= previousStageStart`, so equality triggers revert.
|
|
566
|
+
vm.expectRevert(abi.encodeWithSelector(REVDeployer.REVDeployer_StageTimesMustIncrease.selector));
|
|
567
|
+
REV_DEPLOYER.deployFor({
|
|
568
|
+
revnetId: 0,
|
|
569
|
+
configuration: config,
|
|
570
|
+
terminalConfigurations: terminalConfigurations,
|
|
571
|
+
suckerDeploymentConfiguration: suckerConfig,
|
|
572
|
+
tiered721HookConfiguration: REVEmpty721Config.empty721Config(uint32(uint160(JBConstants.NATIVE_TOKEN))),
|
|
573
|
+
allowedPosts: REVEmpty721Config.emptyAllowedPosts()
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|