TypeScript test harness and end-to-end flow suites for the WIRE OPP (Outpost Protocol) cross-chain messaging system.
This repo stands up a full local cluster — the WIRE depot plus its Ethereum and
Solana outposts — and drives flow-* scenarios across all three chains, verifying
that OPP envelopes circulate and that on-chain state stays consistent end to end.
The OPP message flow spans three chains:
- WIRE depot (
nodeop+kiod) — system contractssysio.epoch,sysio.msgch,sysio.opreg,sysio.chalg,sysio.synd,sysio.bond,sysio.liq,sysio.swap(a depot-local AMM with no outpost participation),sysio.andon,sysio.dclaim, … - Ethereum outpost (
anvil) —OPP.sol,OPPInbound.sol,OutpostManager.sol,SyndicationPool.sol,BAR.sol(+liqEth). - Solana outpost (
solana-test-validator) — all four wire-solana Anchor programs loaded at genesis (liqsol_core, which hosts the OPP outpost interface, plusliqsol_token/transfer_hook/validator_leaderboard), with the liqsol surface stood up by wire-solana's ownanchor run init-*scripts.
wire-tools-ts consumes the other WIRE repos as siblings on disk — it builds
nothing on-chain itself, it orchestrates the already-built artifacts of:
| Sibling repo | Provides | Built with |
|---|---|---|
wire-sysio |
nodeop, kiod, clio, system-contract .wasm/.abi |
CMake / Ninja |
wire-ethereum |
outpost Solidity contracts + deployLocal.ts |
Hardhat |
wire-solana |
the four Anchor programs' .so + IDL (liqsol_core — which hosts the OPP outpost interface — plus liqsol_token, transfer_hook, validator_leaderboard) + the init-* scripts |
Anchor |
All four are checked out together as a single workspace via Google's repo tool.
For cloning and syncing the platform, follow
wire-platform-manifest/README.md first —
this README assumes the siblings already exist next to this repo.
For a full, from-scratch host build of every dependency (Ubuntu 24.04 / WSL2),
see docs/local-setup.md.
| Tool | Pinned version | Why | Install |
|---|---|---|---|
| Node.js | >= 24.9 |
runs the harness + flow tests | nodejs.org or nvm |
| pnpm | 10.32.1 |
the only supported package manager | corepack enable && corepack prepare [email protected] --activate |
| Rust | 1.86.0 |
toolchain for Solana / Anchor builds | see below |
Foundry (anvil) |
>= 1.5 |
local Ethereum node for the ETH outpost | see below |
Solana CLI (solana-test-validator) |
4.2.0 (Agave) |
local Solana validator for the SOL outpost | see below |
Anchor (anchor) via avm |
0.31.0 |
builds the four wire-solana programs; anchor run drives their init-* scripts during the bootstrap |
see below |
The Solana / Anchor / Rust versions are pinned by
wire-solana(Anchor.toml→anchor_version = "0.31.0",solana_version = "4.2.0";rust-toolchain.toml→channel = "1.86.0"). Match them — a mismatched validator or Anchor CLI produces program-load failures that look like flow bugs.
# ── Node + pnpm (via Corepack, ships with Node ≥ 16.9) ─────────────────────
corepack enable
corepack prepare [email protected] --activate
# ── Rust ───────────────────────────────────────────────────────────────────
# https://rustup.rs
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
. "$HOME/.cargo/env"
# ── Foundry / anvil ──────────────────────────────────────────────────────────
# https://book.getfoundry.sh/getting-started/installation
curl -L https://foundry.paradigm.xyz | bash
"$HOME/.foundry/bin/foundryup"
export PATH="$HOME/.foundry/bin:$PATH"
# ── Solana CLI (Agave) — pin 4.2.0 to match wire-solana ─────────────────────
# https://docs.anza.xyz/cli/install
sh -c "$(curl -sSfL https://release.anza.xyz/v4.2.0/install)"
export PATH="$HOME/.local/share/solana/install/active_release/bin:$PATH"
# ── Anchor via avm (Anchor Version Manager) — pin 0.31.0 ─────────────────────
# https://www.anchor-lang.com/docs/installation
cargo install --git https://github.com/coral-xyz/anchor avm --force
avm install 0.31.0
avm use 0.31.0Verify:
node --version # v22+ (v24 OK)
pnpm --version # 10.32.1
anvil --version # 1.5.x
solana --version # 4.2.0 ... Agave
anchor --version # anchor-cli 0.31.0The cluster loads compiled artifacts from the sibling repos. Build them before running anything here (each repo's own README/CLAUDE.md is authoritative):
# 1. wire-sysio — produces bin/{nodeop,kiod,clio} + system-contract wasm/abi
# (build/release or build/debug). See wire-sysio/CLAUDE.md for the CMake invocation.
ls ../wire-sysio/build/release/bin/nodeop # sanity check
# 2. wire-ethereum — compile the outpost contracts
cd ../wire-ethereum && pnpm install && pnpm build # npx hardhat compile
# 3. wire-solana — the four programs' .so + IDL, and the deps `anchor run` needs
# `build:programs` is the build the platform gate runs and the build the
# harness deploys; a bare `anchor build` is NOT equivalent.
cd ../wire-solana && npm install && npm run build:programspnpm install # also auto-links sibling @wireio/* packages via .pnpmfile.cjs
pnpm build # tsc -b across all packagespnpm workspace (no nx/turbo/lerna); everything lives under packages/.
| Package | Name | Purpose |
|---|---|---|
cluster-tool |
@wireio/cluster-tool |
Core harness: process managers, chain clients, bootstrap, wire-cluster-tool CLI |
flow-operator-collateral-deposit |
@wireio/test-flow-operator-collateral-deposit |
Node-operator collateral deposit + withdraw remit |
flow-batch-operator-termination |
@wireio/test-flow-batch-operator-termination |
Batch-operator termination via delivery underperformance |
flow-liq-syndication |
@wireio/test-flow-liq-syndication |
Real syndication reaches the destination wallet; reported yield is fully released |
flow-liq-yield |
@wireio/test-flow-liq-yield |
Syndicated liqSOL parked → linked → credited; reported yield minted, sold through sysio.swap, claimed as WIRE; DESYNDICATE_LIQ paid on the outpost |
flow-liq-kicker |
@wireio/test-flow-liq-kicker |
sysio.kicker pays LIQETH holders the accrued WIRE gift — a manual kick checked against the accrual model, then a batch operator's crank-pushed kick when the nodeop carries it |
flow-emissions-soak |
@wireio/test-flow-emissions-soak |
Multi-hour emissions + sysio.dclaim payout soak |
flow-ethereum-round-trip |
@wireio/test-flow-ethereum-round-trip |
Fresh ETH → liqETH → shadow → external liqETH redemption, exact net of the desyndication fee |
flow-batch-relay-restart |
@wireio/test-flow-batch-relay-restart |
Real relay restarts with a durable pending payout and resumes exact settlement |
flow-batch-roster-replacement |
@wireio/test-flow-batch-roster-replacement |
Nonbootstrap replacement is consumed by both outposts and records canonical accepted deliveries |
debugging-* / test-app-server |
@wireio/debugging-* |
OPP debugging server, client tooling, TUI, shared types |
Flow packages depend on the harness via workspace:*.
A successful syndication finishes only when its fee-adjusted principal is liquid in the destination WIRE wallet. A successful desyndication finishes only when the external recipient receives the exact return. Held intake, parked credit, burns, and outbound queue entries are intermediate checks.
| Enabled flow | Settlement obligation |
|---|---|
flow-liq-syndication |
Explicit governance resolution, full principal delivery through authenticated linking, and full reported-yield release |
flow-liq-yield |
Actual bonder, parked credit delivered on linking, WIRE yield claim, and external redemption payout |
flow-syndication-underwriting |
Actual bonds release each successful deposit, including the initially unlinked destination |
flow-syndication-plugin-underwriting |
The same deliveries with every bond, crank, approval and claim made by the batch operators' batch_operator_plugin underwriter role, with no flow intervention |
flow-syndication-plugin-underwriting-cap |
One funded operator's exposure cap holds the second request until its first bond is paid, then the same operator bonds, releases and claims it; parked credit is linked |
flow-syndication-challenge |
Actual bond/challenge/INVALID accounting remains tested; the subsequent successful custody probe must reach the wallet |
flow-syndication-rate-limit |
Actual bond and FIFO/bucket assertions; accepted redemption must reach the external wallet |
flow-emergency-stop |
Actual bond/challenge and deferred payouts; repaired-custody probe deposits must also reach the wallet |
flow-ethereum-round-trip |
Fresh real Ethereum acquisition and custody, full shadow delivery, external payout of the principal net of the live fee, then another accepted inbound epoch without duplicate payment |
flow-batch-relay-restart |
The Ethereum round trip with the sole live relay stopped while a durable return waits; retained-data restart must finish the payout |
flow-batch-roster-replacement |
Both real registries consume the nonbootstrap replacement; its recorded votes match accepted canonical envelopes on both outposts |
The other six enabled flows (flow-batch-operator-slashing,
flow-batch-operator-termination, flow-emissions-soak, flow-node-owner-nft,
flow-operator-collateral-deposit, flow-producer-registration) exercise their own paths. Imported
bootstrap collateral and reward funding have their own assertions. The eight
former disabled swap/reserve flows have been removed.
WireSyndicationTool.planResolveEnvelope is an explicit sysio.bond::rslvvalid
shortcut for an unbonded OPEN request. It refuses provider-funded, challenged or
invalid requests, and never replaces the real bond-provider flows. Native
sysio_synd_tests/generic_governance_and_provider_release_have_identical_settlement
compares both paths on EC1/NTA and EC2/NTB: linked/parked delivery, fee rounding,
freeze, partial release, same-epoch budgets, yield release, and external returns.
Collateral, challenge windows and bounty ownership intentionally differ; existing
bond tests cover those obligations. Matching settlement does not make the
shortcut a test of provider participation or external relay liveness.
Validation on 2026-10-02 used fresh clusters, the canonical runner and heartbeat, and Solana 4.2.0. All changed live flows passed:
| Flow | Successful report steps | Elapsed |
|---|---|---|
flow-liq-syndication |
226 | 10m 31s |
flow-syndication-challenge |
270 | 12m 39s |
flow-syndication-rate-limit |
268 | 24m 05s |
flow-emergency-stop |
312 | 20m 31s |
The rate-limit flow's final external-wallet check waited another 139 seconds for payout after the existing queued-redemption assertions. TypeScript compilation, 53 shared-helper tests and 32 focused flow tests passed. The first live attempts stopped at the bootstrap toolchain check because the shell selected Solana 4.0.3; the successful runs above selected the installed pinned 4.2.0 toolchain explicitly.
The eight former reserve/swap flow packages, their dedicated helpers and bootstrap flags have been deleted. The 15 remaining flows exercise supported functionality. Syndication underwriting uses depot bond providers, without swap-underwriter daemons.
Every flow needs three paths into the sibling repos, supplied as env vars (or as flags to the helper script below):
| Env var | Points to |
|---|---|
WIRE_BUILD_PATH |
wire-sysio build dir (must contain bin/nodeop), e.g. ../wire-sysio/build/release |
WIRE_ETH_PATH |
wire-ethereum repo root (must contain hardhat.config.ts) |
WIRE_SOLANA_PATH |
wire-solana repo root (its four programs built, node_modules installed — the bootstrap runs anchor run init-* there) |
WIRE_CLUSTER_PATH |
(optional) cluster data dir; the harness generates a fresh temp dir per run when unset |
scripts/run-flow.mjs discovers flow packages with a test script
dynamically, lets you pick one by name / regex (or interactively), validates the
sibling-repo paths, wires the env vars, and drives the matching package's test
script. This is the canonical flow runner — sessions/automation MUST use it
(per wire-platform-manifest/.claude/rules/run-flows-via-canonical-scripts.md),
and every live run is paired with the heartbeat monitor (see
Monitoring a live flow run below).
# Usage: ./scripts/run-flow.mjs [name-or-pattern] [options]
# Interactive picker over enabled packages/flow-* (no argument):
./scripts/run-flow.mjs \
--wire-build-path ../wire-sysio/build/release \
--ethereum-path ../wire-ethereum \
--solana-path ../wire-solana
# Exact name (full or short form):
./scripts/run-flow.mjs flow-operator-collateral-deposit --wire-build-path … --ethereum-path … --solana-path …
./scripts/run-flow.mjs operator-collateral-deposit --wire-build-path … --ethereum-path … --solana-path …
# Regex — 1 match runs it, multiple matches drop into a scoped picker:
./scripts/run-flow.mjs collateral --wire-build-path … --ethereum-path … --solana-path …Each --wire-build-path / --ethereum-path / --solana-path flag falls back to
its env var (WIRE_BUILD_PATH / WIRE_ETH_PATH / WIRE_SOLANA_PATH); one of the
two is required. --cluster-path (env WIRE_CLUSTER_PATH) is optional — omit it
and the harness picks a fresh temp dir.
For a human at an interactive terminal only — sessions/automation always go through Option A, which adds path validation and correct stdio handling:
export WIRE_BUILD_PATH=../wire-sysio/build/release
export WIRE_ETH_PATH=../wire-ethereum
export WIRE_SOLANA_PATH=../wire-solana
pnpm --filter @wireio/test-flow-operator-collateral-deposit test
# Unit tests only (builds first; no live flows):
pnpm testRun pnpm check (or ./scripts/check.mjs, which takes no arguments) to execute the
same gate as the pre-commit hook. It runs five stages in order and stops at the first
failure:
| # | Stage | Command | Deadline |
|---|---|---|---|
| 1 | package-layout-check |
./scripts/check-package-layout.mjs — every package root holds only the standard layout |
2 min |
| 2 | wql-types-check |
./scripts/generate-wql-types.mjs --check — WIRE QL grammar + engine schema drift |
2 min |
| 3 | ql-app-icons-check |
./scripts/generate-ql-app-icons.mjs --check — desktop app icon drift |
2 min |
| 4 | lint |
pnpm lint |
2 min |
| 5 | build and all unit tests |
pnpm test — the scripts/tests runner regressions, the TypeScript build, all 11 Jest projects (cluster-tool-shared, cluster-tool, flow-batch-operator-slashing, debugging-shared, debugging-server, debugging-client-shared, debugging-client-tool, debugging-client-tool-tui, ql-shared, ql-tool-cli, ql-tool-app), then every package test:unit script |
15 min |
This does not launch live flows. The hook uses the mise-pinned toolchain when available; Node 24.9 or newer is required.
Each stage reports its duration and prints progress every 30 seconds. A timeout or interrupt terminates the stage's process group on Linux/WSL/macOS, escalating after five seconds, and fails the gate. No timeout is treated as a passing test. A stage that exits 127 is reported as "could not start" (command not found), and a stage killed by a signal is reported with the signal's name.
Jest defaults to two workers: CPU-count concurrency oversubscribes the host's
socket-probing path on WSL. For a controlled comparison on another host, run
NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --maxWorkers=4 after building.
Keep the test inventory unchanged when comparing times.
The port allocator probes IPv4 and IPv6 wildcards separately, instead of every NIC, and stops scanning a candidate port window as soon as one port rejects it. Registry locking, reserved-port exclusions, pinned-port rejection, and UDP checks are retained. Real socket regressions cover IPv4 and IPv6-only listener collisions. On the WSL development host (2026-10-02), one default 83-port allocation took 9.44 seconds with the committed allocator and 3.35 seconds with these changes (sequential runs with an otherwise idle test harness). This measures allocation, not the entire hook; test-suite time also includes compilation and process tests.
The integrated lock fixes from PRs #76 and #100 keep the mutex outside the registry directory, keyed by its normalized path, with a 30-second stale threshold for short critical sections. Actual lock compromise rejects the owning operation with the original cause; it does not cancel or roll back its underlying work. The existing long-operation lock settings remain unchanged. Separate test registries isolate bookkeeping, not network namespaces: concurrent live clusters must continue sharing the host registry.
Every live run is watched by the canonical six-probe heartbeat monitor,
scripts/flow-heartbeat-monitor.mjs —
one instance per flow run, started right after the flow and left running
for the run's entire life:
node scripts/flow-heartbeat-monitor.mjs --cluster-path <cluster-dir>Every 90s it probes chain liveness, operators, epoch state, msgch envelopes,
data/opp-debugging/ per-direction artifact counts, and the aggregate cluster
log (FATAL vs NOISE classified), printing one HB … line per cycle — and it
bails loudly on the fatal conditions (epoch stall, zero-delta opp-debugging,
plugin-layer failures) instead of letting a dead run burn its full pollUntil
deadline. It self-concludes when the flow exits. --interval-seconds /
--epoch-duration-seconds are the only tuning knobs; everything else derives
from the cluster's cluster-config.json.
OutpostFrozen (custom program error: 0x17c7) and EnforcedPause() are FATAL
by default. Only flow-emergency-stop passes --expect-freeze to the monitor
when deliberately exercising paused outposts; it counts and quotes those exact
refusals as NOISE, while preserving unrelated failures and all liveness bails.
The liquidity flows run without this flag. It is distinct from
--expect-epoch-freeze, which permits a deliberate epoch stall.
Rules of the road (binding for sessions/automation; see
wire-platform-manifest/.claude/rules/run-flows-via-canonical-scripts.md and
…/cluster-state-active-probing.md):
- Never run a flow without its monitor, and never watch one with a
hand-rolled
tail | grepinstead of the script. - Never wrap either script in session-local launcher/watcher scripts — if a
run needs a new probe, bail, or launch behavior, extend
flow-heartbeat-monitor.mjs/run-flow.mjsthemselves. - Parallel runs need disjoint
--cluster-pathdirs; port allocation is collision-proof viaBindConfig, so parallelism is bounded only by host resources.
BindConfigProvider selects automatic service ports outside the host's actual
automatic client-port ranges, including both ports of the Solana RPC/PubSub pair
and every port of its dynamic window. Fallback services use available ports from
10001 through 65535 with a randomized starting point, excluding those ranges,
the reserved Agave band, and live registry claims. Every candidate still passes
the existing IPv4/IPv6 and transport-specific availability checks.
Host observations are read-only: Linux uses
/proc/sys/net/ipv4/ip_local_port_range; macOS reads the ordinary, high and low
net.inet.ip.portrange bounds; Windows queries IPv4/IPv6 TCP/UDP ranges with
netsh. Unreadable or unsupported settings and exhausted safe pools fail
automatic allocation. A fully pinned plan does not require these observations.
Keep these host settings stable while clusters are running. Explicit
pinned ports/ranges retain their availability-checked behavior; an operator who
pins inside an automatic client range accepts the source-port collision risk.
Ports resolved on one machine must be reviewed against the destination machine's
settings before external deployment.
During Solana startup the harness observes validator.log every 100 ms and
records the first bind error's assigned-port socket states/owners with
ss -tuapn where available. That snapshot is retained in startup diagnostics
even if the conflicting client exits before readiness times out.
For interactive work — poking the chains with clio or the debugging TUI —
drive the wire-cluster-tool CLI directly (alias: wtc). Its commands:
create, run, destroy, package, create-external-config, and
create-api-node.
After create, there are two launchers, and they are not alternatives to pick
between at random:
wire-cluster-tool run |
a daemon's start.sh |
|
|---|---|---|
| Starts | the WHOLE cluster, in dependency order | ONE daemon |
| Needs | this repo installed + built | only that daemon's binary |
| Ports/paths | re-read from cluster-config.json at launch |
frozen from the config at emit time |
| Registers with the bind registry | yes (claimResolved) |
no |
run is the local development path. start.sh exists for the published
artifact: a consumer who unpacks
wire-platform-cluster-external-data-<version>.tgz has the tree but not this
repo, and the argv otherwise lives only inside wire-cluster-tool.
Every daemon directory gets one — <cluster>/data/<daemon>/start.sh — carrying
the same argv run would spawn, with three properties worth knowing:
- Relocatable. No build-host path is frozen.
$NODE_DIRand$CLUSTER_DIRderive from the script's own location (override withWIRE_CLUSTER_DIR). The wire-sysio install prefix isWIRE_PREFIX_PATH, RESOLVED rather than demanded: an explicitWIRE_PREFIX_PATHwins, else the parent of anodeopfound onPATH(so a host with wire-sysio installed needs no configuration at all), elseWIRE_BUILD_PATHfor a tree still pointed at a build dir — and it fails loudly naming all three if none resolves.WIRE_ETH_PATH/WIRE_SOLANA_PATHare asserted outright where referenced, since nothing onPATHcan imply them. PATH-resolved binaries indirect throughWIRE_ANVIL_BIN/WIRE_SOLANA_TEST_VALIDATOR_BIN/WIRE_NODE_BIN, falling back tocommand -v. TheWIRE_prefix is deliberate — unprefixed names collide with ambient ones (NODE_BINis set to a bin DIRECTORY by common node version managers, so the fallback is never reached and the script execs a directory). - Run semantics. Nodes are relaunch-form (no one-shot genesis flags); anvil
carries interval mining. Conditions that depended on the build host — anvil's
--load-state, nodeop's--trace-no-abis— render as shell tests the script evaluates itself. The--trace-no-abistest is additionally PREDICATE-GATED: the flag belongs totrace_api_plugin, so it is emitted only for nodes that actually load it (every role on a local cluster; operators and API nodes in an external tree). A node without the plugin renders no condition at all — nodeop rejects the flag outright when the plugin is absent. - Not bind-registry aware. The ports were issued by the registry at create
time, but a
start.sh-launched daemon does not re-claim them, so it is invisible to a concurrently-resolving cluster on the same host. Intended for a dedicated deploy host; userunwhen other clusters share the machine.
Invoke as ./start.sh — the emitted scripts are mode 0755.
Under the default
KEYsignature provider these scripts contain an inline private key for every producer/operator node, exactly as the daemon's argv does — the script header says so. Published GHA artifacts are unaffected: that workflow is SSM-only and refuses any other provider, so its scripts carrySSM:<id>references instead.
wire-cluster-tool create \
--cluster-path=/tmp/wire-cluster-tool-001 \
--force \
--build-path=/data/shared/code/wire-platform/wire-sysio/build/release \
--producer-count=5 \
--node-count=1 \
--batch-operator-count=3 \
--underwriter-count=0 \
--epoch-duration-sec=60 \
--ethereum-path=/data/shared/code/wire-platform/wire-ethereum \
--solana-path=/data/shared/code/wire-platform/wire-solana \
&& wire-cluster-tool run --cluster-path=/tmp/wire-cluster-tool-001create writes a cluster-config.json under --cluster-path and bootstraps the
chains; run starts the cluster from that saved config and blocks until you
Ctrl+C (which triggers a clean shutdown). Tear it down with:
wire-cluster-tool destroy --cluster-path=/tmp/wire-cluster-tool-001Automatic underwriting requires --enable-batch-operator-underwriting, matching
per-token exposure caps, and liquid shadow LIQ in the operator accounts. The role
defaults to off even when OPP delivery works, except in a local create, which forces
it on together with both mock inventories. See the
setup and existing-testnet recovery procedure.
Every create sets these up on the depot; no flag gates them, because a real
depot needs them too:
| Phase | What it does |
|---|---|
OPPContracts |
deploys sysio.andon before the contracts that read its cord (sysio.swap, sysio.liq, sysio.bond, sysio.synd), and sysio.bond before sysio.synd, all privileged through setsyscode; OPPCodeGrants then grants @sysio.code to sysio.bond and sysio.synd (sysio.andon sends no inline action) |
PanicAccount |
creates andon.panic, the account that may pull and clear the cord besides sysio |
EmergencyStop |
funds and delegates native pull/clear permissions to andon.panic and sysio, then links each action before any liq token is registered |
SyndicationConfig |
after ShadowLiqTokens: sysio.bond::setconfig (hold bond 1000 bps), one sysio.synd::setconfig per shadow pair (ETHEREUM/LIQETH, SOLANA/LIQSOL: free syndication and desyndication charged at floor(quantity × 5 / 10000) plus a pair-specific flat fee, buckets of one million tokens, a 60 s challenge window, a one-token challenge charge and flat fees / gross minima of 5,000,000 / 100,000,000 for LIQETH and 100,000 / 50,000,000 for LIQSOL (9 decimals; flat < minimum; both round-trip legs’ gas: Ethereum ≈507k gas, Solana 9k–104k lamports)), with custody shortfall allowance 100 bps for LIQETH and zero for LIQSOL; then verifies that each shadow is at the depot's 9 decimals and each pair is an active liq token with an active binding |
Break-even sizing assumes a syndication of the pair's min_desyndicate.
Outposts separately configure minimum syndications of 0.005 liqETH (5,000,000
depot units) and 0.0025 liqSOL (2,500,000 base units), each 1/20 of the return
minimum. Return Steps derive max_fee from the live pair immediately before
submission; the contract refuses a higher charge before moving funds.
Native action permissions are armed after the cord readers deploy and before registry setup or epoch traffic. Privileged sysio.synd can pull the cord without a contract-managed puller registry.
sysio.kicker pays governance-budgeted T5 WIRE gifts to LIQ holders through
sysio.liq::addyield (wire-sysio contracts/sysio.system/EMISSIONS.md). The bootstrap
follows docs/platform-bootstrap-config.md ("Kicker deployment and earmark"):
| Phase | What it does |
|---|---|
Kicker |
every create, after SyndicationConfig and any MockLiqPools: deploys sysio.kicker through setsyscode (privileged, so its inline sysio.token::transfer may draw from sysio under sysio@active), reads the privilege back, then setconfig with a 1,000,000 WIRE budget, a 60 s minimum interval, an 86,400 s TWAP window and the contract's 5,000 bps spot-deviation guard (Steps.registry.KickerConfiguration; the contract default interval is one hour) |
KickerPools |
only with --enable-mock-liq-pools (addpool refuses a token without its LIQ/WIRE yield pool): one addpool per LIQ token at the contract defaults, 200 bps, a one-WIRE min_gift and a mandatory 1,000-WIRE daily ceiling |
The --wire-build-path build must carry contracts/sysio.kicker/sysio.kicker.{wasm,abi}.
At the mock pools' 10-token supply a gift accrues about 0.2 WIRE a year, so no kick pays at
the default minimum; a flow that exercises a payment lowers it with setpool and
restores it.
addpool checkpoints current LIQ supply in kickpools.supply_snapshot. Gifts use
min(supply_snapshot, current supply); addyield distributes over current supply.
Full payments checkpoint current supply, while partial payments retain the smaller
supply they priced. A zero snapshot only restarts the clock at current supply.
At the 72-hour catch-up limit, a below-minimum gift forfeits and checkpoints only
if the same interval at the same TWAP sized on current supply reaches min_gift.
Each outpost gets its own emergency stop and per-transfer syndication maximum in local mode:
| Phase | What it does |
|---|---|
EthereumOutpost |
the deploy config names panicAccount (anvil HD index 49, granted the panic role by both the outpost and the liqETH deploy), maxSyndicationPerTransfer (1,000 liqETH) and yieldDeadband (0.01 liqETH); verify-syndication-pool reads them back, with LIQETH at 18 decimals and the panic account able to pause / unpause the pool |
SolanaLiqsolSurface |
set-max-syndication --fresh right after init-global-config (a new outpost's maximum is 0, which refuses every synd), then set_panic naming the harness panic keypair (<cluster>/data/sol-panic-keypair.json), then verify-emergency-stop |
Options follow their command (wire-cluster-tool <command> --flag … — the
command comes first).
| Option | Alias | Description |
|---|---|---|
--cluster-path |
-d |
(required) directory for cluster data + cluster-config.json |
run and destroy take only --cluster-path.
| Option | Alias | Default | Description |
|---|---|---|---|
--build-path |
(required) | wire-sysio build dir (with bin/nodeop) |
|
--ethereum-path |
(required) | wire-ethereum repo root; bootstraps anvil + outpost deploy |
|
--solana-path |
(required) | wire-solana repo root; bootstraps solana-test-validator, loads all four Anchor programs at genesis, runs the init-* scripts, then deploys the OPP outpost |
|
--force |
false |
overwrite an existing cluster directory | |
--node-count |
-n |
1 |
producer node processes to launch |
--producer-count |
-p |
1 |
producer accounts to register on-chain |
--batch-operator-count |
-b |
3 |
batch operators |
--underwriter-count |
-u |
0 |
compatibility option; nonzero values are rejected |
--epoch-duration-sec |
300 |
minimum epoch duration in seconds (60 is the depot floor — sysio.epoch::setconfig rejects lower) |
|
--warmup-epochs |
1 |
epochs before an operator goes WARMUP → ACTIVE |
|
--cooldown-epochs |
1 |
epochs before an operator can deregister after COOLDOWN |
|
--solana-slots-per-epoch |
100 |
solana-test-validator --slots-per-epoch; agave's own default leaves the chain at Solana epoch 0, which the liqsol surface cannot initialize against |
|
--terminate-max-consecutive-misses |
— | consecutive missed-delivery termination threshold | |
--terminate-max-percent-misses24h |
— | 24h missed-delivery percentage termination threshold | |
--terminate-window-ms |
— | termination evaluation window in ms | |
--bind-all |
false |
bind every daemon to 0.0.0.0 instead of loopback |
|
--enable-mock-liq-pools |
false; forced on by a local create |
seed the mock LIQETH/LIQSOL yield pools during epoch zero and fund outpost custody to back all outstanding shadow | |
--enable-mock-syndication-import |
false; forced on by a local create |
import the mock bonder's LIQSOL and LIQETH positions during epoch zero, seal import, and back all mock shadow in outpost custody | |
--enable-batch-operator-underwriting |
false; forced on by a local create |
run the underwriter role on every batch operator and grant bond-row RAM. Default caps: 10.000000000 LIQSOL and 10.000000000 LIQETH. Requires liquid shadow inventory; the flag alone does not fund it. With --enable-mock-syndication-import, import 100 of each shadow per funded operator using its linked ED/EM keys and back the full minted supply on both outposts before the first epoch. batchOperatorUnderwriting configures funded count and caps. See underwriting operations. |
|
--api-count |
0 |
API nodes — non-producing nodeops meshed with bios + producers, serving /v1/chain/* and the query engine's POST /v1/query/execute; never producer_api_plugin |
|
--query-engine-read-mode |
nodeop's own (head) |
read mode of the API nodes' query engine (head or irreversible); renders read-mode only when set |
|
--query-engine-timeout-ms |
30000 |
query-timeout-ms of the API nodes' query engine — the request deadline, queue time included, which a client timeout_ms may only lower; always rendered into the API nodes' config.ini |
|
--query-engine-<limit> |
plugin default | one per other query-* limit (worker-threads, max-in-flight, max-query-bytes, max-capture-ms, max-abi-bytes, max-scan-rows, max-raw-bytes, max-memory-bytes, max-groups, max-result-rows, max-response-bytes), rendered as query-<limit> into the API nodes' config.ini only when set; passing any of the thirteen --query-engine-* flags requires --api-count ≥ 1 |
|
--bind-* |
auto | per-daemon address/port pins (--bind-anvil-port, --bind-nodeop-ports-bios-http, …); unpinned ports are auto-assigned collision-free |
|
--bind-config |
— | a BindConfig JSON file: a complete config is used verbatim (no port probing — remote addresses stay put), a partial one is merged over the resolved defaults (CLI --bind-* > file > defaults). Give a remote outpost as an IP literal: run treats every host name as local, so it checks and registers a host name's ports on this host (bind registry §6) |
|
--external-outpost-config |
— | an ExternalOutpostConfig JSON file — the deployed addresses + public keys of REMOTE ETH+SOL outposts: bootstrap the depot against them without contacting them (no local anvil/validator, no outpost deploys, batch operators synced without their daemon); see "External outpost clusters" |
|
--logging-levels-console / --logging-levels-file |
info / debug |
per-sink levels for the HARNESS's own logger. console additionally sets the level of every nodeop logger (net_plugin_impl, producer_plugin, …): libfc filters at the logger, not the sink, so one level necessarily drives both of nodeop's sinks and the console is the binding one — it is the stream the harness captures. Raising it to debug on a large cluster produces GBs of nodeop output per minute; --logging-levels-file does NOT bound that, as it never touches nodeop's logging.json. |
|
--logging-file-format |
jsonl |
log file format: text or jsonl |
|
--report-path / --report-basename |
<cluster>/reports, cluster-build |
Report output location |
create controls how the cluster's own signing keys are handled via
--signature-provider-type (default KEY):
-
KEY— keys are generated locally and embedded inline in each node's--signature-providerspec (KEY:<privateKey>). The default; byte-identical to the historical bootstrap. -
SSM— keys are published to AWS SSM Parameter Store and referenced by a REGION-LESSSSM:<secretId>spec (the depot's ssm plugin resolves the region from the AWS environment chain). Requires--signature-provider-ssmcarrying the secret-id pattern, either inline JSON (leading{) or a file path, plus anawsClusterNodeConfig(the AWS account + the regions every secret is replicated to — there is no primary region):wire-cluster-tool create … --signature-provider-type SSM \ --signature-provider-ssm '{"awsSecretIdPattern":"/wire/{cluster}/{account}/{keyType}"}'Pattern placeholders:
{cluster}(the AWS account —dev/test/prod),{account}(the operator's durable handle or a node name),{keyType}, and the optional{version}; an unknown or unfilled placeholder fails fast. Publishing runs at create time, to EVERY region, and needs AWS credentials. -
KIOD— material-less; the key lives in the local kiod wallet and specs renderKIOD:<kiod-url>.
package archives each node's full config tree (config.ini, logging.json, data
dirs) plus the cluster genesis.json into <cluster>/packages/<node>.<ext> —
one self-contained archive per node, the hand-off artifact for a multihost
environment with distinct compute and storage (for example S3/EC2 — equally GCS
or any other provider; deliberately loosely coupled to the target). Runs only on
a successfully-created, STOPPED cluster:
wire-cluster-tool -d <cluster-dir> package --package-type zip # case-insensitiveThe format is a required --package-type choice (zip today; the
ClusterPackageType enum + its per-type backend is the extension seam). Under
the default KEY provider each node's config.ini embeds its signing-key
specs, so archives are sensitive; cluster-keys.json is NEVER included.
Bootstrap a depot against Ethereum + Solana outposts that already run on their
own chains, instead of the local anvil / solana-test-validator: pass
--external-outpost-config <file> at create. The file is TRUSTED and holds
values only — the outposts' real-world deployed addresses and public keys:
{
"ethereum": {
"chainId": 560048,
"rpcUrl": "https://ethereum-hoodi-rpc.publicnode.com",
"outpostAddresses": { "OPP": "0x…", "OPPInbound": "0x…", "BAR": "0x…" },
"liqEthAddresses": { "LiqEthToken": "0x…" }
},
"solana": {
"rpcUrl": "https://api.testnet.solana.com",
"programId": "5nBtmutQLrRKBUxNfHJPDjiW5u8id6QM9Hhjg1D1g1XH"
}
}outpostAddressesis the outpost deploy record (outpost-addrs-<network>.json) and must carryOPP,OPPInboundandBAR.liqEthAddresses(the liqEth deploy record) andsolana.mints([{ code, mint, decimals }]) are optional.rpcUrlis the endpoint the operator daemons dial. Without it they dial the bind config'sanvil/solanaaddress.- The platform's
docs/ops/cluster-inputs/external-outpost-config.jsonis a complete example (Hoodi + Solana testnet).
What create does with it:
- Never contacts an outpost. No anvil or solana-test-validator starts, nothing is deployed and no endpoint is probed. The harness's own outpost clients refuse to run in this mode.
- Builds the daemon artifacts from source. The ABI files come from the
wire-ethereum build and the
liqsol_coreIDL from the wire-solana build, with the config's addresses and program id overlaid — the same step a local cluster runs. - Syncs the batch operators without their daemon. Each batch-operator node
starts without
batch_operator_plugin(so without an outpost client), syncs the depot chain and is stopped with the rest of the cluster. Itsstart.shcarries the full daemon args, so the deployed node runs the plugin. - Gates success on the depot's head block advancing, an outbound envelope
being queued for every registered outpost, and every batch-operator node having
synced — not on epoch distribution (there is no local chain to advance). A LOCAL
cluster instead gates on
sysio.epoch::current_epoch_indexpassing the bootstrap epoch. A remoteanvil/solanabind address WITHOUT--external-outpost-configfails fast.
Swap-underwriter daemon counts default to zero in all deployment modes. Nonzero values are rejected. Manual syndication bond providers can submit actions directly; automatic underwriting runs in the batch operators only when explicitly enabled and funded, as described in the underwriting setup.
Every bootstrap — create and every flow, local or --external-outpost-config —
writes <cluster-path>/wire-opreg-bootstrap.json right after
sysio.epoch::schbatchgps: the cluster's build label and awsAccount, each
bootstrapped operator's WIRE account, operator type (PRODUCER / BATCH /
UNDERWRITER) and public keys with their key types (K1 / BLS / EM /
ED), the initial batch-operator groups with the first active group, and the
depot's epoch duration. awsAccount is awsClusterNodeConfig.account (or
null); the cluster-tool writes label: null and the
create-wire-platform-cluster workflow sets it after a successful create. It
holds public data only. The
verify-wire-opreg-bootstrap step checks it against sysio.opreg in the same
bootstrap. Its JSON Schema, an example and the field reference are in
docs/outpost/; the TypeScript codec is
WireOpregBootstrapSchemaCodec (@wireio/cluster-tool-shared).
create-external-config clones a CREATED, STOPPED local cluster into a fresh,
deployable directory with a different (typically remote) BindConfig merged in,
and emits a self-described external-cluster-config.json:
wire-cluster-tool create-external-config \
--local-cluster-path /opt/wire/testnet-local \
--external-cluster-path /opt/wire/testnet \
--external-bind-config ~/testnet-bind-config.jsonThe five stages run as Report steps: Validate (the external BindConfig is
topology-compatible — one bind entry per node/role, every operator account
present, no duplicate ports, sane solana dynamic range; fails fast before any
write), Clone (copy the tree, excluding *.pid / logs/ / reports/,
preserving cluster-keys.json's 0600), Rebind (re-render cluster-config.json,
genesis.json, every node's config.ini / logging.json, and
cluster-state.json from the merged, external-rooted model — never text-patched),
Emit (external-cluster-config.json), and Verify (scan the tree for any
stale local port + round-trip the emitted JSON). The emitted config carries the
external bindings, each operator account's key providers matching the source
cluster's provider type (KEY inline for a KEY cluster, SSM
awsSecretId references — no plaintext — for an SSM cluster, material-less
KIOD for a KIOD cluster), the depot epochDurationSec + genesis path, and
the ethereum/solana outpost references — fully self-described, so the external
directory can then be packaged and deployed on another host (create →
create-external-config → package).
A single cluster's lifecycle — create with keys in AWS SSM, then
create-external-config on that same cluster:
# 1. Create a cluster whose signing keys are PUBLISHED to AWS SSM (not inline).
# Publishing runs at create time and needs AWS credentials in the environment.
wire-cluster-tool create \
--cluster-path /opt/wire/testnet-local \
--build-path /opt/wire-sysio/build/release \
--ethereum-path /opt/wire-ethereum \
--solana-path /opt/wire-solana \
--signature-provider-type SSM \
--signature-provider-ssm '{"awsSecretIdPattern":"/wire/{cluster}/{account}/{keyType}"}'
# Each generated key is PutParameter'd to SSM under the rendered id, in EVERY
# region of awsClusterNodeConfig.regions — e.g.
# /wire/test/batchop.a/K1 ({cluster} = awsClusterNodeConfig.account)
# — and node/daemon --signature-provider specs render a region-less SSM:<id>.
# 2. Stop the cluster, then clone it into a deployable external directory with a
# remote BindConfig merged in, emitting its self-described external config.
wire-cluster-tool create-external-config \
--local-cluster-path /opt/wire/testnet-local \
--external-cluster-path /opt/wire/testnet \
--external-bind-config ~/testnet-bind-config.json
# Because the source cluster used SSM, external-cluster-config.json carries SSM
# providers ({awsRegions, awsSecretId} — the SAME ids create published,
# reconstructed from the pattern; awsRegions is informational, for operators and
# disaster recovery) with NO plaintext keys. (A KEY cluster emits
# inline KEY providers; a KIOD cluster, material-less KIOD providers.)create-api-node emits a self-contained API node — one that serves the chain's
HTTP API and syncs from peers, with no cluster tree around it and no signing
keys. It writes exactly two files into --output-path and touches nothing else:
wire-cluster-tool create-api-node \
--output-path /opt/wire/api-node \
--http-server-address 0.0.0.0:8888 \
--p2p-peer-address peer-a.example:9876 \
--p2p-peer-address peer-b.example:9876 \
--genesis-json /opt/wire/genesis.json| Emitted | What it is |
|---|---|
config.ini |
the nodeop config — chain-state-db-size-mb, the finality-status / account-query / HTTP / vote-thread tuning, the endpoint, one p2p-peer-address line per peer, and the plugin set (net_plugin, chain_api_plugin, trace_api_plugin, query_engine_plugin), plus query-timeout-ms (default 30000) and any other set --query-engine-* value as its read-mode / query-* line |
start.sh |
mode 0755; execs nodeop with --config-dir "$NODE_DIR", --data-dir "$NODE_DIR/data", and --genesis-json when a genesis was supplied |
genesis.json |
only when --genesis-json was passed — the file is COPIED next to the script, so the emitted directory stays self-contained |
The remaining flags all default through one resolution path, so the CLI and a
programmatic caller agree: --chain-state-db-size-mb (1024),
--transaction-finality-status-max-storage-size-gb (10 — supplying it is what
ENABLES the finality-status feature), --enable-account-queries (true; disable
with --enable-account-queries=false), --http-max-in-flight-requests (100),
--http-threads (4), --vote-threads (4 — without it a non-producing node
neither processes nor relays finalizer votes), --agent-name (wire-api-node), and
[--query-engine-read-mode <head|irreversible>] [--query-engine-<limit> <n>]...
(--query-engine-timeout-ms 30000; every other one unset — an omitted one
renders no line, leaving nodeop's head read mode and the plugin's own limit
defaults in force).
Flag names follow nodeop's own option names wherever one exists, so the ini
line and the flag that produced it read the same — except the --query-engine-*
flags, which are the spellings shared with create and render as nodeop's
read-mode / query-<limit>.
start.sh resolves the wire-sysio install prefix exactly as a cluster daemon's
does — an explicit WIRE_PREFIX_PATH, else the parent of a nodeop on PATH,
else WIRE_BUILD_PATH — and fails loudly naming all three if none resolves. It
carries no $CLUSTER_DIR, no cluster-config.json probe, and no inline key: a
standalone API node signs nothing. --data-dir is not pre-created; nodeop makes
it on first start, so pointing the node at an existing data dir or a snapshot is
just a matter of populating it.
--http-server-address and every --p2p-peer-address are used VERBATIM — they
name an arbitrary deployment host, so nothing is bound, probed, or claimed
against the local bind registry.
Full walkthrough: docs/create-api-node-guide.md.
| Variable | Used by | Description |
|---|---|---|
WIRE_BUILD_PATH |
flows | wire-sysio build dir (with bin/nodeop) |
WIRE_ETH_PATH |
flows | wire-ethereum repo root |
WIRE_SOLANA_PATH |
flows | wire-solana repo root |
WIRE_CLUSTER_PATH |
flows | cluster data dir (optional; temp dir if unset) |
LOG_LEVEL |
everything | harness log level: debug | info | warn | error (default info) |
The harness manages chain child processes with child_process.spawn + tree-kill
(not pm2). On startup it pkills any stray nodeop / kiod / anvil /
solana-test-validator, registers exit handlers for cleanup, and (when a cluster
dir is set) tees per-process and combined logs to <cluster-path>/logs/.
processes/—ProcessManager,WIREChainManager(nodeop+kiod),AnvilManager,SolanaValidatorManager.cluster/—ClusterManagerorchestrates the create → bootstrap → start lifecycle.clients/—Clio,WIREClient,ETHClient(ethers),SOLClient(@solana/web3.js).bootstrap/+cluster/ETHBootstrapper.ts/bootstrap/SOLBootstrap.ts— chain initialization and test-cluster custody seeding.
OPP debugging artifacts (the raw envelope bytes each side saw) land under
<cluster-path>/data/opp-debugging/.
See CLAUDE.md and STYLE.md. Highlights:
- Prettier: no semicolons, no trailing commas, double quotes, 2-space indent,
arrow parens
avoid. - Every new/modified exported symbol ships unit tests in the same change.
- No
src/inimport/exportspecifiers — use package aliases or barrels.
Running two or more flows (or clusters) concurrently on one host is supported, but only because each of the following shared-state hazards is explicitly handled. Keep them in mind when touching any of these areas:
- Port selection vs port binding.
BindConfigProvider.resolvepicks free ports, but a picked port is not bound until its daemon spawns (possibly minutes later). A second process resolving in that window would happily re-pick it. Every resolving process therefore registers the ports it resolved in/tmp/wire-platform-bind-config/<pid>.bind-config.jsonbefore releasing the host-global port lock; later resolvers read every LIVE registration into their exclusion set (dead-pid files are reaped —kill(pid, 0), plus a recycled-pid guard comparing/proc/<pid>/cmdlinewith the executable the file records). Files are removed on process exit;findAvailablereads the registry but never writes it. The file format and lock are a language-neutral protocol that non-Node clients (wire-sysio's Python test-port lease) implement too:docs/bind-registry/. - Hardhat deploy shares the repo's compile cache. The Ethereum outpost
deploy (
npx hardhat run src/scripts/deployLocal.ts) compiles-if-stale into<wire-ethereum>/artifacts/+<wire-ethereum>/cache/, which are checkout-wide. Hardhat has no cross-process build lock, and two concurrent compiles corrupt those dirs for every later run. The harness serializes the hardhat invocation with a host-global file lock (EthereumOutpostBootstrapper.HardhatDeployLockPath, long-hold retry options). When artifacts are already fresh the hold is just the deploy (~30–60s); a colliding pair serializes its two deploys. - Ethereum deploy state is per-cluster, never repo-shared. Deploy configs
and address files (
outpost-addrs.json,liqeth-addrs.json, …) live under<cluster>/data/ethereum-deployments/(ClusterConfigProvider.ethereumDeploymentsPath), anddeployLocal.tsis pointed there viaWIRE_ETH_DEPLOYMENTS_PATH. The pre-rewrite location —<wire-ethereum>/.local/deployments/, shared by every run — let one run's deploy wipe another's configs and address files mid-deploy (2026-07-02 pair-1 incident: the "stale artifact" clear of run B deleted the address file run A was about to read). Never read outpost addresses from the repo path; go throughClusterConfigProvider.ethereumDeploymentsPath/EthereumCollateralTool.loadOutpostAddresses(deploymentsPath). - Hardhat ABI artifacts are read-shared. ABIs load from
<wire-ethereum>/artifacts/(read-only after compile, which the deploy lock serializes) — that is fine to share; only deploy state had to move. - Solana state is already per-cluster. The SOL bootstrap persists its
keypair + mock-mints under
<cluster>/data/, and programs load per validator via--bpf-program— no shared writes. - Process cleanup is pid-targeted, never name-targeted.
ProcessManagersweeps only pids recorded in THIS cluster's pidfiles and signals only its own registered pids on exit (with a/procrecycled-pid guard). A host-widepkill nodeopfrom any tooling would kill every parallel run's nodes — see the incident note inProcessManager.ts.
Flows that need the first bonder set enableMockSyndicationImport: true in
Scenario.defaults. The MockSyndicationImport phase runs after
SyndicationConfig and any MockLiqPools seed, before EpochBootstrap.
It generates an unlinked ED/EM bonder, imports each position in its own Step,
and calls importdone. Steps.registry.readMockSyndicationBonder(ctx) reloads
its keys from the durable mock-syndication-bonder label; its Solana keypair
also follows the sol-<label>-keypair.json convention. No WIRE account or
authex link is created: positions stay parked until the flow links the keys.
Either mock flag enables MockShadowBackingSolana and
MockShadowBackingEthereum. Each reads custody and the depot's full outstanding
shadow after all seeds. If custody is short, it acquires and donates only the
difference between outstanding shadow and current custody; if already covered, the write Steps move no funds. A final
verify Step re-reads both sides. Solana airdrop, deposit and donation and
Ethereum deposit and donation are separate Report Steps. The default bootstrap
imports no positions and performs none of these backing writes.
Ethereum also seeds syndicatedPrincipal in depot units through
OutpostManager.execute(pool, initializeSyndication(...)), signed by the local
deployer who holds the manager's configuration role. This separate Step preserves
the current chain code, token code, precision and deadband. The final verification
requires principal to equal the backing and custody to cover it, so the epoch
relay's realizeYield() does not count the imported backing as new yield.