Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,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=
5 changes: 5 additions & 0 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
26 changes: 25 additions & 1 deletion docs/DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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… \
Expand All @@ -201,11 +202,34 @@ MAINNET_CONFIRM=SUB_ROSA_MAINNET KEEPER_SECRET=S… ROUND_CONTRACT_ID=C… \
pnpm mainnet:settle -- --execute # 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).

Expand Down
78 changes: 45 additions & 33 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,43 +40,55 @@ 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.

## Escrow conservation preflight

The contract keeps one identity per round — the escrow it holds equals the
payout plus refunds plus whatever is still locked — and refuses to `settle` or
`void` a round that cannot prove it, failing with `EscrowNotConserved`. The SDK
re-derives the same accounting off-chain so a keeper can halt before paying a
fee.

`proveEscrowConservation` walks the bidder index in pages, reads every bid
state, and cross-checks the walk against the bidder list on the round record. It
never throws; a drifted, duplicated, or unreadable index comes back as an issue
on the report:
## 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
const report = await client.proveEscrowConservation(roundId, "settle");
if (!report.conserved) {
for (const issue of report.issues) console.warn(issue.code, issue.message);
}
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 }),
);
```

`preflightSettleConservation` and `preflightVoidConservation` are the stricter
wrappers: they throw `SubRosaEscrowConservationError` (a `SubRosaPreflightError`
with `kind: "escrow_not_conserved"`) carrying the `roundId`, the `phase`, and
the full report, so a keeper can branch on `error.kind` instead of parsing text.
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`).

```ts
try {
await client.preflightSettleConservation(roundId);
await client.settle(roundId);
} catch (error) {
if (error instanceof SubRosaEscrowConservationError) {
console.error(error.roundId, error.phase, error.report.issues);
}
}
## 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
```

Issue codes cover page drift (`page_total_drift`, `page_count_mismatch`,
`cursor_stalled`), index integrity (`duplicate_bidder`, `index_mismatch`,
`bid_state_missing`, `bidder_already_settled`, `winner_not_indexed`), and the
accounting itself (`escrow_stranded`, `round_wrong_status`, `no_winner`).
`--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.
20 changes: 20 additions & 0 deletions packages/sdk/fixtures/mainnet-readiness.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
16 changes: 16 additions & 0 deletions packages/sdk/mainnet-artifacts.json
Original file line number Diff line number Diff line change
@@ -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"
}
150 changes: 102 additions & 48 deletions packages/sdk/scripts/mainnet-ready.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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);
});
Loading
Loading