Skip to content

Wormhole: enforce cross-chain executor guardrails#962

Open
mswilkison wants to merge 9 commits into
mainfrom
codex/wormhole-ntt-guardrails
Open

Wormhole: enforce cross-chain executor guardrails#962
mswilkison wants to merge 9 commits into
mainfrom
codex/wormhole-ntt-guardrails

Conversation

@mswilkison

@mswilkison mswilkison commented May 5, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Configure and enforce the expected L1 recipient chain for L2 Wormhole redemptions
  • Validate NTT executor refund address, fee recipient, fee bps, and exact delivery payment at transfer time
  • Add focused non-smock harness tests for the new guardrails

Validation

  • npx hardhat compile --no-size-contracts passed
  • npx hardhat test test/cross-chain/wormhole/L2BTCRedeemerWormhole.hardening.test.ts test/cross-chain/wormhole/L1BTCDepositorNttWithExecutor.hardening.test.ts passed with 5 tests
  • git diff --check passed for the safe-fix batch

Notes

This is public-safe guardrail hardening. The NTT path is not deployed, and this PR does not include any private deployment mitigation.

Summary by CodeRabbit

  • Bug Fixes

    • Hardened cross-chain transfers: tighter refund-address binding, enforced quoted payments, and safer refund handling to prevent replay/refund issues.
  • New Features

    • Configurable L1 Wormhole chain ID with recipient-chain validation for redemptions.
    • Added Sei cross-chain support and loader for Sei-specific interfaces.
  • Documentation

    • Updated deployment README and encoder/API docs to reflect new chain-id and Sei encoding details.
  • Tests

    • New and extended tests and mocks covering payments, refunds, nonces, and chain-ID validation.

Review Change Stack

@mswilkison mswilkison force-pushed the codex/wormhole-ntt-guardrails branch 2 times, most recently from e97c4e2 to 8f11b4b Compare May 5, 2026 16:08
@mswilkison mswilkison force-pushed the codex/wormhole-ntt-guardrails branch from 8f11b4b to 10dccaf Compare May 5, 2026 16:18
@mswilkison mswilkison marked this pull request as ready for review May 5, 2026 16:45
mswilkison and others added 4 commits May 5, 2026 17:28
- L2BTCRedeemerWormhole: pass stored l1BtcRedeemerWormholeChain to
  gateway call instead of caller-supplied recipientChain (defense-in-depth)
- L2BTCRedeemerWormhole: add onlyOwner setter for l1BtcRedeemerWormholeChain
  to support future upgrades without redeploying the proxy
- L2BTCRedeemerWormhole: add __gap storage reserve (OZ upgradeable pattern)
- L1BTCDepositorNttWithExecutor: relax exact-match payment to >= with
  excess ETH refund to prevent price-drift reverts
- L1BTCDepositorNttWithExecutor: remove redundant supportedChains require
  (already checked and reverted inside _getDestinationChainFromReceiver)
- L1BTCDepositorNttWithExecutor: remove dead feeArgs.dbps <= MAX_BPS check
  (defaultExecutorFeeBps is always <= MAX_BPS via setter validation)
- L1BTCDepositorNttWithExecutor: remove dead signedQuote.length > 0 check
  (subsumed by the >= 32 check below it)
- Add solhint-disable for avoid-low-level-calls on ETH refund
- Update hardening test: underpayment reverts, overpayment refunds
- L2BTCRedeemerWormhole: test setL1BtcRedeemerWormholeChain (onlyOwner,
  zero-value revert, storage update, event emission)
- L1BTCDepositorNttWithExecutor: verify NTT manager receives exactly
  requiredPayment (not msg.value) on overpayment; verify depositor
  retains no ETH after refund
piotr-roslaniec
piotr-roslaniec previously approved these changes May 6, 2026
- Reorder _transferTbtc so staged parameters are deleted before the
  external call; cache executorArgs/feeArgs locally so the post-clear
  call still sees the staged values. Prevents a reentrant caller (via
  the ETH overpayment refund inside _transferTbtcWithExecutor) from
  replaying staged params against a second deposit -- finalizeDeposit
  in AbstractL1BTCDepositor is not nonReentrant.
- Move the ETH refund to the very last step of _transferTbtcWithExecutor
  (after the emit), giving the function checks-effects-interactions
  ordering instead of refunding before the NTT transfer.
- Bind executorArgs.refundAddress == msg.sender at stage time in
  setExecutorParameters. Fee bps / payee equality remains enforced at
  finalize time so admins can rotate defaults without invalidating
  in-flight staged params.
- Tighten the underpayment error string from "Incorrect" to
  "Insufficient" -- the only failure path now is underpayment.

Tests:
- Add MockRefundRejector, a forwarding contract that selectively
  refuses ETH only from the depositor, used to exercise the
  "ETH refund failed" branch.
- Add hardening cases for non-zero fee equality (success + payee
  mismatch), ETH-refund-failure path, and stage-time refund-address
  binding.
- Update existing executor / integration / scenarios / minimal-auto-
  nonce tests to override refundAddress to the staging caller; the
  canned EXECUTOR_ARGS_REAL_QUOTE address no longer satisfies the
  stage-time bind.

All 478 wormhole tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
📝 Walkthrough

<review_stack_artifact>

</review_stack_artifact>

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the primary change: enforcing cross-chain executor guardrails in the Wormhole integration. It is concise, specific, and directly reflects the main intent of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/wormhole-ntt-guardrails

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
solidity/contracts/cross-chain/wormhole/L1BTCDepositorNttWithExecutor.sol (1)

944-966: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Emit the net charged amount instead of raw msg.value.

executorCost is emitted as msg.value, but Lines 960-966 refund any excess and only requiredPayment is actually forwarded to nttManagerWithExecutor. Overpaying callers will therefore produce inflated cost events for indexers and accounting. Emitting requiredPayment here, or renaming the field to reflect “user supplied payment,” would keep the event semantically correct.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@solidity/contracts/cross-chain/wormhole/L1BTCDepositorNttWithExecutor.sol`
around lines 944 - 966, The TokensTransferredNttWithExecutor event currently
emits msg.value which can be larger than the actual executor fee forwarded
(requiredPayment) because excess is refunded; update the emit to report the net
charged amount by replacing the msg.value argument with requiredPayment (or
alternatively rename the event field to userSuppliedPayment if you want to
preserve raw input), ensuring the emitted value matches what is actually
forwarded to nttManagerWithExecutor and indexers will see the true executor
cost.
🧹 Nitpick comments (1)
typescript/src/lib/sei/extra-data-encoder.ts (1)

14-16: ⚡ Quick win

Clarify the complete 32-byte encoding format in documentation.

The documentation states "first 2 bytes contain the Wormhole chain ID, followed by the 20-byte EVM-compatible recipient address," but doesn't mention that the address is left-padded with zeros to make the total 32 bytes (2-byte chain ID + 12-byte padding + 20-byte address).

📝 Suggested documentation enhancement
 /**
  * Implementation of the ExtraDataEncoder for Sei chain.
- * Encodes SeiEVM addresses as 32-byte values for use in Bitcoin deposit scripts.
- * The first 2 bytes contain the Wormhole chain ID, followed by the 20-byte
- * EVM-compatible recipient address.
+ * Encodes SeiEVM addresses as 32-byte receiver data for use in Bitcoin deposit scripts.
+ * Format: 2-byte Wormhole chain ID + 12-byte zero padding + 20-byte EVM address.
  */
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@typescript/src/lib/sei/extra-data-encoder.ts` around lines 14 - 16, Update
the doc comment in extra-data-encoder.ts to fully describe the 32-byte layout:
state that the 2-byte Wormhole chain ID is followed by 12 bytes of zero
left-padding and then the 20-byte EVM-compatible recipient address (total 32
bytes), e.g., "2-byte chain ID | 12 bytes zero padding | 20-byte address";
modify the existing comment block that starts "Encodes SeiEVM addresses as
32-byte values..." to include this exact padding detail so callers of the
encoder (e.g., the function that produces the 32-byte buffer) know the byte
ordering and padding convention.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@solidity/contracts/cross-chain/wormhole/L1BTCDepositorNttWithExecutor.sol`:
- Around line 1029-1047: The staged vs finalized fee checks are inconsistent:
_validateExecutorParameters requires feeArgs.dbps == defaultExecutorFeeBps while
setExecutorParameters currently allows feeArgs.dbps >= defaultPlatformFeeBps,
which can let users stage parameters that will later always revert; fix this by
aligning the checks—update setExecutorParameters to require feeArgs.dbps ==
defaultExecutorFeeBps (and enforce feeArgs.payee == defaultExecutorFeeRecipient
when dbps != 0) so staging uses the same source of truth as
_validateExecutorParameters (alternatively, if you prefer the floor behavior,
change _validateExecutorParameters to require feeArgs.dbps >=
defaultPlatformFeeBps instead, but pick one approach and apply it to both
setExecutorParameters and _validateExecutorParameters).

In
`@solidity/test/cross-chain/wormhole/L1BTCDepositorNttWithExecutor.executor.test.ts`:
- Around line 345-346: The positive test for setExecutorParameters may still
pass falsely because createExecutorArgs() used the default refundAddress; update
the test to set refundAddress equal to the caller (e.g., pin refundAddress in
executorArgs) so refund binding won't trigger a revert, and then assert full
success (not just not.revertedWith) by checking the transaction completes
without revert and emits the expected event or returns success; locate
createExecutorArgs, executorArgs, and the setExecutorParameters call and ensure
refundAddress is explicitly set to the caller and the assertion verifies overall
success.

---

Outside diff comments:
In `@solidity/contracts/cross-chain/wormhole/L1BTCDepositorNttWithExecutor.sol`:
- Around line 944-966: The TokensTransferredNttWithExecutor event currently
emits msg.value which can be larger than the actual executor fee forwarded
(requiredPayment) because excess is refunded; update the emit to report the net
charged amount by replacing the msg.value argument with requiredPayment (or
alternatively rename the event field to userSuppliedPayment if you want to
preserve raw input), ensuring the emitted value matches what is actually
forwarded to nttManagerWithExecutor and indexers will see the true executor
cost.

---

Nitpick comments:
In `@typescript/src/lib/sei/extra-data-encoder.ts`:
- Around line 14-16: Update the doc comment in extra-data-encoder.ts to fully
describe the 32-byte layout: state that the 2-byte Wormhole chain ID is followed
by 12 bytes of zero left-padding and then the 20-byte EVM-compatible recipient
address (total 32 bytes), e.g., "2-byte chain ID | 12 bytes zero padding |
20-byte address"; modify the existing comment block that starts "Encodes SeiEVM
addresses as 32-byte values..." to include this exact padding detail so callers
of the encoder (e.g., the function that produces the 32-byte buffer) know the
byte ordering and padding convention.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 606c77c0-419a-4a4d-a8c1-1e65d73455cf

📥 Commits

Reviewing files that changed from the base of the PR and between 52c02d1 and 5c59156.

📒 Files selected for processing (15)
  • solidity/README.adoc
  • solidity/contracts/cross-chain/wormhole/L1BTCDepositorNttWithExecutor.sol
  • solidity/contracts/cross-chain/wormhole/L2BTCRedeemerWormhole.sol
  • solidity/contracts/test/MockL2WormholeGateway.sol
  • solidity/contracts/test/MockRefundRejector.sol
  • solidity/contracts/test/TestL1BTCDepositorNttWithExecutor.sol
  • solidity/test/cross-chain/wormhole/L1BTCDepositorNttWithExecutor.executor.test.ts
  • solidity/test/cross-chain/wormhole/L1BTCDepositorNttWithExecutor.hardening.test.ts
  • solidity/test/cross-chain/wormhole/L1BTCDepositorNttWithExecutor.integration.test.ts
  • solidity/test/cross-chain/wormhole/L1BTCDepositorNttWithExecutor.minimal-auto-nonce.test.ts
  • solidity/test/cross-chain/wormhole/L1BTCDepositorNttWithExecutor.scenarios.test.ts
  • solidity/test/cross-chain/wormhole/L2BTCRedeemerWormhole.hardening.test.ts
  • solidity/test/cross-chain/wormhole/L2BTCRedeemerWormhole.test.ts
  • typescript/src/lib/sei/extra-data-encoder.ts
  • typescript/test/lib/sei/extra-data-encoder.test.ts

Comment on lines +1029 to +1047
require(
executorArgs.refundAddress == msg.sender,
"Executor refund address must be caller"
);
require(
feeArgs.dbps == defaultExecutorFeeBps,
"Fee must match default executor fee"
);

if (feeArgs.dbps == 0) {
require(
feeArgs.payee == address(0),
"Fee payee must be zero when fee is zero"
);
} else {
require(
feeArgs.payee == defaultExecutorFeeRecipient,
"Fee payee must match default executor fee recipient"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Align the staged and finalized fee invariants.

_validateExecutorParameters now requires feeArgs.dbps == defaultExecutorFeeBps, but setExecutorParameters still accepts anything >= defaultPlatformFeeBps at Line 487. If those two owner-configured values diverge, users can successfully stage parameters that are guaranteed to revert at finalize time. Please make both stages enforce the same source of truth, or validate the platform-fee floor here as well so the workflow cannot be bricked by config drift.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@solidity/contracts/cross-chain/wormhole/L1BTCDepositorNttWithExecutor.sol`
around lines 1029 - 1047, The staged vs finalized fee checks are inconsistent:
_validateExecutorParameters requires feeArgs.dbps == defaultExecutorFeeBps while
setExecutorParameters currently allows feeArgs.dbps >= defaultPlatformFeeBps,
which can let users stage parameters that will later always revert; fix this by
aligning the checks—update setExecutorParameters to require feeArgs.dbps ==
defaultExecutorFeeBps (and enforce feeArgs.payee == defaultExecutorFeeRecipient
when dbps != 0) so staging uses the same source of truth as
_validateExecutorParameters (alternatively, if you prefer the floor behavior,
change _validateExecutorParameters to require feeArgs.dbps >=
defaultPlatformFeeBps instead, but pick one approach and apply it to both
setExecutorParameters and _validateExecutorParameters).

Comment on lines +345 to 346
const executorArgs = createExecutorArgs({ refundAddress: owner.address })
const invalidFeeArgs = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Strengthen the max-BPS positive test to avoid false passes.

At Line 357, createExecutorArgs() still uses the default refundAddress; if it doesn’t match caller, setExecutorParameters may revert for refund binding while this assertion still passes (not.revertedWith only checks one reason). Please pin refundAddress there too and assert full success.

Suggested fix
-    it("should accept maximum valid fee basis points (10000) in setExecutorParameters", async () => {
-      const executorArgs = createExecutorArgs()
+    it("should accept maximum valid fee basis points (10000) in setExecutorParameters", async () => {
+      const executorArgs = createExecutorArgs({ refundAddress: owner.address })
       const validFeeArgs = {
         dbps: 10000, // Exactly 10% (10000/100000)
         payee: ethers.Wallet.createRandom().address,
       }
 
-      // This should not revert (but will likely revert on quote validation)
-      // We're testing that the BPS validation passes
-      await expect(
-        depositor.setExecutorParameters(executorArgs, validFeeArgs)
-      ).to.not.be.revertedWith("Fee cannot exceed 100% (10000 bps)")
+      await expect(
+        depositor.setExecutorParameters(executorArgs, validFeeArgs)
+      ).to.not.be.reverted
     })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@solidity/test/cross-chain/wormhole/L1BTCDepositorNttWithExecutor.executor.test.ts`
around lines 345 - 346, The positive test for setExecutorParameters may still
pass falsely because createExecutorArgs() used the default refundAddress; update
the test to set refundAddress equal to the caller (e.g., pin refundAddress in
executorArgs) so refund binding won't trigger a revert, and then assert full
success (not just not.revertedWith) by checking the transaction completes
without revert and emits the expected event or returns success; locate
createExecutorArgs, executorArgs, and the setExecutorParameters call and ensure
refundAddress is explicitly set to the caller and the assertion verifies overall
success.

@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Actionable comments posted: 0

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@typescript/api-reference/classes/SeiBitcoinDepositor.md`:
- Around line 186-189: Update the Throws text in the SeiBitcoinDepositor docs to
reflect receiver-based validation: replace the stale line "Error if the address
is invalid" with a message that mentions the encoded receiver (e.g., "Error if
the encoded receiver is invalid" or "Error if receiver validation fails") so it
accurately describes validation performed by the SeiBitcoinDepositor method that
validates an encoded receiver.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 99a68e28-07cc-4014-9b9c-fca428e972d5

📥 Commits

Reviewing files that changed from the base of the PR and between e17071f and ca76619.

📒 Files selected for processing (11)
  • typescript/api-reference/README.md
  • typescript/api-reference/classes/SeiBitcoinDepositor.md
  • typescript/api-reference/classes/TBTC.md
  • typescript/api-reference/interfaces/SeiBitcoinDepositorConfig.md
  • typescript/src/lib/sei/index.ts
  • typescript/src/lib/sei/sei-depositor.ts
  • typescript/src/services/tbtc.ts
  • typescript/test/lib/sei/index.test.ts
  • typescript/test/lib/sei/sei-depositor.test.ts
  • typescript/test/services/tbtc-sei.test.ts
  • typescript/test/utils/mock-cross-chain-contracts-loader.ts
✅ Files skipped from review due to trivial changes (2)
  • typescript/test/utils/mock-cross-chain-contracts-loader.ts
  • typescript/api-reference/interfaces/SeiBitcoinDepositorConfig.md

Comment on lines 186 to 189
**`Throws`**

Error if the address is invalid

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update the throw description to match receiver-based validation.

The method validates an encoded receiver, not a raw address. The current throw text is stale and can mislead users.

Suggested doc fix
-**`Throws`**
-
-Error if the address is invalid
+**`Throws`**
+
+Error if the encoded receiver is invalid
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@typescript/api-reference/classes/SeiBitcoinDepositor.md` around lines 186 -
189, Update the Throws text in the SeiBitcoinDepositor docs to reflect
receiver-based validation: replace the stale line "Error if the address is
invalid" with a message that mentions the encoded receiver (e.g., "Error if the
encoded receiver is invalid" or "Error if receiver validation fails") so it
accurately describes validation performed by the SeiBitcoinDepositor method that
validates an encoded receiver.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants