Skip to content

feat(wormhole): bridge contract hardening + E2E inbound#24

Open
skansal-rome wants to merge 17 commits into
masterfrom
feat-wormhole-bridge
Open

feat(wormhole): bridge contract hardening + E2E inbound#24
skansal-rome wants to merge 17 commits into
masterfrom
feat-wormhole-bridge

Conversation

@skansal-rome
Copy link
Copy Markdown
Contributor

Summary

  • Cherry-pick Wormhole Token Bridge contracts from wormhole-adapter branch onto master
  • Add EVM events (BridgeSend, BridgeClaim) for RomeScout indexing
  • Add input validation (zero amount, invalid target/chain, fee > amount)
  • Add emergency pause (Ownable + Pausable) with owner-controlled pause()/unpause()
  • Add pause guards to all entry points including generic invoke() functions
  • Fix PDA derivation in scripts (EXTERNAL_AUTHORITY seed + actual program ID)
  • 41 passing Hardhat tests (encoding, validation, events, pause)
  • E2E verified: Sepolia lock → VAA → Solana devnet claim → 0.001 WETH minted

E2E Evidence

  • Sepolia TX: 0xef382c10a820c5dca4656561656ddfc4b91400ecb500166ba9467183625069bc
  • Claim TX: 3dtCW1H8rsQzFKha9GuTTjV9tDkUSnQTMUJjKsRLmiGqcQFqifuZeUhuGRPWcN61ovAurEYDQwVS6VUeDptqm8Vx
  • ATA balance: 100000 (0.001 WETH)

Known Limitations

  • Claim step uses native Solana TX (not Rome EVM CPI) due to Mollusk SVM emulator limitation
  • Outbound flow not yet tested (separate work item)
  • ATA must be created on Solana devnet natively before claiming

Test plan

  • npx hardhat test — 41/41 passing
  • E2E inbound on montispl.devnet
  • E2E outbound (future PR)

🤖 Generated with Claude Code

Sattvik Kansal and others added 9 commits April 7, 2026 18:36
…m wormhole-adapter

Selectively brings wormhole-specific files onto master without the stale SPL token
library. Contracts compile against master's full SplTokenLib. Adds wormhole SDK
file: dependencies and viaIR compiler settings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add 36 tests across 2 test files for the RomeWormholeBridge contract
hardening. 20 tests fail (RED phase) covering planned features that
don't exist yet: BridgeSend/BridgeClaim events, input validation
(zero amount, zero target, zero chain), and emergency pause via
Ownable + Pausable. 16 tests pass covering existing correct
functionality (encoding library, SPL approve encoding, EmptyAccounts).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…bridge contract

Add BridgeSend/BridgeClaim events, input validation (zero amount, invalid
target, invalid chain), and OpenZeppelin Ownable+Pausable emergency controls
to RomeWormholeBridge. All 36 tests now pass (20 previously failing).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove unused IX_INITIALIZE/IX_ATTEST_TOKEN constants from encoding library
- Deduplicate encodeTransferNative/encodeTransferWrapped via shared _encodeTransfer helper
- Fix authoritySignerPda view->pure to eliminate compiler warning
- Extract filterEventLogs test helper to remove 5 duplicated event decode blocks
- Remove redundant defensive assert.rejects in non-owner pause test
- Add missing trailing newline to README.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add fee-exceeds-amount validation to _validateSendParams: revert early
when fee > amount instead of letting the CPI to Wormhole Token Bridge
fail with an opaque error. Three new tests cover the fee boundary
(sendTransferNative/sendTransferWrapped revert, fee==amount passes).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove hardcoded private key from native_solana_claim.ts (CRITICAL),
  replaced with process.env.EVM_PRIVATE_KEY with existence check
- Add _requireNotPaused() guard to invoke(), invokeWormholeCore(),
  invokeTokenBridge(), and invokeSplToken() (HIGH)
- Add NatSpec documenting why programId is caller-supplied (Solana
  runtime validates CPI account constraints)
- Enhance event test assertions to verify field values (sender, amount,
  nonce, claimer, tokenBridgeProgramId, accountCount)
- Add pause guard tests for invoke() and invokeWormholeCore()
- Document viaIR requirement in hardhat.config.ts (stack-too-deep)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rk configs

The send phase runs on Sepolia where the bridge contract doesn't exist.
Derive the Rome PDA locally using findProgramAddressSync instead of calling
bridgeUserPda() on-chain. Also adds sepolia_env and monti_spl_env network
configs for non-interactive E2E testing without the Hardhat keystore.

Updates bridge deployment address to new hardened contract.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…vnet

Fix PDA derivation in send script to use EXTERNAL_AUTHORITY seed (matching
RomeEVMAccount.pda() on-chain) instead of rome_evm_user. Use actual Rome
EVM program ID for the target rollup. Switch all network configs to
montispl.devnet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Rome EVM proxy's Mollusk SVM emulator cannot simulate complex CPI
chains like Wormhole completeWrapped, rejecting the tx during gas
estimation. Send the claim as a native Solana transaction instead,
which executes successfully on-chain.

E2E verified: Sepolia lock -> VAA -> post to Solana -> claim -> 0.001 WETH minted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@skansal-rome
Copy link
Copy Markdown
Contributor Author

E2E Inbound Transfer Verified ✅

Full Sepolia → Rome inbound bridge tested on montispl.devnet.romeprotocol.xyz:

Step Status Evidence
Deploy bridge 0xa4bf7ffe27a3b51bcdf53c342d526097ef48ad39 on montispl.devnet
Lock 0.001 ETH on Sepolia TX 0xef382c10...
VAA signed (seq 343854) Wormholescan
Post VAA to Solana devnet 3G2tBDgmHcJySDNLneBUaiPCocprXZMChbLEqPKUG17r
Create ATA on Solana devnet AajAGKFtW82wREhNE3zp6abN2nDgbdwZqNSwKejneWCC
Claim (native Solana TX) 3dtCW1H8rsQzFKha9GuTTjV9tDkUSnQTMUJjKsRLmiGqcQFqifuZeUhuGRPWcN61ovAurEYDQwVS6VUeDptqm8Vx
WETH balance 0.001 WETH in PDA 6qGnMuT8Bg6tTHKLnC4vKCL52m5Suwh1tZW15Ny8PEXL

Balance verified from both Solana devnet (spl-token balance) and Rome EVM RPC (account_info via CPI precompile).

Note on claim approach

The claim step sends a native Solana transaction directly (bypassing Rome EVM proxy) because the Mollusk SVM emulator in the proxy cannot simulate complex CPI chains like Wormhole's completeWrapped. The on-chain execution works — the emulator is the blocker. See companion PRs:

  • rome-protocol/rome-evm-private#233 — fix CannotRevertIrreversibleCall for CPI reverts
  • rome-protocol/rome-sdk#295 — use reversible mode for atomic transactions

🤖 This comment was generated by Claude Code.

@gitguardian
Copy link
Copy Markdown

gitguardian Bot commented Apr 8, 2026

️✅ There are no secrets present in this pull request anymore.

If these secrets were true positive and are still valid, we highly recommend you to revoke them.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find here more information about risks.


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

Sattvik Kansal and others added 5 commits April 8, 2026 10:36
…ssage account

The CPI precompile can only sign for the user's PDA and PDAs derived from
salt seeds — it cannot sign for arbitrary keypairs. Wormhole's transfer
instructions require a fresh message account signer. This solves the
outbound blocker by deriving the message account as a PDA from the user's
seed + a random salt, using invoke_signed to have the CPI precompile sign
for it.

Contract changes:
- Add messageSalt (bytes32) parameter to sendTransferNative/sendTransferWrapped
- Add _invokeSigned internal method (delegatecall to CPI invoke_signed)
- Add deriveMessagePda view function for on-chain PDA derivation
- Approve step uses invoke (user PDA only), transfer uses invoke_signed (+ message PDA)

SDK changes (wormhole-sdk-ts):
- Update ABI with messageSalt param and BridgeSend/BridgeClaim events
- Update encode functions with messageSalt
- Add deriveMessagePda helper to accountMeta.ts

Scripts:
- New wormhole_rome_to_sepolia.ts: full outbound flow (Rome wrapped WETH → Sepolia ETH)
- Updated wormhole_transfer.ts: replace Keypair.generate() with PDA-derived message

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tive network

The Rome EVM proxy emulator cannot simulate write-CPI (SPL Token transfers,
Wormhole bridge operations) through the CPI precompile — it hits
CannotRevertIrreversibleCall on the atomic endpoint and generic revert on
the iterative endpoint. Same limitation that required the inbound claim to
bypass the proxy with native Solana transactions.

Rewrote wormhole_rome_to_sepolia.ts with 2-step hybrid approach:
  Step 1a: SPL Token transfer from PDA ATA → payer ATA (via Rome EVM CPI)
  Step 1b: Wormhole transferWrapped as native Solana tx (payer + message keypair)

Added monti_spl_iterative network config for the -i endpoint.

Note: Step 1a still fails due to the emulator limitation. This is a known
platform constraint that requires Rome EVM proxy/emulator changes to resolve.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New RomeWormholeBridge at 0x2eb91e687247300853f392c4a903609df0cf8fcb
deployed with invoke_signed support for outbound transfers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Full end-to-end trace of both inbound (Sepolia -> Rome) and outbound
(Rome -> Sepolia) flows with architecture diagrams, CPI chain traces,
transaction hashes, balance flows, and test results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace Solidity's abi.encodeCall for _invoke and _invokeSigned with
Yul assembly that constructs the delegatecall data directly from
calldata. This avoids the expensive calldata→memory→ABI encode cycle
for AccountMeta tuple arrays.

Before: sendTransferWrapped consumed 1,399,644 / 1,400,000 CU (exceeded)
After:  sendTransferWrapped consumed 1,091,889 / 1,400,000 CU (308K saved)

The Yul optimization uses calldatacopy to bulk-copy account arrays
directly into the delegatecall buffer, skipping Solidity's per-element
copy loop and ABI re-encoding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@skansal-rome
Copy link
Copy Markdown
Contributor Author

Update: Yul-optimized CPI invoke for outbound flow

Problem

sendTransferWrapped via CPI consumed 1,399,644 / 1,400,000 CU — exceeding the limit. Solidity's abi.encodeCall for the 18-account tuple array was extremely expensive in BPF compute.

Fix (commit 87f0047)

Replaced _invoke and _invokeSigned with Yul assembly that:

  • Uses calldatacopy to bulk-copy AccountMeta arrays directly into the delegatecall buffer
  • Skips Solidity's per-element calldata→memory copy loop + ABI re-encoding

Results

Metric Before After Savings
CU consumed 1,399,644 1,091,889 308K (22%)
Status EXCEEDED SUCCESS

E2E verified

  • Outbound TX: 0x9e428426756f0276717ec3f3f2e0fa2f676df4e7d9a56170ff975983618d5474
  • New contract: 0x31ab49a6035f6a17b22872aba223b747ed69d93a on montispl
  • All 41 contract tests pass

🤖 This response was generated by Claude Code.

Sattvik Kansal and others added 3 commits April 9, 2026 14:56
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both _invoke and _invokeSigned read mload(0x40) but never advanced
the free memory pointer after writing. Any subsequent Solidity
memory allocation would overwrite the delegatecall buffer.

Fix: add mstore(0x40, add(ptr, totalLen)) after computing totalLen
in both assembly blocks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
WrappedTokenView is a minimal ERC-20 view wrapper over a Wormhole-minted
wrapped SPL mint so MetaMask (or any ERC-20 UI) can display the balance
held in the user's Rome-PDA-owned ATA on Solana.

Why not ERC20SPL from the SDK: the SDK's transparent-proxy wrapper calls
SplToken.program_id() to derive the ATA, but that view precompile is
unimplemented on the maximus SPL Token precompile (returns 0x). This
contract sidesteps the issue by hardcoding the program ids
(SPL_TOKEN_PROGRAM, ATA_PROGRAM, ROME_EVM_PROGRAM) and deriving the
user PDA + ATA directly via SystemProgram.find_program_address.

balanceOf reads the SPL Token Account's u64 amount field (offset 64,
LE) via CpiProgram.account_info, returning 0 when the ATA doesn't
exist. Transfer/approve revert — this is strictly a view shim, not a
functional ERC-20. The canonical production path is the SDK's
ERC20SPL once the SplToken.program_id() precompile lands on Rome.

Deployed on maximus for Sepolia-WETH wrapped mint
(6F5YWWrUMNpee8C6BDUc6DmRvYRMDDTgJHwKhbXuifWs):
  contract: 0x08a34004e48f800a8d865986c3d8e2e13511ca03
Users can Import as a custom token in MetaMask (symbol whWETH,
decimals 8) to see their Wormhole-inbound ETH balance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@skansal-rome
Copy link
Copy Markdown
Contributor Author

Add read-only ERC-20 shim for MetaMask balance display

WrappedTokenView lets MetaMask (and any ERC-20 UI) display the balance of a Wormhole-minted wrapped SPL mint that's held in the user's Rome-PDA-owned ATA on Solana. Needed for the outbound-bridge flow (rome-bridge#1) so users can see their bridged WETH after an inbound.

Why not ERC20SPL from rome-solidity-sdk: the SDK's transparent-proxy wrapper calls SplToken.program_id() to derive the ATA, but that view precompile is unimplemented on the maximus SPL Token precompile (returns 0x). Verified by directly calling selectors on 0xFF00000000000000000000000000000000000005. This contract sidesteps by hardcoding the program ids (SPL Token, Associated Token, Rome EVM) and deriving the user PDA + ATA directly via SystemProgram.find_program_address (which does work).

balanceOf reads the SPL Token Account's u64 amount at offset 64 (LE) via CpiProgram.account_info, returning 0 when the ATA doesn't exist. Transfer/approve revert — this is strictly a view shim, not a functional ERC-20. The canonical production path is the SDK's ERC20SPL once the SplToken.program_id() precompile lands on Rome (probably a one-line fix in rome-evm-private exposing the constant program id as a view).

Deployed on maximus for the Sepolia-WETH wrapped mint (6F5YWWrUMNpee8C6BDUc6DmRvYRMDDTgJHwKhbXuifWs):

  • contract: 0x08a34004e48f800a8d865986c3d8e2e13511ca03
  • symbol: whWETH, decimals: 8
  • Import as a custom token in MetaMask on Rome Maximus to see the Wormhole-inbound ETH balance.

Production follow-ups (not in this PR):

  1. Implement SplToken.program_id() (and probably AssociatedSplToken.program_id()) as view precompiles on Rome — unblocks rome-solidity-sdk's full ERC20SPL with transfer/approve.
  2. Deploy ERC20SPLFactory + TokenRegistry on maximus, then point the bridge UI at the registry and call wallet_watchAsset on inbound-claim success so the token auto-imports.

🤖 This response was generated by Claude Code.

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.

1 participant