Bridge

Moving tokens between Somnia networks. A specialised chain is only useful if value can reach it — Hideki is a ~10 ms-block network built for traders, and this is how balances get there from the general-purpose testnet.

ts
import { BridgeToken, ChainId, createBridgeTransfer, sendBridgeStep } from "@somnia-chain/markets-sdk/chains";

It lives in the chains module because that is what it is: verified static addresses, alongside the chain definitions and their Multicall3 entries. The whole module is pure — no RPC, no client, no signer. createBridgeTransfer returns transaction data; you send it.

⚠️ This is a dev/test bridge. One validator, a threshold-1 ISM, EOA owners — one key is the entire bridge. SOMNIA_BRIDGE.status is "dev-test" and it means it: do not put real funds behind it.

What's live

One lane, five routes — full deployment record:

TokenSomnia Testnet (50312)Hideki Testnet (50383)Decimals
STTnativenative18
USDsocollateralsynthetic18
WBTCcollateralsynthetic8
WETHcollateralsynthetic18
HBTTcollateralsynthetic18

Mainnet, Elwood and the local chain are not bridged — getBridgeNetwork returns null for them, and createBridgeTransfer throws rather than inventing a route. Bridging is also not transitive: a chain has to be a member of the token's own route.

The enumerations

ts
BridgeToken.WBTC;        // "WBTC" — the bridge's own enum
ChainId.hidekiTestnet;   // 50383 — from the chains module: the chain id IS the value

Both are const objects with a matching type, not TS enums — the pattern the rest of this SDK uses. You get BridgeToken.WBTC for the value and BridgeToken for the type, plain literals still assign, and nothing non-erasable is emitted.

Networks are named by ChainId, which lives with the chain definitions rather than here — every Somnia network has a chain id; only some are bridged. Its keys are the definition export names, so ChainId.hidekiTestnet === hidekiTestnet.id by construction, and the id is the value because that is what wallets, manifests and viem speak — on this bridge the Hyperlane domain id equals the chain id, so one number identifies a network everywhere.

Bridging

ts
const plan = createBridgeTransfer({
  token: BridgeToken.WBTC,
  from: ChainId.somniaShannon,
  to: ChainId.hidekiTestnet,
  amount: 100_000_000n, // 1 WBTC — 8 decimals, not 18
  recipient: account.address,
});

if (plan.approveStep) await sendBridgeStep(client, plan.approveStep, { account });
const receipt = await sendBridgeStep(client, plan.bridgeStep, { account });

Two fields are the whole plan: bridgeStep — the transferRemote call, always present — and approveStep, the ERC-20 approval a collateral route needs first, simply absent on native and synthetic routes. Branching on plan.approveStep narrows it, so no ! is ever needed; its role is the field name, and description is the human label.

sendBridgeStep(client, step, { account }) is the sender: it signs locally (fixed fees, fixed gas ceiling, never estimated) and broadcasts via Somnia's realtime_sendRawTransaction, which blocks server-side and returns the receipt in the same call — send + confirm in one round-trip, so the approve-then-bridge ordering above is safe by construction. It throws on a reverted receipt, and on a node without the method (anvil, stock geth) it falls back to eth_sendRawTransaction + a receipt wait. It needs a local signer (viem privateKeyToAccount, a session account); with a browser wallet, spread the step into walletClient.sendTransaction({ ...plan.bridgeStep, account }) instead — extensions don't sign raw transactions. One consequence of fixed ceilings worth knowing: Somnia's mempool admits a transaction only when the balance covers gas × maxFeePerGas (0.6 STT at the defaults) on top of its value, even though unused gas is never charged — fund small accounts for the envelope, not the expected fee.

Each transaction is { chainId, to, data, value, description } — everything you must send, nothing you must fill in. No gas, no nonce, no fees: those belong to the signer, and a builder guessing gas for a contract on another chain would be inventing numbers. value is ours, because it is protocol-semantic:

Route modelvalueApproval
nativethe amount — the coin rides in msg.valuenone
collateral0nERC-20 approve to the router
synthetic0nnone — the router burns your balance

Amounts are bigint base units of the origin side's decimals. Read them from plan.origin.decimals rather than assuming 18 — WBTC is 8 on both sides.

Browser wallets and JSON boundaries. A viem wallet client takes a step as-is — including one on custom(window.ethereum). For the raw eth_sendTransaction path, or to move a server-built plan across a JSON boundary (a step's value is a bigint, and JSON.stringify throws on bigints), toEip1193Transaction(step, { from? }) returns the hex-quantified, JSON-safe request object — typed as viem's own RpcTransactionRequest. It deliberately drops chainId: an EIP-1193 wallet signs on its active chain, so switch with wallet_switchEthereumChain first. The walkthrough shows both paths end to end.

Two failure modes worth preflighting

Native-route liquidity. An STT transfer is native on both sides, so delivery is paid out of the destination router's own balance rather than by minting. If that balance is short, process() reverts and the transfer sits in escrow until someone refunds the router — not lost, but stuck. The destination side's requiresDestinationLiquidity flags it; check the balance yourself before offering the transfer:

ts
if (plan.destination.requiresDestinationLiquidity) {
  const liquidity = await destinationClient.getBalance({ address: plan.destination.router });
  if (liquidity < plan.amount) throw new Error("destination router is short — transfer would stall");
}

At the last verification the Somnia-side router held 0 STT and Hideki's held 100, so a first Hideki → Somnia transfer stalls until seeded. Seeding is a plain native transfer to the router.

The relayer is a liveness dependency. No InterchainGasPaymaster is deployed, so quoteGasPayment is 0 on every router and the relayer pays delivery gas unmetered (SOMNIA_BRIDGE.relayerPaysDestinationGas). A sender attaches nothing for delivery — but if the relayer runs dry, transfers keep being accepted on the origin and stop being delivered. Should a gas quote ever be introduced, read it and pass it as gasPayment; it lands in bridgeStep.value.

Delivery is asynchronous either way: transferRemote escrows and dispatches, and the far side is credited seconds later (median ~4s per leg since the relayer's 2026-08 latency work; 5–10s before it). The transaction you send does not wait for it.

Looking things up

ts
getBridgeToken(BridgeToken.USDso, ChainId.hidekiTestnet); // one side of one route, or null
listBridgeTokens(ChainId.somniaShannon);                        // everything bridgeable from a network
getBridgeNetwork(ChainId.somniaShannon);                        // mailbox / ISM / hooks, or null
listBridgeNetworks();                                           // the bridged networks
getBridgeRoute(BridgeToken.WBTC, 50312, 50383);                 // the lane, either order, or null
getBridgeRouter(BridgeToken.STT, ChainId.somniaShannon);        // just the router address
SOMNIA_BRIDGE;                                                  // the lane: routes, trust model, docs

Lookups return null on a miss (the get* contract from CONVENTIONS); createBridgeTransfer throws, because an unsupported triple is a programming error and its message names what would work.

warpRouterAbi is exported too, for reading a router directly — quoteGasPayment, routers, destinationGas, token.

How the values got here

Every address, decimal and model in the registry was read off the live chains, not transcribed: all ten routers have code, are owned by one account, are wired to their chain's mailbox and ISM, and are mutually enrolled in both directions; the collateral routers' token() matches the canonical ERC-20; the synthetics' decimals() match; quoteGasPayment is exactly 0 everywhere. The transferRemote encoding was simulated with eth_callvalue = amount on the native route returns a real message id, while dropping the value, skipping the approval, or naming an unenrolled destination each revert.

test/bridge.test.ts pins the calldata offline. test/bridge.e2e.test.ts re-runs the whole verification against both chains on demand — nothing signed, nothing sent:

sh
SOMNIA_E2E_BRIDGE=1 pnpm test

A note on one client-side trap the registry exists to avoid: a native router answers token() with the zero address rather than reverting, so code that probes token() to decide "collateral or native" silently gets 0x0…0. Use plan.origin.model / BridgeTokenDetails.model instead.

Adding a token or a lane

Both are data. hyperlane-bridge-infra is the source of truth — a new route there becomes a new entry in src/chains/bridge/registry.ts, and the types already carry it: destinations is a list, SOMNIA_BRIDGE.routes is a list, and every lookup is a filter. Adding a route needs no agent restart and no new core contracts; the validator and relayer are token-agnostic.