Last reviewed: 2026-08-26
The deploy procedure for the reputation oracle, plus the gates that must hold before it runs. Tooling and runbook only — the decision to launch is not made here, and is blocked on the 90-day probe window (#786) and the Soroban Security Audit Bank audit (#716/#717).
Run
npx tsx scripts/mainnet-preflight.mtsfirst. It checks every gate that can be checked mechanically and prints the rest as explicit sign-offs. Do not work down this document from memory.
The product's claim is that an anchor's score is independently verifiable on-chain. That claim is worth less than nothing if the contract launches with an empty or thin dataset: a confident-looking score derived from four samples is more misleading than no score at all. "Never launch an empty credit bureau" is the reason for the 90-day window, not a formality.
Two failures found while building this checklist show why each gate is mechanical rather than remembered:
- The testnet contract's registry is empty, so every score read returns a zeroed tuple. Nothing surfaced that until something read it (#723).
- The testnet bytecode predates the authorization fixes in #907, so the
unauthenticated
set_corridor_metricspath is still live there. The fix was merged and looked done (#913).
Both would have been repeated on mainnet.
| Gate | Checked by |
|---|---|
| Contract tests pass, wasm builds | preflight (cargo test, wasm build) |
STELLAR_NETWORK=mainnet set explicitly |
preflight — no default exists (#912) |
MAINNET_DEPLOYER_KEY, PUBLISHER_SECRET, DATABASE_URL present |
preflight |
| ≥90 days of probe samples | preflight (queries probe_samples) |
| Deployed bytecode matches source | preflight (entrypoint probe) |
| Admin ≠ upgrade admin | preflight (getOracleGovernance) |
| Publisher ≠ admin | preflight (Keypair.fromSecret) |
| Anchor registry seeded | preflight (list_anchors) |
| Security audit complete | manual — #716/#717 |
Keys in HSM/KMS per docs/SECURITY.md |
manual — verify custody out of band |
| Rollback rehearsed | manual — section 5 |
Three keys govern the contract. Each is a distinct Stellar account and none may be held by the same keypair as another.
| Key | What it controls | Allowed in a deployment environment |
|---|---|---|
| Admin | Anchor registration, publisher authorization, and admin rotation (propose_admin/accept_admin). Operator-level changes to what the oracle covers and who can write to it. |
No. Kept offline; used from an operator's machine only. |
| Upgrade admin | WASM upgrades via init_upgrade/upgrade. Can replace the contract entirely. |
No. Kept offline; separate multisig from the admin. |
| Publisher | Submits outcomes and rates (submit_outcome, set_corridor_metrics, publish_corridor_rate). No authority over the registry, other publishers, or the contract itself. |
Yes. PUBLISHER_SECRET in the Vercel production environment holds this key and nothing else. |
The admin key carries considerably more authority than the publisher: it can authorize or revoke any writer, begin an admin rotation, and alter what anchors the oracle covers. A serverless runtime is a wide attack surface (env-var leak, SSRF, a compromised dependency, a stray log line). Publisher-level exposure is recoverable; admin-level exposure is not, because the admin can revoke and replace the publisher before the situation is understood.
On testnet the admin and publisher share an account for convenience. That
shortcut must not carry over. The preflight check Publisher is not the contract admin enforces this mechanically: it derives the public key from
PUBLISHER_SECRET and fails if it matches admin() read from the deployed
contract.
export STELLAR_NETWORK=mainnet # required; the tooling will not guess
# 1. Upload the wasm. Dry-run first, always.
npx tsx --tsconfig tsconfig.scripts.json scripts/deploy-oracle-mainnet.ts \
--mode upload-wasm --dry-run
npx tsx --tsconfig tsconfig.scripts.json scripts/deploy-oracle-mainnet.ts \
--mode upload-wasm --live
# → records the wasm hash
# 2. Deploy an instance from that hash.
npx tsx --tsconfig tsconfig.scripts.json scripts/deploy-oracle-mainnet.ts \
--mode deploy-contract --wasm-hash <64-hex> --liveRecord the resulting contract id in .deployments/mainnet.json, matching the
shape of testnet.json. This is the source of truth — lib/oracle/deployment.ts
reads it, so nothing else needs editing (#723).
Do this before registering a single anchor. An unbound upgrade admin is an open door.
# Operational admin: a multisig account, not a single key.
# Upgrade admin: a DIFFERENT multisig account.Both are Address values, so multisig requires no contract change —
require_auth() delegates the threshold check to the host. Use
propose_admin → accept_admin for any later rotation, never a direct
overwrite: the two-step handoff makes it impossible to hand authority to an
address that cannot sign.
Verify, do not assume:
npx tsx scripts/verify-oracle-read.mtsIt fails loudly when one account holds both roles.
npx tsx --tsconfig tsconfig.scripts.json scripts/init-oracle-registry.ts # anchors
npx tsx scripts/verify-oracle-read.mts # confirmPublishing historical outcomes is the publisher's job, not a separate migration:
packages/publisher reads outcome_log and submits rows that are reconciled but
unpublished. Point it at mainnet and let it drain, rather than writing a bespoke
backfill that bypasses the same-path guarantees (per-row tx hashes and resumable
partial batches, #909).
Confirm before announcing anything:
list_anchorsreturns the expected setget_score_for_corridorreturns non-null for a seeded pair — anullmeans zero samples, which is what an empty registry looks like (#723)contract_versionis non-zero
An upgrade is not undoable by re-running the deploy. upgrade() swaps the
WASM in place and bumps the stored version; there is no downgrade path, and
storage is preserved across the swap, so a bad migration leaves bad state behind.
Therefore:
- Keep the previous wasm hash. Rolling back means
upgrade()to the old hash, which needs that hash on hand and the upgrade admin's signatures. - Test the rollback on testnet first, with the same multisig shape. A rollback rehearsed only on paper is not a rollback plan.
- Data damage is separate from code damage. If a migration wrote wrong values, reverting the code does not revert them. Migrations are admin-gated (#907) and idempotent, but idempotent is not reversible.
- A compromised admin key is not a rollback scenario, it is an incident. The upgrade admin can replace the contract with anything; that is why it must be a separate multisig from the operational admin.
/api/publisher/healthreports the durable last-publish time; the reputation cron alerts when rows are pending and nothing has published for an hour (#910).- The nightly
oracle-readjob reads the contract warn-only and reports registry, custody and version skew (#723, #913). - Watch for the deployed-bytecode-predates-source warning after any source
change to
contracts/reputation— that is the signal that a merge has not reached the chain.
docs/ORACLE_SPEC.md— contract interface and custody modeldocs/SECURITY.md— key-handling requirementsdocs/ANCHOR_REPUTATION.md— the published scoring formula