Comparing mythril with wormhole

Author

@0xinit

Stars

53

Repository

0xinit/cryptoskills

skills/mythril/SKILL.md

Mythril

Mythril is a symbolic execution tool for EVM bytecode that detects security vulnerabilities in Solidity smart contracts. It uses the LASER symbolic virtual machine to explore all reachable contract states across multiple transactions, then feeds path constraints to the Z3 SMT solver to generate concrete exploit inputs. It is maintained by Consensys Diligence.

Unlike static analyzers (Slither, Semgrep) that match code patterns, Mythril proves whether a vulnerability is actually exploitable by constructing valid transaction sequences that trigger it. This makes it slower but significantly more precise for certain vulnerability classes.

What You Probably Got Wrong

LLMs generate bad Mythril commands. These are the blind spots that cause wasted time and missed bugs.

  • Mythril is SLOW. Always set timeouts. A single-contract analysis with default settings can run for 30+ minutes and consume 8+ GB of RAM. Production usage requires --execution-timeout and --solver-timeout. Without them, your CI pipeline will hang indefinitely.
  • Default analysis depth is often too shallow. Without the -t (transaction count) flag, Mythril defaults to 2 transactions. Many real exploits (reentrancy across multiple functions, multi-step oracle manipulation) require -t 3 or higher. But -t 3 is exponentially slower than -t 2 — 10x to 100x slower.
  • Docker is easier than pip install for most users. Mythril depends on Z3 solver, py-solc-x, and specific Python versions. Docker (myth via mythril/myth) eliminates dependency hell. Pip install frequently fails due to Z3 build issues on macOS and certain Linux distros.
  • Mythril analyzes bytecode, not source. It compiles your Solidity to bytecode first, then symbolically executes the bytecode. This means it does not understand variable names, function names, or Solidity-level constructs. The output references bytecode offsets, not source lines — unless you provide source maps.
  • myth analyze is the correct command. The old myth -x syntax is deprecated. Use myth analyze for file analysis and myth analyze --address for on-chain contracts. myth -x may still work but produces deprecation warnings.
  • Mythril does NOT replace static analysis. It finds different bugs. Slither catches style issues, missing access control patterns, and centralization risks in seconds. Mythril catches reachable state-dependent exploits in minutes to hours. Use both.
  • False positives happen, especially with assert violations. Mythril reports any reachable assert(false) as a vulnerability. Custom assert statements used for invariant checking will trigger SWC-110 findings. Triage the output — not every finding is a real bug.
  • Solc version must match the contract pragma. Mythril calls solc internally. If the installed solc version does not satisfy the pragma, compilation fails silently or with cryptic errors. Use --solv to specify the version.

Installation

Docker (Recommended)

docker pull mythril/myth

# Verify
docker run mythril/myth version

Run Mythril on a local file (mount the current directory):

docker run -v $(pwd):/src mythril/myth analyze /src/Contract.sol

pip

Requires Python 3.8-3.12 and a working C++ compiler (for Z3).

pip3 install mythril

Verify:

myth version

If Z3 fails to build, install the system package first:

# Ubuntu/Debian
sudo apt-get install libz3-dev

# macOS
brew install z3

# Then retry
pip3 install mythril

From Source

git clone https://github.com/Consensys/mythril.git
cd mythril
pip3 install -e .

Specifying Solc Version

Mythril uses py-solc-x to manage compiler versions. Install the version your contracts need:

# Mythril installs solc automatically, but you can force a version
python3 -c "from solcx import install_solc; install_solc('0.8.28')"

Or specify at analysis time:

myth analyze Contract.sol --solv 0.8.28

Quick Start

Analyze a Single File

myth analyze contracts/Vault.sol

Analyze with Specific Solc Version

myth analyze contracts/Vault.sol --solv 0.8.20

Analyze a Specific Contract in a Multi-Contract File

myth analyze contracts/Token.sol --solc-args "--base-path . --include-path node_modules"

Analyze Deployed Contract

myth analyze --address 0x1234...abcd --rpc infura-mainnet

Or with a custom RPC URL:

myth analyze --address 0x1234...abcd --rpc-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY

Analysis Depth

The -t flag controls how many transactions Mythril simulates in sequence. This is the single most important performance/coverage tradeoff.

FlagTransactionsUse CaseTime (typical)
-t 11Quick scan, single-function bugs30s - 2min
-t 22Standard analysis (default)2 - 15min
-t 33Deep analysis, multi-step exploits15min - 2hr+
-t 4+4+Exhaustive, rarely practicalHours to days
# Quick scan for obvious issues
myth analyze Contract.sol -t 1 --execution-timeout 120

# Standard depth (default)
myth analyze Contract.sol -t 2 --execution-timeout 600

# Deep analysis — finds reentrancy across function pairs
myth analyze Contract.sol -t 3 --execution-timeout 3600

Each additional transaction multiplies the state space exponentially. A contract with 10 external functions has 10 possible first transactions, 100 possible two-transaction sequences, and 1,000 possible three-transaction sequences. Mythril prunes aggressively using symbolic constraints, but the growth is still significant.

When to Use -t 3

  • Reentrancy across different functions (attacker calls withdraw during deposit callback)
  • Flash loan attack paths (borrow -> manipulate -> profit across 3+ calls)
  • Multi-step privilege escalation (register -> claim role -> execute)
  • State-dependent vulnerabilities where the setup requires 2+ prior transactions

Detection Modules

Mythril maps findings to SWC (Smart Contract Weakness Classification) identifiers.

ModuleSWCWhat It Finds
ether_thiefSWC-105Arbitrary ETH withdrawal via unprotected selfdestruct or call
suicideSWC-106Unprotected selfdestruct callable by non-owner
delegatecallSWC-112Delegatecall to user-controlled address
reentrancySWC-107State changes after external calls
integerSWC-101Integer overflow/underflow (relevant for Solidity <0.8.0 or unchecked blocks)
unchecked_retvalSWC-104Unchecked return value on low-level calls
tx_originSWC-115tx.origin used for authentication
exceptionsSWC-110Reachable assert violations
external_callsSWC-107Dangerous external call patterns
arbitrary_writeSWC-124Write to arbitrary storage location
arbitrary_readN/ARead from arbitrary storage location
multiple_sendsSWC-113Multiple ETH sends in single transaction (DoS risk)
state_change_external_callsSWC-107State modification after external call
dependence_on_predictable_varsSWC-116, SWC-120Weak randomness from block variables

Running Specific Modules

# Only check for reentrancy and unchecked return values
myth analyze Contract.sol -m reentrancy,unchecked_retval

# Exclude integer overflow checks (useful for 0.8.x contracts)
myth analyze Contract.sol --exclude-modules integer

Output Formats

Text (Default)

myth analyze Contract.sol
==== Unprotected Ether Withdrawal ====
SWC ID: 105
Severity: High
Contract: Vault
Function name: withdraw(uint256)
PC address: 1234
Estimated Gas Usage: 3456 - 7890

In the function `withdraw(uint256)` a non-zero amount of Ether is sent to
msg.sender.

There is a check on storage index 0. This storage slot can be written to by
calling the function `deposit()`.
----
Transaction Sequence:
  Caller: 0xdeadbeefdeadbeef...
  calldata: 0xd0e30db0...  (deposit)
  value: 1000000000000000000

  Caller: 0xdeadbeefdeadbeef...
  calldata: 0x2e1a7d4d...  (withdraw)
  value: 0

JSON

myth analyze Contract.sol -o json > report.json

JSON output includes structured fields for each issue: title, description, severity, address, contract, function, swc-id, min_gas_used, max_gas_used, and tx_sequence.

Markdown

myth analyze Contract.sol -o markdown > report.md

JSONV2 (Machine-Readable with Source Mapping)

myth analyze Contract.sol -o jsonv2 > report.json

The jsonv2 format includes source mappings and more detailed transaction sequence information, suitable for programmatic processing and CI tooling.

Practical Settings

Production Mythril commands need explicit resource limits. Without them, analysis can consume all available memory and CPU time.

Recommended Settings by Context

# CI pipeline — fast, bounded
myth analyze Contract.sol \
  -t 2 \
  --execution-timeout 300 \
  --solver-timeout 30 \
  --max-depth 50 \
  -o jsonv2

# Pre-audit deep scan — thorough, time-boxed
myth analyze Contract.sol \
  -t 3 \
  --execution-timeout 3600 \
  --solver-timeout 60 \
  --max-depth 128 \
  -o json

# Quick sanity check during development
myth analyze Contract.sol \
  -t 1 \
  --execution-timeout 120 \
  --solver-timeout 10

Key Flags

FlagDefaultDescription
--execution-timeoutNone (unlimited)Max seconds for entire analysis
--solver-timeout10000 (ms)Max time per Z3 query
--max-depth128Max depth of symbolic execution tree
--loop-bound3Max times to unroll loops
--call-depth-limit3Max depth for inter-contract calls
--strategybfsSearch strategy: bfs, dfs, naive-random, weighted-random
--solver-logN/APath to log Z3 queries (debugging)
--bin-runtimeN/AAnalyze raw runtime bytecode
--enable-physicsN/AEnable gas-based path prioritization

Docker with Resource Limits

# Limit Docker container to 4GB RAM and 2 CPUs
docker run \
  --memory=4g \
  --cpus=2 \
  -v $(pwd):/src \
  mythril/myth analyze /src/Contract.sol \
  -t 2 \
  --execution-timeout 300 \
  --solver-timeout 30

Analyzing Deployed Contracts

Mythril can pull bytecode from any EVM chain and analyze it directly.

# Ethereum mainnet via Infura
myth analyze \
  --address 0xdAC17F958D2ee523a2206206994597C13D831ec7 \
  --rpc-url https://mainnet.infura.io/v3/YOUR_PROJECT_ID

# With transaction depth
myth analyze \
  --address 0xdAC17F958D2ee523a2206206994597C13D831ec7 \
  --rpc-url https://mainnet.infura.io/v3/YOUR_PROJECT_ID \
  -t 2 \
  --execution-timeout 600

On-chain analysis is slower because Mythril must resolve storage values and external contract dependencies via RPC calls.

Analyzing Raw Bytecode

# Analyze compiled bytecode directly
myth analyze --bin-runtime -c "0x6080604052..."

# From a file containing bytecode
myth analyze --bin-runtime -f bytecode.bin

CI Integration

GitHub Actions

name: Mythril Security Scan

on:
  pull_request:
    paths:
      - 'contracts/**'
      - 'src/**'

jobs:
  mythril:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4

      - name: Run Mythril
        uses: docker://mythril/myth:latest
        with:
          args: >-
            analyze /github/workspace/src/Contract.sol
            --solv 0.8.28
            -t 2
            --execution-timeout 300
            --solver-timeout 30
            -o jsonv2

      - name: Upload Report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: mythril-report
          path: report.json

Multi-Contract CI Scan

#!/usr/bin/env bash
set -euo pipefail

# Scan all Solidity files in src/, fail if any High severity issues found
FAILED=0

for sol_file in src/*.sol; do
  echo "Analyzing: $sol_file"
  if ! docker run --memory=4g -v "$(pwd)":/src mythril/myth analyze \
    "/src/$sol_file" \
    --solv 0.8.28 \
    -t 2 \
    --execution-timeout 300 \
    --solver-timeout 30 \
    -o json > "reports/$(basename "$sol_file" .sol).json" 2>&1; then
    echo "ISSUES FOUND in $sol_file"
    FAILED=1
  fi
done

exit $FAILED

Comparison with Slither

Mythril and Slither are complementary, not competing tools.

AspectSlitherMythril
ApproachStatic analysis (AST pattern matching)Symbolic execution (SMT solving)
SpeedSecondsMinutes to hours
Detectors90+ detectors, many style/best-practice~15 modules, focused on exploitability
False positivesHigher (pattern-based)Lower (proves exploitability)
False negativesMisses state-dependent bugsMisses pattern-based issues
Multi-transactionNoYes (core strength)
Upgradeable contractsGood supportLimited
CI suitabilityExcellent (fast)Requires timeout tuning
OutputJSON, SARIF, markdown, textJSON, JSONV2, markdown, text
Installpip install slither-analyzerpip install mythril or Docker

Recommended Workflow

  1. Slither first — catches low-hanging fruit in seconds
  2. Fix all Slither High/Medium findings — these are usually real
  3. Mythril second — deep scan for state-dependent exploits
  4. Review Mythril findings manually — verify the transaction sequences make sense
  5. Targeted Mythril runs — use -m to focus on specific vulnerability classes

Tool Comparison

ToolTypeSpeedDepthEase of UseBest For
SlitherStatic analysisSecondsPattern-basedEasyFast feedback, style, known patterns
MythrilSymbolic executionMinutes-hoursState-dependentMediumProving exploitability, multi-tx bugs
EchidnaFuzzingMinutes-hoursProperty-basedMediumCustom invariant testing
HalmosSymbolic testingMinutesFoundry-integratedMediumFormal verification in test framework
CertoraFormal verificationMinutes-hoursFull specificationHardComplete correctness proofs
SemgrepPattern matchingSecondsSyntacticEasyCustom rules, large codebases

When to Use What

  • Every PR: Slither (fast, catches regressions)
  • Pre-audit: Mythril + Echidna (find state-dependent bugs before auditors do)
  • Critical DeFi: Certora or Halmos (prove correctness properties mathematically)
  • Custom rules: Semgrep (org-specific patterns across many repos)

Common Pitfalls

Solc Version Mismatch

mythril.exceptions.CompilerError: Solc experienced a fatal error

Fix: specify the correct solc version:

myth analyze Contract.sol --solv 0.8.28

Import Resolution

Mythril cannot resolve imports by default. Provide paths:

myth analyze Contract.sol \
  --solc-args "--base-path . --include-path node_modules --include-path lib"

For Foundry projects with remappings:

myth analyze src/Contract.sol \
  --solc-args "--base-path . --include-path lib --allow-paths ."

Timeout Without Results

If Mythril times out with no findings, it does not mean the contract is safe — it means analysis was incomplete. Increase the timeout or reduce scope:

# Focus on specific modules to reduce analysis scope
myth analyze Contract.sol -m reentrancy -t 2 --execution-timeout 600

Memory Exhaustion

Large contracts with many state variables cause memory exhaustion. Limit with Docker:

docker run --memory=8g -v $(pwd):/src mythril/myth analyze /src/Contract.sol -t 2

Reference

Last verified: February 2026

Author

@0xinit

Stars

53

Repository

0xinit/cryptoskills

skills/wormhole/SKILL.md

Wormhole

Wormhole is a generic cross-chain messaging protocol secured by a set of 19 Guardian nodes. Each Guardian runs a full node for every connected chain and observes the Wormhole Core Contract. When a contract emits a message via publishMessage(), Guardians independently observe the event, sign an attestation, and produce a VAA (Verified Action Approval) once 13-of-19 signatures are collected. The VAA is the fundamental primitive -- a signed, portable proof that something happened on a source chain, verifiable on any destination chain.

What You Probably Got Wrong

AI agents confuse Wormhole's multiple transfer mechanisms, chain ID schemes, and delivery semantics. These are the critical corrections.

  • NTT is NOT the Token Bridge -- NTT (Native Token Transfers) burns tokens on the source chain and mints natively on the destination. The legacy Token Bridge locks tokens on the source and mints wrapped representations (e.g., WETHwh). NTT produces canonical tokens; Token Bridge produces wrapped tokens. Choose based on whether you control the token contract.
  • VAAs must be verified on the destination chain -- A raw VAA is just bytes. The destination chain's Core Bridge contract verifies the guardian signatures against the current guardian set before any action is taken. Never trust unverified VAA bytes.
  • Wormhole chain IDs are NOT EVM chain IDs -- Wormhole uses its own chain ID scheme. Ethereum = 2, Solana = 1, Arbitrum = 23, Base = 30, Optimism = 24. Using EVM chain IDs (1, 42161, 8453, 10) will silently route messages to the wrong chain or revert.
  • The guardian set rotates -- Guardian addresses change via governance VAAs. Never hardcode guardian public keys or set hashes. Always read the current guardian set from the Core Bridge contract.
  • publishMessage() does NOT deliver the message -- Publishing only emits an event on the source chain. A relayer (automatic or manual) must fetch the VAA and submit it to the destination chain. Without a relayer, the message sits undelivered forever.
  • Consistency levels matter -- consistencyLevel = 1 means "instant" (observed immediately, less safe). consistencyLevel = 15 waits for 15 block confirmations on Ethereum before Guardians sign. For high-value transfers, use consistencyLevel = 15 (finalized).
  • Standard Relayer has gas limits -- The automatic Standard Relayer caps gas on the destination chain. If your receiveWormholeMessages handler does significant computation, use manual relaying or increase the gas budget via quoteDeliveryPrice.
  • Token Bridge amounts lose precision -- The Token Bridge normalizes all amounts to 8 decimal places. If your token has 18 decimals, the last 10 digits are truncated. NTT does not have this limitation.
  • Emitter addresses are bytes32, not EVM addresses -- Wormhole identifies message senders as bytes32. For EVM chains, the address is left-padded with zeros. For Solana, it is the program's emitter PDA. Always normalize when comparing emitter addresses cross-chain.

Quick Start

Installation

npm install @wormhole-foundation/sdk viem

Client Setup

import { createPublicClient, createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { mainnet, arbitrum } from "viem/chains";

const account = privateKeyToAccount(
  process.env.PRIVATE_KEY as `0x${string}`
);

const sourceClient = createPublicClient({
  chain: mainnet,
  transport: http(process.env.ETH_RPC_URL),
});

const sourceWallet = createWalletClient({
  account,
  chain: mainnet,
  transport: http(process.env.ETH_RPC_URL),
});

Send a Cross-Chain Message (Minimal)

const CORE_BRIDGE = "0x98f3c9e6E3fAce36bAAd05FE09d375Ef1464288B" as const;

const coreBridgeAbi = [
  {
    name: "publishMessage",
    type: "function",
    stateMutability: "payable",
    inputs: [
      { name: "nonce", type: "uint32" },
      { name: "payload", type: "bytes" },
      { name: "consistencyLevel", type: "uint8" },
    ],
    outputs: [{ name: "sequence", type: "uint64" }],
  },
  {
    name: "messageFee",
    type: "function",
    stateMutability: "view",
    inputs: [],
    outputs: [{ name: "", type: "uint256" }],
  },
] as const;

const messageFee = await sourceClient.readContract({
  address: CORE_BRIDGE,
  abi: coreBridgeAbi,
  functionName: "messageFee",
});

const payload = new TextEncoder().encode("Hello from Ethereum");

const { request } = await sourceClient.simulateContract({
  address: CORE_BRIDGE,
  abi: coreBridgeAbi,
  functionName: "publishMessage",
  args: [
    0, // nonce -- use 0 unless batching messages
    `0x${Buffer.from(payload).toString("hex")}`,
    15, // consistencyLevel -- finalized (15 blocks on Ethereum)
  ],
  value: messageFee,
  account: account.address,
});

const hash = await sourceWallet.writeContract(request);
const receipt = await sourceClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error("publishMessage reverted");

Core Concepts

VAA Structure

VAA:
├── version (uint8)           -- always 1
├── guardianSetIndex (uint32) -- which guardian set signed this
├── signatures[]              -- 13+ guardian signatures
└── body
    ├── timestamp (uint32)
    ├── nonce (uint32)
    ├── emitterChainId (uint16)   -- Wormhole chain ID, NOT EVM chain ID
    ├── emitterAddress (bytes32)  -- left-padded for EVM
    ├── sequence (uint64)
    ├── consistencyLevel (uint8)
    └── payload (bytes)           -- arbitrary application data

Consistency Levels

LevelNameMeaningUse Case
1InstantObserved immediately, no finality waitLow-value messages, latency-sensitive
15FinalizedWait for chain finality (15 blocks on Ethereum)High-value transfers, security-critical
200SafeChain-specific "safe" headMedium-value, balanced latency/security

Wormhole Chain IDs (Common)

ChainWormhole IDEVM Chain ID
Solana1N/A
Ethereum21
BSC456
Polygon5137
Avalanche643114
Fantom10250
Sui21N/A
Aptos22N/A
Arbitrum2342161
Optimism2410
Base308453

See resources/chain-ids.md for the full mapping.

Emitter Address Normalization

import { type Address } from "viem";

function evmAddressToBytes32(address: Address): `0x${string}` {
  return `0x000000000000000000000000${address.slice(2)}` as `0x${string}`;
}

function bytes32ToEvmAddress(bytes32: `0x${string}`): Address {
  return `0x${bytes32.slice(26)}` as Address;
}

NTT (Native Token Transfers)

NTT enables canonical token transfers across chains by burning on the source and minting on the destination. Unlike the Token Bridge, NTT produces native tokens (not wrapped), and the token deployer retains full control.

Architecture

  • NttManager: Core contract -- burn/mint logic, rate limiting, peer registration
  • Transceiver: Transport layer that sends/receives messages via the guardian network
  • Token: Your ERC-20 with burn/mint capabilities granted to the NttManager

NTT Token Requirements

Your token must implement burn/mint controlled by the NttManager. See templates/ntt-starter.sol for a complete implementation.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

error CallerNotMinter(address caller);

contract NttToken is ERC20, ERC20Burnable, Ownable {
    address public minter;

    event MinterUpdated(address indexed oldMinter, address indexed newMinter);

    constructor(string memory name, string memory symbol, address initialOwner)
        ERC20(name, symbol) Ownable(initialOwner) {}

    /// @notice Set the minter address (NttManager on this chain)
    function setMinter(address newMinter) external onlyOwner {
        address old = minter;
        minter = newMinter;
        emit MinterUpdated(old, newMinter);
    }

    /// @notice Mint tokens -- only callable by the NttManager
    function mint(address to, uint256 amount) external {
        if (msg.sender != minter) revert CallerNotMinter(msg.sender);
        _mint(to, amount);
    }
}

Sending an NTT Transfer

import { type Address } from "viem";

const NTT_MANAGER = "0x..." as Address; // Your deployed NttManager
const WORMHOLE_ARBITRUM_CHAIN_ID = 23;

const nttManagerAbi = [
  {
    name: "transfer",
    type: "function",
    stateMutability: "payable",
    inputs: [
      { name: "amount", type: "uint256" },
      { name: "recipientChain", type: "uint16" },
      { name: "recipient", type: "bytes32" },
    ],
    outputs: [{ name: "messageSequence", type: "uint64" }],
  },
] as const;

const amount = 1000_000000000000000000n; // 1000 tokens (18 decimals)
const recipientBytes32 = evmAddressToBytes32(account.address);

// Approve NttManager, then transfer
const { request } = await sourceClient.simulateContract({
  address: NTT_MANAGER,
  abi: nttManagerAbi,
  functionName: "transfer",
  args: [amount, WORMHOLE_ARBITRUM_CHAIN_ID, recipientBytes32],
  value: 0n,
  account: account.address,
});

const hash = await sourceWallet.writeContract(request);
const receipt = await sourceClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error("NTT transfer reverted");

NTT Rate Limiting

NTT includes built-in rate limiting per peer chain. Both inbound and outbound limits are configurable. If a transfer exceeds the limit and shouldQueue = true, it enters a queue. If shouldQueue = false, the transfer reverts. See examples/deploy-ntt/ for full deployment setup including peer registration and rate limit configuration.

Cross-Chain Messaging

Publishing a Message (Solidity)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {IWormhole} from "@wormhole-foundation/sdk-solidity/interfaces/IWormhole.sol";

error InsufficientFee(uint256 required, uint256 provided);

contract CrossChainSender {
    IWormhole public immutable wormhole;
    event MessageSent(uint64 indexed sequence, bytes payload);

    constructor(address wormholeCoreBridge) {
        wormhole = IWormhole(wormholeCoreBridge);
    }

    /// @notice Send a cross-chain message via Wormhole
    /// @param payload Arbitrary bytes to deliver to the destination
    /// @param consistencyLevel Finality requirement (1 = instant, 15 = finalized)
    /// @return sequence The Wormhole sequence number for tracking
    function sendMessage(bytes memory payload, uint8 consistencyLevel)
        external payable returns (uint64 sequence)
    {
        uint256 fee = wormhole.messageFee();
        if (msg.value < fee) revert InsufficientFee(fee, msg.value);

        sequence = wormhole.publishMessage{value: fee}(0, payload, consistencyLevel);
        emit MessageSent(sequence, payload);
    }
}

Receiving a Message (Solidity)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {IWormhole} from "@wormhole-foundation/sdk-solidity/interfaces/IWormhole.sol";

error InvalidEmitterChain(uint16 expected, uint16 actual);
error InvalidEmitterAddress(bytes32 expected, bytes32 actual);
error MessageAlreadyConsumed(bytes32 hash);

contract CrossChainReceiver {
    IWormhole public immutable wormhole;
    uint16 public immutable sourceChainId;
    bytes32 public immutable sourceEmitter;
    mapping(bytes32 => bool) public consumedMessages;

    event MessageReceived(uint16 indexed emitterChainId, bytes32 indexed emitterAddress, uint64 sequence, bytes payload);

    constructor(address wormholeCoreBridge, uint16 _sourceChainId, bytes32 _sourceEmitter) {
        wormhole = IWormhole(wormholeCoreBridge);
        sourceChainId = _sourceChainId;
        sourceEmitter = _sourceEmitter;
    }

    /// @notice Receive and verify a cross-chain message
    /// @param encodedVaa The full VAA bytes from the Guardian network
    function receiveMessage(bytes memory encodedVaa) external {
        (IWormhole.VM memory vm, bool valid, string memory reason) =
            wormhole.parseAndVerifyVM(encodedVaa);
        require(valid, reason);

        if (vm.emitterChainId != sourceChainId) revert InvalidEmitterChain(sourceChainId, vm.emitterChainId);
        if (vm.emitterAddress != sourceEmitter) revert InvalidEmitterAddress(sourceEmitter, vm.emitterAddress);

        bytes32 vaaHash = keccak256(encodedVaa);
        if (consumedMessages[vaaHash]) revert MessageAlreadyConsumed(vaaHash);
        consumedMessages[vaaHash] = true;

        emit MessageReceived(vm.emitterChainId, vm.emitterAddress, vm.sequence, vm.payload);
        _processPayload(vm.payload);
    }

    function _processPayload(bytes memory payload) internal {}
}

Fetching a VAA (TypeScript)

const WORMHOLE_API = "https://api.wormholescan.io/api/v1";

async function fetchVaa(
  emitterChain: number,
  emitterAddress: string,
  sequence: bigint,
  maxRetries = 30,
  delayMs = 2000
): Promise<Uint8Array> {
  const url = `${WORMHOLE_API}/vaas/${emitterChain}/${emitterAddress}/${sequence}`;

  for (let i = 0; i < maxRetries; i++) {
    const response = await fetch(url);
    if (response.ok) {
      const json = (await response.json()) as { data: { vaa: string } };
      return Uint8Array.from(atob(json.data.vaa), (c) => c.charCodeAt(0));
    }
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }

  throw new Error(
    `VAA not found after ${maxRetries} retries: chain=${emitterChain} emitter=${emitterAddress} seq=${sequence}`
  );
}

Wormhole Queries

Wormhole Queries enable cross-chain reads without sending a transaction. Guardians execute the read on the target chain and return a signed response. No gas is spent on the target chain.

Query TypeDescriptionUse Case
EthCallQueryRequestExecute eth_call on a remote chainRead contract state
EthCallByTimestampQueryRequesteth_call at a specific timestampHistorical state reads
EthCallWithFinalityQueryRequesteth_call with explicit finalityReads requiring finalized state
import { QueryRequest, EthCallQueryRequest, PerChainQueryRequest } from "@wormhole-foundation/sdk";
import { encodeFunctionData } from "viem";

const callData = encodeFunctionData({
  abi: [{ name: "balanceOf", type: "function", stateMutability: "view",
    inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }] }] as const,
  functionName: "balanceOf",
  args: [account.address],
});

const ethCall = new EthCallQueryRequest("latest", [
  { to: "0x..." as const, data: callData },
]);

const query = new QueryRequest(0, [
  new PerChainQueryRequest(23, ethCall), // Arbitrum
]);

const response = await fetch("https://query.wormhole.com/v1/query", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ bytes: Buffer.from(query.serialize()).toString("hex") }),
});

if (!response.ok) throw new Error(`Query failed: ${response.status}`);

See examples/query-crosschain/ for full examples including on-chain verification.

Standard Relayer

The Standard Relayer handles VAA delivery automatically -- pay upfront on the source chain, the relayer delivers to the destination.

Sending via Standard Relayer

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {IWormholeRelayer} from "@wormhole-foundation/sdk-solidity/interfaces/IWormholeRelayer.sol";

error InsufficientRelayerPayment(uint256 required, uint256 provided);

contract RelayedSender {
    IWormholeRelayer public immutable relayer;
    uint256 public constant DESTINATION_GAS_LIMIT = 300_000;

    event CrossChainMessageSent(uint16 indexed targetChain, address indexed targetAddress, uint64 sequence);

    constructor(address wormholeRelayer) {
        relayer = IWormholeRelayer(wormholeRelayer);
    }

    /// @notice Send a message via the Standard Relayer
    /// @param targetChain Wormhole chain ID of destination
    /// @param targetAddress Receiver contract on destination
    /// @param payload Message to deliver
    function sendMessage(uint16 targetChain, address targetAddress, bytes memory payload) external payable {
        (uint256 deliveryCost, ) = relayer.quoteEVMDeliveryPrice(targetChain, 0, DESTINATION_GAS_LIMIT);
        if (msg.value < deliveryCost) revert InsufficientRelayerPayment(deliveryCost, msg.value);

        uint64 sequence = relayer.sendPayloadToEvm{value: deliveryCost}(
            targetChain, targetAddress, payload, 0, DESTINATION_GAS_LIMIT
        );
        emit CrossChainMessageSent(targetChain, targetAddress, sequence);
    }
}

Receiving via Standard Relayer

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {IWormholeReceiver} from "@wormhole-foundation/sdk-solidity/interfaces/IWormholeReceiver.sol";

error OnlyRelayer(address caller, address expected);
error UnregisteredSender(uint16 sourceChain, bytes32 sourceAddress);

contract RelayedReceiver is IWormholeReceiver {
    address public immutable wormholeRelayer;
    mapping(uint16 => bytes32) public registeredSenders;

    event CrossChainMessageReceived(uint16 indexed sourceChain, bytes32 indexed sourceAddress, bytes payload);

    constructor(address _wormholeRelayer) { wormholeRelayer = _wormholeRelayer; }

    /// @notice Register a trusted sender on a remote chain
    function registerSender(uint16 chainId, bytes32 sender) external {
        registeredSenders[chainId] = sender;
    }

    /// @notice Called by the Wormhole Relayer to deliver a message
    function receiveWormholeMessages(
        bytes memory payload, bytes[] memory, bytes32 sourceAddress,
        uint16 sourceChain, bytes32
    ) external payable override {
        if (msg.sender != wormholeRelayer) revert OnlyRelayer(msg.sender, wormholeRelayer);
        if (registeredSenders[sourceChain] != sourceAddress) revert UnregisteredSender(sourceChain, sourceAddress);

        emit CrossChainMessageReceived(sourceChain, sourceAddress, payload);
        _handlePayload(sourceChain, payload);
    }

    function _handlePayload(uint16 sourceChain, bytes memory payload) internal {}
}

Gas Estimation

const WORMHOLE_RELAYER = "0x27428DD2d3DD32A4D7f7C497eAaa23130d894911" as const;

const relayerAbi = [
  {
    name: "quoteEVMDeliveryPrice",
    type: "function",
    stateMutability: "view",
    inputs: [
      { name: "targetChain", type: "uint16" },
      { name: "receiverValue", type: "uint256" },
      { name: "gasLimit", type: "uint256" },
    ],
    outputs: [
      { name: "nativePriceQuote", type: "uint256" },
      { name: "targetChainRefundPerGasUnused", type: "uint256" },
    ],
  },
] as const;

const [deliveryCost] = await sourceClient.readContract({
  address: WORMHOLE_RELAYER,
  abi: relayerAbi,
  functionName: "quoteEVMDeliveryPrice",
  args: [23, 0n, 300_000n], // Arbitrum, no receiver value, 300k gas
});

Token Bridge (Legacy)

The Token Bridge locks tokens on the source chain and mints wrapped representations on the destination. Use NTT for new deployments where you control the token.

CriteriaToken BridgeNTT
Token ownershipThird-party tokens you don't controlTokens you deploy and control
Token representationWrapped (e.g., WETHwh)Native (canonical)
Decimal precisionTruncated to 8 decimalsFull precision preserved
Rate limitingNone built-inBuilt-in, configurable

Transfer Flow

  1. Attest (one-time): call attestToken() on source, fetch VAA, call createWrapped() on destination
  2. Transfer: approve Token Bridge, call transferTokens(), fetch VAA
  3. Redeem: submit VAA to destination Token Bridge via completeTransfer()

See examples/token-bridge/ for a complete working example with attestation, transfer, and redemption.

const TOKEN_BRIDGE = "0x3ee18B2214AFF97000D974cf647E7C347E8fa585" as const;

const tokenBridgeAbi = [
  { name: "attestToken", type: "function", stateMutability: "payable",
    inputs: [{ name: "tokenAddress", type: "address" }, { name: "nonce", type: "uint32" }],
    outputs: [{ name: "sequence", type: "uint64" }] },
  { name: "transferTokens", type: "function", stateMutability: "payable",
    inputs: [
      { name: "token", type: "address" }, { name: "amount", type: "uint256" },
      { name: "recipientChain", type: "uint16" }, { name: "recipient", type: "bytes32" },
      { name: "arbiterFee", type: "uint256" }, { name: "nonce", type: "uint32" },
    ],
    outputs: [{ name: "sequence", type: "uint64" }] },
  { name: "completeTransfer", type: "function", stateMutability: "nonpayable",
    inputs: [{ name: "encodedVm", type: "bytes" }], outputs: [] },
] as const;

Deployment Pattern

Multi-Chain NTT Setup

  1. Deploy your token on each chain
  2. Deploy NttManager on each chain
  3. Deploy Transceiver on each chain
  4. Register peers bidirectionally (setPeer on both NttManagers)
  5. Configure rate limits (inbound and outbound)
  6. Set the NttManager as the minter on each token contract
const setPeerAbi = [
  { name: "setPeer", type: "function", stateMutability: "nonpayable",
    inputs: [
      { name: "peerChainId", type: "uint16" }, { name: "peerContract", type: "bytes32" },
      { name: "decimals", type: "uint8" }, { name: "inboundLimit", type: "uint256" },
    ], outputs: [] },
] as const;

// On Ethereum: register Arbitrum NttManager as peer
const { request } = await sourceClient.simulateContract({
  address: ETH_NTT_MANAGER,
  abi: setPeerAbi,
  functionName: "setPeer",
  args: [23, evmAddressToBytes32(ARB_NTT_MANAGER), 18, 1_000_000_000000000000000000n],
  account: account.address,
});

See examples/deploy-ntt/ for the complete deployment walkthrough.

Fee & Gas Estimation

Core Bridge Message Fee

const messageFee = await sourceClient.readContract({
  address: CORE_BRIDGE,
  abi: coreBridgeAbi,
  functionName: "messageFee",
});
// Always read dynamically -- never hardcode as 0

Standard Relayer Delivery Price

const [deliveryCost, refundPerGas] = await sourceClient.readContract({
  address: WORMHOLE_RELAYER,
  abi: relayerAbi,
  functionName: "quoteEVMDeliveryPrice",
  args: [targetChainId, receiverValue, gasLimit],
});

Contract Addresses

Last verified: February 2026

ContractEthereumArbitrumBaseOptimismSolana
Core Bridge0x98f3c9e6E3fAce36bAAd05FE09d375Ef1464288B0xa5f208e072434bC67592E4C49C1B991BA79BCA460xbebdb6C8ddC678FfA9f8748f85C815C556Dd8ac60xEe91C335eab126dF5fDB3797EA9d6aD93aeC9722worm2ZoG2kUd4vFXhvjh93UUH596ayRfgQ2MgjNMTth
Token Bridge0x3ee18B2214AFF97000D974cf647E7C347E8fa5850x0b2402144Bb366A632D14B83F244D2e0e21bD39c0x8d2de8d2f73F1F4cAB472AC9A881C9b123C796270x1D68124e65faFC907325e3EDbF8c4d84499DAa8bwormDTUJ6AWPNvk59vGQbDvGJmqbDTdgWgAqcLBCgUb
Standard Relayer0x27428DD2d3DD32A4D7f7C497eAaa23130d8949110x27428DD2d3DD32A4D7f7C497eAaa23130d8949110x706F82e9bb5b0813f02e75c3e0a2ead1b0F4E9Cb0x27428DD2d3DD32A4D7f7C497eAaa23130d894911N/A

See resources/contract-addresses.md for NFT Bridge addresses and verification commands.

Error Handling

ErrorCauseFix
InvalidVAAVAA signature verification failedEnsure correct guardian set and uncorrupted VAA
GuardianSetExpiredSigned by an old guardian setRefetch a fresh VAA from the Guardian network
InsufficientFeemsg.value < messageFeeRead messageFee() and send at least that amount
TransferAmountTooSmallToken Bridge normalized amount to 0Amount must be >= 10^(decimals - 8)
ReplayProtectionVAA already consumedEach VAA can only be redeemed once per chain
OnlyRelayerNon-relayer called receiveWormholeMessagesOnly the Wormhole Relayer contract can call this
RateLimitExceededNTT transfer exceeds configured limitWait for replenishment or set shouldQueue = true

See resources/error-codes.md for the complete error reference.

References

AI Skill Finder

Ask me what skills you need

What are you building?

Tell me what you're working on and I'll find the best agent skills for you.