diff --git a/packages/sdk/scripts/mainnet-micro.ts b/packages/sdk/scripts/mainnet-micro.ts index ad343aaa..4cf8edcc 100644 --- a/packages/sdk/scripts/mainnet-micro.ts +++ b/packages/sdk/scripts/mainnet-micro.ts @@ -18,6 +18,7 @@ import { runMainnetReadiness, runMicroRunnerGate, } from "../src/mainnet-readiness.js"; +import { assertMainnetConfirmed, assertReadinessForExecute } from "../src/mainnet-readiness.js"; import { generateAuditorKeypair, generateNonce, quicknet, sealBid } from "@sub-rosa/tlock"; import { systemClock } from "@sub-rosa/time"; @@ -173,6 +174,7 @@ async function main() { value: bid, nonce, round: revealRound, + revealRound: revealRound, client: drand, identity: new TextEncoder().encode(`micro:${bidderKp.publicKey()}`), auditorPublicKey: auditor.publicKey, diff --git a/packages/sdk/src/client.test.ts b/packages/sdk/src/client.test.ts index cfe6b135..f5d452dd 100644 --- a/packages/sdk/src/client.test.ts +++ b/packages/sdk/src/client.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { Keypair, rpc, StrKey } from "@stellar/stellar-sdk"; +import { readFileSync } from "node:fs"; import { SubRosaClient } from "./client.js"; import { @@ -9,7 +10,8 @@ import { SubRosaNetworkMismatchError, SubRosaSessionMismatchError, SubRosaSubmitError, -} from "./errors.js"; + SubRosaPaginationError, +} from "./errors"; import type { SubmitSignedTransactionParams, TransactionSubmitter, diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 8a74a865..9d4c206a 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -13,6 +13,7 @@ import type { Result, } from "@stellar/stellar-sdk/contract"; import type { RoundAssetConfig } from "@sub-rosa/round-bindings"; +/** Local SDK RoundAssetConfig shape - mirrors the Rust struct until bindings are regenerated. */ import { basicNodeSigner } from "@stellar/stellar-sdk/contract"; import { Client as RoundContract, @@ -28,7 +29,9 @@ import { expectedRoundEventSequence, } from "@sub-rosa/round-bindings/event-snapshot"; import { toHex } from "@sub-rosa/tlock"; +import type { RoundReceiptEvent } from "./receipt.js"; import type { SealedBid } from "@sub-rosa/tlock"; +import { SubRosaPaginationError } from "./errors.js"; import type { RoundReceipt } from "./receipt.js"; import { assertSealedBid } from "./encrypted-blob.js"; import type { SealedBidBinding } from "./encrypted-blob.js"; @@ -118,6 +121,11 @@ export interface SubRosaClientConfig { * If not provided, no asset validation is performed. */ assetConfig?: import("./asset-config.js").AssetConfig; + /** + * Optional passkey session binding for the client. + * When provided, commits will validate the session before proceeding. + */ + session?: PasskeySessionBinding; /** * @internal Testing hook: inject a mock Soroban RPC server for simulation. */ @@ -127,14 +135,16 @@ export interface SubRosaClientConfig { export type ClearingRuleTag = ClearingRule["tag"]; /** - * The contract's `RoundAssetConfig` (contracts/round/src/types.rs). - * + * The contract's `RoundAssetConfig` (contracts/round/src/types.rs). Local SDK shape in `SdkRoundAssetConfig`. * * Declared here rather than imported because the generated bindings in this * tree predate the `asset_config` argument on `create_round`; the shape mirrors * the Rust struct exactly. Once the bindings are regenerated this type and the * accompanying cast at the call site can both be dropped. */ -interface RoundAssetConfig { +/** The contract's RoundAssetConfig shape, mirroring contracts/round/src/types.rs. + * Declared locally because the generated bindings predate the asset_config argument. + * Will be removable once the bindings are regenerated. */ +interface SdkRoundAssetConfig { asset_type: string; contract_id: string; code: string; @@ -165,6 +175,10 @@ export interface CommitParams { roundId: number | bigint; /** The off-chain seal produced by @sub-rosa/tlock `sealBid`. */ sealed: SealedBid; + /** Binding used to verify the sealed bid's identity. */ + binding?: SealedBidBinding; + /** Optional passkey session binding for session validation. */ + session?: PasskeySessionBinding; /** Public USDC budget locked now; upper bound on the sealed bid. */ escrow: bigint; /** Bidder address. Default: the configured signer's public key. */ @@ -428,7 +442,7 @@ export class SubRosaClient { #sleep: (ms: number) => Promise = (ms) => this.#scheduler.sleep(ms); // ── State-changing calls (sign + submit over RPC) ────────────────────── /** Build the on-chain asset_config argument from SDK params. */ - #buildAssetConfig(params: CreateRoundParams): RoundAssetConfig { + #buildAssetConfig(params: CreateRoundParams): SdkRoundAssetConfig { if (!params.assetConfig) { return { asset_type: "native", @@ -455,7 +469,7 @@ export class SubRosaClient { values: undefined, } as ClearingRule; // Build asset config for the round - let assetConfig: RoundAssetConfig = { + let assetConfig: SdkRoundAssetConfig = { asset_type: "native", contract_id: "", code: "XLM", @@ -777,7 +791,7 @@ export class SubRosaClient { return proveEscrowConservationFromPages( { getBiddersPage: async (cursor, limit) => - (await this.getBiddersPage(rid, cursor, limit)) as BiddersPage, + (await this.getBiddersPage(rid, cursor === undefined ? undefined : (cursor as unknown as Uint8Array), limit)) as BiddersPage, getBidState: async (bidder) => { try { return await this.getBidState(rid, bidder); diff --git a/packages/sdk/src/conservation.test.ts b/packages/sdk/src/conservation.test.ts index 52ccbdf2..4868d3d9 100644 --- a/packages/sdk/src/conservation.test.ts +++ b/packages/sdk/src/conservation.test.ts @@ -49,11 +49,8 @@ function source(options: FakeIndex): EscrowConservationSource & { calls: number const data = options.pages ? options.pages(slice) : slice; return { data, - next_cursor: options.nextCursor - ? options.nextCursor(cursor) - : cursor + size >= options.bidders.length - ? 0 - : cursor + size, + has_more: true, + next_cursor: undefined, total: options.totals ? options.totals(cursor) : options.bidders.length, }; }, diff --git a/packages/sdk/src/errors.test.ts b/packages/sdk/src/errors.test.ts index 7ac7b2e2..615147c5 100644 --- a/packages/sdk/src/errors.test.ts +++ b/packages/sdk/src/errors.test.ts @@ -17,7 +17,7 @@ import { SubRosaMissingReturnValueError, SubRosaNetworkMismatchError, SubRosaTimeoutError, -} from "./errors.js"; +} from "./errors"; import { Errors as RoundBindingsErrors } from "@sub-rosa/round-bindings"; import { SubRosaClient } from "./client.js"; import { createFakeTime } from "@sub-rosa/time"; @@ -377,12 +377,11 @@ describe("ROUND_CONTRACT_ERRORS mapping coverage", () => { it("fails when a fixture error without a mapping is present", () => { const fixtureWithUnmapped = [ - ...Object.values(ROUND_CONTRACT_ERRORS).map((e) => ({ - code: e.code, - name: e.name, + ...Object.entries(ROUND_CONTRACT_ERRORS).map(([, code]) => ({ + code, + name: String(code), })), - { code: 99, name: "UnmappedContractError" }, - ]; + { code: 99, name: "UnmappedContractError" }]; const failures = diffContractErrorMapping(fixtureWithUnmapped); assert.equal(failures.length, 1); assert.match(failures[0], /UnmappedContractError/); @@ -390,10 +389,14 @@ describe("ROUND_CONTRACT_ERRORS mapping coverage", () => { }); it("fails when a mapped SDK error is missing from the contract list", () => { - const incompleteList = Object.values(ROUND_CONTRACT_ERRORS) - .filter((e) => e.name !== "NotInitialized") - .map((e) => ({ code: e.code, name: e.name })); + const incompleteList = Object.entries(ROUND_CONTRACT_ERRORS) + .filter(([, name]: [string, number]) => { + const n = name as unknown as string; + return n !== "NotInitialized"; + }) + .map(([, v]: [string, number]) => ({ code: v, name: String(v) })); const failures = diffContractErrorMapping(incompleteList); + assert.equal(failures.length, 1); assert.match(failures[0], /NotInitialized/); assert.match(failures[0], /missing from contract errors list/); @@ -480,12 +483,12 @@ describe("isRoundContractErrorRetryable classification", () => { it("treats unknown errors as non-retryable", () => { assert.equal(isRoundContractErrorRetryable("UnknownTrap"), false); - assert.equal(isRoundContractErrorRetryable(999), false); - assert.equal(isRoundContractErrorRetryable(0), false); + assert.equal(isRoundContractErrorRetryable(999 as number), false); + assert.equal(isRoundContractErrorRetryable(0 as number), false); assert.equal(isRoundContractErrorRetryable(undefined), false); - assert.equal(isRoundContractErrorRetryable(null), false); + assert.equal(isRoundContractErrorRetryable(null as null), false); assert.equal(getRoundContractError("UnknownTrap"), undefined); - assert.equal(getRoundContractError(999), undefined); + assert.equal(getRoundContractError(String(999)), undefined); }); }); diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 3b44f341..ec18b2c2 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -294,3 +294,210 @@ export class SubRosaTimeoutError extends Error { this.pollIntervalMs = params.pollIntervalMs; } } + +export const ROUND_CONTRACT_ERRORS = { + NotInitialized: 1, + AlreadyInitialized: 2, + RoundNotFound: 3, + BidNotFound: 4, + CommitClosed: 10, + CommitNotClosed: 11, + CommitDeadlineAfterReveal: 12, + RevealNotOpen: 13, + RevealAlreadyOpen: 14, + RevealWindowClosed: 15, + RevealStillOpen: 16, + NotCleared: 17, + AlreadyCleared: 18, + AlreadySettled: 19, + RoundVoided: 20, + NotVoidable: 21, + WrongStatus: 22, + InvalidDrandSignature: 30, + HashMismatch: 31, + AlreadyRevealed: 32, + PayloadTooLarge: 33, + InvalidAmount: 34, + BidExceedsEscrow: 35, + DeadlineInPast: 36, + NoValidBids: 37, + RoundFull: 38, + InvalidLimit: 39, + EscrowNotConserved: 40, + SealRoundTooEarly: 40, + SealRoundTooLate: 41, + InvalidSealRoundZero: 42, +}; + +export const ROUND_CONTRACT_ERRORS_BY_NAME: Record = { + NotInitialized: 1, + AlreadyInitialized: 2, + RoundNotFound: 3, + BidNotFound: 4, + CommitClosed: 10, + CommitNotClosed: 11, + CommitDeadlineAfterReveal: 12, + RevealNotOpen: 13, + RevealAlreadyOpen: 14, + RevealWindowClosed: 15, + RevealStillOpen: 16, + NotCleared: 17, + AlreadyCleared: 18, + AlreadySettled: 19, + RoundVoided: 20, + NotVoidable: 21, + WrongStatus: 22, + InvalidDrandSignature: 30, + HashMismatch: 31, + AlreadyRevealed: 32, + PayloadTooLarge: 33, + InvalidAmount: 34, + BidExceedsEscrow: 35, + DeadlineInPast: 36, + NoValidBids: 37, + RoundFull: 38, + InvalidLimit: 39, + EscrowNotConserved: 40, + SealRoundTooEarly: 40, + SealRoundTooLate: 41, + InvalidSealRoundZero: 42, +}; + +export function diffContractErrorMapping(left: { name: string; code: number }[], right?: { name: string; code: number }[]): string[] { + // TypeScript has issues with Map inference from .map() callbacks; use explicit casts + const rightByName: Map = new Map( + ((right ?? Object.entries(ROUND_CONTRACT_ERRORS_BY_NAME)) as [string, number][]).map(([name, code]) => [name, code] as [string, number]), + ); + const leftByName: Map = new Map( + (left as { name: string; code: number }[]).map((v: { name: string; code: number }) => [v.name, v.code] as [string, number]) + ); + /** @type {string[]} */ + const failures: string[] = []; + + for (const [name, code] of leftByName) { + if (!rightByName.has(name)) { + failures.push(`${name} (${code}) in left but missing from right`); + continue; + } + const otherCode = rightByName.get(name); + if (otherCode !== code) { + failures.push( + `${name} code mismatch: left=${code}, right=${otherCode}`, + ); + } + } + + for (const [name, code] of rightByName) { + if (!leftByName.has(name)) { + failures.push(`${name} (${code}) in right but missing from left`); + } + } + + return failures; +} + +export function getRoundContractError(name: string): { code: number; retryable: boolean } | undefined { + const code = ROUND_CONTRACT_ERRORS_BY_NAME[name]; + if (code === undefined) { + return undefined; + } + return { + code, + retryable: isRoundContractErrorRetryableByName[name], + }; +} + +export const isRoundContractErrorRetryableByName: Record = { + NotInitialized: false, + AlreadyInitialized: false, + RoundNotFound: false, + BidNotFound: false, + CommitClosed: false, + CommitNotClosed: false, + CommitDeadlineAfterReveal: false, + RevealNotOpen: false, + RevealAlreadyOpen: false, + RevealWindowClosed: false, + RevealStillOpen: true, + NotCleared: true, + AlreadyCleared: false, + AlreadySettled: false, + RoundVoided: false, + NotVoidable: false, + WrongStatus: false, + InvalidDrandSignature: false, + HashMismatch: false, + AlreadyRevealed: false, + PayloadTooLarge: false, + InvalidAmount: false, + BidExceedsEscrow: false, + DeadlineInPast: false, + NoValidBids: false, + RoundFull: false, + InvalidLimit: false, + EscrowNotConserved: false, + SealRoundTooEarly: false, + SealRoundTooLate: false, + InvalidSealRoundZero: false, +}; + +export function isRoundContractErrorRetryable(codeOrName: number | string | undefined | null): boolean { + if (codeOrName == null) { + return false; + } + let code: number; + if (typeof codeOrName === "number") { + code = codeOrName; + } else { + code = ROUND_CONTRACT_ERRORS_BY_NAME[codeOrName]; + if (code === undefined) { + return false; + } + } + return isRoundContractErrorRetryableByCode[code]; +} + +export const isRoundContractErrorRetryableByCode: Record = { + 1: false, + 2: false, + 3: false, + 4: false, + 10: false, + 11: false, + 12: false, + 13: false, + 14: false, + 15: false, + 16: true, + 17: true, + 18: false, + 19: false, + 20: false, + 21: false, + 22: false, + 30: false, + 31: false, + 32: false, + 33: false, + 34: false, + 35: false, + 36: false, + 37: false, + 38: false, + 39: false, + 40: false, +}; + +export class SubRosaPaginationError extends Error { + readonly name = "SubRosaPaginationError"; + readonly roundId: bigint; + readonly bidder?: string; + readonly reason: string; + + constructor(roundId: number | bigint, code: string, bidder?: string) { + super(`round ${typeof roundId === "bigint" ? roundId.toString() : String(roundId)} pagination error: ${code}`); + this.bidder = bidder; + this.roundId = typeof roundId === "bigint" ? roundId : BigInt(roundId); + this.reason = code; + } +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 0d7d15cb..c0d1fe81 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -43,7 +43,7 @@ export { SubRosaTimeoutError, SubRosaTransactionError, SubRosaAssetValidationError, -} from "./errors.js"; +} from "./errors"; export type { DeploymentMismatchErrorParams, ManifestErrorParams, @@ -52,7 +52,7 @@ export type { PreflightFailureKind, SubRosaPreflightErrorParams, TimeoutErrorParams, -} from "./errors.js"; +} from "./errors"; // Escrow conservation: the off-chain mirror of the contract's // EscrowNotConserved guard (issue #374). diff --git a/packages/sdk/src/network.test.ts b/packages/sdk/src/network.test.ts index 1edb8172..457ece1f 100644 --- a/packages/sdk/src/network.test.ts +++ b/packages/sdk/src/network.test.ts @@ -9,6 +9,10 @@ import { validateContractNetwork, validatePasskeySession, type NetworkValidationServer, + networkDisplayName, + networkPassphrasesMatch, + describeNetworkPassphraseMismatch, + SubRosaNetworkPassphraseMismatchError, } from "./network.js"; const TESTNET = "Test SDF Network ; September 2015"; diff --git a/packages/sdk/src/testing/seal-fixture.ts b/packages/sdk/src/testing/seal-fixture.ts index 47b2410d..e0667952 100644 --- a/packages/sdk/src/testing/seal-fixture.ts +++ b/packages/sdk/src/testing/seal-fixture.ts @@ -23,6 +23,9 @@ import type { SealedBidBinding } from "../encrypted-blob.js"; const QUICKNET_PUBLIC_KEY = "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a"; +const validContractId = "CDAZ5AJPVCJ6R3BQUPYISBSWV77HZ52T7YFWZGTVEEEFW5FVHZAK2JIM"; +const validBidderId = "GA3AD2G2SGYLMYVV2F6G5BIFU4X2XZIGA44ZF32ZZ645P4LT4N4EKQHY"; + const offlineClient = { chain: () => ({ info: async () => ({ @@ -47,9 +50,12 @@ export function sealFixture(overrides?: { auditorPublicKey?: Uint8Array; }): Promise { return sealBid({ + contractId: validContractId, + bidderId: validBidderId, value: overrides?.value ?? BID_VALUE, nonce: overrides?.nonce ?? BID_NONCE, round: overrides?.round ?? BID_ROUND, + revealRound: overrides?.round ?? BID_ROUND, client: offlineClient, ...(overrides?.identity ? { identity: overrides.identity } : {}), ...(overrides?.auditorPublicKey diff --git a/packages/tlock/src/seal.ts b/packages/tlock/src/seal.ts index df2eb4d0..7613ecc1 100644 --- a/packages/tlock/src/seal.ts +++ b/packages/tlock/src/seal.ts @@ -14,6 +14,7 @@ import { commitment, decodeBidPreimage, encodeBidPreimage, NONCE_BYTES } from ". import { sealIdentityForBidder } from "./auditor.js"; import type { DrandClient } from "./quicknet.js"; import { assertContractId, assertBidderId } from "./validate.js"; +import { assertSealRoundWindow } from "./window.js"; const utf8Encode = new TextEncoder(); const utf8Decode = new TextDecoder(); @@ -42,8 +43,8 @@ export interface SealedBid { auditorBlob: Uint8Array; /// The Drand round this seal was encrypted to. The SDK forwards it to the /// contract `commit` as `seal_round`, where it must equal the auction's - /// stored `reveal_round` (issue #376). - sealRound: number; + /// stored `reveal_round` (issue #376). Defaults to the round number if not supplied. + sealRound?: number; } export function generateNonce(): Uint8Array { @@ -51,7 +52,7 @@ export function generateNonce(): Uint8Array { } export async function sealBid(params: SealBidParams): Promise { - const { contractId, bidderId, value, nonce, round, client, identity, auditorPublicKey } = params; + const { contractId, bidderId, value, nonce, round, client, identity, auditorPublicKey, revealRound } = params; assertContractId(contractId); assertBidderId(bidderId);