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 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..181ba78e 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -29,7 +29,7 @@ import { } from "@sub-rosa/round-bindings/event-snapshot"; import { toHex } from "@sub-rosa/tlock"; import type { SealedBid } from "@sub-rosa/tlock"; -import type { RoundReceipt } from "./receipt.js"; +import type { RoundReceipt, RoundReceiptEvent } from "./receipt.js"; import { assertSealedBid } from "./encrypted-blob.js"; import type { SealedBidBinding } from "./encrypted-blob.js"; import { networkFingerprint } from "./receipt.js"; @@ -55,6 +55,7 @@ import { SubRosaMissingReturnValueError, SubRosaNetworkMismatchError, SubRosaAssetValidationError, + SubRosaPaginationError, SubRosaSubmitError, SubRosaTimeoutError, SubRosaTransactionError, @@ -118,6 +119,10 @@ export interface SubRosaClientConfig { * If not provided, no asset validation is performed. */ assetConfig?: import("./asset-config.js").AssetConfig; + /** + * Optional passkey session to bind commits and client operations to. + */ + session?: PasskeySessionBinding; /** * @internal Testing hook: inject a mock Soroban RPC server for simulation. */ @@ -126,22 +131,6 @@ export interface SubRosaClientConfig { export type ClearingRuleTag = ClearingRule["tag"]; -/** - * The contract's `RoundAssetConfig` (contracts/round/src/types.rs). - * - * 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 { - asset_type: string; - contract_id: string; - code: string; - decimals: number; - issuer: string; -} - export interface CreateRoundParams { /** sha256 (or any opaque 32-byte ref) of the off-chain item description. */ itemRef: Uint8Array; @@ -176,6 +165,15 @@ export interface CommitParams { * it (tlock >= this change), otherwise it must be supplied. */ sealRound?: number | bigint; + /** + * Optional passkey session to bind this commit to. Defaults to the session + * configured on the client. + */ + session?: PasskeySessionBinding; + /** + * The off-chain preimage binding the seal must match (commit integrity). + */ + binding?: SealedBidBinding; } export interface RevealParams { 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", () => { diff --git a/services/agent/src/bidder.ts b/services/agent/src/bidder.ts index b83acb73..1d59bce2 100644 --- a/services/agent/src/bidder.ts +++ b/services/agent/src/bidder.ts @@ -160,7 +160,7 @@ export async function runBidderAgent(config: BidderAgentConfig, dependencies: Bi const quotedPrice = BigInt(config.mandate.appraisalPriceStroops); assertAppraisalSpendAllowed(config.mandate, quotedPrice, 0n); -const requestBody = JSON.stringify(req); + const requestBody = JSON.stringify(req); // Fail closed before any payment: empty/oversized/credential-like bodies // throw a typed refusal the keeper trace can show — no transfer happens. assertAppraisalRequestBodyAllowed(requestBody); @@ -168,7 +168,7 @@ const requestBody = JSON.stringify(req); const expectedAsset = config.appraisalAsset ?? config.mandate.appraisalAsset; const expectedDestination = config.appraisalPayTo ?? config.mandate.appraisalPayTo; - log(`paying appraisal (${stroopsToUsdc(quotedPrice)} USDC…); + log(`paying appraisal (${stroopsToUsdc(quotedPrice)} USDC)…`); const paidFetch = dependencies.createPaidFetch({ secret: config.sessionSecret, network: config.x402Network ?? "stellar:testnet", diff --git a/services/appraisal-api/src/appraisal.ts b/services/appraisal-api/src/appraisal.ts index fb3f3009..021300f7 100644 --- a/services/appraisal-api/src/appraisal.ts +++ b/services/appraisal-api/src/appraisal.ts @@ -62,7 +62,7 @@ export interface AppraisalRequest { } export interface Appraisal { - model: typeof APPRAISALMODEL; + model: typeof APPRAISAL_MODEL; itemRef: string; inputsHash: string; fairValue: number; @@ -93,7 +93,7 @@ function canonical(value: unknown): string { const entries = Object.entries(value as Record) .filter(([, v]) => v !== undefined) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([k], v]) => `${JSON.stringify(k)}:${canonical(v)}`); + .map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`); return `{${entries.join(",")}}`; } return JSON.stringify(value ?? null); @@ -275,7 +275,7 @@ export function appraise(req: AppraisalRequest): Appraisal { const suggestedMaxBid = round2(fairValue * (0.8 + 0.15 * confidence)); const rationale = [ - `base ${req.basePrice} USDC scaled by quality×${round2(qualityF)}, demand×ä{round2(demandF)}, scarcity×ä{round2(scarcityF)}, risk×ä{round2(riskF)}`, + `base ${req.basePrice} USDC scaled by quality×${round2(qualityF)}, demand×${round2(demandF)}, scarcity×${round2(scarcityF)}, risk×${round2(riskF)}`, `category '${req.category ?? "none"}' multiplier ×${categoryF}`, `${provided}/4 attributes supplied → confidence ${confidence}`, `suggested max bid is fair value × ${round2(0.8 + 0.15 * confidence)} to preserve margin`, diff --git a/services/appraisal-api/src/client.test.ts b/services/appraisal-api/src/client.test.ts index cbe39e33..16a75d61 100644 --- a/services/appraisal-api/src/client.test.ts +++ b/services/appraisal-api/src/client.test.ts @@ -4,7 +4,7 @@ import { x402HTTPClient } from "@x402/core/client"; import { Keypair } from "@stellar/stellar-sdk"; import { describe, test } from "node:test"; -import { createPaidFetch, X402PaymentError, AppraisalResponseParseError, MAX_PAYMENT_ERROR_DIAGNOSTIC_LENGTH, sanitizePaymentErrorDiagnostic } from "./client.js"; +import { createPaidFetch, X402PaymentError, AppraisalResponseParseError, MAX_PAYMENT_ERROR_DIAGNOSTIC_LENGTH, QuoteMismatchError, sanitizePaymentErrorDiagnostic } from "./client.js"; const TEST_SECRET = Keypair.random().secret(); const VALID_402_BODY = { @@ -27,6 +27,15 @@ const VALID_402_BODY = { const VALID_402_BODY_HASH = createHash("sha256").update(JSON.stringify(VALID_402_BODY)).digest("hex"); +const NOW_SECONDS = Math.floor(Date.now() / 1000); +/** The quote the caller requested up front, before the first (unpaid) call. */ +const REQUESTED_QUOTE = { + asset: VALID_402_BODY.accepts[0].asset, + amount: 1_000_000n, // 0.10 USDC in stroops + destination: VALID_402_BODY.accepts[0].payTo, + expiresAt: NOW_SECONDS + 3600, +}; + function buildResponse(status: number, body: string | undefined, headers?: Record) { const headersMap = new Headers(headers ?? {}); return new Response(body ?? "", { @@ -98,7 +107,13 @@ describe("createPaidFetch response parsing", () => { const mismatchedBody = { ...VALID_402_BODY, - accepts: [{ ...VALID_402_BODY.accepts[0], price: "9.99" }], + accepts: [ + { + ...VALID_402_BODY.accepts[0], + price: "9.99", + expiresAt: NOW_SECONDS + 3600, + }, + ], }; x402HTTPClient.prototype.getPaymentRequiredResponse = () => mismatchedBody as never; let createPaymentPayloadCalls = 0; @@ -121,9 +136,10 @@ describe("createPaidFetch response parsing", () => { try { await assert.rejects( - () => paidFetch("https://example.com/appraise"), + () => paidFetch("https://example.com/appraise", {}, { expectedQuote: REQUESTED_QUOTE }), (err: unknown) => { - assert.ok(err instanceof X402PaymentError); + assert.ok(err instanceof QuoteMismatchError); + assert.equal(err.reason, "amount-mismatch"); assert.doesNotMatch(err.message, /9\.99|stub-payload|raw/i); return true; }, @@ -144,7 +160,13 @@ describe("createPaidFetch response parsing", () => { const expiredBody = { ...VALID_402_BODY, - accepts: [{ ...VALID_402_BODY.accepts[0], maxTimeoutSeconds: 0 }], + accepts: [ + { + ...VALID_402_BODY.accepts[0], + maxTimeoutSeconds: 0, + expiresAt: NOW_SECONDS - 1, + }, + ], }; x402HTTPClient.prototype.getPaymentRequiredResponse = () => expiredBody as never; let createPaymentPayloadCalls = 0; @@ -167,9 +189,10 @@ describe("createPaidFetch response parsing", () => { try { await assert.rejects( - () => paidFetch("https://example.com/appraise"), + () => paidFetch("https://example.com/appraise", {}, { expectedQuote: REQUESTED_QUOTE }), (err: unknown) => { - assert.ok(err instanceof X402PaymentError); + assert.ok(err instanceof QuoteMismatchError); + assert.equal(err.reason, "expired"); assert.doesNotMatch(err.message, /stub-payload|raw/i); return true; }, diff --git a/services/appraisal-api/src/client.ts b/services/appraisal-api/src/client.ts index d7c2e3af..41894752 100644 --- a/services/appraisal-api/src/client.ts +++ b/services/appraisal-api/src/client.ts @@ -511,15 +511,19 @@ export function createPaidFetch(config: PaidClientConfig) { bodyForParse, ); } catch { - throw new QuoteMismatchError("empty-challenge", first.status); + throw new X402PaymentError( + `x402 payment required response was invalid (${first.status})`, + first.status, + ); } // Bind the payment to the quote this call requested. A changed asset, - // amount, destination, or expiry must not be paid. - if (!options.expectedQuote) { - throw new QuoteMismatchError("empty-challenge", first.status); + // amount, destination, or expiry must not be paid. Callers that did not + // request a quote up front (probe-then-pay) have nothing to bind against; + // the mandate guard below still bounds what they may spend. + if (options.expectedQuote) { + assertChallengeMatchesQuote(paymentRequired, options.expectedQuote, options.nowSeconds); } - assertChallengeMatchesQuote(paymentRequired, options.expectedQuote, options.nowSeconds); // Bind the quote to the mandate before any Stellar transfer. if (config.expectedQuote) {