Federated multisig gateway between the VIZ blockchain and external networks
(TON live, Solana in progress). Lock VIZ on VIZ, mint wrapped VIZ (wVIZ) on
the remote chain — and the reverse. Each remote chain is a pluggable adapter;
adding a new network requires no changes to the trust-critical core.
Secured by an M-of-N signer federation. Default and recommended: 5-of-7.
The full research, security math, and rollout plan are in
VIZ-Gateway-Research-and-Plan.md.
To stand it up solo on testnet, follow RUNBOOK.md.
A custody multisig trades theft-resistance against freeze-resistance. 5-of-7
is the BFT-clean point for f = 2: an attacker needs 5 of 7 independent keys
to steal, and the bridge keeps running with 2 operators offline. Reproduce
the numbers:
node tools/threshold-calc.mjs
packages/common trust-critical core: canonical mapping, idempotency, caps, threshold
packages/viz-watcher follows VIZ irreversible head, detects deposits (peg-in)
packages/gram-watcher follows TON finality, detects wVIZ burns (peg-out)
packages/signer the only component with keys; validates + signs (one per operator)
packages/coordinator UNTRUSTED; collects approvals, broadcasts once threshold met
packages/recon enforces locked-VIZ == circulating-wVIZ; auto-pauses on drift
packages/solana-watcher Solana remote-chain adapter + watcher (read + mint/burn write-path implemented; live cutover pending)
contracts/ton/ multisig-v2 + Jetton deploy scripts (dry-run by default) + setup
contracts/solana/ Token-2022 wVIZ mint + SPL multisig deploy script (dry-run by default)
setup-viz/ one-time VIZ gateway account setup (3-of-4 guardian master)
tools/threshold-calc.mjs the federation-size numbers
VIZ side is free; peg-out is free. The only fee is on peg-in, to cover TON mint gas and a small margin.
- Base fee = max(static gas-covering floor, 0.20%) — the floor is a fixed gas-derived constant (
mintGasTon × FEE_GRAM_VIZ_PER_TON × margin= 0.06 × 500 × 1.5 ≈ 45 VIZ), computed once at config load; 0.20% only overtakes the floor above ~22,500 VIZ. - First-time recipients pay an extra wallet-deploy surcharge (
walletDeployGasTon × FEE_GRAM_VIZ_PER_TON × margin= 0.05 × 500 × 1.5 ≈ 37.5 VIZ) so the gateway isn't out-of-pocket for on-chain jetton-wallet rent. - No minimum — but deposits below the minimum that can't cover the fee are refunded (minus a small refund fee to cover the return transfer gas). Dust ≤ refund fee is retained as gateway surplus.
- Invalid/no-memo peg-in deposits are auto-refunded to the originating VIZ account.
- Peg-out to unusable destination — if a peg-out memo names a VIZ account that is empty, malformed, or non-existent, the wVIZ is auto-returned to the original TON sender minus
refundFeeMilliViz(currently 5 VIZ). If the peg-out amount ≤ 5 VIZ, it is retained as gateway surplus. This applies to TON/GRAM peg-outs only.
Config: FEE_FLOOR_MILLI_VIZ, FEE_BPS, GRAM_MINT_GAS_TON, GRAM_WALLET_DEPLOY_GAS_TON, FEE_MARGIN, FEE_GRAM_VIZ_PER_TON (default 500), REFUND_FEE_MILLI_VIZ (see .env.example). GRAM fees are fully static — no live price oracle; the GRAM floor and activation surcharge are derived once from these constants.
Early but partly live. The trust-critical core (packages/common) is
implemented and typechecks. Both read paths are wired:
- VIZ (
VizJsChain) — verified againsthttps://node.viz.cx: reads the irreversible head (~14-block lag), detects and parses real deposits, reads balances. - TON (
GramHttpChain) — verified against toncenter: reads masterchain seqno and Jetton total supply live; thetransfer_notificationparser used for peg-out detection round-trips for both payload encodings (tools/gram-notification-spike.cjs).
The reconciliation job is wired (recon): it computes locked-VIZ vs
circulating-wVIZ from both live adapters and, on drift, trips the shared pause
(verified — mismatched endpoints trip CRITICAL + non-zero exit; a matched 0/0
reports OK). Run a one-shot check with RECON_ONCE=1.
Persistence + shared pause are wired (SqliteGatewayStore, node:sqlite):
the idempotency ledger and a global pause flag live in one SQLite file shared
across all processes. Verified across separate processes — a deposit claimed by
one process is rejected by another, and a pause tripped by recon is honored by
the watchers (which stop scanning) and the signer (which returns HTTP 423).
Clearing the pause is a deliberate unpause(), never automatic.
Write-path signing is implemented and verified (KeyedSigner, vizSign,
tonSign): operators validate the proposal against the action they derived
independently, then sign. Verified through the real signer
(tools/writepaths-spike.cjs): two operators' independent VIZ partial
signatures merge to the same set as a single all-keys signing; the VIZ proposal
builder derives live TaPoS from node.viz.cx; tampered proposals are rejected;
the TON ed25519 mint approval verifies and a tampered order hash fails.
Orchestration is wired (coordinator → Orchestrator): a watcher detects an
event and POSTs it to the coordinator, which builds the one shared proposal, asks
each signer to validate+sign, and broadcasts once the threshold is met. Verified
(tools/orchestration-spike.cjs) to complete a peg at 1-of-1 and 2-of-3
with real signatures, stop collecting at threshold, and refuse under-threshold or
rogue signers. Defaults to 1-of-1 so a single operator (you) can run a
working bridge solo; grow by adding signer keys and raising the threshold — no
redeploy.
Operators need no VIZ validator status — an operator is just a VIZ secp256k1 +
TON ed25519 keypair the existing operators chose to trust. The active set governs
itself: any operator runs rotate propose with the new set + threshold, others
rotate co-sign, and once T partials are collected rotate broadcast viz rewrites
the gateway's VIZ active authority. No guardian on the normal path; the
MASTER_GUARDIANS council is last-resort recovery only.
You can self-remove up to N − T operators while keeping liveness; below that,
recovery falls to the guardians. VIZ partials must be collected and broadcast within
one hour (the chain's TaPoS window), so a rotation is a short coordinated ceremony.
The public federation.json (operator ids + pubkeys) is committable; per-operator
secrets stay in each operator's .env. The TON side is on-chain and asynchronous: rotate:gram submit-gram posts an
update_multisig_params order, each current signer runs rotate:gram approve-gram,
and rotate:gram status confirms once the multisig signer set has changed.
Solana is implemented (packages/solana-watcher, contracts/solana) as the
second remote chain via the shared RemoteChain interface: the read adapter
(finalized slot, supply, burn detection), the peg-out burn program
(gateway-deposit, slot-paginated fail-closed scan), and the peg-in mint
write-path (durable-nonce SPL mint proposal → per-operator ed25519 partials →
merge + broadcast) are all built and offline-verified in npm run verify
(solana-*-spike.cjs). The Token-2022 + SPL-multisig deploy script dry-runs.
What remains is the live cutover (Phase 2 in docs/plan-mainnet-deploy.md):
deploy the wVIZ mint + gateway-deposit program, fund the submitter, and run the
live proofs (tools/solana-devnet-proof.cjs, tools/solana-pegout-proof.cjs).
Mainnet soft-launched TON-only to halve the recon surface.
What still needs live contracts (not stubbed logic — missing on-chain targets):
the actual broadcastRelease (needs a funded gateway account on VIZ) and the
remote submitMint (TON: on-chain multisig-v2 order; Solana: SPL-multisig mint).
The 24h cap counters are still per-process (the pause they trigger is shared).
Nothing here moves real funds yet.
npm install
npm run build
npm run threshold # prints the 5-of-7 analysis
Two offline suites, both run in CI (.github/workflows/ci.yml) and needing no chain RPC:
npm run verify # scenario spikes (tools/*-spike.cjs): federation, canonical, recon, caps, ...
npm run test:unit # node:test unit suites in packages/*/test
test:unit compiles those suites through tsconfig.test.json to dist-test/
first — the sources use extensionless imports, so node --test can't execute the .ts files
directly — then runs the compiled *.test.js. It builds the workspace first, since the tests
resolve @gateway/* against each package's dist/.
Each trusted operator:
cp .env.example .env # fill in YOUR secrets (VIZ_SIGNING_WIF, GRAM_SIGNER_MNEMONIC)
docker compose up --build
That starts this operator's watchers + signer + recon. The public federation
manifest (the 7 public keys, gateway addresses, threshold, caps) is shared and
committable; the per-operator secrets in .env are never shared or committed.
Run the coordinator (any one operator, or rotate):
docker compose -f docker-compose.coordinator.yml up --build
Each operator independently verifies the source-chain event against its own nodes and signs only the deterministic canonical action it recomputes locally. The coordinator holds no keys, so compromising it cannot cause theft — only a liveness stall, which any operator can resolve by self-hosting one. Funds move only after source-chain finality (VIZ irreversibility / TON masterchain), and a reconciliation job halts everything on any peg drift. See the plan doc for the full threat table.