@bananapus/suckers-v6 0.0.27 → 0.0.28
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 +14 -2
- package/package.json +1 -1
- package/src/JBArbitrumSucker.sol +21 -6
- package/src/JBCCIPSucker.sol +4 -2
- package/src/JBSucker.sol +4 -1
- package/src/JBSuckerRegistry.sol +132 -12
- package/src/JBSwapCCIPSucker.sol +29 -5
- package/src/interfaces/IL1ArbitrumGateway.sol +23 -0
- package/src/libraries/JBCCIPLib.sol +19 -6
- package/test/ForkClaimMainnet.t.sol +5 -2
- package/test/ForkMainnet.t.sol +9 -8
- package/test/ForkSwapMainnet.t.sol +10 -8
- package/test/SuckerAttacks.t.sol +1 -1
- package/test/SuckerRegressions.t.sol +1 -1
- package/test/audit/CertikAIScan.t.sol +352 -0
- package/test/audit/DeprecatedSuckerAggregateViews.t.sol +247 -0
- package/test/audit/ZeroOutputSwapPending.t.sol +218 -0
- package/test/audit/codex-nemesis-DeprecatedRemovalUndercount.t.sol +141 -0
- package/test/audit/codex-nemesis-SwapZeroLocalTotalUnbackedClaim.t.sol +179 -0
- package/test/unit/deployer.t.sol +9 -8
- package/test/unit/emergency.t.sol +2 -2
- package/test/unit/multi_chain_evolution.t.sol +2 -2
package/RISKS.md
CHANGED
|
@@ -24,6 +24,7 @@ This file focuses on the bridge-like risks in the sucker system: merkle-root pro
|
|
|
24
24
|
- **CCIP:** trusts `CCIP_ROUTER` identity plus `any2EvmMessage.sender` and `sourceChainSelector`. The router address is immutable at deploy time -- if Chainlink rotates routers, the sucker is bricked (no upgrade path).
|
|
25
25
|
- **CREATE2 peer assumption.** `peer()` defaults to `address(this)`, assuming deterministic cross-chain deployment. Breaks if deployer address, init code, or factory nonce differs across chains. Incorrect peer = permanent fund loss (messages accepted from nobody, or routed to wrong address).
|
|
26
26
|
- **Controller/terminal must exist on destination chain.** `_handleClaim` calls `controllerOf(projectId).mintTokensOf()`. If the project does not exist or has no controller on the remote chain, all claims permanently revert -- funds are stuck.
|
|
27
|
+
- **Registry allowlisting does not verify deployer singleton provenance.** `JBSuckerRegistry.deploySuckersFor()` only checks the deployer allowlist, not the singleton implementation behind the deployer. `configureSingleton()` can point an approved deployer at any JBSucker singleton. This is a privileged-only concern (both deployer configuration and registry allowlisting require governance). Defense: validate deployer configuration during registry allowlist reviews.
|
|
27
28
|
- **No reentrancy guard.** The contract relies on state ordering (mark-executed-before-external-call) rather than explicit ReentrancyGuard. Correct today, but fragile to future refactors.
|
|
28
29
|
|
|
29
30
|
## 2. Merkle Tree Risks
|
|
@@ -41,6 +42,7 @@ This file focuses on the bridge-like risks in the sucker system: merkle-root pro
|
|
|
41
42
|
- **CCIP: no guaranteed delivery order.** CCIP does not guarantee in-order delivery. The contract handles this by accepting any nonce > current, but concurrent `toRemote` calls for the same token could result in a later root arriving first, skipping an intermediate root.
|
|
42
43
|
- **OP Stack: generally ordered** but the L1-to-L2 message must be relayed by an off-chain actor. A delayed or dropped relay permanently blocks claims until someone retries the relay.
|
|
43
44
|
- **Message loss.** If an AMB silently fails (accepts the message on L1 but never delivers to L2), `numberOfClaimsSent` is incremented but the remote peer never receives the root. Those leaves are blocked from both remote claim and local emergency exit (conservative: locked, not double-spent). Recovery requires enabling the emergency hatch.
|
|
45
|
+
- **Aggregate balance accounting.** `amountToAddToBalanceOf(token)` computes `balanceOf(this) - outbox.balance`, making all contract-held tokens fungible claim backing. This is intentional: all funds serve the same project, so refunded ETH (from failed Arbitrum retryable tickets) and stale native deliveries correctly become project-claimable. The tradeoff is that later roots can consume liquidity from earlier failed batches, but the project is the ultimate beneficiary in all cases.
|
|
44
46
|
- **`fromRemote` does not revert on stale nonce.** By design, stale/duplicate messages are silently ignored (emitting `StaleRootRejected`). This prevents fund loss on native token transfers where reverting would lose the ETH, but means monitoring must watch for this event to detect bridge issues.
|
|
45
47
|
|
|
46
48
|
## 4. Token Mapping Risks
|
|
@@ -62,7 +64,7 @@ This file focuses on the bridge-like risks in the sucker system: merkle-root pro
|
|
|
62
64
|
- **Renounced registry ownership risk.** If the registry owner calls `renounceOwnership()`, `setToRemoteFee()` becomes permanently uncallable and the fee is frozen at its current value across all suckers. This is a deliberate trade-off: it allows the registry owner to credibly commit to a fee level, but eliminates the ability to respond to future ETH price changes. The fee is still capped at `MAX_TO_REMOTE_FEE`, so the maximum downside is bounded.
|
|
63
65
|
- **Immutable fee project.** `FEE_PROJECT_ID` is set at construction and cannot be changed. If the fee project is abandoned or its terminal removed, there is no way to redirect fees without deploying new suckers.
|
|
64
66
|
- **Cross-reference: sucker registration path.** Suckers are deployed via `JBSuckerRegistry.deploySuckersFor`, which requires `DEPLOY_SUCKERS` permission from the project owner. The registry's `deploy` function uses `CREATE2` with a deployer-specific salt. The sucker's `peer()` address is deterministic — a misconfigured peer means the sucker accepts messages from the wrong remote address. See [nana-omnichain-deployers-v6 RISKS.md](../nana-omnichain-deployers-v6/RISKS.md) for deployer-level risks.
|
|
65
|
-
- **Registry aggregate views fail open.** `JBSuckerRegistry.remoteBalanceOf`, `remoteSurplusOf`, and `remoteTotalSupplyOf`
|
|
67
|
+
- **Registry aggregate views fail open.** `JBSuckerRegistry.remoteBalanceOf`, `remoteSurplusOf`, and `remoteTotalSupplyOf` include both active and deprecated suckers with per-chain deduplication (max per chain, not sum). When multiple suckers target the same peer chain (e.g., during migration), the highest reported value is used. The views silently skip any sucker that reverts. This preserves liveness for dashboards and cross-chain estimates, but it means the returned aggregate can understate remote state whenever one peer is broken, censored, or simply expensive to query.
|
|
66
68
|
|
|
67
69
|
## 6. Deprecation Lifecycle
|
|
68
70
|
|
|
@@ -83,6 +85,8 @@ This file focuses on the bridge-like risks in the sucker system: merkle-root pro
|
|
|
83
85
|
- **Claim vs emergency exit use separate bitmap slots.** Emergency exit uses `_executedFor[keccak256(abi.encode(terminalToken))]` while regular claims use `_executedFor[terminalToken]`. This means a leaf that was emergency-exited locally could theoretically also be claimed remotely if the root was already sent -- double-spend is prevented only by the `numberOfClaimsSent` check.
|
|
84
86
|
- **`numberOfClaimsSent` is the critical guard.** Emergency exit reverts if `outbox.numberOfClaimsSent != 0 && outbox.numberOfClaimsSent - 1 >= index`. The `numberOfClaimsSent != 0` precondition prevents underflow when no root has ever been sent — in that case, all leaves are available for emergency exit. This means leaves at indices below `numberOfClaimsSent` cannot be emergency-exited (they may have been sent to the remote peer). If `_sendRootOverAMB` silently fails, these leaves are permanently locked.
|
|
85
87
|
- **Emergency exit decrements `outbox.balance`.** If emergency exits drain the outbox balance below the amount that was already sent to the bridge, the accounting becomes inconsistent. The contract guards against this by only allowing exit for unsent leaves.
|
|
88
|
+
- **Emergency exit recipient is the leaf beneficiary.** `exitThroughEmergencyHatch()` refunds to `claimData.leaf.beneficiary`, not the original `prepare()` caller. The depositor chose this beneficiary when preparing the bridge; the leaf structure does not store the depositor address. If Alice prepares for Bob and the bridge fails, Bob gets the emergency refund, not Alice. This is the intended behavior: the depositor delegated their claim to the beneficiary.
|
|
89
|
+
- **`numberOfClaimsSent` advancement timing.** `_sendRoot()` sets `numberOfClaimsSent` before `_sendRootOverAMB()` completes. If the L1 transaction succeeds but L2 delivery fails, those leaves are blocked from emergency exit. Mitigations: Arbitrum retryable tickets can be manually re-executed on L2; Optimism messages can be re-relayed by anyone. If delivery permanently fails, `enableEmergencyHatchFor()` combined with project owner intervention can recover. Adding a rollback mechanism would introduce double-spend risk (leaf claimable on both chains). Current design is conservative: locked funds are preferable to double-spent funds.
|
|
86
90
|
- **Emergency hatch + minting.** Emergency exit calls `_handleClaim`, which mints project tokens via the controller. If the controller or token contract is broken/missing, emergency exits also revert -- there is no "raw withdrawal" of terminal tokens without minting.
|
|
87
91
|
|
|
88
92
|
## 8. DoS Vectors
|
|
@@ -130,7 +134,11 @@ The fee is paid to `FEE_PROJECT_ID` (the protocol project), not to the sucker's
|
|
|
130
134
|
|
|
131
135
|
### 10.5 Registry aggregate views prioritize liveness over completeness
|
|
132
136
|
|
|
133
|
-
`JBSuckerRegistry.remoteBalanceOf`, `remoteSurplusOf`, and `remoteTotalSupplyOf` intentionally use `try/catch` around each sucker and silently ignore peers that revert. This is accepted because a single bad peer should not brick every cross-chain dashboard or estimator. The trade-off is that these read surfaces are best-effort only: consumers must treat them as lower bounds, not exact reconciled totals, unless they independently verify that every active sucker responded successfully.
|
|
137
|
+
`JBSuckerRegistry.remoteBalanceOf`, `remoteSurplusOf`, and `remoteTotalSupplyOf` intentionally use `try/catch` around each sucker and silently ignore peers that revert. Both active and deprecated suckers are included, with per-chain deduplication: when multiple suckers target the same peer chain (e.g., during a migration window), the highest reported value is used to avoid double-counting. This is accepted because a single bad peer should not brick every cross-chain dashboard or estimator. The trade-off is that these read surfaces are best-effort only: consumers must treat them as lower bounds, not exact reconciled totals, unless they independently verify that every active sucker responded successfully.
|
|
138
|
+
|
|
139
|
+
### 10.8 Zero-output swap batches route to pendingSwapOf
|
|
140
|
+
|
|
141
|
+
When `JBSwapCCIPSucker.ccipReceive` receives bridge tokens and the swap succeeds but returns zero local tokens (e.g., due to extreme price impact or dust amounts), the batch is routed to `pendingSwapOf` for later retry via `retrySwap`. Without this, claims would proceed with zero terminal backing, minting unbacked project tokens. The trade-off is that these batches require a manual `retrySwap` call once pool conditions improve. Anyone can call `retrySwap` — it is permissionless.
|
|
134
142
|
|
|
135
143
|
### 10.6 Hookless V4 spot pricing is sandwich-vulnerable by design
|
|
136
144
|
|
|
@@ -139,3 +147,7 @@ When the only available Uniswap pool for a cross-denomination swap is a hookless
|
|
|
139
147
|
### 10.7 `mapTokens` refunds ETH on enable-only batches
|
|
140
148
|
|
|
141
149
|
`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.
|
|
150
|
+
|
|
151
|
+
### 10.9 Zero-value `prepare()` is allowed
|
|
152
|
+
|
|
153
|
+
`prepare()` does not reject `projectTokenCount == 0`. A zero-value check would be trivially bypassed by passing `1` instead, so it provides no real protection against remap-window consumption. The cost to create a leaf with `projectTokenCount = 1` is negligible (1 wei of project tokens). The one-time remap window is protected by the token mapping's `enabled` flag and the outbox tree count, not by minimum deposit requirements.
|
package/package.json
CHANGED
package/src/JBArbitrumSucker.sol
CHANGED
|
@@ -20,6 +20,7 @@ import {JBLayer} from "./enums/JBLayer.sol";
|
|
|
20
20
|
import {IArbGatewayRouter} from "./interfaces/IArbGatewayRouter.sol";
|
|
21
21
|
import {IArbL1GatewayRouter} from "./interfaces/IArbL1GatewayRouter.sol";
|
|
22
22
|
import {IArbL2GatewayRouter} from "./interfaces/IArbL2GatewayRouter.sol";
|
|
23
|
+
import {IL1ArbitrumGateway} from "./interfaces/IL1ArbitrumGateway.sol";
|
|
23
24
|
import {IJBArbitrumSucker} from "./interfaces/IJBArbitrumSucker.sol";
|
|
24
25
|
import {ARBChains} from "./libraries/ARBChains.sol";
|
|
25
26
|
import {JBMessageRoot} from "./structs/JBMessageRoot.sol";
|
|
@@ -256,12 +257,26 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
|
|
|
256
257
|
// If the token is an ERC-20, bridge it to the peer.
|
|
257
258
|
// If the amount is `0` then we do not need to bridge any ERC20.
|
|
258
259
|
if (token != JBConstants.NATIVE_TOKEN && amount != 0) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
260
|
+
uint256 tokenTransportCost;
|
|
261
|
+
uint256 maxSubmissionCostERC20;
|
|
262
|
+
{
|
|
263
|
+
// Get the exact calldata length the gateway will create for the retryable ticket.
|
|
264
|
+
// The Arbitrum Inbox validates maxSubmissionCost against this actual payload, not the user data.
|
|
265
|
+
// slither-disable-next-line calls-loop
|
|
266
|
+
address gateway = GATEWAYROUTER.getGateway(token);
|
|
267
|
+
// slither-disable-next-line calls-loop
|
|
268
|
+
uint256 outboundCalldataLength =
|
|
269
|
+
IL1ArbitrumGateway(gateway)
|
|
270
|
+
.getOutboundCalldata({
|
|
271
|
+
_token: token, _from: address(this), _to: _peerAddress(), _amount: amount, _data: bytes("")
|
|
272
|
+
})
|
|
273
|
+
.length;
|
|
274
|
+
// slither-disable-next-line calls-loop
|
|
275
|
+
maxSubmissionCostERC20 = ARBINBOX.calculateRetryableSubmissionFee({
|
|
276
|
+
dataLength: outboundCalldataLength, baseFee: maxFeePerGas
|
|
277
|
+
});
|
|
278
|
+
tokenTransportCost = maxSubmissionCostERC20 + (remoteToken.minGas * maxFeePerGas);
|
|
279
|
+
}
|
|
265
280
|
|
|
266
281
|
// Ensure we bridge enough for gas costs on L2 side
|
|
267
282
|
if (transportPayment < callTransportCost + tokenTransportCost) {
|
package/src/JBCCIPSucker.sol
CHANGED
|
@@ -228,8 +228,9 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
|
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
// Determine fee payment mode: native ETH or LINK token.
|
|
231
|
-
// When transportPayment == 0, we pay in LINK from the
|
|
232
|
-
// This enables chains with no meaningful native token (e.g. Tempo)
|
|
231
|
+
// When transportPayment == 0, we pay in LINK pulled from the caller via transferFrom.
|
|
232
|
+
// This enables chains with no meaningful native token (e.g. Tempo) while keeping
|
|
233
|
+
// toRemote permissionless — the caller provides LINK inline with their bridge intent.
|
|
233
234
|
address feeToken = transportPayment == 0 ? CCIPHelper.linkOfChain(block.chainid) : address(0);
|
|
234
235
|
|
|
235
236
|
// Build and send the CCIP message with the root payload.
|
|
@@ -240,6 +241,7 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
|
|
|
240
241
|
peerAddress: _peerAddress(),
|
|
241
242
|
transportPayment: transportPayment,
|
|
242
243
|
feeToken: feeToken,
|
|
244
|
+
feeTokenPayer: feeToken != address(0) ? _msgSender() : address(0),
|
|
243
245
|
gasLimit: gasLimit,
|
|
244
246
|
encodedPayload: abi.encode(_CCIP_MSG_TYPE_ROOT, abi.encode(sucker_message)),
|
|
245
247
|
tokenAmounts: tokenAmounts,
|
package/src/JBSucker.sol
CHANGED
|
@@ -561,7 +561,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
561
561
|
uint256 nextEarliestDeprecationTime = block.timestamp + _maxMessagingDelay();
|
|
562
562
|
|
|
563
563
|
// The deprecation can be entirely disabled *or* it has to be later than the earliest possible time.
|
|
564
|
-
if (timestamp != 0 && timestamp
|
|
564
|
+
if (timestamp != 0 && timestamp <= nextEarliestDeprecationTime) {
|
|
565
565
|
revert JBSucker_DeprecationTimestampTooSoon({
|
|
566
566
|
givenTime: timestamp, minimumTime: nextEarliestDeprecationTime
|
|
567
567
|
});
|
|
@@ -982,6 +982,9 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
982
982
|
// (e.g., the bridge is non-functional), the project owner can call `enableEmergencyHatchFor` to allow
|
|
983
983
|
// local withdrawals via `exitThroughEmergencyHatch`.
|
|
984
984
|
if (map.remoteToken == bytes32(0) && _outboxOf[token].numberOfClaimsSent != _outboxOf[token].tree.count) {
|
|
985
|
+
// Disable before external call to prevent reentrancy via prepare().
|
|
986
|
+
// _sendRoot uses the `currentMapping` parameter, not storage, so this is safe.
|
|
987
|
+
_remoteTokenFor[token].enabled = false;
|
|
985
988
|
_sendRoot({transportPayment: transportPaymentValue, token: token, remoteToken: currentMapping});
|
|
986
989
|
}
|
|
987
990
|
|
package/src/JBSuckerRegistry.sol
CHANGED
|
@@ -191,7 +191,9 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
/// @notice The cumulative balance across all remote peer chains for a project, denominated in a given currency.
|
|
194
|
-
/// @dev
|
|
194
|
+
/// @dev Includes both active and deprecated suckers to prevent undercounting during migration windows.
|
|
195
|
+
/// Uses per-chain deduplication (max per chain, not sum) to prevent double-counting when both a deprecated and
|
|
196
|
+
/// active sucker exist for the same chain. Silently skips suckers that revert.
|
|
195
197
|
/// @param projectId The ID of the project.
|
|
196
198
|
/// @param decimals The decimal precision for the returned value.
|
|
197
199
|
/// @param currency The currency to normalize to.
|
|
@@ -207,25 +209,65 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
|
|
|
207
209
|
returns (uint256 balance)
|
|
208
210
|
{
|
|
209
211
|
address[] memory allSuckers = _suckersOf[projectId].keys();
|
|
210
|
-
|
|
212
|
+
uint256 len = allSuckers.length;
|
|
213
|
+
|
|
214
|
+
// Per-chain dedup arrays. The number of suckers per project is small (typically 1-5),
|
|
215
|
+
// so a linear scan is cheaper than a mapping.
|
|
216
|
+
uint256[] memory chainIds = new uint256[](len);
|
|
217
|
+
uint256[] memory maxBalances = new uint256[](len);
|
|
218
|
+
uint256 chainCount;
|
|
219
|
+
|
|
220
|
+
for (uint256 i; i < len;) {
|
|
211
221
|
// slither-disable-next-line unused-return
|
|
212
222
|
(, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
|
|
213
|
-
|
|
223
|
+
// Include both active and deprecated suckers in aggregate economic views.
|
|
224
|
+
if (val == _SUCKER_EXISTS || val == _SUCKER_DEPRECATED) {
|
|
214
225
|
// slither-disable-next-line calls-loop
|
|
215
226
|
try IJBSucker(allSuckers[i]).peerChainBalanceOf(decimals, currency) returns (
|
|
216
227
|
JBDenominatedAmount memory amt
|
|
217
228
|
) {
|
|
218
|
-
|
|
229
|
+
// slither-disable-next-line calls-loop
|
|
230
|
+
uint256 chainId = IJBSucker(allSuckers[i]).peerChainId();
|
|
231
|
+
// Per-chain max: if multiple suckers target the same chain (deprecated + active
|
|
232
|
+
// during migration), take the highest value to avoid double-counting.
|
|
233
|
+
bool found;
|
|
234
|
+
for (uint256 j; j < chainCount;) {
|
|
235
|
+
if (chainIds[j] == chainId) {
|
|
236
|
+
if (amt.value > maxBalances[j]) maxBalances[j] = amt.value;
|
|
237
|
+
found = true;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
unchecked {
|
|
241
|
+
++j;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (!found) {
|
|
245
|
+
chainIds[chainCount] = chainId;
|
|
246
|
+
maxBalances[chainCount] = amt.value;
|
|
247
|
+
unchecked {
|
|
248
|
+
++chainCount;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
219
251
|
} catch {}
|
|
220
252
|
}
|
|
221
253
|
unchecked {
|
|
222
254
|
++i;
|
|
223
255
|
}
|
|
224
256
|
}
|
|
257
|
+
|
|
258
|
+
// Sum the per-chain max values.
|
|
259
|
+
for (uint256 k; k < chainCount;) {
|
|
260
|
+
balance += maxBalances[k];
|
|
261
|
+
unchecked {
|
|
262
|
+
++k;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
225
265
|
}
|
|
226
266
|
|
|
227
267
|
/// @notice The cumulative surplus across all remote peer chains for a project, denominated in a given currency.
|
|
228
|
-
/// @dev
|
|
268
|
+
/// @dev Includes both active and deprecated suckers to prevent undercounting during migration windows.
|
|
269
|
+
/// Uses per-chain deduplication (max per chain, not sum) to prevent double-counting when both a deprecated and
|
|
270
|
+
/// active sucker exist for the same chain. Silently skips suckers that revert.
|
|
229
271
|
/// @param projectId The ID of the project.
|
|
230
272
|
/// @param decimals The decimal precision for the returned value.
|
|
231
273
|
/// @param currency The currency to normalize to.
|
|
@@ -241,42 +283,120 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
|
|
|
241
283
|
returns (uint256 surplus)
|
|
242
284
|
{
|
|
243
285
|
address[] memory allSuckers = _suckersOf[projectId].keys();
|
|
244
|
-
|
|
286
|
+
uint256 len = allSuckers.length;
|
|
287
|
+
|
|
288
|
+
// Per-chain dedup arrays. The number of suckers per project is small (typically 1-5),
|
|
289
|
+
// so a linear scan is cheaper than a mapping.
|
|
290
|
+
uint256[] memory chainIds = new uint256[](len);
|
|
291
|
+
uint256[] memory maxSurplus = new uint256[](len);
|
|
292
|
+
uint256 chainCount;
|
|
293
|
+
|
|
294
|
+
for (uint256 i; i < len;) {
|
|
245
295
|
// slither-disable-next-line unused-return
|
|
246
296
|
(, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
|
|
247
|
-
|
|
297
|
+
// Include both active and deprecated suckers in aggregate economic views.
|
|
298
|
+
if (val == _SUCKER_EXISTS || val == _SUCKER_DEPRECATED) {
|
|
248
299
|
// slither-disable-next-line calls-loop
|
|
249
300
|
try IJBSucker(allSuckers[i]).peerChainSurplusOf(decimals, currency) returns (
|
|
250
301
|
JBDenominatedAmount memory amt
|
|
251
302
|
) {
|
|
252
|
-
|
|
303
|
+
// slither-disable-next-line calls-loop
|
|
304
|
+
uint256 chainId = IJBSucker(allSuckers[i]).peerChainId();
|
|
305
|
+
// Per-chain max: if multiple suckers target the same chain (deprecated + active
|
|
306
|
+
// during migration), take the highest value to avoid double-counting.
|
|
307
|
+
bool found;
|
|
308
|
+
for (uint256 j; j < chainCount;) {
|
|
309
|
+
if (chainIds[j] == chainId) {
|
|
310
|
+
if (amt.value > maxSurplus[j]) maxSurplus[j] = amt.value;
|
|
311
|
+
found = true;
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
unchecked {
|
|
315
|
+
++j;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (!found) {
|
|
319
|
+
chainIds[chainCount] = chainId;
|
|
320
|
+
maxSurplus[chainCount] = amt.value;
|
|
321
|
+
unchecked {
|
|
322
|
+
++chainCount;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
253
325
|
} catch {}
|
|
254
326
|
}
|
|
255
327
|
unchecked {
|
|
256
328
|
++i;
|
|
257
329
|
}
|
|
258
330
|
}
|
|
331
|
+
|
|
332
|
+
// Sum the per-chain max values.
|
|
333
|
+
for (uint256 k; k < chainCount;) {
|
|
334
|
+
surplus += maxSurplus[k];
|
|
335
|
+
unchecked {
|
|
336
|
+
++k;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
259
339
|
}
|
|
260
340
|
|
|
261
341
|
/// @notice The cumulative total supply across all remote peer chains for a project.
|
|
262
|
-
/// @dev
|
|
342
|
+
/// @dev Includes both active and deprecated suckers to prevent undercounting during migration windows.
|
|
343
|
+
/// Uses per-chain deduplication (max per chain, not sum) to prevent double-counting when both a deprecated and
|
|
344
|
+
/// active sucker exist for the same chain. Silently skips suckers that revert.
|
|
263
345
|
/// @param projectId The ID of the project.
|
|
264
346
|
/// @return totalSupply The combined peer chain total supply.
|
|
265
347
|
function remoteTotalSupplyOf(uint256 projectId) external view override returns (uint256 totalSupply) {
|
|
266
348
|
address[] memory allSuckers = _suckersOf[projectId].keys();
|
|
267
|
-
|
|
349
|
+
uint256 len = allSuckers.length;
|
|
350
|
+
|
|
351
|
+
// Per-chain dedup arrays. The number of suckers per project is small (typically 1-5),
|
|
352
|
+
// so a linear scan is cheaper than a mapping.
|
|
353
|
+
uint256[] memory chainIds = new uint256[](len);
|
|
354
|
+
uint256[] memory maxSupply = new uint256[](len);
|
|
355
|
+
uint256 chainCount;
|
|
356
|
+
|
|
357
|
+
for (uint256 i; i < len;) {
|
|
268
358
|
// slither-disable-next-line unused-return
|
|
269
359
|
(, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
|
|
270
|
-
|
|
360
|
+
// Include both active and deprecated suckers in aggregate economic views.
|
|
361
|
+
if (val == _SUCKER_EXISTS || val == _SUCKER_DEPRECATED) {
|
|
271
362
|
// slither-disable-next-line calls-loop
|
|
272
363
|
try IJBSucker(allSuckers[i]).peerChainTotalSupply() returns (uint256 supply) {
|
|
273
|
-
|
|
364
|
+
// slither-disable-next-line calls-loop
|
|
365
|
+
uint256 chainId = IJBSucker(allSuckers[i]).peerChainId();
|
|
366
|
+
// Per-chain max: if multiple suckers target the same chain (deprecated + active
|
|
367
|
+
// during migration), take the highest value to avoid double-counting.
|
|
368
|
+
bool found;
|
|
369
|
+
for (uint256 j; j < chainCount;) {
|
|
370
|
+
if (chainIds[j] == chainId) {
|
|
371
|
+
if (supply > maxSupply[j]) maxSupply[j] = supply;
|
|
372
|
+
found = true;
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
unchecked {
|
|
376
|
+
++j;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (!found) {
|
|
380
|
+
chainIds[chainCount] = chainId;
|
|
381
|
+
maxSupply[chainCount] = supply;
|
|
382
|
+
unchecked {
|
|
383
|
+
++chainCount;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
274
386
|
} catch {}
|
|
275
387
|
}
|
|
276
388
|
unchecked {
|
|
277
389
|
++i;
|
|
278
390
|
}
|
|
279
391
|
}
|
|
392
|
+
|
|
393
|
+
// Sum the per-chain max values.
|
|
394
|
+
for (uint256 k; k < chainCount;) {
|
|
395
|
+
totalSupply += maxSupply[k];
|
|
396
|
+
unchecked {
|
|
397
|
+
++k;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
280
400
|
}
|
|
281
401
|
|
|
282
402
|
//*********************************************************************//
|
package/src/JBSwapCCIPSucker.sol
CHANGED
|
@@ -313,12 +313,35 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
313
313
|
}
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
316
|
+
// Zero-output swap guard: When a swap succeeds but returns zero local tokens, the
|
|
317
|
+
// batch must NOT be marked claimable. Without this guard, `_addToBalance` would see
|
|
318
|
+
// `pendingSwapOf.bridgeAmount == 0` (no pending swap stored) and allow claims to
|
|
319
|
+
// proceed — minting the full bridged project-token amount while adding zero terminal
|
|
320
|
+
// backing, breaking cross-chain solvency.
|
|
321
|
+
//
|
|
322
|
+
// Route zero-output swaps into `pendingSwapOf` so the swap can be retried via
|
|
323
|
+
// `retrySwap` once pool conditions improve. Only store the conversion rate when
|
|
324
|
+
// the swap produced a positive local amount.
|
|
319
325
|
if (root.amount > 0) {
|
|
320
|
-
|
|
321
|
-
|
|
326
|
+
if (localAmount == 0 && any2EvmMessage.destTokenAmounts.length == 1) {
|
|
327
|
+
Client.EVMTokenAmount memory zeroSwapTokenAmount = any2EvmMessage.destTokenAmounts[0];
|
|
328
|
+
// Only route to pending if there were actual bridge tokens delivered.
|
|
329
|
+
// If bridgeAmount is also 0 (zero-value batch), store the conversion rate
|
|
330
|
+
// normally — there is nothing to retry.
|
|
331
|
+
if (zeroSwapTokenAmount.amount > 0) {
|
|
332
|
+
pendingSwapOf[localToken][root.remoteRoot.nonce] = PendingSwap({
|
|
333
|
+
bridgeToken: zeroSwapTokenAmount.token,
|
|
334
|
+
bridgeAmount: zeroSwapTokenAmount.amount,
|
|
335
|
+
leafTotal: root.amount
|
|
336
|
+
});
|
|
337
|
+
} else {
|
|
338
|
+
_conversionRateOf[localToken][root.remoteRoot.nonce] =
|
|
339
|
+
ConversionRate({leafTotal: root.amount, localTotal: 0});
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
_conversionRateOf[localToken][root.remoteRoot.nonce] =
|
|
343
|
+
ConversionRate({leafTotal: root.amount, localTotal: localAmount});
|
|
344
|
+
}
|
|
322
345
|
}
|
|
323
346
|
|
|
324
347
|
// Store the inbox merkle root for later claims.
|
|
@@ -563,6 +586,7 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
563
586
|
peerAddress: _toAddress(peer()),
|
|
564
587
|
transportPayment: transportPayment,
|
|
565
588
|
feeToken: feeToken,
|
|
589
|
+
feeTokenPayer: feeToken != address(0) ? _msgSender() : address(0),
|
|
566
590
|
gasLimit: MESSENGER_BASE_GAS_LIMIT + remoteToken.minGas,
|
|
567
591
|
encodedPayload: encodedPayload,
|
|
568
592
|
tokenAmounts: tokenAmounts,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.0;
|
|
3
|
+
|
|
4
|
+
/// @notice Interface for Arbitrum L1 gateways to query the exact calldata they construct for retryable tickets.
|
|
5
|
+
interface IL1ArbitrumGateway {
|
|
6
|
+
/// @notice Returns the calldata that the gateway will submit as a retryable ticket to the Inbox.
|
|
7
|
+
/// @param _token The L1 token address.
|
|
8
|
+
/// @param _from The sender on L1.
|
|
9
|
+
/// @param _to The recipient on L2.
|
|
10
|
+
/// @param _amount The amount of tokens being bridged.
|
|
11
|
+
/// @param _data Additional data (forwarded to the L2 gateway).
|
|
12
|
+
/// @return The ABI-encoded calldata for `finalizeInboundTransfer` on the L2 gateway.
|
|
13
|
+
function getOutboundCalldata(
|
|
14
|
+
address _token,
|
|
15
|
+
address _from,
|
|
16
|
+
address _to,
|
|
17
|
+
uint256 _amount,
|
|
18
|
+
bytes calldata _data
|
|
19
|
+
)
|
|
20
|
+
external
|
|
21
|
+
view
|
|
22
|
+
returns (bytes memory);
|
|
23
|
+
}
|
|
@@ -68,13 +68,14 @@ library JBCCIPLib {
|
|
|
68
68
|
/// @dev Runs via DELEGATECALL. Handles EVM2AnyMessage construction, getFee, ccipSend, and refund.
|
|
69
69
|
/// @dev Supports two fee modes:
|
|
70
70
|
/// - `transportPayment > 0`: pay CCIP fees in native ETH (existing behavior).
|
|
71
|
-
/// - `transportPayment == 0`: pay CCIP fees in LINK from the
|
|
71
|
+
/// - `transportPayment == 0`: pay CCIP fees in LINK pulled from the caller via transferFrom.
|
|
72
72
|
/// This enables chains with no meaningful native token (e.g. Tempo) to use CCIP.
|
|
73
73
|
/// @param ccipRouter The CCIP router.
|
|
74
74
|
/// @param remoteChainSelector The CCIP chain selector for the remote chain.
|
|
75
75
|
/// @param peerAddress The peer sucker address on the remote chain.
|
|
76
76
|
/// @param transportPayment The ETH transport payment available (0 for LINK fee mode).
|
|
77
77
|
/// @param feeToken The fee token address: address(0) for native ETH, LINK address for LINK fee mode.
|
|
78
|
+
/// @param feeTokenPayer The address to pull LINK fees from via transferFrom (0 to use sucker's own balance).
|
|
78
79
|
/// @param gasLimit The gas limit for the CCIP message.
|
|
79
80
|
/// @param encodedPayload The ABI-encoded payload (e.g., abi.encode(type, data)).
|
|
80
81
|
/// @param tokenAmounts The token amounts to bridge (from prepareTokenAmounts).
|
|
@@ -87,6 +88,7 @@ library JBCCIPLib {
|
|
|
87
88
|
address peerAddress,
|
|
88
89
|
uint256 transportPayment,
|
|
89
90
|
address feeToken,
|
|
91
|
+
address feeTokenPayer,
|
|
90
92
|
uint256 gasLimit,
|
|
91
93
|
bytes memory encodedPayload,
|
|
92
94
|
Client.EVMTokenAmount[] memory tokenAmounts,
|
|
@@ -95,6 +97,10 @@ library JBCCIPLib {
|
|
|
95
97
|
external
|
|
96
98
|
returns (bool refundFailed, uint256 refundAmount)
|
|
97
99
|
{
|
|
100
|
+
// Cache to reduce stack pressure.
|
|
101
|
+
address router = address(ccipRouter);
|
|
102
|
+
uint64 chainSel = remoteChainSelector;
|
|
103
|
+
|
|
98
104
|
// Build the CCIP message.
|
|
99
105
|
Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
|
|
100
106
|
receiver: abi.encode(peerAddress),
|
|
@@ -106,10 +112,17 @@ library JBCCIPLib {
|
|
|
106
112
|
|
|
107
113
|
// Get the CCIP fee for sending the message.
|
|
108
114
|
// slither-disable-next-line calls-loop
|
|
109
|
-
uint256 fees =
|
|
115
|
+
uint256 fees = ICCIPRouter(router).getFee({destinationChainSelector: chainSel, message: message});
|
|
110
116
|
|
|
111
117
|
if (feeToken != address(0)) {
|
|
112
|
-
// LINK fee path:
|
|
118
|
+
// LINK fee path: pull the fee from the caller so toRemote stays permissionless.
|
|
119
|
+
// The caller must approve LINK to the sucker before calling toRemote.
|
|
120
|
+
if (feeTokenPayer != address(0)) {
|
|
121
|
+
// slither-disable-next-line arbitrary-send-erc20
|
|
122
|
+
IERC20(feeToken).safeTransferFrom({from: feeTokenPayer, to: address(this), value: fees});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Approve the router to spend LINK (fee + any bridged LINK amount).
|
|
113
126
|
// When the fee token is also a bridged token (e.g. LINK on Tempo), the approval
|
|
114
127
|
// must cover both the bridged amount and the fee. prepareTokenAmounts() already
|
|
115
128
|
// approved the bridged amount, but forceApprove replaces (not adds), so we must
|
|
@@ -121,14 +134,14 @@ library JBCCIPLib {
|
|
|
121
134
|
break;
|
|
122
135
|
}
|
|
123
136
|
}
|
|
124
|
-
SafeERC20.forceApprove({token: IERC20(feeToken), spender:
|
|
137
|
+
SafeERC20.forceApprove({token: IERC20(feeToken), spender: router, value: totalApproval});
|
|
125
138
|
|
|
126
139
|
// slither-disable-next-line calls-loop,unused-return
|
|
127
|
-
|
|
140
|
+
ICCIPRouter(router).ccipSend({destinationChainSelector: chainSel, message: message});
|
|
128
141
|
} else {
|
|
129
142
|
// Native ETH fee path.
|
|
130
143
|
// slither-disable-next-line calls-loop,unused-return,arbitrary-send-eth
|
|
131
|
-
|
|
144
|
+
ICCIPRouter(router).ccipSend{value: fees}({destinationChainSelector: chainSel, message: message});
|
|
132
145
|
|
|
133
146
|
// Calculate the excess transport payment to refund.
|
|
134
147
|
refundAmount = transportPayment - fees;
|
|
@@ -417,9 +417,12 @@ abstract contract CCIPSuckerClaimForkTestBase is TestBaseWorkflow {
|
|
|
417
417
|
vm.prank(rootSender);
|
|
418
418
|
suckerL1.toRemote{value: ccipFeeAmount}(token);
|
|
419
419
|
} else {
|
|
420
|
-
// LINK fee path:
|
|
420
|
+
// LINK fee path: caller provides LINK inline — approve + transferFrom.
|
|
421
421
|
address linkToken = _linkTokenOf(block.chainid);
|
|
422
|
-
|
|
422
|
+
uint256 linkForFees = 100 ether;
|
|
423
|
+
deal(linkToken, rootSender, linkForFees);
|
|
424
|
+
vm.prank(rootSender);
|
|
425
|
+
IERC20(linkToken).approve({spender: address(suckerL1), value: linkForFees});
|
|
423
426
|
vm.prank(rootSender);
|
|
424
427
|
suckerL1.toRemote(token);
|
|
425
428
|
}
|
package/test/ForkMainnet.t.sol
CHANGED
|
@@ -344,21 +344,22 @@ abstract contract CCIPSuckerMainnetForkTestBase is TestBaseWorkflow {
|
|
|
344
344
|
assertLt(rootSender.balance, ccipFeeAmount, "CCIP fees should have been deducted");
|
|
345
345
|
assertGt(rootSender.balance, 0, "Excess native should be returned");
|
|
346
346
|
} else {
|
|
347
|
-
// LINK fee path:
|
|
347
|
+
// LINK fee path: caller provides LINK inline — approve + transferFrom.
|
|
348
348
|
// On Tempo, CALLVALUE always returns 0, so transportPayment = 0, triggering LINK fee mode.
|
|
349
349
|
address linkToken = _linkTokenOf(block.chainid);
|
|
350
350
|
uint256 linkForFees = 100 ether;
|
|
351
|
-
deal(linkToken,
|
|
352
|
-
uint256
|
|
353
|
-
|
|
351
|
+
deal(linkToken, rootSender, linkForFees);
|
|
352
|
+
uint256 senderLinkBefore = IERC20(linkToken).balanceOf(rootSender);
|
|
353
|
+
vm.prank(rootSender);
|
|
354
|
+
IERC20(linkToken).approve({spender: address(suckerL1), value: linkForFees});
|
|
354
355
|
vm.prank(rootSender);
|
|
355
356
|
suckerL1.toRemote(token);
|
|
356
357
|
|
|
357
|
-
// Verify LINK was consumed for CCIP fees.
|
|
358
|
+
// Verify LINK was consumed from the caller for CCIP fees.
|
|
358
359
|
assertLt(
|
|
359
|
-
IERC20(linkToken).balanceOf(
|
|
360
|
-
|
|
361
|
-
"LINK should have been consumed for CCIP fees"
|
|
360
|
+
IERC20(linkToken).balanceOf(rootSender),
|
|
361
|
+
senderLinkBefore,
|
|
362
|
+
"LINK should have been consumed from caller for CCIP fees"
|
|
362
363
|
);
|
|
363
364
|
}
|
|
364
365
|
|
|
@@ -310,19 +310,21 @@ abstract contract SwapCCIPSuckerForkTestBase is TestBaseWorkflow {
|
|
|
310
310
|
assertLt(rootSender.balance, ccipFeeAmount, "CCIP fees should have been deducted");
|
|
311
311
|
assertGt(rootSender.balance, 0, "Excess native should be returned");
|
|
312
312
|
} else {
|
|
313
|
-
// LINK fee path:
|
|
313
|
+
// LINK fee path: caller provides LINK inline — approve + transferFrom.
|
|
314
314
|
address linkToken = CCIPHelper.linkOfChain(block.chainid);
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
315
|
+
uint256 linkForFees = 100 ether;
|
|
316
|
+
deal(linkToken, rootSender, linkForFees);
|
|
317
|
+
uint256 senderLinkBefore = IERC20(linkToken).balanceOf(rootSender);
|
|
318
|
+
vm.prank(rootSender);
|
|
319
|
+
IERC20(linkToken).approve({spender: address(suckerL1), value: linkForFees});
|
|
318
320
|
vm.prank(rootSender);
|
|
319
321
|
suckerL1.toRemote(token);
|
|
320
322
|
|
|
321
|
-
// Verify LINK was consumed for CCIP fees.
|
|
323
|
+
// Verify LINK was consumed from the caller for CCIP fees.
|
|
322
324
|
assertLt(
|
|
323
|
-
IERC20(linkToken).balanceOf(
|
|
324
|
-
|
|
325
|
-
"LINK should have been consumed for CCIP fees"
|
|
325
|
+
IERC20(linkToken).balanceOf(rootSender),
|
|
326
|
+
senderLinkBefore,
|
|
327
|
+
"LINK should have been consumed from caller for CCIP fees"
|
|
326
328
|
);
|
|
327
329
|
}
|
|
328
330
|
|
package/test/SuckerAttacks.t.sol
CHANGED
|
@@ -473,7 +473,7 @@ contract SuckerAttacks is Test {
|
|
|
473
473
|
// The sucker uses outbox.balance for validation, so large claims should fail
|
|
474
474
|
|
|
475
475
|
// Set up deprecated state for emergency exit
|
|
476
|
-
uint256 deprecationTimestamp = block.timestamp + 14 days;
|
|
476
|
+
uint256 deprecationTimestamp = block.timestamp + 14 days + 1;
|
|
477
477
|
// forge-lint: disable-next-line(unsafe-typecast)
|
|
478
478
|
sucker.setDeprecation(uint40(deprecationTimestamp));
|
|
479
479
|
vm.warp(deprecationTimestamp);
|
|
@@ -243,7 +243,7 @@ contract SuckerRegressionsTest is Test {
|
|
|
243
243
|
uint256 projectTokenCount = 5 ether;
|
|
244
244
|
|
|
245
245
|
// Set up the sucker to be deprecated so emergency exit is allowed.
|
|
246
|
-
uint256 deprecationTimestamp = block.timestamp + 14 days;
|
|
246
|
+
uint256 deprecationTimestamp = block.timestamp + 14 days + 1;
|
|
247
247
|
// forge-lint: disable-next-line(unsafe-typecast)
|
|
248
248
|
sucker.setDeprecation(uint40(deprecationTimestamp));
|
|
249
249
|
vm.warp(deprecationTimestamp);
|