@bananapus/suckers-v6 0.0.23 → 0.0.24
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 +5 -1
- package/USER_JOURNEYS.md +13 -0
- package/foundry.toml +1 -0
- package/package.json +8 -5
- package/script/Deploy.s.sol +28 -2
- package/src/JBArbitrumSucker.sol +14 -13
- package/src/JBCCIPSucker.sol +111 -144
- package/src/JBOptimismSucker.sol +0 -5
- package/src/JBSucker.sol +517 -314
- package/src/JBSuckerRegistry.sol +96 -19
- package/src/JBSwapCCIPSucker.sol +600 -0
- package/src/deployers/JBArbitrumSuckerDeployer.sol +10 -0
- package/src/deployers/JBBaseSuckerDeployer.sol +3 -0
- package/src/deployers/JBCCIPSuckerDeployer.sol +21 -8
- package/src/deployers/JBCeloSuckerDeployer.sol +8 -0
- package/src/deployers/JBOptimismSuckerDeployer.sol +8 -0
- package/src/deployers/JBSuckerDeployer.sol +13 -0
- package/src/deployers/JBSwapCCIPSuckerDeployer.sol +117 -0
- package/src/interfaces/ICCIPRouter.sol +1 -1
- package/src/interfaces/IGeomeanOracle.sol +21 -0
- package/src/interfaces/IJBSucker.sol +28 -1
- package/src/interfaces/IJBSwapCCIPSuckerDeployer.sol +27 -0
- package/src/libraries/CCIPHelper.sol +109 -39
- package/src/libraries/JBCCIPLib.sol +188 -0
- package/src/libraries/JBSuckerLib.sol +343 -0
- package/src/libraries/JBSwapLib.sol +144 -0
- package/src/libraries/JBSwapPoolLib.sol +875 -0
- package/src/structs/JBDenominatedAmount.sol +13 -0
- package/src/structs/JBMessageRoot.sol +18 -0
- package/test/ForkClaimMainnet.t.sol +1036 -0
- package/test/ForkMainnet.t.sol +243 -35
- package/test/ForkSwap.t.sol +445 -0
- package/test/ForkSwapMainnet.t.sol +523 -0
- package/test/InteropCompat.t.sol +37 -14
- package/test/SuckerAttacks.t.sol +31 -7
- package/test/SuckerDeepAttacks.t.sol +92 -15
- package/test/SuckerRegressions.t.sol +15 -3
- package/test/TestAuditGaps.sol +92 -14
- package/test/audit/ArbitrumL2ToRemoteFeeDoS.t.sol +9 -0
- package/test/audit/DeprecatedSuckerDestination.t.sol +11 -1
- package/test/audit/ToRemoteFeeFallback.t.sol +9 -0
- package/test/audit/TrustedForwarderSpoof.t.sol +14 -2
- package/test/audit/TrustedForwarderSpoofCCIP.t.sol +12 -3
- package/test/audit/codex-CCIPLegacyFormatCompatibility.t.sol +85 -0
- package/test/audit/codex-CCIPWrappedNativeMisunwrap.t.sol +196 -0
- package/test/audit/codex-FeeLocking.t.sol +328 -0
- package/test/audit/codex-NemesisSwapQueueOrder.t.sol +186 -0
- package/test/audit/codex-PeerDeterminism.t.sol +174 -0
- package/test/audit/codex-PeerSnapshotDesync.t.sol +162 -0
- package/test/audit/codex-SwapBatchRateMixing.t.sol +139 -0
- package/test/audit/codex-SwapZeroAmountBatchGap.t.sol +203 -0
- package/test/audit/codex-ToRemoteFeeIrrecoverable.t.sol +15 -0
- package/test/mocks/MockMessenger.sol +1 -1
- package/test/regression/MapTokensDust.t.sol +5 -0
- package/test/unit/ccip_native_interop.t.sol +56 -14
- package/test/unit/ccip_refund.t.sol +9 -1
- package/test/unit/deployer.t.sol +62 -0
- package/test/unit/emergency.t.sol +7 -2
- package/test/unit/fee_fallback.t.sol +16 -0
- package/test/unit/invariants.t.sol +3 -0
- package/test/unit/merkle.t.sol +16 -10
- package/test/unit/merkle_equivalence.t.sol +121 -0
- package/test/unit/multi_chain_evolution.t.sol +5 -3
- package/test/unit/peer_chain_state.t.sol +618 -0
- package/test/unit/swap_ccip.t.sol +577 -0
- package/test/Fork.t.sol +0 -502
- package/test/ForkClaim.t.sol +0 -549
package/RISKS.md
CHANGED
|
@@ -127,6 +127,10 @@ The registry owner can adjust `toRemoteFee` via `JBSuckerRegistry.setToRemoteFee
|
|
|
127
127
|
|
|
128
128
|
The fee is paid to `FEE_PROJECT_ID` (the protocol project), not to the sucker's own `projectId()`. This centralizes fee collection, but it is still only best-effort: if the fee project's native terminal is missing or its `pay` call reverts, the fee ETH stays in the sucker contract and is later recoverable through the normal claim path. The sucker's project does not directly benefit from the anti-spam fee.
|
|
129
129
|
|
|
130
|
-
### 10.5
|
|
130
|
+
### 10.5 Hookless V4 spot pricing is sandwich-vulnerable by design
|
|
131
|
+
|
|
132
|
+
When the only available Uniswap pool for a cross-denomination swap is a hookless V4 pool (no V3 pool exists), `_getV4Quote` falls back to the instantaneous spot tick from `POOL_MANAGER.getSlot0()` instead of a TWAP oracle. This tick is manipulable via sandwich attacks, allowing an attacker to skew the `minAmountOut` and extract value from the swap. The sigmoid slippage model limits the damage but operates on a corrupted baseline. This is an accepted tradeoff: reverting when no TWAP is available would cause the CCIP message to fail, leaving bridged tokens stuck in the CCIP router until manual retry. Getting some value (even at a worse rate) is preferred over a stuck bridge message, especially since this path only triggers when no V3 pool with built-in TWAP exists at all. The hooked V4 path also falls back to spot if the hook's `observe()` reverts, with the same tradeoff.
|
|
133
|
+
|
|
134
|
+
### 10.6 `mapTokens` refunds ETH on enable-only batches
|
|
131
135
|
|
|
132
136
|
`mapTokens()` only uses `msg.value` when one or more mappings are being disabled and need transport payment for the final root flush. If every mapping in the batch is enable-only (`numberToDisable == 0`), the full `msg.value` is refunded to `_msgSender()`. If the refund transfer fails (e.g., the caller is a non-payable contract), the call reverts with `JBSucker_RefundFailed`. When disables are present, any dust remainder from integer division (`msg.value % numberToDisable`) is also refunded on a best-effort basis.
|
package/USER_JOURNEYS.md
CHANGED
|
@@ -66,6 +66,19 @@
|
|
|
66
66
|
2. Use `exitThroughEmergencyHatch(...)` with the relevant claim data.
|
|
67
67
|
3. Treat emergency execution slots as distinct state that still must not allow the same economic position to be claimed twice.
|
|
68
68
|
|
|
69
|
+
## Journey 6: Create A Proxy Project And Route Payments Through It
|
|
70
|
+
|
|
71
|
+
**Starting state:** a project exists on the home chain with an ERC-20 token deployed, and wants to let users on other chains acquire proxy tokens backed by real project tokens.
|
|
72
|
+
|
|
73
|
+
**Success:** a proxy project is created once, and payments routed through it mint proxy tokens 1:1 with the real tokens deposited.
|
|
74
|
+
|
|
75
|
+
**Flow**
|
|
76
|
+
1. Call `JBSuckerTerminal.createProxy(realProjectId, name, symbol, salt)` to launch a locked proxy project with a permanent 1:1 ruleset, backed by the real project's ERC-20 token.
|
|
77
|
+
2. Anyone can then call `JBSuckerTerminal.pay(proxyProjectId, token, amount, beneficiary, ...)` to pay the real project and automatically receive proxy tokens.
|
|
78
|
+
3. Suckers registered for the proxy project get mint permission through `JBSuckerTerminal`'s data hook, enabling cross-chain bridging of proxy token positions.
|
|
79
|
+
|
|
80
|
+
**Failure cases that matter:** calling `createProxy` on a project without an ERC-20 deployed, attempting to create a second proxy for the same project, and paying with a token that has no primary terminal on the real project.
|
|
81
|
+
|
|
69
82
|
## Hand-Offs
|
|
70
83
|
|
|
71
84
|
- Use [nana-omnichain-deployers-v6](../nana-omnichain-deployers-v6/USER_JOURNEYS.md) when a project wants suckers packaged into its launch flow instead of deployed separately.
|
package/foundry.toml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bananapus/suckers-v6",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.24",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,12 +19,15 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@arbitrum/nitro-contracts": "^1.2.1",
|
|
22
|
-
"@bananapus/core-v6": "^0.0.
|
|
23
|
-
"@bananapus/permission-ids-v6": "^0.0.
|
|
24
|
-
"@chainlink/contracts-ccip": "^1.
|
|
25
|
-
"@chainlink/local": "github:smartcontractkit/chainlink-local",
|
|
22
|
+
"@bananapus/core-v6": "^0.0.34",
|
|
23
|
+
"@bananapus/permission-ids-v6": "^0.0.17",
|
|
24
|
+
"@chainlink/contracts-ccip": "^1.6.0",
|
|
25
|
+
"@chainlink/local": "github:smartcontractkit/chainlink-local#v0.2.7",
|
|
26
26
|
"@openzeppelin/contracts": "^5.6.1",
|
|
27
27
|
"@prb/math": "^4.1.0",
|
|
28
|
+
"@uniswap/v3-core": "github:Uniswap/v3-core#0.8",
|
|
29
|
+
"@uniswap/v3-periphery": "github:Uniswap/v3-periphery#0.8",
|
|
30
|
+
"@uniswap/v4-core": "^1.0.2",
|
|
28
31
|
"solady": "^0.1.26"
|
|
29
32
|
},
|
|
30
33
|
"devDependencies": {
|
package/script/Deploy.s.sol
CHANGED
|
@@ -52,6 +52,8 @@ contract DeployScript is Script, Sphinx {
|
|
|
52
52
|
bytes32 ARB_OP_SALT = "_SUCKER_ARB_OP_V6_";
|
|
53
53
|
// forge-lint: disable-next-line(mixed-case-variable)
|
|
54
54
|
bytes32 OP_BASE_SALT = "_SUCKER_OP_BASE_V6_";
|
|
55
|
+
// forge-lint: disable-next-line(mixed-case-variable)
|
|
56
|
+
bytes32 TEMPO_SALT = "_SUCKER_ETH_TEMPO_V6_";
|
|
55
57
|
|
|
56
58
|
// forge-lint: disable-next-line(mixed-case-variable)
|
|
57
59
|
IJBSuckerRegistry REGISTRY;
|
|
@@ -62,8 +64,9 @@ contract DeployScript is Script, Sphinx {
|
|
|
62
64
|
function configureSphinx() public override {
|
|
63
65
|
// TODO: Update to contain JB Emergency Developers
|
|
64
66
|
sphinxConfig.projectName = "nana-suckers-v6";
|
|
65
|
-
sphinxConfig.mainnets = ["ethereum", "optimism", "base", "arbitrum"];
|
|
66
|
-
sphinxConfig.testnets =
|
|
67
|
+
sphinxConfig.mainnets = ["ethereum", "optimism", "base", "arbitrum", "tempo"];
|
|
68
|
+
sphinxConfig.testnets =
|
|
69
|
+
["ethereum_sepolia", "optimism_sepolia", "base_sepolia", "arbitrum_sepolia", "tempo_moderato"];
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
function run() public {
|
|
@@ -580,6 +583,16 @@ contract DeployScript is Script, Sphinx {
|
|
|
580
583
|
})
|
|
581
584
|
)
|
|
582
585
|
);
|
|
586
|
+
|
|
587
|
+
// Tempo
|
|
588
|
+
PRE_APPROVED_DEPLOYERS.push(
|
|
589
|
+
address(
|
|
590
|
+
_deployCCIPSuckerFor({
|
|
591
|
+
salt: TEMPO_SALT,
|
|
592
|
+
remoteChainId: block.chainid == 1 ? CCIPHelper.TEMPO_ID : CCIPHelper.TEMPO_MOD_ID
|
|
593
|
+
})
|
|
594
|
+
)
|
|
595
|
+
);
|
|
583
596
|
}
|
|
584
597
|
|
|
585
598
|
// Check if we should do the L2 portion.
|
|
@@ -678,6 +691,19 @@ contract DeployScript is Script, Sphinx {
|
|
|
678
691
|
)
|
|
679
692
|
);
|
|
680
693
|
}
|
|
694
|
+
|
|
695
|
+
// Tempo / Tempo Moderato.
|
|
696
|
+
if (block.chainid == 4217 || block.chainid == 42_431) {
|
|
697
|
+
// Tempo -> ETH.
|
|
698
|
+
PRE_APPROVED_DEPLOYERS.push(
|
|
699
|
+
address(
|
|
700
|
+
_deployCCIPSuckerFor({
|
|
701
|
+
salt: TEMPO_SALT,
|
|
702
|
+
remoteChainId: block.chainid == 4217 ? CCIPHelper.ETH_ID : CCIPHelper.ETH_SEP_ID
|
|
703
|
+
})
|
|
704
|
+
)
|
|
705
|
+
);
|
|
706
|
+
}
|
|
681
707
|
}
|
|
682
708
|
|
|
683
709
|
// forge-lint: disable-next-line(mixed-case-function)
|
package/src/JBArbitrumSucker.sol
CHANGED
|
@@ -12,7 +12,6 @@ import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
|
|
|
12
12
|
import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
13
13
|
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
14
14
|
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
|
|
15
|
-
import {BitMaps} from "@openzeppelin/contracts/utils/structs/BitMaps.sol";
|
|
16
15
|
|
|
17
16
|
import {JBSucker} from "./JBSucker.sol";
|
|
18
17
|
import {JBArbitrumSuckerDeployer} from "./deployers/JBArbitrumSuckerDeployer.sol";
|
|
@@ -25,13 +24,9 @@ import {IJBArbitrumSucker} from "./interfaces/IJBArbitrumSucker.sol";
|
|
|
25
24
|
import {ARBChains} from "./libraries/ARBChains.sol";
|
|
26
25
|
import {JBMessageRoot} from "./structs/JBMessageRoot.sol";
|
|
27
26
|
import {JBRemoteToken} from "./structs/JBRemoteToken.sol";
|
|
28
|
-
import {MerkleLib} from "./utils/MerkleLib.sol";
|
|
29
27
|
|
|
30
28
|
/// @notice A `JBSucker` implementation to suck tokens between two chains connected by an Arbitrum bridge.
|
|
31
29
|
contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
|
|
32
|
-
using BitMaps for BitMaps.BitMap;
|
|
33
|
-
using MerkleLib for MerkleLib.Tree;
|
|
34
|
-
|
|
35
30
|
//*********************************************************************//
|
|
36
31
|
// --------------------------- custom errors ------------------------- //
|
|
37
32
|
//*********************************************************************//
|
|
@@ -98,7 +93,7 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
|
|
|
98
93
|
/// @return valid A flag if the sender is a valid representative of the remote peer.
|
|
99
94
|
function _isRemotePeer(address sender) internal view override returns (bool) {
|
|
100
95
|
// Convert the bytes32 peer to an address for comparison with EVM bridge contracts.
|
|
101
|
-
address peerAddress =
|
|
96
|
+
address peerAddress = _peerAddress();
|
|
102
97
|
|
|
103
98
|
// If we are the L1 peer,
|
|
104
99
|
if (LAYER == JBLayer.L1) {
|
|
@@ -125,7 +120,7 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
|
|
|
125
120
|
)
|
|
126
121
|
internal
|
|
127
122
|
{
|
|
128
|
-
address peerAddress =
|
|
123
|
+
address peerAddress = _peerAddress();
|
|
129
124
|
// slither-disable-next-line unused-return,calls-loop
|
|
130
125
|
ARBINBOX.unsafeCreateRetryableTicket{value: callTransportCost + nativeValue}({
|
|
131
126
|
to: peerAddress,
|
|
@@ -139,6 +134,14 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
|
|
|
139
134
|
});
|
|
140
135
|
}
|
|
141
136
|
|
|
137
|
+
/// @notice Approves the Arbitrum gateway to spend `amount` of `token`.
|
|
138
|
+
/// @param token The ERC-20 token to approve.
|
|
139
|
+
/// @param amount The amount to approve.
|
|
140
|
+
function _approveGateway(address token, uint256 amount) internal {
|
|
141
|
+
// slither-disable-next-line calls-loop
|
|
142
|
+
SafeERC20.forceApprove({token: IERC20(token), spender: GATEWAYROUTER.getGateway(token), value: amount});
|
|
143
|
+
}
|
|
144
|
+
|
|
142
145
|
/// @notice Uses the L1/L2 gateway to send the root and assets over the bridge to the peer.
|
|
143
146
|
/// @param transportPayment the amount of `msg.value` that is going to get paid for sending this message.
|
|
144
147
|
/// @param token The token to bridge the outbox tree for.
|
|
@@ -191,13 +194,12 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
|
|
|
191
194
|
uint256 nativeValue;
|
|
192
195
|
|
|
193
196
|
// Cache peer address to avoid redundant calls.
|
|
194
|
-
address peerAddress =
|
|
197
|
+
address peerAddress = _peerAddress();
|
|
195
198
|
|
|
196
199
|
// If the token is an ERC-20, bridge it to the peer.
|
|
197
200
|
// If the amount is `0` then we do not need to bridge any ERC20.
|
|
198
201
|
if (token != JBConstants.NATIVE_TOKEN && amount != 0) {
|
|
199
|
-
|
|
200
|
-
SafeERC20.forceApprove({token: IERC20(token), spender: GATEWAYROUTER.getGateway(token), value: amount});
|
|
202
|
+
_approveGateway({token: token, amount: amount});
|
|
201
203
|
|
|
202
204
|
// Convert bytes32 types to address at the Arbitrum bridge API boundary.
|
|
203
205
|
// slither-disable-next-line calls-loop,unused-return
|
|
@@ -274,8 +276,7 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
|
|
|
274
276
|
}
|
|
275
277
|
|
|
276
278
|
// Approve the tokens to be bridged.
|
|
277
|
-
|
|
278
|
-
SafeERC20.forceApprove({token: IERC20(token), spender: GATEWAYROUTER.getGateway(token), value: amount});
|
|
279
|
+
_approveGateway({token: token, amount: amount});
|
|
279
280
|
|
|
280
281
|
// Perform the ERC-20 bridge transfer. Convert bytes32 peer to address at the Arbitrum bridge API boundary.
|
|
281
282
|
// slither-disable-start out-of-order-retryable
|
|
@@ -283,7 +284,7 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
|
|
|
283
284
|
IArbL1GatewayRouter(address(GATEWAYROUTER)).outboundTransferCustomRefund{value: tokenTransportCost}({
|
|
284
285
|
token: token,
|
|
285
286
|
refundTo: _msgSender(),
|
|
286
|
-
to:
|
|
287
|
+
to: _peerAddress(),
|
|
287
288
|
amount: amount,
|
|
288
289
|
maxGas: remoteToken.minGas,
|
|
289
290
|
gasPriceBid: maxFeePerGas,
|
package/src/JBCCIPSucker.sol
CHANGED
|
@@ -1,36 +1,41 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
pragma solidity 0.8.28;
|
|
3
3
|
|
|
4
|
+
// External packages (alphabetized)
|
|
4
5
|
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
5
6
|
import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
|
|
6
7
|
import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
|
|
7
8
|
import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
8
|
-
import {IAny2EVMMessageReceiver} from "@chainlink/contracts-ccip/
|
|
9
|
-
import {Client} from "@chainlink/contracts-ccip/
|
|
10
|
-
|
|
11
|
-
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
|
|
12
|
-
import {BitMaps} from "@openzeppelin/contracts/utils/structs/BitMaps.sol";
|
|
13
|
-
|
|
9
|
+
import {IAny2EVMMessageReceiver} from "@chainlink/contracts-ccip/contracts/interfaces/IAny2EVMMessageReceiver.sol";
|
|
10
|
+
import {Client} from "@chainlink/contracts-ccip/contracts/libraries/Client.sol";
|
|
11
|
+
// Local: base contracts
|
|
14
12
|
import {JBSucker} from "./JBSucker.sol";
|
|
13
|
+
|
|
14
|
+
// Local: deployers
|
|
15
15
|
import {JBCCIPSuckerDeployer} from "./deployers/JBCCIPSuckerDeployer.sol";
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
|
|
17
|
+
// Local: interfaces (alphabetized)
|
|
18
|
+
import {ICCIPRouter} from "./interfaces/ICCIPRouter.sol";
|
|
18
19
|
import {IJBCCIPSuckerDeployer} from "./interfaces/IJBCCIPSuckerDeployer.sol";
|
|
20
|
+
import {IJBSuckerRegistry} from "./interfaces/IJBSuckerRegistry.sol";
|
|
21
|
+
|
|
22
|
+
// Local: libraries (alphabetized)
|
|
23
|
+
import {CCIPHelper} from "./libraries/CCIPHelper.sol";
|
|
24
|
+
import {JBCCIPLib} from "./libraries/JBCCIPLib.sol";
|
|
25
|
+
|
|
26
|
+
// Local: structs (alphabetized)
|
|
19
27
|
import {JBMessageRoot} from "./structs/JBMessageRoot.sol";
|
|
20
28
|
import {JBRemoteToken} from "./structs/JBRemoteToken.sol";
|
|
21
29
|
import {JBTokenMapping} from "./structs/JBTokenMapping.sol";
|
|
22
|
-
import {MerkleLib} from "./utils/MerkleLib.sol";
|
|
23
30
|
|
|
24
31
|
/// @notice A `JBSucker` implementation to suck tokens between chains with Chainlink CCIP
|
|
25
32
|
contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
|
|
26
|
-
using MerkleLib for MerkleLib.Tree;
|
|
27
|
-
using BitMaps for BitMaps.BitMap;
|
|
28
|
-
|
|
29
33
|
//*********************************************************************//
|
|
30
34
|
// --------------------------- custom errors ------------------------- //
|
|
31
35
|
//*********************************************************************//
|
|
32
36
|
|
|
33
37
|
error JBCCIPSucker_InvalidRouter(address router);
|
|
38
|
+
error JBCCIPSucker_UnknownMessageType(uint8 messageType);
|
|
34
39
|
|
|
35
40
|
//*********************************************************************//
|
|
36
41
|
// ------------------------------ events ----------------------------- //
|
|
@@ -43,6 +48,13 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
|
|
|
43
48
|
/// @param amount The amount of the failed refund (permanently stuck in this contract).
|
|
44
49
|
event TransportPaymentRefundFailed(address indexed recipient, uint256 amount);
|
|
45
50
|
|
|
51
|
+
//*********************************************************************//
|
|
52
|
+
// ----------------------- internal constants ------------------------ //
|
|
53
|
+
//*********************************************************************//
|
|
54
|
+
|
|
55
|
+
/// @notice Message type prefix for root messages (fromRemote).
|
|
56
|
+
uint8 internal constant _CCIP_MSG_TYPE_ROOT = 0;
|
|
57
|
+
|
|
46
58
|
//*********************************************************************//
|
|
47
59
|
// --------------- public immutable stored properties ---------------- //
|
|
48
60
|
//*********************************************************************//
|
|
@@ -60,10 +72,13 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
|
|
|
60
72
|
// ---------------------------- constructor -------------------------- //
|
|
61
73
|
//*********************************************************************//
|
|
62
74
|
|
|
63
|
-
/// @param deployer A contract that deploys the clones for this
|
|
75
|
+
/// @param deployer A contract that deploys the clones for this contract.
|
|
64
76
|
/// @param directory A contract storing directories of terminals and controllers for each project.
|
|
65
77
|
/// @param tokens A contract that manages token minting and burning.
|
|
66
78
|
/// @param permissions A contract storing permissions.
|
|
79
|
+
/// @param feeProjectId The ID of the project that receives fees.
|
|
80
|
+
/// @param registry The sucker registry that tracks deployed suckers.
|
|
81
|
+
/// @param trustedForwarder The trusted forwarder for ERC-2771 meta-transactions.
|
|
67
82
|
constructor(
|
|
68
83
|
JBCCIPSuckerDeployer deployer,
|
|
69
84
|
IJBDirectory directory,
|
|
@@ -75,10 +90,16 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
|
|
|
75
90
|
)
|
|
76
91
|
JBSucker(directory, permissions, tokens, feeProjectId, registry, trustedForwarder)
|
|
77
92
|
{
|
|
93
|
+
// Read the remote chain ID from the deployer.
|
|
78
94
|
REMOTE_CHAIN_ID = IJBCCIPSuckerDeployer(deployer).ccipRemoteChainId();
|
|
95
|
+
|
|
96
|
+
// Read the CCIP chain selector from the deployer.
|
|
79
97
|
REMOTE_CHAIN_SELECTOR = IJBCCIPSuckerDeployer(deployer).ccipRemoteChainSelector();
|
|
98
|
+
|
|
99
|
+
// Read the CCIP router from the deployer.
|
|
80
100
|
CCIP_ROUTER = IJBCCIPSuckerDeployer(deployer).ccipRouter();
|
|
81
101
|
|
|
102
|
+
// Ensure the CCIP router is not the zero address.
|
|
82
103
|
if (address(CCIP_ROUTER) == address(0)) revert JBCCIPSucker_InvalidRouter(address(CCIP_ROUTER));
|
|
83
104
|
}
|
|
84
105
|
|
|
@@ -87,9 +108,8 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
|
|
|
87
108
|
//*********************************************************************//
|
|
88
109
|
|
|
89
110
|
/// @notice Returns the chain on which the peer is located.
|
|
90
|
-
/// @return chainId of the peer.
|
|
111
|
+
/// @return chainId The chain ID of the peer.
|
|
91
112
|
function peerChainId() external view virtual override returns (uint256 chainId) {
|
|
92
|
-
// Return the remote chain id
|
|
93
113
|
return REMOTE_CHAIN_ID;
|
|
94
114
|
}
|
|
95
115
|
|
|
@@ -97,23 +117,22 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
|
|
|
97
117
|
// ------------------------- public views ---------------------------- //
|
|
98
118
|
//*********************************************************************//
|
|
99
119
|
|
|
100
|
-
/// @notice
|
|
101
|
-
/// @return CCIP router address
|
|
102
|
-
function getRouter() public view returns (address) {
|
|
120
|
+
/// @notice Returns the address of the current CCIP router.
|
|
121
|
+
/// @return router The CCIP router address.
|
|
122
|
+
function getRouter() public view returns (address router) {
|
|
103
123
|
return address(CCIP_ROUTER);
|
|
104
124
|
}
|
|
105
125
|
|
|
106
|
-
/// @notice
|
|
107
|
-
/// @param interfaceId The
|
|
108
|
-
/// @return
|
|
109
|
-
/// @dev Should indicate whether the contract implements IAny2EVMMessageReceiver
|
|
110
|
-
/// e.g. return interfaceId == type(IAny2EVMMessageReceiver).interfaceId || interfaceId == type(IERC165).interfaceId
|
|
126
|
+
/// @notice Checks whether this contract supports a given interface.
|
|
127
|
+
/// @param interfaceId The interface ID to check.
|
|
128
|
+
/// @return supported Whether the interface is supported.
|
|
129
|
+
/// @dev Should indicate whether the contract implements IAny2EVMMessageReceiver.
|
|
111
130
|
/// This allows CCIP to check if ccipReceive is available before calling it.
|
|
112
131
|
/// If this returns false or reverts, only tokens are transferred to the receiver.
|
|
113
132
|
/// If this returns true, tokens are transferred and ccipReceive is called atomically.
|
|
114
133
|
/// Additionally, if the receiver address does not have code associated with
|
|
115
134
|
/// it at the time of execution (EXTCODESIZE returns 0), only tokens will be transferred.
|
|
116
|
-
function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
|
|
135
|
+
function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool supported) {
|
|
117
136
|
return interfaceId == type(IAny2EVMMessageReceiver).interfaceId || super.supportsInterface(interfaceId);
|
|
118
137
|
}
|
|
119
138
|
|
|
@@ -128,70 +147,40 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
|
|
|
128
147
|
/// reverting on invalid sender/peer data is correct here because accepting and silently discarding a
|
|
129
148
|
/// malformed message would lose the bridged tokens with no recovery path. A revert keeps tokens in the
|
|
130
149
|
/// CCIP router where they can be retried or recovered.
|
|
131
|
-
function ccipReceive(Client.Any2EVMMessage calldata any2EvmMessage) external override {
|
|
150
|
+
function ccipReceive(Client.Any2EVMMessage calldata any2EvmMessage) external virtual override {
|
|
132
151
|
// Use msg.sender (not _msgSender()) because the CCIP router never uses ERC2771 meta-transactions.
|
|
133
152
|
// Using _msgSender() would allow a trusted forwarder to spoof the router address via the
|
|
134
153
|
// ERC-2771 calldata suffix.
|
|
135
154
|
if (msg.sender != address(CCIP_ROUTER)) revert JBSucker_NotPeer(_toBytes32(msg.sender));
|
|
136
155
|
|
|
137
|
-
// Decode the
|
|
138
|
-
JBMessageRoot memory root = abi.decode(any2EvmMessage.data, (JBMessageRoot));
|
|
156
|
+
// Decode the sender address from the CCIP message.
|
|
139
157
|
address origin = abi.decode(any2EvmMessage.sender, (address));
|
|
140
158
|
|
|
141
159
|
// Make sure that the message came from our peer.
|
|
142
|
-
if (origin !=
|
|
160
|
+
if (origin != _peerAddress() || any2EvmMessage.sourceChainSelector != REMOTE_CHAIN_SELECTOR) {
|
|
143
161
|
revert JBSucker_NotPeer(_toBytes32(origin));
|
|
144
162
|
}
|
|
145
163
|
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
// If CCIP's guarantee fails, the bridge itself is compromised.
|
|
149
|
-
//
|
|
150
|
-
// We intentionally do NOT validate root.amount against destTokenAmounts[0].amount here.
|
|
151
|
-
// CCIP fees are paid separately (via feeToken), so delivered amounts should always match what was sent.
|
|
152
|
-
// If we reverted on a mismatch, the tokens already transferred by CCIP would be locked in the router
|
|
153
|
-
// with no recovery path — a concrete fund-loss risk that outweighs the theoretical defense-in-depth
|
|
154
|
-
// benefit against a CCIP-level failure or peer compromise.
|
|
155
|
-
|
|
156
|
-
// We either send no tokens or a single token.
|
|
157
|
-
if (any2EvmMessage.destTokenAmounts.length == 1) {
|
|
158
|
-
// The sucker only handles ERC-20s or native. CCIP delivers wrapped native (WETH).
|
|
159
|
-
Client.EVMTokenAmount memory tokenAmount = any2EvmMessage.destTokenAmounts[0];
|
|
160
|
-
// Unwrap WETH -> ETH only when the root says the token is NATIVE_TOKEN.
|
|
161
|
-
// When root.token is an ERC-20 address (e.g., bridging to a chain where ETH is an ERC-20), no unwrap.
|
|
162
|
-
if (root.token == _toBytes32(JBConstants.NATIVE_TOKEN)) {
|
|
163
|
-
// We can (safely) assume that the token that is set in the `destTokenAmounts` is a valid wrapped
|
|
164
|
-
// native.
|
|
165
|
-
// If this ends up not being the case then our sanity check to see if we unwrapped the native asset will
|
|
166
|
-
// fail.
|
|
167
|
-
// forge-lint: disable-next-line(mixed-case-variable)
|
|
168
|
-
IWrappedNativeToken wrapped_native = IWrappedNativeToken(tokenAmount.token);
|
|
169
|
-
uint256 balanceBefore = _balanceOf({token: JBConstants.NATIVE_TOKEN, addr: address(this)});
|
|
170
|
-
|
|
171
|
-
// Withdraw the wrapped native asset.
|
|
172
|
-
wrapped_native.withdraw(tokenAmount.amount);
|
|
173
|
-
|
|
174
|
-
// Sanity check the unwrapping of the native asset.
|
|
175
|
-
// slither-disable-next-line incorrect-equality
|
|
176
|
-
assert(
|
|
177
|
-
balanceBefore + tokenAmount.amount
|
|
178
|
-
== _balanceOf({token: JBConstants.NATIVE_TOKEN, addr: address(this)})
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
164
|
+
// Discriminate message type: abi.encode(uint8 type, bytes payload).
|
|
165
|
+
(uint8 messageType, bytes memory payload) = JBCCIPLib.decodeTypedMessage(any2EvmMessage.data);
|
|
182
166
|
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
167
|
+
// Handle root messages (merkle tree updates with bridged assets).
|
|
168
|
+
if (messageType == _CCIP_MSG_TYPE_ROOT) {
|
|
169
|
+
// Decode the root message from the payload.
|
|
170
|
+
JBMessageRoot memory root = abi.decode(payload, (JBMessageRoot));
|
|
186
171
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
172
|
+
// Only unwrap WETH -> ETH when the root targets native token (not when claiming WETH as ERC-20).
|
|
173
|
+
if (root.token == bytes32(uint256(uint160(JBConstants.NATIVE_TOKEN)))) {
|
|
174
|
+
JBCCIPLib.unwrapReceivedTokens({
|
|
175
|
+
ccipRouter: CCIP_ROUTER, destTokenAmounts: any2EvmMessage.destTokenAmounts
|
|
176
|
+
});
|
|
177
|
+
}
|
|
190
178
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
179
|
+
// Forward the root message to this contract's fromRemote handler.
|
|
180
|
+
this.fromRemote(root);
|
|
181
|
+
} else {
|
|
182
|
+
revert JBCCIPSucker_UnknownMessageType(messageType);
|
|
183
|
+
}
|
|
195
184
|
}
|
|
196
185
|
|
|
197
186
|
//*********************************************************************//
|
|
@@ -199,14 +188,16 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
|
|
|
199
188
|
//*********************************************************************//
|
|
200
189
|
|
|
201
190
|
/// @notice Uses CCIP to send the root and assets over the bridge to the peer.
|
|
202
|
-
/// @dev CCIP
|
|
203
|
-
///
|
|
204
|
-
///
|
|
205
|
-
///
|
|
206
|
-
///
|
|
207
|
-
/// @param transportPayment
|
|
191
|
+
/// @dev Delegates CCIP message construction and sending to JBCCIPLib (via DELEGATECALL) to reduce bytecode.
|
|
192
|
+
/// @dev Supports two fee modes:
|
|
193
|
+
/// - `transportPayment > 0`: pay CCIP fees in native ETH (existing behavior).
|
|
194
|
+
/// - `transportPayment == 0`: pay CCIP fees in LINK from the sucker's pre-funded balance.
|
|
195
|
+
/// This enables chains with no meaningful native token (e.g. Tempo) to use CCIP.
|
|
196
|
+
/// @param transportPayment The amount of `msg.value` that is going to get paid for sending this message.
|
|
208
197
|
/// @param token The token to bridge the outbox tree for.
|
|
198
|
+
/// @param amount The amount of tokens to bridge.
|
|
209
199
|
/// @param remoteToken Information about the remote token being bridged to.
|
|
200
|
+
/// @param sucker_message The message root to send to the remote peer.
|
|
210
201
|
// forge-lint: disable-next-line(mixed-case-function)
|
|
211
202
|
function _sendRootOverAMB(
|
|
212
203
|
uint256 transportPayment,
|
|
@@ -218,88 +209,64 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
|
|
|
218
209
|
JBMessageRoot memory sucker_message
|
|
219
210
|
)
|
|
220
211
|
internal
|
|
212
|
+
virtual
|
|
221
213
|
override
|
|
222
214
|
{
|
|
223
|
-
//
|
|
224
|
-
if (transportPayment == 0) revert JBSucker_ExpectedMsgValue();
|
|
225
|
-
|
|
215
|
+
// Start with the base gas limit for cross-chain calls.
|
|
226
216
|
uint256 gasLimit = MESSENGER_BASE_GAS_LIMIT;
|
|
227
217
|
Client.EVMTokenAmount[] memory tokenAmounts;
|
|
218
|
+
|
|
228
219
|
if (amount != 0) {
|
|
229
|
-
//
|
|
220
|
+
// Add extra gas for the ERC-20 token transfer on the remote chain.
|
|
230
221
|
gasLimit += remoteToken.minGas;
|
|
231
222
|
|
|
232
|
-
// Wrap native ETH -> WETH
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
IWrappedNativeToken wrapped_native = CCIP_ROUTER.getWrappedNative();
|
|
239
|
-
// Deposit the wrapped native asset.
|
|
240
|
-
// slither-disable-next-line calls-loop,arbitrary-send-eth
|
|
241
|
-
wrapped_native.deposit{value: amount}();
|
|
242
|
-
// Update the token to be the wrapped native asset.
|
|
243
|
-
token = address(wrapped_native);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Set the token amounts
|
|
247
|
-
tokenAmounts = new Client.EVMTokenAmount[](1);
|
|
248
|
-
tokenAmounts[0] = Client.EVMTokenAmount({token: token, amount: amount});
|
|
249
|
-
|
|
250
|
-
// approve the Router to spend tokens on contract's behalf. It will spend the amount of the given token
|
|
251
|
-
SafeERC20.forceApprove({token: IERC20(token), spender: address(CCIP_ROUTER), value: amount});
|
|
223
|
+
// Wrap native ETH -> WETH if needed, build the CCIP token amounts array, and approve the router.
|
|
224
|
+
// slither-disable-next-line unused-return
|
|
225
|
+
(tokenAmounts,) = JBCCIPLib.prepareTokenAmounts({ccipRouter: CCIP_ROUTER, token: token, amount: amount});
|
|
226
|
+
} else {
|
|
227
|
+
// No tokens to bridge — use an empty array.
|
|
228
|
+
tokenAmounts = new Client.EVMTokenAmount[](0);
|
|
252
229
|
}
|
|
253
230
|
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
231
|
+
// Determine fee payment mode: native ETH or LINK token.
|
|
232
|
+
// When transportPayment == 0, we pay in LINK from the sucker's pre-funded balance.
|
|
233
|
+
// This enables chains with no meaningful native token (e.g. Tempo).
|
|
234
|
+
address feeToken = transportPayment == 0 ? CCIPHelper.linkOfChain(block.chainid) : address(0);
|
|
235
|
+
|
|
236
|
+
// Build and send the CCIP message with the root payload.
|
|
237
|
+
// slither-disable-next-line reentrancy-events
|
|
238
|
+
(bool refundFailed, uint256 refundAmount) = JBCCIPLib.sendCCIPMessage({
|
|
239
|
+
ccipRouter: CCIP_ROUTER,
|
|
240
|
+
remoteChainSelector: REMOTE_CHAIN_SELECTOR,
|
|
241
|
+
peerAddress: _peerAddress(),
|
|
242
|
+
transportPayment: transportPayment,
|
|
243
|
+
feeToken: feeToken,
|
|
244
|
+
gasLimit: gasLimit,
|
|
245
|
+
encodedPayload: abi.encode(_CCIP_MSG_TYPE_ROOT, abi.encode(sucker_message)),
|
|
259
246
|
tokenAmounts: tokenAmounts,
|
|
260
|
-
|
|
261
|
-
// Additional arguments, setting gas limit
|
|
262
|
-
Client.EVMExtraArgsV1({gasLimit: gasLimit})
|
|
263
|
-
),
|
|
264
|
-
// Set the feeToken to a feeTokenAddress, indicating specific asset will be used for fees,
|
|
265
|
-
// We pay in the native asset.
|
|
266
|
-
feeToken: address(0)
|
|
247
|
+
refundRecipient: _msgSender()
|
|
267
248
|
});
|
|
268
249
|
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
250
|
+
// Emit an event if the excess transport payment refund failed.
|
|
251
|
+
if (refundFailed) emit TransportPaymentRefundFailed(_msgSender(), refundAmount);
|
|
252
|
+
}
|
|
272
253
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
254
|
+
//*********************************************************************//
|
|
255
|
+
// ------------------------ internal views --------------------------- //
|
|
256
|
+
//*********************************************************************//
|
|
276
257
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
// `
|
|
282
|
-
|
|
283
|
-
// would roll back — but the CCIP message is already in-flight. The tokens would be gone, the
|
|
284
|
-
// merkle root never gets processed, and the outbox state is inconsistent.
|
|
285
|
-
//
|
|
286
|
-
// If the refund fails, the ETH (transportPayment - fees) will be permanently stuck in this
|
|
287
|
-
// contract. There is no sweep or recovery function — `_addToBalance` only
|
|
288
|
-
// moves funds tracked via `fromRemote`, not arbitrary ETH. This is an accepted tradeoff:
|
|
289
|
-
// stuck dust from a fee overpayment is far less harmful than bricking the entire bridge
|
|
290
|
-
// operation. The event provides observability so it doesn't go unnoticed.
|
|
291
|
-
//
|
|
292
|
-
uint256 refundAmount = transportPayment - fees;
|
|
293
|
-
if (refundAmount != 0) {
|
|
294
|
-
// slither-disable-next-line calls-loop,msg-value-loop,reentrancy-events
|
|
295
|
-
(bool sent,) = _msgSender().call{value: refundAmount}("");
|
|
296
|
-
if (!sent) emit TransportPaymentRefundFailed(_msgSender(), refundAmount);
|
|
297
|
-
}
|
|
258
|
+
/// @notice Checks whether the given sender is a remote peer. Unused in this context.
|
|
259
|
+
/// @param sender The address to check.
|
|
260
|
+
/// @return _valid Whether the sender is a remote peer.
|
|
261
|
+
function _isRemotePeer(address sender) internal view override returns (bool _valid) {
|
|
262
|
+
// We do not check if it is the `peer` here, as this contract is supposed to be the caller *NOT* the peer.
|
|
263
|
+
return sender == address(this);
|
|
298
264
|
}
|
|
299
265
|
|
|
300
|
-
/// @notice
|
|
266
|
+
/// @notice Validates a token mapping. Allows CCIP-specific mapping rules.
|
|
301
267
|
/// @dev Unlike OP/Arbitrum suckers (which share ETH as native on both chains), this CCIP sucker can connect
|
|
302
268
|
/// chains with different native tokens. This means `NATIVE_TOKEN` may map to an ERC-20 on the remote chain.
|
|
269
|
+
/// @param map The token mapping to validate.
|
|
303
270
|
///
|
|
304
271
|
/// Example: ETH mainnet (native = ETH) <-> Celo (native = CELO, ETH is an ERC-20).
|
|
305
272
|
/// - On mainnet: `mapToken({localToken: NATIVE_TOKEN, remoteToken: celoETH_address})`
|
package/src/JBOptimismSucker.sol
CHANGED
|
@@ -7,7 +7,6 @@ import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
|
|
|
7
7
|
import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
8
8
|
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
9
9
|
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
|
|
10
|
-
import {BitMaps} from "@openzeppelin/contracts/utils/structs/BitMaps.sol";
|
|
11
10
|
|
|
12
11
|
import {JBSucker} from "./JBSucker.sol";
|
|
13
12
|
import {JBOptimismSuckerDeployer} from "./deployers/JBOptimismSuckerDeployer.sol";
|
|
@@ -17,13 +16,9 @@ import {IOPMessenger} from "./interfaces/IOPMessenger.sol";
|
|
|
17
16
|
import {IOPStandardBridge} from "./interfaces/IOPStandardBridge.sol";
|
|
18
17
|
import {JBMessageRoot} from "./structs/JBMessageRoot.sol";
|
|
19
18
|
import {JBRemoteToken} from "./structs/JBRemoteToken.sol";
|
|
20
|
-
import {MerkleLib} from "./utils/MerkleLib.sol";
|
|
21
19
|
|
|
22
20
|
/// @notice A `JBSucker` implementation to suck tokens between two chains connected by an OP Bridge.
|
|
23
21
|
contract JBOptimismSucker is JBSucker, IJBOptimismSucker {
|
|
24
|
-
using BitMaps for BitMaps.BitMap;
|
|
25
|
-
using MerkleLib for MerkleLib.Tree;
|
|
26
|
-
|
|
27
22
|
//*********************************************************************//
|
|
28
23
|
// --------------- public immutable stored properties ---------------- //
|
|
29
24
|
//*********************************************************************//
|