@bananapus/suckers-v6 1.0.1 → 1.0.2

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
@@ -37,7 +37,7 @@ The main idea is not "bridge the token contract." The main idea is "bridge a Jui
37
37
  | Contract | Role |
38
38
  | --- | --- |
39
39
  | `JBSucker` | Base bridge logic for prepare, relay, accounting sync, claim, token mapping, and lifecycle controls. |
40
- | `JBSuckerRegistry` | Registry for per-project sucker deployments, deployer allowlists, and shared bridge fee settings. |
40
+ | `JBSuckerRegistry` | Registry for per-project sucker deployments, deployer and owner-gated token-pair allowlists, and shared bridge fee settings. |
41
41
  | Chain-specific suckers | Transport-specific implementations for OP Stack, Arbitrum, CCIP, and related environments. |
42
42
 
43
43
  ## Mental model
@@ -66,6 +66,7 @@ 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
+ - native/native mappings and different-address local/remote token mappings must be approved by the registry owner for the specific `(localToken, remoteChainId, remoteToken)` route before a project can choose them; non-native same-address mappings and disabled mappings do not need owner approval
69
70
  - 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
71
  - 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
72
  - `syncAccountingData` pays no registry `toRemoteFee`, but bridge-specific transport costs still apply and duplicate bundles can still consume bridge/indexer resources
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/suckers-v6",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,7 +17,7 @@ A sucker bridges a Juicebox project's token economy between two chains. Cashed-o
17
17
  | `JBOptimismSucker` | OP Stack bridge transport. |
18
18
  | `JBCCIPSucker` | Chainlink CCIP transport. |
19
19
  | `JBBaseSucker` | Base/OP Stack transport. |
20
- | `JBSuckerRegistry` | Project-to-sucker inventory, deployer allowlist, shared `toRemote` fee, deprecation removal, cross-chain surplus/supply aggregation. |
20
+ | `JBSuckerRegistry` | Project-to-sucker inventory, deployer allowlist, owner-gated token-pair allowlist, shared `toRemote` fee, deprecation removal, cross-chain surplus/supply aggregation. |
21
21
 
22
22
  `IJBSucker` is the minimal interface; `IJBSuckerExtended` adds the deprecation, emergency-hatch, and retained-fee surface.
23
23
 
@@ -81,7 +81,7 @@ The same `JBChainAccounting[] accounts` bundle is also carried by the root messa
81
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. |
82
82
  | `claim(JBClaim calldata claimData)` | Claim bridged project tokens for the leaf's beneficiary by proving inclusion against the inbox root. |
83
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. |
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`). |
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`). Native/native mappings and different-address mappings must also be approved by the registry owner for this sucker's peer chain. |
85
85
  | `mapTokens(JBTokenMapping[] calldata maps) payable` | Map multiple local tokens to remote tokens in one call. |
86
86
 
87
87
  ### JBSucker — deprecation & emergency (`IJBSuckerExtended`)
@@ -125,6 +125,10 @@ The same `JBChainAccounting[] accounts` bundle is also carried by the root messa
125
125
  | `removeDeprecatedSucker(uint256 projectId, address sucker)` | Remove a fully deprecated sucker from a project's inventory. |
126
126
  | `allowSuckerDeployer(address deployer)` / `allowSuckerDeployers(address[] calldata deployers)` | Add deployer(s) to the allowlist. Owner-only. |
127
127
  | `removeSuckerDeployer(address deployer)` | Remove a deployer from the allowlist. Owner-only. |
128
+ | `allowTokenMapping(address localToken, uint256 remoteChainId, bytes32 remoteToken)` / `allowTokenMappings(address[] calldata localTokens, uint256[] calldata remoteChainIds, bytes32[] calldata remoteTokens)` | Add route-scoped approvals for native/native or different-address local/remote token mappings. Owner-only. |
129
+ | `removeTokenMapping(address localToken, uint256 remoteChainId, bytes32 remoteToken)` / `removeTokenMappings(address[] calldata localTokens, uint256[] calldata remoteChainIds, bytes32[] calldata remoteTokens)` | Remove route-scoped token-mapping approvals. Owner-only. |
130
+ | `requireTokenMappingAllowed(address localToken, uint256 remoteChainId, bytes32 remoteToken)` | Revert unless the mapping can be chosen. Disabled mappings and non-native same-address mappings pass directly; native/native and different-address mappings require route approval. |
131
+ | `tokenMappingIsAllowed(address localToken, uint256 remoteChainId, bytes32 remoteToken)` | Stored owner approval for an owner-gated token route. |
128
132
  | `setToRemoteFee(uint256 fee)` | Set the ETH fee (wei) paid into the fee project on each `toRemote` call. Reverts if `fee > MAX_TO_REMOTE_FEE`. Owner-only. |
129
133
  | `suckersOf(uint256 projectId)` | All active suckers for a project. |
130
134
  | `allSuckersOf(uint256 projectId)` | Every sucker ever registered for a project, including deprecated ones. |
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Configuration surface
4
4
 
5
- - [`src/JBSuckerRegistry.sol`](../src/JBSuckerRegistry.sol) is the first stop for deployer allowlists, shared fees, project inventory, and deprecation helpers.
5
+ - [`src/JBSuckerRegistry.sol`](../src/JBSuckerRegistry.sol) is the first stop for deployer allowlists, owner-gated token-pair allowlists, shared fees, project inventory, and deprecation helpers.
6
6
  - Transport-specific deployers in `src/deployers/` are where chain-specific constants and bridge addresses live.
7
7
  - [`script/Deploy.s.sol`](../script/Deploy.s.sol) is where deployment-time environment wiring belongs.
8
8
 
@@ -10,6 +10,7 @@
10
10
 
11
11
  - If you edit base sucker accounting, verify claim flow across at least one chain-specific implementation.
12
12
  - If you edit token mapping logic, re-check the registry and deployer assumptions that feed it.
13
+ - If you edit owner-gated token mapping rules, verify native/native, different-address, non-native same-address, disabled, deploy-time, and route-scoped cases.
13
14
  - If you edit token mapping semantics, verify that remapping is still impossible once outbox activity has made economic equivalence depend on permanence.
14
15
  - If you edit deprecation or emergency paths, verify the intended operator workflow still works end to end.
15
16
  - If you edit snapshot or claim-boundary logic, verify `numberOfClaimsSent`, peer snapshots, `syncAccountingData`, and emergency exit behavior together.
@@ -18,6 +19,7 @@
18
19
  ## Common failure modes
19
20
 
20
21
  - Cross-chain issue is blamed on transport when the root or token mapping was wrong before message delivery.
22
+ - A token-pair approval is assumed to be global when it is actually scoped to one `(localToken, remoteChainId, remoteToken)` route.
21
23
  - Registry configuration drifts from what a deployer or external operator expects.
22
24
  - Emergency hatches or deprecation paths are stale because nobody exercises them until stress conditions arrive.
23
25
 
@@ -3,7 +3,7 @@
3
3
  ## Core roles
4
4
 
5
5
  - [`src/JBSucker.sol`](../src/JBSucker.sol) owns the shared prepare, relay, claim, token-mapping, and lifecycle logic.
6
- - [`src/JBSuckerRegistry.sol`](../src/JBSuckerRegistry.sol) owns project-to-sucker inventory, deployer allowlists, and shared remote-fee settings.
6
+ - [`src/JBSuckerRegistry.sol`](../src/JBSuckerRegistry.sol) owns project-to-sucker inventory, deployer allowlists, owner-gated token-pair allowlists, and shared remote-fee settings.
7
7
  - Chain-specific sucker contracts such as [`src/JBArbitrumSucker.sol`](../src/JBArbitrumSucker.sol), [`src/JBOptimismSucker.sol`](../src/JBOptimismSucker.sol), [`src/JBCCIPSucker.sol`](../src/JBCCIPSucker.sol), and [`src/JBCeloSucker.sol`](../src/JBCeloSucker.sol) own transport-specific delivery and verification.
8
8
  - Matching deployers under `src/deployers/` own clone and transport configuration.
9
9
 
@@ -17,7 +17,7 @@
17
17
 
18
18
  ## High-risk areas
19
19
 
20
- - Token mapping: mapping mistakes break economic equivalence, not just UX.
20
+ - Token mapping: mapping mistakes break economic equivalence, not just UX. Native/native and different-address mappings require route-scoped registry-owner approval before a project can choose them.
21
21
  - Root ordering and replay protection: message sequencing is part of correctness.
22
22
  - Emergency and deprecation paths: these are operational safety surfaces that must remain reliable.
23
23
  - Shared accounting vs transport logic: many incidents stem from confusing these layers.
package/src/JBSucker.sol CHANGED
@@ -1307,6 +1307,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1307
1307
  returns (uint256 transportPaymentSpent)
1308
1308
  {
1309
1309
  address token = map.localToken;
1310
+ bytes32 remoteToken = map.remoteToken;
1310
1311
  JBRemoteToken memory currentMapping = _remoteTokenFor[token];
1311
1312
 
1312
1313
  // Once the emergency hatch for a token is enabled it can't be disabled.
@@ -1328,12 +1329,15 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1328
1329
  alsoGrantAccessIf: _msgSender() == address(REGISTRY)
1329
1330
  });
1330
1331
 
1332
+ // The registry owner gates mappings that assert economic equivalence across distinct chain assets.
1333
+ REGISTRY.requireTokenMappingAllowed({localToken: token, remoteChainId: peerChainId(), remoteToken: remoteToken});
1334
+
1331
1335
  // Make sure that the token does not get remapped to another remote token.
1332
1336
  // As this would cause the funds for this token to be double spendable on the other side.
1333
1337
  // It should not be possible to cause any issues even without this check
1334
1338
  // a bridge *should* never accept such a request. This is mostly a sanity check.
1335
1339
  if (
1336
- currentMapping.addr != bytes32(0) && currentMapping.addr != map.remoteToken && map.remoteToken != bytes32(0)
1340
+ currentMapping.addr != bytes32(0) && currentMapping.addr != remoteToken && remoteToken != bytes32(0)
1337
1341
  && _outboxOf[token].tree.count != 0
1338
1342
  ) {
1339
1343
  revert JBSucker_TokenAlreadyMapped({localToken: token, mappedTo: currentMapping.addr});
@@ -1342,10 +1346,10 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1342
1346
  // A remote token can back only one local token's outbox in this sucker. Otherwise two independent source
1343
1347
  // nonces would race into the same destination inbox key (`root.token`), making one token's root stale or
1344
1348
  // overwriting the other. Other suckers have separate inbox/outbox storage and are unaffected.
1345
- if (map.remoteToken != bytes32(0)) {
1346
- address mappedLocalToken = _localTokenForRemoteToken[map.remoteToken];
1349
+ if (remoteToken != bytes32(0)) {
1350
+ address mappedLocalToken = _localTokenForRemoteToken[remoteToken];
1347
1351
  if (mappedLocalToken != address(0) && mappedLocalToken != token) {
1348
- revert JBSucker_RemoteTokenAlreadyMapped({remoteToken: map.remoteToken, localToken: mappedLocalToken});
1352
+ revert JBSucker_RemoteTokenAlreadyMapped({remoteToken: remoteToken, localToken: mappedLocalToken});
1349
1353
  }
1350
1354
  }
1351
1355
 
@@ -1359,7 +1363,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1359
1363
  // created and the mapping cannot be changed to a different remote token. If tokens are stuck in this state
1360
1364
  // (e.g., the bridge is non-functional), the project owner can call `enableEmergencyHatchFor` to allow
1361
1365
  // local withdrawals via `exitThroughEmergencyHatch`.
1362
- if (map.remoteToken == bytes32(0) && _outboxOf[token].numberOfClaimsSent != _outboxOf[token].tree.count) {
1366
+ if (remoteToken == bytes32(0) && _outboxOf[token].numberOfClaimsSent != _outboxOf[token].tree.count) {
1363
1367
  // Disable before external call to prevent reentrancy via prepare().
1364
1368
  // _sendRoot uses the `currentMapping` parameter, not storage, so this is safe.
1365
1369
  _remoteTokenFor[token].enabled = false;
@@ -1369,13 +1373,13 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1369
1373
 
1370
1374
  // Update the reverse reservation if an unused local token is being remapped to a new remote token.
1371
1375
  if (
1372
- map.remoteToken != bytes32(0) && currentMapping.addr != bytes32(0) && currentMapping.addr != map.remoteToken
1376
+ remoteToken != bytes32(0) && currentMapping.addr != bytes32(0) && currentMapping.addr != remoteToken
1373
1377
  && _localTokenForRemoteToken[currentMapping.addr] == token
1374
1378
  ) {
1375
1379
  delete _localTokenForRemoteToken[currentMapping.addr];
1376
1380
  }
1377
1381
 
1378
- bytes32 remoteToken = map.remoteToken == bytes32(0) ? currentMapping.addr : map.remoteToken;
1382
+ remoteToken = remoteToken == bytes32(0) ? currentMapping.addr : remoteToken;
1379
1383
  if (remoteToken != bytes32(0)) _localTokenForRemoteToken[remoteToken] = token;
1380
1384
 
1381
1385
  // Update the token mapping.
@@ -2022,17 +2026,18 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
2022
2026
  /// @notice Allow sucker implementations to add/override mapping rules to suit their specific needs.
2023
2027
  /// @param map The token mapping to validate.
2024
2028
  function _validateTokenMapping(JBTokenMapping calldata map) internal pure virtual {
2025
- bool isNative = map.localToken == JBConstants.NATIVE_TOKEN;
2026
-
2027
2029
  // If the token being mapped is the native token, the `remoteToken` must also be the native token.
2028
2030
  // The native token can also be mapped to the 0 address, which is used to disable native token bridging.
2029
- if (isNative && map.remoteToken != _toBytes32(JBConstants.NATIVE_TOKEN) && map.remoteToken != bytes32(0)) {
2030
- revert JBSucker_InvalidNativeRemoteAddress({remoteToken: map.remoteToken});
2031
+ if (map.localToken == JBConstants.NATIVE_TOKEN) {
2032
+ if (map.remoteToken != _toBytes32(JBConstants.NATIVE_TOKEN) && map.remoteToken != bytes32(0)) {
2033
+ revert JBSucker_InvalidNativeRemoteAddress({remoteToken: map.remoteToken});
2034
+ }
2035
+ return;
2031
2036
  }
2032
2037
 
2033
2038
  // Enforce a reasonable minimum gas limit for bridging. A minimum which is too low could lead to the loss of
2034
2039
  // funds.
2035
- if (map.minGas < MESSENGER_ERC20_MIN_GAS_LIMIT && !isNative) {
2040
+ if (map.minGas < MESSENGER_ERC20_MIN_GAS_LIMIT) {
2036
2041
  revert JBSucker_BelowMinGas({minGas: map.minGas, minGasLimit: MESSENGER_ERC20_MIN_GAS_LIMIT});
2037
2042
  }
2038
2043
  }
@@ -6,6 +6,7 @@ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
6
6
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
7
7
  import {IJBPrices} from "@bananapus/core-v6/src/interfaces/IJBPrices.sol";
8
8
  import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
9
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
9
10
  import {JBFixedPointNumber} from "@bananapus/core-v6/src/libraries/JBFixedPointNumber.sol";
10
11
  import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
11
12
  import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
@@ -14,13 +15,13 @@ import {Context} from "@openzeppelin/contracts/utils/Context.sol";
14
15
  import {EnumerableMap} from "@openzeppelin/contracts/utils/structs/EnumerableMap.sol";
15
16
  import {mulDiv} from "@prb/math/src/Common.sol";
16
17
 
17
- import {JBChainAccounting} from "./structs/JBChainAccounting.sol";
18
- import {JBPeerChainContext} from "./structs/JBPeerChainContext.sol";
19
- import {JBPeerChainValue} from "./structs/JBPeerChainValue.sol";
20
18
  import {JBSuckerState} from "./enums/JBSuckerState.sol";
21
19
  import {IJBSucker} from "./interfaces/IJBSucker.sol";
22
20
  import {IJBSuckerDeployer} from "./interfaces/IJBSuckerDeployer.sol";
23
21
  import {IJBSuckerRegistry} from "./interfaces/IJBSuckerRegistry.sol";
22
+ import {JBChainAccounting} from "./structs/JBChainAccounting.sol";
23
+ import {JBPeerChainContext} from "./structs/JBPeerChainContext.sol";
24
+ import {JBPeerChainValue} from "./structs/JBPeerChainValue.sol";
24
25
  import {JBSuckerDeployerConfig} from "./structs/JBSuckerDeployerConfig.sol";
25
26
  import {JBSuckersPair} from "./structs/JBSuckersPair.sol";
26
27
  import {PeerAccountScratch} from "./structs/PeerAccountScratch.sol";
@@ -50,6 +51,14 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
50
51
  /// @notice Thrown when a sucker is being removed from active listings but is not deprecated.
51
52
  error JBSuckerRegistry_SuckerIsNotDeprecated(address sucker, JBSuckerState suckerState);
52
53
 
54
+ /// @notice Thrown when token mapping arrays have different lengths.
55
+ error JBSuckerRegistry_TokenMappingLengthMismatch(
56
+ uint256 localTokenCount, uint256 remoteChainIdCount, uint256 remoteTokenCount
57
+ );
58
+
59
+ /// @notice Thrown when a project chooses an owner-gated token mapping that has not been approved.
60
+ error JBSuckerRegistry_TokenMappingNotAllowed(address localToken, uint256 remoteChainId, bytes32 remoteToken);
61
+
53
62
  /// @notice Thrown when a sucker reports a zero peer chain ID and cannot identify a real peer chain.
54
63
  error JBSuckerRegistry_ZeroPeerChainId(address sucker);
55
64
 
@@ -79,10 +88,10 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
79
88
  // --------------- public immutable stored properties ---------------- //
80
89
  //*********************************************************************//
81
90
 
82
- /// @notice The Juicebox directory used to look up project terminals and controllers.
91
+ /// @notice The Juicebox directory for project terminals and controllers.
83
92
  IJBDirectory public immutable override DIRECTORY;
84
93
 
85
- /// @notice The prices contract used to value remote per-context surplus and balance into a requested currency,
94
+ /// @notice The prices contract for valuing remote per-context surplus and balance into a requested currency,
86
95
  /// exactly as the terminal store values local surplus.
87
96
  IJBPrices public immutable PRICES;
88
97
 
@@ -97,6 +106,15 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
97
106
  /// @custom:param deployer The address of the deployer to check.
98
107
  mapping(address deployer => bool) public override suckerDeployerIsAllowed;
99
108
 
109
+ /// @notice Tracks whether projects may choose an owner-gated token mapping.
110
+ /// @dev Disable mappings and non-native same-address mappings pass through the mapping checks directly.
111
+ /// @custom:param localToken The local token address.
112
+ /// @custom:param remoteChainId The ID of the remote chain.
113
+ /// @custom:param remoteToken The remote token address encoded as bytes32.
114
+ mapping(address localToken => mapping(uint256 remoteChainId => mapping(bytes32 remoteToken => bool)))
115
+ public
116
+ override tokenMappingIsAllowed;
117
+
100
118
  /// @notice The ETH fee (in wei) paid into the fee project via terminal.pay() on each toRemote() call.
101
119
  uint256 public override toRemoteFee;
102
120
 
@@ -114,7 +132,7 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
114
132
 
115
133
  /// @param directory The juicebox directory.
116
134
  /// @param permissions A contract storing permissions.
117
- /// @param prices The prices contract used to value remote per-context surplus/balance into a requested currency.
135
+ /// @param prices The prices contract for valuing remote per-context surplus/balance into a requested currency.
118
136
  /// @param initialOwner The initial owner of this contract.
119
137
  constructor(
120
138
  IJBDirectory directory,
@@ -216,108 +234,8 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
216
234
  }
217
235
  }
218
236
 
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).
225
- /// @param sucker The sucker to read.
226
- /// @param chainId The peer chain to read.
227
- /// @param projectId The project whose price feeds to use.
228
- /// @param currency The currency to value into.
229
- /// @param decimals The decimal precision for the returned value.
230
- /// @return A `JBPeerChainValue` with the valued balance, the peer chain ID, and its snapshot freshness key.
231
- function remoteBalanceOf(
232
- address sucker,
233
- uint256 chainId,
234
- uint256 projectId,
235
- uint256 currency,
236
- uint256 decimals
237
- )
238
- external
239
- view
240
- returns (JBPeerChainValue memory)
241
- {
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);
244
-
245
- // Value each context's balance out of the currency and decimals it was recorded in, into the requested
246
- // `currency` and `decimals`, and sum across every context. A context already denominated in `currency` folds
247
- // in at par; a cross-currency context is converted through the project's price feed.
248
- uint256 value;
249
- uint256 numContexts = contexts.length;
250
- for (uint256 i; i < numContexts;) {
251
- value += _valued({
252
- amount: contexts[i].balance,
253
- fromCurrency: contexts[i].currency,
254
- fromDecimals: contexts[i].decimals,
255
- toCurrency: currency,
256
- toDecimals: decimals,
257
- projectId: projectId
258
- });
259
- unchecked {
260
- ++i;
261
- }
262
- }
263
-
264
- // Carry the peer chain ID and snapshot freshness alongside the summed value so the aggregator can deduplicate
265
- // peers and keep only the freshest snapshot per chain.
266
- return JBPeerChainValue({value: value, peerChainId: chainId, snapshotTimestamp: snapshot});
267
- }
268
-
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).
275
- /// @param sucker The sucker to read.
276
- /// @param chainId The peer chain to read.
277
- /// @param projectId The project whose price feeds to use.
278
- /// @param currency The currency to value into.
279
- /// @param decimals The decimal precision for the returned value.
280
- /// @return A `JBPeerChainValue` with the valued surplus, the peer chain ID, and its snapshot freshness key.
281
- function remoteSurplusOf(
282
- address sucker,
283
- uint256 chainId,
284
- uint256 projectId,
285
- uint256 currency,
286
- uint256 decimals
287
- )
288
- external
289
- view
290
- returns (JBPeerChainValue memory)
291
- {
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);
294
-
295
- // Value each context's surplus out of the currency and decimals it was recorded in, into the requested
296
- // `currency` and `decimals`, and sum across every context. A context already denominated in `currency` folds
297
- // in at par; a cross-currency context is converted through the project's price feed.
298
- uint256 value;
299
- uint256 numContexts = contexts.length;
300
- for (uint256 i; i < numContexts;) {
301
- value += _valued({
302
- amount: contexts[i].surplus,
303
- fromCurrency: contexts[i].currency,
304
- fromDecimals: contexts[i].decimals,
305
- toCurrency: currency,
306
- toDecimals: decimals,
307
- projectId: projectId
308
- });
309
- unchecked {
310
- ++i;
311
- }
312
- }
313
-
314
- // Carry the peer chain ID and snapshot freshness alongside the summed value so the aggregator can deduplicate
315
- // peers and keep only the freshest snapshot per chain.
316
- return JBPeerChainValue({value: value, peerChainId: chainId, snapshotTimestamp: snapshot});
317
- }
318
-
319
237
  /// @notice The cumulative total supply across all remote peer chains for a project.
320
- /// @dev Each sucker now holds an accounting record per source chain it has heard about (its direct peer plus chains
238
+ /// @dev Each sucker holds an accounting record per source chain it has heard about (its direct peer plus chains
321
239
  /// gossiped through it), so this aggregates over every (sucker, chain) pair and dedups per chain. Includes
322
240
  /// deprecated suckers only when no active sucker answers for the same peer chain, to prevent undercounting during
323
241
  /// migration windows without letting stale deprecated records dominate live routes. Silently skips suckers and
@@ -367,6 +285,24 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
367
285
  }
368
286
  }
369
287
 
288
+ /// @notice Reverts unless a local-to-remote token mapping can be chosen by a project.
289
+ /// @dev Disable mappings never require owner approval. Non-native same-address mappings pass through directly.
290
+ /// Native-to-native and differing-address mappings must be explicitly allowed.
291
+ /// @param localToken The local token address.
292
+ /// @param remoteChainId The ID of the remote chain.
293
+ /// @param remoteToken The remote token address encoded as bytes32.
294
+ function requireTokenMappingAllowed(
295
+ address localToken,
296
+ uint256 remoteChainId,
297
+ bytes32 remoteToken
298
+ )
299
+ external
300
+ view
301
+ override
302
+ {
303
+ _requireTokenMappingAllowed({localToken: localToken, remoteChainId: remoteChainId, remoteToken: remoteToken});
304
+ }
305
+
370
306
  /// @notice All active (non-deprecated) suckers for a project, with their remote peer address and chain ID.
371
307
  /// @param projectId The ID of the project to get the suckers of.
372
308
  /// @return pairs The pairs of suckers and their metadata.
@@ -489,9 +425,9 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
489
425
  //*********************************************************************//
490
426
 
491
427
  /// @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.
428
+ /// @dev Each (sucker, chain) is valued independently so a reverted sucker read or missing cross-currency price
429
+ /// feed drops only that one chain. Reads the sucker's chains itself, and is extracted from the aggregate view to
430
+ /// keep both stacks shallow.
495
431
  /// @param scratch The per-chain dedup scratch to fold values into.
496
432
  /// @param sucker The sucker whose chains to value.
497
433
  /// @param isActive Whether the sucker is active (vs deprecated).
@@ -506,9 +442,8 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
506
442
  view
507
443
  {
508
444
  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.
445
+ // Aggregate over direct peer chains and gossiped virtual chains so the result reflects every chain the project
446
+ // knows, not only its direct bridges.
512
447
  try IJBSucker(sucker).peerChainIds(true) returns (uint256[] memory ids) {
513
448
  chainIds = ids;
514
449
  } catch {
@@ -517,37 +452,20 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
517
452
 
518
453
  uint256 numChains = chainIds.length;
519
454
  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 {
537
- try this.remoteBalanceOf({
538
- sucker: sucker,
539
- chainId: chainIds[c],
540
- projectId: params.projectId,
541
- currency: params.currency,
542
- decimals: params.decimals
543
- }) returns (
544
- JBPeerChainValue memory value
545
- ) {
546
- scratch.chainCount = _recordPeerChainValue({
547
- scratch: scratch, read: value, sucker: sucker, isActive: isActive
548
- });
549
- } catch {}
455
+ (bool ok, JBPeerChainValue memory value) = _remoteValueOf({
456
+ sucker: sucker,
457
+ chainId: chainIds[c],
458
+ projectId: params.projectId,
459
+ currency: params.currency,
460
+ decimals: params.decimals,
461
+ surplus: params.surplus
462
+ });
463
+
464
+ if (ok) {
465
+ scratch.chainCount =
466
+ _recordPeerChainValue({scratch: scratch, read: value, sucker: sucker, isActive: isActive});
550
467
  }
468
+
551
469
  unchecked {
552
470
  ++c;
553
471
  }
@@ -693,7 +611,7 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
693
611
  }
694
612
  }
695
613
 
696
- /// @notice Allocates scratch arrays used to collapse many suckers into one aggregate value per peer chain.
614
+ /// @notice Allocates scratch arrays for collapsing many suckers into one aggregate value per peer chain.
697
615
  /// @dev `len` is the number of suckers being scanned, which is the maximum possible number of distinct peer
698
616
  /// chains. `chainCount` starts at zero and is incremented as new peer chains are discovered.
699
617
  /// @param len The maximum number of peer-chain entries the aggregation can need.
@@ -751,9 +669,8 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
751
669
 
752
670
  /// @notice Records a combined peer-chain read (value, peer chain ID, snapshot freshness key) from one sucker.
753
671
  /// @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.
672
+ /// same non-zero peer-chain requirement the registry applies everywhere else. A zero peer chain ID fails the whole
673
+ /// aggregate view because it makes the source chain unidentifiable.
757
674
  /// @param scratch The per-chain aggregate values and freshness keys recorded so far.
758
675
  /// @param read The combined value, peer chain ID, and snapshot freshness key returned by the sucker.
759
676
  /// @param sucker The sucker the read came from, used only for the zero-peer-chain error.
@@ -847,18 +764,115 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
847
764
  }
848
765
  }
849
766
 
850
- /// @notice Values an amount held in one currency/decimals into another, mirroring the terminal store.
851
- /// @dev Adjusts decimals, then converts currency via the prices contract. Both steps short-circuit on identity, and
852
- /// the currency step also short-circuits on a zero amount, so a same-currency context consults no feed. A missing
853
- /// feed reverts (fail-closed), and the caller catches it to drop just the affected sucker.
767
+ /// @notice Values one peer chain's raw balance or surplus held by one sucker into a currency.
768
+ /// @dev Returns `false` if the sucker read reverts or any cross-currency price read fails, allowing aggregate
769
+ /// views to skip only the affected (sucker, chain) record.
770
+ /// @param sucker The sucker to read.
771
+ /// @param chainId The peer chain to read.
772
+ /// @param projectId The project whose price feeds to use.
773
+ /// @param currency The currency to value into.
774
+ /// @param decimals The decimal precision for the returned value.
775
+ /// @param surplus Whether to value surplus (true) or balance (false).
776
+ /// @return ok Whether the peer-chain value was read and valued successfully.
777
+ /// @return read The peer-chain value, peer chain ID, and snapshot freshness key.
778
+ function _remoteValueOf(
779
+ address sucker,
780
+ uint256 chainId,
781
+ uint256 projectId,
782
+ uint256 currency,
783
+ uint256 decimals,
784
+ bool surplus
785
+ )
786
+ internal
787
+ view
788
+ returns (bool ok, JBPeerChainValue memory read)
789
+ {
790
+ JBPeerChainContext[] memory contexts;
791
+ uint256 snapshot;
792
+
793
+ // The sucker read is isolated so one reverting sucker/chain does not make the aggregate view unusable.
794
+ try IJBSucker(sucker).peerChainContextsOf(chainId) returns (
795
+ JBPeerChainContext[] memory readContexts, uint256 readSnapshot
796
+ ) {
797
+ contexts = readContexts;
798
+ snapshot = readSnapshot;
799
+ } catch {
800
+ return (false, read);
801
+ }
802
+
803
+ uint256 value;
804
+ uint256 numContexts = contexts.length;
805
+ for (uint256 i; i < numContexts;) {
806
+ // The same context set backs both aggregate views; select the requested side before pricing.
807
+ uint256 amount = surplus ? contexts[i].surplus : contexts[i].balance;
808
+
809
+ // If one context cannot be valued, the caller skips the whole (sucker, chain) record instead of returning a
810
+ // partially priced total.
811
+ (bool priced, uint256 valued) = _tryValued({
812
+ amount: amount,
813
+ fromCurrency: contexts[i].currency,
814
+ fromDecimals: contexts[i].decimals,
815
+ toCurrency: currency,
816
+ toDecimals: decimals,
817
+ projectId: projectId
818
+ });
819
+
820
+ if (!priced) return (false, read);
821
+
822
+ // Contexts have already been folded by currency/decimals on the sucker, so valued outputs share the
823
+ // requested units and can be added directly.
824
+ value += valued;
825
+
826
+ unchecked {
827
+ ++i;
828
+ }
829
+ }
830
+
831
+ read = JBPeerChainValue({value: value, peerChainId: chainId, snapshotTimestamp: snapshot});
832
+ ok = true;
833
+ }
834
+
835
+ /// @notice Reverts unless a local-to-remote token mapping is allowed.
836
+ /// @param localToken The local token address.
837
+ /// @param remoteChainId The ID of the remote chain.
838
+ /// @param remoteToken The remote token address encoded as bytes32.
839
+ function _requireTokenMappingAllowed(address localToken, uint256 remoteChainId, bytes32 remoteToken) internal view {
840
+ if (_tokenMappingPassesWithoutApproval({localToken: localToken, remoteToken: remoteToken})) return;
841
+
842
+ // All economically asserted mappings must be allowlisted by the registry owner before project owners choose
843
+ // them.
844
+ if (!tokenMappingIsAllowed[localToken][remoteChainId][remoteToken]) {
845
+ revert JBSuckerRegistry_TokenMappingNotAllowed({
846
+ localToken: localToken, remoteChainId: remoteChainId, remoteToken: remoteToken
847
+ });
848
+ }
849
+ }
850
+
851
+ /// @notice Whether a local-to-remote token mapping can be chosen without owner approval.
852
+ /// @param localToken The local token address.
853
+ /// @param remoteToken The remote token address encoded as bytes32.
854
+ /// @return Whether the mapping passes without owner approval.
855
+ function _tokenMappingPassesWithoutApproval(address localToken, bytes32 remoteToken) internal pure returns (bool) {
856
+ // Disabling a mapping is a project-local safety action, not a claim about remote token equivalence.
857
+ if (remoteToken == bytes32(0)) return true;
858
+
859
+ // Non-native same-address mappings are unambiguous across EVM chains. Native tokens use the same sentinel
860
+ // even when the underlying assets differ, so native-to-native still needs registry owner approval.
861
+ return localToken != JBConstants.NATIVE_TOKEN && remoteToken == bytes32(uint256(uint160(localToken)));
862
+ }
863
+
864
+ /// @notice Tries to value an amount held in one currency/decimals into another.
865
+ /// @dev Returns `false` when the needed cross-currency price read reverts or returns zero. Same-currency and
866
+ /// zero-amount values never consult the price feed.
854
867
  /// @param amount The raw amount in `fromCurrency`/`fromDecimals`.
855
868
  /// @param fromCurrency The currency the amount is held in.
856
869
  /// @param fromDecimals The decimals the amount is held in.
857
870
  /// @param toCurrency The currency to value into.
858
871
  /// @param toDecimals The decimals to value into.
859
872
  /// @param projectId The project whose price feeds to use.
860
- /// @return The amount valued into `toCurrency`/`toDecimals`.
861
- function _valued(
873
+ /// @return ok Whether the amount was valued successfully.
874
+ /// @return converted The amount valued into `toCurrency`/`toDecimals`.
875
+ function _tryValued(
862
876
  uint256 amount,
863
877
  uint256 fromCurrency,
864
878
  uint256 fromDecimals,
@@ -868,23 +882,34 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
868
882
  )
869
883
  internal
870
884
  view
871
- returns (uint256)
885
+ returns (bool ok, uint256 converted)
872
886
  {
873
- // Step 1: adjust decimals.
874
- uint256 value = fromDecimals == toDecimals
887
+ // Convert the raw context amount to the caller's requested decimals before any currency comparison, matching
888
+ // terminal-store valuation semantics.
889
+ converted = fromDecimals == toDecimals
875
890
  ? amount
876
891
  : JBFixedPointNumber.adjustDecimals({value: amount, decimals: fromDecimals, targetDecimals: toDecimals});
877
892
 
878
- // Step 2: convert currency. The price is the denominator: pricePerUnitOf returns the `fromCurrency` price of
879
- // one `toCurrency`, so dividing the amount by it yields the amount in `toCurrency`.
880
- if (value == 0 || fromCurrency == toCurrency) return value;
881
- return mulDiv({
882
- x: value,
883
- y: 10 ** _PRICE_FIDELITY,
884
- denominator: PRICES.pricePerUnitOf({
885
- projectId: projectId, pricingCurrency: fromCurrency, unitCurrency: toCurrency, decimals: _PRICE_FIDELITY
886
- })
887
- });
893
+ // Zero and same-currency values need no price feed. This is what lets same-asset remote accounting work even
894
+ // when a project has not configured a price feed.
895
+ if (converted == 0 || fromCurrency == toCurrency) return (true, converted);
896
+
897
+ // Cross-currency valuation is best-effort. A reverting or zero price makes the caller drop this (sucker, chain)
898
+ // record rather than mix priced and unpriced contexts.
899
+ try PRICES.pricePerUnitOf({
900
+ projectId: projectId, pricingCurrency: fromCurrency, unitCurrency: toCurrency, decimals: _PRICE_FIDELITY
901
+ }) returns (
902
+ uint256 price
903
+ ) {
904
+ if (price == 0) return (false, 0);
905
+
906
+ // `pricePerUnitOf` returns the `fromCurrency` price of one `toCurrency`, so division expresses the source
907
+ // amount in `toCurrency`.
908
+ converted = mulDiv({x: converted, y: 10 ** _PRICE_FIDELITY, denominator: price});
909
+ ok = true;
910
+ } catch {
911
+ return (false, 0);
912
+ }
888
913
  }
889
914
 
890
915
  //*********************************************************************//
@@ -920,12 +945,74 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
920
945
  }
921
946
  }
922
947
 
948
+ /// @notice Adds a local-to-remote token mapping to the allowlist.
949
+ /// @dev Can only be called by this contract's owner (initially project ID 1, or JuiceboxDAO).
950
+ /// @param localToken The local token address.
951
+ /// @param remoteChainId The ID of the remote chain.
952
+ /// @param remoteToken The remote token address encoded as bytes32.
953
+ function allowTokenMapping(
954
+ address localToken,
955
+ uint256 remoteChainId,
956
+ bytes32 remoteToken
957
+ )
958
+ public
959
+ override
960
+ onlyOwner
961
+ {
962
+ tokenMappingIsAllowed[localToken][remoteChainId][remoteToken] = true;
963
+ emit TokenMappingAllowed({
964
+ localToken: localToken, remoteChainId: remoteChainId, remoteToken: remoteToken, caller: _msgSender()
965
+ });
966
+ }
967
+
968
+ /// @notice Adds multiple local-to-remote token mappings to the allowlist.
969
+ /// @dev Can only be called by this contract's owner (initially project ID 1, or JuiceboxDAO).
970
+ /// @param localTokens The local token addresses.
971
+ /// @param remoteChainIds The remote chain IDs.
972
+ /// @param remoteTokens The remote token addresses encoded as bytes32.
973
+ function allowTokenMappings(
974
+ address[] calldata localTokens,
975
+ uint256[] calldata remoteChainIds,
976
+ bytes32[] calldata remoteTokens
977
+ )
978
+ public
979
+ override
980
+ onlyOwner
981
+ {
982
+ uint256 localTokenCount = localTokens.length;
983
+ if (localTokenCount != remoteChainIds.length || localTokenCount != remoteTokens.length) {
984
+ revert JBSuckerRegistry_TokenMappingLengthMismatch({
985
+ localTokenCount: localTokenCount,
986
+ remoteChainIdCount: remoteChainIds.length,
987
+ remoteTokenCount: remoteTokens.length
988
+ });
989
+ }
990
+
991
+ // Cache _msgSender() to avoid redundant calls in the loop.
992
+ address sender = _msgSender();
993
+
994
+ // Iterate over each parallel pair exactly once so a single admin transaction configures a full route set.
995
+ for (uint256 i; i < localTokenCount;) {
996
+ address localToken = localTokens[i];
997
+ uint256 remoteChainId = remoteChainIds[i];
998
+ bytes32 remoteToken = remoteTokens[i];
999
+
1000
+ tokenMappingIsAllowed[localToken][remoteChainId][remoteToken] = true;
1001
+ emit TokenMappingAllowed({
1002
+ localToken: localToken, remoteChainId: remoteChainId, remoteToken: remoteToken, caller: sender
1003
+ });
1004
+ unchecked {
1005
+ ++i;
1006
+ }
1007
+ }
1008
+ }
1009
+
923
1010
  /// @notice Deploy one or more cross-chain suckers for a project in a single transaction. Each sucker is created via
924
1011
  /// its deployer, registered in this registry, and immediately configured with its token mappings. Multiple suckers
925
1012
  /// targeting the same peer chain are allowed for bridge resilience. The caller must have `DEPLOY_SUCKERS`
926
1013
  /// permission, which also authorizes the initial token mappings in each deployment configuration.
927
1014
  /// @param projectId The ID of the project to deploy suckers for.
928
- /// @param salt The salt used to deploy the contract. For the suckers to be peers, this must be the same value on
1015
+ /// @param salt The deployment salt. For the suckers to be peers, this must be the same value on
929
1016
  /// each chain where suckers are deployed.
930
1017
  /// @param configurations The sucker deployer configs to use to deploy the suckers.
931
1018
  /// @return suckers The addresses of the deployed suckers.
@@ -1030,6 +1117,68 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
1030
1117
  emit SuckerDeployerRemoved({deployer: deployer, caller: _msgSender()});
1031
1118
  }
1032
1119
 
1120
+ /// @notice Removes a local-to-remote token mapping from the allowlist.
1121
+ /// @dev Can only be called by this contract's owner (initially project ID 1, or JuiceboxDAO).
1122
+ /// @param localToken The local token address.
1123
+ /// @param remoteChainId The ID of the remote chain.
1124
+ /// @param remoteToken The remote token address encoded as bytes32.
1125
+ function removeTokenMapping(
1126
+ address localToken,
1127
+ uint256 remoteChainId,
1128
+ bytes32 remoteToken
1129
+ )
1130
+ public
1131
+ override
1132
+ onlyOwner
1133
+ {
1134
+ tokenMappingIsAllowed[localToken][remoteChainId][remoteToken] = false;
1135
+ emit TokenMappingRemoved({
1136
+ localToken: localToken, remoteChainId: remoteChainId, remoteToken: remoteToken, caller: _msgSender()
1137
+ });
1138
+ }
1139
+
1140
+ /// @notice Removes multiple local-to-remote token mappings from the allowlist.
1141
+ /// @dev Can only be called by this contract's owner (initially project ID 1, or JuiceboxDAO).
1142
+ /// @param localTokens The local token addresses.
1143
+ /// @param remoteChainIds The remote chain IDs.
1144
+ /// @param remoteTokens The remote token addresses encoded as bytes32.
1145
+ function removeTokenMappings(
1146
+ address[] calldata localTokens,
1147
+ uint256[] calldata remoteChainIds,
1148
+ bytes32[] calldata remoteTokens
1149
+ )
1150
+ public
1151
+ override
1152
+ onlyOwner
1153
+ {
1154
+ uint256 localTokenCount = localTokens.length;
1155
+ if (localTokenCount != remoteChainIds.length || localTokenCount != remoteTokens.length) {
1156
+ revert JBSuckerRegistry_TokenMappingLengthMismatch({
1157
+ localTokenCount: localTokenCount,
1158
+ remoteChainIdCount: remoteChainIds.length,
1159
+ remoteTokenCount: remoteTokens.length
1160
+ });
1161
+ }
1162
+
1163
+ // Cache _msgSender() to avoid redundant calls in the loop.
1164
+ address sender = _msgSender();
1165
+
1166
+ // Iterate over each parallel pair exactly once so a single admin transaction can remove a route set.
1167
+ for (uint256 i; i < localTokenCount;) {
1168
+ address localToken = localTokens[i];
1169
+ uint256 remoteChainId = remoteChainIds[i];
1170
+ bytes32 remoteToken = remoteTokens[i];
1171
+
1172
+ tokenMappingIsAllowed[localToken][remoteChainId][remoteToken] = false;
1173
+ emit TokenMappingRemoved({
1174
+ localToken: localToken, remoteChainId: remoteChainId, remoteToken: remoteToken, caller: sender
1175
+ });
1176
+ unchecked {
1177
+ ++i;
1178
+ }
1179
+ }
1180
+ }
1181
+
1033
1182
  /// @notice Set the ETH fee (in wei) paid into the fee project on each toRemote() call.
1034
1183
  /// @dev Only callable by the contract owner. Fee cannot exceed MAX_TO_REMOTE_FEE.
1035
1184
  /// @param fee The new fee amount in wei.
@@ -35,6 +35,20 @@ interface IJBSuckerRegistry {
35
35
  /// @param caller The address that removed the sucker.
36
36
  event SuckerDeprecated(uint256 projectId, address sucker, address caller);
37
37
 
38
+ /// @notice Emitted when a local-to-remote token mapping is added to the allowlist.
39
+ /// @param localToken The local token address.
40
+ /// @param remoteChainId The ID of the remote chain.
41
+ /// @param remoteToken The remote token address encoded as bytes32.
42
+ /// @param caller The address that allowed the token mapping.
43
+ event TokenMappingAllowed(address localToken, uint256 remoteChainId, bytes32 remoteToken, address caller);
44
+
45
+ /// @notice Emitted when a local-to-remote token mapping is removed from the allowlist.
46
+ /// @param localToken The local token address.
47
+ /// @param remoteChainId The ID of the remote chain.
48
+ /// @param remoteToken The remote token address encoded as bytes32.
49
+ /// @param caller The address that removed the token mapping.
50
+ event TokenMappingRemoved(address localToken, uint256 remoteChainId, bytes32 remoteToken, address caller);
51
+
38
52
  /// @notice Emitted when the toRemoteFee is changed.
39
53
  /// @param oldFee The previous fee.
40
54
  /// @param newFee The new fee.
@@ -90,6 +104,14 @@ interface IJBSuckerRegistry {
90
104
  /// @return totalSupply The combined peer chain total supply.
91
105
  function remoteTotalSupplyOf(uint256 projectId) external view returns (uint256 totalSupply);
92
106
 
107
+ /// @notice Reverts unless a token mapping can be chosen by a project.
108
+ /// @dev Disable mappings never require owner approval. Non-native same-address mappings pass through directly.
109
+ /// Native-to-native and differing-address mappings must be explicitly allowed.
110
+ /// @param localToken The local token address.
111
+ /// @param remoteChainId The ID of the remote chain.
112
+ /// @param remoteToken The remote token address encoded as bytes32.
113
+ function requireTokenMappingAllowed(address localToken, uint256 remoteChainId, bytes32 remoteToken) external view;
114
+
93
115
  /// @notice Whether the specified sucker deployer is approved by this registry.
94
116
  /// @param deployer The address of the deployer to check.
95
117
  /// @return Whether the deployer is allowed.
@@ -105,6 +127,20 @@ interface IJBSuckerRegistry {
105
127
  /// @return The addresses of the suckers.
106
128
  function suckersOf(uint256 projectId) external view returns (address[] memory);
107
129
 
130
+ /// @notice Whether a local-to-remote token mapping is approved by this registry.
131
+ /// @param localToken The local token address.
132
+ /// @param remoteChainId The ID of the remote chain.
133
+ /// @param remoteToken The remote token address encoded as bytes32.
134
+ /// @return Whether the token mapping is allowed.
135
+ function tokenMappingIsAllowed(
136
+ address localToken,
137
+ uint256 remoteChainId,
138
+ bytes32 remoteToken
139
+ )
140
+ external
141
+ view
142
+ returns (bool);
143
+
108
144
  /// @notice The ETH fee (in wei) paid into the fee project on each toRemote() call.
109
145
  /// @return The current fee.
110
146
  function toRemoteFee() external view returns (uint256);
@@ -153,6 +189,23 @@ interface IJBSuckerRegistry {
153
189
  /// @param deployers The addresses of the deployers to allow.
154
190
  function allowSuckerDeployers(address[] calldata deployers) external;
155
191
 
192
+ /// @notice Add a local-to-remote token mapping to the allowlist.
193
+ /// @param localToken The local token address.
194
+ /// @param remoteChainId The ID of the remote chain.
195
+ /// @param remoteToken The remote token address encoded as bytes32.
196
+ function allowTokenMapping(address localToken, uint256 remoteChainId, bytes32 remoteToken) external;
197
+
198
+ /// @notice Add multiple local-to-remote token mappings to the allowlist.
199
+ /// @param localTokens The local token addresses.
200
+ /// @param remoteChainIds The remote chain IDs.
201
+ /// @param remoteTokens The remote token addresses encoded as bytes32.
202
+ function allowTokenMappings(
203
+ address[] calldata localTokens,
204
+ uint256[] calldata remoteChainIds,
205
+ bytes32[] calldata remoteTokens
206
+ )
207
+ external;
208
+
156
209
  /// @notice Deploy one or more suckers for the specified project.
157
210
  /// @dev This call also applies each configuration's token mappings on the deployed suckers. `DEPLOY_SUCKERS`
158
211
  /// authorizes those initial mappings; use `MAP_SUCKER_TOKEN` for post-deployment mapping changes.
@@ -177,6 +230,23 @@ interface IJBSuckerRegistry {
177
230
  /// @param deployer The address of the deployer to remove.
178
231
  function removeSuckerDeployer(address deployer) external;
179
232
 
233
+ /// @notice Remove a local-to-remote token mapping from the allowlist.
234
+ /// @param localToken The local token address.
235
+ /// @param remoteChainId The ID of the remote chain.
236
+ /// @param remoteToken The remote token address encoded as bytes32.
237
+ function removeTokenMapping(address localToken, uint256 remoteChainId, bytes32 remoteToken) external;
238
+
239
+ /// @notice Remove multiple local-to-remote token mappings from the allowlist.
240
+ /// @param localTokens The local token addresses.
241
+ /// @param remoteChainIds The remote chain IDs.
242
+ /// @param remoteTokens The remote token addresses encoded as bytes32.
243
+ function removeTokenMappings(
244
+ address[] calldata localTokens,
245
+ uint256[] calldata remoteChainIds,
246
+ bytes32[] calldata remoteTokens
247
+ )
248
+ external;
249
+
180
250
  /// @notice Set the ETH fee (in wei) paid on each toRemote() call. Owner only.
181
251
  /// @param fee The new fee amount in wei.
182
252
  function setToRemoteFee(uint256 fee) external;