From 42e5e57bd3268209c58551328bbc3b7cd5009fdd Mon Sep 17 00:00:00 2001 From: Bushra Date: Wed, 30 Sep 2026 14:34:50 +0100 Subject: [PATCH] feat(sdk): fail strict mainnet readiness on deployment drift Readiness checked operator-supplied expectations, so `mainnet:ready --strict` and `mainnet:verify` could pass against a drifted deployment: wrong contract, testnet passphrase, old WASM, or a swapped escrow SAC all still went green. - Commit the mainnet deployment as data: packages/sdk/mainnet-artifacts.json is the source of truth, parsed by parseMainnetManifest with a closed schema. - Compare the live deployment against the manifest through the read-only client: contractId, networkPassphrase, wasmHash, tokenContract. Mismatch or unreadable blocks; the report names the field, fingerprints the passphrase, and redacts anything shaped like a secret key. - Replay a recorded snapshot with no RPC (`--fixture`), committed under packages/sdk/fixtures and exercised in CI, so each mismatch field is proven to fail without touching mainnet. - mainnet-ready/mainnet-verify exit non-zero on drift and need no secret keys; balance checks take public keys. Refs #383 --- .env.example | 5 + .github/workflows/coverage.yml | 5 + docs/DEPLOY.md | 26 +- packages/sdk/README.md | 53 ++ packages/sdk/fixtures/mainnet-readiness.json | 20 + packages/sdk/mainnet-artifacts.json | 16 + packages/sdk/scripts/mainnet-ready.ts | 150 +++- packages/sdk/scripts/mainnet-verify.ts | 122 ++- packages/sdk/src/errors.ts | 53 ++ packages/sdk/src/index.ts | 28 + packages/sdk/src/mainnet-artifacts.ts | 246 +++++- packages/sdk/src/mainnet-manifest.ts | 149 ++++ packages/sdk/src/mainnet-readiness.test.ts | 692 +++++++++++++-- packages/sdk/src/mainnet-readiness.ts | 879 +++++++++++++++---- packages/sdk/src/public-api-snapshot.test.ts | 17 + 15 files changed, 2133 insertions(+), 328 deletions(-) create mode 100644 packages/sdk/fixtures/mainnet-readiness.json create mode 100644 packages/sdk/mainnet-artifacts.json create mode 100644 packages/sdk/src/mainnet-manifest.ts diff --git a/.env.example b/.env.example index 91e22de7..64d3c1ce 100644 --- a/.env.example +++ b/.env.example @@ -59,5 +59,10 @@ X402_APPRAISAL_URL= # OPERATOR_SECRET=S… # signs deploy + settle # BIDDER_SECRET=S… # signs micro commit; defaults to OPERATOR_SECRET if unset # MAINNET_DRY_RUN=1 +# --- Mainnet readiness (read-only, no secret keys) --- +# OPERATOR_PUBLIC_KEY=G… # balance review only +# KEEPER_PUBLIC_KEY=G… # balance review only +# BIDDER_PUBLIC_KEY=G… # balance review only +# MAINNET_READER_PUBKEY=G… # read-only simulation source (default: project account) # MICRO_BID_STROOPS= # MICRO_ESCROW_STROOPS= diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 21f2f915..1ea4a3df 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -39,5 +39,10 @@ jobs: - name: Validate error normalization run: pnpm errors:normalize:test && pnpm errors:normalize:check + - name: Mainnet readiness matches the committed manifest (recorded fixture, no RPC, no secrets) + run: | + pnpm --filter @sub-rosa/sdk exec tsx scripts/mainnet-ready.ts --strict --fixture + pnpm --filter @sub-rosa/sdk exec tsx scripts/mainnet-verify.ts --fixture + - name: Run coverage gate run: pnpm coverage:test diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 819854b8..3cf91083 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -192,6 +192,7 @@ Deploying new contract round? ```bash pnpm mainnet:ready -- --strict # consolidated read-only readiness +pnpm mainnet:ready -- --fixture packages/sdk/fixtures/mainnet-readiness.json --strict # CI-safe: recorded deployment, no mainnet RPC pnpm mainnet:verify # read-only — no secrets pnpm mainnet:micro # dry-run checklist MAINNET_CONFIRM=SUB_ROSA_MAINNET OPERATOR_SECRET=S… BIDDER_SECRET=S… \ @@ -200,11 +201,34 @@ MAINNET_CONFIRM=SUB_ROSA_MAINNET KEEPER_SECRET=S… ROUND_CONTRACT_ID=C… \ pnpm mainnet:settle # keeper settle (requires readiness + confirm) ``` +### What readiness pins + +`pnpm mainnet:ready` and `pnpm mainnet:verify` load the committed manifest +(`packages/sdk/mainnet-artifacts.json`) and compare the live deployment with it +through the read-only client. Four fields must agree, and each one blocks on its +own: + +| manifest field | live value it is compared against | +| ------------------- | --------------------------------------------------- | +| `contractId` | the contract the client is bound to | +| `networkPassphrase` | the passphrase the RPC reports | +| `wasmHash` | the executable hash in the contract ledger entry | +| `tokenContract` | `usdc` in the deployed `GlobalConfig` (escrow SAC) | + +A field that cannot be read blocks too, and the report names the disagreeing +field. Passphrases are printed as a short fingerprint, never in full, so logs +stay shareable. The manifest is a committed file on purpose: editing readiness, +the manifest, and the verify script in one review is what keeps a green check +honest. + +Neither command needs a secret key. Balance checks take public keys: +`OPERATOR_PUBLIC_KEY`, `KEEPER_PUBLIC_KEY`, `BIDDER_PUBLIC_KEY`. + ### Mainnet launch checklist 1. Run `pnpm mainnet:ready -- --strict` (no secrets required for baseline checks). 2. Run `pnpm mainnet:verify` to confirm settled round 1 matches frozen artifacts. -3. Optional balance review: `pnpm mainnet:ready -- --with-balances` with funded operator/keeper secrets in env. +3. Optional balance review: `pnpm mainnet:ready -- --with-balances` with funded operator/keeper **public** keys in env. 4. For value-moving commands, set `MAINNET_CONFIRM=SUB_ROSA_MAINNET` and re-run readiness implicitly via deploy/micro/settle guards. 5. After settlement, confirm contract native XLM SAC balance is **0** (settle script enforces this). diff --git a/packages/sdk/README.md b/packages/sdk/README.md index c7f65eab..d210c8ab 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -23,3 +23,56 @@ cached for later calls. A mismatch throws `SubRosaNetworkMismatchError` before simulation, signing, or submission, with the conflicting values and a suggested fix. Contract IDs do not encode a Stellar network, so copying a `C...` address between Testnet and Mainnet requires updating all three configuration values. + +## Mainnet readiness (manifest-pinned) + +`packages/sdk/mainnet-artifacts.json` is the artifact manifest the repo commits. +`mainnet:ready` and `mainnet:verify` both load it and compare the live deployment +against it through the read-only client: + +| manifest field | compared against | +| ---------------- | ------------------------------------------------------------ | +| `contractId` | the contract the client is bound to | +| `networkPassphrase` | the passphrase the RPC reports (`getNetwork`) | +| `wasmHash` | the executable hash read from the contract ledger entry | +| `tokenContract` | `usdc` in the deployed `GlobalConfig` (the escrow SAC) | + +Any disagreement blocks, and a field that cannot be read at all blocks too — an +unverifiable field must never read as a pass. The report names the field and +prints a redacted value: passphrases become a short sha256 fingerprint, and +anything shaped like an `S...` secret key is redacted outright. The configured +passphrase and contract id are compared with the manifest before any RPC call, +so pointing `NETWORK_PASSPHRASE` at testnet fails instead of reporting a green +check against the wrong network. + +```ts +import { + assertDeploymentMatches, + defaultMainnetReadinessInput, + readLiveDeployment, + runMainnetReadiness, +} from "@sub-rosa/sdk"; + +// Live: throws SubRosaDeploymentMismatchError naming every disagreeing field. +assertDeploymentMatches(manifest, await readLiveDeployment(client, server, contractId, fetchHash)); + +// Full report, or a replay of a recorded snapshot with no RPC at all: +const report = await runMainnetReadiness( + defaultMainnetReadinessInput({ fixture: recordedSnapshot }), +); +``` + +Readiness never needs a secret key: the client is read-only and balance checks +take public keys (`OPERATOR_PUBLIC_KEY`, `KEEPER_PUBLIC_KEY`, `BIDDER_PUBLIC_KEY`). + +## Commands + +```bash +pnpm mainnet:ready -- --strict # live, read-only +pnpm mainnet:ready -- --fixture packages/sdk/fixtures/mainnet-readiness.json # CI-safe +pnpm mainnet:verify # settlement proof + manifest +``` + +`--fixture` replays a recorded deployment through the same comparison with no +mainnet RPC, so CI can prove each mismatch field fails and a matching recording +passes. diff --git a/packages/sdk/fixtures/mainnet-readiness.json b/packages/sdk/fixtures/mainnet-readiness.json new file mode 100644 index 00000000..a5bfa960 --- /dev/null +++ b/packages/sdk/fixtures/mainnet-readiness.json @@ -0,0 +1,20 @@ +{ + "networkPassphrase": "Public Global Stellar Network ; September 2015", + "contractId": "CA7KSDEYJEPGZEB2ZROTLUWKQQ6GIRIQNGG6Z745MZ34QHP4UJPWODEX", + "wasmHash": "353915ad440965ea5f8d92fdb8d93cb2e309fb365e68e6762bca7fd6762b30c7", + "tokenContract": "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + "ledger": 61753153, + "contractBalance": "0", + "round": { + "roundId": "1", + "status": "Settled", + "revealRound": "29174905", + "bidders": ["GB6IO4Z9ASMSL7A7Y5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X3"], + "bid": { + "escrow": "50000000", + "revealedValue": "10000000", + "valid": true, + "settled": true + } + } +} diff --git a/packages/sdk/mainnet-artifacts.json b/packages/sdk/mainnet-artifacts.json new file mode 100644 index 00000000..85f890f3 --- /dev/null +++ b/packages/sdk/mainnet-artifacts.json @@ -0,0 +1,16 @@ +{ + "network": "Stellar Mainnet", + "networkPassphrase": "Public Global Stellar Network ; September 2015", + "rpcUrl": "https://rpc.ankr.com/stellar_soroban", + "contractId": "CA7KSDEYJEPGZEB2ZROTLUWKQQ6GIRIQNGG6Z745MZ34QHP4UJPWODEX", + "wasmHash": "353915ad440965ea5f8d92fdb8d93cb2e309fb365e68e6762bca7fd6762b30c7", + "tokenContract": "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + "escrowToken": "native XLM (SAC)", + "settledRoundId": 1, + "revealRound": 29174905, + "bidStroops": "10000000", + "escrowStroops": "50000000", + "bidXlm": "1", + "escrowXlm": "5", + "status": "Settled" +} diff --git a/packages/sdk/scripts/mainnet-ready.ts b/packages/sdk/scripts/mainnet-ready.ts index 543cfeb5..54f330a1 100644 --- a/packages/sdk/scripts/mainnet-ready.ts +++ b/packages/sdk/scripts/mainnet-ready.ts @@ -3,83 +3,137 @@ import { createLogger } from '@sub-rosa/logging'; const diagnostics = createLogger("packages.sdk.scripts.mainnet-ready"); // Consolidated mainnet launch readiness — read-only by default. // +// Reads the committed artifact manifest and compares the live deployment with +// it. Any disagreement in contract id, network passphrase, wasm hash, or escrow +// token contract blocks, and the report names the field. +// // Usage: // pnpm mainnet:ready -// pnpm mainnet:ready -- --dry-run -// pnpm mainnet:ready -- --with-balances --strict - -import { Keypair } from "@stellar/stellar-sdk"; +// pnpm mainnet:ready -- --strict +// pnpm mainnet:ready -- --fixture packages/sdk/fixtures/mainnet-readiness.json +// pnpm mainnet:ready -- --with-balances +// +// Never needs a secret key: the client is read-only and every account input is +// a public key. import { SubRosaClient } from "../src/client.js"; -import { MAINNET_ARTIFACTS, MAINNET_CONFIRM_PHRASE } from "../src/mainnet-artifacts.js"; +import { MAINNET_CONFIRM_PHRASE } from "../src/mainnet-artifacts.js"; +import { + loadMainnetReadinessFixture, + loadMainnetManifest, +} from "../src/mainnet-manifest.js"; import { defaultMainnetReadinessInput, formatReadinessReport, - hasBlockingFailures, runMainnetReadiness, } from "../src/mainnet-readiness.js"; const DEFAULT_READER_PUBKEY = "GCDARJFKKSTJYAZC647H4ZSSSPXPPSKOWOHGMUNCT22VG74KXZ5BHVNR"; +function hasFlag(flag: string): boolean { + return process.argv.includes(flag); +} + +/** Value that follows a flag, or undefined when it was passed bare. */ +function argValue(flag: string): string | undefined { + const index = process.argv.indexOf(flag); + if (index === -1) return undefined; + const next = process.argv[index + 1]; + return next !== undefined && !next.startsWith("--") ? next : undefined; +} + async function main() { const dryRun = process.argv.includes("--dry-run") || process.env.MAINNET_DRY_RUN === "1"; const withBalances = process.argv.includes("--with-balances"); const strict = process.argv.includes("--strict"); + // `--fixture` may be bare: replay the committed recording. + // `--fixture` may be bare, in which case the committed recording is replayed. + const replayFixture = hasFlag("--fixture"); + const fixturePath = replayFixture ? argValue("--fixture") : undefined; - const rpcUrl = process.env.RPC_URL ?? MAINNET_ARTIFACTS.rpcUrl; - const networkPassphrase = - process.env.NETWORK_PASSPHRASE ?? MAINNET_ARTIFACTS.networkPassphrase; - const contractId = - process.env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId; + // The committed manifest is the source of truth. Env vars may point the run + // somewhere else, but then the config checks below fail — deliberately. + const loaded = loadMainnetManifest(hasFlag("--manifest") ? argValue("--manifest") : undefined); + const manifest = loaded.manifest; + diagnostics.info("manifest-loaded", "Committed artifact manifest", { + path: loaded.path, + sha256: loaded.sha256.slice(0, 12), + contract: manifest.contractId, + network: manifest.network, + }); - const operatorAccount = process.env.OPERATOR_SECRET - ? Keypair.fromSecret(process.env.OPERATOR_SECRET).publicKey() - : undefined; - const keeperAccount = process.env.KEEPER_SECRET - ? Keypair.fromSecret(process.env.KEEPER_SECRET).publicKey() - : undefined; - const bidderAccount = process.env.BIDDER_SECRET - ? Keypair.fromSecret(process.env.BIDDER_SECRET).publicKey() - : undefined; + const input = defaultMainnetReadinessInput( + { + rpcUrl: process.env.RPC_URL ?? manifest.rpcUrl, + networkPassphrase: + process.env.NETWORK_PASSPHRASE ?? manifest.networkPassphrase, + contractId: process.env.ROUND_CONTRACT_ID ?? manifest.contractId, + manifestSource: loaded.path, + live: !dryRun, + withBalances, + operatorAccount: process.env.OPERATOR_PUBLIC_KEY, + keeperAccount: process.env.KEEPER_PUBLIC_KEY, + bidderAccount: process.env.BIDDER_PUBLIC_KEY, + }, + manifest, + ); - const input = defaultMainnetReadinessInput({ - rpcUrl, - networkPassphrase, - contractId, - live: !dryRun, - withBalances, - operatorAccount, - keeperAccount, - bidderAccount, - }); + const fixture = replayFixture + ? loadMainnetReadinessFixture(fixturePath).fixture + : undefined; + if (fixture) { + diagnostics.info("fixture-mode", "Replaying a recorded deployment", { + rpc: "none", + secrets: "none", + }); + } - const reader = dryRun - ? undefined - : new SubRosaClient({ - rpcUrl, - networkPassphrase, - contractId, - publicKey: - process.env.MAINNET_READER_PUBKEY ?? DEFAULT_READER_PUBKEY, - }); + const reader = + fixture || dryRun + ? undefined + : new SubRosaClient({ + rpcUrl: input.rpcUrl, + networkPassphrase: input.networkPassphrase, + contractId: input.contractId, + publicKey: process.env.MAINNET_READER_PUBKEY ?? DEFAULT_READER_PUBKEY, + }); - const report = await runMainnetReadiness(input, { reader }); + const report = await runMainnetReadiness( + fixture ? { ...input, fixture } : input, + { reader }, + ); diagnostics.info("progress", formatReadinessReport(report)); - if (strict && hasBlockingFailures(report.checks)) { - throw new Error("readiness checks failed in strict mode"); - } - if (report.blockCount > 0) { - diagnostics.info("blocking-issues-must-be-resolved-before-mainnet-executi", "\nBlocking issues must be resolved before mainnet execution."); - diagnostics.info("value-moving-commands-require", "Value-moving commands require:"); + if (report.deployment && !report.deployment.matched) { + diagnostics.error( + "deployment-mismatch", + `deployment does not match the committed manifest ${loaded.path}`, + { + fields: report.deployment.mismatchedFieldNames, + unreadable: report.deployment.unreadable.map((f) => f.field), + }, + ); + } + diagnostics.info( + "blocking-issues-must-be-resolved-before-mainnet-executi", + "\nBlocking issues must be resolved before mainnet execution.", + ); + diagnostics.info( + "value-moving-commands-require", + "Value-moving commands require:", + ); diagnostics.info("mainnet-confirm", ` MAINNET_CONFIRM=${MAINNET_CONFIRM_PHRASE}`); process.exit(1); } - diagnostics.info("mainnet-readiness-ok", "\n✅ MAINNET READINESS OK"); + if (strict) { + diagnostics.info("strict-ok", "Strict readiness: no blocking findings"); + } + + diagnostics.info("mainnet-readiness-ok", "\nMAINNET READINESS OK"); diagnostics.info("recommended-launch-checklist", "Recommended launch checklist:"); diagnostics.info("1-pnpm-mainnet-ready-strict", " 1. pnpm mainnet:ready -- --strict"); diagnostics.info("2-pnpm-mainnet-verify", " 2. pnpm mainnet:verify"); @@ -89,7 +143,7 @@ async function main() { } main().catch((err) => { - diagnostics.error("mainnet-readiness-failed", "\n❌ MAINNET READINESS FAILED"); + diagnostics.error("mainnet-readiness-failed", "\nMAINNET READINESS FAILED"); diagnostics.error("progress-2", normalizeError(err)); process.exit(1); }); diff --git a/packages/sdk/scripts/mainnet-verify.ts b/packages/sdk/scripts/mainnet-verify.ts index ea17bbd2..ac53c565 100644 --- a/packages/sdk/scripts/mainnet-verify.ts +++ b/packages/sdk/scripts/mainnet-verify.ts @@ -3,34 +3,95 @@ import { createLogger } from '@sub-rosa/logging'; const diagnostics = createLogger("packages.sdk.scripts.mainnet-verify"); // Read-only mainnet proof checker — no transactions, no secrets required. // -// Verifies the deployed Round contract and settled round 1 match frozen artifacts. +// Verifies the deployed Round contract and settled round 1 match frozen +// artifacts. The committed manifest is the source of truth: the deployment's +// contract id, network passphrase, wasm hash, and escrow token are compared +// against it before the settlement proof is accepted, so this script cannot +// pass against a different deployment than the one readiness pins. + +import { rpc } from "@stellar/stellar-sdk"; import { SubRosaClient } from "../src/client.js"; -import { MAINNET_ARTIFACTS } from "../src/mainnet-artifacts.js"; -import { verifySettledRoundProof } from "../src/mainnet-readiness.js"; +import { + loadMainnetReadinessFixture, + loadMainnetManifest, +} from "../src/mainnet-manifest.js"; +import { + assertDeploymentMatches, + fetchContractWasmHash, + fixtureDeployment, + fixtureReader, + readLiveDeployment, + verifySettledRoundProof, +} from "../src/mainnet-readiness.js"; + +function hasFlag(flag: string): boolean { + return process.argv.includes(flag); +} + +/** Value that follows a flag, or undefined when it was passed bare. */ +function argValue(flag: string): string | undefined { + const index = process.argv.indexOf(flag); + if (index === -1) return undefined; + const next = process.argv[index + 1]; + return next !== undefined && !next.startsWith("--") ? next : undefined; +} async function main() { - const dryRun = process.argv.includes("--dry-run") || process.env.MAINNET_DRY_RUN === "1"; + const dryRun = + process.argv.includes("--dry-run") || process.env.MAINNET_DRY_RUN === "1"; + // `--fixture` may be bare: replay the committed recording. + // `--fixture` may be bare, in which case the committed recording is replayed. + const replayFixture = hasFlag("--fixture"); + const fixturePath = replayFixture ? argValue("--fixture") : undefined; + + const loaded = loadMainnetManifest(hasFlag("--manifest") ? argValue("--manifest") : undefined); + const manifest = loaded.manifest; diagnostics.info("sub-rosa-mainnet-settlement-proof-read-only", "Sub Rosa — mainnet settlement proof (read-only)\n"); diagnostics.info("checklist", "Checklist:"); diagnostics.info("contract-id-matches-frozen-artifact", " [ ] Contract id matches frozen artifact"); + diagnostics.info("network-passphrase-matches-frozen-artifact", " [ ] Network passphrase matches frozen artifact"); + diagnostics.info("wasm-hash-matches-frozen-artifact", " [ ] Deployed wasm hash matches frozen artifact"); + diagnostics.info("token-contract-matches-frozen-artifact", " [ ] Escrow token contract matches frozen artifact"); diagnostics.info("round-1-status-is-settled", " [ ] Round 1 status is Settled"); diagnostics.info("drand-reveal-round-r-matches-artifact", " [ ] Drand reveal round R matches artifact"); diagnostics.info("bid-escrow-stroops-match-micro-smoke-amounts-1-5-xlm", " [ ] Bid/escrow stroops match micro smoke amounts (1 / 5 XLM)"); diagnostics.info("bidder-marked-valid-settled", " [ ] Bidder marked valid + settled\n"); + const roundId = BigInt(process.env.ROUND_ID ?? String(manifest.settledRoundId)); + const expected = { + bidStroops: manifest.bidStroops, + escrowStroops: manifest.escrowStroops, + revealRound: Number(manifest.revealRound), + }; + + if (replayFixture) { + const { fixture, path } = loadMainnetReadinessFixture(fixturePath); + diagnostics.info("fixture-mode", "Replaying a recorded deployment", { + path, + rpc: "none", + }); + assertDeploymentMatches(manifest, fixtureDeployment(fixture), path); + await verifySettledRoundProof(fixtureReader(fixture), roundId, expected); + diagnostics.info("mainnet-verify-passed", "✅ MAINNET VERIFY PASSED (fixture)"); + return; + } + if (dryRun) { diagnostics.info("dry-run-would-read-rpc-only-re-run-without-dry-run-to-f", "DRY-RUN — would read RPC only. Re-run without --dry-run to fetch live state.\n"); diagnostics.info("expected", "Expected:"); diagnostics.info("progress", JSON.stringify( { - contractId: MAINNET_ARTIFACTS.contractId, - roundId: MAINNET_ARTIFACTS.settledRoundId, - status: MAINNET_ARTIFACTS.status, - revealRound: MAINNET_ARTIFACTS.revealRound, - bidStroops: MAINNET_ARTIFACTS.bidStroops.toString(), - escrowStroops: MAINNET_ARTIFACTS.escrowStroops.toString(), + contractId: manifest.contractId, + networkPassphrase: manifest.networkPassphrase, + wasmHash: manifest.wasmHash, + tokenContract: manifest.tokenContract, + roundId: manifest.settledRoundId, + status: manifest.status, + revealRound: manifest.revealRound, + bidStroops: manifest.bidStroops.toString(), + escrowStroops: manifest.escrowStroops.toString(), }, null, 2, @@ -38,26 +99,41 @@ async function main() { return; } + const rpcUrl = process.env.RPC_URL ?? manifest.rpcUrl; + const networkPassphrase = process.env.NETWORK_PASSPHRASE ?? manifest.networkPassphrase; + const contractId = process.env.ROUND_CONTRACT_ID ?? manifest.contractId; + const reader = new SubRosaClient({ - rpcUrl: process.env.RPC_URL ?? MAINNET_ARTIFACTS.rpcUrl, - networkPassphrase: process.env.NETWORK_PASSPHRASE ?? MAINNET_ARTIFACTS.networkPassphrase, - contractId: process.env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId, + rpcUrl, + networkPassphrase, + contractId, publicKey: process.env.MAINNET_READER_PUBKEY ?? "GCDARJFKKSTJYAZC647H4ZSSSPXPPSKOWOHGMUNCT22VG74KXZ5BHVNR", }); - const roundId = BigInt(process.env.ROUND_ID ?? String(MAINNET_ARTIFACTS.settledRoundId)); - await verifySettledRoundProof(reader, roundId, { - bidStroops: MAINNET_ARTIFACTS.bidStroops, - escrowStroops: MAINNET_ARTIFACTS.escrowStroops, - revealRound: MAINNET_ARTIFACTS.revealRound, + const server = new rpc.Server(rpcUrl); + const live = await readLiveDeployment( + reader, + server, + contractId, + (id) => fetchContractWasmHash(server, id), + ); + const comparison = assertDeploymentMatches(manifest, live, loaded.path); + diagnostics.info("deployment-matches-manifest", "Deployment matches the committed manifest", { + contractId: live.contractId ?? null, + wasmHash: live.wasmHash ?? null, + tokenContract: live.tokenContract ?? null, + fields: comparison.fields.map((f) => f.field).join(","), }); + await verifySettledRoundProof(reader, roundId, expected); + diagnostics.info("mainnet-verify-passed", "✅ MAINNET VERIFY PASSED"); - diagnostics.info("contract", " contract:", { "value1_0": process.env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId }); - diagnostics.info("round", " round: ", { "value1_0": roundId.toString(), "value2_1": "status:", "status_2": MAINNET_ARTIFACTS.status }); - diagnostics.info("r", " R: ", { "value1_0": MAINNET_ARTIFACTS.revealRound.toString() }); - diagnostics.info("bid", " bid: ", { "bidXlm_0": MAINNET_ARTIFACTS.bidXlm, "value2_1": "XLM" }); - diagnostics.info("escrow", " escrow: ", { "escrowXlm_0": MAINNET_ARTIFACTS.escrowXlm, "value2_1": "XLM" }); + diagnostics.info("contract", " contract:", { "value1_0": contractId }); + diagnostics.info("round", " round: ", { "value1_0": roundId.toString(), "value2_1": "status:", "status_2": manifest.status }); + diagnostics.info("r", " R: ", { "value1_0": manifest.revealRound.toString() }); + diagnostics.info("bid", " bid: ", { "bidXlm_0": manifest.bidXlm, "value2_1": "XLM" }); + diagnostics.info("escrow", " escrow: ", { "escrowXlm_0": manifest.escrowXlm, "value2_1": "XLM" }); + diagnostics.info("token", " token: ", { "value1_0": manifest.tokenContract, "value2_1": "label:", "label_2": manifest.escrowToken }); } main().catch((err) => { diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 9c4897d0..5121316f 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -115,6 +115,59 @@ export class SubRosaPreflightError extends Error { } } +export interface ManifestErrorParams { + message: string; + /** Manifest field that failed validation, when the failure is field-scoped. */ + field?: string; + /** Path of the committed manifest file, when it was read from disk. */ + path?: string; + cause?: unknown; +} + +/** Raised when the committed artifact manifest is missing, unreadable, or invalid. */ +export class SubRosaManifestError extends Error { + readonly name = "SubRosaManifestError"; + readonly field?: string; + readonly path?: string; + + constructor(message: string, params: Omit = {}) { + super(message, params.cause === undefined ? undefined : { cause: params.cause }); + this.field = params.field; + this.path = params.path; + } +} + +export interface DeploymentMismatchErrorParams { + /** Manifest field names that disagreed, in manifest order. */ + fields: string[]; + /** One redacted, human-readable line per field. */ + details: string[]; + /** Source of the expectations, e.g. the manifest path. */ + manifestSource?: string; +} + +/** + * Raised when the live deployment disagrees with the committed manifest. The + * message names the offending fields and carries only redacted values, so it is + * safe to log. + */ +export class SubRosaDeploymentMismatchError extends Error { + readonly name = "SubRosaDeploymentMismatchError"; + readonly fields: string[]; + readonly details: string[]; + readonly manifestSource?: string; + + constructor(params: DeploymentMismatchErrorParams) { + super( + `deployment does not match the committed mainnet manifest: ${params.fields.join(", ")}` + + (params.manifestSource ? ` (${params.manifestSource})` : ""), + ); + this.fields = params.fields; + this.details = params.details; + this.manifestSource = params.manifestSource; + } +} + export class SubRosaTimeoutError extends Error { readonly name = "SubRosaTimeoutError"; readonly hash: string; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 2c28dbf2..9b86ff09 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -28,6 +28,8 @@ export { } from "./submitter.js"; export { SubRosaClientConfigError, + SubRosaDeploymentMismatchError, + SubRosaManifestError, SubRosaMissingReturnValueError, SubRosaNetworkMismatchError, SubRosaPreflightError, @@ -36,6 +38,8 @@ export { SubRosaTransactionError, } from "./errors.js"; export type { + DeploymentMismatchErrorParams, + ManifestErrorParams, NetworkMismatchErrorParams, PreflightFailureKind, SubRosaPreflightErrorParams, @@ -58,11 +62,20 @@ export { type BlobValidationResult, } from "./encrypted-blob.js"; export { + DEPLOYMENT_CHECK_IDS, + DEPLOYMENT_FIELDS, + DEPLOYMENT_FIELD_LABELS, MAINNET_ARTIFACTS, MAINNET_CONFIRM_PHRASE, MAINNET_DEPLOY_MIN_XLM_STROOPS, + MAINNET_MANIFEST, + MAINNET_MANIFEST_PATH, MAINNET_MICRO_MAX_ESCROW, MAINNET_MIN_FEE_RESERVE_STROOPS, + MAINNET_XLM_SAC_ID, + parseMainnetManifest, + type DeploymentField, + type MainnetManifest, } from "./mainnet-artifacts.js"; export { AssetConfigError, @@ -73,21 +86,36 @@ export { type AssetType, } from "./asset-config.js"; export { + assertDeploymentMatches, assertMainnetConfirmed, assertMicroAmounts, assertReadinessForExecute, + compareDeployment, createSacBalanceReader, defaultMainnetReadinessInput, + deploymentChecks, fetchContractWasmHash, + fixtureDeployment, + fixtureReader, formatReadinessReport, hasBlockingFailures, nativeXlmSacId, + parseMainnetReadinessFixture, + readLiveDeployment, runMainnetReadiness, + summarizeDeploymentValue, verifySettledRoundProof, + type DeploymentComparison, + type DeploymentFieldComparison, + type MainnetFixtureBid, + type MainnetFixtureRound, + type MainnetLiveDeployment, type MainnetReadinessDeps, + type MainnetReadinessFixture, type MainnetReadinessInput, type MainnetReadinessReport, type ReadinessCheck, + type ReadinessReader, type ReadinessStatus, } from "./mainnet-readiness.js"; diff --git a/packages/sdk/src/mainnet-artifacts.ts b/packages/sdk/src/mainnet-artifacts.ts index e5ea89d8..d5398f43 100644 --- a/packages/sdk/src/mainnet-artifacts.ts +++ b/packages/sdk/src/mainnet-artifacts.ts @@ -6,8 +6,6 @@ export const MAINNET_ARTIFACTS = { rpcUrl: "https://rpc.ankr.com/stellar_soroban", contractId: "CA7KSDEYJEPGZEB2ZROTLUWKQQ6GIRIQNGG6Z745MZ34QHP4UJPWODEX", wasmHash: "353915ad440965ea5f8d92fdb8d93cb2e309fb365e68e6762bca7fd6762b30c7", - settledRoundId: 1, - revealRound: 29_174_905, /** Native XLM SAC — escrow token for the mainnet smoke round. */ escrowToken: "native XLM (SAC)", /** Stroops — 1 XLM bid, 5 XLM escrow (not testnet USDC demo amounts). */ @@ -15,6 +13,8 @@ export const MAINNET_ARTIFACTS = { escrowStroops: 50_000_000n, bidXlm: "1", escrowXlm: "5", + settledRoundId: 1, + revealRound: 29_174_905, status: "Settled" as const, proofCommand: "pnpm mainnet:verify", deployCommand: "pnpm mainnet:deploy", @@ -23,6 +23,13 @@ export const MAINNET_ARTIFACTS = { "https://stellar.expert/explorer/public/contract/CA7KSDEYJEPGZEB2ZROTLUWKQQ6GIRIQNGG6Z745MZ34QHP4UJPWODEX", } as const; +/** + * Native XLM SAC address for a given network passphrase. The mainnet value is + * committed in `mainnet-artifacts.json` so a drifted token config is a manifest + * mismatch rather than a silently re-derived expectation. + */ +export const MAINNET_XLM_SAC_ID = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; + /** Hard ceiling for optional mainnet micro runner (1 XLM escrow). */ export const MAINNET_MICRO_MAX_ESCROW = 10_000_000n; @@ -34,3 +41,238 @@ export const MAINNET_DEPLOY_MIN_XLM_STROOPS = 300_000_000n; /** Minimum XLM reserve for keeper/bidder fee coverage. */ export const MAINNET_MIN_FEE_RESERVE_STROOPS = 5_000_000n; + +/** + * Deployment identity a readiness check pins. Every one of these is compared + * against the live chain, so a manifest edit that is not backed by a real + * deployment fails the check instead of silently re-baselining it. + */ +export const DEPLOYMENT_FIELDS = [ + "contractId", + "networkPassphrase", + "wasmHash", + "tokenContract", +] as const; + +export type DeploymentField = (typeof DEPLOYMENT_FIELDS)[number]; + +/** Readiness check id per manifest field. Stable: it is the id operators grep. */ +export const DEPLOYMENT_CHECK_IDS: Record = { + contractId: "contract-id", + networkPassphrase: "network-passphrase", + wasmHash: "wasm-hash", + tokenContract: "token-contract", +}; + +/** Human labels used in readiness output, keyed by manifest field. */ +export const DEPLOYMENT_FIELD_LABELS: Record = { + contractId: "Contract id", + networkPassphrase: "Network passphrase", + wasmHash: "Artifact wasm hash", + tokenContract: "Escrow token contract", +}; + +/** + * The committed artifact manifest. `mainnet-artifacts.json` is the file the repo + * ships; `MAINNET_MANIFEST` is the in-module mirror of it for callers that + * cannot touch the filesystem (bundlers, keepers). `parseMainnetManifest` proves + * a loaded file has exactly this shape, and the SDK test suite fails if the two + * ever drift. + */ +export interface MainnetManifest { + network: string; + networkPassphrase: string; + rpcUrl: string; + contractId: string; + wasmHash: string; + tokenContract: string; + escrowToken: string; + settledRoundId: bigint; + revealRound: bigint; + bidStroops: bigint; + escrowStroops: bigint; + bidXlm: string; + escrowXlm: string; + status: "Settled"; +} + +/** Repo-relative path of the committed manifest. */ +export const MAINNET_MANIFEST_PATH = "packages/sdk/mainnet-artifacts.json"; +/** In-module mirror of `mainnet-artifacts.json`. */ +export const MAINNET_MANIFEST: MainnetManifest = { + network: MAINNET_ARTIFACTS.network, + networkPassphrase: MAINNET_ARTIFACTS.networkPassphrase, + rpcUrl: MAINNET_ARTIFACTS.rpcUrl, + contractId: MAINNET_ARTIFACTS.contractId, + wasmHash: MAINNET_ARTIFACTS.wasmHash, + tokenContract: MAINNET_XLM_SAC_ID, + escrowToken: MAINNET_ARTIFACTS.escrowToken, + settledRoundId: BigInt(MAINNET_ARTIFACTS.settledRoundId), + revealRound: BigInt(MAINNET_ARTIFACTS.revealRound), + bidStroops: MAINNET_ARTIFACTS.bidStroops, + escrowStroops: MAINNET_ARTIFACTS.escrowStroops, + bidXlm: MAINNET_ARTIFACTS.bidXlm, + escrowXlm: MAINNET_ARTIFACTS.escrowXlm, + status: MAINNET_ARTIFACTS.status, +}; + +const WASM_HASH_RE = /^[0-9a-f]{64}$/i; + +class ManifestFieldError extends Error { + readonly field: string; + + constructor(field: string, detail: string) { + super(`mainnet manifest field ${field} is invalid: ${detail}`); + this.name = "ManifestFieldError"; + this.field = field; + } +} + +function asRecord(raw: unknown): Record { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new ManifestFieldError("", "expected a JSON object"); + } + return raw as Record; +} + +function requiredString( + raw: Record, + field: string, +): string { + const value = raw[field]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new ManifestFieldError(field, "expected a non-empty string"); + } + return value.trim(); +} + +function requiredHexHash(raw: Record, field: string): string { + const value = requiredString(raw, field); + if (!WASM_HASH_RE.test(value)) { + throw new ManifestFieldError( + field, + "expected 64 hexadecimal characters (the sha256 of the deployed wasm)", + ); + } + return value.toLowerCase(); +} + +function requiredSorobanId( + raw: Record, + field: string, +): string { + const value = requiredString(raw, field).toUpperCase(); + if (!/^C[A-Z2-7]{55}$/.test(value)) { + throw new ManifestFieldError( + field, + "expected a Soroban contract id (C + 55 base32 characters)", + ); + } + return value; +} + +function requiredPositiveBigInt( + raw: Record, + field: string, +): bigint { + const value = raw[field]; + if (typeof value === "bigint") { + if (value <= 0n) { + throw new ManifestFieldError(field, "expected a positive integer"); + } + return value; + } + const text = typeof value === "string" ? value.trim() : value; + if (typeof text === "number") { + if (!Number.isSafeInteger(text) || text <= 0) { + throw new ManifestFieldError( + field, + "expected a positive integer or decimal string", + ); + } + return BigInt(text); + } + if (typeof text !== "string" || !/^\d+$/.test(text) || text === "0") { + throw new ManifestFieldError( + field, + "expected a positive integer or decimal string (no signs, no exponents)", + ); + } + return BigInt(text); +} + +const KNOWN_MANIFEST_FIELDS = [ + "network", + "networkPassphrase", + "rpcUrl", + "contractId", + "wasmHash", + "tokenContract", + "escrowToken", + "settledRoundId", + "revealRound", + "bidStroops", + "escrowStroops", + "bidXlm", + "escrowXlm", + "status", +] as const; + +/** + * Validate a parsed manifest document. Unknown keys are rejected on purpose: a + * typo'd deployment field would otherwise read as "no expectation recorded" and + * leave a field uncompared. + */ +export function parseMainnetManifest(raw: unknown): MainnetManifest { + const record = asRecord(raw); + for (const key of Object.keys(record)) { + if (!KNOWN_MANIFEST_FIELDS.includes(key as (typeof KNOWN_MANIFEST_FIELDS)[number])) { + throw new ManifestFieldError( + key, + "unknown field — the manifest schema is closed so a typo cannot silently skip a comparison", + ); + } + } + + const rpcUrl = requiredString(record, "rpcUrl"); + if (!/^https:\/\//i.test(rpcUrl)) { + throw new ManifestFieldError( + "rpcUrl", + "expected an https:// endpoint for a mainnet manifest", + ); + } + + const status = requiredString(record, "status"); + if (status !== "Settled") { + throw new ManifestFieldError( + "status", + `expected the proof round status to be "Settled", got ${JSON.stringify(status)}`, + ); + } + + const bidStroops = requiredPositiveBigInt(record, "bidStroops"); + const escrowStroops = requiredPositiveBigInt(record, "escrowStroops"); + if (bidStroops > escrowStroops) { + throw new ManifestFieldError( + "bidStroops", + "bid cannot exceed escrow in the committed manifest", + ); + } + + return { + network: requiredString(record, "network"), + networkPassphrase: requiredString(record, "networkPassphrase"), + rpcUrl, + contractId: requiredSorobanId(record, "contractId"), + wasmHash: requiredHexHash(record, "wasmHash"), + tokenContract: requiredSorobanId(record, "tokenContract"), + escrowToken: requiredString(record, "escrowToken"), + settledRoundId: requiredPositiveBigInt(record, "settledRoundId"), + revealRound: requiredPositiveBigInt(record, "revealRound"), + bidStroops, + escrowStroops, + bidXlm: requiredString(record, "bidXlm"), + escrowXlm: requiredString(record, "escrowXlm"), + status: "Settled", + }; +} diff --git a/packages/sdk/src/mainnet-manifest.ts b/packages/sdk/src/mainnet-manifest.ts new file mode 100644 index 00000000..f1f53c25 --- /dev/null +++ b/packages/sdk/src/mainnet-manifest.ts @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: MIT +// Reads the committed artifact manifest and the recorded fixture from disk. +// Deliberately NOT re-exported from the package barrel: `node:fs` must stay out +// of browser bundles, so callers that already have a parsed manifest (or run in +// a browser) depend on `mainnet-artifacts.js` alone. +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { normalizeError } from "@sub-rosa/logging/errors"; + +import { SubRosaManifestError } from "./errors.js"; +import { + MAINNET_MANIFEST_PATH, + parseMainnetManifest, + type MainnetManifest, +} from "./mainnet-artifacts.js"; +import { + parseMainnetReadinessFixture, + type MainnetReadinessFixture, +} from "./mainnet-readiness.js"; + +/** Repo-relative path of the committed manifest. */ +export const MAINNET_MANIFEST_PATH_REPO = MAINNET_MANIFEST_PATH; + +/** Repo-relative path of the committed fixture that replays a green check. */ +export const MAINNET_FIXTURE_PATH = "packages/sdk/fixtures/mainnet-readiness.json"; + +const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url)); +const REPO_ROOT = resolve(PACKAGE_ROOT, "..", ".."); + +export interface LoadedMainnetManifest { + manifest: MainnetManifest; + /** Absolute path the manifest was read from, for report/log context. */ + path: string; + /** sha256 of the manifest bytes, so a report can pin what it compared. */ + sha256: string; +} + +/** + * Where a repo-relative artifact may live. The commands run from the repo root + * (`pnpm mainnet:ready`) and from the package (`pnpm --filter … exec`), so try + * both before giving up and naming the cwd-relative path in the error. + */ +function candidatePaths( + relative: string | undefined, + fallbackRelative: string, + cwd: string, +): string[] { + const rel = relative ?? fallbackRelative; + if (isAbsolute(rel)) return [rel]; + const inPackage = rel.replace(/^packages\/sdk\//, ""); + const candidates = [ + resolve(cwd, rel), + resolve(REPO_ROOT, rel), + resolve(PACKAGE_ROOT, inPackage), + ]; + if (relative === undefined) candidates.push(resolve(REPO_ROOT, inPackage)); + return [...new Set(candidates)]; +} + +function firstExisting(candidates: string[]): string { + return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]!; +} + +/** Absolute path of the committed manifest. */ +export function resolveMainnetManifestPath( + path?: string, + cwd: string = process.cwd(), +): string { + return firstExisting(candidatePaths(path, MAINNET_MANIFEST_PATH, cwd)); +} + +function readJson(path: string, label: string): { raw: unknown; text: string } { + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch (cause) { + throw new SubRosaManifestError( + `cannot read the ${label} at ${path}`, + { cause, path }, + ); + } + try { + return { raw: JSON.parse(text), text }; + } catch (cause) { + throw new SubRosaManifestError( + `the ${label} at ${path} is not valid JSON`, + { cause, path }, + ); + } +} + +function fieldOf(cause: unknown): string | undefined { + return cause instanceof Error && "field" in cause + ? String((cause as { field: unknown }).field) + : undefined; +} + +/** + * Load the manifest the repo commits. Throws `SubRosaManifestError` (never + * silently falls back to defaults) so a missing or edited manifest cannot turn + * a readiness run into a green check against unreviewed expectations. + */ +export function loadMainnetManifest( + path?: string, + cwd: string = process.cwd(), +): LoadedMainnetManifest { + const resolved = resolveMainnetManifestPath(path, cwd); + const { raw, text } = readJson(resolved, "committed mainnet manifest"); + let manifest: MainnetManifest; + try { + manifest = parseMainnetManifest(raw); + } catch (cause) { + throw new SubRosaManifestError( + `the committed mainnet manifest at ${resolved} is invalid: ${normalizeError(cause).message}`, + { cause, field: fieldOf(cause), path: resolved }, + ); + } + return { + manifest, + path: resolved, + sha256: createHash("sha256").update(text, "utf8").digest("hex"), + }; +} + +/** + * Load a recorded mainnet snapshot for CI. Same closed-schema validation as the + * manifest: no RPC endpoint, no account, and no secret key is involved. + */ +export function loadMainnetReadinessFixture( + path?: string, + cwd: string = process.cwd(), +): { fixture: MainnetReadinessFixture; path: string } { + const resolved = firstExisting( + candidatePaths(path, MAINNET_FIXTURE_PATH, cwd), + ); + const { raw } = readJson(resolved, "mainnet readiness fixture"); + let fixture: MainnetReadinessFixture; + try { + fixture = parseMainnetReadinessFixture(raw); + } catch (cause) { + throw new SubRosaManifestError( + `the mainnet readiness fixture at ${resolved} is invalid: ${normalizeError(cause).message}`, + { cause, field: fieldOf(cause), path: resolved }, + ); + } + return { fixture, path: resolved }; +} diff --git a/packages/sdk/src/mainnet-readiness.test.ts b/packages/sdk/src/mainnet-readiness.test.ts index 469d8c35..c6c2e1e7 100644 --- a/packages/sdk/src/mainnet-readiness.test.ts +++ b/packages/sdk/src/mainnet-readiness.test.ts @@ -1,36 +1,59 @@ // Copyright (c) 2026 Sub Rosa contributors import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { describe, it } from "node:test"; -import type { BidState, Round } from "@sub-rosa/round-bindings"; +import type { BidState, GlobalConfig, Round } from "@sub-rosa/round-bindings"; +import { SubRosaDeploymentMismatchError } from "./errors.js"; import { + loadMainnetManifest, + loadMainnetReadinessFixture, +} from "./mainnet-manifest.js"; +import { + MAINNET_ARTIFACTS, + MAINNET_MANIFEST, + MAINNET_MICRO_MAX_ESCROW, + MAINNET_XLM_SAC_ID, + parseMainnetManifest, + type MainnetManifest, +} from "./mainnet-artifacts.js"; +import { + assertDeploymentMatches, assertMainnetConfirmed, assertMicroAmounts, assertReadinessForExecute, + compareDeployment, defaultMainnetReadinessInput, + fixtureDeployment, hasBlockingFailures, + parseMainnetReadinessFixture, runMainnetReadiness, + summarizeDeploymentValue, verifySettledRoundProof, + type MainnetLiveDeployment, type MainnetReadinessDeps, + type MainnetReadinessFixture, + type ReadinessReader, } from "./mainnet-readiness.js"; -import { - MAINNET_ARTIFACTS, - MAINNET_CONFIRM_PHRASE, - MAINNET_MICRO_MAX_ESCROW, -} from "./mainnet-artifacts.js"; +import { MAINNET_CONFIRM_PHRASE } from "./mainnet-artifacts.js"; -const mockRpc = (balance = "1000000000"): MainnetReadinessDeps["rpc"] => +const mockRpc = ( + balance = "1000000000", + overrides: Partial> = {}, +): NonNullable => ({ getHealth: async () => ({ status: "healthy", latestLedger: 123, ledgerRetentionWindow: 1000, oldestLedger: 1 }), getLatestLedger: async () => ({ sequence: 123 }), getLedgerEntries: async () => ({ entries: [], latestLedger: 123 }), getAccountEntry: async () => ({ balance: () => ({ toString: () => balance }) }), + getNetwork: async () => ({ passphrase: MAINNET_MANIFEST.networkPassphrase, protocolVersion: "22" }), simulateTransaction: async () => ({ result: { retval: 0n } }), - }) as unknown as MainnetReadinessDeps["rpc"]; + ...overrides, + }) as unknown as NonNullable; -const CONTRACT_ID = MAINNET_ARTIFACTS.contractId; +const CONTRACT_ID = MAINNET_MANIFEST.contractId; const BIDDER = "GBIDDERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; const settledRound = { @@ -41,21 +64,51 @@ const settledRound = { item_ref: Buffer.alloc(32), operator: "GOPERATOR", reveal_deadline: 1_000n, - reveal_round: BigInt(MAINNET_ARTIFACTS.revealRound), + reveal_round: BigInt(MAINNET_MANIFEST.revealRound), status: { tag: "Settled", values: undefined }, winner: BIDDER, - winning_bid: MAINNET_ARTIFACTS.bidStroops, + winning_bid: MAINNET_MANIFEST.bidStroops, } as Round; const settledBidState: BidState = { commitment: Buffer.alloc(32), - escrow: MAINNET_ARTIFACTS.escrowStroops, + escrow: MAINNET_MANIFEST.escrowStroops, revealed_nonce: Buffer.alloc(32), - revealed_value: MAINNET_ARTIFACTS.bidStroops, + revealed_value: MAINNET_MANIFEST.bidStroops, settled: true, valid: true, }; +const mockReader = ( + overrides: Partial = {}, +): ReadinessReader => ({ + contractId: CONTRACT_ID, + networkPassphrase: MAINNET_MANIFEST.networkPassphrase, + getConfig: async () => ({ usdc: MAINNET_XLM_SAC_ID }) as unknown as GlobalConfig, + getRound: async () => settledRound, + getBidders: async () => [BIDDER], + getBidState: async () => settledBidState, + ...overrides, +}); + +/** A recorded deployment that matches the committed manifest exactly. */ +const committedFixture = (): MainnetReadinessFixture => { + const { fixture } = loadMainnetReadinessFixture(); + return fixture; +}; + +const matchingLive = (): MainnetLiveDeployment => ({ + contractId: MAINNET_MANIFEST.contractId, + networkPassphrase: MAINNET_MANIFEST.networkPassphrase, + wasmHash: MAINNET_MANIFEST.wasmHash, + tokenContract: MAINNET_MANIFEST.tokenContract, +}); + +const fixtureRun = async (fixture: MainnetReadinessFixture) => + runMainnetReadiness( + defaultMainnetReadinessInput({ fixture, manifestSource: "fixture" }), + ); + describe("assertMainnetConfirmed", () => { it("accepts the required confirmation phrase", () => { assert.doesNotThrow(() => @@ -84,47 +137,357 @@ describe("assertMicroAmounts", () => { ); assert.throws( () => assertMicroAmounts(1n, MAINNET_MICRO_MAX_ESCROW + 1n), - /MAINNET_MICRO_MAX_ESCROW/, + /exceeds MAINNET_MICRO_MAX_ESCROW/, ); }); }); -describe("verifySettledRoundProof", () => { - it("passes when round state matches frozen artifacts", async () => { - const reader = { - getRound: async () => settledRound, - getBidders: async () => [BIDDER], - getBidState: async () => settledBidState, - }; +describe("committed manifest", () => { + it("parses the committed file into the in-module manifest", () => { + const { manifest, path, sha256 } = loadMainnetManifest(); + assert.equal(path.replaceAll("\\", "/").endsWith("packages/sdk/mainnet-artifacts.json"), true); + assert.match(sha256, /^[0-9a-f]{64}$/); + assert.deepEqual(manifest, MAINNET_MANIFEST); + }); - await verifySettledRoundProof(reader, 1n, { - bidStroops: MAINNET_ARTIFACTS.bidStroops, - escrowStroops: MAINNET_ARTIFACTS.escrowStroops, - revealRound: MAINNET_ARTIFACTS.revealRound, + it("keeps MAINNET_ARTIFACTS and the manifest in step", () => { + assert.equal(MAINNET_MANIFEST.contractId, MAINNET_ARTIFACTS.contractId); + assert.equal(MAINNET_MANIFEST.wasmHash, MAINNET_ARTIFACTS.wasmHash); + assert.equal(MAINNET_MANIFEST.networkPassphrase, MAINNET_ARTIFACTS.networkPassphrase); + assert.equal(MAINNET_MANIFEST.bidStroops, MAINNET_ARTIFACTS.bidStroops); + assert.equal(MAINNET_MANIFEST.settledRoundId, BigInt(MAINNET_ARTIFACTS.settledRoundId)); + assert.equal(MAINNET_MANIFEST.tokenContract, MAINNET_XLM_SAC_ID); + }); + + it("is idempotent, so a parsed manifest can be re-validated", () => { + assert.deepEqual(parseMainnetManifest(MAINNET_MANIFEST), MAINNET_MANIFEST); + }); + + it("rejects an unknown field so a typo cannot skip a comparison", () => { + const raw = { ...MAINNET_MANIFEST, wasmHashh: MAINNET_MANIFEST.wasmHash }; + assert.throws( + () => parseMainnetManifest(raw), + /unknown field/, + ); + }); + + it("rejects malformed deployment identity fields", () => { + assert.throws( + () => parseMainnetManifest({ ...MAINNET_MANIFEST, contractId: "CA7KSDEY" }), + /contractId/, + ); + assert.throws( + () => parseMainnetManifest({ ...MAINNET_MANIFEST, wasmHash: "not-a-hash" }), + /wasmHash/, + ); + assert.throws( + () => parseMainnetManifest({ ...MAINNET_MANIFEST, tokenContract: "USDC" }), + /tokenContract/, + ); + assert.throws( + () => parseMainnetManifest({ ...MAINNET_MANIFEST, rpcUrl: "http://rpc.example" }), + /https/, + ); + assert.throws( + () => parseMainnetManifest({ ...MAINNET_MANIFEST, bidStroops: "60000000" }), + /bid cannot exceed escrow/, + ); + }); + + it("rejects a manifest that is not an object", () => { + assert.throws(() => parseMainnetManifest([]), /JSON object/); + assert.throws(() => parseMainnetManifest(null), /JSON object/); + }); +}); + +describe("summarizeDeploymentValue", () => { + it("prints public identifiers verbatim", () => { + assert.equal( + summarizeDeploymentValue("contractId", MAINNET_MANIFEST.contractId), + MAINNET_MANIFEST.contractId, + ); + assert.equal( + summarizeDeploymentValue("wasmHash", MAINNET_MANIFEST.wasmHash), + MAINNET_MANIFEST.wasmHash, + ); + }); + + it("fingerprints a passphrase instead of echoing it", () => { + const summary = summarizeDeploymentValue( + "networkPassphrase", + MAINNET_MANIFEST.networkPassphrase, + ); + assert.equal(summary.includes(MAINNET_MANIFEST.networkPassphrase), false); + assert.match(summary, /passphrase fingerprint [0-9a-f]{12}/); + }); + + it("redacts anything shaped like a Stellar secret key", () => { + const secret = `S${"A".repeat(55)}`; + const summary = summarizeDeploymentValue("networkPassphrase", secret); + assert.equal(summary, ""); + assert.equal(summary.includes(secret), false); + }); + + it("marks missing values as unread", () => { + assert.equal(summarizeDeploymentValue("wasmHash", null), ""); + }); +}); + +describe("compareDeployment", () => { + it("matches every field of a matching deployment", () => { + const comparison = compareDeployment(MAINNET_MANIFEST, matchingLive()); + assert.equal(comparison.matched, true); + assert.deepEqual(comparison.mismatched, []); + assert.equal(comparison.mismatchedFieldNames, ""); + }); + + it("normalises hash and contract id case", () => { + const comparison = compareDeployment(MAINNET_MANIFEST, { + ...matchingLive(), + wasmHash: MAINNET_MANIFEST.wasmHash.toUpperCase(), + contractId: MAINNET_MANIFEST.contractId.toLowerCase(), }); + assert.equal(comparison.matched, true); }); - it("fails when status is not settled", async () => { - const reader = { - getRound: async () => - ({ ...settledRound, status: { tag: "Open", values: undefined } }) as Round, - getBidders: async () => [BIDDER], - getBidState: async () => settledBidState, + const mismatches: Array<{ + field: keyof MainnetLiveDeployment; + value: string; + expected: string; + }> = [ + { + field: "contractId", + value: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + expected: "contractId", + }, + { + field: "networkPassphrase", + value: "Test SDF Network ; September 2015", + expected: "networkPassphrase", + }, + { + field: "wasmHash", + value: "deadbeef".repeat(8), + expected: "wasmHash", + }, + { + field: "tokenContract", + value: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + expected: "tokenContract", + }, + ]; + + for (const { field, value, expected } of mismatches) { + it(`flags a mismatched ${expected}`, () => { + const comparison = compareDeployment(MAINNET_MANIFEST, { + ...matchingLive(), + [field]: value, + }); + assert.equal(comparison.matched, false); + assert.deepEqual(comparison.mismatchedFieldNames, expected); + assert.equal(comparison.mismatched[0]?.ok, false); + }); + } + + it("never reports an unreadable field as a match", () => { + const comparison = compareDeployment(MAINNET_MANIFEST, { + ...matchingLive(), + tokenContract: null, + }); + assert.equal(comparison.matched, false); + assert.equal(comparison.unreadable.length, 1); + assert.equal(comparison.unreadable[0]?.field, "tokenContract"); + assert.equal(comparison.unreadable[0]?.actual, null); + }); +}); + +describe("assertDeploymentMatches", () => { + it("passes on a matching deployment and names the field on mismatch", () => { + assert.doesNotThrow(() => + assertDeploymentMatches(MAINNET_MANIFEST, matchingLive(), "manifest.json"), + ); + try { + assertDeploymentMatches( + MAINNET_MANIFEST, + { ...matchingLive(), wasmHash: "deadbeef".repeat(8) }, + "mainnet-artifacts.json", + ); + assert.fail("expected a mismatch"); + } catch (err) { + assert.ok(err instanceof SubRosaDeploymentMismatchError); + assert.deepEqual(err.fields, ["wasmHash"]); + assert.equal(err.manifestSource, "mainnet-artifacts.json"); + assert.match(err.message, /wasmHash/); + } + }); +}); + +describe("runMainnetReadiness — fixture mode (no mainnet RPC)", () => { + it("passes with the committed fixture", async () => { + const report = await fixtureRun(committedFixture()); + assert.equal(report.mode, "fixture"); + assert.equal(report.blockCount, 0); + assert.equal(hasBlockingFailures(report.checks), false); + assert.equal(report.deployment?.matched, true); + assert.equal(report.passCount >= 6, true); + }); + + it("never touches the injected dependencies", async () => { + const boom = async (): Promise => { + throw new Error("no RPC in fixture mode"); }; + const report = await runMainnetReadiness( + defaultMainnetReadinessInput({ fixture: committedFixture() }), + { + rpc: { + getHealth: boom, + getLatestLedger: boom, + getLedgerEntries: boom, + getAccountEntry: boom, + getNetwork: boom, + simulateTransaction: boom, + } as unknown as NonNullable, + reader: { + getRound: boom, + getBidders: boom, + getBidState: boom, + getConfig: boom, + } as unknown as ReadinessReader, + fetchWasmHash: boom, + sacBalance: boom, + }, + ); + assert.equal(report.mode, "fixture"); + assert.equal(report.blockCount, 0); + }); - await assert.rejects( + const fieldMutations: Array<{ + name: string; + checkId: string; + field: string; + mutate: (f: MainnetReadinessFixture) => MainnetReadinessFixture; + }> = [ + { + name: "wasm hash", + checkId: "wasm-hash", + field: "wasmHash", + mutate: (f) => ({ ...f, wasmHash: "deadbeef".repeat(8) }), + }, + { + name: "network passphrase", + checkId: "network-passphrase", + field: "networkPassphrase", + mutate: (f) => ({ ...f, networkPassphrase: "Test SDF Network ; September 2015" }), + }, + { + name: "contract id", + checkId: "contract-id", + field: "contractId", + mutate: (f) => ({ + ...f, + contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + }), + }, + { + name: "escrow token contract", + checkId: "token-contract", + field: "tokenContract", + mutate: (f) => ({ + ...f, + tokenContract: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + }), + }, + ]; + + for (const { name, checkId, field, mutate } of fieldMutations) { + it(`blocks on a mismatched ${name} and names the field`, async () => { + const report = await fixtureRun(mutate(committedFixture())); + const fieldCheck = report.checks.find((c) => c.id === checkId); + assert.equal(fieldCheck?.status, "block", `${name} must block`); + assert.equal(hasBlockingFailures(report.checks), true); + assert.equal(report.deployment?.matched, false); + assert.equal(report.deployment?.mismatchedFieldNames, field); + assert.throws( + () => assertReadinessForExecute(report.checks), + new RegExp(checkId), + ); + try { + assertDeploymentMatches( + MAINNET_MANIFEST, + fixtureDeployment(mutate(committedFixture())), + ); + assert.fail("expected a mismatch"); + } catch (err) { + assert.ok(err instanceof SubRosaDeploymentMismatchError); + assert.deepEqual(err.fields, [field]); + } + }); + } + + it("blocks a recorded balance that is not zero", async () => { + const report = await runMainnetReadiness( + defaultMainnetReadinessInput({ + fixture: { ...committedFixture(), contractBalance: "1000" }, + withBalances: true, + }), + ); + assert.equal( + report.checks.find((c) => c.id === "contract-balance")?.status, + "block", + ); + }); + + it("blocks when the recorded round is not settled", async () => { + const base = committedFixture(); + const report = await fixtureRun({ + ...base, + round: { ...base.round, status: "Cleared" }, + }); + const settled = report.checks.find((c) => c.id === "settled-round"); + assert.equal(settled?.status, "block"); + assert.match(settled?.message ?? "", /Cleared/); + }); +}); + +describe("parseMainnetReadinessFixture", () => { + it("rejects a fixture that is missing a field", () => { + const base = committedFixture() as unknown as Record; + const { tokenContract: _dropped, ...withoutToken } = base; + assert.throws( + () => parseMainnetReadinessFixture(withoutToken), + /tokenContract/, + ); + }); + + it("rejects a non-decimal stroop value", () => { + const base = committedFixture(); + assert.throws( () => - verifySettledRoundProof(reader, 1n, { - bidStroops: MAINNET_ARTIFACTS.bidStroops, - escrowStroops: MAINNET_ARTIFACTS.escrowStroops, - revealRound: MAINNET_ARTIFACTS.revealRound, + parseMainnetReadinessFixture({ + ...base, + round: { + ...base.round, + bid: { ...base.round.bid, escrow: "5e7" }, + }, }), - /Settled/, + /decimal integer string/, + ); + }); + + it("requires a bidder list", () => { + const base = committedFixture(); + assert.throws( + () => + parseMainnetReadinessFixture({ + ...base, + round: { ...base.round, bidders: [] }, + }), + /bidders/, ); }); }); -describe("runMainnetReadiness", () => { +describe("runMainnetReadiness — live comparison", () => { it("returns dry-run warnings without live RPC dependencies", async () => { const report = await runMainnetReadiness( defaultMainnetReadinessInput({ live: false, withBalances: true }), @@ -132,76 +495,241 @@ describe("runMainnetReadiness", () => { assert.equal(report.mode, "dry-run"); assert.ok(report.warnCount >= 3); - assert.equal(report.blockCount, 0); assert.equal(hasBlockingFailures(report.checks), false); }); - it("blocks on wasm hash mismatch with mocked RPC", async () => { - const reader = { - getRound: async () => settledRound, - getBidders: async () => [BIDDER], - getBidState: async () => settledBidState, - }; + it("passes live checks with mocked dependencies", async () => { const report = await runMainnetReadiness( - defaultMainnetReadinessInput({ contractId: CONTRACT_ID }), + defaultMainnetReadinessInput({ + withBalances: true, + operatorAccount: "GOPERATOR", + }), { - reader, - rpc: mockRpc(), - fetchWasmHash: async () => "deadbeef".repeat(8), + reader: mockReader(), + rpc: mockRpc("500000000"), + fetchWasmHash: async () => MAINNET_MANIFEST.wasmHash, + sacBalance: async (addr) => (addr === CONTRACT_ID ? 0n : 1n), }, ); - const wasmCheck = report.checks.find((c) => c.id === "wasm-hash"); - assert.equal(wasmCheck?.status, "block"); - assert.throws(() => assertReadinessForExecute(report.checks), /wasm-hash/); + assert.equal(hasBlockingFailures(report.checks), false); + assert.ok(report.passCount >= 8); + assert.equal(report.deployment?.matched, true); }); - it("passes live checks with mocked dependencies", async () => { - const reader = { - getRound: async () => settledRound, - getBidders: async () => [BIDDER], - getBidState: async () => settledBidState, - }; + const liveMismatches: Array<{ + name: string; + checkId: string; + deps: MainnetReadinessDeps; + }> = [ + { + name: "wasm hash", + checkId: "wasm-hash", + deps: { fetchWasmHash: async () => "deadbeef".repeat(8) }, + }, + { + name: "network passphrase", + checkId: "network-passphrase", + deps: { + rpc: mockRpc("500000000", { + getNetwork: async () => ({ + passphrase: "Test SDF Network ; September 2015", + protocolVersion: "22", + }), + }), + }, + }, + { + name: "escrow token contract", + checkId: "token-contract", + deps: { + reader: mockReader({ + getConfig: async () => + ({ + usdc: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + }) as unknown as GlobalConfig, + }), + }, + }, + { + name: "unreadable escrow token contract", + checkId: "token-contract", + deps: { + reader: mockReader({ + getConfig: async () => { + throw new Error("config not found"); + }, + }), + }, + }, + { + name: "contract id the client is bound to", + checkId: "contract-id", + deps: { + reader: mockReader({ + contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + }), + }, + }, + ]; + + for (const { name, checkId, deps } of liveMismatches) { + it(`blocks on a mismatched live ${name}`, async () => { + const report = await runMainnetReadiness(defaultMainnetReadinessInput(), { + reader: mockReader(), + rpc: mockRpc("500000000"), + fetchWasmHash: async () => MAINNET_MANIFEST.wasmHash, + ...deps, + }); + const fieldCheck = report.checks.find((c) => c.id === checkId); + assert.equal(fieldCheck?.status, "block", `${name} must block`); + assert.equal(hasBlockingFailures(report.checks), true); + assert.throws(() => assertReadinessForExecute(report.checks), new RegExp(checkId)); + }); + } + + it("blocks on config drift without reading the network", async () => { + // dry-run: the config/manifest comparison happens before any RPC use. + const report = await runMainnetReadiness( + defaultMainnetReadinessInput({ + live: false, + networkPassphrase: "Test SDF Network ; September 2015", + contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + }), + ); + assert.equal( + report.checks.find((c) => c.id === "config-network-passphrase")?.status, + "block", + ); + assert.equal( + report.checks.find((c) => c.id === "config-contract-id")?.status, + "block", + ); + }); + + it("blocks every deployment field when no read-only client is available", async () => { + const report = await runMainnetReadiness(defaultMainnetReadinessInput(), { + rpc: mockRpc("500000000"), + }); + for (const id of ["contract-id", "network-passphrase", "wasm-hash", "token-contract"]) { + assert.equal( + report.checks.find((c) => c.id === id)?.status, + "block", + `${id} must block without a client`, + ); + } + }); + + it("refuses to read a balance for a token the manifest does not pin", async () => { const report = await runMainnetReadiness( defaultMainnetReadinessInput({ withBalances: true, - operatorAccount: "GOPERATOR", + tokenSacId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", }), { - reader, + reader: mockReader(), rpc: mockRpc("500000000"), - fetchWasmHash: async () => MAINNET_ARTIFACTS.wasmHash, - sacBalance: async (addr) => (addr === CONTRACT_ID ? 0n : 1n), + fetchWasmHash: async () => MAINNET_MANIFEST.wasmHash, + sacBalance: async () => { + throw new Error("should not be called"); + }, }, ); + const balance = report.checks.find((c) => c.id === "contract-balance"); + assert.equal(balance?.status, "block"); + assert.match(balance?.message ?? "", /manifest pins/); + }); +}); - assert.equal(hasBlockingFailures(report.checks), false); - assert.ok(report.passCount >= 5); +describe("readiness output", () => { + it("names the mismatched fields in the formatted report", async () => { + const report = await fixtureRun({ + ...committedFixture(), + wasmHash: "deadbeef".repeat(8), + tokenContract: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + }); + const { formatReadinessReport } = await import("./mainnet-readiness.js"); + const text = formatReadinessReport(report); + assert.match(text, /Mismatched deployment fields: wasmHash, tokenContract/); + assert.match(text, /Manifest: fixture/); + }); + + it("keeps a passphrase out of the report even if one is misconfigured", async () => { + const secret = `S${"B".repeat(55)}`; + const report = await fixtureRun({ + ...committedFixture(), + networkPassphrase: secret, + }); + const { formatReadinessReport } = await import("./mainnet-readiness.js"); + const text = formatReadinessReport(report); + assert.equal(text.includes(secret), false); + assert.match(text, /Network passphrase: .*disagrees with the committed manifest/); }); +}); + +describe("no secret key is required", () => { + it("completes a fixture run with no secrets in the environment", async () => { + const before = { ...process.env }; + for (const key of Object.keys(process.env)) { + if (/SECRET|SEED|MNEMONIC/i.test(key)) delete process.env[key]; + } + try { + const report = await fixtureRun(committedFixture()); + assert.equal(report.blockCount, 0); + } finally { + for (const key of Object.keys(process.env)) delete process.env[key]; + Object.assign(process.env, before); + } + }); + + it("keeps secret handling out of the readiness code and scripts", () => { + const sources = [ + "src/mainnet-readiness.ts", + "src/mainnet-manifest.ts", + "src/mainnet-artifacts.ts", + "scripts/mainnet-ready.ts", + "scripts/mainnet-verify.ts", + ]; + for (const file of sources) { + const text = readFileSync(new URL(`../${file}`, import.meta.url), "utf8"); + assert.equal(/fromSecret\(/.test(text), false, `${file} must not derive keys`); + assert.equal(/\bsecretKey\b/.test(text), false, `${file} must not configure a signer`); + assert.equal(/_SECRET\b/.test(text), false, `${file} must not read *_SECRET env vars`); + } + }); +}); - it("blocks when contract escrow balance is non-zero", async () => { +describe("verifySettledRoundProof", () => { + it("passes when round state matches frozen artifacts", async () => { const reader = { getRound: async () => settledRound, getBidders: async () => [BIDDER], getBidState: async () => settledBidState, }; - const report = await runMainnetReadiness( - defaultMainnetReadinessInput({ withBalances: true }), - { - reader, - rpc: { - ...mockRpc(), - simulateTransaction: async () => - ({ result: { retval: 1n } }) as unknown as Awaited< - ReturnType["simulateTransaction"]> - >, - } as MainnetReadinessDeps["rpc"], - fetchWasmHash: async () => MAINNET_ARTIFACTS.wasmHash, - sacBalance: async () => 1_000_000n, - }, - ); - const balanceCheck = report.checks.find((c) => c.id === "contract-balance"); - assert.equal(balanceCheck?.status, "block"); + await verifySettledRoundProof(reader, 1n, { + bidStroops: MAINNET_MANIFEST.bidStroops, + escrowStroops: MAINNET_MANIFEST.escrowStroops, + revealRound: Number(MAINNET_MANIFEST.revealRound), + }); + }); + + it("fails when status is not settled", async () => { + const reader = { + getRound: async () => + ({ ...settledRound, status: { tag: "Open", values: undefined } }) as Round, + getBidders: async () => [BIDDER], + getBidState: async () => settledBidState, + }; + + await assert.rejects( + () => + verifySettledRoundProof(reader, 1n, { + bidStroops: MAINNET_MANIFEST.bidStroops, + escrowStroops: MAINNET_MANIFEST.escrowStroops, + revealRound: Number(MAINNET_MANIFEST.revealRound), + }), + /Settled/, + ); }); }); diff --git a/packages/sdk/src/mainnet-readiness.ts b/packages/sdk/src/mainnet-readiness.ts index 4bdcb83d..b3478777 100644 --- a/packages/sdk/src/mainnet-readiness.ts +++ b/packages/sdk/src/mainnet-readiness.ts @@ -9,15 +9,23 @@ import { scValToNative, TransactionBuilder, } from "@stellar/stellar-sdk"; +import type { BidState, GlobalConfig, Round, Status } from "@sub-rosa/round-bindings"; import type { SubRosaClient } from "./client.js"; import { - MAINNET_ARTIFACTS, - MAINNET_CONFIRM_PHRASE, + DEPLOYMENT_CHECK_IDS, + DEPLOYMENT_FIELDS, + DEPLOYMENT_FIELD_LABELS, MAINNET_DEPLOY_MIN_XLM_STROOPS, + MAINNET_CONFIRM_PHRASE, + MAINNET_MANIFEST, MAINNET_MICRO_MAX_ESCROW, MAINNET_MIN_FEE_RESERVE_STROOPS, + type DeploymentField, + type MainnetManifest, } from "./mainnet-artifacts.js"; +import { SubRosaDeploymentMismatchError } from "./errors.js"; +import { networkFingerprint } from "./receipt.js"; export type ReadinessStatus = "pass" | "warn" | "block"; @@ -28,17 +36,86 @@ export interface ReadinessCheck { message: string; } +/** + * Deployment identity read from the network (or replayed from a recorded + * fixture). `null` means the field could not be read, which is a failure — an + * unverifiable field must never read as a pass. + */ +export interface MainnetLiveDeployment { + contractId: string | null; + networkPassphrase: string | null; + wasmHash: string | null; + tokenContract: string | null; + /** Normalized reason a field could not be read, keyed by field. */ + unreadable?: Partial>; +} + +export interface DeploymentFieldComparison { + field: DeploymentField; + label: string; + /** Redacted manifest value, safe to log. */ + expected: string; + /** Redacted live value, or null when the field could not be read. */ + actual: string | null; + ok: boolean; + /** True when the field could not be read (treated as a failure, not a pass). */ + unreadable: boolean; +} + +export interface DeploymentComparison { + fields: DeploymentFieldComparison[]; + /** Fields whose value is present but different. */ + mismatched: DeploymentFieldComparison[]; + /** Fields that could not be read at all. */ + unreadable: DeploymentFieldComparison[]; + matched: boolean; + /** Comma-separated field names, for logs and error messages. */ + mismatchedFieldNames: string; +} + +export interface MainnetFixtureBid { + escrow: string; + revealedValue: string | null; + valid: boolean; + settled: boolean; +} + +export interface MainnetFixtureRound { + roundId: string; + status: string; + revealRound: string; + bidders: string[]; + bid: MainnetFixtureBid; +} + +/** + * A recorded mainnet snapshot. Replaying one runs the exact same comparison as + * a live read, so CI can prove the strict check fails closed without touching a + * mainnet RPC or any secret. + */ +export interface MainnetReadinessFixture { + networkPassphrase: string; + contractId: string; + wasmHash: string; + tokenContract: string; + /** Ledger the recording came from, for context only. */ + ledger?: number; + /** Recorded contract escrow balance in stroops. */ + contractBalance?: string; + round: MainnetFixtureRound; +} + export interface MainnetReadinessInput { + /** The committed manifest. Source of truth for every expected value. */ + manifest: MainnetManifest; + /** Where the manifest came from (path), echoed in the report. */ + manifestSource?: string; rpcUrl: string; networkPassphrase: string; contractId: string; - expectedWasmHash: string; - settledRoundId: bigint; - expectedBidStroops: bigint; - expectedEscrowStroops: bigint; - expectedRevealRound: number; - /** When false, emit dry-run placeholders instead of live RPC checks. */ live?: boolean; + /** Recorded deployment metadata. When set, no RPC call is made. */ + fixture?: MainnetReadinessFixture; /** When true, include optional balance checks when account ids are provided. */ withBalances?: boolean; tokenSacId?: string; @@ -47,14 +124,21 @@ export interface MainnetReadinessInput { bidderAccount?: string; } +export type ReadinessReader = Pick< + SubRosaClient, + "getRound" | "getBidState" | "getBidders" | "getConfig" +> & + Partial>; + export interface MainnetReadinessDeps { - reader?: Pick; + reader?: ReadinessReader; rpc?: Pick< rpc.Server, | "getHealth" | "getLatestLedger" | "getLedgerEntries" | "getAccountEntry" + | "getNetwork" | "simulateTransaction" >; sacBalance?: (address: string) => Promise; @@ -62,11 +146,14 @@ export interface MainnetReadinessDeps { } export interface MainnetReadinessReport { - mode: "dry-run" | "live"; + mode: "dry-run" | "fixture" | "live"; checks: ReadinessCheck[]; passCount: number; warnCount: number; blockCount: number; + /** Live-or-fixture deployment compared against the manifest. */ + deployment?: DeploymentComparison; + manifestSource?: string; } const check = ( @@ -76,6 +163,120 @@ const check = ( message: string, ): ReadinessCheck => ({ id, label, status, message }); +/** Stellar secret keys are S… + 55 base32 chars. Never echo one. */ +const SECRET_KEY_RE = /^S[A-Z2-7]{55}$/i; + +/** + * Render a value for a report or log line. Contract ids and wasm hashes are + * public identifiers and print in full; a passphrase is reduced to a short + * fingerprint, and anything shaped like a secret key is redacted outright. + */ +export function summarizeDeploymentValue( + field: DeploymentField, + value: string | null | undefined, +): string { + if (value === null || value === undefined) return ""; + const trimmed = value.trim(); + if (trimmed.length === 0) return ""; + if (SECRET_KEY_RE.test(trimmed)) return ""; + if (field === "networkPassphrase") { + return `passphrase fingerprint ${fingerprint(trimmed)}`; + } + return trimmed; +} + +function fingerprint(value: string): string { + return networkFingerprint(value).slice(0, 12); +} + +function sameValue(field: DeploymentField, a: string, b: string): boolean { + if (field === "wasmHash") return a.toLowerCase() === b.toLowerCase(); + if (field === "contractId" || field === "tokenContract") { + return a.toUpperCase() === b.toUpperCase(); + } + return a.trim() === b.trim(); +} + +/** + * Compare the four deployment identity fields against the committed manifest. + * Pure: no RPC, no I/O, so it is the same code path in CI and on a laptop. + */ +export function compareDeployment( + manifest: MainnetManifest, + live: MainnetLiveDeployment, +): DeploymentComparison { + const fields: DeploymentFieldComparison[] = DEPLOYMENT_FIELDS.map((field) => { + const expected = manifest[field] ?? ""; + const raw = live[field]; + const present = typeof raw === "string" && raw.trim().length > 0; + const unreadable = !present; + return { + field, + label: DEPLOYMENT_FIELD_LABELS[field], + expected: summarizeDeploymentValue(field, expected), + actual: present ? summarizeDeploymentValue(field, raw) : null, + ok: present && sameValue(field, expected, raw as string), + unreadable, + }; + }); + + const mismatched = fields.filter((f) => !f.ok && !f.unreadable); + const unreadable = fields.filter((f) => f.unreadable); + return { + fields, + mismatched, + unreadable, + matched: mismatched.length === 0 && unreadable.length === 0, + mismatchedFieldNames: [...mismatched, ...unreadable] + .map((f) => f.field) + .join(", "), + }; +} + +function comparisonDetail(comparison: DeploymentFieldComparison): string { + return comparison.unreadable + ? `${DEPLOYMENT_FIELD_LABELS[comparison.field]} could not be read from the deployment (manifest expects ${comparison.expected})` + : `${DEPLOYMENT_FIELD_LABELS[comparison.field]} disagrees with the committed manifest: manifest ${comparison.expected}, deployment ${comparison.actual}`; +} + +/** + * Turn a comparison into the four blocking readiness checks. Every disagreement + * blocks, including a field that could not be read at all. + */ +export function deploymentChecks( + manifest: MainnetManifest, + live: MainnetLiveDeployment, +): { checks: ReadinessCheck[]; comparison: DeploymentComparison } { + const comparison = compareDeployment(manifest, live); + const checks = comparison.fields.map((field) => + check( + DEPLOYMENT_CHECK_IDS[field.field], + field.label, + field.ok ? "pass" : "block", + field.ok + ? `matches the committed manifest (${field.expected})` + : comparisonDetail(field), + ), + ); + return { checks, comparison }; +} + +/** Throw when the live deployment disagrees with the committed manifest. */ +export function assertDeploymentMatches( + manifest: MainnetManifest, + live: MainnetLiveDeployment, + manifestSource?: string, +): DeploymentComparison { + const comparison = compareDeployment(manifest, live); + if (comparison.matched) return comparison; + const bad = [...comparison.mismatched, ...comparison.unreadable]; + throw new SubRosaDeploymentMismatchError({ + fields: bad.map((f) => f.field), + details: bad.map(comparisonDetail), + manifestSource, + }); +} + export function nativeXlmSacId(networkPassphrase: string): string { return Asset.native().contractId(networkPassphrase); } @@ -123,13 +324,20 @@ export function formatReadinessReport(report: MainnetReadinessReport): string { const lines = [ "Sub Rosa — mainnet launch readiness (read-only)", `Mode: ${report.mode}`, - "", ]; + if (report.manifestSource) lines.push(`Manifest: ${report.manifestSource}`); + lines.push(""); for (const c of report.checks) { const tag = c.status === "pass" ? "PASS" : c.status === "warn" ? "WARN" : "BLOCK"; lines.push(`[${tag}] ${c.label}: ${c.message}`); } + if (report.deployment && !report.deployment.matched) { + lines.push(""); + lines.push( + `Mismatched deployment fields: ${report.deployment.mismatchedFieldNames || "none"}`, + ); + } lines.push(""); lines.push( `Summary: ${report.passCount} pass, ${report.warnCount} warn, ${report.blockCount} block`, @@ -207,20 +415,268 @@ export async function verifySettledRoundProof( } if (bidState.revealed_value !== expected.bidStroops) { throw new Error( - `revealed ${bidState.revealed_value} != ${expected.bidStroops}`, + `revealed ${bidState.revealed_value} != expected ${expected.bidStroops}`, ); } if (bidState.escrow !== expected.escrowStroops) { - throw new Error(`escrow ${bidState.escrow} != ${expected.escrowStroops}`); + throw new Error(`escrow ${bidState.escrow} != expected ${expected.escrowStroops}`); } if (!bidState.valid || !bidState.settled) { throw new Error("bid not valid/settled"); } } +function fixtureStatus(tag: string): Status { + // Keep the recorded tag verbatim so a mismatched or mistyped fixture status + // shows up in the report instead of being coerced into a lifecycle state. + return { tag, values: undefined } as unknown as Status; +} + +class FixtureFieldError extends Error { + readonly field: string; + + constructor(field: string, detail: string) { + super(`mainnet fixture field ${field} is invalid: ${detail}`); + this.name = "FixtureFieldError"; + this.field = field; + } +} + +function fixtureString( + raw: Record, + field: string, +): string { + const value = raw[field]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new FixtureFieldError(field, "expected a non-empty string"); + } + return value.trim(); +} + +function fixtureBigIntString( + raw: Record, + field: string, +): string { + const value = fixtureString(raw, field); + if (!/^\d+$/.test(value)) { + throw new FixtureFieldError(field, "expected a decimal integer string"); + } + return value; +} + +function fixtureRecord( + raw: unknown, + field: string, +): Record { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + throw new FixtureFieldError(field, "expected an object"); + } + return raw as Record; +} + +function fixtureBoolean( + raw: Record, + field: string, +): boolean { + const value = raw[field]; + if (typeof value !== "boolean") { + throw new FixtureFieldError(field, "expected true or false"); + } + return value; +} + +/** + * Validate a recorded fixture. A fixture is test data, so it gets the same + * closed-schema treatment as the manifest: an unreadable field is a load error + * rather than a check that quietly passes. + */ +export function parseMainnetReadinessFixture(raw: unknown): MainnetReadinessFixture { + const record = fixtureRecord(raw, ""); + const round = fixtureRecord(record["round"], "round"); + const bid = fixtureRecord(round["bid"], "round.bid"); + const revealedValue = bid["revealedValue"]; + if (revealedValue !== null && typeof revealedValue !== "string") { + throw new FixtureFieldError("round.bid.revealedValue", "expected a string or null"); + } + const bidders = round["bidders"]; + if (!Array.isArray(bidders) || bidders.length === 0) { + throw new FixtureFieldError("round.bidders", "expected a non-empty array"); + } + for (const [index, bidder] of bidders.entries()) { + if (typeof bidder !== "string" || bidder.trim().length === 0) { + throw new FixtureFieldError( + `round.bidders[${index}]`, + "expected a non-empty string", + ); + } + } + const ledger = record["ledger"]; + if (ledger !== undefined && !Number.isSafeInteger(ledger as number)) { + throw new FixtureFieldError("ledger", "expected a ledger sequence number"); + } + const contractBalance = record["contractBalance"]; + if (contractBalance !== undefined) { + if (typeof contractBalance !== "string" || !/^\d+$/.test(contractBalance)) { + throw new FixtureFieldError( + "contractBalance", + "expected a decimal integer string in stroops", + ); + } + } + + return { + networkPassphrase: fixtureString(record, "networkPassphrase"), + contractId: fixtureString(record, "contractId"), + wasmHash: fixtureString(record, "wasmHash"), + tokenContract: fixtureString(record, "tokenContract"), + ...(ledger === undefined ? {} : { ledger: ledger as number }), + ...(contractBalance === undefined + ? {} + : { contractBalance: contractBalance as string }), + round: { + roundId: fixtureBigIntString(round, "roundId"), + status: fixtureString(round, "status"), + revealRound: fixtureBigIntString(round, "revealRound"), + bidders: bidders.map((b) => String(b).trim()), + bid: { + escrow: fixtureBigIntString(bid, "escrow"), + revealedValue: + revealedValue === null ? null : String(revealedValue).trim(), + valid: fixtureBoolean(bid, "valid"), + settled: fixtureBoolean(bid, "settled"), + }, + }, + }; +} + +/** Build a read-only client over a recorded fixture. No RPC, no signing. */ +export function fixtureReader( + fixture: MainnetReadinessFixture, +): ReadinessReader { + const round = { + auditor_pubkey: Buffer.alloc(32), + bidders: fixture.round.bidders, + clearing_rule: { tag: "HighestBid", values: undefined }, + commit_deadline: 0n, + item_ref: Buffer.alloc(32), + operator: fixture.round.bidders[0] ?? "GOPERATOR", + reveal_deadline: 0n, + reveal_round: BigInt(fixture.round.revealRound), + status: fixtureStatus(fixture.round.status), + winner: fixture.round.bidders[0] ?? null, + winning_bid: fixture.round.bid.revealedValue + ? BigInt(fixture.round.bid.revealedValue) + : 0n, + } as unknown as Round; + + const bidState = { + commitment: Buffer.alloc(32), + escrow: BigInt(fixture.round.bid.escrow), + revealed_nonce: Buffer.alloc(32), + revealed_value: fixture.round.bid.revealedValue + ? BigInt(fixture.round.bid.revealedValue) + : undefined, + settled: fixture.round.bid.settled, + valid: fixture.round.bid.valid, + } as unknown as BidState; + + return { + contractId: fixture.contractId, + networkPassphrase: fixture.networkPassphrase, + getConfig: async () => + ({ usdc: fixture.tokenContract }) as unknown as GlobalConfig, + getRound: async (roundId) => { + if (BigInt(roundId) !== BigInt(fixture.round.roundId)) { + throw new Error( + `round ${roundId.toString()} is not in the recorded fixture`, + ); + } + return round; + }, + getBidders: async (roundId) => { + if (BigInt(roundId) !== BigInt(fixture.round.roundId)) { + throw new Error( + `round ${roundId.toString()} is not in the recorded fixture`, + ); + } + return [...fixture.round.bidders]; + }, + getBidState: async (roundId) => { + if (BigInt(roundId) !== BigInt(fixture.round.roundId)) { + throw new Error( + `round ${roundId.toString()} is not in the recorded fixture`, + ); + } + return bidState; + }, + }; +} + +/** Read a recorded snapshot as if it had been read from the network. */ +export function fixtureDeployment( + fixture: MainnetReadinessFixture, +): MainnetLiveDeployment { + return { + contractId: fixture.contractId, + networkPassphrase: fixture.networkPassphrase, + wasmHash: fixture.wasmHash, + tokenContract: fixture.tokenContract, + }; +} + +/** + * Read the deployment identity through the read-only client and the RPC. Each + * field is read independently so one failure still reports the other three. + */ +export async function readLiveDeployment( + reader: ReadinessReader, + rpcServer: Pick, + contractId: string, + fetchWasmHash: (contractId: string) => Promise, +): Promise { + const unreadable: Partial> = {}; + const [networkResult, wasmResult, configResult] = await Promise.allSettled([ + rpcServer.getNetwork(), + fetchWasmHash(contractId), + reader.getConfig(), + ]); + + const networkPassphrase = + networkResult.status === "fulfilled" + ? networkResult.value.passphrase + : null; + if (networkResult.status === "rejected") { + unreadable.networkPassphrase = normalizeError(networkResult.reason).message; + } + + const wasmHash = wasmResult.status === "fulfilled" ? wasmResult.value : null; + if (wasmResult.status === "rejected") { + unreadable.wasmHash = normalizeError(wasmResult.reason).message; + } + + const config = configResult.status === "fulfilled" ? configResult.value : null; + const tokenContract = + config && typeof config.usdc === "string" && config.usdc.trim().length > 0 + ? config.usdc + : null; + if (configResult.status === "rejected") { + unreadable.tokenContract = normalizeError(configResult.reason).message; + } else if (config && tokenContract === null) { + unreadable.tokenContract = "the deployed contract reports no escrow token address"; + } + + return { + contractId: reader.contractId ?? contractId, + networkPassphrase, + wasmHash, + tokenContract, + unreadable, + }; +} + function summarize(checks: ReadinessCheck[]): Omit< MainnetReadinessReport, - "mode" | "checks" + "mode" | "checks" | "deployment" | "manifestSource" > { return { passCount: checks.filter((c) => c.status === "pass").length, @@ -229,76 +685,81 @@ function summarize(checks: ReadinessCheck[]): Omit< }; } -export async function runMainnetReadiness( +/** Configured values must agree with the manifest before anything is read. */ +function configChecks( input: MainnetReadinessInput, - deps: MainnetReadinessDeps = {}, -): Promise { +): ReadinessCheck[] { const checks: ReadinessCheck[] = []; - const live = input.live ?? true; + const passphraseOk = + input.networkPassphrase.trim() === input.manifest.networkPassphrase; + checks.push( + check( + "config-network-passphrase", + "Configured network passphrase", + passphraseOk ? "pass" : "block", + passphraseOk + ? `${summarizeDeploymentValue("networkPassphrase", input.networkPassphrase)} matches the committed manifest` + : `configured passphrase (${summarizeDeploymentValue("networkPassphrase", input.networkPassphrase)}) is not the passphrase the committed manifest pins — a green check here would be a check against the wrong network`, + ), + ); - if (input.networkPassphrase === MAINNET_ARTIFACTS.networkPassphrase) { - checks.push( - check( - "network-passphrase", - "Network passphrase", - "pass", - "matches Stellar mainnet", - ), - ); - } else { - checks.push( - check( - "network-passphrase", - "Network passphrase", - "block", - `expected mainnet passphrase, got ${JSON.stringify(input.networkPassphrase)}`, - ), - ); - } + const contractOk = + input.contractId.trim().toUpperCase() === + input.manifest.contractId.toUpperCase(); + checks.push( + check( + "config-contract-id", + "Configured contract id", + contractOk ? "pass" : "block", + contractOk + ? `${input.contractId} matches the committed manifest` + : `configured contract ${input.contractId} is not the contract the committed manifest pins (${input.manifest.contractId})`, + ), + ); + return checks; +} - if (/^https:\/\//i.test(input.rpcUrl)) { - checks.push( - check("rpc-url", "RPC URL", "pass", `uses HTTPS (${input.rpcUrl})`), - ); - } else if (/^http:\/\//i.test(input.rpcUrl)) { - checks.push( - check( - "rpc-url", - "RPC URL", - "warn", - "uses HTTP — prefer HTTPS for mainnet", - ), - ); - } else { - checks.push( - check( - "rpc-url", - "RPC URL", - "block", - `invalid RPC URL ${JSON.stringify(input.rpcUrl)}`, - ), - ); +function rpcUrlCheck(rpcUrl: string): ReadinessCheck { + if (/^https:\/\//i.test(rpcUrl)) { + return check("rpc-url", "RPC URL", "pass", `uses HTTPS (${rpcUrl})`); } - - if (input.contractId === MAINNET_ARTIFACTS.contractId) { - checks.push( - check( - "contract-id", - "Contract id", - "pass", - "matches frozen artifact", - ), - ); - } else { - checks.push( - check( - "contract-id", - "Contract id", - "warn", - `differs from frozen artifact (${MAINNET_ARTIFACTS.contractId})`, - ), + if (/^http:\/\//i.test(rpcUrl)) { + return check( + "rpc-url", + "RPC URL", + "warn", + "uses HTTP — prefer HTTPS for mainnet", ); } + return check( + "rpc-url", + "RPC URL", + "block", + `invalid RPC URL ${JSON.stringify(rpcUrl)}`, + ); +} + +export async function runMainnetReadiness( + input: MainnetReadinessInput, + deps: MainnetReadinessDeps = {}, +): Promise { + const checks: ReadinessCheck[] = []; + const manifest = input.manifest; + const fixture = input.fixture; + const live = fixture ? true : (input.live ?? true); + + checks.push( + check( + "manifest-source", + "Committed artifact manifest", + "pass", + input.manifestSource + ? `loaded ${input.manifestSource} (${manifest.contractId} @ ${manifest.network})` + : `built-in manifest for ${manifest.contractId} (${manifest.network})`, + ), + ); + checks.push(...configChecks(input)); + checks.push(rpcUrlCheck(input.rpcUrl)); if (!live) { checks.push( @@ -308,17 +769,23 @@ export async function runMainnetReadiness( "warn", "dry-run — would ping RPC health", ), - check( - "wasm-hash", - "Artifact wasm hash", - "warn", - `dry-run — would verify ${input.expectedWasmHash}`, - ), + ); + for (const field of DEPLOYMENT_FIELDS) { + checks.push( + check( + DEPLOYMENT_CHECK_IDS[field], + DEPLOYMENT_FIELD_LABELS[field], + "warn", + `dry-run — would compare the live ${DEPLOYMENT_FIELD_LABELS[field].toLowerCase()} against the committed manifest`, + ), + ); + } + checks.push( check( "settled-round", "Settled round proof", "warn", - `dry-run — would verify round ${input.settledRoundId.toString()}`, + `dry-run — would verify round ${manifest.settledRoundId.toString()}`, ), ); if (input.withBalances) { @@ -327,17 +794,84 @@ export async function runMainnetReadiness( "contract-balance", "Contract escrow balance", "warn", - "dry-run — would assert contract SAC balance is 0", + "dry-run — would assert contract token balance is 0", ), ); } return { mode: "dry-run", checks, + manifestSource: input.manifestSource, ...summarize(checks), }; } + const expectedRound = { + bidStroops: manifest.bidStroops, + escrowStroops: manifest.escrowStroops, + revealRound: Number(manifest.revealRound), + }; + + // ── Recorded fixture: same comparison, no mainnet RPC ──────────────────── + if (fixture) { + const { checks: deployment, comparison } = deploymentChecks( + manifest, + fixtureDeployment(fixture), + ); + checks.push( + check( + "rpc-reachable", + "RPC reachable", + "pass", + fixture.ledger === undefined + ? "fixture mode — recorded deployment, no RPC" + : `fixture mode — recorded at ledger ${fixture.ledger}, no RPC`, + ), + ...deployment, + ); + try { + await verifySettledRoundProof(fixtureReader(fixture), manifest.settledRoundId, expectedRound); + checks.push( + check( + "settled-round", + "Settled round proof", + "pass", + `recorded round ${manifest.settledRoundId.toString()} settled with expected amounts`, + ), + ); + } catch (err) { + checks.push( + check( + "settled-round", + "Settled round proof", + "block", + normalizeError(err).message, + ), + ); + } + if (input.withBalances && fixture.contractBalance !== undefined) { + const balance = BigInt(fixture.contractBalance); + checks.push( + check( + "contract-balance", + "Contract escrow balance", + balance === 0n ? "pass" : "block", + balance === 0n + ? "recorded contract escrow balance is 0" + : `expected 0 stroops, recorded ${balance.toString()}`, + ), + ); + } + return { + mode: "fixture", + checks, + deployment: comparison, + manifestSource: input.manifestSource, + ...summarize(checks), + }; + } + + // ── Live: read the deployment through the read-only client ─────────────── const rpcServer = deps.rpc ?? new rpc.Server(input.rpcUrl); try { @@ -366,118 +900,120 @@ export async function runMainnetReadiness( deps.fetchWasmHash ?? ((contractId: string) => fetchContractWasmHash(rpcServer, contractId)); - try { - const onChainHash = await fetchWasmHash(input.contractId); - if (onChainHash.toLowerCase() === input.expectedWasmHash.toLowerCase()) { - checks.push( - check( - "wasm-hash", - "Artifact wasm hash", - "pass", - "on-chain hash matches frozen artifact", - ), - ); - } else { + if (!deps.reader) { + for (const field of DEPLOYMENT_FIELDS) { checks.push( check( - "wasm-hash", - "Artifact wasm hash", + DEPLOYMENT_CHECK_IDS[field], + DEPLOYMENT_FIELD_LABELS[field], "block", - `on-chain ${onChainHash} != artifact ${input.expectedWasmHash}`, + "read-only client dependency missing — the deployment cannot be compared with the committed manifest", ), ); } - } catch (err) { checks.push( check( - "wasm-hash", - "Artifact wasm hash", + "settled-round", + "Settled round proof", "block", - normalizeError(err).message, + "reader dependency missing", ), ); + return { + mode: "live", + checks, + manifestSource: input.manifestSource, + ...summarize(checks), + }; } - if (!deps.reader) { + const liveDeployment = await readLiveDeployment( + deps.reader, + rpcServer, + input.contractId, + fetchWasmHash, + ); + const { checks: deployment, comparison } = deploymentChecks( + manifest, + liveDeployment, + ); + checks.push(...deployment); + + try { + await verifySettledRoundProof(deps.reader, manifest.settledRoundId, expectedRound); + checks.push( + check( + "settled-round", + "Settled round proof", + "pass", + `round ${manifest.settledRoundId.toString()} settled with expected amounts`, + ), + ); + } catch (err) { checks.push( check( "settled-round", "Settled round proof", "block", - "reader dependency missing", + normalizeError(err).message, ), ); - } else { - try { - await verifySettledRoundProof(deps.reader, input.settledRoundId, { - bidStroops: input.expectedBidStroops, - escrowStroops: input.expectedEscrowStroops, - revealRound: input.expectedRevealRound, - }); - checks.push( - check( - "settled-round", - "Settled round proof", - "pass", - `round ${input.settledRoundId.toString()} settled with expected amounts`, - ), - ); - } catch (err) { + } + + if (input.withBalances) { + const tokenSacId = input.tokenSacId ?? manifest.tokenContract; + if (tokenSacId !== manifest.tokenContract) { checks.push( check( - "settled-round", - "Settled round proof", + "contract-balance", + "Contract escrow balance", "block", - normalizeError(err).message, + `refusing to read the balance of ${tokenSacId}: the committed manifest pins ${manifest.tokenContract}`, ), ); - } - } - - if (input.withBalances) { - const tokenSacId = - input.tokenSacId ?? nativeXlmSacId(input.networkPassphrase); - const sacBalance = - deps.sacBalance ?? - createSacBalanceReader( - input.rpcUrl, - input.networkPassphrase, - tokenSacId, - input.operatorAccount ?? - input.keeperAccount ?? - "GCDARJFKKSTJYAZC647H4ZSSSPXPPSKOWOHGMUNCT22VG74KXZ5BHVNR", - ); - - try { - const contractBalance = await sacBalance(input.contractId); - if (contractBalance === 0n) { - checks.push( - check( - "contract-balance", - "Contract escrow balance", - "pass", - "native XLM SAC balance is 0", - ), + } else { + const sacBalance = + deps.sacBalance ?? + createSacBalanceReader( + input.rpcUrl, + input.networkPassphrase, + tokenSacId, + input.operatorAccount ?? + input.keeperAccount ?? + "GCDARJFKKSTJYAZC647H4ZSSSPXPPSKOWOHGMUNCT22VG74KXZ5BHVNR", ); - } else { + + try { + const contractBalance = await sacBalance(input.contractId); + if (contractBalance === 0n) { + checks.push( + check( + "contract-balance", + "Contract escrow balance", + "pass", + "escrow token balance is 0", + ), + ); + } else { + checks.push( + check( + "contract-balance", + "Contract escrow balance", + "block", + `expected 0 stroops, got ${contractBalance.toString()}`, + ), + ); + } + } catch (err) { checks.push( check( "contract-balance", "Contract escrow balance", "block", - `expected 0 stroops, got ${contractBalance.toString()}`, + normalizeError(err).message, ), ); } - } catch (err) { - checks.push( - check( - "contract-balance", - "Contract escrow balance", - "block", - normalizeError(err).message, - ), - ); } const accountChecks: Array<{ @@ -551,22 +1087,21 @@ export async function runMainnetReadiness( return { mode: "live", checks, + deployment: comparison, + manifestSource: input.manifestSource, ...summarize(checks), }; } export function defaultMainnetReadinessInput( - overrides: Partial = {}, + overrides: Partial> = {}, + manifest: MainnetManifest = MAINNET_MANIFEST, ): MainnetReadinessInput { return { - rpcUrl: MAINNET_ARTIFACTS.rpcUrl, - networkPassphrase: MAINNET_ARTIFACTS.networkPassphrase, - contractId: MAINNET_ARTIFACTS.contractId, - expectedWasmHash: MAINNET_ARTIFACTS.wasmHash, - settledRoundId: BigInt(MAINNET_ARTIFACTS.settledRoundId), - expectedBidStroops: MAINNET_ARTIFACTS.bidStroops, - expectedEscrowStroops: MAINNET_ARTIFACTS.escrowStroops, - expectedRevealRound: MAINNET_ARTIFACTS.revealRound, + manifest, + rpcUrl: manifest.rpcUrl, + networkPassphrase: manifest.networkPassphrase, + contractId: manifest.contractId, live: true, withBalances: false, ...overrides, diff --git a/packages/sdk/src/public-api-snapshot.test.ts b/packages/sdk/src/public-api-snapshot.test.ts index 2f3f3cfe..25f10ce3 100644 --- a/packages/sdk/src/public-api-snapshot.test.ts +++ b/packages/sdk/src/public-api-snapshot.test.ts @@ -6,10 +6,16 @@ import * as sdk from "./index.js"; const EXPECTED_EXPORTS = [ "ASSET_FIXTURES", "AssetConfigError", + "DEPLOYMENT_CHECK_IDS", + "DEPLOYMENT_FIELDS", + "DEPLOYMENT_FIELD_LABELS", "ACTIVE_ROUND_STATUSES", "ERROR_ROUND_STATUSES", "KeeperStatusClient", "MAINNET_ARTIFACTS", + "MAINNET_MANIFEST", + "MAINNET_MANIFEST_PATH", + "MAINNET_XLM_SAC_ID", "MAINNET_CONFIRM_PHRASE", "MAINNET_DEPLOY_MIN_XLM_STROOPS", "MAINNET_MICRO_MAX_ESCROW", @@ -23,6 +29,8 @@ const EXPECTED_EXPORTS = [ "StatusJsonParseError", "SubRosaClient", "SubRosaClientConfigError", + "SubRosaDeploymentMismatchError", + "SubRosaManifestError", "SubRosaMissingReturnValueError", "SubRosaNetworkMismatchError", "SubRosaPreflightError", @@ -30,6 +38,7 @@ const EXPECTED_EXPORTS = [ "SubRosaTimeoutError", "SubRosaTransactionError", "TERMINAL_ROUND_STATUSES", + "assertDeploymentMatches", "assertMainnetConfirmed", "assertMicroAmounts", "assertReadinessForExecute", @@ -37,11 +46,15 @@ const EXPECTED_EXPORTS = [ "contractErrorCode", "createOzChannelsSubmitter", "createOzChannelsSubmitterFromEnv", + "compareDeployment", "createSacBalanceReader", "defaultMainnetReadinessInput", + "deploymentChecks", "evaluatePreflight", "fetchContractWasmHash", "fetchKeeperStatus", + "fixtureDeployment", + "fixtureReader", "formatReadinessReport", "hasBlockingFailures", "isActiveRoundStatus", @@ -51,6 +64,9 @@ const EXPECTED_EXPORTS = [ "isKeeperRoundTerminal", "isTerminalRoundStatus", "nativeXlmSacId", + "parseMainnetManifest", + "parseMainnetReadinessFixture", + "readLiveDeployment", "networkFingerprint", "normalizeRoundId", "normalizeSorobanContractId", @@ -58,6 +74,7 @@ const EXPECTED_EXPORTS = [ "redactReceipt", "roundStatusLabel", "runMainnetReadiness", + "summarizeDeploymentValue", "serializeReceipt", "tryDecodeBase64", "tryDecodeHex",