@bananapus/suckers-v6 0.0.78 → 1.0.0

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.
@@ -14,6 +14,7 @@ import {Context} from "@openzeppelin/contracts/utils/Context.sol";
14
14
  import {EnumerableMap} from "@openzeppelin/contracts/utils/structs/EnumerableMap.sol";
15
15
  import {mulDiv} from "@prb/math/src/Common.sol";
16
16
 
17
+ import {JBChainAccounting} from "./structs/JBChainAccounting.sol";
17
18
  import {JBPeerChainContext} from "./structs/JBPeerChainContext.sol";
18
19
  import {JBPeerChainValue} from "./structs/JBPeerChainValue.sol";
19
20
  import {JBSuckerState} from "./enums/JBSuckerState.sol";
@@ -22,7 +23,9 @@ import {IJBSuckerDeployer} from "./interfaces/IJBSuckerDeployer.sol";
22
23
  import {IJBSuckerRegistry} from "./interfaces/IJBSuckerRegistry.sol";
23
24
  import {JBSuckerDeployerConfig} from "./structs/JBSuckerDeployerConfig.sol";
24
25
  import {JBSuckersPair} from "./structs/JBSuckersPair.sol";
26
+ import {PeerAccountScratch} from "./structs/PeerAccountScratch.sol";
25
27
  import {PeerValueScratch} from "./structs/PeerValueScratch.sol";
28
+ import {RemoteValueParams} from "./structs/RemoteValueParams.sol";
26
29
 
27
30
  /// @notice The canonical registry that deploys, tracks, and governs cross-chain suckers for Juicebox projects. It
28
31
  /// maintains an allowlist of approved deployer contracts, allows multiple active suckers per peer chain for bridge
@@ -156,18 +159,78 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
156
159
  return exists && (val == _SUCKER_EXISTS || val == _SUCKER_DEPRECATED);
157
160
  }
158
161
 
159
- /// @notice Values one sucker's raw peer-chain balance into a currency with peer chain ID and freshness.
160
- /// @dev Exposed as an external self-call boundary so `totalRemoteBalanceOf` can `try` it and drop a single sucker
161
- /// whose price feed is missing. A context whose currency already matches `currency` folds in at par (no feed read);
162
- /// a missing cross-currency feed reverts, and the aggregator catches it and skips just this sucker.
162
+ /// @notice The freshest accounting record per source chain that a project's suckers hold, for re-gossiping to a
163
+ /// peer.
164
+ /// @dev A sucker building an outbound gossip bundle calls this to gather the project's full cross-chain knowledge
165
+ /// (the registry is the only place a hub chain's per-peer suckers are visible together), then prepends its own
166
+ /// local record. Records are deduped per chain (freshest wins; an active sucker's record supersedes a deprecated
167
+ /// one's), and the destination chain and the local chain are excluded. Suckers and records that revert are
168
+ /// silently skipped.
169
+ /// @param projectId The ID of the project.
170
+ /// @param exceptChainId The destination chain to exclude (it has authoritative data about itself).
171
+ /// @return accounts The deduped raw accounting records, one per known source chain.
172
+ function peerChainAccountsOf(
173
+ uint256 projectId,
174
+ uint256 exceptChainId
175
+ )
176
+ external
177
+ view
178
+ override
179
+ returns (JBChainAccounting[] memory accounts)
180
+ {
181
+ address[] memory allSuckers = _suckersOf[projectId].keys();
182
+
183
+ // Bound the distinct-chain scratch by the total records across the project's suckers.
184
+ (, uint256 totalChains) = _peerChainIdsBySucker(allSuckers);
185
+ PeerAccountScratch memory scratch = PeerAccountScratch({
186
+ chainIds: new uint256[](totalChains),
187
+ records: new JBChainAccounting[](totalChains),
188
+ hasActiveRecord: new bool[](totalChains),
189
+ chainCount: 0
190
+ });
191
+
192
+ uint256 len = allSuckers.length;
193
+ for (uint256 i; i < len;) {
194
+ (, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
195
+ // Include both active and deprecated suckers; deprecated only fill a gap no active sucker answers.
196
+ if (val == _SUCKER_EXISTS || val == _SUCKER_DEPRECATED) {
197
+ _gatherSuckerAccounts({
198
+ scratch: scratch,
199
+ sucker: allSuckers[i],
200
+ isActive: val == _SUCKER_EXISTS,
201
+ exceptChainId: exceptChainId
202
+ });
203
+ }
204
+ unchecked {
205
+ ++i;
206
+ }
207
+ }
208
+
209
+ // Trim to the populated chains.
210
+ accounts = new JBChainAccounting[](scratch.chainCount);
211
+ for (uint256 k; k < scratch.chainCount;) {
212
+ accounts[k] = scratch.records[k];
213
+ unchecked {
214
+ ++k;
215
+ }
216
+ }
217
+ }
218
+
219
+ /// @notice Values one peer chain's raw balance held by one sucker into a currency, with peer chain ID and
220
+ /// freshness.
221
+ /// @dev Exposed as an external self-call boundary so `totalRemoteBalanceOf` can `try` it and drop a single
222
+ /// (sucker, chain) whose price feed is missing without losing that sucker's other chains. A context whose currency
223
+ /// already matches `currency` folds in at par (no feed read); a missing cross-currency feed reverts, and the
224
+ /// aggregator catches it and skips just this (sucker, chain).
163
225
  /// @param sucker The sucker to read.
226
+ /// @param chainId The peer chain to read.
164
227
  /// @param projectId The project whose price feeds to use.
165
228
  /// @param currency The currency to value into.
166
229
  /// @param decimals The decimal precision for the returned value.
167
- /// @return A `JBPeerChainValue` with the valued balance, the sucker's peer chain ID, and its snapshot freshness
168
- /// key.
230
+ /// @return A `JBPeerChainValue` with the valued balance, the peer chain ID, and its snapshot freshness key.
169
231
  function remoteBalanceOf(
170
232
  address sucker,
233
+ uint256 chainId,
171
234
  uint256 projectId,
172
235
  uint256 currency,
173
236
  uint256 decimals
@@ -176,10 +239,8 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
176
239
  view
177
240
  returns (JBPeerChainValue memory)
178
241
  {
179
- // Read this sucker's raw snapshot: one context per distinct local currency the peer reported, plus the peer
180
- // chain ID and the snapshot's freshness key.
181
- (JBPeerChainContext[] memory contexts, uint256 chainId, uint256 snapshot) =
182
- IJBSucker(sucker).peerChainContextsOf();
242
+ // Read this sucker's raw contexts for the chain: one per distinct local currency, plus the freshness key.
243
+ (JBPeerChainContext[] memory contexts, uint256 snapshot) = IJBSucker(sucker).peerChainContextsOf(chainId);
183
244
 
184
245
  // Value each context's balance out of the currency and decimals it was recorded in, into the requested
185
246
  // `currency` and `decimals`, and sum across every context. A context already denominated in `currency` folds
@@ -205,18 +266,21 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
205
266
  return JBPeerChainValue({value: value, peerChainId: chainId, snapshotTimestamp: snapshot});
206
267
  }
207
268
 
208
- /// @notice Values one sucker's raw peer-chain surplus into a currency with peer chain ID and freshness.
209
- /// @dev Exposed as an external self-call boundary so `totalRemoteSurplusOf` can `try` it and drop a single sucker
210
- /// whose price feed is missing. A context whose currency already matches `currency` folds in at par (no feed read);
211
- /// a missing cross-currency feed reverts, and the aggregator catches it and skips just this sucker.
269
+ /// @notice Values one peer chain's raw surplus held by one sucker into a currency, with peer chain ID and
270
+ /// freshness.
271
+ /// @dev Exposed as an external self-call boundary so `totalRemoteSurplusOf` can `try` it and drop a single
272
+ /// (sucker, chain) whose price feed is missing without losing that sucker's other chains. A context whose currency
273
+ /// already matches `currency` folds in at par (no feed read); a missing cross-currency feed reverts, and the
274
+ /// aggregator catches it and skips just this (sucker, chain).
212
275
  /// @param sucker The sucker to read.
276
+ /// @param chainId The peer chain to read.
213
277
  /// @param projectId The project whose price feeds to use.
214
278
  /// @param currency The currency to value into.
215
279
  /// @param decimals The decimal precision for the returned value.
216
- /// @return A `JBPeerChainValue` with the valued surplus, the sucker's peer chain ID, and its snapshot freshness
217
- /// key.
280
+ /// @return A `JBPeerChainValue` with the valued surplus, the peer chain ID, and its snapshot freshness key.
218
281
  function remoteSurplusOf(
219
282
  address sucker,
283
+ uint256 chainId,
220
284
  uint256 projectId,
221
285
  uint256 currency,
222
286
  uint256 decimals
@@ -225,10 +289,8 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
225
289
  view
226
290
  returns (JBPeerChainValue memory)
227
291
  {
228
- // Read this sucker's raw snapshot: one context per distinct local currency the peer reported, plus the peer
229
- // chain ID and the snapshot's freshness key.
230
- (JBPeerChainContext[] memory contexts, uint256 chainId, uint256 snapshot) =
231
- IJBSucker(sucker).peerChainContextsOf();
292
+ // Read this sucker's raw contexts for the chain: one per distinct local currency, plus the freshness key.
293
+ (JBPeerChainContext[] memory contexts, uint256 snapshot) = IJBSucker(sucker).peerChainContextsOf(chainId);
232
294
 
233
295
  // Value each context's surplus out of the currency and decimals it was recorded in, into the requested
234
296
  // `currency` and `decimals`, and sum across every context. A context already denominated in `currency` folds
@@ -255,29 +317,41 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
255
317
  }
256
318
 
257
319
  /// @notice The cumulative total supply across all remote peer chains for a project.
258
- /// @dev Includes deprecated suckers only when no active sucker answers for the same peer chain, to prevent
259
- /// undercounting during migration windows without letting stale deprecated snapshots dominate live routes.
260
- /// Silently skips suckers that revert.
320
+ /// @dev Each sucker now holds an accounting record per source chain it has heard about (its direct peer plus chains
321
+ /// gossiped through it), so this aggregates over every (sucker, chain) pair and dedups per chain. Includes
322
+ /// deprecated suckers only when no active sucker answers for the same peer chain, to prevent undercounting during
323
+ /// migration windows without letting stale deprecated records dominate live routes. Silently skips suckers and
324
+ /// records that revert.
261
325
  /// @param projectId The ID of the project.
262
326
  /// @return totalSupply The combined peer chain total supply.
263
327
  function remoteTotalSupplyOf(uint256 projectId) external view override returns (uint256 totalSupply) {
264
328
  address[] memory allSuckers = _suckersOf[projectId].keys();
265
- uint256 len = allSuckers.length;
266
329
 
267
- // Per-chain dedup arrays. The number of suckers per project is small (typically 1-5),
268
- // so a linear scan is cheaper than a mapping.
269
- PeerValueScratch memory scratch = _peerValueScratch(len);
330
+ // Gather each sucker's known peer chains once, and size the per-chain dedup scratch by the total across them.
331
+ (uint256[][] memory chainIdsBySucker, uint256 totalChains) = _peerChainIdsBySucker(allSuckers);
332
+ PeerValueScratch memory scratch = _peerValueScratch(totalChains);
270
333
 
334
+ uint256 len = allSuckers.length;
271
335
  for (uint256 i; i < len;) {
272
336
  (, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
273
337
  // Include both active and deprecated suckers in aggregate economic views.
274
338
  if (val == _SUCKER_EXISTS || val == _SUCKER_DEPRECATED) {
275
- // One call returns the value, peer chain ID, and snapshot freshness key together.
276
- try IJBSucker(allSuckers[i]).peerChainTotalSupplyValue() returns (JBPeerChainValue memory read) {
277
- scratch.chainCount = _recordPeerChainValue({
278
- scratch: scratch, read: read, sucker: allSuckers[i], isActive: val == _SUCKER_EXISTS
279
- });
280
- } catch {}
339
+ bool isActive = val == _SUCKER_EXISTS;
340
+ uint256[] memory chainIds = chainIdsBySucker[i];
341
+ uint256 numChains = chainIds.length;
342
+ for (uint256 c; c < numChains;) {
343
+ // One call returns this chain's value, peer chain ID, and freshness key together.
344
+ try IJBSucker(allSuckers[i]).peerChainTotalSupplyValue(chainIds[c]) returns (
345
+ JBPeerChainValue memory read
346
+ ) {
347
+ scratch.chainCount = _recordPeerChainValue({
348
+ scratch: scratch, read: read, sucker: allSuckers[i], isActive: isActive
349
+ });
350
+ } catch {}
351
+ unchecked {
352
+ ++c;
353
+ }
354
+ }
281
355
  }
282
356
  unchecked {
283
357
  ++i;
@@ -365,11 +439,11 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
365
439
  }
366
440
 
367
441
  /// @notice The cumulative peer-chain balance across all remote peer chains for a project, valued into a currency.
368
- /// @dev Dedups same-peer suckers by freshest snapshot, then sums each sucker's balance valued into `currency`.
369
- /// Includes deprecated suckers only when no active sucker answers for the same peer chain, to prevent undercounting
370
- /// during migration windows without letting stale deprecated snapshots dominate live routes. A context whose
371
- /// currency already matches is taken at par (no feed); a missing cross-currency feed reverts and that sucker is
372
- /// silently skipped (conservative, bias-low).
442
+ /// @dev Aggregates over every (sucker, chain) pair and dedups per chain by freshest record, then sums each chain's
443
+ /// balance valued into `currency`. Includes deprecated suckers only when no active sucker answers for the same peer
444
+ /// chain, to prevent undercounting during migration windows without letting stale deprecated records dominate live
445
+ /// routes. A context whose currency already matches is taken at par (no feed); a missing cross-currency feed
446
+ /// reverts and that (sucker, chain) is silently skipped (conservative, bias-low).
373
447
  /// @param projectId The ID of the project.
374
448
  /// @param currency The currency to value the combined balance into.
375
449
  /// @param decimals The decimal precision for the returned value.
@@ -384,85 +458,138 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
384
458
  override
385
459
  returns (uint256 balance)
386
460
  {
387
- address[] memory allSuckers = _suckersOf[projectId].keys();
388
- uint256 len = allSuckers.length;
461
+ return _aggregateRemoteValueOf({projectId: projectId, currency: currency, decimals: decimals, surplus: false});
462
+ }
389
463
 
390
- // Per-chain dedup arrays. The number of suckers per project is small (typically 1-5),
391
- // so a linear scan is cheaper than a mapping.
392
- PeerValueScratch memory scratch = _peerValueScratch(len);
464
+ /// @notice The cumulative peer-chain surplus across all remote peer chains for a project, valued into a currency.
465
+ /// @dev Aggregates over every (sucker, chain) pair and dedups per chain by freshest record, then sums each chain's
466
+ /// surplus valued into `currency`. Includes deprecated suckers only when no active sucker answers for the same peer
467
+ /// chain, to prevent undercounting during migration windows without letting stale deprecated records dominate live
468
+ /// routes. A context whose currency already matches is taken at par (no feed); a missing cross-currency feed
469
+ /// reverts and that (sucker, chain) is silently skipped (conservative, bias-low).
470
+ /// @param projectId The ID of the project.
471
+ /// @param currency The currency to value the combined surplus into.
472
+ /// @param decimals The decimal precision for the returned value.
473
+ /// @return surplus The combined peer chain surplus.
474
+ function totalRemoteSurplusOf(
475
+ uint256 projectId,
476
+ uint256 currency,
477
+ uint256 decimals
478
+ )
479
+ external
480
+ view
481
+ override
482
+ returns (uint256 surplus)
483
+ {
484
+ return _aggregateRemoteValueOf({projectId: projectId, currency: currency, decimals: decimals, surplus: true});
485
+ }
393
486
 
394
- for (uint256 i; i < len;) {
395
- (, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
396
- // Include both active and deprecated suckers in aggregate economic views.
397
- if (val == _SUCKER_EXISTS || val == _SUCKER_DEPRECATED) {
398
- // A registry self-call values the sucker's raw contexts so a missing feed reverts only this sucker
399
- // (caught here), and returns the value, peer chain ID, and snapshot freshness key together.
487
+ //*********************************************************************//
488
+ // ------------------------ internal views --------------------------- //
489
+ //*********************************************************************//
490
+
491
+ /// @notice Values every known peer chain held by one sucker and folds each into the per-chain dedup scratch.
492
+ /// @dev Each (sucker, chain) is valued through a registry self-call so a missing price feed reverts only that one
493
+ /// pair (caught here), not the sucker's other chains. Reads the sucker's chains itself, and is extracted from the
494
+ /// aggregate view, to keep both stacks shallow.
495
+ /// @param scratch The per-chain dedup scratch to fold values into.
496
+ /// @param sucker The sucker whose chains to value.
497
+ /// @param isActive Whether the sucker is active (vs deprecated).
498
+ /// @param params The invariant valuation parameters for this aggregation pass.
499
+ function _accrueChainValues(
500
+ PeerValueScratch memory scratch,
501
+ address sucker,
502
+ bool isActive,
503
+ RemoteValueParams memory params
504
+ )
505
+ internal
506
+ view
507
+ {
508
+ uint256[] memory chainIds;
509
+ // Aggregate over the full set — directly-connected plus gossiped (virtual) chains — so cross-chain
510
+ // accounting
511
+ // reflects every chain the project knows, not only its direct bridges.
512
+ try IJBSucker(sucker).peerChainIds(true) returns (uint256[] memory ids) {
513
+ chainIds = ids;
514
+ } catch {
515
+ return;
516
+ }
517
+
518
+ uint256 numChains = chainIds.length;
519
+ for (uint256 c; c < numChains;) {
520
+ // A registry self-call values one chain's raw contexts so a missing feed reverts only this (sucker, chain)
521
+ // (caught here). Recording inside the `try` keeps this function under the stack-slot limit.
522
+ if (params.surplus) {
523
+ try this.remoteSurplusOf({
524
+ sucker: sucker,
525
+ chainId: chainIds[c],
526
+ projectId: params.projectId,
527
+ currency: params.currency,
528
+ decimals: params.decimals
529
+ }) returns (
530
+ JBPeerChainValue memory value
531
+ ) {
532
+ scratch.chainCount = _recordPeerChainValue({
533
+ scratch: scratch, read: value, sucker: sucker, isActive: isActive
534
+ });
535
+ } catch {}
536
+ } else {
400
537
  try this.remoteBalanceOf({
401
- sucker: allSuckers[i], projectId: projectId, currency: currency, decimals: decimals
538
+ sucker: sucker,
539
+ chainId: chainIds[c],
540
+ projectId: params.projectId,
541
+ currency: params.currency,
542
+ decimals: params.decimals
402
543
  }) returns (
403
- JBPeerChainValue memory read
544
+ JBPeerChainValue memory value
404
545
  ) {
405
546
  scratch.chainCount = _recordPeerChainValue({
406
- scratch: scratch, read: read, sucker: allSuckers[i], isActive: val == _SUCKER_EXISTS
547
+ scratch: scratch, read: value, sucker: sucker, isActive: isActive
407
548
  });
408
549
  } catch {}
409
550
  }
410
551
  unchecked {
411
- ++i;
412
- }
413
- }
414
-
415
- // Sum the per-chain selected values.
416
- for (uint256 k; k < scratch.chainCount;) {
417
- balance += scratch.values[k];
418
- unchecked {
419
- ++k;
552
+ ++c;
420
553
  }
421
554
  }
422
555
  }
423
556
 
424
- /// @notice The cumulative peer-chain surplus across all remote peer chains for a project, valued into a currency.
425
- /// @dev Dedups same-peer suckers by freshest snapshot, then sums each sucker's surplus valued into `currency`.
426
- /// Includes deprecated suckers only when no active sucker answers for the same peer chain, to prevent undercounting
427
- /// during migration windows without letting stale deprecated snapshots dominate live routes. A context whose
428
- /// currency already matches is taken at par (no feed); a missing cross-currency feed reverts and that sucker is
429
- /// silently skipped (conservative, bias-low).
557
+ /// @notice The cumulative peer-chain balance or surplus across all of a project's peer chains, valued into a
558
+ /// currency.
559
+ /// @dev Aggregates over every (sucker, chain) pair and dedups per chain by freshest record (active supersedes
560
+ /// deprecated), then sums the selected per-chain values. Shared by `totalRemoteBalanceOf` and
561
+ /// `totalRemoteSurplusOf`.
430
562
  /// @param projectId The ID of the project.
431
- /// @param currency The currency to value the combined surplus into.
563
+ /// @param currency The currency to value into.
432
564
  /// @param decimals The decimal precision for the returned value.
433
- /// @return surplus The combined peer chain surplus.
434
- function totalRemoteSurplusOf(
565
+ /// @param surplus Whether to aggregate surplus (true) or balance (false).
566
+ /// @return total The combined valued amount across every peer chain.
567
+ function _aggregateRemoteValueOf(
435
568
  uint256 projectId,
436
569
  uint256 currency,
437
- uint256 decimals
570
+ uint256 decimals,
571
+ bool surplus
438
572
  )
439
- external
573
+ internal
440
574
  view
441
- override
442
- returns (uint256 surplus)
575
+ returns (uint256 total)
443
576
  {
444
577
  address[] memory allSuckers = _suckersOf[projectId].keys();
445
- uint256 len = allSuckers.length;
446
578
 
447
- // Per-chain dedup arrays. The number of suckers per project is small (typically 1-5),
448
- // so a linear scan is cheaper than a mapping.
449
- PeerValueScratch memory scratch = _peerValueScratch(len);
579
+ // Size the per-chain dedup scratch by the total records across the project's suckers.
580
+ (, uint256 totalChains) = _peerChainIdsBySucker(allSuckers);
581
+ PeerValueScratch memory scratch = _peerValueScratch(totalChains);
582
+ RemoteValueParams memory params =
583
+ RemoteValueParams({projectId: projectId, currency: currency, decimals: decimals, surplus: surplus});
450
584
 
585
+ uint256 len = allSuckers.length;
451
586
  for (uint256 i; i < len;) {
452
587
  (, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
453
588
  // Include both active and deprecated suckers in aggregate economic views.
454
589
  if (val == _SUCKER_EXISTS || val == _SUCKER_DEPRECATED) {
455
- // A registry self-call values the sucker's raw contexts so a missing feed reverts only this sucker
456
- // (caught here), and returns the value, peer chain ID, and snapshot freshness key together.
457
- try this.remoteSurplusOf({
458
- sucker: allSuckers[i], projectId: projectId, currency: currency, decimals: decimals
459
- }) returns (
460
- JBPeerChainValue memory read
461
- ) {
462
- scratch.chainCount = _recordPeerChainValue({
463
- scratch: scratch, read: read, sucker: allSuckers[i], isActive: val == _SUCKER_EXISTS
464
- });
465
- } catch {}
590
+ _accrueChainValues({
591
+ scratch: scratch, sucker: allSuckers[i], isActive: val == _SUCKER_EXISTS, params: params
592
+ });
466
593
  }
467
594
  unchecked {
468
595
  ++i;
@@ -471,22 +598,51 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
471
598
 
472
599
  // Sum the per-chain selected values.
473
600
  for (uint256 k; k < scratch.chainCount;) {
474
- surplus += scratch.values[k];
601
+ total += scratch.values[k];
475
602
  unchecked {
476
603
  ++k;
477
604
  }
478
605
  }
479
606
  }
480
607
 
481
- //*********************************************************************//
482
- // ------------------------ internal views --------------------------- //
483
- //*********************************************************************//
484
-
485
608
  /// @dev ERC-2771 specifies the context as being a single address (20 bytes).
486
609
  function _contextSuffixLength() internal view virtual override(ERC2771Context, Context) returns (uint256) {
487
610
  return ERC2771Context._contextSuffixLength();
488
611
  }
489
612
 
613
+ /// @notice Reads one sucker's raw records and folds each into the per-chain gather scratch.
614
+ /// @dev Extracted from `peerChainAccountsOf` to keep its stack shallow. A sucker that reverts contributes nothing.
615
+ /// The destination chain, the local chain, and chain 0 are excluded.
616
+ /// @param scratch The per-chain gather scratch to fold records into.
617
+ /// @param sucker The sucker to read records from.
618
+ /// @param isActive Whether the sucker is active (vs deprecated).
619
+ /// @param exceptChainId The destination chain to exclude.
620
+ function _gatherSuckerAccounts(
621
+ PeerAccountScratch memory scratch,
622
+ address sucker,
623
+ bool isActive,
624
+ uint256 exceptChainId
625
+ )
626
+ internal
627
+ view
628
+ {
629
+ try IJBSucker(sucker).peerChainAccountsOf() returns (JBChainAccounting[] memory records) {
630
+ uint256 numRecords = records.length;
631
+ for (uint256 r; r < numRecords;) {
632
+ // Exclude the destination chain (authoritative about itself), the local chain, and chain 0.
633
+ if (
634
+ records[r].chainId != exceptChainId && records[r].chainId != block.chainid
635
+ && records[r].chainId != 0
636
+ ) {
637
+ _recordPeerChainAccounting({scratch: scratch, record: records[r], isActive: isActive});
638
+ }
639
+ unchecked {
640
+ ++r;
641
+ }
642
+ }
643
+ } catch {}
644
+ }
645
+
490
646
  /// @notice The calldata. Preferred to use over `msg.data`.
491
647
  /// @return calldata The `msg.data` of this call.
492
648
  function _msgData() internal view override(ERC2771Context, Context) returns (bytes calldata) {
@@ -499,6 +655,44 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
499
655
  return ERC2771Context._msgSender();
500
656
  }
501
657
 
658
+ /// @notice Reads a sucker's peer chain ID, reverting if the sucker cannot identify a real peer chain.
659
+ /// @param sucker The sucker to query.
660
+ /// @return chainId The non-zero peer chain ID.
661
+ function _peerChainIdOf(IJBSucker sucker) internal view returns (uint256 chainId) {
662
+ chainId = sucker.peerChainId();
663
+ if (chainId == 0) revert JBSuckerRegistry_ZeroPeerChainId({sucker: address(sucker)});
664
+ }
665
+
666
+ /// @notice Gathers each sucker's known peer chains and the total across them, to size per-chain aggregation
667
+ /// scratch.
668
+ /// @dev Each sucker holds a record per source chain it has heard about, so the distinct-chain count can exceed the
669
+ /// sucker count. A sucker that reverts contributes no chains. The gathered arrays are reused by the caller's
670
+ /// per-chain loop so `peerChainIds()` is read once per sucker.
671
+ /// @param allSuckers The project's suckers (active and deprecated).
672
+ /// @return chainIdsBySucker Each sucker's peer chain IDs, parallel to `allSuckers`; empty for a sucker that
673
+ /// reverts.
674
+ /// @return totalChains The total number of (sucker, chain) entries, the upper bound on distinct peer chains.
675
+ function _peerChainIdsBySucker(address[] memory allSuckers)
676
+ internal
677
+ view
678
+ returns (uint256[][] memory chainIdsBySucker, uint256 totalChains)
679
+ {
680
+ uint256 len = allSuckers.length;
681
+ chainIdsBySucker = new uint256[][](len);
682
+ for (uint256 i; i < len;) {
683
+ // The full set — directly-connected plus gossiped (virtual) chains — drives cross-chain aggregation.
684
+ try IJBSucker(allSuckers[i]).peerChainIds(true) returns (uint256[] memory chainIds) {
685
+ chainIdsBySucker[i] = chainIds;
686
+ totalChains += chainIds.length;
687
+ } catch {
688
+ chainIdsBySucker[i] = new uint256[](0);
689
+ }
690
+ unchecked {
691
+ ++i;
692
+ }
693
+ }
694
+ }
695
+
502
696
  /// @notice Allocates scratch arrays used to collapse many suckers into one aggregate value per peer chain.
503
697
  /// @dev `len` is the number of suckers being scanned, which is the maximum possible number of distinct peer
504
698
  /// chains. `chainCount` starts at zero and is incremented as new peer chains are discovered.
@@ -512,12 +706,77 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
512
706
  scratch.hasActiveValue = new bool[](len);
513
707
  }
514
708
 
515
- /// @notice Reads a sucker's peer chain ID, reverting if the sucker cannot identify a real peer chain.
516
- /// @param sucker The sucker to query.
517
- /// @return chainId The non-zero peer chain ID.
518
- function _peerChainIdOf(IJBSucker sucker) internal view returns (uint256 chainId) {
519
- chainId = sucker.peerChainId();
520
- if (chainId == 0) revert JBSuckerRegistry_ZeroPeerChainId({sucker: address(sucker)});
709
+ /// @notice Records one source chain's raw accounting record into a per-chain gather scratch, keeping the freshest.
710
+ /// @dev Mirrors `_recordPeerValue`'s selection rule for raw records: an active sucker's record supersedes a
711
+ /// deprecated one's for the same chain; among same-state records the strictly-fresher timestamp wins; equal
712
+ /// freshness keeps the first writer, since records from one origin chain at one freshness key are identical. Used
713
+ /// to gather records for re-gossiping.
714
+ /// @param scratch The per-chain gather scratch recorded so far.
715
+ /// @param record The record to fold in.
716
+ /// @param isActive Whether the record came from an active sucker.
717
+ function _recordPeerChainAccounting(
718
+ PeerAccountScratch memory scratch,
719
+ JBChainAccounting memory record,
720
+ bool isActive
721
+ )
722
+ internal
723
+ pure
724
+ {
725
+ for (uint256 k; k < scratch.chainCount;) {
726
+ if (scratch.chainIds[k] == record.chainId) {
727
+ if (isActive) {
728
+ // An active record replaces a deprecated one, or a staler active one.
729
+ if (!scratch.hasActiveRecord[k] || record.timestamp > scratch.records[k].timestamp) {
730
+ scratch.records[k] = record;
731
+ }
732
+ scratch.hasActiveRecord[k] = true;
733
+ } else if (!scratch.hasActiveRecord[k] && record.timestamp > scratch.records[k].timestamp) {
734
+ // A deprecated record only fills the gap until an active record for this chain is seen.
735
+ scratch.records[k] = record;
736
+ }
737
+ return;
738
+ }
739
+ unchecked {
740
+ ++k;
741
+ }
742
+ }
743
+
744
+ scratch.chainIds[scratch.chainCount] = record.chainId;
745
+ scratch.records[scratch.chainCount] = record;
746
+ scratch.hasActiveRecord[scratch.chainCount] = isActive;
747
+ unchecked {
748
+ scratch.chainCount = scratch.chainCount + 1;
749
+ }
750
+ }
751
+
752
+ /// @notice Records a combined peer-chain read (value, peer chain ID, snapshot freshness key) from one sucker.
753
+ /// @dev A wrapper over `_recordPeerValue` that unpacks the single-call `JBPeerChainValue` read and enforces the
754
+ /// same non-zero peer-chain requirement the registry applies everywhere else. The peer-chain check reverts here
755
+ /// (inside the caller's `try` success body, so the revert propagates) to preserve the prior behavior where a
756
+ /// sucker reporting a zero peer chain ID fails the whole aggregate view.
757
+ /// @param scratch The per-chain aggregate values and freshness keys recorded so far.
758
+ /// @param read The combined value, peer chain ID, and snapshot freshness key returned by the sucker.
759
+ /// @param sucker The sucker the read came from, used only for the zero-peer-chain error.
760
+ /// @param isActive Whether the value came from an active sucker.
761
+ /// @return The updated number of populated chain entries.
762
+ function _recordPeerChainValue(
763
+ PeerValueScratch memory scratch,
764
+ JBPeerChainValue memory read,
765
+ address sucker,
766
+ bool isActive
767
+ )
768
+ internal
769
+ pure
770
+ returns (uint256)
771
+ {
772
+ if (read.peerChainId == 0) revert JBSuckerRegistry_ZeroPeerChainId({sucker: sucker});
773
+ return _recordPeerValue({
774
+ scratch: scratch,
775
+ chainId: read.peerChainId,
776
+ value: read.value,
777
+ snapshotTimestamp: read.snapshotTimestamp,
778
+ isActive: isActive
779
+ });
521
780
  }
522
781
 
523
782
  /// @notice Records a project-scoped peer-chain aggregate value.
@@ -541,6 +800,14 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
541
800
  pure
542
801
  returns (uint256)
543
802
  {
803
+ // A freshly-deployed active sucker advertises its direct peer chain through `peerChainIds(true)` before it has
804
+ // received any snapshot, producing an empty sentinel (value 0, timestamp 0). Skip that empty active record so
805
+ // it cannot supersede a deprecated sucker's real record for the chain during a migration window. The timestamp
806
+ // is the discriminator — a real snapshot always stamps a nonzero freshness key, so a zero key means "never
807
+ // synced" and this never drops a legitimately zero-valued synced chain; the value clause keeps the skip to
808
+ // genuinely empty records.
809
+ if (isActive && snapshotTimestamp == 0 && value == 0) return scratch.chainCount;
810
+
544
811
  for (uint256 j; j < scratch.chainCount;) {
545
812
  if (scratch.chainIds[j] == chainId) {
546
813
  // Each sucker caches the entire remote chain's state (not a per-sucker share), so multiple
@@ -580,36 +847,6 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
580
847
  }
581
848
  }
582
849
 
583
- /// @notice Records a combined peer-chain read (value, peer chain ID, snapshot freshness key) from one sucker.
584
- /// @dev A wrapper over `_recordPeerValue` that unpacks the single-call `JBPeerChainValue` read and enforces the
585
- /// same non-zero peer-chain requirement the registry applies everywhere else. The peer-chain check reverts here
586
- /// (inside the caller's `try` success body, so the revert propagates) to preserve the prior behavior where a
587
- /// sucker reporting a zero peer chain ID fails the whole aggregate view.
588
- /// @param scratch The per-chain aggregate values and freshness keys recorded so far.
589
- /// @param read The combined value, peer chain ID, and snapshot freshness key returned by the sucker.
590
- /// @param sucker The sucker the read came from, used only for the zero-peer-chain error.
591
- /// @param isActive Whether the value came from an active sucker.
592
- /// @return The updated number of populated chain entries.
593
- function _recordPeerChainValue(
594
- PeerValueScratch memory scratch,
595
- JBPeerChainValue memory read,
596
- address sucker,
597
- bool isActive
598
- )
599
- internal
600
- pure
601
- returns (uint256)
602
- {
603
- if (read.peerChainId == 0) revert JBSuckerRegistry_ZeroPeerChainId({sucker: sucker});
604
- return _recordPeerValue({
605
- scratch: scratch,
606
- chainId: read.peerChainId,
607
- value: read.value,
608
- snapshotTimestamp: read.snapshotTimestamp,
609
- isActive: isActive
610
- });
611
- }
612
-
613
850
  /// @notice Values an amount held in one currency/decimals into another, mirroring the terminal store.
614
851
  /// @dev Adjusts decimals, then converts currency via the prices contract. Both steps short-circuit on identity, and
615
852
  /// the currency step also short-circuits on a zero amount, so a same-currency context consults no feed. A missing
@@ -785,6 +1022,14 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
785
1022
  emit SuckerDeprecated({projectId: projectId, sucker: address(sucker), caller: _msgSender()});
786
1023
  }
787
1024
 
1025
+ /// @notice Removes a sucker deployer from the allowlist.
1026
+ /// @dev Can only be called by this contract's owner (initially project ID 1, or JuiceboxDAO).
1027
+ /// @param deployer The address of the deployer to remove.
1028
+ function removeSuckerDeployer(address deployer) public override onlyOwner {
1029
+ suckerDeployerIsAllowed[deployer] = false;
1030
+ emit SuckerDeployerRemoved({deployer: deployer, caller: _msgSender()});
1031
+ }
1032
+
788
1033
  /// @notice Set the ETH fee (in wei) paid into the fee project on each toRemote() call.
789
1034
  /// @dev Only callable by the contract owner. Fee cannot exceed MAX_TO_REMOTE_FEE.
790
1035
  /// @param fee The new fee amount in wei.
@@ -794,12 +1039,4 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
794
1039
  toRemoteFee = fee;
795
1040
  emit ToRemoteFeeChanged({oldFee: oldFee, newFee: fee, caller: _msgSender()});
796
1041
  }
797
-
798
- /// @notice Removes a sucker deployer from the allowlist.
799
- /// @dev Can only be called by this contract's owner (initially project ID 1, or JuiceboxDAO).
800
- /// @param deployer The address of the deployer to remove.
801
- function removeSuckerDeployer(address deployer) public override onlyOwner {
802
- suckerDeployerIsAllowed[deployer] = false;
803
- emit SuckerDeployerRemoved({deployer: deployer, caller: _msgSender()});
804
- }
805
1042
  }