diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 819854b8..b93d3bbb 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -196,8 +196,9 @@ pnpm mainnet:verify # read-only — no secrets pnpm mainnet:micro # dry-run checklist MAINNET_CONFIRM=SUB_ROSA_MAINNET OPERATOR_SECRET=S… BIDDER_SECRET=S… \ pnpm mainnet:micro -- --execute # optional micro commit (≤1 XLM escrow) +pnpm mainnet:settle # readiness + capped, read-only dry-run MAINNET_CONFIRM=SUB_ROSA_MAINNET KEEPER_SECRET=S… ROUND_CONTRACT_ID=C… \ - pnpm mainnet:settle # keeper settle (requires readiness + confirm) + pnpm mainnet:settle -- --execute # keeper settle (requires readiness + confirm) ``` ### Mainnet launch checklist diff --git a/services/keeper/package.json b/services/keeper/package.json index 8e4d9669..80676a89 100644 --- a/services/keeper/package.json +++ b/services/keeper/package.json @@ -15,7 +15,7 @@ "watch": "node --import tsx src/watch.ts", "serve": "node --import tsx src/serve.ts", "queue": "node --import tsx src/queue.ts", - "test": "node --import tsx --test src/checkpoint.test.ts src/checkpoint-restart.test.ts src/dry-run.test.ts src/keeper.test.ts src/watch.test.ts src/store.test.ts src/queue-cli.test.ts src/watch-queue.test.ts src/settlement-guard.test.ts src/status.test.ts src/status-server.test.ts src/queue-replay.test.ts", + "test": "node --import tsx --test src/dry-run.test.ts src/keeper.test.ts src/watch.test.ts src/store.test.ts src/queue-cli.test.ts src/watch-queue.test.ts src/settlement-guard.test.ts src/status.test.ts src/status-server.test.ts src/queue-replay.test.ts scripts/mainnet-settle.test.ts", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { diff --git a/services/keeper/scripts/mainnet-settle-gate.ts b/services/keeper/scripts/mainnet-settle-gate.ts new file mode 100644 index 00000000..ec058252 --- /dev/null +++ b/services/keeper/scripts/mainnet-settle-gate.ts @@ -0,0 +1,51 @@ +import { + assertReadinessForExecute, + MAINNET_MICRO_MAX_ESCROW, + type ReadinessCheck, +} from "@sub-rosa/sdk"; + +export function parseSettleAmount( + amount: unknown, + maxAmount: bigint = MAINNET_MICRO_MAX_ESCROW, +): bigint { + let parsed: bigint; + if (typeof amount === "bigint") { + parsed = amount; + } else if (typeof amount === "number" && Number.isSafeInteger(amount)) { + parsed = BigInt(amount); + } else if (typeof amount === "string" && /^\d+$/.test(amount)) { + parsed = BigInt(amount); + } else { + throw new Error("settle amount must be an integer number of stroops"); + } + + if (parsed <= 0n) { + throw new Error("settle amount must be positive"); + } + if (parsed > maxAmount) { + throw new Error( + `settle amount ${parsed} exceeds MAINNET_MICRO_MAX_ESCROW (${maxAmount})`, + ); + } + return parsed; +} + +export interface MainnetSettleGateOptions { + execute: boolean; + runReadiness: () => Promise<{ checks: ReadinessCheck[] }>; + readSettleAmount: () => Promise; + buildSettle: (amount: bigint) => Promise; +} + +export async function runMainnetSettleGate( + options: MainnetSettleGateOptions, +): Promise<{ amount: bigint; submitted: boolean }> { + const readiness = await options.runReadiness(); + assertReadinessForExecute(readiness.checks); + + const amount = parseSettleAmount(await options.readSettleAmount()); + if (!options.execute) return { amount, submitted: false }; + + await options.buildSettle(amount); + return { amount, submitted: true }; +} \ No newline at end of file diff --git a/services/keeper/scripts/mainnet-settle.sh b/services/keeper/scripts/mainnet-settle.sh index 052ea395..02f6a21a 100755 --- a/services/keeper/scripts/mainnet-settle.sh +++ b/services/keeper/scripts/mainnet-settle.sh @@ -2,7 +2,8 @@ # Mainnet settlement for an existing Round — keeper open/reveal + clear/settle. # # Usage: -# KEEPER_SECRET=S… ROUND_CONTRACT_ID=C… ./services/keeper/scripts/mainnet-settle.sh +# ./services/keeper/scripts/mainnet-settle.sh +# MAINNET_CONFIRM=SUB_ROSA_MAINNET KEEPER_SECRET=S… ./services/keeper/scripts/mainnet-settle.sh --execute set -euo pipefail cd "$(dirname "$0")/../../.." @@ -10,17 +11,22 @@ cd "$(dirname "$0")/../../.." RPC_URL="${RPC_URL:-https://rpc.ankr.com/stellar_soroban}" NETWORK_PASSPHRASE="${NETWORK_PASSPHRASE:-Public Global Stellar Network ; September 2015}" -[[ -n "${KEEPER_SECRET:-}" ]] || { echo "error: KEEPER_SECRET required" >&2; exit 1; } -[[ -n "${ROUND_CONTRACT_ID:-}" ]] || { echo "error: ROUND_CONTRACT_ID required" >&2; exit 1; } +EXECUTE=false +for arg in "$@"; do + [[ "$arg" == "--execute" ]] && EXECUTE=true +done -if [[ "${MAINNET_CONFIRM:-}" != "SUB_ROSA_MAINNET" ]]; then - echo "error: set MAINNET_CONFIRM=SUB_ROSA_MAINNET before mainnet settle" >&2 - exit 1 +if [[ "$EXECUTE" == true ]]; then + [[ -n "${KEEPER_SECRET:-}" ]] || { echo "error: KEEPER_SECRET required for --execute" >&2; exit 1; } + if [[ "${MAINNET_CONFIRM:-}" != "SUB_ROSA_MAINNET" ]]; then + echo "error: set MAINNET_CONFIRM=SUB_ROSA_MAINNET before mainnet settle" >&2 + exit 1 + fi fi -KEEPER_SECRET="$KEEPER_SECRET" \ -ROUND_CONTRACT_ID="$ROUND_CONTRACT_ID" \ +KEEPER_SECRET="${KEEPER_SECRET:-}" \ +ROUND_CONTRACT_ID="${ROUND_CONTRACT_ID:-}" \ ROUND_ID="${ROUND_ID:-1}" \ RPC_URL="$RPC_URL" \ NETWORK_PASSPHRASE="$NETWORK_PASSPHRASE" \ -pnpm --filter @sub-rosa/keeper exec tsx scripts/mainnet-settle.ts +pnpm --filter @sub-rosa/keeper exec tsx scripts/mainnet-settle.ts "$@" diff --git a/services/keeper/scripts/mainnet-settle.test.ts b/services/keeper/scripts/mainnet-settle.test.ts new file mode 100644 index 00000000..1b8f8e66 --- /dev/null +++ b/services/keeper/scripts/mainnet-settle.test.ts @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + defaultMainnetReadinessInput, + MAINNET_ARTIFACTS, + MAINNET_MICRO_MAX_ESCROW, + runMainnetReadiness, + type ReadinessCheck, +} from "@sub-rosa/sdk"; + +import { + parseSettleAmount, + runMainnetSettleGate, +} from "./mainnet-settle-gate.js"; + +const passingChecks: ReadinessCheck[] = [ + { + id: "fixture", + label: "Fixture readiness", + status: "pass", + message: "matches the frozen mainnet fixture", + }, +]; + +describe("mainnet settle gate", () => { + it("does not build a settle when strict readiness is blocked", async () => { + let builds = 0; + let amountReads = 0; + + await assert.rejects( + runMainnetSettleGate({ + execute: true, + async runReadiness() { + return { + checks: [ + { + ...passingChecks[0]!, + status: "block", + message: "fixture mismatch", + }, + ], + }; + }, + async readSettleAmount() { + amountReads += 1; + return 1n; + }, + async buildSettle() { + builds += 1; + }, + }), + /mainnet readiness blocked/, + ); + + assert.equal(builds, 0); + assert.equal(amountReads, 0); + }); + + it("does not build a settle when the amount exceeds the committed cap", async () => { + let builds = 0; + + await assert.rejects( + runMainnetSettleGate({ + execute: true, + async runReadiness() { + return { checks: passingChecks }; + }, + async readSettleAmount() { + return MAINNET_MICRO_MAX_ESCROW + 1n; + }, + async buildSettle() { + builds += 1; + }, + }), + /exceeds MAINNET_MICRO_MAX_ESCROW/, + ); + + assert.equal(builds, 0); + }); + + it("runs a matching fixture dry-run without submitting or contacting mainnet", async () => { + let submissions = 0; + const result = await runMainnetSettleGate({ + execute: false, + async runReadiness() { + return runMainnetReadiness( + defaultMainnetReadinessInput({ + contractId: MAINNET_ARTIFACTS.contractId, + live: false, + }), + ); + }, + async readSettleAmount() { + return "5000000"; + }, + async buildSettle() { + submissions += 1; + }, + }); + + assert.deepEqual(result, { amount: 5_000_000n, submitted: false }); + assert.equal(submissions, 0); + }); + + it("rejects zero and non-integer settle amounts", () => { + assert.throws(() => parseSettleAmount(0n), /must be positive/); + assert.throws(() => parseSettleAmount("1.5"), /must be an integer/); + assert.throws(() => parseSettleAmount(1.5), /must be an integer/); + }); +}); \ No newline at end of file diff --git a/services/keeper/scripts/mainnet-settle.ts b/services/keeper/scripts/mainnet-settle.ts index 4021039b..c9bfa097 100644 --- a/services/keeper/scripts/mainnet-settle.ts +++ b/services/keeper/scripts/mainnet-settle.ts @@ -1,15 +1,14 @@ import { normalizeError } from "@sub-rosa/logging/errors"; import { createLogger } from '@sub-rosa/logging'; const diagnostics = createLogger("services.keeper.scripts.mainnet-settle"); -// Mainnet settlement — keepRound (wait R → open → reveal) + closeRound (clear → settle). -// Env: KEEPER_SECRET, ROUND_CONTRACT_ID, ROUND_ID (default 1) -// Requires MAINNET_CONFIRM=SUB_ROSA_MAINNET before submitting transactions. +// Mainnet settlement — keeper open/reveal + clear/settle. +// Default: read-only dry-run. Add --execute and MAINNET_CONFIRM to submit. import { Keypair } from "@stellar/stellar-sdk"; import { assertMainnetConfirmed, - assertReadinessForExecute, createSacBalanceReader, + MAINNET_ARTIFACTS, defaultMainnetReadinessInput, nativeXlmSacId, runMainnetReadiness, @@ -19,6 +18,7 @@ import { quicknet } from "@sub-rosa/tlock"; import { systemTime } from "@sub-rosa/time"; import { closeRound, keepRound } from "../src/keeper.js"; +import { runMainnetSettleGate } from "./mainnet-settle-gate.js"; const { clock, scheduler } = systemTime; @@ -38,12 +38,15 @@ const bigintReplacer = (_k: string, v: unknown) => typeof v === "bigint" ? v.toString() : v; async function main() { - assertMainnetConfirmed(); + const execute = process.argv.includes("--execute"); + if (execute) assertMainnetConfirmed(); - const keeperSecret = reqEnv("KEEPER_SECRET"); - const contractId = reqEnv("ROUND_CONTRACT_ID"); + const keeperSecret = execute ? reqEnv("KEEPER_SECRET") : undefined; + const contractId = process.env.ROUND_CONTRACT_ID || MAINNET_ARTIFACTS.contractId; const roundId = BigInt(process.env.ROUND_ID ?? "1"); - const keeperKp = Keypair.fromSecret(keeperSecret); + const keeperKp = keeperSecret + ? Keypair.fromSecret(keeperSecret) + : Keypair.random(); const reader = new SubRosaClient({ rpcUrl: RPC_URL, @@ -52,105 +55,127 @@ async function main() { publicKey: keeperKp.publicKey(), }); - const readiness = await runMainnetReadiness( - defaultMainnetReadinessInput({ - rpcUrl: RPC_URL, - networkPassphrase: NETWORK, - contractId, - withBalances: false, - }), - { reader }, - ); - assertReadinessForExecute(readiness.checks); - - const sdk = new SubRosaClient({ - rpcUrl: RPC_URL, - networkPassphrase: NETWORK, - contractId, - secretKey: keeperSecret, - }); - const drand = quicknet(); - const log = (m: string) => diagnostics.info("progress", " ·", { "m_0": m }); - - diagnostics.info("contract", "· contract:", { "contractId_0": contractId }); - diagnostics.info("round", "· round: ", { "value1_0": roundId.toString() }); - diagnostics.info("keeper", "· keeper: ", { "value1_0": keeperKp.publicKey() }); - - let round = await reader.getRound(roundId); - diagnostics.info("status", "\n[status] ", { "tag_0": round.status.tag, "value2_1": "R=", "value3_2": round.reveal_round.toString() }); - - // ── Phase 1: open + reveal ───────────────────────────────────────────── - if (round.status.tag === "Open" || round.status.tag === "Revealing") { - diagnostics.info("1-3-keeper-wait-r-open-reveal-reveal-all", "\n[1/3] keeper: wait R → open_reveal → reveal all…"); - let rev = await keepRound( - { sdk, drand, log, maxWaitSeconds: 600, pollMs: 5000 }, - roundId, - ); - for (let i = 0; i < 5 && rev.finalStatus === "Open"; i++) { - await sleep(5000); - rev = await keepRound( - { sdk, drand, log, maxWaitSeconds: 120, pollMs: 5000 }, - roundId, + const result = await runMainnetSettleGate({ + execute, + runReadiness: () => + runMainnetReadiness( + defaultMainnetReadinessInput({ + rpcUrl: RPC_URL, + networkPassphrase: NETWORK, + contractId, + withBalances: false, + }), + { reader }, + ), + readSettleAmount: async () => { + const bidders = await reader.getBidders(roundId); + let amount = 0n; + for (const bidder of bidders) { + const state = await reader.getBidState(roundId, bidder); + amount += BigInt(state.escrow); + } + return amount; + }, + buildSettle: async () => { + if (!keeperSecret) throw new Error("KEEPER_SECRET is required to execute"); + const sdk = new SubRosaClient({ + rpcUrl: RPC_URL, + networkPassphrase: NETWORK, + contractId, + secretKey: keeperSecret, + }); + const drand = quicknet(); + const log = (m: string) => diagnostics.info("progress", " ·", { "m_0": m }); + + diagnostics.info("contract", "· contract:", { "contractId_0": contractId }); + diagnostics.info("round", "· round: ", { "value1_0": roundId.toString() }); + diagnostics.info("keeper", "· keeper: ", { "value1_0": keeperKp.publicKey() }); + + let round = await reader.getRound(roundId); + diagnostics.info("status", "\n[status] ", { "tag_0": round.status.tag, "value2_1": "R=", "value3_2": round.reveal_round.toString() }); + + // ── Phase 1: open + reveal ───────────────────────────────────────────── + if (round.status.tag === "Open" || round.status.tag === "Revealing") { + diagnostics.info("1-3-keeper-wait-r-open-reveal-reveal-all", "\n[1/3] keeper: wait R → open_reveal → reveal all…"); + let rev = await keepRound( + { sdk, drand, log, maxWaitSeconds: 600, pollMs: 5000 }, + roundId, + ); + for (let i = 0; i < 5 && rev.finalStatus === "Open"; i++) { + await sleep(5000); + rev = await keepRound( + { sdk, drand, log, maxWaitSeconds: 120, pollMs: 5000 }, + roundId, + ); + } + diagnostics.info("keep", " keep:", { "value1_0": JSON.stringify(rev, bigintReplacer) }); + if (rev.finalStatus === "Open") { + throw new Error("reveal not opened — Drand R not yet available"); + } + round = await reader.getRound(roundId); + } + + // ── Phase 2: wait reveal deadline ────────────────────────────────────── + round = await reader.getRound(roundId); + const revealDeadline = Number(round.reveal_deadline); + diagnostics.info("2-3-waiting-for-reveal-deadline", "\n[2/3] waiting for reveal deadline…", { "revealDeadline_0": revealDeadline }); + while (clock.nowSeconds() <= revealDeadline + 3) { + const remain = revealDeadline + 4 - clock.nowSeconds(); + if (remain > 0) { + log(`~${remain}s until clear allowed`); + await sleep(Math.min(10_000, remain * 1000)); + } + } + + // ── Phase 3: clear + settle ──────────────────────────────────────────── + diagnostics.info("3-3-clear-settle", "\n[3/3] clear + settle…"); + let close = await closeRound({ sdk, drand, log }, roundId); + if (!close.settled && close.finalStatus !== "Settled") { + await sleep(5000); + close = await closeRound({ sdk, drand, log }, roundId); + } + diagnostics.info("close", " close:", { "value1_0": JSON.stringify(close, bigintReplacer) }); + + round = await reader.getRound(roundId); + if (round.status.tag !== "Settled") { + throw new Error(`expected Settled, got ${round.status.tag}`); + } + + const bidders = await reader.getBidders(roundId); + for (const bidder of bidders) { + const state = await reader.getBidState(roundId, bidder); + diagnostics.info("bid", ` bid ${bidder.slice(0, 8)}… value=${state.revealed_value?.toString()} valid=${state.valid} settled=${state.settled}`); + } + + const tokenSacId = nativeXlmSacId(NETWORK); + const balanceOf = createSacBalanceReader( + RPC_URL, + NETWORK, + tokenSacId, + keeperKp.publicKey(), ); - } - diagnostics.info("keep", " keep:", { "value1_0": JSON.stringify(rev, bigintReplacer) }); - if (rev.finalStatus === "Open") { - throw new Error("reveal not opened — Drand R not yet available"); - } - round = await reader.getRound(roundId); - } - - // ── Phase 2: wait reveal deadline ────────────────────────────────────── - round = await reader.getRound(roundId); - const revealDeadline = Number(round.reveal_deadline); - diagnostics.info("2-3-waiting-for-reveal-deadline", "\n[2/3] waiting for reveal deadline…", { "revealDeadline_0": revealDeadline }); - while (clock.nowSeconds() <= revealDeadline + 3) { - const remain = revealDeadline + 4 - clock.nowSeconds(); - if (remain > 0) { - log(`~${remain}s until clear allowed`); - await sleep(Math.min(10_000, remain * 1000)); - } - } - - // ── Phase 3: clear + settle ──────────────────────────────────────────── - diagnostics.info("3-3-clear-settle", "\n[3/3] clear + settle…"); - let close = await closeRound({ sdk, drand, log }, roundId); - if (!close.settled && close.finalStatus !== "Settled") { - await sleep(5000); - close = await closeRound({ sdk, drand, log }, roundId); - } - diagnostics.info("close", " close:", { "value1_0": JSON.stringify(close, bigintReplacer) }); - - round = await reader.getRound(roundId); - if (round.status.tag !== "Settled") { - throw new Error(`expected Settled, got ${round.status.tag}`); - } - - const bidders = await reader.getBidders(roundId); - for (const b of bidders) { - const st = await reader.getBidState(roundId, b); - diagnostics.info("bid", ` bid ${b.slice(0, 8)}… value=${st.revealed_value?.toString()} valid=${st.valid} settled=${st.settled}`); - } + const contractBalance = await balanceOf(contractId); + if (contractBalance !== 0n) { + throw new Error( + `contract escrow balance guardrail failed: expected 0, got ${contractBalance.toString()} stroops`, + ); + } + + diagnostics.info("mainnet-settlement-complete", "\n✅ MAINNET SETTLEMENT COMPLETE"); + diagnostics.info("contract-2", " contract:", { "contractId_0": contractId }); + diagnostics.info("round-2", " round:", { "value1_0": roundId.toString() }); + diagnostics.info("winner", " winner:", { "value1_0": close.winner ?? round.winner }); + diagnostics.info("final-status", " final status:", { "tag_0": round.status.tag }); + }, + }); - const tokenSacId = nativeXlmSacId(NETWORK); - const balanceOf = createSacBalanceReader( - RPC_URL, - NETWORK, - tokenSacId, - keeperKp.publicKey(), - ); - const contractBalance = await balanceOf(contractId); - if (contractBalance !== 0n) { - throw new Error( - `contract escrow balance guardrail failed: expected 0, got ${contractBalance.toString()} stroops`, + if (!execute) { + diagnostics.info( + "mainnet-settle-dry-run", + "DRY-RUN: readiness passed; no transaction submitted.", + { "amount_0": result.amount.toString() }, ); } - - diagnostics.info("mainnet-settlement-complete", "\n✅ MAINNET SETTLEMENT COMPLETE"); - diagnostics.info("contract-2", " contract:", { "contractId_0": contractId }); - diagnostics.info("round-2", " round:", { "value1_0": roundId.toString() }); - diagnostics.info("winner", " winner:", { "value1_0": close.winner ?? round.winner }); - diagnostics.info("final-status", " final status:", { "tag_0": round.status.tag }); } main().catch((err) => {