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
3 changes: 2 additions & 1 deletion docs/DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion services/keeper/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
51 changes: 51 additions & 0 deletions services/keeper/scripts/mainnet-settle-gate.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
buildSettle: (amount: bigint) => Promise<void>;
}

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 };
}
24 changes: 15 additions & 9 deletions services/keeper/scripts/mainnet-settle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,31 @@
# 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")/../../.."

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 "$@"
111 changes: 111 additions & 0 deletions services/keeper/scripts/mainnet-settle.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading