@bananapus/suckers-v6 0.0.29 → 0.0.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RISKS.md +8 -0
- package/package.json +1 -1
- package/src/JBArbitrumSucker.sol +3 -4
- package/src/JBSucker.sol +15 -16
- package/src/JBSwapCCIPSucker.sol +76 -50
- package/src/libraries/JBSuckerLib.sol +3 -3
- package/src/libraries/JBSwapPoolLib.sol +54 -33
- package/src/structs/JBMessageRoot.sol +3 -4
- package/test/AdversarialSuckerFork.t.sol +449 -0
- package/test/ForkArbitrum.t.sol +15 -12
- package/test/ForkCelo.t.sol +14 -12
- package/test/ForkClaimMainnet.t.sol +9 -124
- package/test/ForkMainnet.t.sol +7 -128
- package/test/ForkOPStack.t.sol +7 -70
- package/test/ForkSwap.t.sol +2 -2
- package/test/ForkSwapMainnet.t.sol +4 -88
- package/test/InteropCompat.t.sol +3 -3
- package/test/MultiSuckerFork.t.sol +529 -0
- package/test/SuckerAttacks.t.sol +4 -4
- package/test/SuckerCrossChainAdversarial.t.sol +22 -22
- package/test/SuckerDeepAttacks.t.sol +10 -10
- package/test/TestAuditGaps.sol +11 -11
- package/test/audit/{codex-CCIPLegacyFormatCompatibility.t.sol → CCIPLegacyFormatCompatibility.t.sol} +1 -1
- package/test/audit/{codex-CCIPWrappedNativeMisunwrap.t.sol → CCIPWrappedNativeMisunwrap.t.sol} +1 -1
- package/test/audit/DeprecatedSuckerDestination.t.sol +1 -1
- package/test/audit/{2026-04-24-codex-nemesis-FreshRound.t.sol → FreshRound.t.sol} +5 -10
- package/test/audit/{codex-PeerDeterminism.t.sol → PeerDeterminism.t.sol} +6 -6
- package/test/audit/{codex-PeerSnapshotDesync.t.sol → PeerSnapshotDesync.t.sol} +2 -2
- package/test/audit/{2026-04-22-codex-nemesis-PeerTopologyAuthBreak.t.sol → PeerTopologyAuthBreak.t.sol} +1 -1
- package/test/audit/{2026-04-22-codex-nemesis-RegistryPeerAuthBreak.t.sol → RegistryPeerAuthBreak.t.sol} +17 -17
- package/test/audit/{2026-04-24-codex-nemesis-RegistryPeerMismatch.t.sol → RegistryPeerMismatch.t.sol} +11 -11
- package/test/audit/StaleNonceMetadataOverwrite.t.sol +318 -0
- package/test/audit/{codex-SwapZeroAmountBatchGap.t.sol → SwapZeroAmountBatchGap.t.sol} +2 -2
- package/test/audit/{2026-04-25-codex-nemesis-TransientClaimContext.t.sol → TransientClaimContext.t.sol} +2 -1
- package/test/audit/TrustedForwarderSpoof.t.sol +2 -2
- package/test/audit/TrustedForwarderSpoofCCIP.t.sol +1 -1
- package/test/audit/ZeroOutputSwapPending.t.sol +2 -2
- package/test/fork/OptimismSuckerFork.t.sol +6 -6
- package/test/helpers/SuckerForkHelpers.sol +126 -0
- package/test/unit/ccip_native_interop.t.sol +7 -7
- package/test/unit/deployer.t.sol +6 -6
- package/test/unit/merkle.t.sol +27 -27
- package/test/unit/multi_chain_evolution.t.sol +8 -10
- package/test/unit/peer_chain_state.t.sol +1 -1
- /package/test/audit/{codex-nemesis-DeprecatedRemovalUndercount.t.sol → DeprecatedRemovalUndercount.t.sol} +0 -0
- /package/test/audit/{CertikAIScan.t.sol → DeprecationBoundaryAndMapToken.t.sol} +0 -0
- /package/test/audit/{codex-FeeLocking.t.sol → FeeLocking.t.sol} +0 -0
- /package/test/audit/{codex-MapTokensEnableOnlyValueStuck.t.sol → MapTokensEnableOnlyValueStuck.t.sol} +0 -0
- /package/test/audit/{2026-04-21-codex-nemesis-RegistryStaleDeprecatedMaxSurplus.t.sol → RegistryStaleDeprecatedMaxSurplus.t.sol} +0 -0
- /package/test/audit/{2026-04-24-codex-nemesis-RegistryStaleMaxAggregation.t.sol → RegistryStaleMaxAggregation.t.sol} +0 -0
- /package/test/audit/{codex-SwapBatchRateMixing.t.sol → SwapBatchRateMixing.t.sol} +0 -0
- /package/test/audit/{codex-NemesisSwapQueueOrder.t.sol → SwapQueueOrder.t.sol} +0 -0
- /package/test/audit/{codex-nemesis-SwapZeroLocalTotalUnbackedClaim.t.sol → SwapZeroLocalTotalUnbackedClaim.t.sol} +0 -0
- /package/test/audit/{codex-ToRemoteFeeIrrecoverable.t.sol → ToRemoteFeeIrrecoverable.t.sol} +0 -0
- /package/test/audit/{2026-04-22-codex-nemesis-ZeroOutputRetryClaim.t.sol → ZeroOutputRetryClaim.t.sol} +0 -0
package/RISKS.md
CHANGED
|
@@ -151,3 +151,11 @@ When the only available Uniswap pool for a cross-denomination swap is a hookless
|
|
|
151
151
|
### 10.9 Zero-value `prepare()` is allowed
|
|
152
152
|
|
|
153
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.
|
|
154
|
+
|
|
155
|
+
### 10.10 Cross-chain currency uses standardized `JBCurrencyIds.ETH` (1), not local token addresses
|
|
156
|
+
|
|
157
|
+
Snapshot messages encode surplus and balance values using `JBCurrencyIds.ETH` (currency ID `1`) as the cross-chain currency identifier, not `uint32(uint160(JBConstants.NATIVE_TOKEN))` (currency ID `61166`). This is intentional: `NATIVE_TOKEN` (`0x000...EEEe`) is a local sentinel meaning "the native token on this chain," which may represent different assets on different networks (e.g., ETH on mainnet, MATIC on Polygon). A standardized semantic currency ID is required for cross-chain values to be comparable.
|
|
158
|
+
|
|
159
|
+
On the consuming side, contracts like `REVOwner` and `REVLoans` query sucker-reported values using `uint32(uint160(NATIVE_TOKEN))` — the local terminal convention. The `JBPrices` oracle in `JBSuckerLib.convertPeerValue` resolves the conversion between currency ID `1` (ETH) and currency ID `61166` (native token) when a price feed is registered for the pair. If no feed exists, `convertPeerValue` returns `0`, which is acceptable: it means the project has not configured cross-chain pricing for that token pair, and remote values are simply not factored into local calculations.
|
|
160
|
+
|
|
161
|
+
Projects that need accurate cross-chain surplus and supply accounting should register a price feed for the `1 ↔ 61166` pair via `JBPrices`. On chains where the native token is ETH, this is a 1:1 identity feed. On chains where the native token is not ETH, the feed should reflect the actual exchange rate.
|
package/package.json
CHANGED
package/src/JBArbitrumSucker.sol
CHANGED
|
@@ -206,8 +206,8 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
|
|
|
206
206
|
// slither-disable-next-line calls-loop,unused-return
|
|
207
207
|
IArbL2GatewayRouter(address(GATEWAYROUTER))
|
|
208
208
|
.outboundTransfer({
|
|
209
|
-
|
|
210
|
-
|
|
209
|
+
l1Token: _toAddress(remoteToken.addr), to: peerAddress, amount: amount, data: bytes("")
|
|
210
|
+
});
|
|
211
211
|
} else {
|
|
212
212
|
// Otherwise, the token is the native token, and the amount will be sent as `msg.value`.
|
|
213
213
|
nativeValue = amount;
|
|
@@ -269,8 +269,7 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
|
|
|
269
269
|
IL1ArbitrumGateway(gateway)
|
|
270
270
|
.getOutboundCalldata({
|
|
271
271
|
_token: token, _from: address(this), _to: _peerAddress(), _amount: amount, _data: bytes("")
|
|
272
|
-
})
|
|
273
|
-
.length;
|
|
272
|
+
}).length;
|
|
274
273
|
// slither-disable-next-line calls-loop
|
|
275
274
|
maxSubmissionCostERC20 = ARBINBOX.calculateRetryableSubmissionFee({
|
|
276
275
|
dataLength: outboundCalldataLength, baseFee: maxFeePerGas
|
package/src/JBSucker.sol
CHANGED
|
@@ -178,11 +178,10 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
178
178
|
/// @dev The `currency` and `decimals` fields describe the denomination; `value` is the balance amount.
|
|
179
179
|
JBDenominatedAmount private _peerChainBalance;
|
|
180
180
|
|
|
181
|
-
/// @notice
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
uint64 private _peerSnapshotNonce;
|
|
181
|
+
/// @notice The `block.timestamp` from the source chain when the most recent accepted peer snapshot was taken.
|
|
182
|
+
/// @dev Only snapshots with a strictly newer source timestamp are accepted, preventing stale rollbacks.
|
|
183
|
+
/// Returns 0 if no snapshot has been received yet.
|
|
184
|
+
uint256 public snapshotTimestamp;
|
|
186
185
|
|
|
187
186
|
//*********************************************************************//
|
|
188
187
|
// ---------------------------- constructor -------------------------- //
|
|
@@ -400,12 +399,12 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
400
399
|
emit StaleRootRejected({token: localToken, receivedNonce: root.remoteRoot.nonce, currentNonce: inbox.nonce});
|
|
401
400
|
}
|
|
402
401
|
|
|
403
|
-
// --- Project-wide shared state update (gated by
|
|
404
|
-
//
|
|
402
|
+
// --- Project-wide shared state update (gated by source timestamp) ---
|
|
403
|
+
// Only accept snapshots whose source timestamp is strictly newer than the last accepted one.
|
|
405
404
|
// This prevents a staler per-token message from rolling back shared state (surplus, balance, supply)
|
|
406
405
|
// that was already updated by a fresher message for a different token.
|
|
407
|
-
if (root.
|
|
408
|
-
|
|
406
|
+
if (root.sourceTimestamp > snapshotTimestamp) {
|
|
407
|
+
snapshotTimestamp = root.sourceTimestamp;
|
|
409
408
|
|
|
410
409
|
// Update unconditionally — a legitimate zero supply must clear phantom cached supply.
|
|
411
410
|
peerChainTotalSupply = root.sourceTotalSupply;
|
|
@@ -875,12 +874,12 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
875
874
|
// slither-disable-next-line calls-loop,unused-return
|
|
876
875
|
IJBController(address(DIRECTORY.controllerOf(cachedProjectId)))
|
|
877
876
|
.mintTokensOf({
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
877
|
+
projectId: cachedProjectId,
|
|
878
|
+
tokenCount: projectTokenAmount,
|
|
879
|
+
beneficiary: _toAddress(beneficiary),
|
|
880
|
+
memo: "",
|
|
881
|
+
useReservedPercent: false
|
|
882
|
+
});
|
|
884
883
|
}
|
|
885
884
|
|
|
886
885
|
/// @notice Inserts a new leaf into the outbox merkle tree for the specified `token`.
|
|
@@ -1489,7 +1488,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
1489
1488
|
nonce: nonce,
|
|
1490
1489
|
root: root,
|
|
1491
1490
|
messageVersion: MESSAGE_VERSION,
|
|
1492
|
-
|
|
1491
|
+
sourceTimestamp: block.timestamp
|
|
1493
1492
|
});
|
|
1494
1493
|
|
|
1495
1494
|
// Send the root over the AMB (positional args — slither IR parser crashes on named args here).
|
package/src/JBSwapCCIPSucker.sol
CHANGED
|
@@ -273,6 +273,7 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
273
273
|
|
|
274
274
|
address localToken = _toAddress(root.token);
|
|
275
275
|
uint256 localAmount;
|
|
276
|
+
bool swapFailed;
|
|
276
277
|
|
|
277
278
|
if (any2EvmMessage.destTokenAmounts.length == 1) {
|
|
278
279
|
Client.EVMTokenAmount memory tokenAmount = any2EvmMessage.destTokenAmounts[0];
|
|
@@ -284,7 +285,7 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
284
285
|
// Swap bridge token -> local token via best V3/V4 pool.
|
|
285
286
|
// Wrapped in try-catch so a swap failure doesn't revert the entire CCIP message
|
|
286
287
|
// (which would leave tokens stuck in the OffRamp). On failure, bridge tokens are
|
|
287
|
-
// stored for later retry via `retrySwap
|
|
288
|
+
// stored for later retry via `retrySwap` (written below, after nonce validation).
|
|
288
289
|
// slither-disable-next-line reentrancy-benign,reentrancy-events
|
|
289
290
|
try this.executeSwapExternal({
|
|
290
291
|
tokenIn: tokenAmount.token, tokenOut: localToken, amount: tokenAmount.amount
|
|
@@ -293,59 +294,78 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
293
294
|
) {
|
|
294
295
|
localAmount = swapped;
|
|
295
296
|
} catch {
|
|
296
|
-
|
|
297
|
-
pendingSwapOf
|
|
298
|
-
|
|
299
|
-
});
|
|
300
|
-
// localAmount stays 0 — the conversion rate code below will set
|
|
301
|
-
// localTotal: 0, gating claims until retrySwap succeeds.
|
|
297
|
+
swapFailed = true;
|
|
298
|
+
// localAmount stays 0 — pendingSwapOf and conversion rate are written
|
|
299
|
+
// below, after fromRemote validates the nonce.
|
|
302
300
|
}
|
|
303
301
|
}
|
|
304
302
|
}
|
|
305
303
|
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
304
|
+
// Capture the inbox nonce before fromRemote to detect whether the root was accepted.
|
|
305
|
+
uint64 inboxNonceBefore = _inboxOf[localToken].nonce;
|
|
306
|
+
|
|
307
|
+
// Store the inbox merkle root for later claims.
|
|
308
|
+
// Must be called BEFORE writing batch metadata and conversion rates so that stale
|
|
309
|
+
// (duplicate/replayed) roots that fromRemote silently rejects do not overwrite
|
|
310
|
+
// metadata from the original accepted delivery.
|
|
311
|
+
this.fromRemote(root);
|
|
312
|
+
|
|
313
|
+
// Only write batch metadata and conversion rates if fromRemote accepted this root.
|
|
314
|
+
// fromRemote updates _inboxOf[localToken].nonce when the root's nonce is strictly
|
|
315
|
+
// greater than the current inbox nonce; stale roots are silently rejected.
|
|
316
|
+
// We detect acceptance by checking if the nonce actually changed.
|
|
317
|
+
if (_inboxOf[localToken].nonce > inboxNonceBefore) {
|
|
318
|
+
// Record the batch range so _findNonceForLeafIndex can resolve leaf ownership
|
|
319
|
+
// independently of nonce ordering. Each nonce is self-describing: [start, end).
|
|
320
|
+
if (batchEnd > 0) {
|
|
321
|
+
_batchStartOf[localToken][root.remoteRoot.nonce] = batchStart;
|
|
322
|
+
_batchEndOf[localToken][root.remoteRoot.nonce] = batchEnd;
|
|
323
|
+
if (root.remoteRoot.nonce > _highestReceivedNonce[localToken]) {
|
|
324
|
+
_highestReceivedNonce[localToken] = root.remoteRoot.nonce;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Store pendingSwapOf for failed swaps now that nonce is validated.
|
|
329
|
+
if (swapFailed) {
|
|
330
|
+
Client.EVMTokenAmount memory failedTokenAmount = any2EvmMessage.destTokenAmounts[0];
|
|
331
|
+
pendingSwapOf[localToken][root.remoteRoot.nonce] = PendingSwap({
|
|
332
|
+
bridgeToken: failedTokenAmount.token,
|
|
333
|
+
bridgeAmount: failedTokenAmount.amount,
|
|
334
|
+
leafTotal: root.amount
|
|
335
|
+
});
|
|
313
336
|
}
|
|
314
|
-
}
|
|
315
337
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
338
|
+
// Zero-output swap guard: When a swap succeeds but returns zero local tokens, the
|
|
339
|
+
// batch must NOT be marked claimable. Without this guard, `_addToBalance` would see
|
|
340
|
+
// `pendingSwapOf.bridgeAmount == 0` (no pending swap stored) and allow claims to
|
|
341
|
+
// proceed — minting the full bridged project-token amount while adding zero terminal
|
|
342
|
+
// backing, breaking cross-chain solvency.
|
|
343
|
+
//
|
|
344
|
+
// Route zero-output swaps into `pendingSwapOf` so the swap can be retried via
|
|
345
|
+
// `retrySwap` once pool conditions improve. Only store the conversion rate when
|
|
346
|
+
// the swap produced a positive local amount.
|
|
347
|
+
if (root.amount > 0 && !swapFailed) {
|
|
348
|
+
if (localAmount == 0 && any2EvmMessage.destTokenAmounts.length == 1) {
|
|
349
|
+
Client.EVMTokenAmount memory zeroSwapTokenAmount = any2EvmMessage.destTokenAmounts[0];
|
|
350
|
+
// Only route to pending if there were actual bridge tokens delivered.
|
|
351
|
+
// If bridgeAmount is also 0 (zero-value batch), store the conversion rate
|
|
352
|
+
// normally — there is nothing to retry.
|
|
353
|
+
if (zeroSwapTokenAmount.amount > 0) {
|
|
354
|
+
pendingSwapOf[localToken][root.remoteRoot.nonce] = PendingSwap({
|
|
355
|
+
bridgeToken: zeroSwapTokenAmount.token,
|
|
356
|
+
bridgeAmount: zeroSwapTokenAmount.amount,
|
|
357
|
+
leafTotal: root.amount
|
|
358
|
+
});
|
|
359
|
+
} else {
|
|
360
|
+
_conversionRateOf[localToken][root.remoteRoot.nonce] =
|
|
361
|
+
ConversionRate({leafTotal: root.amount, localTotal: 0});
|
|
362
|
+
}
|
|
337
363
|
} else {
|
|
338
364
|
_conversionRateOf[localToken][root.remoteRoot.nonce] =
|
|
339
|
-
ConversionRate({leafTotal: root.amount, localTotal:
|
|
365
|
+
ConversionRate({leafTotal: root.amount, localTotal: localAmount});
|
|
340
366
|
}
|
|
341
|
-
} else {
|
|
342
|
-
_conversionRateOf[localToken][root.remoteRoot.nonce] =
|
|
343
|
-
ConversionRate({leafTotal: root.amount, localTotal: localAmount});
|
|
344
367
|
}
|
|
345
368
|
}
|
|
346
|
-
|
|
347
|
-
// Store the inbox merkle root for later claims.
|
|
348
|
-
this.fromRemote(root);
|
|
349
369
|
} else {
|
|
350
370
|
revert JBCCIPSucker_UnknownMessageType(messageType);
|
|
351
371
|
}
|
|
@@ -405,10 +425,13 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
405
425
|
PendingSwap memory pending = pendingSwapOf[localToken][nonce];
|
|
406
426
|
if (pending.bridgeAmount == 0) revert JBSwapCCIPSucker_NoPendingSwap();
|
|
407
427
|
|
|
408
|
-
// slither-disable-next-line reentrancy-no-eth,reentrancy-benign,reentrancy-events
|
|
428
|
+
// slither-disable-next-line reentrancy-no-eth,reentrancy-benign,reentrancy-events,reentrancy-eth
|
|
409
429
|
uint256 localAmount =
|
|
410
430
|
_executeSwap({tokenIn: pending.bridgeToken, tokenOut: localToken, amount: pending.bridgeAmount});
|
|
411
431
|
|
|
432
|
+
// Revert on zero output — matches outbound guard at toRemote.
|
|
433
|
+
if (localAmount == 0) revert JBSwapCCIPSucker_SwapFailed();
|
|
434
|
+
|
|
412
435
|
// Update the conversion rate so claims can proceed, then clear the pending swap.
|
|
413
436
|
_conversionRateOf[localToken][nonce] = ConversionRate({leafTotal: pending.leafTotal, localTotal: localAmount});
|
|
414
437
|
delete pendingSwapOf[localToken][nonce];
|
|
@@ -430,7 +453,10 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
430
453
|
if (_retrySwapLocked) revert JBSwapCCIPSucker_SwapPending(0);
|
|
431
454
|
// slither-disable-next-line events-maths
|
|
432
455
|
_currentClaimLeafIndex = claimData.leaf.index + 1;
|
|
456
|
+
// slither-disable-next-line reentrancy-eth
|
|
433
457
|
super.claim(claimData);
|
|
458
|
+
// Clear stale transient context to prevent leaking into same-tx emergency exits.
|
|
459
|
+
_currentClaimLeafIndex = 0;
|
|
434
460
|
}
|
|
435
461
|
|
|
436
462
|
//*********************************************************************//
|
|
@@ -453,14 +479,14 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
453
479
|
if (_currentClaimLeafIndex != 0) {
|
|
454
480
|
uint64 nonce = _findNonceForLeafIndex({token: token, leafIndex: _currentClaimLeafIndex - 1});
|
|
455
481
|
if (nonce != 0) {
|
|
482
|
+
// Gate on pending swaps — if a swap failed and hasn't been retried yet,
|
|
483
|
+
// claims must wait. This check must come BEFORE the leafTotal gate so that
|
|
484
|
+
// failed swaps (where _conversionRateOf was never written) still block claims.
|
|
485
|
+
if (pendingSwapOf[token][nonce].bridgeAmount > 0) {
|
|
486
|
+
revert JBSwapCCIPSucker_SwapPending(nonce);
|
|
487
|
+
}
|
|
456
488
|
ConversionRate storage rate = _conversionRateOf[token][nonce];
|
|
457
489
|
if (rate.leafTotal > 0) {
|
|
458
|
-
// Gate on pending swaps — if a swap failed and hasn't been retried yet,
|
|
459
|
-
// claims must wait. Check pendingSwapOf rather than localTotal == 0 to
|
|
460
|
-
// distinguish a pending swap from a legitimately zero-output swap.
|
|
461
|
-
if (pendingSwapOf[token][nonce].bridgeAmount > 0) {
|
|
462
|
-
revert JBSwapCCIPSucker_SwapPending(nonce);
|
|
463
|
-
}
|
|
464
490
|
amount = amount * rate.localTotal / rate.leafTotal;
|
|
465
491
|
}
|
|
466
492
|
}
|
|
@@ -219,7 +219,7 @@ library JBSuckerLib {
|
|
|
219
219
|
/// @param nonce The outbox nonce for this send.
|
|
220
220
|
/// @param root The merkle root of the outbox tree.
|
|
221
221
|
/// @param messageVersion The message format version.
|
|
222
|
-
/// @param
|
|
222
|
+
/// @param sourceTimestamp The `block.timestamp` on the source chain when the snapshot is taken.
|
|
223
223
|
/// @return message The constructed JBMessageRoot.
|
|
224
224
|
function buildSnapshotMessage(
|
|
225
225
|
IJBDirectory directory,
|
|
@@ -229,7 +229,7 @@ library JBSuckerLib {
|
|
|
229
229
|
uint64 nonce,
|
|
230
230
|
bytes32 root,
|
|
231
231
|
uint8 messageVersion,
|
|
232
|
-
|
|
232
|
+
uint256 sourceTimestamp
|
|
233
233
|
)
|
|
234
234
|
external
|
|
235
235
|
view
|
|
@@ -268,7 +268,7 @@ library JBSuckerLib {
|
|
|
268
268
|
sourceDecimals: _ETH_DECIMALS,
|
|
269
269
|
sourceSurplus: ethSurplus,
|
|
270
270
|
sourceBalance: ethBalance,
|
|
271
|
-
|
|
271
|
+
sourceTimestamp: sourceTimestamp
|
|
272
272
|
});
|
|
273
273
|
}
|
|
274
274
|
|
|
@@ -167,6 +167,12 @@ library JBSwapPoolLib {
|
|
|
167
167
|
IWrappedNativeToken(config.weth).withdraw(amountOut);
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
|
+
|
|
171
|
+
// V4 outputs native ETH for WETH-paired pools. If the caller requested WETH (not NATIVE_TOKEN),
|
|
172
|
+
// wrap the received ETH so the caller gets the token they expect.
|
|
173
|
+
if (isV4 && tokenOut != JBConstants.NATIVE_TOKEN && normalizedOut == config.weth) {
|
|
174
|
+
IWrappedNativeToken(config.weth).deposit{value: amountOut}();
|
|
175
|
+
}
|
|
170
176
|
}
|
|
171
177
|
|
|
172
178
|
/// @notice Execute the body of a V4 unlock callback. Called via DELEGATECALL from the sucker's
|
|
@@ -177,40 +183,50 @@ library JBSwapPoolLib {
|
|
|
177
183
|
/// @return Encoded output amount.
|
|
178
184
|
function executeV4UnlockCallback(IPoolManager poolManager, bytes calldata data) external returns (bytes memory) {
|
|
179
185
|
// Decode the swap parameters packed during _executeV4Swap.
|
|
180
|
-
(
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}),
|
|
189
|
-
hookData: ""
|
|
190
|
-
});
|
|
186
|
+
(
|
|
187
|
+
PoolKey memory key,
|
|
188
|
+
bool zeroForOne,
|
|
189
|
+
int256 amountSpecified,
|
|
190
|
+
uint160 sqrtPriceLimitX96,
|
|
191
|
+
uint256 minAmountOut,
|
|
192
|
+
address weth
|
|
193
|
+
) = abi.decode(data, (PoolKey, bool, int256, uint160, uint256, address));
|
|
191
194
|
|
|
192
|
-
// V4 sign convention: negative = we owe (input), positive = we're owed (output).
|
|
193
|
-
int128 delta0 = delta.amount0();
|
|
194
|
-
int128 delta1 = delta.amount1();
|
|
195
195
|
uint256 amountIn;
|
|
196
196
|
uint256 amountOut;
|
|
197
197
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
198
|
+
{
|
|
199
|
+
// Execute the swap through the V4 PoolManager.
|
|
200
|
+
BalanceDelta delta = poolManager.swap({
|
|
201
|
+
key: key,
|
|
202
|
+
params: SwapParams({
|
|
203
|
+
zeroForOne: zeroForOne, amountSpecified: amountSpecified, sqrtPriceLimitX96: sqrtPriceLimitX96
|
|
204
|
+
}),
|
|
205
|
+
hookData: ""
|
|
206
|
+
});
|
|
206
207
|
|
|
207
|
-
|
|
208
|
-
|
|
208
|
+
// V4 sign convention: negative = we owe (input), positive = we're owed (output).
|
|
209
|
+
int128 delta0 = delta.amount0();
|
|
210
|
+
int128 delta1 = delta.amount1();
|
|
211
|
+
|
|
212
|
+
// Extract input and output amounts based on swap direction.
|
|
213
|
+
if (zeroForOne) {
|
|
214
|
+
amountIn = uint256(uint128(-delta0));
|
|
215
|
+
amountOut = uint256(uint128(delta1));
|
|
216
|
+
} else {
|
|
217
|
+
amountIn = uint256(uint128(-delta1));
|
|
218
|
+
amountOut = uint256(uint128(delta0));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Enforce the minimum output from the TWAP quote.
|
|
222
|
+
if (amountOut < minAmountOut) revert JBSwapPoolLib_SlippageExceeded(amountOut, minAmountOut);
|
|
223
|
+
}
|
|
209
224
|
|
|
210
225
|
// Settle input (pay what we owe to the PoolManager).
|
|
211
226
|
Currency inputCurrency = zeroForOne ? key.currency0 : key.currency1;
|
|
212
227
|
if (Currency.unwrap(inputCurrency) == address(0)) {
|
|
213
|
-
// Native ETH: settle by sending ETH value directly.
|
|
228
|
+
// Native ETH: unwrap WETH if needed, then settle by sending ETH value directly.
|
|
229
|
+
if (weth != address(0)) IWrappedNativeToken(weth).withdraw(amountIn);
|
|
214
230
|
// slither-disable-next-line unused-return,arbitrary-send-eth
|
|
215
231
|
poolManager.settle{value: amountIn}();
|
|
216
232
|
} else {
|
|
@@ -826,17 +842,22 @@ library JBSwapPoolLib {
|
|
|
826
842
|
// Determine swap direction based on currency ordering in the pool key.
|
|
827
843
|
bool zeroForOne = Currency.unwrap(key.currency0) == v4In;
|
|
828
844
|
|
|
829
|
-
//
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
845
|
+
// Build the encoded unlock data in a scoped block to avoid stack-too-deep.
|
|
846
|
+
bytes memory unlockData;
|
|
847
|
+
{
|
|
848
|
+
// Compute the sqrt price limit from the expected amounts.
|
|
849
|
+
uint160 sqrtPriceLimitX96 = JBSwapLib.sqrtPriceLimitFromAmounts({
|
|
850
|
+
amountIn: amount, minimumAmountOut: minAmountOut, zeroForOne: zeroForOne
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
// V4 uses negative amounts for exact-input swaps.
|
|
854
|
+
int256 exactInputAmount = -int256(amount);
|
|
833
855
|
|
|
834
|
-
|
|
835
|
-
|
|
856
|
+
unlockData = abi.encode(key, zeroForOne, exactInputAmount, sqrtPriceLimitX96, minAmountOut, config.weth);
|
|
857
|
+
}
|
|
836
858
|
|
|
837
859
|
// Unlock the PoolManager and encode the swap parameters for the callback.
|
|
838
|
-
bytes memory result =
|
|
839
|
-
config.poolManager.unlock(abi.encode(key, zeroForOne, exactInputAmount, sqrtPriceLimitX96, minAmountOut));
|
|
860
|
+
bytes memory result = config.poolManager.unlock(unlockData);
|
|
840
861
|
|
|
841
862
|
// Decode the output amount returned by the unlock callback.
|
|
842
863
|
amountOut = abi.decode(result, (uint256));
|
|
@@ -17,9 +17,8 @@ import {JBInboxTreeRoot} from "./JBInboxTreeRoot.sol";
|
|
|
17
17
|
/// `sourceDecimals` precision.
|
|
18
18
|
/// @custom:member sourceBalance The total recorded balance on the source chain, denominated in `sourceCurrency` at
|
|
19
19
|
/// `sourceDecimals` precision.
|
|
20
|
-
/// @custom:member
|
|
21
|
-
///
|
|
22
|
-
/// token-local inbox root updates.
|
|
20
|
+
/// @custom:member sourceTimestamp The `block.timestamp` on the source chain when the snapshot was taken. Used by the
|
|
21
|
+
/// receiving chain to reject stale surplus/balance/supply updates without blocking token-local inbox root updates.
|
|
23
22
|
struct JBMessageRoot {
|
|
24
23
|
uint8 version;
|
|
25
24
|
bytes32 token;
|
|
@@ -30,5 +29,5 @@ struct JBMessageRoot {
|
|
|
30
29
|
uint8 sourceDecimals;
|
|
31
30
|
uint256 sourceSurplus;
|
|
32
31
|
uint256 sourceBalance;
|
|
33
|
-
|
|
32
|
+
uint256 sourceTimestamp;
|
|
34
33
|
}
|