Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

wire-tools-ts

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.

What it exercises

The OPP message flow spans three chains:

  • WIRE depot (nodeop + kiod) — system contracts sysio.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, plus liqsol_token / transfer_hook / validator_leaderboard), with the liqsol surface stood up by wire-solana's own anchor run init-* scripts.

Where this repo fits in the platform

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.

Prerequisites

Toolchain

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.

Install snippets

# ── 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.0

Verify:

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.0

Built dependency chains

The 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:programs

Install & build this repo

pnpm install      # also auto-links sibling @wireio/* packages via .pnpmfile.cjs
pnpm build        # tsc -b across all packages

Packages

pnpm 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:*.

LIQ settlement audit — syndication-underwriting

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.

Removed reserve and swap flows

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.

Running flows

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

Option A — the run-flow.mjs helper (THE canonical way)

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.

Option B — pnpm --filter directly (what Option A runs under the hood)

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 test

Commit checks

Run 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.

Monitoring a live flow run

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 | grep instead 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.mjs themselves.
  • Parallel runs need disjoint --cluster-path dirs; port allocation is collision-proof via BindConfig, so parallelism is bounded only by host resources.

Service ports and automatic client ports

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.

Launching a persistent test cluster

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.

Two ways to start a daemon — and how they relate

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_DIR and $CLUSTER_DIR derive from the script's own location (override with WIRE_CLUSTER_DIR). The wire-sysio install prefix is WIRE_PREFIX_PATH, RESOLVED rather than demanded: an explicit WIRE_PREFIX_PATH wins, else the parent of a nodeop found on PATH (so a host with wire-sysio installed needs no configuration at all), else WIRE_BUILD_PATH for a tree still pointed at a build dir — and it fails loudly naming all three if none resolves. WIRE_ETH_PATH / WIRE_SOLANA_PATH are asserted outright where referenced, since nothing on PATH can imply them. PATH-resolved binaries indirect through WIRE_ANVIL_BIN / WIRE_SOLANA_TEST_VALIDATOR_BIN / WIRE_NODE_BIN, falling back to command -v. The WIRE_ prefix is deliberate — unprefixed names collide with ambient ones (NODE_BIN is 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-abis test is additionally PREDICATE-GATED: the flag belongs to trace_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; use run when other clusters share the machine.

Invoke as ./start.sh — the emitted scripts are mode 0755.

Under the default KEY signature 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 carry SSM:<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-001

create 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-001

Underwriting, syndication and the emergency stop

Automatic 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.

The LIQ kicker

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

Common options (every command)

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.

create options

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

Cluster signature providers

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-provider spec (KEY:<privateKey>). The default; byte-identical to the historical bootstrap.

  • SSM — keys are published to AWS SSM Parameter Store and referenced by a REGION-LESS SSM:<secretId> spec (the depot's ssm plugin resolves the region from the AWS environment chain). Requires --signature-provider-ssm carrying the secret-id pattern, either inline JSON (leading {) or a file path, plus an awsClusterNodeConfig (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 render KIOD:<kiod-url>.

Node packaging

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-insensitive

The 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.

External outpost clusters

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"
  }
}
  • outpostAddresses is the outpost deploy record (outpost-addrs-<network>.json) and must carry OPP, OPPInbound and BAR. liqEthAddresses (the liqEth deploy record) and solana.mints ([{ code, mint, decimals }]) are optional.
  • rpcUrl is the endpoint the operator daemons dial. Without it they dial the bind config's anvil / solana address.
  • The platform's docs/ops/cluster-inputs/external-outpost-config.json is 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_core IDL 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. Its start.sh carries 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_index passing the bootstrap epoch. A remote anvil/solana bind address WITHOUT --external-outpost-config fails 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.

Bootstrapped operator roster (wire-opreg-bootstrap.json)

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).

Exporting a deployable external config

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.json

The 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).

End-to-end: an SSM-keyed cluster → deployable external config

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.)

Standalone API nodes

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.

Environment variables

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)

Architecture

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/ — ClusterManager orchestrates 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/.

Code style & conventions

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/ in import/export specifiers — use package aliases or barrels.

Appendix

Useful Info

Parallel Process Caveats

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.resolve picks 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.json before 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>/cmdline with the executable the file records). Files are removed on process exit; findAvailable reads 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), and deployLocal.ts is pointed there via WIRE_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 through ClusterConfigProvider.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. ProcessManager sweeps only pids recorded in THIS cluster's pidfiles and signals only its own registered pids on exit (with a /proc recycled-pid guard). A host-wide pkill nodeop from any tooling would kill every parallel run's nodes — see the incident note in ProcessManager.ts.

Mock syndication import and custody backing

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages