Technical writing

How to test cross-chain bridge workflows locally

Test a cross-chain bridge locally as an asynchronous state machine: pin the topology, submit one uniquely identified operation, and assert each owned transition through indexing, proof readiness, claim, and final state. Replace fixed sleeps with bounded polling and retain the last observed state on every timeout. A successful source transaction alone is not an end-to-end bridge test. Sources: AggSandbox architecture overview, Aggkit bridge service flow.

Developer infrastructure

A repeatable method for testing deposits, message propagation, proof readiness, claims, restarts, and duplicate operations across a pinned local bridge topology.

Tested with
AggSandbox 0.1.0
Validation scope
CLI command shapes and the state-polling harness were checked against the linked source version; this review did not run a full multi-container benchmark.

This guide answers

  • How do you test a cross-chain bridge locally?
  • Which states should a bridge integration test assert?
  • How do you debug a bridge transaction that never becomes claimable?

When to use this / when not to use this

Use this when

  • You are testing a bridge, message relay, or cross-domain workflow with observable intermediate states.
  • You need deterministic local failure injection for restarts, duplicate claims, delayed indexing, or proof readiness.

Do not use this when

  • You need economic, validator, or production-network guarantees that a local topology cannot reproduce.
  • The protocol exposes no stable identifiers or state probes; add observability before treating timing as correctness.

Write the complete lifecycle before the test

A successful source-chain receipt proves only that the bridge contract accepted an operation. The useful test boundary continues through event observation, service indexing, message or exit-root propagation, proof or claim readiness, destination execution, and the final balance or application state. Sources: Aggkit bridge service flow, Agglayer Unified Bridge asset flow.

Name these states in the CLI and test suite. Protocols differ, but a transition model stops the environment from collapsing every pending condition into waiting. It also tells failure injection where to act: before indexing, after proof creation, during a restart, or immediately before a duplicate claim.

Minimum lifecycle

  • Source transaction accepted and expected bridge event emitted.
  • Deposit or message indexed with stable origin and destination identifiers.
  • Proof, exit root, or claim material becomes available.
  • Destination claim accepted exactly once.
  • Recipient balance or application state matches the asset and message semantics.

Pin the whole topology, not only contract bytecode

Reproducibility requires chain IDs, genesis state, RPC endpoints, service images, bridge addresses, deployment order, funded accounts, and block timing. Pinning contracts while letting the indexer, relayer, or proof service float still creates failures that another developer cannot reproduce. Sources: Agglayer integration-test topology.

Use one command to start the environment, but keep every component inspectable. Health, chain height, deployed addresses, recent bridge events, pending operations, and claims should be available from the normal CLI surface. A convenient wrapper that hides protocol state makes the first demo easier and every failure harder. Sources: Docker Compose startup and health checks.

Topology manifest

  • Network IDs, RPC URLs, ports, block timing, and deterministic accounts.
  • Contract artifacts, constructor inputs, deployment transactions, and addresses.
  • Service images, configuration digests, dependency order, and health checks.
  • Token mappings and native-versus-wrapped asset semantics.
  • A reset command that returns every component to the same initial state.
System diagramBridge lifecycle under test

Every arrow is asynchronous and should have its own condition, deadline, and diagnostic source.

Source: AggSandbox bridge operations guide
  1. 01SubmitSource transaction + bridge event
  2. 02IndexDeposit/message correlation
  3. 03PropagateExit root, proof, or readiness
  4. 04ClaimDestination transaction exactly once
  5. 05AssertFinal asset or application state

Assert each boundary at its source of truth

A final balance can be correct while the test exercised the wrong token mapping, reused a previous claim, or skipped an intermediate service. Read the source receipt and event from the source chain, indexing status from the bridge service, proof readiness from its producer, and destination execution from the destination chain. Sources: Unified Bridge component reference.

Carry one correlation record through the test: source transaction hash, deposit or message identifier, origin and destination network IDs, token and recipient, proof identifier, and claim transaction hash. Each assertion should print the subset needed for the next command when it fails.

High-value cases

  • Assets and arbitrary messages in every supported direction.
  • Native assets, mapped tokens, and wrapped-token accounting.
  • Duplicate claim attempts and idempotent status queries.
  • Unsupported network, invalid recipient, and insufficient balance failures.
  • Two concurrent deposits with the same asset and recipient.

Turn timeouts and restarts into evidence

A timeout that reports only elapsed seconds throws away the useful state. Poll a named transition with a deadline, store the last observation, and on failure collect source receipt, matching events, service health, indexed message status, destination height, proof readiness, and claim state in one bounded report. Sources: Aggkit claim-readiness polling protocol.

Restart services at deliberate points and verify recovery from durable state. Stop the indexer after source acceptance, restart the claim service after proof readiness, and repeat the status command throughout. Local testing is most valuable when it exercises the recovery paths that are expensive and slow to reproduce on public networks.

Before trusting the suite

  • Run from clean state and after an intentional mid-flow restart.
  • Break one dependency and confirm the expected transition reports the failure.
  • Repeat the suite without inheriting nonces, addresses, or claims from the previous run.
  • Keep one end-to-end path fast enough for ordinary development.
  • Archive the topology manifest and timeout report in CI artifacts.

A minimal bridge harness that never sleeps blindly

This reduced TypeScript example captures the reusable part of the AggSandbox test strategy: correlate the source transaction, poll one owned state transition, and preserve the last observation when the deadline expires.

bridge-fixture.tstypescript
type Deposit = { id: string; status: "pending" | "claimable" | "claimed" };

export async function bridgeAndWait(api: BridgeApi, amount: bigint) {
  const source = await api.bridgeAsset({ from: 0, to: 1, amount });
  const deadline = Date.now() + 60_000;
  let last: Deposit | undefined;

  while (Date.now() < deadline) {
    last = await api.getDeposit(source.depositId);
    if (last.status === "claimable") {
      return { sourceTx: source.txHash, deposit: last };
    }
    await api.waitForNextBlock(1);
  }

  throw new BridgeTimeout({
    depositId: source.depositId,
    sourceTx: source.txHash,
    last
  });
}

What this minimal boundary guarantees

  • The source transaction and deposit identifier remain coupled in the return value.
  • Readiness is a protocol state, not elapsed wall-clock time.
  • The loop is bounded by a deadline and one-chain block progress.
  • Timeouts retain the last bridge-service observation for diagnostics.

Implementation examples

Concrete commands and data shapes you can adapt.

Start a sandbox, bridge an asset, and inspect the claimshell
aggsandbox start --detach
aggsandbox bridge asset \
  --network-id 0 \
  --destination-network-id 1 \
  --amount 0.1 \
  --token-address 0x0000000000000000000000000000000000000000

aggsandbox show claims --network-id 1
Poll a state instead of sleepingtypescript
await waitFor({
  deadlineMs: 60_000,
  describe: "deposit becomes claimable",
  read: () => bridge.getDeposit(depositId),
  accept: (deposit) => deposit.status === "claimable",
  onTimeout: (last) => writeStateReport({ depositId, last })
});

Decision log

The choices that shape the design—and what each choice costs.

  1. Use one CLI over a visible Docker topology

    Developers get repeatable startup without losing access to individual chain and service state.

    Tradeoff: The local environment requires Docker resources and careful port and network-ID management.
  2. Expose intermediate bridge states

    A bridge is asynchronous; debugging needs more than source success and final balance.

    Tradeoff: The CLI surface must track protocol terminology and service-specific identifiers.
  3. Use condition-based waits

    The same tests adapt to fast laptops and slower CI while failing on the missing transition.

    Tradeoff: Every polled state needs a bounded API and an actionable timeout report.

Failure cases

What breaks, how it presents, and the recovery boundary.

Source transaction succeeds but no claim appears
Signal

The bridge event exists, while the indexing or propagation state never advances.

Response

Trace the source event identifier through service logs and status APIs before inspecting the destination chain.

A fixed sleep flakes in CI
Signal

The same test alternates between passing and missing readiness with no state change in its output.

Response

Poll the named readiness condition with a deadline and include the last observed state on failure.

The test passes from stale state
Signal

A claim or balance from the prior run satisfies the final assertion.

Response

Use deterministic reset plus per-run identifiers and assert the exact source-to-claim correlation.

Repositories and primary references

Read the implementation, specifications, and tool documentation behind the article.

Inspect the systems that ground this guide in implementation work.

Last updated: