From 33f032741228976a1a47f63146a22e5e140c7d99 Mon Sep 17 00:00:00 2001 From: bywura Date: Wed, 21 Oct 2026 23:24:24 +0000 Subject: [PATCH 1/2] Enable weekly Dependabot updates for npm, Cargo, and GitHub Actions - Configure weekly schedule for all three ecosystems - Limit 5 open PRs per ecosystem to reduce noise - Group patch/minor updates together where supported - Use 'dependencies' label consistent with repository conventions - Do not change dependency versions --- .github/dependabot.yml | 46 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..b3553bb1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,46 @@ +version: 2 +updates: + # Enable version updates for npm + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "npm" + groups: + # Group patch and minor updates together + "npm-and-minor": + update-types: ["patch", "minor"] + package-management-puppet: true + + # Enable version updates for Cargo + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "cargo" + groups: + # Group patch and minor updates together + "cargo-and-minor": + update-types: ["patch", "minor"] + package-management-puppet: true + + # Enable version updates for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" + groups: + # Group patch and minor updates together + "github-actions-and-minor": + update-types: ["patch", "minor"] + package-management-puppet: true \ No newline at end of file From e93e81441a368dc2ce840a9b1794b4e7b195fc4d Mon Sep 17 00:00:00 2001 From: bywura Date: Thu, 22 Oct 2026 08:44:01 +0000 Subject: [PATCH 2/2] feat: add seal-round window error codes (40-42) and reorganize error categories - Add SealRoundTooEarly, SealRoundTooLate, InvalidSealRoundZero errors - Reorganize error codes: 40-42 = seal-round window, 43-49 = escrow accounting - Update ERRORS.md, types.rs, error_paths.rs, test.rs accordingly - Update SDK errors.ts and related test fixtures - Update PasskeyPanel and useTraceHealth to use normalizeError Co-authored-by: Codebuff --- apps/web/src/components/PasskeyPanel.tsx | 14 +- apps/web/src/hooks/useTraceHealth.ts | 3 +- contracts/round/ERRORS.md | 18 +- contracts/round/src/error_paths.rs | 5 +- contracts/round/src/test.rs | 20 ++- contracts/round/src/types.rs | 7 +- packages/round-bindings/src/index.ts | 5 +- packages/sdk/src/client.test.ts | 2 + packages/sdk/src/client.ts | 1 + packages/sdk/src/errors.ts | 166 +++++++++++++++++++ packages/sdk/src/index.ts | 4 + packages/sdk/src/network.test.ts | 4 + packages/sdk/src/public-api-snapshot.test.ts | 10 ++ packages/sdk/src/testing/seal-fixture.ts | 9 + packages/tlock/src/seal.test.ts | 2 +- packages/tlock/src/seal.ts | 3 +- scripts/check-round-errors.test.mjs | 14 +- 17 files changed, 258 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/PasskeyPanel.tsx b/apps/web/src/components/PasskeyPanel.tsx index 215d0caf..c1453d07 100644 --- a/apps/web/src/components/PasskeyPanel.tsx +++ b/apps/web/src/components/PasskeyPanel.tsx @@ -1,4 +1,8 @@ -import { publicErrorMessage } from "@sub-rosa/logging/errors"; +import { + normalizeError, + publicErrorMessage, + type NormalizedError, +} from "@sub-rosa/logging/errors"; // Copyright (c) 2026 Sub Rosa contributors import { useMemo, useState } from "react"; import { SubRosaClient, validatePasskeySession } from "@sub-rosa/sdk"; @@ -60,7 +64,7 @@ export function PasskeyPanel({ contractId?: string; } | null>(null); const [session, setSession] = useState(initialSession); - const [panelError, setPanelError] = useState(null); + const [panelError, setPanelError] = useState(null); const walletWasmHash = resolvePasskeyWalletWasmHash(); const rpId = import.meta.env.VITE_PASSKEY_RP_ID ?? window.location.hostname; @@ -131,7 +135,7 @@ export function PasskeyPanel({ ); } catch (e) { setStatus("error"); - const err = e instanceof Error ? e : new Error(String(e)); + const err = normalizeError(e); setPanelError(err); setMessage(publicErrorMessage(e)); } @@ -173,7 +177,7 @@ export function PasskeyPanel({ setMessage(`Smart wallet deployed on testnet: ${created.contractId}`); } catch (e) { setStatus("error"); - const err = e instanceof Error ? e : new Error(String(e)); + const err = normalizeError(e); setPanelError(err); const detail = publicErrorMessage(e); setMessage( @@ -233,7 +237,7 @@ export function PasskeyPanel({ setMessage("Passkey commit submitted successfully."); } catch (e: unknown) { setStatus("error"); - const err = e instanceof Error ? e : new Error(String(e)); + const err = normalizeError(e); setPanelError(err); // Ensure fixture secret seed is kept strictly out of the error message const scrubbed = err.message.replace(/\bS[A-Z2-7]{55}\b/g, "[REDACTED]"); diff --git a/apps/web/src/hooks/useTraceHealth.ts b/apps/web/src/hooks/useTraceHealth.ts index a024ce53..5689ef9c 100644 --- a/apps/web/src/hooks/useTraceHealth.ts +++ b/apps/web/src/hooks/useTraceHealth.ts @@ -1,5 +1,6 @@ // Copyright (c) 2026 Sub Rosa contributors import { useMemo } from "react"; +import { normalizeError } from "@sub-rosa/logging/errors"; import { DEMO_TRACE } from "../demo/trace"; import { assertDemoTrace } from "../demo/trace-health-check"; import { verifyMilestones } from "../demo/demo-trace.checksum"; @@ -24,7 +25,7 @@ export function useTraceHealth(): TraceHealthState { try { assertDemoTrace(DEMO_TRACE); } catch (e) { - const message = e instanceof Error ? e.message : String(e); + const { message } = normalizeError(e); return { ok: false, errorCode: `TRACE_INVALID: ${message}` }; } diff --git a/contracts/round/ERRORS.md b/contracts/round/ERRORS.md index 7bc459da..470d05c1 100644 --- a/contracts/round/ERRORS.md +++ b/contracts/round/ERRORS.md @@ -28,7 +28,8 @@ stay in sync with `contracts/round/src/types.rs`. | 1–4 | Initialization & state lookup | | 10–22 | Lifecycle & timing | | 30–39 | Cryptography & validation | -| 40–49 | Escrow accounting | +| 40–42 | Seal-round window | +| 43–49 | Escrow accounting | ## Initialization & state lookup (1–4) @@ -57,7 +58,7 @@ stay in sync with `contracts/round/src/types.rs`. | 21 | `NotVoidable` | `void` | Round is past the `Open` status, or `now <= reveal_deadline + VOID_GRACE` (3600 s). | The round cannot be voided from its current state, or the grace window has not elapsed yet. | Either complete the normal lifecycle, or wait until `reveal_deadline + 1 hour` and try `void` again. | | 22 | `WrongStatus` | `commit` | `round.status != Status::Open`. | A bid can only be submitted to a round in the Open status. | Start a new round; a Revealing/Cleared/Settled/Voided round no longer accepts commits. | -## Cryptography & validation (30–40) +## Cryptography & validation (30–39) | Code | Variant | Raised by | Trigger | User-facing message | Suggested next action | | ---: | --- | --- | --- | --- | --- | @@ -71,11 +72,20 @@ stay in sync with `contracts/round/src/types.rs`. | 37 | `NoValidBids` | `settle` | `round.winner` is `None` on a round whose status is `Cleared`. | Round has no winner to settle against. | Investigate: under current behavior the contract transitions to `Voided` (with all escrow refunded) when no valid bid is revealed, so this code should not appear in normal flow. If it does, the round is in an inconsistent state and warrants a manual review. | | 38 | `RoundFull` | `commit` | `round.bidders.len() >= MAX_BIDDERS` (500). | The round has reached its bidder cap. | Start a new round to accept further bidders. | | 39 | `InvalidLimit` | `get_bidders_page` | `limit == 0` or `limit > 100`. | Page size must be between 1 and 100 (inclusive). | Pass a `limit` in `[1, 100]`; use `next_cursor` from the previous page to walk larger rounds. | + +## Seal-round window (40–42) + +A committed seal must name the Drand round the auction committed to open — +exactly `Round::reveal_round`, never ±1 or 0 (issue #376). The window is +enforced before any escrow is locked, so a rejected seal moves no funds. + +| Code | Variant | Raised by | Trigger | User-facing message | Suggested next action | +| ---: | --- | --- | --- | --- | --- | | 40 | `SealRoundTooEarly` | `commit` | `seal_round < round.reveal_round`: the seal names a Drand round before the one the auction committed to open. | The sealed bid is locked to the wrong Drand round. | Reseal the bid to the round published in the `created` event (`reveal_round`) and commit again; no escrow was moved. | | 41 | `SealRoundTooLate` | `commit` | `seal_round > round.reveal_round`: the seal names a Drand round after the one the auction committed to open. | The sealed bid is locked to the wrong Drand round. | Reseal the bid to the round published in the `created` event (`reveal_round`) and commit again; no escrow was moved. | | 42 | `InvalidSealRoundZero` | `create_round`, `commit` | `reveal_round == 0` at round creation, or `seal_round == 0` at commit. A zero or overflowing round can never be published by the Drand chain. | The Drand round number is malformed. | Use a positive round the quicknet chain can actually publish (`genesis + period × R` must fit in `u64`); check before locking escrow. | -## Escrow accounting (40–49) +## Escrow accounting (43–49) Escrow conservation is one predicate, enforced by `reveal`, `clear`, `void`, and `settle`: committed escrow equals the settled payout plus refunds plus the @@ -84,7 +94,7 @@ the round's bidder index. | Code | Variant | Raised by | Trigger | User-facing message | Suggested next action | | ---: | --- | --- | --- | --- | --- | -| 40 | `EscrowNotConserved` | `reveal`, `clear`, `settle`, `void` | The round's escrow ledger does not satisfy `committed == payout + refunds + locked`; or the escrow it reports locked is not matched by indexed, unsettled bids; or a planned payout plus refunds would not drain exactly the locked balance. Concretely: a bidder index that drifted from the escrowed set (dropped, duplicated, or phantom bidder), a payout above the winner's escrow, a bid already marked settled, or a locked balance that survives a terminal round. | The round's escrow cannot be reconciled with the bids it was taken against, so no funds were moved. | Do not retry — the round's state is inconsistent. Export a receipt (`exportReceipt` / `getRound` + `getBidState` per bidder) and compare the bidder index against the escrowed set. If the index is intact, re-simulate from a fresh ledger view before escalating. | +| 43 | `EscrowNotConserved` | `reveal`, `clear`, `settle`, `void` | The round's escrow ledger does not satisfy `committed == payout + refunds + locked`; or the escrow it reports locked is not matched by indexed, unsettled bids; or a planned payout plus refunds would not drain exactly the locked balance. Concretely: a bidder index that drifted from the escrowed set (dropped, duplicated, or phantom bidder), a payout above the winner's escrow, a bid already marked settled, or a locked balance that survives a terminal round. | The round's escrow cannot be reconciled with the bids it was taken against, so no funds were moved. | Do not retry — the round's state is inconsistent. Export a receipt (`exportReceipt` / `getRound` + `getBidState` per bidder) and compare the bidder index against the escrowed set. If the index is intact, re-simulate from a fresh ledger view before escalating. | Because the check runs before and after the transfers inside a single invocation, a rejection reverts every transfer the call had already made: a diff --git a/contracts/round/src/error_paths.rs b/contracts/round/src/error_paths.rs index d3344646..2811786e 100644 --- a/contracts/round/src/error_paths.rs +++ b/contracts/round/src/error_paths.rs @@ -45,6 +45,9 @@ const ERROR_PATH_REGISTRY: &[(Error, &'static str)] = &[ (Error::NoValidBids, "error_path_no_valid_bids"), (Error::RoundFull, "error_path_round_full"), (Error::InvalidLimit, "error_path_invalid_limit"), + (Error::SealRoundTooEarly, "error_path_seal_round_too_early"), + (Error::SealRoundTooLate, "error_path_seal_round_too_late"), + (Error::InvalidSealRoundZero, "error_path_invalid_seal_round_zero"), (Error::EscrowNotConserved, "error_path_escrow_not_conserved"), ]; @@ -88,7 +91,7 @@ fn settle_happy_path(f: &Fixture, t_reveal: u64, commit_deadline: u64, reveal_de fn error_paths_registry_covers_every_variant() { assert_eq!( ERROR_PATH_REGISTRY.len(), - 28, + 31, "update ERROR_PATH_REGISTRY when adding/removing Error variants" ); for (variant, name) in ERROR_PATH_REGISTRY { diff --git a/contracts/round/src/test.rs b/contracts/round/src/test.rs index d74c18f1..fd6ab9ca 100644 --- a/contracts/round/src/test.rs +++ b/contracts/round/src/test.rs @@ -2272,7 +2272,7 @@ pub(super) const DOCUMENTED_ERROR_CODES: &[(Error, u32)] = &[ (Error::RoundVoided, 20), (Error::NotVoidable, 21), (Error::WrongStatus, 22), - // ── 30–40: cryptography & validation ── + // ── 30–39: cryptography & validation ── (Error::InvalidDrandSignature, 30), (Error::HashMismatch, 31), (Error::AlreadyRevealed, 32), @@ -2283,8 +2283,12 @@ pub(super) const DOCUMENTED_ERROR_CODES: &[(Error, u32)] = &[ (Error::NoValidBids, 37), (Error::RoundFull, 38), (Error::InvalidLimit, 39), - // ── 40–49: escrow accounting ── - (Error::EscrowNotConserved, 40), + // ── 40–42: seal-round window ── + (Error::SealRoundTooEarly, 40), + (Error::SealRoundTooLate, 41), + (Error::InvalidSealRoundZero, 42), + // ── 43–49: escrow accounting ── + (Error::EscrowNotConserved, 43), ]; /// Convert an `Error` to its on-chain discriminant using the [`repr(u32)`] @@ -2323,6 +2327,9 @@ pub(super) fn variant_name(e: Error) -> &'static str { Error::NoValidBids => "NoValidBids", Error::RoundFull => "RoundFull", Error::InvalidLimit => "InvalidLimit", + Error::SealRoundTooEarly => "SealRoundTooEarly", + Error::SealRoundTooLate => "SealRoundTooLate", + Error::InvalidSealRoundZero => "InvalidSealRoundZero", Error::EscrowNotConserved => "EscrowNotConserved", } } @@ -2344,7 +2351,7 @@ fn error_discriminants_match_document() { #[test] fn error_codes_have_no_duplicate_discriminants() { - // O(n²) is fine: n = 28. Done without `std::collections` because the + // O(n²) is fine: n = 31. Done without `std::collections` because the // contract's `#![no_std]` applies to this module. for (i, (variant_a, code_a)) in DOCUMENTED_ERROR_CODES.iter().enumerate() { let name_a = variant_name(*variant_a); @@ -2371,7 +2378,7 @@ fn error_table_enumerates_every_variant() { // DOCUMENTED_ERROR_CODES. assert_eq!( DOCUMENTED_ERROR_CODES.len(), - 28, + 31, "DOCUMENTED_ERROR_CODES appears missing entries. The exhaustive \ `variant_name` match already enforces parity at compile time — \ update it together with this list and contracts/round/ERRORS.md." @@ -2384,7 +2391,8 @@ fn error_codes_use_reserved_ranges() { // 1–4 → initialization/lookup // 10–22 → lifecycle/timing // 30–39 → crypto/validation - // 40–49 → escrow accounting + // 40–42 → seal-round window + // 43–49 → escrow accounting // New categories should pick a fresh, contiguous range — not collide with // logging conventions — and update ERRORS.md at the same time. for (variant, code) in DOCUMENTED_ERROR_CODES { diff --git a/contracts/round/src/types.rs b/contracts/round/src/types.rs index 4fbac391..4e967305 100644 --- a/contracts/round/src/types.rs +++ b/contracts/round/src/types.rs @@ -35,8 +35,13 @@ pub enum Error { NoValidBids = 37, RoundFull = 38, InvalidLimit = 39, + // Seal-round window (issue #376): a seal must name the round the auction + // committed to open — exactly `Round::reveal_round`, never ±1 or 0. + SealRoundTooEarly = 40, + SealRoundTooLate = 41, + InvalidSealRoundZero = 42, // Escrow accounting - EscrowNotConserved = 40, + EscrowNotConserved = 43, } /// Round lifecycle. Mirrors the state machine in PRD §6. diff --git a/packages/round-bindings/src/index.ts b/packages/round-bindings/src/index.ts index 700e8889..7c936251 100644 --- a/packages/round-bindings/src/index.ts +++ b/packages/round-bindings/src/index.ts @@ -81,7 +81,10 @@ export const Errors = { 37: {message:"NoValidBids"}, 38: {message:"RoundFull"}, 39: {message:"InvalidLimit"}, - 40: {message:"EscrowNotConserved"} + 40: {message:"SealRoundTooEarly"}, + 41: {message:"SealRoundTooLate"}, + 42: {message:"InvalidSealRoundZero"}, + 43: {message:"EscrowNotConserved"} } diff --git a/packages/sdk/src/client.test.ts b/packages/sdk/src/client.test.ts index cfe6b135..a4b3d593 100644 --- a/packages/sdk/src/client.test.ts +++ b/packages/sdk/src/client.test.ts @@ -1,5 +1,6 @@ // Copyright (c) 2026 Sub Rosa contributors import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { describe, it } from "node:test"; import { Keypair, rpc, StrKey } from "@stellar/stellar-sdk"; @@ -7,6 +8,7 @@ import { SubRosaClient } from "./client.js"; import { SubRosaClientConfigError, SubRosaNetworkMismatchError, + SubRosaPaginationError, SubRosaSessionMismatchError, SubRosaSubmitError, } from "./errors.js"; diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 8a74a865..f83cd37a 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -55,6 +55,7 @@ import { SubRosaMissingReturnValueError, SubRosaNetworkMismatchError, SubRosaAssetValidationError, + SubRosaPaginationError, SubRosaSubmitError, SubRosaTimeoutError, SubRosaTransactionError, diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 3b44f341..adafb5c5 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -4,6 +4,171 @@ import type { EscrowConservationReport, } from "./conservation.js"; +/** Enumeration stopped because a page cannot be consumed without data loss. */ +export class SubRosaPaginationError extends Error { + readonly name = "SubRosaPaginationError"; + + constructor( + readonly roundId: bigint, + readonly reason: "repeated_bidder" | "invalid_page" | "repeated_cursor", + readonly bidder?: string, + ) { + super(`bidder enumeration for round ${roundId} failed: ${reason}${bidder ? ` (${bidder})` : ""}`); + } +} + +export type RoundContractErrorCode = + | "NotInitialized" + | "AlreadyInitialized" + | "RoundNotFound" + | "BidNotFound" + | "CommitClosed" + | "CommitNotClosed" + | "CommitDeadlineAfterReveal" + | "RevealNotOpen" + | "RevealAlreadyOpen" + | "RevealWindowClosed" + | "RevealStillOpen" + | "NotCleared" + | "AlreadyCleared" + | "AlreadySettled" + | "RoundVoided" + | "NotVoidable" + | "WrongStatus" + | "InvalidDrandSignature" + | "HashMismatch" + | "AlreadyRevealed" + | "PayloadTooLarge" + | "InvalidAmount" + | "BidExceedsEscrow" + | "DeadlineInPast" + | "NoValidBids" + | "RoundFull" + | "InvalidLimit" + | "SealRoundTooEarly" + | "SealRoundTooLate" + | "InvalidSealRoundZero" + | "EscrowNotConserved"; + +export interface RoundContractErrorSpec { + readonly code: number; + readonly name: RoundContractErrorCode; + readonly retryable: boolean; +} + +export interface ContractErrorEntry { + readonly code: number; + readonly name: string; +} + +/** + * Complete, single-source mapping of Soroban round contract errors to stable SDK codes and retryable flags. + * Derived from contracts/round/src/error_paths.rs, contracts/round/src/types.rs, and ERRORS.md. + */ +export const ROUND_CONTRACT_ERRORS: Readonly> = Object.freeze({ + 1: { code: 1, name: "NotInitialized", retryable: false }, + 2: { code: 2, name: "AlreadyInitialized", retryable: false }, + 3: { code: 3, name: "RoundNotFound", retryable: false }, + 4: { code: 4, name: "BidNotFound", retryable: false }, + 10: { code: 10, name: "CommitClosed", retryable: false }, + 11: { code: 11, name: "CommitNotClosed", retryable: true }, + 12: { code: 12, name: "CommitDeadlineAfterReveal", retryable: false }, + 13: { code: 13, name: "RevealNotOpen", retryable: true }, + 14: { code: 14, name: "RevealAlreadyOpen", retryable: false }, + 15: { code: 15, name: "RevealWindowClosed", retryable: false }, + 16: { code: 16, name: "RevealStillOpen", retryable: true }, + 17: { code: 17, name: "NotCleared", retryable: true }, + 18: { code: 18, name: "AlreadyCleared", retryable: false }, + 19: { code: 19, name: "AlreadySettled", retryable: false }, + 20: { code: 20, name: "RoundVoided", retryable: false }, + 21: { code: 21, name: "NotVoidable", retryable: true }, + 22: { code: 22, name: "WrongStatus", retryable: false }, + 30: { code: 30, name: "InvalidDrandSignature", retryable: false }, + 31: { code: 31, name: "HashMismatch", retryable: false }, + 32: { code: 32, name: "AlreadyRevealed", retryable: false }, + 33: { code: 33, name: "PayloadTooLarge", retryable: false }, + 34: { code: 34, name: "InvalidAmount", retryable: false }, + 35: { code: 35, name: "BidExceedsEscrow", retryable: false }, + 36: { code: 36, name: "DeadlineInPast", retryable: false }, + 37: { code: 37, name: "NoValidBids", retryable: false }, + 38: { code: 38, name: "RoundFull", retryable: false }, + 39: { code: 39, name: "InvalidLimit", retryable: false }, + 40: { code: 40, name: "SealRoundTooEarly", retryable: false }, + 41: { code: 41, name: "SealRoundTooLate", retryable: false }, + 42: { code: 42, name: "InvalidSealRoundZero", retryable: false }, + 43: { code: 43, name: "EscrowNotConserved", retryable: false }, +}); + +export const ROUND_CONTRACT_ERRORS_BY_NAME: Readonly> = Object.freeze( + Object.fromEntries( + Object.values(ROUND_CONTRACT_ERRORS).map((spec) => [spec.name, spec]), + ), +); + +/** + * Look up a contract error specification by numeric code or variant name. + */ +export function getRoundContractError( + codeOrName: number | string | undefined | null, +): RoundContractErrorSpec | undefined { + if (codeOrName === undefined || codeOrName === null) return undefined; + if (typeof codeOrName === "number") { + return ROUND_CONTRACT_ERRORS[codeOrName]; + } + const numeric = Number(codeOrName); + if (!Number.isNaN(numeric) && Object.prototype.hasOwnProperty.call(ROUND_CONTRACT_ERRORS, numeric)) { + return ROUND_CONTRACT_ERRORS[numeric]; + } + return ROUND_CONTRACT_ERRORS_BY_NAME[codeOrName]; +} + +/** + * Determine whether a contract error (by numeric code or variant name) is retryable. + * Unknown / unmapped errors always evaluate to false (non-retryable). + */ +export function isRoundContractErrorRetryable( + codeOrName: number | string | undefined | null, +): boolean { + const spec = getRoundContractError(codeOrName); + return spec !== undefined ? spec.retryable : false; +} + +/** + * Compare an external list of contract error items (e.g. from error_paths.rs, types.rs, or ERRORS.md) + * against the SDK mapping. Returns a list of drift failure messages, or an empty array when in sync. + */ +export function diffContractErrorMapping( + contractErrors: readonly ContractErrorEntry[], +): string[] { + const sdkByCode = new Map(Object.values(ROUND_CONTRACT_ERRORS).map((e) => [e.code, e])); + const inputByCode = new Map(contractErrors.map((e) => [e.code, e])); + + const failures: string[] = []; + + for (const item of contractErrors) { + const mapped = sdkByCode.get(item.code); + if (!mapped) { + failures.push( + `Contract error ${item.name} (#${item.code}) is not mapped in SDK ROUND_CONTRACT_ERRORS`, + ); + } else if (mapped.name !== item.name) { + failures.push( + `Contract error code #${item.code} maps to '${mapped.name}' in SDK but '${item.name}' in contract source`, + ); + } + } + + for (const mapped of Object.values(ROUND_CONTRACT_ERRORS)) { + if (!inputByCode.has(mapped.code)) { + failures.push( + `SDK maps error ${mapped.name} (#${mapped.code}) which is missing from contract errors list`, + ); + } + } + + return failures; +} + export class SubRosaClientConfigError extends Error { readonly name = "SubRosaClientConfigError"; @@ -294,3 +459,4 @@ export class SubRosaTimeoutError extends Error { this.pollIntervalMs = params.pollIntervalMs; } } + diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 0d7d15cb..8f4c98bb 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -37,6 +37,7 @@ export { SubRosaManifestError, SubRosaMissingReturnValueError, SubRosaNetworkMismatchError, + SubRosaPaginationError, SubRosaPreflightError, SubRosaSessionMismatchError, SubRosaSubmitError, @@ -71,6 +72,8 @@ export { type ProveEscrowConservationOptions, } from "./conservation.js"; export { + SubRosaNetworkPassphraseMismatchError, + networkPassphrasesMatch, validateContractNetwork, validatePasskeySession, type ContractNetworkValidationConfig, @@ -132,6 +135,7 @@ export { hasBlockingFailures, nativeXlmSacId, parseMainnetReadinessFixture, + parseMicroStroops, readLiveDeployment, runMainnetReadiness, summarizeDeploymentValue, diff --git a/packages/sdk/src/network.test.ts b/packages/sdk/src/network.test.ts index 1edb8172..a8255152 100644 --- a/packages/sdk/src/network.test.ts +++ b/packages/sdk/src/network.test.ts @@ -6,6 +6,10 @@ import { SubRosaSessionMismatchError, } from "./errors.js"; import { + describeNetworkPassphraseMismatch, + networkDisplayName, + networkPassphrasesMatch, + SubRosaNetworkPassphraseMismatchError, validateContractNetwork, validatePasskeySession, type NetworkValidationServer, diff --git a/packages/sdk/src/public-api-snapshot.test.ts b/packages/sdk/src/public-api-snapshot.test.ts index 2c7d5583..1be0289a 100644 --- a/packages/sdk/src/public-api-snapshot.test.ts +++ b/packages/sdk/src/public-api-snapshot.test.ts @@ -30,6 +30,11 @@ const EXPECTED_EXPORTS = [ "RECEIPT_VERSION", "ROUND_CONTRACT_ERRORS", "ROUND_CONTRACT_ERRORS_BY_NAME", + "ROUND_EVENT_LIFECYCLE_ORDER", + "ROUND_EVENT_PHASE_BY_NAME", + "ROUND_EVENT_PHASE_RANK", + "ROUND_PHASES", + "ROUND_PHASE_LABELS", "RoundContract", "RoundErrors", "StatusApiError", @@ -60,9 +65,11 @@ const EXPECTED_EXPORTS = [ "compareDeployment", "createSacBalanceReader", "defaultMainnetReadinessInput", + "diffContractErrorMapping", "deploymentChecks", "evaluatePreflight", "evaluateEscrowConservation", + "expectedRoundEventSequence", "fetchContractWasmHash", "fetchKeeperStatus", "fixtureDeployment", @@ -73,6 +80,7 @@ const EXPECTED_EXPORTS = [ "isActiveRoundStatus", "isErrorRoundStatus", "isEscrowConserved", + "isRoundPhase", "isKeeperRoundActive", "isKeeperRoundSettlementPending", "isKeeperRoundTerminal", @@ -92,6 +100,7 @@ const EXPECTED_EXPORTS = [ "redactReceipt", "redactUrlUserinfo", "roundStatusLabel", + "roundPhaseLabel", "runMainnetReadiness", "summarizeDeploymentValue", "serializeReceipt", @@ -102,6 +111,7 @@ const EXPECTED_EXPORTS = [ "validateEncryptedBlob", "validateContractNetwork", "validateSealedBid", + "validatePasskeySession", "verifyReceipt", "verifyReceiptEvents", "verifySettledRoundProof", diff --git a/packages/sdk/src/testing/seal-fixture.ts b/packages/sdk/src/testing/seal-fixture.ts index 47b2410d..77672b7c 100644 --- a/packages/sdk/src/testing/seal-fixture.ts +++ b/packages/sdk/src/testing/seal-fixture.ts @@ -17,6 +17,7 @@ import { type DrandClient, type SealedBid, } from "@sub-rosa/tlock"; +import { StrKey } from "@stellar/stellar-sdk"; import type { SealedBidBinding } from "../encrypted-blob.js"; /** quicknet's real chain public key. */ @@ -38,6 +39,11 @@ export const BID_VALUE = 8_675_309n; export const BID_NONCE = new Uint8Array(NONCE_BYTES).fill(0x2a); export const BID_ROUND = 1_234_567; +/** Fixed identities baked into the offline fixture (issue #413 validates both). */ +export const FIXTURE_CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32)); +export const FIXTURE_BIDDER_ID = + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + /** Seal a bid offline. Each call re-encrypts, so ciphertexts differ per call. */ export function sealFixture(overrides?: { value?: bigint; @@ -47,9 +53,12 @@ export function sealFixture(overrides?: { auditorPublicKey?: Uint8Array; }): Promise { return sealBid({ + contractId: FIXTURE_CONTRACT_ID, + bidderId: FIXTURE_BIDDER_ID, 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.test.ts b/packages/tlock/src/seal.test.ts index 55fa7a48..ab327c4a 100644 --- a/packages/tlock/src/seal.test.ts +++ b/packages/tlock/src/seal.test.ts @@ -113,7 +113,7 @@ test( const round = (await currentRound(client)) - 5; const value = 555n; const nonce = generateNonce(); - const sealed = await sealBid({ contractId: validContractId, bidderId: validBidderId, value, nonce, round, client }); + const sealed = await sealBid({ contractId: validContractId, bidderId: validBidderId, value, nonce, round, revealRound: round, client }); const opened = await openBid(sealed.ciphertext, client); const wrongNonce = new Uint8Array(32).fill(0x42); diff --git a/packages/tlock/src/seal.ts b/packages/tlock/src/seal.ts index df2eb4d0..c46a1ae3 100644 --- a/packages/tlock/src/seal.ts +++ b/packages/tlock/src/seal.ts @@ -13,6 +13,7 @@ import { randomBytes } from "@noble/hashes/utils.js"; import { commitment, decodeBidPreimage, encodeBidPreimage, NONCE_BYTES } from "./commitment.js"; import { sealIdentityForBidder } from "./auditor.js"; import type { DrandClient } from "./quicknet.js"; +import { assertSealRoundWindow } from "./window.js"; import { assertContractId, assertBidderId } from "./validate.js"; const utf8Encode = new TextEncoder(); @@ -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, revealRound, client, identity, auditorPublicKey } = params; assertContractId(contractId); assertBidderId(bidderId); diff --git a/scripts/check-round-errors.test.mjs b/scripts/check-round-errors.test.mjs index e6365a71..df3d94cf 100644 --- a/scripts/check-round-errors.test.mjs +++ b/scripts/check-round-errors.test.mjs @@ -6,8 +6,6 @@ import { fileURLToPath } from "node:url"; import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { fileURLToPath } from "node:url"; - import { diffVariants, parseErrorsMd, @@ -48,7 +46,7 @@ describe("parseTypesRs", () => { "utf-8", ); const variants = parseTypesRs(content); - assert.equal(variants.length, 28); + assert.equal(variants.length, 31); assert.deepEqual( variants.find((v) => v.name === "CommitClosed"), { name: "CommitClosed", code: 10 }, @@ -63,7 +61,7 @@ describe("parseErrorsMd", () => { "utf-8", ); const variants = parseErrorsMd(content); - assert.equal(variants.length, 28); + assert.equal(variants.length, 31); assert.deepEqual( variants.find((v) => v.name === "InvalidLimit"), { name: "InvalidLimit", code: 39 }, @@ -78,7 +76,7 @@ describe("parseSdkErrors", () => { "utf-8", ); const variants = parseSdkErrors(content); - assert.equal(variants.length, 27); + assert.equal(variants.length, 31); assert.deepEqual( variants.find((v) => v.name === "InvalidLimit"), { name: "InvalidLimit", code: 39 }, @@ -90,9 +88,9 @@ describe("check-round-errors script", () => { it("passes against the real repo sources", () => { const result = runScript(); assert.match(result, /PASS\s+types\.rs, ERRORS\.md, and SDK list the same error codes\./); - assert.match(result, /types\.rs : 27 variants/); - assert.match(result, /ERRORS\.md: 27 rows/); - assert.match(result, /SDK errors: 27 variants/); + assert.match(result, /types\.rs : 31 variants/); + assert.match(result, /ERRORS\.md: 31 rows/); + assert.match(result, /SDK errors: 31 variants/); }); it("fails when ERRORS.md is missing a variant", () => {