@bananapus/suckers-v6 0.0.47 → 0.0.49

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.
@@ -23,11 +23,10 @@ library SuckerDeploymentLib {
23
23
  Vm internal constant vm = Vm(VM_ADDRESS);
24
24
 
25
25
  function getDeployment(string memory path) internal returns (SuckerDeployment memory deployment) {
26
- // get chainId for which we need to get the deployment.
26
+ // Match the current chain ID to the Sphinx network name used in deployment artifacts.
27
27
  uint256 chainId = block.chainid;
28
28
 
29
- // Deploy to get the constants.
30
- // TODO: get constants without deploy.
29
+ // `SphinxConstants` exposes Sphinx's supported chain ID to network name mapping.
31
30
  SphinxConstants sphinxConstants = new SphinxConstants();
32
31
  NetworkInfo[] memory networks = sphinxConstants.getNetworkInfoArray();
33
32
 
@@ -55,14 +54,13 @@ library SuckerDeploymentLib {
55
54
  })
56
55
  );
57
56
 
58
- bytes32 _network = keccak256(abi.encodePacked(networkName));
59
- bool _isMainnet = _network == keccak256("ethereum") || _network == keccak256("ethereum_sepolia");
60
- // forge-lint: disable-next-line(mixed-case-variable)
61
- bool _isOP = _network == keccak256("optimism") || _network == keccak256("optimism_sepolia");
62
- bool _isBase = _network == keccak256("base") || _network == keccak256("base_sepolia");
63
- bool _isArb = _network == keccak256("arbitrum") || _network == keccak256("arbitrum_sepolia");
57
+ bytes32 networkHash = keccak256(abi.encodePacked(networkName));
58
+ bool isMainnet = networkHash == keccak256("ethereum") || networkHash == keccak256("ethereum_sepolia");
59
+ bool isOp = networkHash == keccak256("optimism") || networkHash == keccak256("optimism_sepolia");
60
+ bool isBase = networkHash == keccak256("base") || networkHash == keccak256("base_sepolia");
61
+ bool isArb = networkHash == keccak256("arbitrum") || networkHash == keccak256("arbitrum_sepolia");
64
62
 
65
- if (_isMainnet || _isOP) {
63
+ if (isMainnet || isOp) {
66
64
  deployment.optimismDeployer = IJBSuckerDeployer(
67
65
  _getDeploymentAddress({
68
66
  path: path,
@@ -73,7 +71,7 @@ library SuckerDeploymentLib {
73
71
  );
74
72
  }
75
73
 
76
- if (_isMainnet || _isBase) {
74
+ if (isMainnet || isBase) {
77
75
  deployment.baseDeployer = IJBSuckerDeployer(
78
76
  _getDeploymentAddress({
79
77
  path: path,
@@ -84,7 +82,7 @@ library SuckerDeploymentLib {
84
82
  );
85
83
  }
86
84
 
87
- if (_isMainnet || _isArb) {
85
+ if (isMainnet || isArb) {
88
86
  deployment.arbitrumDeployer = IJBSuckerDeployer(
89
87
  _getDeploymentAddress({
90
88
  path: path,
@@ -255,16 +255,17 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
255
255
  // If the token is an ERC-20, bridge it to the peer.
256
256
  // If the amount is `0` then we do not need to bridge any ERC20.
257
257
  if (token != JBConstants.NATIVE_TOKEN && amount != 0) {
258
+ address gateway;
258
259
  uint256 tokenTransportCost;
259
260
  uint256 maxSubmissionCostERC20;
260
261
  {
261
262
  // Get the exact calldata length the gateway will create for the retryable ticket.
262
263
  // The Arbitrum Inbox validates maxSubmissionCost against this actual payload, not the user data.
263
- address gateway = GATEWAYROUTER.getGateway(token);
264
+ gateway = GATEWAYROUTER.getGateway(token);
264
265
  uint256 outboundCalldataLength =
265
266
  IL1ArbitrumGateway(gateway)
266
267
  .getOutboundCalldata({
267
- _token: token, _from: address(this), _to: _peerAddress(), _amount: amount, _data: bytes("")
268
+ token: token, from: address(this), to: _peerAddress(), amount: amount, data: bytes("")
268
269
  }).length;
269
270
  maxSubmissionCostERC20 = ARBINBOX.calculateRetryableSubmissionFee({
270
271
  dataLength: outboundCalldataLength, baseFee: maxFeePerGas
@@ -299,6 +300,8 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
299
300
  gasPriceBid: maxFeePerGas,
300
301
  data: bytes(abi.encode(maxSubmissionCostERC20, bytes("")))
301
302
  });
303
+
304
+ SafeERC20.forceApprove({token: IERC20(token), spender: gateway, value: 0});
302
305
  } else {
303
306
  // Ensure we bridge enough for gas costs on L2 side
304
307
  if (transportPayment < callTransportCost) {
@@ -202,7 +202,7 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
202
202
  /// @param token The token to bridge the outbox tree for.
203
203
  /// @param amount The amount of tokens to bridge.
204
204
  /// @param remoteToken Information about the remote token to bridge to.
205
- /// @param sucker_message The message root to send to the remote peer.
205
+ /// @param suckerMessage The message root to send to the remote peer.
206
206
  // forge-lint: disable-next-line(mixed-case-function)
207
207
  function _sendRootOverAMB(
208
208
  uint256 transportPayment,
@@ -210,7 +210,7 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
210
210
  address token,
211
211
  uint256 amount,
212
212
  JBRemoteToken memory remoteToken,
213
- JBMessageRoot memory sucker_message
213
+ JBMessageRoot memory suckerMessage
214
214
  )
215
215
  internal
216
216
  virtual
@@ -246,7 +246,7 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
246
246
  feeToken: feeToken,
247
247
  feeTokenPayer: feeToken != address(0) ? _msgSender() : address(0),
248
248
  gasLimit: gasLimit,
249
- encodedPayload: abi.encode(_CCIP_MSG_TYPE_ROOT, abi.encode(sucker_message)),
249
+ encodedPayload: abi.encode(_CCIP_MSG_TYPE_ROOT, abi.encode(suckerMessage)),
250
250
  tokenAmounts: tokenAmounts,
251
251
  refundRecipient: _msgSender()
252
252
  });
@@ -164,6 +164,8 @@ contract JBCeloSucker is JBOptimismSucker {
164
164
  minGasLimit: remoteToken.minGas,
165
165
  extraData: bytes("")
166
166
  });
167
+
168
+ SafeERC20.forceApprove({token: IERC20(bridgeToken), spender: address(OPBRIDGE), value: 0});
167
169
  }
168
170
 
169
171
  // Send the messenger message with nativeValue = 0.
@@ -126,6 +126,8 @@ contract JBOptimismSucker is JBSucker, IJBOptimismSucker {
126
126
  minGasLimit: remoteToken.minGas,
127
127
  extraData: bytes("")
128
128
  });
129
+
130
+ SafeERC20.forceApprove({token: IERC20(token), spender: address(OPBRIDGE), value: 0});
129
131
  } else {
130
132
  // Otherwise, the token is the native token, and the amount will be sent as `msg.value`.
131
133
  nativeValue = amount;
package/src/JBSucker.sol CHANGED
@@ -83,6 +83,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
83
83
  error JBSucker_NoRetainedToRemoteFee(address account);
84
84
  error JBSucker_NoRetainedTransportPaymentRefund(address account);
85
85
  error JBSucker_RefundFailed(address beneficiary, uint256 amount);
86
+ error JBSucker_RemoteTokenAlreadyMapped(bytes32 remoteToken, address localToken);
86
87
  error JBSucker_TokenAlreadyMapped(address localToken, bytes32 mappedTo);
87
88
  error JBSucker_TokenHasInvalidEmergencyHatchState(address token);
88
89
  error JBSucker_TokenNotMapped(address token);
@@ -175,6 +176,14 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
175
176
  /// @custom:param token The local terminal token to get the inbox for.
176
177
  mapping(address token => JBInboxTreeRoot root) internal _inboxOf;
177
178
 
179
+ /// @notice The local token that has reserved each remote token address in this sucker.
180
+ /// @dev Inbound roots are keyed by `root.token` on the destination chain. Within a single sucker, allowing two
181
+ /// local tokens to send roots to the same remote token would give them independent source nonces but one shared
182
+ /// destination inbox, causing stale rejections or root overwrites. Each sucker keeps its own reservation map, so
183
+ /// separate bridge lanes for the same asset pair can coexist.
184
+ /// @custom:param remoteToken The remote terminal token address encoded as bytes32.
185
+ mapping(bytes32 remoteToken => address localToken) internal _localTokenForRemoteToken;
186
+
178
187
  /// @notice The outbox merkle tree for a given token.
179
188
  /// @custom:param token The local terminal token to get the outbox for.
180
189
  mapping(address token => JBOutboxTree) internal _outboxOf;
@@ -856,23 +865,23 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
856
865
  }
857
866
 
858
867
  /// @notice Initializes the sucker with the project ID.
859
- /// @param _projectId The ID of the project (on the local chain) that this sucker is associated with.
860
- function initialize(uint256 _projectId) public initializer {
861
- _initialize({_projectId: _projectId, remotePeer: bytes32(0)});
868
+ /// @param initialProjectId The ID of the project (on the local chain) that this sucker is associated with.
869
+ function initialize(uint256 initialProjectId) public initializer {
870
+ _initialize({initialProjectId: initialProjectId, remotePeer: bytes32(0)});
862
871
  }
863
872
 
864
873
  /// @notice Initializes the sucker with the project ID and an explicit peer address.
865
874
  /// @param localProjectId The ID of the project (on the local chain) that this sucker is associated with.
866
875
  /// @param remotePeer The remote peer address. Leave zero to use the default deterministic same-address peer.
867
876
  function initialize(uint256 localProjectId, bytes32 remotePeer) public initializer {
868
- _initialize({_projectId: localProjectId, remotePeer: remotePeer});
877
+ _initialize({initialProjectId: localProjectId, remotePeer: remotePeer});
869
878
  }
870
879
 
871
880
  /// @notice Initializes the sucker's project and optional peer address.
872
- /// @param _projectId The ID of the project (on the local chain) that this sucker is associated with.
881
+ /// @param initialProjectId The ID of the project (on the local chain) that this sucker is associated with.
873
882
  /// @param remotePeer The remote peer address. Leave zero to use the default deterministic same-address peer.
874
- function _initialize(uint256 _projectId, bytes32 remotePeer) internal {
875
- _localProjectId = _projectId;
883
+ function _initialize(uint256 initialProjectId, bytes32 remotePeer) internal {
884
+ _localProjectId = initialProjectId;
876
885
  _peer = remotePeer;
877
886
  deployer = _msgSender();
878
887
  }
@@ -1031,6 +1040,10 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1031
1040
  /// after outbox activity, the same local funds could be claimed against two different remote tokens. A
1032
1041
  /// misconfigured mapping therefore requires deploying a new sucker. Re-enabling a previously disabled mapping
1033
1042
  /// (back to the same remote token) is supported.
1043
+ /// @dev Remote tokens are also unique per local token within this sucker. The source side keeps separate
1044
+ /// outboxes/nonces per local token, but the destination side stores roots under the remote token address. Sharing
1045
+ /// one remote token across multiple local tokens in the same sucker would merge those inboxes on the destination
1046
+ /// chain. Separate suckers can still map the same local/remote token pair, letting users choose a bridge lane.
1034
1047
  /// @param map The local and remote terminal token addresses to map, and minimum amount/gas limits for bridging
1035
1048
  /// them.
1036
1049
  /// @param transportPaymentValue The amount of `msg.value` to send for the token mapping.
@@ -1068,6 +1081,16 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1068
1081
  revert JBSucker_TokenAlreadyMapped({localToken: token, mappedTo: currentMapping.addr});
1069
1082
  }
1070
1083
 
1084
+ // A remote token can back only one local token's outbox in this sucker. Otherwise two independent source
1085
+ // nonces would race into the same destination inbox key (`root.token`), making one token's root stale or
1086
+ // overwriting the other. Other suckers have separate inbox/outbox storage and are unaffected.
1087
+ if (map.remoteToken != bytes32(0)) {
1088
+ address mappedLocalToken = _localTokenForRemoteToken[map.remoteToken];
1089
+ if (mappedLocalToken != address(0) && mappedLocalToken != token) {
1090
+ revert JBSucker_RemoteTokenAlreadyMapped({remoteToken: map.remoteToken, localToken: mappedLocalToken});
1091
+ }
1092
+ }
1093
+
1071
1094
  // No inbox guard needed here. Token remapping only affects the outbound (sending) path —
1072
1095
  // it changes where tokens get bridged TO. Existing inbox claims are resolved against the inbox merkle
1073
1096
  // tree keyed by the local token address. Changing the remote token doesn't invalidate those claims
@@ -1085,6 +1108,17 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1085
1108
  _sendRoot({transportPayment: transportPaymentValue, token: token, remoteToken: currentMapping});
1086
1109
  }
1087
1110
 
1111
+ // Update the reverse reservation if an unused local token is being remapped to a new remote token.
1112
+ if (
1113
+ map.remoteToken != bytes32(0) && currentMapping.addr != bytes32(0) && currentMapping.addr != map.remoteToken
1114
+ && _localTokenForRemoteToken[currentMapping.addr] == token
1115
+ ) {
1116
+ delete _localTokenForRemoteToken[currentMapping.addr];
1117
+ }
1118
+
1119
+ bytes32 remoteToken = map.remoteToken == bytes32(0) ? currentMapping.addr : map.remoteToken;
1120
+ if (remoteToken != bytes32(0)) _localTokenForRemoteToken[remoteToken] = token;
1121
+
1088
1122
  // Update the token mapping.
1089
1123
  _remoteTokenFor[token] = JBRemoteToken({
1090
1124
  enabled: map.remoteToken != bytes32(0),
@@ -1092,7 +1126,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1092
1126
  minGas: map.minGas,
1093
1127
  // This is done so that a token can be disabled and then enabled again
1094
1128
  // while ensuring the remoteToken never changes (unless it hasn't been used yet)
1095
- addr: map.remoteToken == bytes32(0) ? currentMapping.addr : map.remoteToken
1129
+ addr: remoteToken
1096
1130
  });
1097
1131
  }
1098
1132
 
@@ -18,6 +18,7 @@ import {IJBSuckerDeployer} from "./interfaces/IJBSuckerDeployer.sol";
18
18
  import {IJBSuckerRegistry} from "./interfaces/IJBSuckerRegistry.sol";
19
19
  import {JBSuckerDeployerConfig} from "./structs/JBSuckerDeployerConfig.sol";
20
20
  import {JBSuckersPair} from "./structs/JBSuckersPair.sol";
21
+ import {PeerValueScratch} from "./structs/PeerValueScratch.sol";
21
22
 
22
23
  /// @notice The canonical registry that deploys, tracks, and governs cross-chain suckers for Juicebox projects. It
23
24
  /// maintains an allowlist of approved deployer contracts, allows multiple active suckers per peer chain for bridge
@@ -210,26 +211,21 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
210
211
 
211
212
  // Per-chain dedup arrays. The number of suckers per project is small (typically 1-5),
212
213
  // so a linear scan is cheaper than a mapping.
213
- uint256[] memory chainIds = new uint256[](len);
214
- uint256[] memory maxBalances = new uint256[](len);
215
- bool[] memory hasActiveValue = new bool[](len);
216
- uint256 chainCount;
214
+ PeerValueScratch memory scratch = _peerValueScratch(len);
217
215
 
218
216
  for (uint256 i; i < len;) {
219
217
  (, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
220
218
  // Include both active and deprecated suckers in aggregate economic views.
221
219
  if (val == _SUCKER_EXISTS || val == _SUCKER_DEPRECATED) {
222
- try IJBSucker(allSuckers[i]).peerChainBalanceOf(decimals, currency) returns (
220
+ try IJBSucker(allSuckers[i]).peerChainBalanceOf({decimals: decimals, currency: currency}) returns (
223
221
  JBDenominatedAmount memory amt
224
222
  ) {
225
223
  uint256 chainId = IJBSucker(allSuckers[i]).peerChainId();
226
- chainCount = _recordPeerValue({
227
- chainIds: chainIds,
228
- values: maxBalances,
229
- hasActiveValue: hasActiveValue,
230
- chainCount: chainCount,
224
+ scratch.chainCount = _recordPeerValue({
225
+ scratch: scratch,
231
226
  chainId: chainId,
232
227
  value: amt.value,
228
+ snapshotTimestamp: _snapshotTimestampOf(allSuckers[i]),
233
229
  isActive: val == _SUCKER_EXISTS
234
230
  });
235
231
  } catch {}
@@ -239,9 +235,9 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
239
235
  }
240
236
  }
241
237
 
242
- // Sum the per-chain max values.
243
- for (uint256 k; k < chainCount;) {
244
- balance += maxBalances[k];
238
+ // Sum the per-chain selected values.
239
+ for (uint256 k; k < scratch.chainCount;) {
240
+ balance += scratch.values[k];
245
241
  unchecked {
246
242
  ++k;
247
243
  }
@@ -271,26 +267,21 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
271
267
 
272
268
  // Per-chain dedup arrays. The number of suckers per project is small (typically 1-5),
273
269
  // so a linear scan is cheaper than a mapping.
274
- uint256[] memory chainIds = new uint256[](len);
275
- uint256[] memory maxSurplus = new uint256[](len);
276
- bool[] memory hasActiveValue = new bool[](len);
277
- uint256 chainCount;
270
+ PeerValueScratch memory scratch = _peerValueScratch(len);
278
271
 
279
272
  for (uint256 i; i < len;) {
280
273
  (, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
281
274
  // Include both active and deprecated suckers in aggregate economic views.
282
275
  if (val == _SUCKER_EXISTS || val == _SUCKER_DEPRECATED) {
283
- try IJBSucker(allSuckers[i]).peerChainSurplusOf(decimals, currency) returns (
276
+ try IJBSucker(allSuckers[i]).peerChainSurplusOf({decimals: decimals, currency: currency}) returns (
284
277
  JBDenominatedAmount memory amt
285
278
  ) {
286
279
  uint256 chainId = IJBSucker(allSuckers[i]).peerChainId();
287
- chainCount = _recordPeerValue({
288
- chainIds: chainIds,
289
- values: maxSurplus,
290
- hasActiveValue: hasActiveValue,
291
- chainCount: chainCount,
280
+ scratch.chainCount = _recordPeerValue({
281
+ scratch: scratch,
292
282
  chainId: chainId,
293
283
  value: amt.value,
284
+ snapshotTimestamp: _snapshotTimestampOf(allSuckers[i]),
294
285
  isActive: val == _SUCKER_EXISTS
295
286
  });
296
287
  } catch {}
@@ -300,9 +291,9 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
300
291
  }
301
292
  }
302
293
 
303
- // Sum the per-chain max values.
304
- for (uint256 k; k < chainCount;) {
305
- surplus += maxSurplus[k];
294
+ // Sum the per-chain selected values.
295
+ for (uint256 k; k < scratch.chainCount;) {
296
+ surplus += scratch.values[k];
306
297
  unchecked {
307
298
  ++k;
308
299
  }
@@ -321,10 +312,7 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
321
312
 
322
313
  // Per-chain dedup arrays. The number of suckers per project is small (typically 1-5),
323
314
  // so a linear scan is cheaper than a mapping.
324
- uint256[] memory chainIds = new uint256[](len);
325
- uint256[] memory maxSupply = new uint256[](len);
326
- bool[] memory hasActiveValue = new bool[](len);
327
- uint256 chainCount;
315
+ PeerValueScratch memory scratch = _peerValueScratch(len);
328
316
 
329
317
  for (uint256 i; i < len;) {
330
318
  (, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
@@ -332,13 +320,11 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
332
320
  if (val == _SUCKER_EXISTS || val == _SUCKER_DEPRECATED) {
333
321
  try IJBSucker(allSuckers[i]).peerChainTotalSupply() returns (uint256 supply) {
334
322
  uint256 chainId = IJBSucker(allSuckers[i]).peerChainId();
335
- chainCount = _recordPeerValue({
336
- chainIds: chainIds,
337
- values: maxSupply,
338
- hasActiveValue: hasActiveValue,
339
- chainCount: chainCount,
323
+ scratch.chainCount = _recordPeerValue({
324
+ scratch: scratch,
340
325
  chainId: chainId,
341
326
  value: supply,
327
+ snapshotTimestamp: _snapshotTimestampOf(allSuckers[i]),
342
328
  isActive: val == _SUCKER_EXISTS
343
329
  });
344
330
  } catch {}
@@ -348,9 +334,9 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
348
334
  }
349
335
  }
350
336
 
351
- // Sum the per-chain max values.
352
- for (uint256 k; k < chainCount;) {
353
- totalSupply += maxSupply[k];
337
+ // Sum the per-chain selected values.
338
+ for (uint256 k; k < scratch.chainCount;) {
339
+ totalSupply += scratch.values[k];
354
340
  unchecked {
355
341
  ++k;
356
342
  }
@@ -378,58 +364,91 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
378
364
  return ERC2771Context._msgSender();
379
365
  }
380
366
 
367
+ /// @notice Allocates scratch arrays used to collapse many suckers into one aggregate value per peer chain.
368
+ /// @dev `len` is the number of suckers being scanned, which is the maximum possible number of distinct peer
369
+ /// chains. `chainCount` starts at zero and is incremented as new peer chains are discovered.
370
+ /// @param len The maximum number of peer-chain entries the aggregation can need.
371
+ /// @return scratch Empty scratch space sized for the current aggregation pass.
372
+ function _peerValueScratch(uint256 len) internal pure returns (PeerValueScratch memory scratch) {
373
+ // Allocate each parallel array up front so `_recordPeerValue` can update by index without resizing memory.
374
+ scratch.chainIds = new uint256[](len);
375
+ scratch.values = new uint256[](len);
376
+ scratch.snapshotTimestamps = new uint256[](len);
377
+ scratch.hasActiveValue = new bool[](len);
378
+ }
379
+
381
380
  /// @notice Records a project-scoped peer-chain aggregate value.
382
381
  /// @dev Callers pass scratch arrays sized from `_suckersOf[projectId].keys()`, so entries are already scoped to
383
382
  /// the project being aggregated. For each peer chain, active suckers replace deprecated suckers; deprecated
384
383
  /// values are only used as a migration fallback when no active sucker has reported for that chain.
385
- /// @param chainIds The peer-chain ids recorded so far.
386
- /// @param values The aggregate values recorded so far.
387
- /// @param hasActiveValue Whether the recorded value for each index came from an active sucker.
388
- /// @param chainCount The number of populated chain entries.
384
+ /// @param scratch The per-chain aggregate values and freshness keys recorded so far.
389
385
  /// @param chainId The peer-chain id to record.
390
386
  /// @param value The value to record.
387
+ /// @param snapshotTimestamp The snapshot freshness key to record.
391
388
  /// @param isActive Whether the value came from an active sucker.
392
389
  /// @return The updated number of populated chain entries.
393
390
  function _recordPeerValue(
394
- uint256[] memory chainIds,
395
- uint256[] memory values,
396
- bool[] memory hasActiveValue,
397
- uint256 chainCount,
391
+ PeerValueScratch memory scratch,
398
392
  uint256 chainId,
399
393
  uint256 value,
394
+ uint256 snapshotTimestamp,
400
395
  bool isActive
401
396
  )
402
397
  internal
403
398
  pure
404
399
  returns (uint256)
405
400
  {
406
- for (uint256 j; j < chainCount;) {
407
- if (chainIds[j] == chainId) {
401
+ for (uint256 j; j < scratch.chainCount;) {
402
+ if (scratch.chainIds[j] == chainId) {
408
403
  // Each sucker caches the entire remote chain's state (not a per-sucker share), so multiple
409
- // suckers targeting the same chain report redundant snapshots. MAX picks the freshest value
410
- // without double-counting SUM would inflate bonding curve denominators.
404
+ // suckers targeting the same chain report redundant snapshots. Prefer the freshest source-chain
405
+ // snapshot; use MAX only as a same-freshness tie-breaker or deprecated fallback.
411
406
  if (isActive) {
412
- if (!hasActiveValue[j] || value > values[j]) values[j] = value;
413
- hasActiveValue[j] = true;
414
- } else if (!hasActiveValue[j] && value > values[j]) {
407
+ if (
408
+ !scratch.hasActiveValue[j] || snapshotTimestamp > scratch.snapshotTimestamps[j]
409
+ || (snapshotTimestamp == scratch.snapshotTimestamps[j] && value > scratch.values[j])
410
+ ) {
411
+ scratch.values[j] = value;
412
+ scratch.snapshotTimestamps[j] = snapshotTimestamp;
413
+ }
414
+ scratch.hasActiveValue[j] = true;
415
+ } else if (
416
+ !scratch.hasActiveValue[j]
417
+ && (snapshotTimestamp > scratch.snapshotTimestamps[j]
418
+ || (snapshotTimestamp == scratch.snapshotTimestamps[j] && value > scratch.values[j]))
419
+ ) {
415
420
  // Deprecated suckers only fill the gap until an active value for this chain has been observed.
416
- values[j] = value;
421
+ scratch.values[j] = value;
422
+ scratch.snapshotTimestamps[j] = snapshotTimestamp;
417
423
  }
418
- return chainCount;
424
+ return scratch.chainCount;
419
425
  }
420
426
  unchecked {
421
427
  ++j;
422
428
  }
423
429
  }
424
430
 
425
- chainIds[chainCount] = chainId;
426
- values[chainCount] = value;
427
- hasActiveValue[chainCount] = isActive;
431
+ scratch.chainIds[scratch.chainCount] = chainId;
432
+ scratch.values[scratch.chainCount] = value;
433
+ scratch.snapshotTimestamps[scratch.chainCount] = snapshotTimestamp;
434
+ scratch.hasActiveValue[scratch.chainCount] = isActive;
428
435
  unchecked {
429
- return chainCount + 1;
436
+ return scratch.chainCount + 1;
430
437
  }
431
438
  }
432
439
 
440
+ /// @notice Reads a sucker's snapshot timestamp, returning zero if the sucker does not expose it.
441
+ /// @dev Older or malformed suckers should not brick aggregate registry views. A zero timestamp makes their value
442
+ /// lose to any successful fresh read for the same peer chain.
443
+ /// @param sucker The sucker to query.
444
+ /// @return timestamp The reported snapshot timestamp, or zero if the call fails.
445
+ function _snapshotTimestampOf(address sucker) internal view returns (uint256 timestamp) {
446
+ // Keep aggregate views available even if one registered sucker has a stale ABI or reverts unexpectedly.
447
+ try IJBSucker(sucker).snapshotTimestamp() returns (uint256 result) {
448
+ timestamp = result;
449
+ } catch {}
450
+ }
451
+
433
452
  //*********************************************************************//
434
453
  // ---------------------- public transactions ----------------------- //
435
454
  //*********************************************************************//
@@ -564,7 +583,7 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
564
583
  }
565
584
 
566
585
  // Mark the sucker as deprecated (retains mint permission, excluded from active listings).
567
- _suckersOf[projectId].set(address(sucker), _SUCKER_DEPRECATED);
586
+ _suckersOf[projectId].set({key: address(sucker), value: _SUCKER_DEPRECATED});
568
587
  emit SuckerDeprecated({projectId: projectId, sucker: address(sucker), caller: _msgSender()});
569
588
  }
570
589