@bananapus/suckers-v6 0.0.77 → 0.0.79

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,7 +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`
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`
26
27
  - claimants prove inclusion against the peer inbox tree to mint on the destination chain
27
28
 
28
29
  The base implementation is extended for multiple bridge families so the same project model can work across different networks.
@@ -35,16 +36,17 @@ The main idea is not "bridge the token contract." The main idea is "bridge a Jui
35
36
 
36
37
  | Contract | Role |
37
38
  | --- | --- |
38
- | `JBSucker` | Base bridge logic for prepare, relay, claim, token mapping, and lifecycle controls. |
39
+ | `JBSucker` | Base bridge logic for prepare, relay, accounting sync, claim, token mapping, and lifecycle controls. |
39
40
  | `JBSuckerRegistry` | Registry for per-project sucker deployments, deployer allowlists, and shared bridge fee settings. |
40
41
  | Chain-specific suckers | Transport-specific implementations for OP Stack, Arbitrum, CCIP, and related environments. |
41
42
 
42
43
  ## Mental model
43
44
 
44
- Each sucker pair has two jobs:
45
+ Each sucker pair has three jobs:
45
46
 
46
47
  1. destroy or lock the local economic position into a claimable message
47
48
  2. recreate the remote position from a bridged Merkle root plus transported value
49
+ 3. keep a per-source-chain accounting store fresh enough for cross-chain estimates, gossiping every chain's record across the sucker mesh
48
50
 
49
51
  That means every bridge path has two trust surfaces:
50
52
 
@@ -64,7 +66,9 @@ That means every bridge path has two trust surfaces:
64
66
  - do not reason about suckers as if they were generic ERC-20 bridges
65
67
  - root ordering and message delivery semantics matter as much as proof format
66
68
  - token mapping is part of the economic invariant
67
- - 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
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
68
72
  - emergency and deprecation paths are part of normal operational safety
69
73
 
70
74
  ## Where state lives
@@ -80,6 +84,7 @@ That means every bridge path has two trust surfaces:
80
84
  3. `test/ForkClaimMainnet.t.sol`
81
85
  4. `test/regression/PeerSnapshotDesync.t.sol`
82
86
  5. `test/regression/ToRemoteFeeIrrecoverable.t.sol`
87
+ 6. `test/regression/CCIPUntypedMessageRejected.t.sol`
83
88
 
84
89
  ## Install
85
90
 
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.77",
3
+ "version": "0.0.79",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -49,6 +49,26 @@ A sucker bridges a Juicebox project's token economy between two chains. Cashed-o
49
49
  | `terminalTokenAmount` | `uint256` | The amount of terminal tokens to claim. |
50
50
  | `metadata` | `bytes32` | Opaque, caller-defined payload covered by the leaf hash. `bytes32(0)` when no extra context. |
51
51
 
52
+ ### `JBAccountingSnapshot` (argument to `fromRemoteAccounting`)
53
+
54
+ A cross-chain accounting gossip bundle: the sending chain's own record plus every peer-chain record it currently holds.
55
+
56
+ | Field | Type | Meaning |
57
+ |-------|------|---------|
58
+ | `version` | `uint8` | Message format version. Must match `MESSAGE_VERSION`. |
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. |
71
+
52
72
  ## Key functions
53
73
 
54
74
  ### JBSucker — bridge flow (`IJBSucker`)
@@ -57,6 +77,8 @@ A sucker bridges a Juicebox project's token economy between two chains. Cashed-o
57
77
  |----------|--------------|
58
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). |
59
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`. |
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. |
60
82
  | `claim(JBClaim calldata claimData)` | Claim bridged project tokens for the leaf's beneficiary by proving inclusion against the inbox root. |
61
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. |
62
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`). |
@@ -86,8 +108,12 @@ A sucker bridges a Juicebox project's token economy between two chains. Cashed-o
86
108
  | `outboxOf(address token)` | The outbox merkle tree (`JBOutboxTree`) for a token. |
87
109
  | `amountToAddToBalanceOf(address token)` | Tokens received from bridging that are waiting to be added to the project's terminal balance. |
88
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. |
89
- | `peerChainTotalSupply()` | The last-known peer-chain total token supply (used by data hooks to compute effective cross-chain supply). |
90
- | `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. |
91
117
  | `retainedToRemoteFeeOf(address account)` | ETH owed to `account` from a failed `toRemote` fee payment. |
92
118
  | `retainedTransportPaymentRefundOf(address account)` | ETH owed to `account` from a failed transport-payment refund. |
93
119
 
@@ -105,7 +131,8 @@ A sucker bridges a Juicebox project's token economy between two chains. Cashed-o
105
131
  | `suckerPairsOf(uint256 projectId)` | The local/remote sucker pairs (`JBSuckersPair[]`) for a project. |
106
132
  | `isSuckerOf(uint256 projectId, address addr)` | Whether `addr` is a registry-deployed sucker for the project. |
107
133
  | `suckerDeployerIsAllowed(address deployer)` | Whether a deployer is on the allowlist. |
108
- | `remoteTotalSupplyOf(uint256 projectId)` | Combined peer-chain total supply across all remote chains (dedups same-peer suckers by freshest snapshot). |
109
- | `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). |
110
137
  | `totalRemoteBalanceOf(uint256 projectId, uint256 currency, uint256 decimals)` | Combined peer-chain balance valued into `currency`, same valuation rules as above. |
111
138
  | `toRemoteFee()` / `MAX_TO_REMOTE_FEE()` | The current `toRemote` fee and its hardcoded ceiling. |
@@ -12,7 +12,7 @@
12
12
  - If you edit token mapping logic, re-check the registry and deployer assumptions that feed it.
13
13
  - If you edit token mapping semantics, verify that remapping is still impossible once outbox activity has made economic equivalence depend on permanence.
14
14
  - If you edit deprecation or emergency paths, verify the intended operator workflow still works end to end.
15
- - If you edit snapshot or claim-boundary logic, verify `numberOfClaimsSent`, peer snapshots, and emergency exit behavior together.
15
+ - If you edit snapshot or claim-boundary logic, verify `numberOfClaimsSent`, peer snapshots, `syncAccountingData`, and emergency exit behavior together.
16
16
  - If you touch bridge-specific code, confirm whether the real bug is transport-side or shared accounting-side.
17
17
 
18
18
  ## Common failure modes
@@ -11,8 +11,9 @@
11
11
 
12
12
  1. Local state is prepared into a claimable Merkle leaf.
13
13
  2. A root is relayed to the peer chain through the bridge-specific transport.
14
- 3. The remote side records the root in its inbox state.
15
- 4. Claimants prove inclusion and recreate their position on the destination chain.
14
+ 3. The remote side records the root in its inbox state and stores the latest peer accounting snapshot.
15
+ 4. Accounting-only snapshots can also be relayed or retried with `syncAccountingData` without sending a new root.
16
+ 5. Claimants prove inclusion and recreate their position on the destination chain.
16
17
 
17
18
  ## High-risk areas
18
19
 
@@ -21,10 +22,11 @@
21
22
  - Emergency and deprecation paths: these are operational safety surfaces that must remain reliable.
22
23
  - Shared accounting vs transport logic: many incidents stem from confusing these layers.
23
24
  - Peer snapshots and `numberOfClaimsSent`: these guard against double-spend at the cost of conservative locking when timing goes wrong.
25
+ - Accounting-only messages: these must never mutate token-local inbox roots or claimable value.
24
26
 
25
27
  ## Tests to trust first
26
28
 
27
29
  - [`test/ForkMainnet.t.sol`](../test/ForkMainnet.t.sol), [`test/ForkArbitrum.t.sol`](../test/ForkArbitrum.t.sol), [`test/ForkCelo.t.sol`](../test/ForkCelo.t.sol), and [`test/ForkOPStack.t.sol`](../test/ForkOPStack.t.sol) for real transport assumptions.
28
30
  - [`test/ForkSwap.t.sol`](../test/ForkSwap.t.sol), [`test/ForkClaimMainnet.t.sol`](../test/ForkClaimMainnet.t.sol), and [`test/SuckerRegressions.t.sol`](../test/SuckerRegressions.t.sol) for pinned cross-chain edge cases.
29
31
  - [`test/unit/invariants.t.sol`](../test/unit/invariants.t.sol), [`test/unit/peer_chain_state.t.sol`](../test/unit/peer_chain_state.t.sol), and [`test/unit/registry.t.sol`](../test/unit/registry.t.sol) for shared-accounting invariants.
30
- - [`test/SuckerAttacks.t.sol`](../test/SuckerAttacks.t.sol), [`test/SuckerDeepAttacks.t.sol`](../test/SuckerDeepAttacks.t.sol), [`test/regression/PeerSnapshotDesync.t.sol`](../test/regression/PeerSnapshotDesync.t.sol), and [`test/regression/PeerDeterminism.t.sol`](../test/regression/PeerDeterminism.t.sol) when the bug could involve base logic, registry behavior, or a specific bridge implementation.
32
+ - [`test/SuckerAttacks.t.sol`](../test/SuckerAttacks.t.sol), [`test/SuckerDeepAttacks.t.sol`](../test/SuckerDeepAttacks.t.sol), [`test/regression/PeerSnapshotDesync.t.sol`](../test/regression/PeerSnapshotDesync.t.sol), [`test/regression/CCIPUntypedMessageRejected.t.sol`](../test/regression/CCIPUntypedMessageRejected.t.sol), and [`test/regression/PeerDeterminism.t.sol`](../test/regression/PeerDeterminism.t.sol) when the bug could involve base logic, registry behavior, or a specific bridge implementation.
@@ -23,6 +23,7 @@ import {IArbL2GatewayRouter} from "./interfaces/IArbL2GatewayRouter.sol";
23
23
  import {IL1ArbitrumGateway} from "./interfaces/IL1ArbitrumGateway.sol";
24
24
  import {IJBArbitrumSucker} from "./interfaces/IJBArbitrumSucker.sol";
25
25
  import {ARBChains} from "./libraries/ARBChains.sol";
26
+ import {JBAccountingSnapshot} from "./structs/JBAccountingSnapshot.sol";
26
27
  import {JBMessageRoot} from "./structs/JBMessageRoot.sol";
27
28
  import {JBRemoteToken} from "./structs/JBRemoteToken.sol";
28
29
 
@@ -75,7 +76,7 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
75
76
  }
76
77
 
77
78
  //*********************************************************************//
78
- // ------------------------ external views --------------------------- //
79
+ // ------------------------- public views ---------------------------- //
79
80
  //*********************************************************************//
80
81
 
81
82
  /// @notice Returns the chain on which the peer is located.
@@ -90,37 +91,34 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
90
91
  }
91
92
 
92
93
  //*********************************************************************//
93
- // ------------------------ internal views --------------------------- //
94
+ // --------------------- internal transactions ----------------------- //
94
95
  //*********************************************************************//
95
96
 
96
- /// @notice Checks if the `sender` (`_msgSender()`) is a valid representative of the remote peer.
97
- /// @param sender The message's sender.
98
- /// @return valid A flag if the sender is a valid representative of the remote peer.
99
- function _isRemotePeer(address sender) internal view override returns (bool) {
100
- // Convert the bytes32 peer to an address for comparison with EVM bridge contracts.
101
- address peerAddress = _peerAddress();
102
-
103
- // If we are the L1 peer,
104
- if (LAYER == JBLayer.L1) {
105
- IBridge bridge = ARBINBOX.bridge();
106
- // Check that the sender is the bridge and that the outbox has our peer as the sender.
107
- return sender == address(bridge) && peerAddress == IOutbox(bridge.activeOutbox()).l2ToL1Sender();
108
- }
109
-
110
- // If we are the L2 peer, check using the `AddressAliasHelper`.
111
- return sender == AddressAliasHelper.applyL1ToL2Alias(peerAddress);
97
+ /// @notice Approves the Arbitrum gateway to spend `amount` of `approvalToken`.
98
+ /// @param approvalToken The ERC-20 token to approve.
99
+ /// @param gatewayLookupToken The token key used by the gateway router to resolve the spender.
100
+ /// @param amount The amount to approve.
101
+ /// @return gateway The gateway that was approved.
102
+ function _approveGateway(
103
+ address approvalToken,
104
+ address gatewayLookupToken,
105
+ uint256 amount
106
+ )
107
+ internal
108
+ returns (address gateway)
109
+ {
110
+ gateway = GATEWAYROUTER.getGateway(gatewayLookupToken);
111
+ SafeERC20.forceApprove({token: IERC20(approvalToken), spender: gateway, value: amount});
112
112
  }
113
113
 
114
- //*********************************************************************//
115
- // --------------------- internal transactions ----------------------- //
116
- //*********************************************************************//
117
-
118
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.
119
116
  function _createRetryableTicket(
120
117
  uint256 callTransportCost,
121
118
  uint256 nativeValue,
122
119
  uint256 maxSubmissionCost,
123
120
  uint256 maxFeePerGas,
121
+ uint256 gasLimit,
124
122
  bytes memory data
125
123
  )
126
124
  internal
@@ -132,19 +130,44 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
132
130
  maxSubmissionCost: maxSubmissionCost,
133
131
  excessFeeRefundAddress: _msgSender(),
134
132
  callValueRefundAddress: peerAddress,
135
- gasLimit: MESSENGER_BASE_GAS_LIMIT,
133
+ gasLimit: gasLimit,
136
134
  maxFeePerGas: maxFeePerGas,
137
135
  data: data
138
136
  });
139
137
  }
140
138
 
141
- /// @notice Approves the Arbitrum gateway to spend `amount` of `token`.
142
- /// @param token The ERC-20 token to approve.
143
- /// @param amount The amount to approve.
144
- /// @return gateway The gateway that was approved.
145
- function _approveGateway(address token, uint256 amount) internal returns (address gateway) {
146
- gateway = GATEWAYROUTER.getGateway(token);
147
- SafeERC20.forceApprove({token: IERC20(token), spender: gateway, value: amount});
139
+ /// @notice Uses the L1/L2 message bridge to send accounting data over the bridge to the peer.
140
+ /// @param transportPayment The amount of `msg.value` that is going to get paid for sending this message.
141
+ /// @param snapshot The accounting snapshot to send to the remote peer.
142
+ // forge-lint: disable-next-line(mixed-case-function)
143
+ function _sendAccountingSnapshotOverAMB(
144
+ uint256 transportPayment,
145
+ JBAccountingSnapshot memory snapshot
146
+ )
147
+ internal
148
+ override
149
+ {
150
+ // Build the calldata that will be sent to the peer. This calls `JBSucker.fromRemoteAccounting` remotely.
151
+ bytes memory data = abi.encodeCall(JBSucker.fromRemoteAccounting, (snapshot));
152
+ JBRemoteToken memory remoteToken;
153
+
154
+ // Depending on which layer we are on, send the call to the other layer.
155
+ if (LAYER == JBLayer.L1) {
156
+ // L1→L2 requires transport payment for retryable tickets.
157
+ if (transportPayment == 0) revert JBSucker_ExpectedMsgValue({msgValue: transportPayment});
158
+ _toL2({
159
+ token: JBConstants.NATIVE_TOKEN,
160
+ transportPayment: transportPayment,
161
+ amount: 0,
162
+ data: data,
163
+ remoteToken: remoteToken,
164
+ gasLimit: _messagingGasLimit({accounts: snapshot.accounts})
165
+ });
166
+ } else {
167
+ // L2→L1 via ArbSys is free — reject any transport payment.
168
+ if (transportPayment != 0) revert JBSucker_UnexpectedMsgValue(transportPayment);
169
+ _toL1({token: JBConstants.NATIVE_TOKEN, amount: 0, data: data, remoteToken: remoteToken});
170
+ }
148
171
  }
149
172
 
150
173
  /// @notice Uses the L1/L2 gateway to send the root and assets over the bridge to the peer.
@@ -171,7 +194,12 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
171
194
  // L1→L2 requires transport payment for retryable tickets.
172
195
  if (transportPayment == 0) revert JBSucker_ExpectedMsgValue({msgValue: transportPayment});
173
196
  _toL2({
174
- 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})
175
203
  });
176
204
  } else {
177
205
  // L2→L1 via ArbSys is free — reject any transport payment.
@@ -202,7 +230,9 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
202
230
  // If the token is an ERC-20, bridge it to the peer.
203
231
  // If the amount is `0` then we do not need to bridge any ERC20.
204
232
  if (token != JBConstants.NATIVE_TOKEN && amount != 0) {
205
- address gateway = _approveGateway({token: token, amount: amount});
233
+ address gateway = _approveGateway({
234
+ approvalToken: token, gatewayLookupToken: _toAddress(remoteToken.addr), amount: amount
235
+ });
206
236
 
207
237
  // Convert bytes32 types to address at the Arbitrum bridge API boundary.
208
238
  IArbL2GatewayRouter(address(GATEWAYROUTER))
@@ -233,12 +263,14 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
233
263
  /// @param token The token to bridge.
234
264
  /// @param amount The amount of tokens to bridge.
235
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.
236
267
  function _toL2(
237
268
  address token,
238
269
  uint256 transportPayment,
239
270
  uint256 amount,
240
271
  bytes memory data,
241
- JBRemoteToken memory remoteToken
272
+ JBRemoteToken memory remoteToken,
273
+ uint256 gasLimit
242
274
  )
243
275
  internal
244
276
  {
@@ -251,8 +283,9 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
251
283
  maxSubmissionCost =
252
284
  ARBINBOX.calculateRetryableSubmissionFee({dataLength: data.length, baseFee: maxFeePerGas});
253
285
 
254
- // Tracks the cost for the call to the remote peer.
255
- 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);
256
289
  }
257
290
 
258
291
  // If the token is an ERC-20, bridge it to the peer.
@@ -291,7 +324,7 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
291
324
  }
292
325
 
293
326
  // Approve the tokens to be bridged.
294
- _approveGateway({token: token, amount: amount});
327
+ _approveGateway({approvalToken: token, gatewayLookupToken: token, amount: amount});
295
328
 
296
329
  // Perform the ERC-20 bridge transfer. Convert bytes32 peer to address at the Arbitrum bridge API boundary.
297
330
  IArbL1GatewayRouter(address(GATEWAYROUTER)).outboundTransferCustomRefund{value: tokenTransportCost}({
@@ -329,7 +362,30 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
329
362
  nativeValue: nativeValue,
330
363
  maxSubmissionCost: maxSubmissionCost,
331
364
  maxFeePerGas: maxFeePerGas,
365
+ gasLimit: gasLimit,
332
366
  data: data
333
367
  });
334
368
  }
369
+
370
+ //*********************************************************************//
371
+ // ------------------------ internal views --------------------------- //
372
+ //*********************************************************************//
373
+
374
+ /// @notice Checks if the `sender` (`_msgSender()`) is a valid representative of the remote peer.
375
+ /// @param sender The message's sender.
376
+ /// @return valid A flag if the sender is a valid representative of the remote peer.
377
+ function _isRemotePeer(address sender) internal view override returns (bool) {
378
+ // Convert the bytes32 peer to an address for comparison with EVM bridge contracts.
379
+ address peerAddress = _peerAddress();
380
+
381
+ // If we are the L1 peer,
382
+ if (LAYER == JBLayer.L1) {
383
+ IBridge bridge = ARBINBOX.bridge();
384
+ // Check that the sender is the bridge and that the outbox has our peer as the sender.
385
+ return sender == address(bridge) && peerAddress == IOutbox(bridge.activeOutbox()).l2ToL1Sender();
386
+ }
387
+
388
+ // If we are the L2 peer, check using the `AddressAliasHelper`.
389
+ return sender == AddressAliasHelper.applyL1ToL2Alias(peerAddress);
390
+ }
335
391
  }
@@ -32,7 +32,7 @@ contract JBBaseSucker is JBOptimismSucker {
32
32
  {}
33
33
 
34
34
  //*********************************************************************//
35
- // ------------------------ external views --------------------------- //
35
+ // ------------------------- public views ---------------------------- //
36
36
  //*********************************************************************//
37
37
 
38
38
  /// @notice Returns the chain on which the peer is located.
@@ -24,6 +24,8 @@ import {CCIPHelper} from "./libraries/CCIPHelper.sol";
24
24
  import {JBCCIPLib} from "./libraries/JBCCIPLib.sol";
25
25
 
26
26
  // Local: structs (alphabetized)
27
+ import {JBAccountingSnapshot} from "./structs/JBAccountingSnapshot.sol";
28
+ import {JBChainAccounting} from "./structs/JBChainAccounting.sol";
27
29
  import {JBMessageRoot} from "./structs/JBMessageRoot.sol";
28
30
  import {JBRemoteToken} from "./structs/JBRemoteToken.sol";
29
31
  import {JBTokenMapping} from "./structs/JBTokenMapping.sol";
@@ -70,6 +72,9 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
70
72
  // ----------------------- internal constants ------------------------ //
71
73
  //*********************************************************************//
72
74
 
75
+ /// @notice Message type prefix for accounting-only messages (fromRemoteAccounting).
76
+ uint8 internal constant _CCIP_MSG_TYPE_ACCOUNTING = 1;
77
+
73
78
  /// @notice Message type prefix for root messages (fromRemote).
74
79
  uint8 internal constant _CCIP_MSG_TYPE_ROOT = 0;
75
80
 
@@ -121,39 +126,6 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
121
126
  if (address(CCIP_ROUTER) == address(0)) revert JBCCIPSucker_InvalidRouter(address(CCIP_ROUTER));
122
127
  }
123
128
 
124
- //*********************************************************************//
125
- // ------------------------ external views --------------------------- //
126
- //*********************************************************************//
127
-
128
- /// @notice Returns the chain on which the peer is located.
129
- /// @return chainId The chain ID of the peer.
130
- function peerChainId() public view virtual override returns (uint256 chainId) {
131
- return REMOTE_CHAIN_ID;
132
- }
133
-
134
- //*********************************************************************//
135
- // ------------------------- public views ---------------------------- //
136
- //*********************************************************************//
137
-
138
- /// @notice Returns the address of the current CCIP router.
139
- /// @return router The CCIP router address.
140
- function getRouter() public view returns (address router) {
141
- return address(CCIP_ROUTER);
142
- }
143
-
144
- /// @notice Checks whether this contract supports a given interface.
145
- /// @param interfaceId The interface ID to check.
146
- /// @return supported Whether the interface is supported.
147
- /// @dev Should indicate whether the contract implements IAny2EVMMessageReceiver.
148
- /// This allows CCIP to check if ccipReceive is available before calling it.
149
- /// If this returns false or reverts, only tokens are transferred to the receiver.
150
- /// If this returns true, tokens are transferred and ccipReceive is called atomically.
151
- /// Additionally, if the receiver address does not have code associated with
152
- /// it at the time of execution (EXTCODESIZE returns 0), only tokens will be transferred.
153
- function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool supported) {
154
- return interfaceId == type(IAny2EVMMessageReceiver).interfaceId || super.supportsInterface(interfaceId);
155
- }
156
-
157
129
  //*********************************************************************//
158
130
  // --------------------- external transactions ----------------------- //
159
131
  //*********************************************************************//
@@ -180,7 +152,7 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
180
152
  }
181
153
 
182
154
  // Discriminate message type: abi.encode(uint8 type, bytes payload).
183
- (uint8 messageType, bytes memory payload) = JBCCIPLib.decodeTypedMessage(any2EvmMessage.data);
155
+ (uint8 messageType, bytes memory payload) = abi.decode(any2EvmMessage.data, (uint8, bytes));
184
156
 
185
157
  // Handle root messages (merkle tree updates with bridged assets).
186
158
  if (messageType == _CCIP_MSG_TYPE_ROOT) {
@@ -231,15 +203,119 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
231
203
 
232
204
  // Forward the root message to this contract's fromRemote handler.
233
205
  this.fromRemote(root);
206
+ } else if (messageType == _CCIP_MSG_TYPE_ACCOUNTING) {
207
+ // Accounting-only messages must not carry tokens; transported value belongs exclusively to root messages.
208
+ uint256 deliveryCount = any2EvmMessage.destTokenAmounts.length;
209
+ if (deliveryCount != 0) {
210
+ revert JBCCIPSucker_UnexpectedDeliveredTokens(deliveryCount);
211
+ }
212
+
213
+ JBAccountingSnapshot memory snapshot = abi.decode(payload, (JBAccountingSnapshot));
214
+
215
+ // Forward the accounting message to this contract's authenticated accounting handler.
216
+ this.fromRemoteAccounting(snapshot);
234
217
  } else {
235
218
  revert JBCCIPSucker_UnknownMessageType({messageType: messageType});
236
219
  }
237
220
  }
238
221
 
222
+ //*********************************************************************//
223
+ // ------------------------- public views ---------------------------- //
224
+ //*********************************************************************//
225
+
226
+ /// @notice Returns the address of the current CCIP router.
227
+ /// @return router The CCIP router address.
228
+ function getRouter() public view returns (address router) {
229
+ return address(CCIP_ROUTER);
230
+ }
231
+
232
+ /// @notice Returns the chain on which the peer is located.
233
+ /// @return chainId The chain ID of the peer.
234
+ function peerChainId() public view virtual override returns (uint256 chainId) {
235
+ return REMOTE_CHAIN_ID;
236
+ }
237
+
238
+ /// @notice Checks whether this contract supports a given interface.
239
+ /// @param interfaceId The interface ID to check.
240
+ /// @return supported Whether the interface is supported.
241
+ /// @dev Should indicate whether the contract implements IAny2EVMMessageReceiver.
242
+ /// This allows CCIP to check if ccipReceive is available before calling it.
243
+ /// If this returns false or reverts, only tokens are transferred to the receiver.
244
+ /// If this returns true, tokens are transferred and ccipReceive is called atomically.
245
+ /// Additionally, if the receiver address does not have code associated with
246
+ /// it at the time of execution (EXTCODESIZE returns 0), only tokens will be transferred.
247
+ function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool supported) {
248
+ return interfaceId == type(IAny2EVMMessageReceiver).interfaceId || super.supportsInterface(interfaceId);
249
+ }
250
+
239
251
  //*********************************************************************//
240
252
  // --------------------- internal transactions ----------------------- //
241
253
  //*********************************************************************//
242
254
 
255
+ /// @notice Uses CCIP to send accounting data over the bridge to the peer.
256
+ /// @dev Supports the same native/LINK fee modes as root messages, but never transports token amounts.
257
+ /// @param transportPayment The amount of `msg.value` that is going to get paid for sending this message.
258
+ /// @param snapshot The accounting snapshot to send to the remote peer.
259
+ // forge-lint: disable-next-line(mixed-case-function)
260
+ function _sendAccountingSnapshotOverAMB(
261
+ uint256 transportPayment,
262
+ JBAccountingSnapshot memory snapshot
263
+ )
264
+ internal
265
+ virtual
266
+ override
267
+ {
268
+ _sendCcipMessage({
269
+ transportPayment: transportPayment,
270
+ gasLimit: _messagingGasLimit({accounts: snapshot.accounts}),
271
+ encodedPayload: abi.encode(_CCIP_MSG_TYPE_ACCOUNTING, abi.encode(snapshot)),
272
+ tokenAmounts: new Client.EVMTokenAmount[](0)
273
+ });
274
+ }
275
+
276
+ /// @notice Sends a CCIP message and records failed native-fee refunds as caller credit.
277
+ /// @param transportPayment The amount of `msg.value` available to pay native CCIP fees.
278
+ /// @param gasLimit The destination gas limit to ask CCIP to provide.
279
+ /// @param encodedPayload The typed CCIP payload to send to the peer sucker.
280
+ /// @param tokenAmounts The token amounts to bridge with the message.
281
+ function _sendCcipMessage(
282
+ uint256 transportPayment,
283
+ uint256 gasLimit,
284
+ bytes memory encodedPayload,
285
+ Client.EVMTokenAmount[] memory tokenAmounts
286
+ )
287
+ internal
288
+ {
289
+ // Cache the caller so refund accounting and LINK fee pulls are charged to the same account.
290
+ address sender = _msgSender();
291
+
292
+ // Determine fee payment mode: native ETH or LINK token.
293
+ // When transportPayment == 0, we pay in LINK pulled from the caller via transferFrom.
294
+ // This enables chains with no meaningful native token (e.g. Tempo) while keeping
295
+ // toRemote permissionless — the caller provides LINK inline with their bridge intent.
296
+ address feeToken = transportPayment == 0 ? CCIPHelper.linkOfChain(block.chainid) : address(0);
297
+
298
+ // Build and send the CCIP message with the provided typed payload.
299
+ (bool refundFailed, uint256 refundAmount) = JBCCIPLib.sendCCIPMessage({
300
+ ccipRouter: CCIP_ROUTER,
301
+ remoteChainSelector: REMOTE_CHAIN_SELECTOR,
302
+ peerAddress: _peerAddress(),
303
+ transportPayment: transportPayment,
304
+ feeToken: feeToken,
305
+ feeTokenPayer: feeToken != address(0) ? sender : address(0),
306
+ gasLimit: gasLimit,
307
+ encodedPayload: encodedPayload,
308
+ tokenAmounts: tokenAmounts,
309
+ refundRecipient: sender
310
+ });
311
+
312
+ // Retain failed refunds as caller credit instead of leaving them project-addable or stranded.
313
+ if (refundFailed) {
314
+ _retainTransportPaymentRefund({account: sender, amount: refundAmount});
315
+ emit TransportPaymentRefundFailed({recipient: sender, amount: refundAmount, caller: sender});
316
+ }
317
+ }
318
+
243
319
  /// @notice Uses CCIP to send the root and assets over the bridge to the peer.
244
320
  /// @dev Delegates CCIP message construction and sending to JBCCIPLib (via DELEGATECALL) to reduce bytecode.
245
321
  /// @dev Supports two fee modes:
@@ -264,8 +340,8 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
264
340
  virtual
265
341
  override
266
342
  {
267
- // Start with the base gas limit for cross-chain calls.
268
- uint256 gasLimit = MESSENGER_BASE_GAS_LIMIT;
343
+ // Budget for the root receiver plus the accounting contexts carried in the root message.
344
+ uint256 gasLimit = _messagingGasLimit({accounts: suckerMessage.accounts});
269
345
  Client.EVMTokenAmount[] memory tokenAmounts;
270
346
 
271
347
  if (amount != 0) {
@@ -279,32 +355,12 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
279
355
  tokenAmounts = new Client.EVMTokenAmount[](0);
280
356
  }
281
357
 
282
- // Determine fee payment mode: native ETH or LINK token.
283
- // When transportPayment == 0, we pay in LINK pulled from the caller via transferFrom.
284
- // This enables chains with no meaningful native token (e.g. Tempo) while keeping
285
- // toRemote permissionless — the caller provides LINK inline with their bridge intent.
286
- address feeToken = transportPayment == 0 ? CCIPHelper.linkOfChain(block.chainid) : address(0);
287
-
288
- // Build and send the CCIP message with the root payload.
289
- (bool refundFailed, uint256 refundAmount) = JBCCIPLib.sendCCIPMessage({
290
- ccipRouter: CCIP_ROUTER,
291
- remoteChainSelector: REMOTE_CHAIN_SELECTOR,
292
- peerAddress: _peerAddress(),
358
+ _sendCcipMessage({
293
359
  transportPayment: transportPayment,
294
- feeToken: feeToken,
295
- feeTokenPayer: feeToken != address(0) ? _msgSender() : address(0),
296
360
  gasLimit: gasLimit,
297
361
  encodedPayload: abi.encode(_CCIP_MSG_TYPE_ROOT, abi.encode(suckerMessage)),
298
- tokenAmounts: tokenAmounts,
299
- refundRecipient: _msgSender()
362
+ tokenAmounts: tokenAmounts
300
363
  });
301
-
302
- // Retain failed refunds as caller credit instead of leaving them project-addable or stranded.
303
- if (refundFailed) {
304
- // Refund accounting is isolated per caller; reentry cannot increase the retained credit.
305
- _retainTransportPaymentRefund({account: _msgSender(), amount: refundAmount});
306
- emit TransportPaymentRefundFailed({recipient: _msgSender(), amount: refundAmount, caller: _msgSender()});
307
- }
308
364
  }
309
365
 
310
366
  //*********************************************************************//