@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.
package/README.md CHANGED
@@ -22,8 +22,8 @@ The codebase includes multiple bridge variants, but the canonical deployment and
22
22
  Suckers bridge a project by tracking claims in append-only Merkle trees:
23
23
 
24
24
  - users call `prepare` to burn tokens and create a bridge claim in the local outbox tree
25
- - anyone can relay the current root to the peer chain with `toRemote`
26
- - anyone can refresh or retry peer accounting with `syncAccountingData`
25
+ - anyone can relay the current root to the peer chain with `toRemote`, which also gossips the project's per-source-chain accounting bundle
26
+ - anyone can refresh or retry the accounting gossip bundle with `syncAccountingData`
27
27
  - claimants prove inclusion against the peer inbox tree to mint on the destination chain
28
28
 
29
29
  The base implementation is extended for multiple bridge families so the same project model can work across different networks.
@@ -46,7 +46,7 @@ Each sucker pair has three jobs:
46
46
 
47
47
  1. destroy or lock the local economic position into a claimable message
48
48
  2. recreate the remote position from a bridged Merkle root plus transported value
49
- 3. keep the peer's aggregate accounting views fresh enough for cross-chain estimates
49
+ 3. keep a per-source-chain accounting store fresh enough for cross-chain estimates, gossiping every chain's record across the sucker mesh
50
50
 
51
51
  That means every bridge path has two trust surfaces:
52
52
 
@@ -66,8 +66,9 @@ That means every bridge path has two trust surfaces:
66
66
  - do not reason about suckers as if they were generic ERC-20 bridges
67
67
  - root ordering and message delivery semantics matter as much as proof format
68
68
  - token mapping is part of the economic invariant
69
- - peer contexts are merged only when they share both currency and decimals; same-currency contexts with different decimals are kept separate and valued independently at read time, never summed across precisions
70
- - `syncAccountingData` pays no registry `toRemoteFee`, but bridge-specific transport costs still apply and duplicate snapshots can still consume bridge/indexer resources
69
+ - peer contexts are merged only when they share both currency and decimals; same-currency contexts with different decimals are kept separate and valued independently at read time, never summed across precisions — and this merge applies per source chain, since each chain's record is stored and folded on its own
70
+ - accounting propagates as a gossip bundle: a sucker sends its own chain's record plus every peer-chain record the project knows (gathered through the registry), so one sync round from a hub propagates every chain's data to every spoke without a direct sucker between each pair
71
+ - `syncAccountingData` pays no registry `toRemoteFee`, but bridge-specific transport costs still apply and duplicate bundles can still consume bridge/indexer resources
71
72
  - emergency and deprecation paths are part of normal operational safety
72
73
 
73
74
  ## Where state lives
package/foundry.toml CHANGED
@@ -3,6 +3,8 @@ solc = '0.8.28'
3
3
  bytecode_hash = "none"
4
4
  evm_version = 'cancun'
5
5
  optimizer_runs = 200
6
+ # The IR pipeline keeps the per-chain accounting suckers (Arbitrum, CCIP) under the EIP-170 runtime size limit.
7
+ via_ir = true
6
8
  libs = ["node_modules", "lib"]
7
9
  fs_permissions = [{ access = "read-write", path = "./"}]
8
10
  # Archived, unused contracts (e.g. JBSwapCCIPSucker) and their tests live under `archive/` folders and are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/suckers-v6",
3
- "version": "0.0.78",
3
+ "version": "1.0.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,8 +27,8 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@arbitrum/nitro-contracts": "3.2.0",
30
- "@bananapus/core-v6": "^0.0.87",
31
- "@bananapus/permission-ids-v6": "^0.0.32",
30
+ "@bananapus/core-v6": "^1.0.0",
31
+ "@bananapus/permission-ids-v6": "^1.0.0",
32
32
  "@chainlink/contracts-ccip": "1.6.4",
33
33
  "@openzeppelin/contracts": "5.6.1",
34
34
  "@prb/math": "4.1.2",
@@ -51,12 +51,23 @@ A sucker bridges a Juicebox project's token economy between two chains. Cashed-o
51
51
 
52
52
  ### `JBAccountingSnapshot` (argument to `fromRemoteAccounting`)
53
53
 
54
+ A cross-chain accounting gossip bundle: the sending chain's own record plus every peer-chain record it currently holds.
55
+
54
56
  | Field | Type | Meaning |
55
57
  |-------|------|---------|
56
58
  | `version` | `uint8` | Message format version. Must match `MESSAGE_VERSION`. |
57
- | `sourceTotalSupply` | `uint256` | Source-chain project-token supply, including reserved tokens. |
58
- | `sourceContexts` | `JBSourceContext[]` | Raw source-chain surplus and balance contexts, un-valued. |
59
- | `sourceTimestamp` | `uint256` | Monotonic freshness key used to reject stale accounting updates. |
59
+ | `accounts` | `JBChainAccounting[]` | One accounting record per source chain known to the sender (its own chain plus forwarded peers), each carrying its origin chain id and freshness key. The receiver stores the freshest record per source chain. |
60
+
61
+ The same `JBChainAccounting[] accounts` bundle is also carried by the root message `JBMessageRoot` (alongside `token`, `amount`, and `remoteRoot`), so a `toRemote` send propagates accounting too.
62
+
63
+ ### `JBChainAccounting` (one source chain's record in a bundle)
64
+
65
+ | Field | Type | Meaning |
66
+ |-------|------|---------|
67
+ | `chainId` | `uint256` | The source chain this record describes. A receiver ignores a record for its own chain or chain 0. |
68
+ | `totalSupply` | `uint256` | Source-chain project-token supply, including reserved tokens. |
69
+ | `contexts` | `JBSourceContext[]` | Raw source-chain surplus and balance contexts, un-valued (in the source chain's own token addresses and decimals). |
70
+ | `timestamp` | `uint256` | Monotonic source-chain freshness key, gated independently per source chain. |
60
71
 
61
72
  ## Key functions
62
73
 
@@ -66,8 +77,8 @@ A sucker bridges a Juicebox project's token economy between two chains. Cashed-o
66
77
  |----------|--------------|
67
78
  | `prepare(uint256 projectTokenCount, bytes32 beneficiary, uint256 minTokensReclaimed, address token, bytes32 metadata)` | Cash out `projectTokenCount` project tokens into `token` and insert a leaf for `beneficiary` into the outbox tree for bridging. `minTokensReclaimed` bounds slippage; `metadata` is an opaque attribution payload carried in the leaf hash (`bytes32(0)` for a plain bridge). |
68
79
  | `toRemote(address token) payable` | Send the current outbox tree root and bridged assets for `token` to the remote peer through the chain-specific transport. `payable` to fund the transport message and the registry's `toRemoteFee`. |
69
- | `syncAccountingData() payable` | Send the latest total supply, surplus, and balance snapshot without sending an outbox root or paying the registry `toRemoteFee`. Can be retried with unchanged accounting data; `payable` only funds bridge transport. |
70
- | `fromRemoteAccounting(JBAccountingSnapshot calldata snapshot)` | Authenticated receive path for accounting-only snapshots. Updates peer accounting if the snapshot is fresher, without touching any token-local inbox root. |
80
+ | `syncAccountingData() payable` | Send the cross-chain accounting gossip bundle — this chain's own record plus every peer-chain record it holds (gathered across the project's suckers via the registry, minus the destination) without sending an outbox root or paying the registry `toRemoteFee`. Can be retried with unchanged data; `payable` only funds bridge transport. |
81
+ | `fromRemoteAccounting(JBAccountingSnapshot calldata snapshot)` | Authenticated receive path for an accounting-only gossip bundle. Stores the freshest record per source chain, without touching any token-local inbox root. |
71
82
  | `claim(JBClaim calldata claimData)` | Claim bridged project tokens for the leaf's beneficiary by proving inclusion against the inbox root. |
72
83
  | `claim(JBClaim[] calldata claims)` | Claim multiple leaves in one call. Each leaf is routed through an external `this.claim` sub-call, so one failing leaf emits `ClaimFailed` and is reverted in isolation while the rest of the batch proceeds; the failed leaf stays claimable later. |
73
84
  | `mapToken(JBTokenMapping calldata map) payable` | Map a single local token to a remote token for bridging. Mappings are immutable once the outbox tree has entries (can only be disabled, not remapped). Requires `MAP_SUCKER_TOKEN` permission (initial mappings are applied at deploy under `DEPLOY_SUCKERS`). |
@@ -97,8 +108,12 @@ A sucker bridges a Juicebox project's token economy between two chains. Cashed-o
97
108
  | `outboxOf(address token)` | The outbox merkle tree (`JBOutboxTree`) for a token. |
98
109
  | `amountToAddToBalanceOf(address token)` | Tokens received from bridging that are waiting to be added to the project's terminal balance. |
99
110
  | `executedLeafHashOf(address token, uint256 index)` | The committed leaf hash at `(token, index)`, or `bytes32(0)` if unexecuted. Beneficiary contracts re-derive this to authenticate a settlement that a front-runner's direct `claim` already executed. |
100
- | `peerChainTotalSupply()` | The last-known peer-chain total token supply (used by data hooks to compute effective cross-chain supply). |
101
- | `peerChainContextsOf()` | Per-context raw surplus and balance from the latest peer snapshot, with chain ID and freshness key. Un-valued (each context in its own currency/decimals). |
111
+ | `peerChainIds(bool includeVirtual)` | The peer chains this sucker reports accounting for: its directly-connected peer, plus when `includeVirtual` is true every chain learned about through gossip. The registry aggregates the `includeVirtual: true` set. |
112
+ | `peerChainAccountsOf()` | The raw, un-valued `JBChainAccounting[]` record this sucker holds for every known peer chain. The registry reads this to gather a project's cross-chain knowledge and re-gossip it. |
113
+ | `peerChainContextsOf(uint256 chainId)` | Per-context surplus and balance for one peer chain, resolved to local currencies and folded at read time. Un-valued; returned with the chain's freshness key. |
114
+ | `peerChainTotalSupplyOf(uint256 chainId)` | The last-known total token supply on one peer chain (the registry sums these to compute effective cross-chain supply). |
115
+ | `peerChainTotalSupplyValue(uint256 chainId)` | One peer chain's total supply bundled with its chain id and freshness key (`JBPeerChainValue`). |
116
+ | `snapshotTimestampOf(uint256 chainId)` | The freshness key of the latest accepted record for one peer chain. |
102
117
  | `retainedToRemoteFeeOf(address account)` | ETH owed to `account` from a failed `toRemote` fee payment. |
103
118
  | `retainedTransportPaymentRefundOf(address account)` | ETH owed to `account` from a failed transport-payment refund. |
104
119
 
@@ -116,7 +131,8 @@ A sucker bridges a Juicebox project's token economy between two chains. Cashed-o
116
131
  | `suckerPairsOf(uint256 projectId)` | The local/remote sucker pairs (`JBSuckersPair[]`) for a project. |
117
132
  | `isSuckerOf(uint256 projectId, address addr)` | Whether `addr` is a registry-deployed sucker for the project. |
118
133
  | `suckerDeployerIsAllowed(address deployer)` | Whether a deployer is on the allowlist. |
119
- | `remoteTotalSupplyOf(uint256 projectId)` | Combined peer-chain total supply across all remote chains (dedups same-peer suckers by freshest snapshot). |
120
- | `totalRemoteSurplusOf(uint256 projectId, uint256 currency, uint256 decimals)` | Combined peer-chain surplus valued into `currency`. Matching-currency contexts taken at par; missing cross-currency feed skips that sucker (conservative). |
134
+ | `peerChainAccountsOf(uint256 projectId, uint256 exceptChainId)` | The freshest accounting record per source chain across all of a project's suckers, deduped and minus `exceptChainId` — the peer set a sucker forwards when re-gossiping. |
135
+ | `remoteTotalSupplyOf(uint256 projectId)` | Combined peer-chain total supply across all remote chains (aggregates every (sucker, chain) pair, deduped per source chain by freshest record). |
136
+ | `totalRemoteSurplusOf(uint256 projectId, uint256 currency, uint256 decimals)` | Combined peer-chain surplus valued into `currency`. Matching-currency contexts taken at par; a missing cross-currency feed skips that (sucker, chain) (conservative). |
121
137
  | `totalRemoteBalanceOf(uint256 projectId, uint256 currency, uint256 decimals)` | Combined peer-chain balance valued into `currency`, same valuation rules as above. |
122
138
  | `toRemoteFee()` / `MAX_TO_REMOTE_FEE()` | The current `toRemote` fee and its hardcoded ceiling. |
@@ -112,11 +112,13 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
112
112
  }
113
113
 
114
114
  /// @notice Helper to create the retryable ticket, avoiding stack-too-deep.
115
+ /// @param gasLimit The destination L2 gas to provision, scaled to the message's gossip bundle.
115
116
  function _createRetryableTicket(
116
117
  uint256 callTransportCost,
117
118
  uint256 nativeValue,
118
119
  uint256 maxSubmissionCost,
119
120
  uint256 maxFeePerGas,
121
+ uint256 gasLimit,
120
122
  bytes memory data
121
123
  )
122
124
  internal
@@ -128,7 +130,7 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
128
130
  maxSubmissionCost: maxSubmissionCost,
129
131
  excessFeeRefundAddress: _msgSender(),
130
132
  callValueRefundAddress: peerAddress,
131
- gasLimit: MESSENGER_BASE_GAS_LIMIT,
133
+ gasLimit: gasLimit,
132
134
  maxFeePerGas: maxFeePerGas,
133
135
  data: data
134
136
  });
@@ -158,7 +160,8 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
158
160
  transportPayment: transportPayment,
159
161
  amount: 0,
160
162
  data: data,
161
- remoteToken: remoteToken
163
+ remoteToken: remoteToken,
164
+ gasLimit: _messagingGasLimit({accounts: snapshot.accounts})
162
165
  });
163
166
  } else {
164
167
  // L2→L1 via ArbSys is free — reject any transport payment.
@@ -191,7 +194,12 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
191
194
  // L1→L2 requires transport payment for retryable tickets.
192
195
  if (transportPayment == 0) revert JBSucker_ExpectedMsgValue({msgValue: transportPayment});
193
196
  _toL2({
194
- token: token, transportPayment: transportPayment, amount: amount, data: data, remoteToken: remoteToken
197
+ token: token,
198
+ transportPayment: transportPayment,
199
+ amount: amount,
200
+ data: data,
201
+ remoteToken: remoteToken,
202
+ gasLimit: _messagingGasLimit({accounts: message.accounts})
195
203
  });
196
204
  } else {
197
205
  // L2→L1 via ArbSys is free — reject any transport payment.
@@ -255,12 +263,14 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
255
263
  /// @param token The token to bridge.
256
264
  /// @param amount The amount of tokens to bridge.
257
265
  /// @param data The calldata to send to the remote chain. This calls `JBSucker.fromRemote` on the remote peer.
266
+ /// @param gasLimit The destination L2 gas to provision, scaled to the message's gossip bundle.
258
267
  function _toL2(
259
268
  address token,
260
269
  uint256 transportPayment,
261
270
  uint256 amount,
262
271
  bytes memory data,
263
- JBRemoteToken memory remoteToken
272
+ JBRemoteToken memory remoteToken,
273
+ uint256 gasLimit
264
274
  )
265
275
  internal
266
276
  {
@@ -273,8 +283,9 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
273
283
  maxSubmissionCost =
274
284
  ARBINBOX.calculateRetryableSubmissionFee({dataLength: data.length, baseFee: maxFeePerGas});
275
285
 
276
- // Tracks the cost for the call to the remote peer.
277
- callTransportCost = maxSubmissionCost + (MESSENGER_BASE_GAS_LIMIT * maxFeePerGas);
286
+ // Tracks the cost for the call to the remote peer. Scaled to the bundle so a larger mesh's accounting
287
+ // store does not run out of gas on the destination.
288
+ callTransportCost = maxSubmissionCost + (gasLimit * maxFeePerGas);
278
289
  }
279
290
 
280
291
  // If the token is an ERC-20, bridge it to the peer.
@@ -351,6 +362,7 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
351
362
  nativeValue: nativeValue,
352
363
  maxSubmissionCost: maxSubmissionCost,
353
364
  maxFeePerGas: maxFeePerGas,
365
+ gasLimit: gasLimit,
354
366
  data: data
355
367
  });
356
368
  }
@@ -25,6 +25,7 @@ import {JBCCIPLib} from "./libraries/JBCCIPLib.sol";
25
25
 
26
26
  // Local: structs (alphabetized)
27
27
  import {JBAccountingSnapshot} from "./structs/JBAccountingSnapshot.sol";
28
+ import {JBChainAccounting} from "./structs/JBChainAccounting.sol";
28
29
  import {JBMessageRoot} from "./structs/JBMessageRoot.sol";
29
30
  import {JBRemoteToken} from "./structs/JBRemoteToken.sol";
30
31
  import {JBTokenMapping} from "./structs/JBTokenMapping.sol";
@@ -77,9 +78,6 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
77
78
  /// @notice Message type prefix for root messages (fromRemote).
78
79
  uint8 internal constant _CCIP_MSG_TYPE_ROOT = 0;
79
80
 
80
- /// @notice Extra destination gas budgeted for each source accounting context carried in a CCIP message.
81
- uint256 internal constant _CCIP_SOURCE_CONTEXT_GAS_LIMIT = 75_000;
82
-
83
81
  //*********************************************************************//
84
82
  // --------------- public immutable stored properties ---------------- //
85
83
  //*********************************************************************//
@@ -269,7 +267,7 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
269
267
  {
270
268
  _sendCcipMessage({
271
269
  transportPayment: transportPayment,
272
- gasLimit: _ccipGasLimitFor({sourceContextCount: snapshot.sourceContexts.length}),
270
+ gasLimit: _messagingGasLimit({accounts: snapshot.accounts}),
273
271
  encodedPayload: abi.encode(_CCIP_MSG_TYPE_ACCOUNTING, abi.encode(snapshot)),
274
272
  tokenAmounts: new Client.EVMTokenAmount[](0)
275
273
  });
@@ -343,7 +341,7 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
343
341
  override
344
342
  {
345
343
  // Budget for the root receiver plus the accounting contexts carried in the root message.
346
- uint256 gasLimit = _ccipGasLimitFor({sourceContextCount: suckerMessage.sourceContexts.length});
344
+ uint256 gasLimit = _messagingGasLimit({accounts: suckerMessage.accounts});
347
345
  Client.EVMTokenAmount[] memory tokenAmounts;
348
346
 
349
347
  if (amount != 0) {
@@ -369,13 +367,6 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
369
367
  // ------------------------ internal views --------------------------- //
370
368
  //*********************************************************************//
371
369
 
372
- /// @notice The CCIP destination gas limit for a message carrying `sourceContextCount` accounting contexts.
373
- /// @param sourceContextCount The number of source accounting contexts in the message.
374
- /// @return gasLimit The destination gas limit to ask CCIP to provide.
375
- function _ccipGasLimitFor(uint256 sourceContextCount) internal pure returns (uint256 gasLimit) {
376
- return MESSENGER_BASE_GAS_LIMIT + (sourceContextCount * _CCIP_SOURCE_CONTEXT_GAS_LIMIT);
377
- }
378
-
379
370
  /// @notice Checks whether the given sender is a remote peer. Unused in this context.
380
371
  /// @param sender The address to check.
381
372
  /// @return _valid Whether the sender is a remote peer.
@@ -7,6 +7,7 @@ import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
7
7
  import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
8
8
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
9
9
  import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
10
+ import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
10
11
 
11
12
  import {JBSucker} from "./JBSucker.sol";
12
13
  import {JBOptimismSuckerDeployer} from "./deployers/JBOptimismSuckerDeployer.sol";
@@ -102,7 +103,8 @@ contract JBOptimismSucker is JBSucker, IJBOptimismSucker {
102
103
  OPMESSENGER.sendMessage({
103
104
  target: _toAddress(peer()),
104
105
  message: abi.encodeCall(JBSucker.fromRemoteAccounting, (snapshot)),
105
- gasLimit: MESSENGER_BASE_GAS_LIMIT
106
+ // Scale the destination gas with the bundle so a larger mesh's accounting still stores in one relay.
107
+ gasLimit: SafeCast.toUint32(_messagingGasLimit({accounts: snapshot.accounts}))
106
108
  });
107
109
  }
108
110
 
@@ -160,7 +162,8 @@ contract JBOptimismSucker is JBSucker, IJBOptimismSucker {
160
162
  OPMESSENGER.sendMessage{value: nativeValue}({
161
163
  target: peerAddress,
162
164
  message: abi.encodeCall(JBSucker.fromRemote, (message)),
163
- gasLimit: MESSENGER_BASE_GAS_LIMIT
165
+ // Scale the destination gas with the bundle so a larger mesh's accounting still stores in one relay.
166
+ gasLimit: SafeCast.toUint32(_messagingGasLimit({accounts: message.accounts}))
164
167
  });
165
168
  }
166
169
  }