@bananapus/suckers-v6 0.0.13 → 0.0.15

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.
Files changed (39) hide show
  1. package/ADMINISTRATION.md +14 -3
  2. package/ARCHITECTURE.md +67 -17
  3. package/AUDIT_INSTRUCTIONS.md +97 -1
  4. package/CHANGE_LOG.md +14 -2
  5. package/README.md +17 -26
  6. package/RISKS.md +23 -6
  7. package/SKILLS.md +46 -9
  8. package/STYLE_GUIDE.md +2 -2
  9. package/USER_JOURNEYS.md +245 -156
  10. package/foundry.toml +1 -1
  11. package/package.json +3 -3
  12. package/script/Deploy.s.sol +31 -2
  13. package/script/helpers/SuckerDeploymentLib.sol +6 -6
  14. package/src/JBArbitrumSucker.sol +15 -12
  15. package/src/JBBaseSucker.sol +1 -1
  16. package/src/JBCCIPSucker.sol +1 -1
  17. package/src/JBCeloSucker.sol +1 -1
  18. package/src/JBOptimismSucker.sol +1 -1
  19. package/src/JBSucker.sol +24 -7
  20. package/src/JBSuckerRegistry.sol +1 -1
  21. package/src/deployers/JBArbitrumSuckerDeployer.sol +1 -1
  22. package/src/deployers/JBBaseSuckerDeployer.sol +1 -1
  23. package/src/deployers/JBCCIPSuckerDeployer.sol +1 -1
  24. package/src/deployers/JBCeloSuckerDeployer.sol +1 -1
  25. package/src/deployers/JBOptimismSuckerDeployer.sol +1 -1
  26. package/src/deployers/JBSuckerDeployer.sol +1 -1
  27. package/src/libraries/CCIPHelper.sol +1 -1
  28. package/src/utils/MerkleLib.sol +1 -1
  29. package/test/Fork.t.sol +1 -1
  30. package/test/ForkArbitrum.t.sol +1 -1
  31. package/test/ForkCelo.t.sol +1 -1
  32. package/test/ForkClaim.t.sol +1 -1
  33. package/test/ForkMainnet.t.sol +1 -1
  34. package/test/ForkOPStack.t.sol +1 -1
  35. package/test/SuckerDeepAttacks.t.sol +5 -4
  36. package/test/audit/ArbitrumL2ToRemoteFeeDoS.t.sol +120 -0
  37. package/test/audit/CodexNemesisPoC.t.sol +169 -0
  38. package/test/fork/OptimismSuckerFork.t.sol +457 -0
  39. package/test/unit/ccip_refund.t.sol +1 -1
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {stdJson} from "forge-std/Script.sol";
5
5
  import {Vm} from "forge-std/Vm.sol";
@@ -50,12 +50,12 @@ library SuckerDeploymentLib {
50
50
  // Is deployed on all (supported) chains.
51
51
  deployment.registry = IJBSuckerRegistry(
52
52
  _getDeploymentAddress({
53
- path: path, projectName: "nana-suckers-v5", networkName: networkName, contractName: "JBSuckerRegistry"
53
+ path: path, projectName: "nana-suckers-v6", networkName: networkName, contractName: "JBSuckerRegistry"
54
54
  })
55
55
  );
56
56
 
57
57
  bytes32 _network = keccak256(abi.encodePacked(networkName));
58
- bool _isMainnet = _network == keccak256("ethereum") || _network == keccak256("sepolia");
58
+ bool _isMainnet = _network == keccak256("ethereum") || _network == keccak256("ethereum_sepolia");
59
59
  bool _isOP = _network == keccak256("optimism") || _network == keccak256("optimism_sepolia");
60
60
  bool _isBase = _network == keccak256("base") || _network == keccak256("base_sepolia");
61
61
  bool _isArb = _network == keccak256("arbitrum") || _network == keccak256("arbitrum_sepolia");
@@ -64,7 +64,7 @@ library SuckerDeploymentLib {
64
64
  deployment.optimismDeployer = IJBSuckerDeployer(
65
65
  _getDeploymentAddress({
66
66
  path: path,
67
- projectName: "nana-suckers-v5",
67
+ projectName: "nana-suckers-v6",
68
68
  networkName: networkName,
69
69
  contractName: "JBOptimismSuckerDeployer"
70
70
  })
@@ -75,7 +75,7 @@ library SuckerDeploymentLib {
75
75
  deployment.baseDeployer = IJBSuckerDeployer(
76
76
  _getDeploymentAddress({
77
77
  path: path,
78
- projectName: "nana-suckers-v5",
78
+ projectName: "nana-suckers-v6",
79
79
  networkName: networkName,
80
80
  contractName: "JBBaseSuckerDeployer"
81
81
  })
@@ -86,7 +86,7 @@ library SuckerDeploymentLib {
86
86
  deployment.arbitrumDeployer = IJBSuckerDeployer(
87
87
  _getDeploymentAddress({
88
88
  path: path,
89
- projectName: "nana-suckers-v5",
89
+ projectName: "nana-suckers-v6",
90
90
  networkName: networkName,
91
91
  contractName: "JBArbitrumSuckerDeployer"
92
92
  })
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {IBridge} from "@arbitrum/nitro-contracts/src/bridge/IBridge.sol";
5
5
  import {IInbox} from "@arbitrum/nitro-contracts/src/bridge/IInbox.sol";
@@ -154,25 +154,34 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
154
154
  internal
155
155
  override
156
156
  {
157
- // Bridge expects to be paid
158
- if (transportPayment == 0 && LAYER == JBLayer.L1) revert JBSucker_ExpectedMsgValue();
159
-
160
157
  // Build the calldata that will be send to the peer. This will call `JBSucker.fromRemote` on the remote peer.
161
158
  bytes memory data = abi.encodeCall(JBSucker.fromRemote, (message));
162
159
 
163
160
  // Depending on which layer we are on, send the call to the other layer.
164
161
  // slither-disable-start out-of-order-retryable
165
162
  if (LAYER == JBLayer.L1) {
163
+ // L1→L2 requires transport payment for retryable tickets.
164
+ if (transportPayment == 0) revert JBSucker_ExpectedMsgValue();
166
165
  _toL2({
167
166
  token: token, transportPayment: transportPayment, amount: amount, data: data, remoteToken: remoteToken
168
167
  });
169
168
  } else {
169
+ // L2→L1 via ArbSys is free — reject any transport payment.
170
+ if (transportPayment != 0) revert JBSucker_UnexpectedMsgValue(transportPayment);
170
171
  _toL1({token: token, amount: amount, data: data, remoteToken: remoteToken});
171
172
  }
172
173
  // slither-disable-end out-of-order-retryable
173
174
  }
174
175
 
175
176
  /// @notice Bridge the `token` and data to the remote L1 chain.
177
+ /// @dev IMPORTANT — Arbitrum non-atomic bridging limitation:
178
+ /// For ERC-20 transfers, this function performs two independent operations: one for the token bridge
179
+ /// (via the L2 gateway router) and one for the `fromRemote` merkle root message (via `ArbSys.sendTxToL1`).
180
+ /// These are processed independently on L1, with no guaranteed ordering.
181
+ ///
182
+ /// `_handleClaim` calls `_addToBalance` which checks `amountToAddToBalanceOf` (derived from the contract's
183
+ /// actual token balance minus outbox balance). If the tokens have not arrived yet, this check will revert
184
+ /// with `JBSucker_InsufficientBalance`, preventing unbacked token minting.
176
185
  /// @param token The token to bridge.
177
186
  /// @param amount The amount of tokens to bridge.
178
187
  /// @param data The calldata to send to the remote chain. This calls `JBSucker.fromRemote` on the remote peer.
@@ -180,12 +189,6 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
180
189
  function _toL1(address token, uint256 amount, bytes memory data, JBRemoteToken memory remoteToken) internal {
181
190
  uint256 nativeValue;
182
191
 
183
- // Revert if there's a `msg.value`. Sending a message to L1 does not require any payment.
184
- if (msg.value != 0) {
185
- // slither-disable-next-line msg-value-loop
186
- revert JBSucker_UnexpectedMsgValue(msg.value);
187
- }
188
-
189
192
  // If the token is an ERC-20, bridge it to the peer.
190
193
  // If the amount is `0` then we do not need to bridge any ERC20.
191
194
  if (token != JBConstants.NATIVE_TOKEN && amount != 0) {
@@ -213,8 +216,8 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
213
216
  /// @notice Bridge the `token` and data to the remote L2 chain.
214
217
  /// @dev IMPORTANT — Arbitrum non-atomic bridging limitation:
215
218
  /// For ERC-20 transfers, this function creates two independent retryable tickets: one for the token bridge
216
- /// (via the gateway router, line ~245) and one for the `fromRemote` merkle root message (via the inbox,
217
- /// line ~274). These tickets are redeemed independently on L2, with no guaranteed ordering.
219
+ /// (via the gateway router) and one for the `fromRemote` merkle root message (via the inbox).
220
+ /// These tickets are redeemed independently on L2, with no guaranteed ordering.
218
221
  ///
219
222
  /// `_handleClaim` calls `_addToBalance` which checks `amountToAddToBalanceOf` (derived from the contract's
220
223
  /// actual token balance minus outbox balance). If the tokens have not arrived yet, this check will revert
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
5
5
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
5
5
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
5
5
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
5
5
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
package/src/JBSucker.sol CHANGED
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol";
5
5
  import {IJBCashOutTerminal} from "@bananapus/core-v6/src/interfaces/IJBCashOutTerminal.sol";
@@ -466,9 +466,16 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
466
466
  /// tree is append-only), but users will need regenerated proofs computed against the current root. This trade-off
467
467
  /// is accepted because enforcing sequential nonces could permanently block a token's inbox if a single message is
468
468
  /// delayed or lost by the bridge.
469
+ /// @dev Post-deprecation root acceptance: Roots are accepted in DEPRECATED state to prevent stranding tokens that
470
+ /// were sent before deprecation. Even though the mandatory `_maxMessagingDelay()` (14-day) buffer gives in-flight
471
+ /// messages time to arrive, accepting roots after deprecation provides a stronger guarantee that users can always
472
+ /// claim their bridged tokens. Double-spend is not a concern because `toRemote` is already disabled in
473
+ /// `SENDING_DISABLED` and `DEPRECATED` states, so no new outbound transfers can occur.
469
474
  /// @param root The merkle root, token, and amount being received.
470
475
  function fromRemote(JBMessageRoot calldata root) external payable {
471
476
  // Make sure that the message came from our peer.
477
+ // Safe to use _msgSender() here: bridge messengers never use ERC2771 meta-transactions,
478
+ // so this always resolves to msg.sender.
472
479
  if (!_isRemotePeer(_msgSender())) {
473
480
  revert JBSucker_NotPeer(_toBytes32(_msgSender()));
474
481
  }
@@ -495,11 +502,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
495
502
  // If the received tree's nonce is greater than the current inbox tree's nonce, update the inbox tree.
496
503
  // We can't revert because this could be a native token transfer. If we reverted, we would lose the native
497
504
  // tokens.
498
- //
499
- // Deprecated suckers reject new roots to prevent double-spend: once deprecated, the project owner may have
500
- // enabled the emergency hatch for local withdrawals. Accepting a new root after that could allow claiming
501
- // on both chains. The emergency hatch provides recovery for any tokens stuck in this state.
502
- if (root.remoteRoot.nonce > inbox.nonce && state() != JBSuckerState.DEPRECATED) {
505
+ if (root.remoteRoot.nonce > inbox.nonce) {
503
506
  inbox.nonce = root.remoteRoot.nonce;
504
507
  inbox.root = root.remoteRoot.root;
505
508
  emit NewInboxTreeRoot({
@@ -567,6 +570,12 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
567
570
  /// @notice Prepare project tokens and the cash out amount backing them to be bridged to the remote chain.
568
571
  /// @dev This adds the tokens and funds to the outbox tree for the `token`. They will be bridged by the next call to
569
572
  /// `toRemote` for the same `token`.
573
+ /// @dev Reentrancy protection: This function has implicit reentrancy protection through `_pullBackingAssets`.
574
+ /// The `assert` in `_pullBackingAssets` verifies that the contract's token balance increased by exactly the
575
+ /// amount reported by the terminal's `cashOutTokensOf`. A reentrant `prepare()` call would trigger a nested
576
+ /// `cashOutTokensOf`, changing the contract's balance before the outer call's `assert` executes. The outer
577
+ /// `assert` would then fail because the balance delta no longer matches the reported `reclaimedAmount`.
578
+ /// Note: because `assert` is used (not `revert`), a failed reentrancy attempt will consume all remaining gas.
570
579
  /// @param projectTokenCount The number of project tokens to prepare for bridging.
571
580
  /// @param beneficiary The recipient on the remote chain (bytes32 for cross-VM compatibility).
572
581
  /// For EVM peers: the EVM address left-padded to 32 bytes via `_toBytes32`.
@@ -664,6 +673,11 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
664
673
  /// remote
665
674
  /// chain.
666
675
  /// @dev This sends the outbox root for the specified `token` to the remote chain.
676
+ /// @dev Fee payment failure handling: The registry fee payment uses a best-effort pattern (try/catch). If the
677
+ /// fee project's terminal doesn't exist or the `pay` call reverts, the fee is silently absorbed back into the
678
+ /// `transportPayment` instead of reverting the entire transaction. This is a deliberate design choice that favors
679
+ /// bridge availability over fee collection — a failed fee payment should never prevent users from bridging their
680
+ /// tokens. The fee amount is typically small relative to the bridged value, making the tradeoff acceptable.
667
681
  /// @param token The terminal token being bridged.
668
682
  function toRemote(address token) external payable override {
669
683
  JBRemoteToken memory remoteToken = _remoteTokenFor[token];
@@ -692,6 +706,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
692
706
  // Best-effort: if the terminal doesn't exist or the pay call reverts, proceed without fee.
693
707
  IJBTerminal terminal = DIRECTORY.primaryTerminalOf({projectId: FEE_PROJECT_ID, token: JBConstants.NATIVE_TOKEN});
694
708
  if (address(terminal) != address(0)) {
709
+ // slither-disable-next-line unused-return
695
710
  try terminal.pay{value: _toRemoteFee}({
696
711
  projectId: FEE_PROJECT_ID,
697
712
  token: JBConstants.NATIVE_TOKEN,
@@ -700,7 +715,9 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
700
715
  minReturnedTokens: 0,
701
716
  memo: "",
702
717
  metadata: ""
703
- }) {}
718
+ }) returns (
719
+ uint256
720
+ ) {}
704
721
  catch {
705
722
  // Fee payment failed — proceed without fee, return it as transport payment.
706
723
  transportPayment = msg.value;
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol";
5
5
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {IInbox} from "@arbitrum/nitro-contracts/src/bridge/IInbox.sol";
5
5
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
5
5
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
5
5
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
5
5
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
5
5
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol";
5
5
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  /// @notice CCIP chain-specific constants used across Juicebox sucker contracts.
5
5
  library CCIPHelper {
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT OR Apache-2.0
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  /**
5
5
  * @title MerkleLib
package/test/Fork.t.sol CHANGED
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
5
5
  import {MockPriceFeed} from "@bananapus/core-v6/test/mock/MockPriceFeed.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
5
5
  import {IJBSucker} from "../src/interfaces/IJBSucker.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
5
5
  import {IJBSucker} from "../src/interfaces/IJBSucker.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
5
5
  import {MockPriceFeed} from "@bananapus/core-v6/test/mock/MockPriceFeed.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
5
5
  import {IJBSucker} from "../src/interfaces/IJBSucker.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import /* {*} from */ "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
5
5
  import {IJBSucker} from "../src/interfaces/IJBSucker.sol";
@@ -330,7 +330,7 @@ contract SuckerDeepAttacks is Test {
330
330
  }
331
331
 
332
332
  /// @notice fromRemote when DEPRECATED: should silently ignore even with valid higher nonce.
333
- function test_fromRemote_deprecated_silentlyIgnored() public {
333
+ function test_fromRemote_deprecated_stillAccepts() public {
334
334
  // Set deprecation in the past so state=DEPRECATED.
335
335
  sucker.test_setDeprecatedAfter(block.timestamp - 1);
336
336
  assertEq(uint256(sucker.state()), uint256(JBSuckerState.DEPRECATED), "Should be DEPRECATED");
@@ -344,12 +344,13 @@ contract SuckerDeepAttacks is Test {
344
344
  remoteRoot: JBInboxTreeRoot({nonce: 2, root: bytes32(uint256(0xbbbb))})
345
345
  });
346
346
 
347
- // Should NOT revert (native tokens would be lost), but should NOT update.
347
+ // Roots are accepted in DEPRECATED state to prevent stranding tokens that were sent
348
+ // before deprecation. Double-spend is not a concern because toRemote is already disabled.
348
349
  vm.prank(address(sucker));
349
350
  sucker.fromRemote(root);
350
351
 
351
- assertEq(sucker.test_getInboxRoot(TOKEN), bytes32(uint256(0xaaaa)), "Root should NOT update when DEPRECATED");
352
- assertEq(sucker.test_getInboxNonce(TOKEN), 1, "Nonce should remain 1 when DEPRECATED");
352
+ assertEq(sucker.test_getInboxRoot(TOKEN), bytes32(uint256(0xbbbb)), "Root SHOULD update even when DEPRECATED");
353
+ assertEq(sucker.test_getInboxNonce(TOKEN), 2, "Nonce should update to 2 even when DEPRECATED");
353
354
  }
354
355
 
355
356
  /// @notice fromRemote when SENDING_DISABLED: should still accept roots (only sending is disabled).
@@ -0,0 +1,120 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import "forge-std/Test.sol";
5
+
6
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
7
+ import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
8
+ import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
9
+ import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
10
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
11
+ import {IInbox} from "@arbitrum/nitro-contracts/src/bridge/IInbox.sol";
12
+ import {LibClone} from "solady/src/utils/LibClone.sol";
13
+
14
+ import "../../src/JBArbitrumSucker.sol";
15
+ import "../../src/deployers/JBArbitrumSuckerDeployer.sol";
16
+ import "../../src/enums/JBLayer.sol";
17
+ import "../../src/interfaces/IArbGatewayRouter.sol";
18
+ import "../../src/interfaces/IJBSuckerRegistry.sol";
19
+ import "../../src/structs/JBRemoteToken.sol";
20
+
21
+ contract ArbitrumL2FeeHarness is JBArbitrumSucker {
22
+ constructor(
23
+ JBArbitrumSuckerDeployer deployer,
24
+ IJBDirectory directory,
25
+ IJBPermissions permissions,
26
+ IJBTokens tokens,
27
+ IJBSuckerRegistry registry
28
+ )
29
+ JBArbitrumSucker(deployer, directory, permissions, tokens, 1, registry, address(0))
30
+ {}
31
+
32
+ function seedOutbox(address token, bytes32 remoteToken) external {
33
+ _remoteTokenFor[token] =
34
+ JBRemoteToken({addr: remoteToken, enabled: true, emergencyHatch: false, minGas: 200_000});
35
+ _insertIntoTree({
36
+ projectTokenCount: 0,
37
+ token: token,
38
+ terminalTokenAmount: 0,
39
+ beneficiary: bytes32(uint256(uint160(address(0xBEEF))))
40
+ });
41
+ }
42
+ }
43
+
44
+ /// @title ArbitrumL2ToRemoteFeeDoSTest
45
+ /// @notice Regression tests for the Arbitrum L2→L1 transportPayment fix.
46
+ /// Before the fix, `_toL1` checked `msg.value != 0` instead of `transportPayment != 0`.
47
+ /// When a non-zero registry fee existed, `msg.value` was non-zero even though all of it was
48
+ /// consumed by the fee — causing `_toL1` to revert and making L2→L1 bridging impossible.
49
+ /// The fix passes `transportPayment` (msg.value minus fee) into `_toL1`, so the check
50
+ /// correctly passes when all value goes to the fee.
51
+ contract ArbitrumL2ToRemoteFeeDoSTest is Test {
52
+ address internal constant DIRECTORY = address(0x1000);
53
+ address internal constant PERMISSIONS = address(0x2000);
54
+ address internal constant TOKENS = address(0x3000);
55
+ address internal constant REGISTRY = address(0x4000);
56
+ address internal constant TERMINAL = address(0x5000);
57
+
58
+ ArbitrumL2FeeHarness internal sucker;
59
+
60
+ function setUp() public {
61
+ JBArbitrumSuckerDeployer deployer = new JBArbitrumSuckerDeployer({
62
+ directory: IJBDirectory(DIRECTORY),
63
+ permissions: IJBPermissions(PERMISSIONS),
64
+ tokens: IJBTokens(TOKENS),
65
+ configurator: address(this),
66
+ trustedForwarder: address(0)
67
+ });
68
+
69
+ deployer.setChainSpecificConstants({
70
+ layer: JBLayer.L2, inbox: IInbox(address(0)), gatewayRouter: IArbGatewayRouter(address(0xB0B))
71
+ });
72
+
73
+ ArbitrumL2FeeHarness singleton = new ArbitrumL2FeeHarness({
74
+ deployer: deployer,
75
+ directory: IJBDirectory(DIRECTORY),
76
+ permissions: IJBPermissions(PERMISSIONS),
77
+ tokens: IJBTokens(TOKENS),
78
+ registry: IJBSuckerRegistry(REGISTRY)
79
+ });
80
+
81
+ sucker = ArbitrumL2FeeHarness(payable(LibClone.cloneDeterministic(address(singleton), bytes32("arb_fee_dos"))));
82
+ sucker.initialize(1);
83
+ sucker.seedOutbox(JBConstants.NATIVE_TOKEN, bytes32(uint256(uint160(JBConstants.NATIVE_TOKEN))));
84
+
85
+ // Mock the registry fee and fee terminal.
86
+ vm.mockCall(REGISTRY, abi.encodeCall(IJBSuckerRegistry.toRemoteFee, ()), abi.encode(uint256(1)));
87
+ vm.mockCall(
88
+ DIRECTORY,
89
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (1, JBConstants.NATIVE_TOKEN)),
90
+ abi.encode(IJBTerminal(TERMINAL))
91
+ );
92
+ vm.mockCall(TERMINAL, abi.encodeWithSelector(IJBTerminal.pay.selector), abi.encode(uint256(0)));
93
+
94
+ // Mock ArbSys precompile at address(100) so sendTxToL1 succeeds.
95
+ vm.etch(address(100), hex"00");
96
+ vm.mockCall(address(100), abi.encodeWithSignature("sendTxToL1(address,bytes)"), abi.encode(uint256(0)));
97
+ }
98
+
99
+ /// @notice L2→L1 bridging succeeds when msg.value exactly covers the registry fee.
100
+ /// Before the fix, this reverted because _toL1 checked `msg.value != 0`.
101
+ /// After the fix, transportPayment = msg.value - fee = 0, so _toL1 passes.
102
+ function test_toRemoteSucceedsWhenMsgValueCoversFeeExactly() external {
103
+ // msg.value = 1, fee = 1 → transportPayment = 0 → _toL1 accepts
104
+ sucker.toRemote{value: 1}(JBConstants.NATIVE_TOKEN);
105
+ }
106
+
107
+ /// @notice L2→L1 bridging reverts when msg.value exceeds the fee (excess transportPayment).
108
+ /// Sending a message from L2→L1 via ArbSys.sendTxToL1 is free — any leftover transportPayment is invalid.
109
+ function test_toRemoteRevertsWhenExcessTransportPayment() external {
110
+ // msg.value = 2, fee = 1 → transportPayment = 1 → _toL1 reverts
111
+ vm.expectRevert(abi.encodeWithSelector(JBSucker.JBSucker_UnexpectedMsgValue.selector, 1));
112
+ sucker.toRemote{value: 2}(JBConstants.NATIVE_TOKEN);
113
+ }
114
+
115
+ /// @notice L2→L1 bridging reverts when msg.value is insufficient for the fee.
116
+ function test_toRemoteRevertsWhenMsgValueBelowFee() external {
117
+ vm.expectRevert(abi.encodeWithSelector(JBSucker.JBSucker_InsufficientMsgValue.selector, 0, 1));
118
+ sucker.toRemote{value: 0}(JBConstants.NATIVE_TOKEN);
119
+ }
120
+ }