@bananapus/suckers-v6 0.0.57 → 0.0.59

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/suckers-v6",
3
- "version": "0.0.57",
3
+ "version": "0.0.59",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@arbitrum/nitro-contracts": "3.2.0",
29
- "@bananapus/core-v6": "^0.0.60",
29
+ "@bananapus/core-v6": "^0.0.66",
30
30
  "@bananapus/permission-ids-v6": "^0.0.27",
31
31
  "@chainlink/contracts-ccip": "1.6.4",
32
32
  "@chainlink/local": "0.2.7",
@@ -16,7 +16,7 @@ contract JBBaseSucker is JBOptimismSucker {
16
16
  // ---------------------------- constructor -------------------------- //
17
17
  //*********************************************************************//
18
18
 
19
- /// @param deployer A contract that deploys the clones for this contracts.
19
+ /// @param deployer A contract that deploys clones of this contract.
20
20
  /// @param directory A contract storing directories of terminals and controllers for each project.
21
21
  /// @param permissions A contract storing permissions.
22
22
  /// @param prices The price oracle used to convert peer-chain balances and surplus.
@@ -35,7 +35,7 @@ contract JBCeloSucker is JBOptimismSucker {
35
35
  // ---------------------------- constructor -------------------------- //
36
36
  //*********************************************************************//
37
37
 
38
- /// @param deployer A contract that deploys the clones for this contracts.
38
+ /// @param deployer A contract that deploys clones of this contract.
39
39
  /// @param directory A contract storing directories of terminals and controllers for each project.
40
40
  /// @param permissions A contract storing permissions.
41
41
  /// @param prices The price oracle used to convert peer-chain balances and surplus.
@@ -135,7 +135,7 @@ contract JBCeloSucker is JBOptimismSucker {
135
135
  {
136
136
  index; // Silence unused parameter warning (not needed for Celo bridge).
137
137
 
138
- // Revert if there's a `msg.value`. The OP bridge does not expect to be paid.
138
+ // Revert if there's a `msg.value`. The Celo bridge does not expect to be paid.
139
139
  if (transportPayment != 0) {
140
140
  revert JBSucker_UnexpectedMsgValue({value: transportPayment});
141
141
  }
@@ -36,7 +36,7 @@ contract JBOptimismSucker is JBSucker, IJBOptimismSucker {
36
36
  // ---------------------------- constructor -------------------------- //
37
37
  //*********************************************************************//
38
38
 
39
- /// @param deployer A contract that deploys the clones for this contracts.
39
+ /// @param deployer A contract that deploys clones of this contract.
40
40
  /// @param directory A contract storing directories of terminals and controllers for each project.
41
41
  /// @param permissions A contract storing permissions.
42
42
  /// @param prices The price oracle used to convert peer-chain balances and surplus.
package/src/JBSucker.sol CHANGED
@@ -95,12 +95,10 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
95
95
  // ------------------------- public constants ------------------------ //
96
96
  //*********************************************************************//
97
97
 
98
- /// @notice A reasonable minimum gas limit for a basic cross-chain call. The minimum amount of gas required to call
99
- /// the `fromRemote` (successfully/safely) on the remote chain.
98
+ /// @notice A reasonable minimum gas limit for a basic cross-chain call to `fromRemote` on the remote chain.
100
99
  uint32 public constant override MESSENGER_BASE_GAS_LIMIT = 300_000;
101
100
 
102
- /// @notice A reasonable minimum gas limit used when bridging ERC-20s. The minimum amount of gas required to
103
- /// (successfully/safely) perform a transfer on the remote chain.
101
+ /// @notice A reasonable minimum gas limit for performing an ERC-20 transfer on the remote chain.
104
102
  uint32 public constant override MESSENGER_ERC20_MIN_GAS_LIMIT = 200_000;
105
103
 
106
104
  /// @notice The message format version. Used to reject incompatible messages from remote chains.
@@ -142,6 +140,15 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
142
140
  /// @notice The address of this contract's deployer.
143
141
  address public override deployer;
144
142
 
143
+ /// @notice The keccak256 hash of the leaf data committed at execution time, keyed by `(terminalToken,
144
+ /// leafIndex)`. Beneficiary contracts (e.g. `JBReferralSplitHook`) use this to authenticate post-hoc
145
+ /// settlement when their `claim()` call was front-run by a direct external caller — they re-derive the
146
+ /// hash from the claim data they hold and compare. Returns `bytes32(0)` for unexecuted indices —
147
+ /// `_buildTreeHash` is pre-image-resistant so zero unambiguously means "not executed".
148
+ /// @custom:param token The token whose inbox tree contains the leaf.
149
+ /// @custom:param index The leaf's index in the inbox tree.
150
+ mapping(address token => mapping(uint256 index => bytes32)) public override executedLeafHashOf;
151
+
145
152
  /// @notice The last known total token supply on the peer chain, updated each time a bridge message is received.
146
153
  /// @dev Used by data hooks to compute `effectiveTotalSupply = localSupply + sum(peerChainTotalSupply)` across all
147
154
  /// suckers, preventing cash out tax bypass on chains where a holder dominates the local supply.
@@ -601,7 +608,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
601
608
  account: _ownerOf(_projectId), projectId: _projectId, permissionId: JBPermissionIds.SET_SUCKER_DEPRECATION
602
609
  });
603
610
 
604
- // This is the earliest time for when the sucker can be considered deprecated.
611
+ // This is the earliest time the sucker can be considered deprecated.
605
612
  // There is a mandatory delay to allow for remaining messages to be received.
606
613
  // This should be called on both sides of the suckers, preferably with a matching timestamp.
607
614
  uint256 nextEarliestDeprecationTime = block.timestamp + _maxMessagingDelay();
@@ -804,7 +811,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
804
811
  return JBSuckerState.ENABLED;
805
812
  }
806
813
 
807
- // The sucker will soon be considered deprecated, this functions only as a warning to users.
814
+ // The sucker is close to deprecation; this state only warns users.
808
815
  // Deprecation state is intentionally time-based.
809
816
  // forge-lint: disable-next-line(block-timestamp)
810
817
  if (block.timestamp < _deprecatedAfter - _maxMessagingDelay()) {
@@ -927,40 +934,35 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
927
934
  revert JBSucker_NoTerminalForToken({projectId: cachedProjectId, token: token});
928
935
  }
929
936
 
930
- // Perform the `addToBalance` for ERC-20 tokens.
931
- if (token != JBConstants.NATIVE_TOKEN) {
932
- // Record the balance before the transfer for the sanity check.
933
- uint256 balanceBefore = IERC20(token).balanceOf(address(this));
934
-
935
- // Approve the terminal to spend the ERC-20 tokens.
937
+ // Native and ERC-20 differ only in (a) value attachment to the call, and (b) ERC-20 requires an
938
+ // allowance grant + post-transfer balance assertion to catch fee-on-transfer / non-conforming tokens.
939
+ // The terminal call itself is identical for both, so it lives outside the branch.
940
+ uint256 nativeValue;
941
+ uint256 balanceBefore;
942
+ bool isErc20 = token != JBConstants.NATIVE_TOKEN;
943
+ if (isErc20) {
944
+ balanceBefore = IERC20(token).balanceOf(address(this));
936
945
  SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: amount});
946
+ } else {
947
+ nativeValue = amount;
948
+ }
937
949
 
938
- // Add the tokens to the project's balance.
939
- terminal.addToBalanceOf({
940
- projectId: cachedProjectId,
941
- token: token,
942
- amount: amount,
943
- shouldReturnHeldFees: false,
944
- memo: "",
945
- metadata: ""
946
- });
950
+ terminal.addToBalanceOf{value: nativeValue}({
951
+ projectId: cachedProjectId,
952
+ token: token,
953
+ amount: amount,
954
+ shouldReturnHeldFees: false,
955
+ memo: "",
956
+ metadata: ""
957
+ });
947
958
 
948
- // Sanity check: make sure we transferred the full amount.
959
+ if (isErc20) {
960
+ // Sanity check: catches fee-on-transfer / non-conforming ERC-20s that move less than `amount`.
949
961
  assert(IERC20(token).balanceOf(address(this)) == balanceBefore - amount);
950
- } else {
951
- // If the token is the native token, send ETH with the call.
952
- terminal.addToBalanceOf{value: amount}({
953
- projectId: cachedProjectId,
954
- token: token,
955
- amount: amount,
956
- shouldReturnHeldFees: false,
957
- memo: "",
958
- metadata: ""
959
- });
960
962
  }
961
963
  }
962
964
 
963
- /// @notice The action(s) to perform after a user has succesfully proven their claim.
965
+ /// @notice Actions to perform after a user has successfully proven their claim.
964
966
  /// @param terminalToken The terminal token to suck.
965
967
  /// @param terminalTokenAmount The amount of terminal tokens.
966
968
  /// @param projectTokenAmount The amount of project tokens.
@@ -1204,10 +1206,8 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1204
1206
  }
1205
1207
 
1206
1208
  /// @notice Send the outbox root for the specified token to the remote peer.
1207
- /// @dev The call may have a `transportPayment` for bridging native tokens. Require it to be `0` if it is not
1208
- /// needed. Make sure if a value being paid to the bridge is expected to revert if the given value is `0`.
1209
- /// @param transportPayment the amount of `msg.value` that is going to get paid for sending this message. (usually
1210
- /// derived from `msg.value`)
1209
+ /// @dev Some bridges require a nonzero `transportPayment`; zero-cost bridges must reject nonzero values.
1210
+ /// @param transportPayment The amount of `msg.value` paid to the transport for this message.
1211
1211
  /// @param token The terminal token to bridge the merkle tree of.
1212
1212
  /// @param remoteToken The remote token which the `token` is mapped to.
1213
1213
  function _sendRoot(uint256 transportPayment, address token, JBRemoteToken memory remoteToken) internal virtual {
@@ -1324,16 +1324,21 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1324
1324
  // Register the leaf as executed to prevent double-spending.
1325
1325
  _executedFor[terminalToken].set(index);
1326
1326
 
1327
- // Calculate the root based on the leaf, the branch, and the index.
1328
- // Compare to the current root, Revert if they do not match.
1329
- _validateBranchRoot({
1330
- expectedRoot: _inboxOf[terminalToken].root,
1327
+ // Compute the leaf hash once. It's used twice: stored in `executedLeafHashOf` (so beneficiary contracts
1328
+ // can authenticate post-hoc settlement when their `claim()` was front-run) and passed to
1329
+ // `_validateBranchRoot` for merkle verification. The bare executed bitmap proves "some leaf at index I
1330
+ // was executed" but not "which leaf"; storing the hash binds the index to the actual leaf content.
1331
+ bytes32 leafHash = _buildTreeHash({
1331
1332
  projectTokenCount: projectTokenCount,
1332
1333
  terminalTokenAmount: terminalTokenAmount,
1333
1334
  beneficiary: beneficiary,
1334
- metadata: metadata,
1335
- index: index,
1336
- leaves: leaves
1335
+ metadata: metadata
1336
+ });
1337
+ executedLeafHashOf[terminalToken][index] = leafHash;
1338
+
1339
+ // Calculate the root and compare it to the current inbox root.
1340
+ _validateBranchRoot({
1341
+ expectedRoot: _inboxOf[terminalToken].root, leafHash: leafHash, index: index, leaves: leaves
1337
1342
  });
1338
1343
  }
1339
1344
 
@@ -1341,17 +1346,12 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1341
1346
  /// @dev This is a virtual function to allow a tests to override the behavior, it should never be overwritten
1342
1347
  /// otherwise.
1343
1348
  /// @param expectedRoot The expected merkle root to validate against.
1344
- /// @param projectTokenCount The number of project tokens in the leaf.
1345
- /// @param terminalTokenAmount The amount of terminal tokens in the leaf.
1346
- /// @param beneficiary The beneficiary address in the leaf (bytes32 for cross-VM compatibility).
1349
+ /// @param leafHash The precomputed leaf hash (`_buildTreeHash` output) for the leaf being validated.
1347
1350
  /// @param index The index of the leaf in the merkle tree.
1348
1351
  /// @param leaves The merkle branch proving the leaf's inclusion.
1349
1352
  function _validateBranchRoot(
1350
1353
  bytes32 expectedRoot,
1351
- uint256 projectTokenCount,
1352
- uint256 terminalTokenAmount,
1353
- bytes32 beneficiary,
1354
- bytes32 metadata,
1354
+ bytes32 leafHash,
1355
1355
  uint256 index,
1356
1356
  bytes32[_TREE_DEPTH] calldata leaves
1357
1357
  )
@@ -1360,18 +1360,9 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1360
1360
  {
1361
1361
  // Calculate the root based on the leaf, the branch, and the index.
1362
1362
  // Delegates to JBSuckerLib (via DELEGATECALL) to keep MerkleLib.branchRoot bytecode out of each sucker.
1363
- bytes32 root = JBSuckerLib.computeBranchRoot({
1364
- item: _buildTreeHash({
1365
- projectTokenCount: projectTokenCount,
1366
- terminalTokenAmount: terminalTokenAmount,
1367
- beneficiary: beneficiary,
1368
- metadata: metadata
1369
- }),
1370
- branch: leaves,
1371
- index: index
1372
- });
1363
+ bytes32 root = JBSuckerLib.computeBranchRoot({item: leafHash, branch: leaves, index: index});
1373
1364
 
1374
- // Compare to the current root, Revert if they do not match.
1365
+ // Revert if the computed root does not match the expected inbox root.
1375
1366
  if (root != expectedRoot) {
1376
1367
  revert JBSucker_InvalidProof({root: root, inboxRoot: expectedRoot});
1377
1368
  }
@@ -1453,14 +1444,15 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1453
1444
  _executedFor[emergencyExitAddress].set(index);
1454
1445
  }
1455
1446
 
1456
- // Calculate the root based on the leaf, the branch, and the index.
1457
- // Compare to the current root, Revert if they do not match.
1447
+ // Calculate the root and compare it to the current outbox root.
1458
1448
  _validateBranchRoot({
1459
1449
  expectedRoot: _computeOutboxRoot(_outboxOf[terminalToken].tree),
1460
- projectTokenCount: projectTokenCount,
1461
- terminalTokenAmount: terminalTokenAmount,
1462
- beneficiary: beneficiary,
1463
- metadata: metadata,
1450
+ leafHash: _buildTreeHash({
1451
+ projectTokenCount: projectTokenCount,
1452
+ terminalTokenAmount: terminalTokenAmount,
1453
+ beneficiary: beneficiary,
1454
+ metadata: metadata
1455
+ }),
1464
1456
  index: index,
1465
1457
  leaves: leaves
1466
1458
  });
@@ -35,6 +35,7 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
35
35
  error JBSuckerRegistry_InvalidDeployer(IJBSuckerDeployer deployer);
36
36
  error JBSuckerRegistry_SuckerDoesNotBelongToProject(uint256 projectId, address sucker);
37
37
  error JBSuckerRegistry_SuckerIsNotDeprecated(address sucker, JBSuckerState suckerState);
38
+ error JBSuckerRegistry_ZeroPeerChainId(address sucker);
38
39
 
39
40
  //*********************************************************************//
40
41
  // ------------------------- public constants ------------------------ //
@@ -108,6 +109,18 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
108
109
  // ------------------------- external views -------------------------- //
109
110
  //*********************************************************************//
110
111
 
112
+ /// @notice All suckers for a project, INCLUDING deprecated entries that are no longer listed in `suckersOf`.
113
+ /// @dev Used by consumers that need to detect "has any sucker ever peered to chain X?" — e.g. to prevent
114
+ /// premature burn of bridgeable credit by `JBReferralSplitHook.burnUnbridgeableCreditFor`. Returns every key
115
+ /// from `_suckersOf[projectId]` regardless of active/deprecated state. Order matches the underlying
116
+ /// `EnumerableMap` iteration order (insertion order, with swap-and-pop semantics on removal — which this
117
+ /// registry doesn't trigger, since deprecation transitions to `_SUCKER_DEPRECATED` rather than deleting).
118
+ /// @param projectId The ID of the project to get the suckers of.
119
+ /// @return suckers The addresses of every sucker ever registered for `projectId`.
120
+ function allSuckersOf(uint256 projectId) external view override returns (address[] memory suckers) {
121
+ return _suckersOf[projectId].keys();
122
+ }
123
+
111
124
  /// @notice Whether the given address is a sucker (active or deprecated) that was deployed through this registry for
112
125
  /// the specified project. Used by controllers to authorize mint calls from suckers.
113
126
  /// @param projectId The ID of the project to check for.
@@ -142,8 +155,9 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
142
155
  (, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
143
156
  if (val == _SUCKER_EXISTS) {
144
157
  IJBSucker sucker = IJBSucker(allSuckers[i]);
145
- pairs[j] =
146
- JBSuckersPair({local: address(sucker), remote: sucker.peer(), remoteChainId: sucker.peerChainId()});
158
+ pairs[j] = JBSuckersPair({
159
+ local: address(sucker), remote: sucker.peer(), remoteChainId: _peerChainIdOf(sucker)
160
+ });
147
161
  unchecked {
148
162
  ++j;
149
163
  }
@@ -220,7 +234,7 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
220
234
  try IJBSucker(allSuckers[i]).peerChainBalanceOf({decimals: decimals, currency: currency}) returns (
221
235
  JBDenominatedAmount memory amt
222
236
  ) {
223
- uint256 chainId = IJBSucker(allSuckers[i]).peerChainId();
237
+ uint256 chainId = _peerChainIdOf(IJBSucker(allSuckers[i]));
224
238
  scratch.chainCount = _recordPeerValue({
225
239
  scratch: scratch,
226
240
  chainId: chainId,
@@ -276,7 +290,7 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
276
290
  try IJBSucker(allSuckers[i]).peerChainSurplusOf({decimals: decimals, currency: currency}) returns (
277
291
  JBDenominatedAmount memory amt
278
292
  ) {
279
- uint256 chainId = IJBSucker(allSuckers[i]).peerChainId();
293
+ uint256 chainId = _peerChainIdOf(IJBSucker(allSuckers[i]));
280
294
  scratch.chainCount = _recordPeerValue({
281
295
  scratch: scratch,
282
296
  chainId: chainId,
@@ -319,7 +333,7 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
319
333
  // Include both active and deprecated suckers in aggregate economic views.
320
334
  if (val == _SUCKER_EXISTS || val == _SUCKER_DEPRECATED) {
321
335
  try IJBSucker(allSuckers[i]).peerChainTotalSupply() returns (uint256 supply) {
322
- uint256 chainId = IJBSucker(allSuckers[i]).peerChainId();
336
+ uint256 chainId = _peerChainIdOf(IJBSucker(allSuckers[i]));
323
337
  scratch.chainCount = _recordPeerValue({
324
338
  scratch: scratch,
325
339
  chainId: chainId,
@@ -377,6 +391,14 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
377
391
  scratch.hasActiveValue = new bool[](len);
378
392
  }
379
393
 
394
+ /// @notice Reads a sucker's peer chain ID, reverting if the sucker cannot identify a real peer chain.
395
+ /// @param sucker The sucker to query.
396
+ /// @return chainId The non-zero peer chain ID.
397
+ function _peerChainIdOf(IJBSucker sucker) internal view returns (uint256 chainId) {
398
+ chainId = sucker.peerChainId();
399
+ if (chainId == 0) revert JBSuckerRegistry_ZeroPeerChainId({sucker: address(sucker)});
400
+ }
401
+
380
402
  /// @notice Records a project-scoped peer-chain aggregate value.
381
403
  /// @dev Callers pass scratch arrays sized from `_suckersOf[projectId].keys()`, so entries are already scoped to
382
404
  /// the project being aggregated. For each peer chain, active suckers replace deprecated suckers; deprecated
@@ -544,6 +566,7 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
544
566
  // Create the sucker.
545
567
  IJBSucker sucker = configuration.deployer
546
568
  .createForSender({localProjectId: projectId, salt: salt, peer: configuration.peer});
569
+ _peerChainIdOf(sucker);
547
570
  suckers[i] = address(sucker);
548
571
 
549
572
  // Store the sucker as being deployed for this project.
@@ -39,7 +39,9 @@ import {JBSwapPoolLib} from "./libraries/JBSwapPoolLib.sol";
39
39
 
40
40
  // Local: structs (alphabetized).
41
41
  import {JBClaim} from "./structs/JBClaim.sol";
42
+ import {JBConversionRate} from "./structs/JBConversionRate.sol";
42
43
  import {JBMessageRoot} from "./structs/JBMessageRoot.sol";
44
+ import {JBPendingSwap} from "./structs/JBPendingSwap.sol";
43
45
  import {JBRemoteToken} from "./structs/JBRemoteToken.sol";
44
46
 
45
47
  /// @notice A `JBCCIPSucker` extension that swaps between local and bridge tokens using the best
@@ -71,7 +73,7 @@ import {JBRemoteToken} from "./structs/JBRemoteToken.sol";
71
73
  ///
72
74
  /// **Inbound swap resilience**: If a swap reverts during `ccipReceive` (due to insufficient
73
75
  /// liquidity, stale TWAP observations, or extreme price impact), the CCIP message still succeeds.
74
- /// The unswapped bridge tokens are stored in a `PendingSwap` and the merkle root is recorded
76
+ /// The unswapped bridge tokens are stored in a `JBPendingSwap` and the merkle root is recorded
75
77
  /// normally. Claims for the affected batch are gated until `retrySwap` is called successfully.
76
78
  /// Anyone can call `retrySwap` once swap conditions improve.
77
79
  ///
@@ -88,6 +90,7 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
88
90
 
89
91
  error JBSwapCCIPSucker_BatchNotReceived(uint64 nonce);
90
92
  error JBSwapCCIPSucker_CallerNotPoolManager(address caller);
93
+ error JBSwapCCIPSucker_DuplicateBatch(uint64 nonce);
91
94
  error JBSwapCCIPSucker_InvalidBridgeToken(address bridgeToken, address wrappedNativeToken);
92
95
  error JBSwapCCIPSucker_NoPendingSwap(address localToken, uint64 nonce, bool retrySwapLocked);
93
96
  error JBSwapCCIPSucker_OnlySelf(address caller, address expected);
@@ -135,31 +138,11 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
135
138
  // ------------------- internal stored properties -------------------- //
136
139
  //*********************************************************************//
137
140
 
138
- /// @notice Bridge tokens from a failed inbound swap, stored for later retry via `retrySwap`.
139
- /// @custom:member bridgeToken The bridge token received from CCIP.
140
- /// @custom:member bridgeAmount Amount of bridge tokens to swap.
141
- /// @custom:member leafTotal Original leaf-denomination total (for conversion rate).
142
- struct PendingSwap {
143
- address bridgeToken;
144
- uint256 bridgeAmount;
145
- uint256 leafTotal;
146
- }
147
-
148
141
  /// @notice Pending (failed) inbound swaps, keyed by local token and batch nonce.
149
142
  /// @dev Populated when `ccipReceive` swap fails; cleared when `retrySwap` succeeds.
150
143
  /// @custom:param localToken The local token the swap targets.
151
144
  /// @custom:param nonce The CCIP nonce identifying the batch.
152
- mapping(address localToken => mapping(uint64 nonce => PendingSwap)) public pendingSwapOf;
153
-
154
- /// @notice Immutable conversion rate for one received root batch, keyed by nonce.
155
- /// @dev Each batch stores its total leaf and local amounts. Individual claims compute their
156
- /// scaled amount as `claimLeafAmount * localTotal / leafTotal` — no mutable state changes.
157
- /// @custom:member leafTotal Total leaf-denomination (source chain) amount for this batch.
158
- /// @custom:member localTotal Total local-denomination (after swap) amount for this batch.
159
- struct ConversionRate {
160
- uint256 leafTotal;
161
- uint256 localTotal;
162
- }
145
+ mapping(address localToken => mapping(uint64 nonce => JBPendingSwap)) public pendingSwapOf;
163
146
 
164
147
  /// @notice End leaf index (exclusive) for each received root batch, keyed by token and nonce.
165
148
  /// @custom:param token The local token address.
@@ -176,7 +159,7 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
176
159
  /// @notice Conversion rate for each received root batch, keyed by token and nonce.
177
160
  /// @custom:param token The local token address.
178
161
  /// @custom:param nonce The CCIP nonce identifying the batch.
179
- mapping(address token => mapping(uint64 nonce => ConversionRate)) internal _conversionRateOf;
162
+ mapping(address token => mapping(uint64 nonce => JBConversionRate)) internal _conversionRateOf;
180
163
 
181
164
  /// @notice Count of populated batch nonces per token. Appended exactly once per batch in
182
165
  /// `ccipReceive`, so it equals the number of received batches independent of CCIP ordering.
@@ -336,6 +319,19 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
336
319
  }
337
320
  }
338
321
 
322
+ // Detect an already-processed batch before the swap path. The inbox nonce alone cannot be used here:
323
+ // CCIP can deliver nonce 2 before nonce 1, and nonce 1 still needs its self-described batch metadata.
324
+ if (
325
+ _batchEndOf[localToken][nonce] != 0 || _conversionRateOf[localToken][nonce].leafTotal != 0
326
+ || pendingSwapOf[localToken][nonce].bridgeAmount != 0
327
+ ) {
328
+ if (deliveredAmount != 0) {
329
+ revert JBSwapCCIPSucker_DuplicateBatch({nonce: nonce});
330
+ }
331
+
332
+ return;
333
+ }
334
+
339
335
  // After the validation block above, `deliveredToken != address(0)` iff a delivery was present,
340
336
  // because the invariants ensure it equals `BRIDGE_TOKEN` (a non-zero ERC-20) whenever there is one.
341
337
  if (deliveredToken != address(0)) {
@@ -378,61 +374,55 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
378
374
  //
379
375
  // Detect "already seen" without extra storage: a nonce has been processed if it has
380
376
  // either a batch range (batchEnd > 0) or a conversion rate / pending swap recorded.
381
- if (
382
- _batchEndOf[localToken][nonce] == 0 && _conversionRateOf[localToken][nonce].leafTotal == 0
383
- && pendingSwapOf[localToken][nonce].leafTotal == 0
384
- ) {
385
- // Record the batch range so _findNonceForLeafIndex can resolve leaf ownership
386
- // independently of nonce ordering. Each nonce is self-describing: [start, end).
387
- if (batchEnd > 0) {
388
- // Record this batch's half-open leaf range `[batchStart, batchEnd)`. Self-
389
- // describing per-nonce — no implicit chain across nonces — so out-of-order
390
- // delivery can still resolve a leaf to its batch.
391
- _batchStartOf[localToken][nonce] = batchStart;
392
- _batchEndOf[localToken][nonce] = batchEnd;
393
-
394
- // Append `nonce` to the populated-nonce list for this token. The outer
395
- // `_batchEndOf == 0 && _conversionRateOf == 0 && pendingSwapOf == 0` guard
396
- // fires at most once per (token, nonce), so each populated nonce is appended
397
- // exactly once the array stays duplicate-free without extra checks.
398
- //
399
- // Reading `_populatedNonceCount[localToken]` first into a local lets us write
400
- // the new slot and the new count in a single read-modify-write pair (one
401
- // SLOAD, two SSTOREs to distinct slots). The `unchecked` increment is safe:
402
- // `priorCount` is bounded by the total number of populated nonces, which is
403
- // upper-bounded by the CCIP nonce space (`uint64`) — overflow requires more
404
- // batches than `uint64.max`, which the inbox can never produce.
405
- uint64 priorCount = _populatedNonceCount[localToken];
406
- _populatedNonceByIndex[localToken][priorCount] = nonce;
407
- unchecked {
408
- _populatedNonceCount[localToken] = priorCount + 1;
409
- }
377
+ // Record the batch range so _findNonceForLeafIndex can resolve leaf ownership
378
+ // independently of nonce ordering. Each nonce is self-describing: [start, end).
379
+ if (batchEnd > 0) {
380
+ // Record this batch's half-open leaf range `[batchStart, batchEnd)`. Self-
381
+ // describing per-nonce no implicit chain across nonces so out-of-order
382
+ // delivery can still resolve a leaf to its batch.
383
+ _batchStartOf[localToken][nonce] = batchStart;
384
+ _batchEndOf[localToken][nonce] = batchEnd;
385
+
386
+ // Append `nonce` to the populated-nonce list for this token. The duplicate guard
387
+ // above fires at most once per (token, nonce), so each populated nonce is appended
388
+ // exactly once — the array stays duplicate-free without extra checks.
389
+ //
390
+ // Reading `_populatedNonceCount[localToken]` first into a local lets us write
391
+ // the new slot and the new count in a single read-modify-write pair (one
392
+ // SLOAD, two SSTOREs to distinct slots). The `unchecked` increment is safe:
393
+ // `priorCount` is bounded by the total number of populated nonces, which is
394
+ // upper-bounded by the CCIP nonce space (`uint64`) — overflow requires more
395
+ // batches than `uint64.max`, which the inbox can never produce.
396
+ uint64 priorCount = _populatedNonceCount[localToken];
397
+ _populatedNonceByIndex[localToken][priorCount] = nonce;
398
+ unchecked {
399
+ _populatedNonceCount[localToken] = priorCount + 1;
410
400
  }
401
+ }
411
402
 
412
- // Store pendingSwapOf for failed swaps now that nonce is validated.
413
- if (swapFailed) {
414
- pendingSwapOf[localToken][nonce] =
415
- PendingSwap({bridgeToken: deliveredToken, bridgeAmount: deliveredAmount, leafTotal: leafTotal});
416
- }
403
+ // Store pendingSwapOf for failed swaps now that nonce is validated.
404
+ if (swapFailed) {
405
+ pendingSwapOf[localToken][nonce] =
406
+ JBPendingSwap({bridgeToken: deliveredToken, bridgeAmount: deliveredAmount, leafTotal: leafTotal});
407
+ }
417
408
 
418
- // Zero-output swap guard: When a swap succeeds but returns zero local tokens, the
419
- // batch must NOT be marked claimable. Without this guard, `_addToBalance` would see
420
- // `pendingSwapOf.bridgeAmount == 0` (no pending swap stored) and allow claims to
421
- // proceed — minting the full bridged project-token amount while adding zero terminal
422
- // backing, breaking cross-chain solvency.
423
- //
424
- // Route zero-output swaps into `pendingSwapOf` so the swap can be retried via
425
- // `retrySwap` once pool conditions improve. Only store the conversion rate when
426
- // the swap produced a positive local amount.
427
- if (leafTotal > 0 && !swapFailed) {
428
- if (localAmount == 0 && deliveredAmount > 0) {
429
- pendingSwapOf[localToken][nonce] = PendingSwap({
430
- bridgeToken: deliveredToken, bridgeAmount: deliveredAmount, leafTotal: leafTotal
431
- });
432
- } else {
433
- _conversionRateOf[localToken][nonce] =
434
- ConversionRate({leafTotal: leafTotal, localTotal: localAmount});
435
- }
409
+ // Zero-output swap guard: When a swap succeeds but returns zero local tokens, the
410
+ // batch must NOT be marked claimable. Without this guard, `_addToBalance` would see
411
+ // `pendingSwapOf.bridgeAmount == 0` (no pending swap stored) and allow claims to
412
+ // proceed — minting the full bridged project-token amount while adding zero terminal
413
+ // backing, breaking cross-chain solvency.
414
+ //
415
+ // Route zero-output swaps into `pendingSwapOf` so the swap can be retried via
416
+ // `retrySwap` once pool conditions improve. Only store the conversion rate when
417
+ // the swap produced a positive local amount.
418
+ if (leafTotal > 0 && !swapFailed) {
419
+ if (localAmount == 0 && deliveredAmount > 0) {
420
+ pendingSwapOf[localToken][nonce] = JBPendingSwap({
421
+ bridgeToken: deliveredToken, bridgeAmount: deliveredAmount, leafTotal: leafTotal
422
+ });
423
+ } else {
424
+ _conversionRateOf[localToken][nonce] =
425
+ JBConversionRate({leafTotal: leafTotal, localTotal: localAmount});
436
426
  }
437
427
  }
438
428
  } else {
@@ -497,7 +487,7 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
497
487
  }
498
488
  _retrySwapLocked = true;
499
489
 
500
- PendingSwap memory pending = pendingSwapOf[localToken][nonce];
490
+ JBPendingSwap memory pending = pendingSwapOf[localToken][nonce];
501
491
  if (pending.bridgeAmount == 0) {
502
492
  revert JBSwapCCIPSucker_NoPendingSwap({
503
493
  localToken: localToken, nonce: nonce, retrySwapLocked: _retrySwapLocked
@@ -508,7 +498,7 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
508
498
  _executeSwapOrRevert({tokenIn: pending.bridgeToken, tokenOut: localToken, amount: pending.bridgeAmount});
509
499
 
510
500
  // Update the conversion rate so claims can proceed, then clear the pending swap.
511
- _conversionRateOf[localToken][nonce] = ConversionRate({leafTotal: pending.leafTotal, localTotal: localAmount});
501
+ _conversionRateOf[localToken][nonce] = JBConversionRate({leafTotal: pending.leafTotal, localTotal: localAmount});
512
502
  delete pendingSwapOf[localToken][nonce];
513
503
 
514
504
  _retrySwapLocked = false;
@@ -564,7 +554,7 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
564
554
  if (pendingSwapOf[token][nonce].bridgeAmount > 0) {
565
555
  revert JBSwapCCIPSucker_SwapPending({nonce: nonce});
566
556
  }
567
- ConversionRate storage rate = _conversionRateOf[token][nonce];
557
+ JBConversionRate storage rate = _conversionRateOf[token][nonce];
568
558
  if (rate.leafTotal > 0) {
569
559
  amount = amount * rate.localTotal / rate.leafTotal;
570
560
  }
@@ -108,6 +108,18 @@ interface IJBSucker is IERC165 {
108
108
  /// @return The deployer address.
109
109
  function deployer() external view returns (address);
110
110
 
111
+ /// @notice The keccak256 hash of the leaf data committed at execution time, for the leaf at the given
112
+ /// `(terminalToken, index)`. Returns `bytes32(0)` for unexecuted indices.
113
+ /// @dev Beneficiary contracts (e.g. `JBReferralSplitHook`) use this to authenticate post-hoc settlement when
114
+ /// their `claim()` call was front-run by a direct external caller — they re-derive the hash from the claim
115
+ /// data they hold and compare. The hash is computed via `_buildTreeHash(projectTokenCount,
116
+ /// terminalTokenAmount, beneficiary, metadata)` and is pre-image-resistant, so zero unambiguously means
117
+ /// "not executed".
118
+ /// @param token The terminal token whose tree contains the leaf.
119
+ /// @param index The index of the leaf in the inbox tree.
120
+ /// @return hash The committed leaf hash (or `bytes32(0)` if unexecuted).
121
+ function executedLeafHashOf(address token, uint256 index) external view returns (bytes32 hash);
122
+
111
123
  /// @notice The inbox merkle tree root for a given token.
112
124
  /// @param token The local terminal token.
113
125
  /// @return The inbox tree root.
@@ -54,6 +54,14 @@ interface IJBSuckerRegistry {
54
54
  /// @return The projects contract.
55
55
  function PROJECTS() external view returns (IJBProjects);
56
56
 
57
+ /// @notice All suckers for a project, INCLUDING deprecated entries that are no longer listed in `suckersOf`.
58
+ /// @dev Used by consumers that need to detect "has any sucker ever peered to chain X?" — e.g. to prevent
59
+ /// premature burn of bridgeable credit by `JBReferralSplitHook.burnUnbridgeableCreditFor`. Returns every key
60
+ /// from `_suckersOf[projectId]` regardless of active/deprecated state.
61
+ /// @param projectId The ID of the project.
62
+ /// @return The addresses of every sucker ever registered for `projectId`.
63
+ function allSuckersOf(uint256 projectId) external view returns (address[] memory);
64
+
57
65
  /// @notice Returns true if the specified sucker belongs to the specified project and was deployed through this
58
66
  /// registry.
59
67
  /// @param projectId The ID of the project to check for.
@@ -0,0 +1,12 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.0;
3
+
4
+ /// @notice Immutable conversion rate for one received root batch, keyed by nonce.
5
+ /// @dev Each batch stores its total leaf and local amounts. Individual claims compute their scaled amount as
6
+ /// `claimLeafAmount * localTotal / leafTotal` — no mutable state changes.
7
+ /// @custom:member leafTotal Total leaf-denomination (source chain) amount for this batch.
8
+ /// @custom:member localTotal Total local-denomination (after swap) amount for this batch.
9
+ struct JBConversionRate {
10
+ uint256 leafTotal;
11
+ uint256 localTotal;
12
+ }
@@ -0,0 +1,12 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.0;
3
+
4
+ /// @notice Bridge tokens from a failed inbound swap, stored for later retry via `retrySwap`.
5
+ /// @custom:member bridgeToken The bridge token received from CCIP.
6
+ /// @custom:member bridgeAmount Amount of bridge tokens to swap.
7
+ /// @custom:member leafTotal Original leaf-denomination total (for conversion rate).
8
+ struct JBPendingSwap {
9
+ address bridgeToken;
10
+ uint256 bridgeAmount;
11
+ uint256 leafTotal;
12
+ }