@bananapus/suckers-v6 0.0.48 → 0.0.50

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/CHANGELOG.md CHANGED
@@ -15,6 +15,41 @@ This file describes the verified change from `nana-suckers-v5` to the current `n
15
15
  - `JBCeloSucker`
16
16
  - the deployers, structs, and interfaces under `src/`
17
17
 
18
+ ## Unreleased — `JBLeaf.metadata` attribution field
19
+
20
+ The merkle leaf now carries a fifth field: a `bytes32 metadata` payload that travels inside the leaf hash but is
21
+ opaque to the sucker protocol itself.
22
+
23
+ **What changed**
24
+
25
+ - `JBLeaf` struct: new trailing `bytes32 metadata` field.
26
+ - `IJBSucker.prepare`: new trailing `bytes32 metadata` parameter. The metadata is included in the leaf hash, so it's
27
+ covered by the merkle root — receivers can trust it once the claim's merkle proof verifies.
28
+ - `_buildTreeHash` hashes 128 bytes (was 96): `keccak256(projectTokenCount || terminalTokenAmount || beneficiary || metadata)`.
29
+ - `_insertIntoTree`, `_validate`, `_validateBranchRoot`, `_validateForEmergencyExit` all thread `metadata` through.
30
+ - Events `InsertToOutboxTree` and `Claimed` carry the field so off-chain indexers can read it directly without
31
+ cracking the leaf.
32
+ - SVM leaf encoding widens from 96 bytes to 128 bytes; the new 32-byte suffix is the `metadata` field. The
33
+ `_svmBuildTreeHash` interop test mirrors the layout exactly so EVM↔SVM hash equality is preserved.
34
+
35
+ **Intended use**
36
+
37
+ The original motivator is the cross-chain referral split hook (`nana-referral-split-hook-v6`): when a referrer
38
+ on chain Y earns credit for fee-paying activity on chain X, the hook on X uses the fee project's sucker to
39
+ bridge the entitled fee-project tokens. The leaf's `metadata` carries `(originChainId, referralProjectId)` so the
40
+ sibling hook on chain Y can atomically claim, re-pay the fee project locally, and push to the local distributor
41
+ for the right referrer — all under the merkle proof's authentication, no off-chain coordination needed.
42
+
43
+ The field is generic: any future leaf consumer (NFT split hooks, buyback hooks, etc.) can use it for its own
44
+ attribution scheme without further sucker changes. Pass `bytes32(0)` for ordinary bridges that don't need it.
45
+
46
+ **Risk surface**
47
+
48
+ Zero new trust paths. The bridge protocol stays leaf-in-leaf-out; we just put one more 32-byte field under the
49
+ same root. Existing claim, emergency-exit, and root-relay flows behave identically when `metadata == bytes32(0)`.
50
+ The leaf-hash domain changes (96 → 128 bytes), but since nothing is deployed yet there's no on-chain
51
+ compatibility concern.
52
+
18
53
  ## 0.0.46 — Bump nana-core-v6 to 0.0.53
19
54
 
20
55
  `@bananapus/core-v6@0.0.53` ([nana-core-v6 PR #145](https://github.com/Bananapus/nana-core-v6/pull/145)) drops the `via_ir` requirement on `JBCashOutHookSpecsLib`, which lets this package consume the cross-project cashout work (`payAfterCashOutTokensOf` / `addToBalanceAfterCashOutTokensOf`) without needing `via_ir = true` in its own foundry profile.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/suckers-v6",
3
- "version": "0.0.48",
3
+ "version": "0.0.50",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,8 +30,8 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@arbitrum/nitro-contracts": "3.2.0",
33
- "@bananapus/core-v6": "^0.0.54",
34
- "@bananapus/permission-ids-v6": "^0.0.25",
33
+ "@bananapus/core-v6": "^0.0.59",
34
+ "@bananapus/permission-ids-v6": "^0.0.26",
35
35
  "@chainlink/contracts-ccip": "1.6.4",
36
36
  "@chainlink/local": "0.2.7",
37
37
  "@openzeppelin/contracts": "5.6.1",
@@ -255,12 +255,13 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
255
255
  // If the token is an ERC-20, bridge it to the peer.
256
256
  // If the amount is `0` then we do not need to bridge any ERC20.
257
257
  if (token != JBConstants.NATIVE_TOKEN && amount != 0) {
258
+ address gateway;
258
259
  uint256 tokenTransportCost;
259
260
  uint256 maxSubmissionCostERC20;
260
261
  {
261
262
  // Get the exact calldata length the gateway will create for the retryable ticket.
262
263
  // The Arbitrum Inbox validates maxSubmissionCost against this actual payload, not the user data.
263
- address gateway = GATEWAYROUTER.getGateway(token);
264
+ gateway = GATEWAYROUTER.getGateway(token);
264
265
  uint256 outboundCalldataLength =
265
266
  IL1ArbitrumGateway(gateway)
266
267
  .getOutboundCalldata({
@@ -299,6 +300,8 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
299
300
  gasPriceBid: maxFeePerGas,
300
301
  data: bytes(abi.encode(maxSubmissionCostERC20, bytes("")))
301
302
  });
303
+
304
+ SafeERC20.forceApprove({token: IERC20(token), spender: gateway, value: 0});
302
305
  } else {
303
306
  // Ensure we bridge enough for gas costs on L2 side
304
307
  if (transportPayment < callTransportCost) {
@@ -164,6 +164,8 @@ contract JBCeloSucker is JBOptimismSucker {
164
164
  minGasLimit: remoteToken.minGas,
165
165
  extraData: bytes("")
166
166
  });
167
+
168
+ SafeERC20.forceApprove({token: IERC20(bridgeToken), spender: address(OPBRIDGE), value: 0});
167
169
  }
168
170
 
169
171
  // Send the messenger message with nativeValue = 0.
@@ -126,6 +126,8 @@ contract JBOptimismSucker is JBSucker, IJBOptimismSucker {
126
126
  minGasLimit: remoteToken.minGas,
127
127
  extraData: bytes("")
128
128
  });
129
+
130
+ SafeERC20.forceApprove({token: IERC20(token), spender: address(OPBRIDGE), value: 0});
129
131
  } else {
130
132
  // Otherwise, the token is the native token, and the amount will be sent as `msg.value`.
131
133
  nativeValue = amount;
package/src/JBSucker.sol CHANGED
@@ -83,6 +83,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
83
83
  error JBSucker_NoRetainedToRemoteFee(address account);
84
84
  error JBSucker_NoRetainedTransportPaymentRefund(address account);
85
85
  error JBSucker_RefundFailed(address beneficiary, uint256 amount);
86
+ error JBSucker_RemoteTokenAlreadyMapped(bytes32 remoteToken, address localToken);
86
87
  error JBSucker_TokenAlreadyMapped(address localToken, bytes32 mappedTo);
87
88
  error JBSucker_TokenHasInvalidEmergencyHatchState(address token);
88
89
  error JBSucker_TokenNotMapped(address token);
@@ -175,6 +176,14 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
175
176
  /// @custom:param token The local terminal token to get the inbox for.
176
177
  mapping(address token => JBInboxTreeRoot root) internal _inboxOf;
177
178
 
179
+ /// @notice The local token that has reserved each remote token address in this sucker.
180
+ /// @dev Inbound roots are keyed by `root.token` on the destination chain. Within a single sucker, allowing two
181
+ /// local tokens to send roots to the same remote token would give them independent source nonces but one shared
182
+ /// destination inbox, causing stale rejections or root overwrites. Each sucker keeps its own reservation map, so
183
+ /// separate bridge lanes for the same asset pair can coexist.
184
+ /// @custom:param remoteToken The remote terminal token address encoded as bytes32.
185
+ mapping(bytes32 remoteToken => address localToken) internal _localTokenForRemoteToken;
186
+
178
187
  /// @notice The outbox merkle tree for a given token.
179
188
  /// @custom:param token The local terminal token to get the outbox for.
180
189
  mapping(address token => JBOutboxTree) internal _outboxOf;
@@ -285,12 +294,15 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
285
294
  /// project tokens for the beneficiary, and deposits the terminal tokens into the project's local balance.
286
295
  /// @param claimData The terminal token, merkle tree leaf, and proof for the claim.
287
296
  function claim(JBClaim calldata claimData) public virtual override {
288
- // Attempt to validate the proof against the inbox tree for the terminal token.
297
+ // Attempt to validate the proof against the inbox tree for the terminal token. The leaf hash includes
298
+ // `claimData.leaf.metadata` so the proof is only valid for the exact (amount, beneficiary, metadata) tuple the
299
+ // origin committed to.
289
300
  _validate({
290
301
  projectTokenCount: claimData.leaf.projectTokenCount,
291
302
  terminalToken: claimData.token,
292
303
  terminalTokenAmount: claimData.leaf.terminalTokenAmount,
293
304
  beneficiary: claimData.leaf.beneficiary,
305
+ metadata: claimData.leaf.metadata,
294
306
  index: claimData.leaf.index,
295
307
  leaves: claimData.proof
296
308
  });
@@ -301,6 +313,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
301
313
  projectTokenCount: claimData.leaf.projectTokenCount,
302
314
  terminalTokenAmount: claimData.leaf.terminalTokenAmount,
303
315
  index: claimData.leaf.index,
316
+ metadata: claimData.leaf.metadata,
304
317
  caller: _msgSender()
305
318
  });
306
319
 
@@ -321,7 +334,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
321
334
  uint256 _projectId = projectId();
322
335
 
323
336
  _requirePermissionFrom({
324
- account: PROJECTS.ownerOf(_projectId), projectId: _projectId, permissionId: JBPermissionIds.SUCKER_SAFETY
337
+ account: _ownerOf(_projectId), projectId: _projectId, permissionId: JBPermissionIds.SUCKER_SAFETY
325
338
  });
326
339
 
327
340
  // Enable the emergency hatch for each token.
@@ -343,12 +356,14 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
343
356
  /// @param claimData The terminal token, merkle tree leaf, and proof for the claim.
344
357
  function exitThroughEmergencyHatch(JBClaim calldata claimData) external override {
345
358
  // Does all the needed validation to ensure that the claim is valid *and* that claiming through the emergency
346
- // hatch is allowed.
359
+ // hatch is allowed. The leaf hash covers `metadata` so a remote-attribution leaf is only exitable if the
360
+ // emergency exiter knows the exact `metadata` value the origin committed to.
347
361
  _validateForEmergencyExit({
348
362
  projectTokenCount: claimData.leaf.projectTokenCount,
349
363
  terminalToken: claimData.token,
350
364
  terminalTokenAmount: claimData.leaf.terminalTokenAmount,
351
365
  beneficiary: claimData.leaf.beneficiary,
366
+ metadata: claimData.leaf.metadata,
352
367
  index: claimData.leaf.index,
353
368
  leaves: claimData.proof
354
369
  });
@@ -523,7 +538,8 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
523
538
  uint256 projectTokenCount,
524
539
  bytes32 beneficiary,
525
540
  uint256 minTokensReclaimed,
526
- address token
541
+ address token,
542
+ bytes32 metadata
527
543
  )
528
544
  external
529
545
  override
@@ -555,12 +571,15 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
555
571
  projectToken: projectToken, count: projectTokenCount, token: token, minTokensReclaimed: minTokensReclaimed
556
572
  });
557
573
 
558
- // Insert the item into the outbox tree for the terminal `token`.
574
+ // Insert the item into the outbox tree for the terminal `token`. The `metadata` field travels inside the leaf
575
+ // hash so receivers can read attribution context from a proven claim — the sucker protocol itself never
576
+ // inspects it.
559
577
  _insertIntoTree({
560
578
  projectTokenCount: projectTokenCount,
561
579
  token: token,
562
580
  terminalTokenAmount: terminalTokenAmount,
563
- beneficiary: beneficiary
581
+ beneficiary: beneficiary,
582
+ metadata: metadata
564
583
  });
565
584
  }
566
585
 
@@ -577,9 +596,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
577
596
 
578
597
  // The caller must be the project owner or have the `SET_SUCKER_DEPRECATION` permission from them.
579
598
  _requirePermissionFrom({
580
- account: PROJECTS.ownerOf(_projectId),
581
- projectId: _projectId,
582
- permissionId: JBPermissionIds.SET_SUCKER_DEPRECATION
599
+ account: _ownerOf(_projectId), projectId: _projectId, permissionId: JBPermissionIds.SET_SUCKER_DEPRECATION
583
600
  });
584
601
 
585
602
  // This is the earliest time for when the sucker can be considered deprecated.
@@ -987,16 +1004,20 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
987
1004
  uint256 projectTokenCount,
988
1005
  address token,
989
1006
  uint256 terminalTokenAmount,
990
- bytes32 beneficiary
1007
+ bytes32 beneficiary,
1008
+ bytes32 metadata
991
1009
  )
992
1010
  internal
993
1011
  {
994
1012
  // Guard against amounts that would overflow uint128 on SVM (INTEROP-5).
995
1013
  if (terminalTokenAmount > type(uint128).max) revert JBSucker_AmountExceedsUint128(terminalTokenAmount);
996
1014
  if (projectTokenCount > type(uint128).max) revert JBSucker_AmountExceedsUint128(projectTokenCount);
997
- // Build a hash based on the token amounts and the beneficiary.
1015
+ // Build a hash based on the token amounts, the beneficiary, and the attribution metadata.
998
1016
  bytes32 hashed = _buildTreeHash({
999
- projectTokenCount: projectTokenCount, terminalTokenAmount: terminalTokenAmount, beneficiary: beneficiary
1017
+ projectTokenCount: projectTokenCount,
1018
+ terminalTokenAmount: terminalTokenAmount,
1019
+ beneficiary: beneficiary,
1020
+ metadata: metadata
1000
1021
  });
1001
1022
 
1002
1023
  // Get the outbox in storage.
@@ -1014,6 +1035,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1014
1035
  root: _computeOutboxRoot(outbox.tree),
1015
1036
  projectTokenCount: projectTokenCount,
1016
1037
  terminalTokenAmount: terminalTokenAmount,
1038
+ metadata: metadata,
1017
1039
  caller: _msgSender()
1018
1040
  });
1019
1041
  }
@@ -1031,6 +1053,10 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1031
1053
  /// after outbox activity, the same local funds could be claimed against two different remote tokens. A
1032
1054
  /// misconfigured mapping therefore requires deploying a new sucker. Re-enabling a previously disabled mapping
1033
1055
  /// (back to the same remote token) is supported.
1056
+ /// @dev Remote tokens are also unique per local token within this sucker. The source side keeps separate
1057
+ /// outboxes/nonces per local token, but the destination side stores roots under the remote token address. Sharing
1058
+ /// one remote token across multiple local tokens in the same sucker would merge those inboxes on the destination
1059
+ /// chain. Separate suckers can still map the same local/remote token pair, letting users choose a bridge lane.
1034
1060
  /// @param map The local and remote terminal token addresses to map, and minimum amount/gas limits for bridging
1035
1061
  /// them.
1036
1062
  /// @param transportPaymentValue The amount of `msg.value` to send for the token mapping.
@@ -1051,7 +1077,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1051
1077
 
1052
1078
  // The registry can map during authorized deployment. Otherwise, require the project's mapping permission.
1053
1079
  _requirePermissionAllowingOverrideFrom({
1054
- account: PROJECTS.ownerOf(_projectId),
1080
+ account: _ownerOf(_projectId),
1055
1081
  projectId: _projectId,
1056
1082
  permissionId: JBPermissionIds.MAP_SUCKER_TOKEN,
1057
1083
  alsoGrantAccessIf: _msgSender() == address(REGISTRY)
@@ -1068,6 +1094,16 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1068
1094
  revert JBSucker_TokenAlreadyMapped({localToken: token, mappedTo: currentMapping.addr});
1069
1095
  }
1070
1096
 
1097
+ // A remote token can back only one local token's outbox in this sucker. Otherwise two independent source
1098
+ // nonces would race into the same destination inbox key (`root.token`), making one token's root stale or
1099
+ // overwriting the other. Other suckers have separate inbox/outbox storage and are unaffected.
1100
+ if (map.remoteToken != bytes32(0)) {
1101
+ address mappedLocalToken = _localTokenForRemoteToken[map.remoteToken];
1102
+ if (mappedLocalToken != address(0) && mappedLocalToken != token) {
1103
+ revert JBSucker_RemoteTokenAlreadyMapped({remoteToken: map.remoteToken, localToken: mappedLocalToken});
1104
+ }
1105
+ }
1106
+
1071
1107
  // No inbox guard needed here. Token remapping only affects the outbound (sending) path —
1072
1108
  // it changes where tokens get bridged TO. Existing inbox claims are resolved against the inbox merkle
1073
1109
  // tree keyed by the local token address. Changing the remote token doesn't invalidate those claims
@@ -1085,6 +1121,17 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1085
1121
  _sendRoot({transportPayment: transportPaymentValue, token: token, remoteToken: currentMapping});
1086
1122
  }
1087
1123
 
1124
+ // Update the reverse reservation if an unused local token is being remapped to a new remote token.
1125
+ if (
1126
+ map.remoteToken != bytes32(0) && currentMapping.addr != bytes32(0) && currentMapping.addr != map.remoteToken
1127
+ && _localTokenForRemoteToken[currentMapping.addr] == token
1128
+ ) {
1129
+ delete _localTokenForRemoteToken[currentMapping.addr];
1130
+ }
1131
+
1132
+ bytes32 remoteToken = map.remoteToken == bytes32(0) ? currentMapping.addr : map.remoteToken;
1133
+ if (remoteToken != bytes32(0)) _localTokenForRemoteToken[remoteToken] = token;
1134
+
1088
1135
  // Update the token mapping.
1089
1136
  _remoteTokenFor[token] = JBRemoteToken({
1090
1137
  enabled: map.remoteToken != bytes32(0),
@@ -1092,7 +1139,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1092
1139
  minGas: map.minGas,
1093
1140
  // This is done so that a token can be disabled and then enabled again
1094
1141
  // while ensuring the remoteToken never changes (unless it hasn't been used yet)
1095
- addr: map.remoteToken == bytes32(0) ? currentMapping.addr : map.remoteToken
1142
+ addr: remoteToken
1096
1143
  });
1097
1144
  }
1098
1145
 
@@ -1136,7 +1183,9 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1136
1183
  // Record the balance before the cash out for the sanity check.
1137
1184
  uint256 balanceBefore = _balanceOf({token: token, addr: address(this)});
1138
1185
 
1139
- // Cash out the project tokens for terminal tokens.
1186
+ // Cash out the project tokens for terminal tokens. Suckers are a transparent value-mover (the bridge
1187
+ // accounting is the entirety of their function) — they're not a fee-paying entry point for any referrer,
1188
+ // so `referralProjectId: 0` is correct.
1140
1189
  reclaimedAmount = terminal.cashOutTokensOf({
1141
1190
  holder: address(this),
1142
1191
  projectId: cachedProjectId,
@@ -1144,7 +1193,8 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1144
1193
  tokenToReclaim: token,
1145
1194
  minTokensReclaimed: minTokensReclaimed,
1146
1195
  beneficiary: payable(address(this)),
1147
- metadata: bytes("")
1196
+ metadata: bytes(""),
1197
+ referralProjectId: 0
1148
1198
  });
1149
1199
 
1150
1200
  // Sanity check to make sure we received the expected amount.
@@ -1255,6 +1305,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1255
1305
  address terminalToken,
1256
1306
  uint256 terminalTokenAmount,
1257
1307
  bytes32 beneficiary,
1308
+ bytes32 metadata,
1258
1309
  uint256 index,
1259
1310
  bytes32[_TREE_DEPTH] calldata leaves
1260
1311
  )
@@ -1278,6 +1329,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1278
1329
  projectTokenCount: projectTokenCount,
1279
1330
  terminalTokenAmount: terminalTokenAmount,
1280
1331
  beneficiary: beneficiary,
1332
+ metadata: metadata,
1281
1333
  index: index,
1282
1334
  leaves: leaves
1283
1335
  });
@@ -1297,6 +1349,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1297
1349
  uint256 projectTokenCount,
1298
1350
  uint256 terminalTokenAmount,
1299
1351
  bytes32 beneficiary,
1352
+ bytes32 metadata,
1300
1353
  uint256 index,
1301
1354
  bytes32[_TREE_DEPTH] calldata leaves
1302
1355
  )
@@ -1307,7 +1360,10 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1307
1360
  // Delegates to JBSuckerLib (via DELEGATECALL) to keep MerkleLib.branchRoot bytecode out of each sucker.
1308
1361
  bytes32 root = JBSuckerLib.computeBranchRoot({
1309
1362
  item: _buildTreeHash({
1310
- projectTokenCount: projectTokenCount, terminalTokenAmount: terminalTokenAmount, beneficiary: beneficiary
1363
+ projectTokenCount: projectTokenCount,
1364
+ terminalTokenAmount: terminalTokenAmount,
1365
+ beneficiary: beneficiary,
1366
+ metadata: metadata
1311
1367
  }),
1312
1368
  branch: leaves,
1313
1369
  index: index
@@ -1351,6 +1407,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1351
1407
  address terminalToken,
1352
1408
  uint256 terminalTokenAmount,
1353
1409
  bytes32 beneficiary,
1410
+ bytes32 metadata,
1354
1411
  uint256 index,
1355
1412
  bytes32[_TREE_DEPTH] calldata leaves
1356
1413
  )
@@ -1401,6 +1458,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1401
1458
  projectTokenCount: projectTokenCount,
1402
1459
  terminalTokenAmount: terminalTokenAmount,
1403
1460
  beneficiary: beneficiary,
1461
+ metadata: metadata,
1404
1462
  index: index,
1405
1463
  leaves: leaves
1406
1464
  });
@@ -1448,24 +1506,27 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1448
1506
  /// @param projectTokenCount The number of project tokens to cash out.
1449
1507
  /// @param terminalTokenAmount The amount of terminal tokens to reclaim from the cash out.
1450
1508
  /// @param beneficiary The beneficiary which will receive the project tokens (bytes32 for cross-VM compatibility).
1509
+ /// @param metadata Opaque caller-defined attribution payload travelling inside the leaf hash.
1451
1510
  /// @return hash The keccak256 hash of the leaf data.
1452
1511
  function _buildTreeHash(
1453
1512
  uint256 projectTokenCount,
1454
1513
  uint256 terminalTokenAmount,
1455
- bytes32 beneficiary
1514
+ bytes32 beneficiary,
1515
+ bytes32 metadata
1456
1516
  )
1457
1517
  internal
1458
1518
  pure
1459
1519
  returns (bytes32 hash)
1460
1520
  {
1461
- // All three arguments are 32 bytes — hash from free memory to avoid abi.encode allocation overhead.
1521
+ // All four arguments are 32 bytes — hash from free memory to avoid abi.encode allocation overhead.
1462
1522
  // forge-lint: disable-next-line(asm-keccak256)
1463
1523
  assembly {
1464
1524
  let ptr := mload(0x40)
1465
1525
  mstore(ptr, projectTokenCount)
1466
1526
  mstore(add(ptr, 0x20), terminalTokenAmount)
1467
1527
  mstore(add(ptr, 0x40), beneficiary)
1468
- hash := keccak256(ptr, 0x60)
1528
+ mstore(add(ptr, 0x60), metadata)
1529
+ hash := keccak256(ptr, 0x80)
1469
1530
  }
1470
1531
  }
1471
1532
 
@@ -1509,6 +1570,20 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
1509
1570
  return ERC2771Context._msgSender();
1510
1571
  }
1511
1572
 
1573
+ /// @notice Resolve the current owner of the project this sucker belongs to.
1574
+ /// @dev `PROJECTS.ownerOf(...)` is the source of truth for "project owner" permission checks; we hit it from
1575
+ /// every permission-gated entrypoint (`enableEmergencyHatchFor`, `setDeprecation`, `_mapToken`). Routing all
1576
+ /// three through this internal helper emits the abi-encode + STATICCALL + return-decode sequence once in the
1577
+ /// child contract's bytecode instead of inlining it at each call site, which is what keeps `JBSwapCCIPSucker`
1578
+ /// under the EIP-170 limit after the leaf-`metadata` thread-through landed.
1579
+ /// @param forProjectId The project ID to look up — always the sucker's own `projectId()`, but accepted as a
1580
+ /// parameter so callers can pass the cached local they already computed (avoiding a redundant `projectId()`
1581
+ /// call against the read-only registry).
1582
+ /// @return owner The address currently registered as the project's ERC-721 holder.
1583
+ function _ownerOf(uint256 forProjectId) internal view returns (address owner) {
1584
+ return PROJECTS.ownerOf(forProjectId);
1585
+ }
1586
+
1512
1587
  /// @notice Retain a failed `toRemoteFee` payment for later caller refund.
1513
1588
  /// @param account The account that can reclaim the retained fee.
1514
1589
  /// @param amount The retained fee amount.