@bananapus/suckers-v6 0.0.13 → 0.0.15
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/ADMINISTRATION.md +14 -3
- package/ARCHITECTURE.md +67 -17
- package/AUDIT_INSTRUCTIONS.md +97 -1
- package/CHANGE_LOG.md +14 -2
- package/README.md +17 -26
- package/RISKS.md +23 -6
- package/SKILLS.md +46 -9
- package/STYLE_GUIDE.md +2 -2
- package/USER_JOURNEYS.md +245 -156
- package/foundry.toml +1 -1
- package/package.json +3 -3
- package/script/Deploy.s.sol +31 -2
- package/script/helpers/SuckerDeploymentLib.sol +6 -6
- package/src/JBArbitrumSucker.sol +15 -12
- package/src/JBBaseSucker.sol +1 -1
- package/src/JBCCIPSucker.sol +1 -1
- package/src/JBCeloSucker.sol +1 -1
- package/src/JBOptimismSucker.sol +1 -1
- package/src/JBSucker.sol +24 -7
- package/src/JBSuckerRegistry.sol +1 -1
- package/src/deployers/JBArbitrumSuckerDeployer.sol +1 -1
- package/src/deployers/JBBaseSuckerDeployer.sol +1 -1
- package/src/deployers/JBCCIPSuckerDeployer.sol +1 -1
- package/src/deployers/JBCeloSuckerDeployer.sol +1 -1
- package/src/deployers/JBOptimismSuckerDeployer.sol +1 -1
- package/src/deployers/JBSuckerDeployer.sol +1 -1
- package/src/libraries/CCIPHelper.sol +1 -1
- package/src/utils/MerkleLib.sol +1 -1
- package/test/Fork.t.sol +1 -1
- package/test/ForkArbitrum.t.sol +1 -1
- package/test/ForkCelo.t.sol +1 -1
- package/test/ForkClaim.t.sol +1 -1
- package/test/ForkMainnet.t.sol +1 -1
- package/test/ForkOPStack.t.sol +1 -1
- package/test/SuckerDeepAttacks.t.sol +5 -4
- package/test/audit/ArbitrumL2ToRemoteFeeDoS.t.sol +120 -0
- package/test/audit/CodexNemesisPoC.t.sol +169 -0
- package/test/fork/OptimismSuckerFork.t.sol +457 -0
- package/test/unit/ccip_refund.t.sol +1 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import "forge-std/Test.sol";
|
|
5
|
+
|
|
6
|
+
import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
|
|
7
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
8
|
+
import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
|
|
9
|
+
import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
|
|
10
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
11
|
+
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
|
|
12
|
+
import {LibClone} from "solady/src/utils/LibClone.sol";
|
|
13
|
+
|
|
14
|
+
import {JBSucker} from "../../src/JBSucker.sol";
|
|
15
|
+
import {IJBSucker} from "../../src/interfaces/IJBSucker.sol";
|
|
16
|
+
import {IJBSuckerRegistry} from "../../src/interfaces/IJBSuckerRegistry.sol";
|
|
17
|
+
import {JBSuckerState} from "../../src/enums/JBSuckerState.sol";
|
|
18
|
+
import {JBClaim} from "../../src/structs/JBClaim.sol";
|
|
19
|
+
import {JBInboxTreeRoot} from "../../src/structs/JBInboxTreeRoot.sol";
|
|
20
|
+
import {JBLeaf} from "../../src/structs/JBLeaf.sol";
|
|
21
|
+
import {JBMessageRoot} from "../../src/structs/JBMessageRoot.sol";
|
|
22
|
+
import {JBRemoteToken} from "../../src/structs/JBRemoteToken.sol";
|
|
23
|
+
import {MerkleLib} from "../../src/utils/MerkleLib.sol";
|
|
24
|
+
|
|
25
|
+
contract CodexAuditGapSucker is JBSucker {
|
|
26
|
+
using MerkleLib for MerkleLib.Tree;
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
IJBDirectory directory,
|
|
30
|
+
IJBPermissions permissions,
|
|
31
|
+
IJBTokens tokens
|
|
32
|
+
)
|
|
33
|
+
JBSucker(directory, permissions, tokens, 1, IJBSuckerRegistry(address(1)), address(0))
|
|
34
|
+
{}
|
|
35
|
+
|
|
36
|
+
function peerChainId() external view override returns (uint256) {
|
|
37
|
+
return block.chainid;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function _isRemotePeer(address sender) internal view override returns (bool) {
|
|
41
|
+
return sender == address(this);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function _sendRootOverAMB(
|
|
45
|
+
uint256,
|
|
46
|
+
uint256,
|
|
47
|
+
address,
|
|
48
|
+
uint256,
|
|
49
|
+
JBRemoteToken memory,
|
|
50
|
+
JBMessageRoot memory
|
|
51
|
+
)
|
|
52
|
+
internal
|
|
53
|
+
override
|
|
54
|
+
{}
|
|
55
|
+
|
|
56
|
+
function test_insertIntoTree(
|
|
57
|
+
uint256 projectTokenCount,
|
|
58
|
+
address token,
|
|
59
|
+
uint256 terminalTokenAmount,
|
|
60
|
+
bytes32 beneficiary
|
|
61
|
+
)
|
|
62
|
+
external
|
|
63
|
+
{
|
|
64
|
+
_insertIntoTree(projectTokenCount, token, terminalTokenAmount, beneficiary);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function test_setRemoteToken(address localToken, JBRemoteToken memory remoteToken) external {
|
|
68
|
+
_remoteTokenFor[localToken] = remoteToken;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function test_setDeprecatedAfter(uint256 timestamp) external {
|
|
72
|
+
deprecatedAfter = timestamp;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function test_getOutboxRoot(address token) external view returns (bytes32) {
|
|
76
|
+
return _outboxOf[token].tree.root();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function test_getOutboxNonce(address token) external view returns (uint64) {
|
|
80
|
+
return _outboxOf[token].nonce;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function test_getInboxRoot(address token) external view returns (bytes32) {
|
|
84
|
+
return _inboxOf[token].root;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
contract CodexNemesisPoC is Test {
|
|
89
|
+
using MerkleLib for MerkleLib.Tree;
|
|
90
|
+
|
|
91
|
+
address constant DIRECTORY = address(600);
|
|
92
|
+
address constant PERMISSIONS = address(800);
|
|
93
|
+
address constant TOKENS = address(700);
|
|
94
|
+
address constant PROJECT = address(1000);
|
|
95
|
+
address constant TERMINAL = address(1200);
|
|
96
|
+
address constant TOKEN = address(0x000000000000000000000000000000000000EEEe);
|
|
97
|
+
uint256 constant PROJECT_ID = 1;
|
|
98
|
+
|
|
99
|
+
CodexAuditGapSucker internal source;
|
|
100
|
+
CodexAuditGapSucker internal destination;
|
|
101
|
+
|
|
102
|
+
function setUp() public {
|
|
103
|
+
vm.warp(100 days);
|
|
104
|
+
|
|
105
|
+
vm.mockCall(DIRECTORY, abi.encodeCall(IJBDirectory.PROJECTS, ()), abi.encode(PROJECT));
|
|
106
|
+
vm.mockCall(PROJECT, abi.encodeCall(IERC721.ownerOf, (PROJECT_ID)), abi.encode(address(this)));
|
|
107
|
+
vm.mockCall(address(1), abi.encodeCall(IJBSuckerRegistry.toRemoteFee, ()), abi.encode(uint256(0)));
|
|
108
|
+
vm.mockCall(
|
|
109
|
+
DIRECTORY, abi.encodeCall(IJBDirectory.primaryTerminalOf, (PROJECT_ID, TOKEN)), abi.encode(TERMINAL)
|
|
110
|
+
);
|
|
111
|
+
vm.mockCall(TERMINAL, abi.encodeWithSelector(IJBTerminal.pay.selector), abi.encode(uint256(0)));
|
|
112
|
+
|
|
113
|
+
source = _createSucker("codex-source");
|
|
114
|
+
destination = _createSucker("codex-destination");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// @notice Verifies that a deprecated destination now accepts roots, preventing token stranding.
|
|
118
|
+
/// Previously, deprecated suckers rejected incoming roots, which could strand tokens sent just before
|
|
119
|
+
/// deprecation. The fix accepts roots in DEPRECATED state since toRemote is already disabled, preventing
|
|
120
|
+
/// double-spend without stranding tokens.
|
|
121
|
+
function test_deprecatedDestinationAcceptsRootAfterFix() external {
|
|
122
|
+
bytes32 beneficiary = bytes32(uint256(uint160(address(this))));
|
|
123
|
+
|
|
124
|
+
source.test_setRemoteToken(
|
|
125
|
+
TOKEN,
|
|
126
|
+
JBRemoteToken({
|
|
127
|
+
enabled: true, emergencyHatch: false, minGas: 200_000, addr: bytes32(uint256(uint160(TOKEN)))
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
source.test_insertIntoTree({
|
|
131
|
+
projectTokenCount: 10 ether, token: TOKEN, terminalTokenAmount: 1 ether, beneficiary: beneficiary
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
source.toRemote(TOKEN);
|
|
135
|
+
|
|
136
|
+
assertEq(source.outboxOf(TOKEN).numberOfClaimsSent, 1, "leaf must be marked sent on source");
|
|
137
|
+
|
|
138
|
+
destination.test_setDeprecatedAfter(block.timestamp - 1);
|
|
139
|
+
assertEq(uint256(destination.state()), uint256(JBSuckerState.DEPRECATED), "destination must be deprecated");
|
|
140
|
+
|
|
141
|
+
JBMessageRoot memory root = JBMessageRoot({
|
|
142
|
+
version: 1,
|
|
143
|
+
token: bytes32(uint256(uint160(TOKEN))),
|
|
144
|
+
amount: 1 ether,
|
|
145
|
+
remoteRoot: JBInboxTreeRoot({
|
|
146
|
+
nonce: source.test_getOutboxNonce(TOKEN), root: source.test_getOutboxRoot(TOKEN)
|
|
147
|
+
})
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
vm.prank(address(destination));
|
|
151
|
+
destination.fromRemote(root);
|
|
152
|
+
|
|
153
|
+
// After the fix, the root IS accepted even in DEPRECATED state.
|
|
154
|
+
assertEq(
|
|
155
|
+
destination.test_getInboxRoot(TOKEN),
|
|
156
|
+
source.test_getOutboxRoot(TOKEN),
|
|
157
|
+
"deprecated destination should now accept the root"
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function _createSucker(bytes32 salt) internal returns (CodexAuditGapSucker) {
|
|
162
|
+
CodexAuditGapSucker singleton =
|
|
163
|
+
new CodexAuditGapSucker(IJBDirectory(DIRECTORY), IJBPermissions(PERMISSIONS), IJBTokens(TOKENS));
|
|
164
|
+
CodexAuditGapSucker clone =
|
|
165
|
+
CodexAuditGapSucker(payable(address(LibClone.cloneDeterministic(address(singleton), salt))));
|
|
166
|
+
clone.initialize(PROJECT_ID);
|
|
167
|
+
return clone;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import "forge-std/Test.sol";
|
|
5
|
+
import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
|
|
6
|
+
|
|
7
|
+
// Core imports for JB stack interaction.
|
|
8
|
+
import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
9
|
+
import {JBPermissionsData} from "@bananapus/core-v6/src/structs/JBPermissionsData.sol";
|
|
10
|
+
import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
|
|
11
|
+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
12
|
+
|
|
13
|
+
// Sucker imports for OP bridge testing.
|
|
14
|
+
import {IJBSucker} from "../../src/interfaces/IJBSucker.sol";
|
|
15
|
+
import {IJBSuckerRegistry} from "../../src/interfaces/IJBSuckerRegistry.sol";
|
|
16
|
+
import {JBTokenMapping} from "../../src/structs/JBTokenMapping.sol";
|
|
17
|
+
import {IOPMessenger} from "../../src/interfaces/IOPMessenger.sol";
|
|
18
|
+
import {IOPStandardBridge} from "../../src/interfaces/IOPStandardBridge.sol";
|
|
19
|
+
import {JBOptimismSucker} from "../../src/JBOptimismSucker.sol";
|
|
20
|
+
import {JBOptimismSuckerDeployer} from "../../src/deployers/JBOptimismSuckerDeployer.sol";
|
|
21
|
+
|
|
22
|
+
// Chainlink imports for sequencer-aware price feed testing.
|
|
23
|
+
import {JBChainlinkV3SequencerPriceFeed} from "@bananapus/core-v6/src/JBChainlinkV3SequencerPriceFeed.sol";
|
|
24
|
+
import {JBChainlinkV3PriceFeed, AggregatorV3Interface} from "@bananapus/core-v6/src/JBChainlinkV3PriceFeed.sol";
|
|
25
|
+
import {AggregatorV2V3Interface} from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV2V3Interface.sol";
|
|
26
|
+
|
|
27
|
+
/// @notice Optimism mainnet (chain ID 10) fork test for OP-specific sucker behavior.
|
|
28
|
+
///
|
|
29
|
+
/// Validates chain-sensitive features on Optimism:
|
|
30
|
+
/// - OP Stack predeploy contracts exist at canonical addresses
|
|
31
|
+
/// - CrossDomainMessenger responds to xDomainMessageSender()
|
|
32
|
+
/// - JBOptimismSucker deploys correctly with real OP bridge contracts
|
|
33
|
+
/// - Native ETH bridging send-side flow works against real Optimism infrastructure
|
|
34
|
+
/// - L2 sequencer-aware Chainlink price feeds work with real Optimism feeds
|
|
35
|
+
///
|
|
36
|
+
/// Run with: forge test --match-contract OptimismSuckerForkTest -vvv
|
|
37
|
+
contract OptimismSuckerForkTest is TestBaseWorkflow {
|
|
38
|
+
// ── Optimism L2 predeploy addresses (same across all OP Stack L2s) ──
|
|
39
|
+
|
|
40
|
+
// The L2 CrossDomainMessenger predeploy used by OP suckers.
|
|
41
|
+
IOPMessenger constant L2_MESSENGER = IOPMessenger(0x4200000000000000000000000000000000000007);
|
|
42
|
+
|
|
43
|
+
// The L2 StandardBridge predeploy used for ERC-20/ETH bridging.
|
|
44
|
+
IOPStandardBridge constant L2_BRIDGE = IOPStandardBridge(0x4200000000000000000000000000000000000010);
|
|
45
|
+
|
|
46
|
+
// The L2ToL1MessagePasser predeploy (verifies OP infra completeness).
|
|
47
|
+
address constant L2_TO_L1_MESSAGE_PASSER = 0x4200000000000000000000000000000000000016;
|
|
48
|
+
|
|
49
|
+
// WETH predeploy on OP Stack L2s.
|
|
50
|
+
address constant OP_WETH = 0x4200000000000000000000000000000000000006;
|
|
51
|
+
|
|
52
|
+
// ── Optimism Chainlink addresses ──
|
|
53
|
+
|
|
54
|
+
// Chainlink ETH/USD price feed on Optimism mainnet.
|
|
55
|
+
address constant OP_ETH_USD_FEED = 0x13e3Ee699D1909E989722E753853AE30b17e08c5;
|
|
56
|
+
|
|
57
|
+
// Chainlink L2 sequencer uptime feed on Optimism mainnet.
|
|
58
|
+
address constant OP_SEQUENCER_FEED = 0x371EAD81c9102C9BF4874A9075FFFf170F2Ee389;
|
|
59
|
+
|
|
60
|
+
// Grace period: 1 hour after sequencer restart before accepting prices.
|
|
61
|
+
uint256 constant L2_GRACE_PERIOD = 3600;
|
|
62
|
+
|
|
63
|
+
// ── Sucker test state ──
|
|
64
|
+
|
|
65
|
+
// Ruleset metadata used for the test project.
|
|
66
|
+
JBRulesetMetadata _metadata;
|
|
67
|
+
|
|
68
|
+
// The JBOptimismSucker deployer on the L2 fork.
|
|
69
|
+
JBOptimismSuckerDeployer suckerDeployer;
|
|
70
|
+
|
|
71
|
+
// The deployed sucker instance.
|
|
72
|
+
IJBSucker sucker;
|
|
73
|
+
|
|
74
|
+
// The project's ERC-20 token (deployed after project launch).
|
|
75
|
+
IJBToken projectToken;
|
|
76
|
+
|
|
77
|
+
// Tracks whether the Optimism fork was successfully created.
|
|
78
|
+
bool forkCreated;
|
|
79
|
+
|
|
80
|
+
// Accept ETH for cash-out reclaims.
|
|
81
|
+
receive() external payable {}
|
|
82
|
+
|
|
83
|
+
function setUp() public override {
|
|
84
|
+
// Attempt to fork Optimism mainnet; skip all tests if no RPC is available.
|
|
85
|
+
try vm.createSelectFork("optimism") {
|
|
86
|
+
// Fork succeeded — record that fact.
|
|
87
|
+
forkCreated = true;
|
|
88
|
+
} catch {
|
|
89
|
+
// No Optimism RPC configured — skip gracefully.
|
|
90
|
+
forkCreated = false;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Configure ruleset metadata with sensible defaults for sucker testing.
|
|
95
|
+
_metadata = JBRulesetMetadata({
|
|
96
|
+
reservedPercent: JBConstants.MAX_RESERVED_PERCENT / 2, // 50% reserved tokens.
|
|
97
|
+
cashOutTaxRate: 0, // No cash-out tax (simplifies sucker tests).
|
|
98
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)), // ETH as base currency.
|
|
99
|
+
pausePay: false, // Payments enabled.
|
|
100
|
+
pauseCreditTransfers: false, // Credit transfers enabled.
|
|
101
|
+
allowOwnerMinting: true, // Owner can mint (needed for sucker claims).
|
|
102
|
+
allowSetCustomToken: false, // Custom token setting disabled.
|
|
103
|
+
allowTerminalMigration: false, // Terminal migration disabled.
|
|
104
|
+
allowSetTerminals: false, // Setting terminals disabled.
|
|
105
|
+
allowSetController: false, // Setting controller disabled.
|
|
106
|
+
allowAddAccountingContext: true, // Adding accounting contexts allowed.
|
|
107
|
+
allowAddPriceFeed: true, // Adding price feeds allowed.
|
|
108
|
+
ownerMustSendPayouts: false, // Anyone can trigger payouts.
|
|
109
|
+
holdFees: false, // Fees not held.
|
|
110
|
+
useTotalSurplusForCashOuts: true, // Use total surplus across terminals.
|
|
111
|
+
useDataHookForPay: false, // No pay data hook.
|
|
112
|
+
useDataHookForCashOut: false, // No cash-out data hook.
|
|
113
|
+
dataHook: address(0), // No data hook address.
|
|
114
|
+
metadata: 0 // No extra metadata.
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Deploy fresh JB core contracts on the Optimism fork.
|
|
118
|
+
super.setUp();
|
|
119
|
+
|
|
120
|
+
// Stop any active prank from TestBaseWorkflow.
|
|
121
|
+
vm.stopPrank();
|
|
122
|
+
|
|
123
|
+
// Deploy the OP sucker deployer (from a non-privileged address to match prod pattern).
|
|
124
|
+
vm.startPrank(address(0x1112222));
|
|
125
|
+
suckerDeployer = new JBOptimismSuckerDeployer(
|
|
126
|
+
jbDirectory(), // Core directory for project lookups.
|
|
127
|
+
jbPermissions(), // Permissions contract for access control.
|
|
128
|
+
jbTokens(), // Token contract for minting/burning.
|
|
129
|
+
address(this), // Configurator address.
|
|
130
|
+
address(0) // No trusted forwarder for this test.
|
|
131
|
+
);
|
|
132
|
+
vm.stopPrank();
|
|
133
|
+
|
|
134
|
+
// Set the OP-specific constants: messenger and bridge addresses.
|
|
135
|
+
suckerDeployer.setChainSpecificConstants(L2_MESSENGER, L2_BRIDGE);
|
|
136
|
+
|
|
137
|
+
// Deploy the singleton implementation (used as template for clones).
|
|
138
|
+
vm.startPrank(address(0x1112222));
|
|
139
|
+
JBOptimismSucker singleton = new JBOptimismSucker({
|
|
140
|
+
deployer: suckerDeployer, // Deployer that provides bridge references.
|
|
141
|
+
directory: jbDirectory(), // Core directory.
|
|
142
|
+
permissions: jbPermissions(), // Core permissions.
|
|
143
|
+
tokens: jbTokens(), // Core token management.
|
|
144
|
+
feeProjectId: 1, // Fee project ID.
|
|
145
|
+
registry: IJBSuckerRegistry(address(0)), // No registry in test.
|
|
146
|
+
trustedForwarder: address(0) // No trusted forwarder.
|
|
147
|
+
});
|
|
148
|
+
vm.stopPrank();
|
|
149
|
+
|
|
150
|
+
// Register the singleton with the deployer.
|
|
151
|
+
suckerDeployer.configureSingleton(singleton);
|
|
152
|
+
|
|
153
|
+
// Create a sucker clone for project ID 1.
|
|
154
|
+
sucker = suckerDeployer.createForSender(1, "op-fork-salt");
|
|
155
|
+
|
|
156
|
+
// Label the sucker address for trace readability.
|
|
157
|
+
vm.label(address(sucker), "optimismSucker");
|
|
158
|
+
|
|
159
|
+
// Grant the sucker MINT_TOKENS permission so it can mint on claim.
|
|
160
|
+
uint8[] memory ids = new uint8[](1);
|
|
161
|
+
ids[0] = JBPermissionIds.MINT_TOKENS; // Permission to mint project tokens.
|
|
162
|
+
|
|
163
|
+
// Build permissions data for the sucker.
|
|
164
|
+
JBPermissionsData memory perms = JBPermissionsData({
|
|
165
|
+
operator: address(sucker), // The sucker needs mint permission.
|
|
166
|
+
projectId: 1, // For project 1.
|
|
167
|
+
permissionIds: ids // MINT_TOKENS permission.
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Set permissions and launch the project.
|
|
171
|
+
vm.startPrank(multisig());
|
|
172
|
+
jbPermissions().setPermissionsFor(multisig(), perms); // Grant mint to sucker.
|
|
173
|
+
_launchProject(); // Launch a project that accepts native ETH.
|
|
174
|
+
projectToken = jbController().deployERC20For(1, "OPSuckerToken", "OPSOOK", bytes32(0)); // Deploy ERC-20.
|
|
175
|
+
vm.stopPrank();
|
|
176
|
+
|
|
177
|
+
// Mock the registry's toRemoteFee() to return 0 (registry is address(0) in tests).
|
|
178
|
+
vm.mockCall(
|
|
179
|
+
address(0),
|
|
180
|
+
abi.encodeCall(IJBSuckerRegistry.toRemoteFee, ()),
|
|
181
|
+
abi.encode(uint256(0)) // Zero bridging fee for test simplicity.
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// @notice Launches a test project that accepts native ETH payments.
|
|
186
|
+
function _launchProject() internal {
|
|
187
|
+
// Configure surplus allowance for the project.
|
|
188
|
+
JBCurrencyAmount[] memory _surplusAllowances = new JBCurrencyAmount[](1);
|
|
189
|
+
_surplusAllowances[0] = JBCurrencyAmount({
|
|
190
|
+
amount: 5 * 10 ** 18, // Allow 5 ETH surplus withdrawal.
|
|
191
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)) // Denominated in native ETH.
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Configure fund access limits (surplus allowance only, no payout limits).
|
|
195
|
+
JBFundAccessLimitGroup[] memory _fundAccessLimitGroup = new JBFundAccessLimitGroup[](1);
|
|
196
|
+
_fundAccessLimitGroup[0] = JBFundAccessLimitGroup({
|
|
197
|
+
terminal: address(jbMultiTerminal()), // For the multi-terminal.
|
|
198
|
+
token: JBConstants.NATIVE_TOKEN, // For native ETH.
|
|
199
|
+
payoutLimits: new JBCurrencyAmount[](0), // No payout limits.
|
|
200
|
+
surplusAllowances: _surplusAllowances // 5 ETH surplus allowance.
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Build ruleset configuration.
|
|
204
|
+
JBRulesetConfig[] memory _rulesetConfigurations = new JBRulesetConfig[](1);
|
|
205
|
+
_rulesetConfigurations[0].mustStartAtOrAfter = 0; // Start immediately.
|
|
206
|
+
_rulesetConfigurations[0].duration = 0; // No expiration (manual replacement only).
|
|
207
|
+
_rulesetConfigurations[0].weight = 1000 * 10 ** 18; // 1000 tokens per ETH.
|
|
208
|
+
_rulesetConfigurations[0].weightCutPercent = 0; // No weight decay.
|
|
209
|
+
_rulesetConfigurations[0].approvalHook = IJBRulesetApprovalHook(address(0)); // No approval hook.
|
|
210
|
+
_rulesetConfigurations[0].metadata = _metadata; // Configured metadata.
|
|
211
|
+
_rulesetConfigurations[0].splitGroups = new JBSplitGroup[](0); // No split groups.
|
|
212
|
+
_rulesetConfigurations[0].fundAccessLimitGroups = _fundAccessLimitGroup; // Fund access config.
|
|
213
|
+
|
|
214
|
+
// Configure terminal to accept native ETH.
|
|
215
|
+
JBAccountingContext[] memory _tokensToAccept = new JBAccountingContext[](1);
|
|
216
|
+
_tokensToAccept[0] = JBAccountingContext({
|
|
217
|
+
token: JBConstants.NATIVE_TOKEN, // Accept native ETH.
|
|
218
|
+
decimals: 18, // ETH uses 18 decimals.
|
|
219
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)) // Currency matches token.
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Set up the terminal configuration.
|
|
223
|
+
JBTerminalConfig[] memory _terminalConfigurations = new JBTerminalConfig[](1);
|
|
224
|
+
_terminalConfigurations[0] = JBTerminalConfig({
|
|
225
|
+
terminal: jbMultiTerminal(), // Use the deployed multi-terminal.
|
|
226
|
+
accountingContextsToAccept: _tokensToAccept // Accept native ETH.
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Launch the project with the configured ruleset and terminal.
|
|
230
|
+
jbController()
|
|
231
|
+
.launchProjectFor({
|
|
232
|
+
owner: multisig(), // Multisig owns the project.
|
|
233
|
+
projectUri: "optimism-sucker-fork-test", // Descriptive URI.
|
|
234
|
+
rulesetConfigurations: _rulesetConfigurations, // Single ruleset.
|
|
235
|
+
terminalConfigurations: _terminalConfigurations, // Single terminal.
|
|
236
|
+
memo: "" // No memo.
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
241
|
+
// Tests
|
|
242
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
243
|
+
|
|
244
|
+
/// @notice Verify chain ID is correct on the Optimism fork.
|
|
245
|
+
function test_optimism_chainId() public {
|
|
246
|
+
// Skip if no Optimism fork is available.
|
|
247
|
+
if (!forkCreated) {
|
|
248
|
+
vm.skip(true); // Gracefully skip when no Optimism RPC is configured.
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Optimism mainnet chain ID should be 10.
|
|
253
|
+
assertEq(block.chainid, 10, "Fork should report Optimism mainnet chain ID 10");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/// @notice Verify OP Stack predeploy contracts exist at canonical addresses.
|
|
257
|
+
function test_optimism_predeploysExist() public {
|
|
258
|
+
// Skip if no Optimism fork is available.
|
|
259
|
+
if (!forkCreated) {
|
|
260
|
+
vm.skip(true); // Gracefully skip when no Optimism RPC is configured.
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// CrossDomainMessenger should be deployed at the L2 predeploy address.
|
|
265
|
+
assertGt(address(L2_MESSENGER).code.length, 0, "CrossDomainMessenger should be deployed on Optimism");
|
|
266
|
+
|
|
267
|
+
// StandardBridge should be deployed at the L2 predeploy address.
|
|
268
|
+
assertGt(address(L2_BRIDGE).code.length, 0, "StandardBridge should be deployed on Optimism");
|
|
269
|
+
|
|
270
|
+
// L2ToL1MessagePasser should be deployed (OP withdrawals depend on it).
|
|
271
|
+
assertGt(L2_TO_L1_MESSAGE_PASSER.code.length, 0, "L2ToL1MessagePasser should be deployed on Optimism");
|
|
272
|
+
|
|
273
|
+
// WETH predeploy should exist on Optimism.
|
|
274
|
+
assertGt(OP_WETH.code.length, 0, "WETH predeploy should exist on Optimism");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/// @notice Verify the OP CrossDomainMessenger responds to xDomainMessageSender().
|
|
278
|
+
function test_optimism_messengerXDomainSender() public {
|
|
279
|
+
// Skip if no Optimism fork is available.
|
|
280
|
+
if (!forkCreated) {
|
|
281
|
+
vm.skip(true); // Gracefully skip when no Optimism RPC is configured.
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// xDomainMessageSender() should revert when called outside of a cross-domain message.
|
|
286
|
+
// This proves the messenger exists and has the expected function selector.
|
|
287
|
+
try L2_MESSENGER.xDomainMessageSender() {
|
|
288
|
+
// If it doesn't revert, it returned a default value — still proves the contract responds.
|
|
289
|
+
assertTrue(true, "Messenger responded to xDomainMessageSender() without revert");
|
|
290
|
+
} catch {
|
|
291
|
+
// Expected: reverts with "xDomainMessageSender is not set" when not in a cross-domain call.
|
|
292
|
+
assertTrue(true, "Messenger correctly reverts outside cross-domain context");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/// @notice Verify JBOptimismSucker deployed correctly with OP bridge references.
|
|
297
|
+
function test_optimism_suckerBridgeReferences() public {
|
|
298
|
+
// Skip if no Optimism fork is available.
|
|
299
|
+
if (!forkCreated) {
|
|
300
|
+
vm.skip(true); // Gracefully skip when no Optimism RPC is configured.
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Cast to JBOptimismSucker via payable address (JBSucker has a payable fallback).
|
|
305
|
+
JBOptimismSucker opSucker = JBOptimismSucker(payable(address(sucker)));
|
|
306
|
+
|
|
307
|
+
// Verify OPMESSENGER points to the L2 CrossDomainMessenger.
|
|
308
|
+
assertEq(
|
|
309
|
+
address(opSucker.OPMESSENGER()),
|
|
310
|
+
address(L2_MESSENGER),
|
|
311
|
+
"Sucker OPMESSENGER should be the L2 CrossDomainMessenger"
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
// Verify OPBRIDGE points to the L2 StandardBridge.
|
|
315
|
+
assertEq(address(opSucker.OPBRIDGE()), address(L2_BRIDGE), "Sucker OPBRIDGE should be the L2 StandardBridge");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/// @notice Verify sucker's peerChainId() returns Ethereum mainnet (1) when on Optimism (10).
|
|
319
|
+
function test_optimism_suckerPeerChainId() public {
|
|
320
|
+
// Skip if no Optimism fork is available.
|
|
321
|
+
if (!forkCreated) {
|
|
322
|
+
vm.skip(true); // Gracefully skip when no Optimism RPC is configured.
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Cast to JBOptimismSucker via payable address (JBSucker has a payable fallback).
|
|
327
|
+
JBOptimismSucker opSucker = JBOptimismSucker(payable(address(sucker)));
|
|
328
|
+
|
|
329
|
+
// When running on Optimism (chain 10), peer should be Ethereum mainnet (chain 1).
|
|
330
|
+
assertEq(opSucker.peerChainId(), 1, "Peer chain for Optimism should be Ethereum mainnet (chain ID 1)");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/// @notice Test native ETH send-side flow: pay into terminal, prepare cash-out via sucker,
|
|
334
|
+
/// and send to remote via the OP bridge.
|
|
335
|
+
function test_optimism_nativeEthSendFlow() public {
|
|
336
|
+
// Skip if no Optimism fork is available.
|
|
337
|
+
if (!forkCreated) {
|
|
338
|
+
vm.skip(true); // Gracefully skip when no Optimism RPC is configured.
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Set up test actor and amounts.
|
|
343
|
+
address user = makeAddr("opUser");
|
|
344
|
+
uint256 amountToSend = 0.05 ether; // Amount of ETH to pay into the project.
|
|
345
|
+
uint256 maxCashedOut = amountToSend / 2; // Maximum ETH expected from cash-out.
|
|
346
|
+
|
|
347
|
+
// Fund the user with ETH on Optimism.
|
|
348
|
+
vm.deal(user, amountToSend);
|
|
349
|
+
|
|
350
|
+
// Map native token (ETH -> ETH, same on both chains).
|
|
351
|
+
JBTokenMapping memory map = JBTokenMapping({
|
|
352
|
+
localToken: JBConstants.NATIVE_TOKEN, // Local token is native ETH.
|
|
353
|
+
minGas: 200_000, // Minimum gas for cross-domain message execution.
|
|
354
|
+
remoteToken: bytes32(uint256(uint160(JBConstants.NATIVE_TOKEN))) // Remote token is also native ETH.
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Map the token as project owner (multisig).
|
|
358
|
+
vm.prank(multisig());
|
|
359
|
+
sucker.mapToken(map);
|
|
360
|
+
|
|
361
|
+
// Pay into the terminal as the user, receiving project tokens.
|
|
362
|
+
vm.startPrank(user);
|
|
363
|
+
uint256 projectTokenAmount = jbMultiTerminal().pay{value: amountToSend}(
|
|
364
|
+
1, // Project ID 1.
|
|
365
|
+
JBConstants.NATIVE_TOKEN, // Pay with native ETH.
|
|
366
|
+
amountToSend, // Full amount.
|
|
367
|
+
user, // Tokens go to user.
|
|
368
|
+
0, // No minimum tokens.
|
|
369
|
+
"", // No memo.
|
|
370
|
+
"" // No metadata.
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
// Approve the sucker to spend the user's project tokens.
|
|
374
|
+
IERC20(address(projectToken)).approve(address(sucker), projectTokenAmount);
|
|
375
|
+
|
|
376
|
+
// Prepare the cash-out via the sucker (builds the merkle outbox tree).
|
|
377
|
+
sucker.prepare(
|
|
378
|
+
projectTokenAmount, // All project tokens.
|
|
379
|
+
bytes32(uint256(uint160(user))), // Remote beneficiary (same user).
|
|
380
|
+
maxCashedOut, // Maximum ETH to receive on remote.
|
|
381
|
+
JBConstants.NATIVE_TOKEN // Cash out in native ETH.
|
|
382
|
+
);
|
|
383
|
+
vm.stopPrank();
|
|
384
|
+
|
|
385
|
+
// Record logs to verify that OP bridge/messenger emit events.
|
|
386
|
+
vm.recordLogs();
|
|
387
|
+
|
|
388
|
+
// Send the outbox tree to the remote chain via the OP bridge.
|
|
389
|
+
vm.prank(user);
|
|
390
|
+
sucker.toRemote(JBConstants.NATIVE_TOKEN); // Initiates bridging (OP bridge doesn't need msg.value).
|
|
391
|
+
|
|
392
|
+
// Verify the outbox was cleared after sending.
|
|
393
|
+
assertEq(sucker.outboxOf(JBConstants.NATIVE_TOKEN).balance, 0, "Outbox should be cleared after toRemote()");
|
|
394
|
+
|
|
395
|
+
// Verify that the OP bridge or messenger emitted events (proves real contracts accepted our call).
|
|
396
|
+
Vm.Log[] memory logs = vm.getRecordedLogs();
|
|
397
|
+
bool foundBridgeEvent = false; // Track whether we found a bridge/messenger event.
|
|
398
|
+
for (uint256 i = 0; i < logs.length; i++) {
|
|
399
|
+
// Check if the event was emitted by the bridge or messenger predeploys.
|
|
400
|
+
if (logs[i].emitter == address(L2_BRIDGE) || logs[i].emitter == address(L2_MESSENGER)) {
|
|
401
|
+
foundBridgeEvent = true; // Found an event from the OP infrastructure.
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// At least one event should have been emitted by the OP bridge infrastructure.
|
|
407
|
+
assertTrue(foundBridgeEvent, "OP bridge/messenger should have emitted events on Optimism");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/// @notice Verify Chainlink sequencer-aware price feed works with real Optimism feeds.
|
|
411
|
+
function test_optimism_sequencerPriceFeed() public {
|
|
412
|
+
// Skip if no Optimism fork is available.
|
|
413
|
+
if (!forkCreated) {
|
|
414
|
+
vm.skip(true); // Gracefully skip when no Optimism RPC is configured.
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Verify the ETH/USD feed contract exists on Optimism.
|
|
419
|
+
assertGt(OP_ETH_USD_FEED.code.length, 0, "Chainlink ETH/USD feed should be deployed on Optimism");
|
|
420
|
+
|
|
421
|
+
// Verify the sequencer uptime feed contract exists on Optimism.
|
|
422
|
+
assertGt(OP_SEQUENCER_FEED.code.length, 0, "Chainlink sequencer feed should be deployed on Optimism");
|
|
423
|
+
|
|
424
|
+
// Deploy a sequencer-aware price feed using Optimism's real Chainlink feeds.
|
|
425
|
+
JBChainlinkV3SequencerPriceFeed feed = new JBChainlinkV3SequencerPriceFeed(
|
|
426
|
+
AggregatorV3Interface(OP_ETH_USD_FEED), // The underlying ETH/USD price feed.
|
|
427
|
+
3600, // 1-hour staleness threshold.
|
|
428
|
+
AggregatorV2V3Interface(OP_SEQUENCER_FEED), // The L2 sequencer uptime feed.
|
|
429
|
+
L2_GRACE_PERIOD // 1-hour grace period after sequencer restart.
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
// Attempt to get the current price; may revert if sequencer is in grace period.
|
|
433
|
+
try feed.currentUnitPrice(18) returns (uint256 price) {
|
|
434
|
+
// If the sequencer is up and feed is fresh, verify the price is sane.
|
|
435
|
+
assertGt(price, 0, "ETH/USD price should be positive on Optimism");
|
|
436
|
+
|
|
437
|
+
// Sanity check: ETH should be between $100 and $100,000.
|
|
438
|
+
assertGt(price, 100e18, "ETH/USD price should be above $100 on Optimism");
|
|
439
|
+
assertLt(price, 100_000e18, "ETH/USD price should be below $100,000 on Optimism");
|
|
440
|
+
} catch {
|
|
441
|
+
// Sequencer down or feed stale at the fork block — expected behavior.
|
|
442
|
+
assertTrue(true, "Feed reverted as expected (sequencer down or stale at fork block)");
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/// @notice Verify the MESSENGER_BASE_GAS_LIMIT constant is set correctly.
|
|
447
|
+
function test_optimism_messengerBaseGasLimit() public {
|
|
448
|
+
// Skip if no Optimism fork is available.
|
|
449
|
+
if (!forkCreated) {
|
|
450
|
+
vm.skip(true); // Gracefully skip when no Optimism RPC is configured.
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// The base gas limit should be 300,000 (defined in JBSucker.sol).
|
|
455
|
+
assertEq(sucker.MESSENGER_BASE_GAS_LIMIT(), 300_000, "MESSENGER_BASE_GAS_LIMIT should be 300,000");
|
|
456
|
+
}
|
|
457
|
+
}
|