diff --git a/supplier/.env.example b/supplier/.env.example index 6bb6449..c1c9f73 100644 --- a/supplier/.env.example +++ b/supplier/.env.example @@ -160,3 +160,18 @@ SURPLUS_REQUEST_TIMEOUT_MS=120000 SURPLUS_STOP_AFTER_SETTLED_REQUESTS=0 SURPLUS_SETTLED_STATUSES=confirmed SURPLUS_STATE_PATH=/var/lib/surplus-manager/state.json +# --- Vector proof publisher ------------------------------------------------ +# When enabled, every settled Base settlement transaction gets one Vector +# counterpart: the sales it settled are Merkle-batched and anchored in a +# label-674 metadata transaction signed by the proof wallet below. +SURPLUS_PROOF_ENABLED= +# Required when enabled. Mainnet Ogmios endpoint and the 64-hex Ed25519 key +# of the AP3X proof wallet (the retired Vector supplier wallet). +OGMIOS_URL= +SURPLUS_PROOF_WALLET_PRIV_KEY_HEX= +SURPLUS_PROOF_LEDGER_PATH=/var/lib/surplus-manager/vector-proofs.json +# Anchoring pauses when the wallet cannot keep this reserve after one fee. +SURPLUS_PROOF_RESERVE_LOVELACE=5000000 +SURPLUS_PROOF_FEE_BUDGET_LOVELACE=1000000 +SURPLUS_PROOF_INTERVAL_MS=300000 +SURPLUS_PROOF_CONFIRM_TIMEOUT_MS=300000 diff --git a/supplier/src/cli/surplus-seller.ts b/supplier/src/cli/surplus-seller.ts index 705f583..aae8c17 100644 --- a/supplier/src/cli/surplus-seller.ts +++ b/supplier/src/cli/surplus-seller.ts @@ -15,10 +15,20 @@ import { SurplusDashboardService, type SurplusDashboardSnapshot, } from "../surplus/dashboard.js"; +import { FileSurplusVectorProofLedger } from "../surplus/vectorProof.js"; +import { SurplusVectorProofPublisher } from "../surplus/vectorPublisher.js"; +import { LiveOgmiosProvider } from "@marketplace/shared/chain"; +import type { WalletKey } from "@marketplace/shared/tx"; +import { buildAnchorMetadataTx } from "@marketplace/shared/tx/server"; +import { deriveKeypair } from "./gen-keypair.js"; export async function runSurplusSeller( env: Record, -): Promise<{ server: Server; controller: SurplusSellerController }> { +): Promise<{ + server: Server; + controller: SurplusSellerController; + publisher: SurplusVectorProofPublisher | null; +}> { const config = loadSurplusManagerConfig(env); const client = new SurplusClient({ apiBaseUrl: config.apiBaseUrl, @@ -38,11 +48,51 @@ export async function runSurplusSeller( stateStore, log: (message) => console.log(message), }); + + let publisher: SurplusVectorProofPublisher | null = null; + let proofs: FileSurplusVectorProofLedger | undefined; + if (config.proof !== null) { + const proofConfig = config.proof; + const ledger = await FileSurplusVectorProofLedger.open(proofConfig.ledgerPath); + proofs = ledger; + const chain = new LiveOgmiosProvider({ ogmiosUrl: proofConfig.ogmiosUrl }); + const derived = deriveKeypair(proofConfig.walletPrivateKeyHex, 1); + const walletKey: WalletKey = { + pubKeyHash: derived.pubKeyHash, + pubKeyHex: derived.publicKeyHex, + privateKeyHex: derived.privateKeyHex, + address: derived.address, + }; + publisher = new SurplusVectorProofPublisher({ + ledger, + earnings: () => client.getEarnings(), + anchor: async (metadata) => { + const built = await buildAnchorMetadataTx({ chain, walletKey, metadata }); + return { expectedTxHash: built.expectedTxHash }; + }, + awaitTx: (txHash, timeoutMs) => chain.awaitTx(txHash, timeoutMs), + balanceLovelace: async () => { + const utxos = await chain.queryUtxosByAddress(walletKey.address); + return utxos.reduce((sum, utxo) => sum + utxo.lovelace, 0n); + }, + reserveLovelace: proofConfig.reserveLovelace, + feeBudgetLovelace: proofConfig.feeBudgetLovelace, + settledStatuses: config.settledStatuses, + intervalMs: proofConfig.intervalMs, + confirmTimeoutMs: proofConfig.confirmTimeoutMs, + log: (message) => console.log(message), + }); + console.log( + `surplus-proof: publisher enabled with wallet ${derived.address} and ledger ${proofConfig.ledgerPath}`, + ); + } + const dashboard = new SurplusDashboardService({ controller, client, allowance, config, + proofs, }); const server = createSurplusServer(controller, dashboard); await new Promise((resolveListen, rejectListen) => { @@ -53,7 +103,8 @@ export async function runSurplusSeller( }); }); await controller.start(); - return { server, controller }; + publisher?.start(); + return { server, controller, publisher }; } interface DashboardSource { @@ -115,13 +166,14 @@ async function handleRequest( export async function main( env: Record = process.env, ): Promise { - const { server, controller } = await runSurplusSeller(env); + const { server, controller, publisher } = await runSurplusSeller(env); console.log("Surplus seller controller listening"); let shuttingDown = false; const shutdown = async (signal: string): Promise => { if (shuttingDown) return; shuttingDown = true; console.log(`Surplus seller controller shutting down (${signal})`); + await publisher?.stop(); await controller.stop(); await new Promise((resolveClose, rejectClose) => { server.close((error) => error ? rejectClose(error) : resolveClose()); diff --git a/supplier/src/surplus/config.ts b/supplier/src/surplus/config.ts index 16ac21e..b9bc112 100644 --- a/supplier/src/surplus/config.ts +++ b/supplier/src/surplus/config.ts @@ -2,6 +2,7 @@ const USD_RE = /^(?:0|[1-9]\d*)(?:\.\d{1,6})?$/; const EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; const POSITIVE_INT_RE = /^[1-9]\d*$/; const NON_NEGATIVE_INT_RE = /^(?:0|[1-9]\d*)$/; +const HEX64_RE = /^[0-9a-fA-F]{64}$/; export interface SurplusManagerConfig { live: boolean; @@ -25,8 +26,18 @@ export interface SurplusManagerConfig { settledStatuses: string[]; statePath: string; port: number; + proof: SurplusProofConfig | null; } +export interface SurplusProofConfig { + ogmiosUrl: string; + walletPrivateKeyHex: string; + ledgerPath: string; + reserveLovelace: bigint; + feeBudgetLovelace: bigint; + intervalMs: number; + confirmTimeoutMs: number; +} export function loadSurplusManagerConfig( env: Record, ): SurplusManagerConfig { @@ -135,6 +146,62 @@ export function loadSurplusManagerConfig( "SURPLUS_STATE_PATH", ), port: boundedInteger(env.PORT ?? "8080", "PORT", 1, 65_535), + proof: loadProofConfig(env), + }; +} + +function loadProofConfig( + env: Record, +): SurplusProofConfig | null { + const enabledRaw = env.SURPLUS_PROOF_ENABLED; + if (enabledRaw === undefined || enabledRaw === "") return null; + if (enabledRaw !== "1") { + throw new Error("SURPLUS_PROOF_ENABLED must be literal 1 or unset"); + } + + const ogmiosUrl = required(env, "OGMIOS_URL"); + const walletPrivateKeyHex = required( + env, + "SURPLUS_PROOF_WALLET_PRIV_KEY_HEX", + ); + if (!HEX64_RE.test(walletPrivateKeyHex)) { + throw new Error( + "SURPLUS_PROOF_WALLET_PRIV_KEY_HEX must be 64 hexadecimal characters", + ); + } + + return { + ogmiosUrl, + walletPrivateKeyHex: walletPrivateKeyHex.toLowerCase(), + ledgerPath: requiredWithDefault( + env.SURPLUS_PROOF_LEDGER_PATH, + "/var/lib/surplus-manager/vector-proofs.json", + "SURPLUS_PROOF_LEDGER_PATH", + ), + reserveLovelace: BigInt(boundedInteger( + env.SURPLUS_PROOF_RESERVE_LOVELACE ?? "5000000", + "SURPLUS_PROOF_RESERVE_LOVELACE", + 0, + Number.MAX_SAFE_INTEGER, + )), + feeBudgetLovelace: BigInt(boundedInteger( + env.SURPLUS_PROOF_FEE_BUDGET_LOVELACE ?? "1000000", + "SURPLUS_PROOF_FEE_BUDGET_LOVELACE", + 1, + Number.MAX_SAFE_INTEGER, + )), + intervalMs: boundedInteger( + env.SURPLUS_PROOF_INTERVAL_MS ?? "300000", + "SURPLUS_PROOF_INTERVAL_MS", + 60_000, + 86_400_000, + ), + confirmTimeoutMs: boundedInteger( + env.SURPLUS_PROOF_CONFIRM_TIMEOUT_MS ?? "300000", + "SURPLUS_PROOF_CONFIRM_TIMEOUT_MS", + 10_000, + 3_600_000, + ), }; } diff --git a/supplier/src/surplus/vectorPublisher.ts b/supplier/src/surplus/vectorPublisher.ts new file mode 100644 index 0000000..16dd768 --- /dev/null +++ b/supplier/src/surplus/vectorPublisher.ts @@ -0,0 +1,268 @@ +/** + * vectorPublisher.ts — anchors Surplus sale Merkle batches on Vector. + * + * Policy (2026-08-28): every settled Base USDC settlement transaction gets its + * own Vector counterpart. Sales are grouped by their Base `tx_hash`; each group + * becomes one Merkle batch anchored in one label-674 Vector transaction. + * Observed sales that fell out of the Surplus API window before settling are + * anchored together in a trailing catch-up batch so no captured sale is left + * without a proof. + * + * The publisher is fail-closed: + * - it never anchors while the wallet cannot cover reserve + fee budget, + * - every batch is persisted as a pending intent before any submission, + * - a stale pending intent found at cycle start is failed as "submit" and + * rebuilt deterministically (same sales ⇒ same root) on the same cycle. + */ + +import type { SurplusEarnings, SurplusSale } from "./client.js"; +import { + SURPLUS_SALE_PROOF_PROTOCOL, + type CanonicalSurplusSale, + type FileSurplusVectorProofLedger, + type SurplusPendingBatchIntent, +} from "./vectorProof.js"; + +export interface SurplusProofAnchorMetadata { + p: typeof SURPLUS_SALE_PROOF_PROTOCOL; + root: string; + count: number; + first: string; + last: string; +} + +export type SurplusProofLedgerSeam = Pick< + FileSurplusVectorProofLedger, + | "ingestSales" + | "selectUnprovedSales" + | "persistPendingBatch" + | "pendingBatchIntent" + | "confirmPendingBatch" + | "failPendingBatch" +>; + +export interface SurplusVectorProofPublisherOptions { + ledger: SurplusProofLedgerSeam; + earnings: () => Promise; + anchor: ( + metadata: SurplusProofAnchorMetadata, + ) => Promise<{ expectedTxHash: string }>; + awaitTx: (txHash: string, timeoutMs: number) => Promise; + balanceLovelace: () => Promise; + reserveLovelace: bigint; + feeBudgetLovelace: bigint; + settledStatuses: readonly string[]; + intervalMs: number; + confirmTimeoutMs: number; + log: (message: string) => void; +} + +export interface SurplusPublisherCycleResult { + ingested: number; + anchored: number; + fundingBlocked: boolean; + failed: boolean; +} + +export class SurplusVectorProofPublisher { + private readonly options: SurplusVectorProofPublisherOptions; + private timer: NodeJS.Timeout | null = null; + private inFlight: Promise | null = null; + private stopped = false; + + constructor(options: SurplusVectorProofPublisherOptions) { + if (options.reserveLovelace < 0n || options.feeBudgetLovelace <= 0n) { + throw new Error( + "Surplus proof publisher needs a non-negative reserve and a positive fee budget", + ); + } + this.options = options; + } + + start(): void { + if (this.timer !== null) return; + this.stopped = false; + void this.tick(); + this.timer = setInterval(() => void this.tick(), this.options.intervalMs); + this.timer.unref?.(); + } + + async stop(): Promise { + this.stopped = true; + if (this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } + if (this.inFlight !== null) { + await this.inFlight.catch(() => undefined); + } + } + + private async tick(): Promise { + if (this.inFlight !== null || this.stopped) return; + const cycle = this.runCycle(); + this.inFlight = cycle; + try { + await cycle; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + this.options.log(`surplus-proof: cycle failed: ${detail}`); + } finally { + this.inFlight = null; + } + } + + async runCycle(): Promise { + const { ledger, log } = this.options; + const result: SurplusPublisherCycleResult = { + ingested: 0, + anchored: 0, + fundingBlocked: false, + failed: false, + }; + + const settled = await this.observeSettledSales(); + const { inserted } = await ledger.ingestSales(settled); + result.ingested = inserted; + if (inserted > 0) { + log(`surplus-proof: captured ${inserted} newly settled sale(s)`); + } + + const stale = ledger.pendingBatchIntent(); + if (stale !== null) { + await ledger.failPendingBatch(stale.batchRoot, "submit"); + log( + `surplus-proof: failed stale pending batch ${stale.batchRoot} from an interrupted run; retrying this cycle`, + ); + } + + for (const batch of this.planBatches(settled, ledger.selectUnprovedSales())) { + if (this.stopped) break; + if (!(await this.fundsAvailable())) { + result.fundingBlocked = true; + break; + } + const anchored = await this.anchorBatch(batch); + if (!anchored) { + result.failed = true; + break; + } + result.anchored += 1; + } + return result; + } + + /** Settled = carries a Base settlement tx hash and passes the status filter. */ + private async observeSettledSales(): Promise { + const { earnings, settledStatuses } = this.options; + const snapshot = await earnings(); + return snapshot.recentSales.filter((sale) => + sale.transactionHash !== null && + (settledStatuses.length === 0 || + settledStatuses.includes(sale.settlementStatus)) + ); + } + + /** + * One batch per observed Base settlement tx (oldest first), then one + * catch-up batch for unproved sales whose settlement tx is no longer + * observable in the Surplus API window. + */ + private planBatches( + settled: readonly SurplusSale[], + unproved: readonly CanonicalSurplusSale[], + ): CanonicalSurplusSale[][] { + const txBySaleId = new Map(); + for (const sale of settled) { + if (sale.transactionHash !== null) { + txBySaleId.set(sale.id, sale.transactionHash); + } + } + + const groups = new Map(); + const leftovers: CanonicalSurplusSale[] = []; + for (const sale of unproved) { + const settlementTx = txBySaleId.get(sale.saleId); + if (settlementTx === undefined) { + leftovers.push(sale); + continue; + } + const group = groups.get(settlementTx); + if (group === undefined) groups.set(settlementTx, [sale]); + else group.push(sale); + } + + const batches = [...groups.values()].sort( + (left, right) => earliest(left) - earliest(right), + ); + if (leftovers.length > 0) batches.push(leftovers); + return batches; + } + + private async fundsAvailable(): Promise { + const { balanceLovelace, reserveLovelace, feeBudgetLovelace, log } = + this.options; + const balance = await balanceLovelace(); + if (balance >= reserveLovelace + feeBudgetLovelace) return true; + log( + `surplus-proof: funding required — wallet holds ${balance} lovelace, ` + + `needs reserve ${reserveLovelace} + fee budget ${feeBudgetLovelace}; anchoring paused`, + ); + return false; + } + + private async anchorBatch( + sales: readonly CanonicalSurplusSale[], + ): Promise { + const { ledger, anchor, awaitTx, confirmTimeoutMs, log } = this.options; + const saleIds = sales.map((sale) => sale.saleId).sort(); + const intent: SurplusPendingBatchIntent = await ledger.persistPendingBatch( + saleIds, + ); + + let expectedTxHash: string; + try { + const built = await anchor({ + p: SURPLUS_SALE_PROOF_PROTOCOL, + root: intent.batchRoot, + count: intent.count, + first: intent.firstSaleId, + last: intent.lastSaleId, + }); + expectedTxHash = built.expectedTxHash; + } catch (error) { + await ledger.failPendingBatch(intent.batchRoot, "submit"); + const detail = error instanceof Error ? error.message : String(error); + log( + `surplus-proof: submit failed for batch ${intent.batchRoot} (${intent.count} sale(s)): ${detail}`, + ); + return false; + } + + try { + await awaitTx(expectedTxHash, confirmTimeoutMs); + } catch (error) { + await ledger.failPendingBatch(intent.batchRoot, "confirm"); + const detail = error instanceof Error ? error.message : String(error); + log( + `surplus-proof: confirmation timed out for batch ${intent.batchRoot} in tx ${expectedTxHash}: ${detail}`, + ); + return false; + } + + await ledger.confirmPendingBatch(intent.batchRoot, expectedTxHash); + log( + `surplus-proof: anchored ${intent.count} sale(s) [${intent.firstSaleId}..${intent.lastSaleId}] ` + + `root ${intent.batchRoot} in Vector tx ${expectedTxHash}`, + ); + return true; + } +} + +function earliest(sales: readonly CanonicalSurplusSale[]): number { + let value = Number.POSITIVE_INFINITY; + for (const sale of sales) { + if (sale.createdAtEpochMs < value) value = sale.createdAtEpochMs; + } + return value; +} diff --git a/tests/unit/surplus-vector-publisher.test.ts b/tests/unit/surplus-vector-publisher.test.ts new file mode 100644 index 0000000..4470e0f --- /dev/null +++ b/tests/unit/surplus-vector-publisher.test.ts @@ -0,0 +1,227 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + SurplusEarnings, + SurplusSale, +} from "../../supplier/src/surplus/client.js"; +import { FileSurplusVectorProofLedger } from "../../supplier/src/surplus/vectorProof.js"; +import { + SurplusVectorProofPublisher, + type SurplusProofAnchorMetadata, + type SurplusVectorProofPublisherOptions, +} from "../../supplier/src/surplus/vectorPublisher.js"; + +const directories: string[] = []; + +async function temporaryLedger(): Promise { + const directory = await mkdtemp(join(tmpdir(), "surplus-vector-publisher-")); + directories.push(directory); + return FileSurplusVectorProofLedger.open(join(directory, "ledger.json")); +} + +afterEach(async () => { + await Promise.all(directories.splice(0).map((path) => + rm(path, { recursive: true, force: true }) + )); +}); + +function sale( + id: string, + overrides: Partial = {}, +): SurplusSale { + const createdAtMs = Date.parse("2026-08-28T11:00:00.123Z"); + return { + id, + model: "deepseek-v4-flash", + offerId: "offer-1", + settlementStatus: "confirmed", + createdAt: new Date(createdAtMs).toISOString(), + createdAtMs, + sellerCostMicroUsd: 83, + inputTokens: 10, + outputTokens: 2, + cacheReadTokens: 3, + effectiveInputUsdPer1m: 0.003972, + effectiveOutputUsdPer1m: 0.007944, + transactionHash: "0x" + "a".repeat(64), + ...overrides, + }; +} + +function earningsWith(recentSales: SurplusSale[]): SurplusEarnings { + return { + totalEarnedMicroUsd: 186_810, + pendingMicroUsd: 176_817, + paidMicroUsd: 9_993, + requestCount: recentSales.length, + tokenCount: 120, + topModel: "deepseek-v4-flash", + payoutHoldReason: null, + payoutHoldReleasesAt: null, + daily: [], + byModel: [], + recentSales, + } as unknown as SurplusEarnings; +} + +interface HarnessOverrides { + sales?: SurplusSale[]; + balance?: bigint; + anchor?: SurplusVectorProofPublisherOptions["anchor"]; + awaitTx?: SurplusVectorProofPublisherOptions["awaitTx"]; +} + +async function harness(overrides: HarnessOverrides = {}) { + const ledger = await temporaryLedger(); + const anchors: SurplusProofAnchorMetadata[] = []; + const logs: string[] = []; + let txCounter = 0; + const anchor = overrides.anchor ?? (async (metadata: SurplusProofAnchorMetadata) => { + anchors.push(metadata); + txCounter += 1; + return { expectedTxHash: String(txCounter).repeat(64).slice(0, 64) }; + }); + const publisher = new SurplusVectorProofPublisher({ + ledger, + earnings: async () => earningsWith(overrides.sales ?? []), + anchor, + awaitTx: overrides.awaitTx ?? (async () => undefined), + balanceLovelace: async () => overrides.balance ?? 25_000_000n, + reserveLovelace: 5_000_000n, + feeBudgetLovelace: 1_000_000n, + settledStatuses: ["confirmed"], + intervalMs: 60_000, + confirmTimeoutMs: 1_000, + log: (message) => logs.push(message), + }); + return { ledger, publisher, anchors, logs }; +} + +describe("SurplusVectorProofPublisher", () => { + it("anchors one Vector transaction per settled Base settlement transaction", async () => { + const baseTxA = "0x" + "a".repeat(64); + const baseTxB = "0x" + "b".repeat(64); + const sales = [ + sale("01SALEA1", { transactionHash: baseTxA }), + sale("01SALEA2", { transactionHash: baseTxA }), + sale("01SALEB1", { + transactionHash: baseTxB, + createdAtMs: Date.parse("2026-08-28T12:00:00.000Z"), + createdAt: "2026-08-28T12:00:00.000Z", + }), + sale("01UNSETTLED", { transactionHash: null, settlementStatus: "accrued" }), + ]; + const { ledger, publisher, anchors } = await harness({ sales }); + + const result = await publisher.runCycle(); + + expect(result).toMatchObject({ + ingested: 3, + anchored: 2, + fundingBlocked: false, + failed: false, + }); + expect(anchors).toHaveLength(2); + expect(anchors[0]).toMatchObject({ + p: "surplus-sale-proof-v1", + count: 2, + first: "01SALEA1", + last: "01SALEA2", + }); + expect(anchors[1]).toMatchObject({ count: 1, first: "01SALEB1" }); + + for (const id of ["01SALEA1", "01SALEA2", "01SALEB1"]) { + expect(ledger.dashboardProof(id)).toMatchObject({ status: "confirmed" }); + } + expect(ledger.dashboardProof("01SALEA1")?.txHash).not.toBe( + ledger.dashboardProof("01SALEB1")?.txHash, + ); + expect(ledger.dashboardProof("01UNSETTLED")).toBeNull(); + expect(ledger.confirmedBatchHistory()).toHaveLength(2); + }); + + it("pauses anchoring below the reserve plus fee budget without touching the ledger batches", async () => { + const { ledger, publisher, anchors, logs } = await harness({ + sales: [sale("01SALEA1")], + balance: 5_500_000n, + }); + + const result = await publisher.runCycle(); + + expect(result).toMatchObject({ anchored: 0, fundingBlocked: true }); + expect(anchors).toHaveLength(0); + expect(ledger.pendingBatchIntent()).toBeNull(); + expect(ledger.dashboardProof("01SALEA1")).toMatchObject({ status: "pending" }); + expect(logs.some((line) => line.includes("funding required"))).toBe(true); + }); + + it("records a submit failure and retries the same sales on the next cycle", async () => { + const anchor = vi.fn() + .mockRejectedValueOnce(new Error("ogmios unreachable")) + .mockResolvedValue({ expectedTxHash: "c".repeat(64) }); + const { ledger, publisher } = await harness({ + sales: [sale("01SALEA1")], + anchor, + }); + + const firstCycle = await publisher.runCycle(); + expect(firstCycle).toMatchObject({ anchored: 0, failed: true }); + expect(ledger.dashboardProof("01SALEA1")).toMatchObject({ status: "failed" }); + expect(ledger.failedAttemptHistory()).toHaveLength(1); + + const secondCycle = await publisher.runCycle(); + expect(secondCycle).toMatchObject({ anchored: 1, failed: false }); + expect(ledger.dashboardProof("01SALEA1")).toMatchObject({ + status: "confirmed", + txHash: "c".repeat(64), + }); + }); + + it("fails a stale pending batch from an interrupted run and re-anchors it in the same cycle", async () => { + const observed = sale("01SALEA1"); + const { ledger, publisher, anchors } = await harness({ sales: [observed] }); + await ledger.ingestSales([observed]); + await ledger.persistPendingBatch(["01SALEA1"]); + + const result = await publisher.runCycle(); + + expect(result).toMatchObject({ anchored: 1, failed: false }); + expect(anchors).toHaveLength(1); + expect(ledger.failedAttemptHistory()).toMatchObject([{ stage: "submit" }]); + expect(ledger.dashboardProof("01SALEA1")).toMatchObject({ status: "confirmed" }); + }); + + it("anchors window-expired unproved sales together in a trailing catch-up batch", async () => { + const expired = sale("01EXPIRED"); + const { ledger, publisher, anchors } = await harness({ + sales: [sale("01SALEB1", { transactionHash: "0x" + "b".repeat(64) })], + }); + await ledger.ingestSales([expired]); + + const result = await publisher.runCycle(); + + expect(result).toMatchObject({ anchored: 2 }); + expect(anchors.map((metadata) => metadata.first)).toEqual([ + "01SALEB1", + "01EXPIRED", + ]); + expect(ledger.dashboardProof("01EXPIRED")).toMatchObject({ status: "confirmed" }); + }); + + it("marks a confirmation timeout as a confirm-stage failure", async () => { + const { ledger, publisher } = await harness({ + sales: [sale("01SALEA1")], + awaitTx: async () => { + throw new Error("timed out"); + }, + }); + + const result = await publisher.runCycle(); + + expect(result).toMatchObject({ anchored: 0, failed: true }); + expect(ledger.failedAttemptHistory()).toMatchObject([{ stage: "confirm" }]); + expect(ledger.dashboardProof("01SALEA1")).toMatchObject({ status: "failed" }); + }); +});