feat: publish Vector proof anchors per Base settlement

This commit is contained in:
david
2026-08-28 12:12:54 +02:00
parent 0ed594f6ba
commit a6eabe6b98
5 changed files with 632 additions and 3 deletions

View File

@@ -160,3 +160,18 @@ SURPLUS_REQUEST_TIMEOUT_MS=120000
SURPLUS_STOP_AFTER_SETTLED_REQUESTS=0 SURPLUS_STOP_AFTER_SETTLED_REQUESTS=0
SURPLUS_SETTLED_STATUSES=confirmed SURPLUS_SETTLED_STATUSES=confirmed
SURPLUS_STATE_PATH=/var/lib/surplus-manager/state.json 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

View File

@@ -15,10 +15,20 @@ import {
SurplusDashboardService, SurplusDashboardService,
type SurplusDashboardSnapshot, type SurplusDashboardSnapshot,
} from "../surplus/dashboard.js"; } 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( export async function runSurplusSeller(
env: Record<string, string | undefined>, env: Record<string, string | undefined>,
): Promise<{ server: Server; controller: SurplusSellerController }> { ): Promise<{
server: Server;
controller: SurplusSellerController;
publisher: SurplusVectorProofPublisher | null;
}> {
const config = loadSurplusManagerConfig(env); const config = loadSurplusManagerConfig(env);
const client = new SurplusClient({ const client = new SurplusClient({
apiBaseUrl: config.apiBaseUrl, apiBaseUrl: config.apiBaseUrl,
@@ -38,11 +48,51 @@ export async function runSurplusSeller(
stateStore, stateStore,
log: (message) => console.log(message), 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({ const dashboard = new SurplusDashboardService({
controller, controller,
client, client,
allowance, allowance,
config, config,
proofs,
}); });
const server = createSurplusServer(controller, dashboard); const server = createSurplusServer(controller, dashboard);
await new Promise<void>((resolveListen, rejectListen) => { await new Promise<void>((resolveListen, rejectListen) => {
@@ -53,7 +103,8 @@ export async function runSurplusSeller(
}); });
}); });
await controller.start(); await controller.start();
return { server, controller }; publisher?.start();
return { server, controller, publisher };
} }
interface DashboardSource { interface DashboardSource {
@@ -115,13 +166,14 @@ async function handleRequest(
export async function main( export async function main(
env: Record<string, string | undefined> = process.env, env: Record<string, string | undefined> = process.env,
): Promise<void> { ): Promise<void> {
const { server, controller } = await runSurplusSeller(env); const { server, controller, publisher } = await runSurplusSeller(env);
console.log("Surplus seller controller listening"); console.log("Surplus seller controller listening");
let shuttingDown = false; let shuttingDown = false;
const shutdown = async (signal: string): Promise<void> => { const shutdown = async (signal: string): Promise<void> => {
if (shuttingDown) return; if (shuttingDown) return;
shuttingDown = true; shuttingDown = true;
console.log(`Surplus seller controller shutting down (${signal})`); console.log(`Surplus seller controller shutting down (${signal})`);
await publisher?.stop();
await controller.stop(); await controller.stop();
await new Promise<void>((resolveClose, rejectClose) => { await new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => error ? rejectClose(error) : resolveClose()); server.close((error) => error ? rejectClose(error) : resolveClose());

View File

@@ -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 EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
const POSITIVE_INT_RE = /^[1-9]\d*$/; const POSITIVE_INT_RE = /^[1-9]\d*$/;
const NON_NEGATIVE_INT_RE = /^(?:0|[1-9]\d*)$/; const NON_NEGATIVE_INT_RE = /^(?:0|[1-9]\d*)$/;
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
export interface SurplusManagerConfig { export interface SurplusManagerConfig {
live: boolean; live: boolean;
@@ -25,8 +26,18 @@ export interface SurplusManagerConfig {
settledStatuses: string[]; settledStatuses: string[];
statePath: string; statePath: string;
port: number; 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( export function loadSurplusManagerConfig(
env: Record<string, string | undefined>, env: Record<string, string | undefined>,
): SurplusManagerConfig { ): SurplusManagerConfig {
@@ -135,6 +146,62 @@ export function loadSurplusManagerConfig(
"SURPLUS_STATE_PATH", "SURPLUS_STATE_PATH",
), ),
port: boundedInteger(env.PORT ?? "8080", "PORT", 1, 65_535), port: boundedInteger(env.PORT ?? "8080", "PORT", 1, 65_535),
proof: loadProofConfig(env),
};
}
function loadProofConfig(
env: Record<string, string | undefined>,
): 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,
),
}; };
} }

View File

@@ -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<SurplusEarnings>;
anchor: (
metadata: SurplusProofAnchorMetadata,
) => Promise<{ expectedTxHash: string }>;
awaitTx: (txHash: string, timeoutMs: number) => Promise<void>;
balanceLovelace: () => Promise<bigint>;
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<SurplusPublisherCycleResult> | 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<void> {
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<void> {
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<SurplusPublisherCycleResult> {
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<SurplusSale[]> {
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<string, string>();
for (const sale of settled) {
if (sale.transactionHash !== null) {
txBySaleId.set(sale.id, sale.transactionHash);
}
}
const groups = new Map<string, CanonicalSurplusSale[]>();
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<boolean> {
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<boolean> {
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;
}

View File

@@ -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<FileSurplusVectorProofLedger> {
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> = {},
): 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" });
});
});