V2.1 #21

Merged
eric merged 76 commits from v2.1 into main 2026-11-01 22:07:57 +00:00
7 changed files with 245 additions and 72 deletions
Showing only changes of commit 4566271ce3 - Show all commits
+5 -4
View File
@@ -10,13 +10,13 @@ Status: implemented local infrastructure, **not full M2 acceptance or production
- On first open, original blocks are replayed and independently proof/state validated before additive local index backfill. Later opens compare indexed block identity, ownership, transaction inclusion and event continuity/anchors to that validated chain. Inconsistent state fails closed; existing consensus version, genesis, signatures and free-transfer/trade rules are unchanged.
- [Market reconciliation](../server/market/store.ts) uses indexed current delivery/ownership queries and a persisted `(stream ID, sequence, anchor)` checkpoint. Every observed disconnect of a previously completed order causes a durable review hold, even if delivery reappeared before polling. Review is never automatically repaid. Hold updates and checkpoint advancement commit together in the market database.
- Order and listing review now uses stable-ID keyset batches capped at 32 rows each. A strict, durable job record stores stage, continuation ID, observed time, chain tip/revision, and the bounded canonical event page. Each batch commits row effects and its continuation atomically; the canonical event cursor advances only after both tables finish. A chain tip/revision change during a multi-batch scan rewinds the job and captures a fresh event page, preventing a mixed-view scan from being marked caught up. Completion checks the current event head so events appended during the scan remain behind the write fence.
- While any job/event backlog exists, ordinary market mutations and time-dependent payout/expiry decisions stop; diagnostic/read access remains available. Requests advance one bounded slice, and an unref'd HTTP-lifecycle pump drains remaining work without depending on page loads. The production HTTP process enables a single persistent Node worker thread to select rows, evaluate order/listing transitions, and atomically persist row changes plus job progress. The main thread supplies only a bounded snapshot of the relevant canonical transaction/NFT/pending facts and retains event/revision fences. This moves the per-row JSON/status work off-loop, but fact collection and database-snapshot preparation still run on the main thread. Worker failure leaves the durable job/cursor intact, fails closed, and requires process restart to retry; there is no horizontal-worker safety claim.
- While any job/event backlog exists, ordinary market mutations and time-dependent payout/expiry decisions stop; diagnostic/read access remains available. Requests advance one bounded slice, and an unref'd HTTP-lifecycle pump drains remaining work without depending on page loads. The production HTTP process enables a single persistent Node worker thread to select rows, read canonical transaction/NFT/mempool facts from its own SQLite read-only Ledger connection under a consistent read transaction, evaluate order/listing transitions, and atomically persist market row changes plus job progress. The main thread retains event-page acquisition and post-batch tip/revision/event-head/mempool fencing; if observations changed, it rewinds before cursor advancement. This moves fact collection and per-row JSON/status work off-loop, but final fences still run on the request event loop. Worker failure leaves the durable job/cursor intact, fails closed, and requires process restart to retry; there is no horizontal-worker safety claim.
- Reconciliation restart, mid-job event append/reorg fencing, disconnect review, and injected SQLite batch/cursor failures are tested. A failed batch leaves both order effects and durable keyset progress unchanged; restart resumes committed work. Restore mismatch (different stream, different history anchor or a cursor ahead of the restored ledger) is refused rather than resetting the cursor. Restore the matching stopped-node ledger/market backup; any alternative recovery requires reviewed evidence, not deleting a checkpoint.
- [HTTP NFT lookup and custody card checks](../server/testnet/http.ts) use the same index. Wallet deck pagination accepts an optional `tip` query; stale tips return 409. Existing callers without `tip` remain compatible. There is no new public event/admin endpoint, no payment observer and no NFT-lock signer.
## Tests and measurements
The final Docker test-target build passed TypeScript/Vite compilation. All **176 offline tests passed, zero failures/skips**, including native RandomX multi-process HTTP/peer/restart and backup rehearsal. The focused market suite passed **25 tests**; the disposable PostgreSQL suite passed **31 tests** in the preceding integration run. New failure evidence includes:
The final Docker test-target build passed TypeScript/Vite compilation. All **177 offline tests passed, zero failures/skips**, including native RandomX multi-process HTTP/peer/restart and backup rehearsal. The focused market suite passed **26 tests**; the disposable PostgreSQL suite passed **31 tests** in the preceding integration run. New failure evidence includes:
- indexed ownership/sequence versus full replay for transfers, multi-card trades and forks;
- append/disconnect/reconnect ordering, side-branch/mempool exclusion, bounded defensive-copy pages and durable restart;
@@ -29,6 +29,7 @@ The final Docker test-target build passed TypeScript/Vite compilation. All **176
- injected batch-write failure rolling back order effects and keyset progress, plus cursor-write failure rolling back checkpoint advancement while preserving committed review holds;
- worker-owned row evaluation/batch/finalize transaction with injected failure, mutation fence and restart continuation;
- canonical event arrival during an in-flight worker batch forces a fresh bounded event page before the cursor reaches the new head;
- separate worker read-only Ledger connection supplies canonical NFT and delivery facts and re-fences when an event arrives during that snapshot;
- numeric cursor reuse with a different historical hash rejected;
- real HTTP NFT results and stale-tip deck pagination.
@@ -47,10 +48,10 @@ Observed in the development Docker environment:
These numbers are a reproducible **baseline**, not acceptance SLOs: host CPU/storage and production concurrency were not selected, outputs include synthetic article payloads, warm caches apply, one transaction amortizes insertion fsync, and real validation/startup/HTTP/market workloads are excluded. They do not establish a supported production envelope.
The synthetic market-journal harness [benchmark](../scripts/benchmark-market-reconciliation.ts), available as `npm run benchmark:market -- 10000 [terminal|confirmed-delivery] [worker]`, measures terminal and confirmed-delivery reconciliation in its own temporary SQLite directory. With synchronous 32-row batches, 10,000 terminal orders plus 10,000 listings took 2,549 ms (about 7,845 records/s; sampled event-loop-delay p95/max 14.97/48.99 ms). The confirmed-delivery path took 7,609 ms (about 2,628 records/s; 25.36/95.68 ms). With worker-owned selection/evaluation/commits, the terminal fixture took 5,893 ms (about 3,394 records/s; sampled event-loop-delay p95/max 12.12/24.05 ms), and confirmed delivery took 5,939 ms (about 3,367 records/s; 12.02/20.38 ms). Worker IPC reduces sampled event-loop delay but costs elapsed time on simple terminal rows; the confirmed-delivery workload is faster in the worker run. Main-thread row-key/fact collection remains, and timings vary. Synchronous pass max was 95.35 ms; worker dispatch pass max was 31.29 ms for confirmed delivery. Delivery facts are in-memory adapter fixtures, not validated Ledger evidence. These runs are not target-hardware capacity claims and exclude concurrent HTTP traffic, PostgreSQL, actual chain validation and adversarial workloads.
The synthetic market-journal harness [benchmark](../scripts/benchmark-market-reconciliation.ts), available as `npm run benchmark:market -- 10000 [terminal|confirmed-delivery] [worker]`, measures terminal and confirmed-delivery reconciliation in its own temporary SQLite directory. Both modes use the same Ledger-shaped fixture; worker mode reads it through a separate read-only SQLite connection and performs market row selection, canonical transaction/NFT fact reads, status evaluation and market commits on the worker. In the latest development Docker run, synchronous 32-row batches processed 10,000 terminal orders plus 10,000 listings in 4,100 ms (about 4,878 records/s; sampled event-loop-delay p95/max 18.43/21.95 ms), and confirmed delivery in 5,853 ms (about 3,417 records/s; 21.64/55.67 ms). Worker-owned selection/evaluation/fact reads/commits took 4,508 ms for terminal rows (about 4,437 records/s; delay p95/max 10.93/13.40 ms) and 4,088 ms for confirmed delivery (about 4,893 records/s; 10.80/12.48 ms). Thus worker execution reduced sampled delay, improved this delivery fixture, and slightly slowed terminal-only work. Timings vary; synchronous max pass was 47.30 ms for confirmed delivery, worker dispatch max 14.45 ms. The SQLite fixture is synthetic, not validated Ledger evidence. These are not capacity claims and exclude concurrent HTTP traffic, PostgreSQL, actual chain validation and adversarial workloads.
## Remaining gates
M2 still needs target-hardware/data/SLO decisions, populated long-journal/large-event benchmarks, HTTP load tests during worker activity, production financial database/schema and measured recovery/retention/storage growth. The worker removes most per-row evaluation from the event loop, but gathering row keys and chain facts remains on-loop; queue isolation, multi-process fencing and a demonstrated production throughput envelope remain open. Ledger PoW replay and DAG state memory still scale with retained history; indexed queries do not solve those costs. Startup validation checks undo-record counts but not every possible future undo mutation against a second reconstructed undo model; comprehensive corruption/fuzz coverage and independent review remain required.
M2 still needs target-hardware/data/SLO decisions, populated long-journal/large-event benchmarks, concurrent HTTP tests during worker activity, production financial database/schema and measured recovery/retention/storage growth. The worker removes row selection, canonical fact reads and per-row evaluation from the request event loop, but event-page acquisition and post-batch consistency fencing remain on-loop; queue isolation, multi-process fencing and a demonstrated production throughput envelope remain open. Ledger PoW replay and DAG state memory still scale with retained history; indexed queries do not solve those costs. Startup validation checks undo-record counts but not every possible future undo mutation against a second reconstructed undo model; comprehensive corruption/fuzz coverage and independent review remain required.
Custodial reservations still stop at `awaiting-lock`. This increment does **not** consume ordinary transfers as custodial settlement evidence: doing that would bypass the reviewed sale-lock/cancel protocol. Real Monero deposit/withdrawal integration, production treasury keys, host attestations and a reviewed versioned NFT lock/completion/cancellation specification remain blocked. No real funds, credentials, external processor or genesis change were used.
+2 -2
View File
@@ -63,7 +63,7 @@ Acceptance: exact-amount property/boundary tests, versioned signature tests, zer
Can run alongside P0/P1. Addresses SEC-01/SEC-06 rather than adding more fixed caps.
**Implemented M2 increments, 2026-10-20–21:** [canonical indexes and durable market observation](canonical-indexes.md). Canonical ownership/transaction SQL indexes now serve NFT, deck, custody availability and mock-market queries; per-block undo and anchored connect/disconnect events commit with ledger updates. Market checkpoint/reorg holds persist atomically and bounded catch-up blocks new mutations. Order/listing reconciliation advances in durable, stable-ID keyset batches of at most 32 records per table, resumes after restart, and re-fences when the chain tip/revision changes. The HTTP process now uses a single worker thread to select/evaluate each bounded row batch and atomically persist it with cursor progress, using a bounded chain-fact snapshot supplied by the main thread. Final TypeScript/Vite build and **176 offline tests passed**; focused market tests passed 25/25; the disposable PostgreSQL suite passed 31/31. Synthetic 10,000-order/listing confirmed-delivery runs took 5,939 ms with worker evaluation (event-loop-delay p95/max 12.02/20.38 ms) versus 7,609 ms synchronously (25.36/95.68 ms) on the development host. This is local synthetic evidence, not an SLO. Main-thread key/fact collection, concurrent HTTP load, multi-process fencing, target hardware/data, production financial storage/recovery, retained-DAG capacity and operator SLO decisions remain open.
**Implemented M2 increments, 2026-10-20–21:** [canonical indexes and durable market observation](canonical-indexes.md). Canonical ownership/transaction SQL indexes now serve NFT, deck, custody availability and mock-market queries; per-block undo and anchored connect/disconnect events commit with ledger updates. Market checkpoint/reorg holds persist atomically and bounded catch-up blocks new mutations. Order/listing reconciliation advances in durable, stable-ID keyset batches of at most 32 records per table, resumes after restart, and re-fences when the chain tip/revision changes. The HTTP process now uses a single worker thread to select/evaluate bounded row batches, read canonical facts using its own read-only Ledger connection, and atomically persist changes with cursor progress. Final TypeScript/Vite build and **177 offline tests passed**; focused market tests passed 26/26; the disposable PostgreSQL suite passed 31/31. In a same-fixture synthetic 10,000 confirmed-delivery comparison, worker processing took 4,088 ms with sampled event-loop-delay max 12.48 ms versus 5,853 ms and 55.67 ms synchronously on the development host. This is local evidence, not an SLO. Main-thread event-page acquisition/post-batch fencing, concurrent HTTP load, multi-process fencing, target hardware/data, production financial storage/recovery, retained-DAG capacity and operator SLO decisions remain open.
**Next financial-storage increment:** [ADR 003 — PostgreSQL](postgres-financial-storage.md) selects and integrates the custody SQL backend with existing HTTP callers, exact constrained accounting, explicit migration/runtime roles, source-freezing SQLite import and dump/restore rehearsal. The adapter remains on the fictitious sandbox domain; it is not a production deposit/withdrawal worker. SQLite node-only backup deliberately refuses PostgreSQL-bound financial state. Production hardware/SLO tests, coordinated backup/PITR/failover, real payment schema/worker controls and independent review remain open, so this does not close M2/M4 or authorize funds.
@@ -72,7 +72,7 @@ Can run alongside P0/P1. Addresses SEC-01/SEC-06 rather than adding more fixed c
**Deployment increment:** [persistent local custody stack](persistent-custody-sandbox.md) now starts PostgreSQL and the actual app together, with private TLS/SCRAM networking, separate owner/runtime roles, explicit migrations and scoped persistent credential/data volumes. A signed fictitious credit and custodian identity survived container/network removal and recreation; the server rejected plaintext authentication. Existing deployments and SQLite custody were not switched. Production certificate rotation, coordinated backup/restore and external operating review remain open; all XMR remains simulated.
- Indexed canonical ownership, NFT/transfer lookup and paged wallet/explorer APIs: browser signing must not require loading all history. Tip-pinned queries detect reorgs and preserve complete ownership semantics.
- Incremental market reconciliation from durable canonical/reorg events. **Local bounded implementation delivered:** rollback-safe canonical event pages, durable 32-row order/listing keyset jobs, tip/revision re-fencing, restart continuation, mutation fencing, and a single worker thread that selects/evaluates batches and atomically commits them. Regression tests cover reorgs, injected worker commit failures and restart recovery. **Still open:** move bounded key/fact collection off the request event loop, measure concurrent HTTP latency and queue lag, coordinate/fence processes, and establish throughput/overload/recovery limits at declared order/event volumes.
- Incremental market reconciliation from durable canonical/reorg events. **Local bounded implementation delivered:** rollback-safe canonical event pages, durable 32-row order/listing keyset jobs, tip/revision re-fencing, restart continuation, mutation fencing, and a single worker thread that selects/evaluates batches and reads its own read-only canonical SQLite snapshot before atomically committing results. Regression tests cover snapshot reads, reorg/event append, injected worker commit failures and restart recovery. **Still open:** move event-page acquisition and post-batch consistency checks off the request event loop, measure concurrent HTTP latency and queue lag, coordinate/fence processes, and establish throughput/overload/recovery limits at declared order/event volumes.
- A versioned production market/settlement schema with append-only financial events and balanced accounting entries for buyer liabilities, host revenue, author liabilities, seller liabilities, paid/refunded amounts and actual network fees. Derived views are rebuildable; financial history and idempotency keys are not discarded to reclaim space.
- Durable inbox/outbox, processor attempts and reconciliation states; no network operation inside a long DB transaction. Store intent before external side effects; unknown POST outcomes stay quarantined until evidenced resolution. Per-market isolation, actor-scoped paged reads, queue/backpressure and tenant/global exposure budgets.
- Database architecture ADR: retain a measured single-writer SQLite deployment if it meets declared limits, or choose a production DB/worker topology and supported migration. Do not claim horizontal safety merely by launching multiple workers against local state. Start with one authoritative writer per market.
+39 -9
View File
@@ -1,5 +1,5 @@
import { DatabaseSync } from 'node:sqlite';
import { mkdtemp, rm, stat } from 'node:fs/promises';
import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
@@ -18,20 +18,45 @@ if (!['terminal', 'confirmed-delivery'].includes(scenario)) throw new Error('Sce
const workerEnabled = process.argv[4] === 'worker';
const directory = await mkdtemp(join(tmpdir(), 'wikideck-market-reconciliation-'));
const streamId = randomUUID(), tip = 'a'.repeat(64);
const deliveryRows = new Map<string, { height: number; blockHash: string }>();
const ledgerFile = join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet', 'ledger.sqlite');
const seller = '1'.repeat(64), buyer = '2'.repeat(64);
let market: Market | undefined;
let ledgerDb: DatabaseSync | undefined;
try {
// Indexed-only adapter: the benchmark must not fall back to materializing chain snapshots.
await mkdir(join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet'), { recursive: true });
ledgerDb = new DatabaseSync(ledgerFile);
ledgerDb.exec(`PRAGMA journal_mode=WAL; CREATE TABLE blocks(hash TEXT PRIMARY KEY,block_json TEXT NOT NULL);
CREATE TABLE canonical_blocks(height INTEGER PRIMARY KEY,hash TEXT NOT NULL);
CREATE TABLE canonical_nfts(token TEXT PRIMARY KEY,owner TEXT NOT NULL,sequence INTEGER NOT NULL,minted_height INTEGER NOT NULL,block_hash TEXT NOT NULL);
CREATE TABLE canonical_transactions(id TEXT PRIMARY KEY,height INTEGER NOT NULL,block_hash TEXT NOT NULL);
CREATE TABLE canonical_events(sequence INTEGER PRIMARY KEY,type TEXT NOT NULL);
CREATE TABLE pending_transfers(position INTEGER PRIMARY KEY,id TEXT NOT NULL,transaction_json TEXT NOT NULL);`);
const insertBlock = ledgerDb.prepare('INSERT INTO blocks VALUES(?,?)');
const insertTip = ledgerDb.prepare('INSERT INTO canonical_blocks VALUES(?,?)');
const insertNft = ledgerDb.prepare('INSERT INTO canonical_nfts VALUES(?,?,?,?,?)');
const priorHash = 'b'.repeat(64);
for (const [height, hash] of [[1, priorHash], [2, tip]] as const) {
insertBlock.run(hash, JSON.stringify({ height, hash, tokenId: '', owner: seller, transactions: [] }));
insertTip.run(height, hash);
}
const chain = {
snapshot: () => { throw new Error('Benchmark must not request a full chain snapshot'); },
summary: () => ({ tip, revision: 0, height: 2 }),
nft: () => undefined,
canonicalTransaction: (id: string) => deliveryRows.get(id),
pendingTransactions: () => [],
nft: (token: string) => {
const row = ledgerDb!.prepare(`SELECT n.owner,n.sequence,n.minted_height,b.block_json FROM canonical_nfts n
JOIN blocks b ON b.hash=n.block_hash WHERE n.token=?`).get(token);
if (!row) return undefined;
return { block: JSON.parse(row.block_json), owner: row.owner, sequence: row.sequence,
confirmations: 2 - row.minted_height + 1 };
},
canonicalTransaction: (id: string) => ledgerDb!.prepare('SELECT height,block_hash AS blockHash FROM canonical_transactions WHERE id=?').get(id),
pendingTransactions: () => ledgerDb!.prepare('SELECT transaction_json FROM pending_transfers ORDER BY position').all()
.map(row => JSON.parse(row.transaction_json)),
canonicalHead: () => 0,
canonicalEvents: (after: number) => ({ streamId, previousAnchor: GENESIS_HASH, anchor: GENESIS_HASH,
events: [], cursor: after, head: 0, caughtUp: true }),
...(workerEnabled ? { reconciliationDatabasePath: () => ledgerFile } : {}),
submitTransfer: async () => { throw new Error('Benchmark never submits transfers'); },
};
market = await Market.open(directory, chain, { feeBps: 250, confirmations: 2 }, Date.now, { worker: workerEnabled });
@@ -39,6 +64,8 @@ try {
try {
const insertListing = db.prepare('INSERT INTO listings(id,card,status,data) VALUES(?,?,?,?)');
const insertOrder = db.prepare('INSERT INTO orders(id,data) VALUES(?,?)');
const insertNft = ledgerDb?.prepare('INSERT INTO canonical_nfts VALUES(?,?,?,?,?)');
const insertTransaction = ledgerDb?.prepare('INSERT INTO canonical_transactions VALUES(?,?,?)');
db.exec('BEGIN IMMEDIATE');
try {
for (let i = 0; i < count; i++) {
@@ -53,15 +80,16 @@ try {
if (!terminal) {
const delivery = { network: NETWORK, chainId: CHAIN_ID, cardHash, from: seller, to: buyer,
sequence: 0, publicKey: '', signature: '' };
deliveryRows.set(transactionId(delivery), { height: 1, blockHash: tip });
const id = transactionId(delivery);
insertTransaction?.run(id, 1, 'b'.repeat(64));
}
insertNft?.run(cardHash, terminal ? seller : buyer, terminal ? 0 : 1, 1, 'b'.repeat(64));
insertListing.run(listingId, cardHash, listingStatus, JSON.stringify(listing));
insertOrder.run(orderId, JSON.stringify(order));
}
db.exec('COMMIT');
} catch (error) { db.exec('ROLLBACK'); throw error; }
} finally { db.close(); }
const latencies: number[] = [];
const deadline = Date.now() + Math.max(30_000, count * 20);
const eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
@@ -83,6 +111,7 @@ try {
const elapsedMs = performance.now() - start;
if (!started || market.hasReconciliationBacklog() || market.isReconciliationWorkerBusy()) throw new Error('Reconciliation did not finish before benchmark deadline');
await market.close(); market = undefined;
ledgerDb.close(); ledgerDb = undefined;
const bytes = (await stat(join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet', 'market.sqlite'))).size;
latencies.sort((a, b) => a - b);
const percentile = (p: number) => latencies[Math.min(latencies.length - 1, Math.ceil(latencies.length * p) - 1)];
@@ -92,8 +121,9 @@ try {
passLatencyP95Ms: percentile(0.95), passLatencyMaxMs: latencies.at(-1), sqliteBytes: bytes,
eventLoopDelayResolutionMs: 10, eventLoopDelayP95Ms: eventLoopDelay.percentile(95) / 1e6,
eventLoopDelayMaxMs: eventLoopDelay.max / 1e6,
warning: 'Synthetic fixture on this development host only; canonical deliveries are adapter fixtures, not Ledger evidence. Excludes concurrent HTTP load, PostgreSQL, and production SLO certification.' }, null, 2));
warning: 'Synthetic fixture on this development host only; SQLite Ledger-shaped facts are not mined or validated chain evidence. Excludes concurrent HTTP load, PostgreSQL, and production SLO certification.' }, null, 2));
} finally {
market?.close();
if (market) await market.close();
ledgerDb?.close();
await rm(directory, { recursive: true, force: true });
}
+56 -4
View File
@@ -1,9 +1,12 @@
import { DatabaseSync } from 'node:sqlite';
import { parentPort, workerData } from 'node:worker_threads';
import { isDeepStrictEqual } from 'node:util';
import { createHash } from 'node:crypto';
const db = new DatabaseSync(workerData.marketFile);
db.exec('PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL;');
const ledger = workerData.ledgerFile ? new DatabaseSync(workerData.ledgerFile, { readOnly: true }) : null;
ledger?.exec('PRAGMA busy_timeout=5000; PRAGMA query_only=ON;');
const port = parentPort;
if (!port) throw new Error('Reconciliation commit worker requires a parent port');
@@ -23,6 +26,46 @@ function rows(stage, afterId, limit) {
return db.prepare(`SELECT id,data FROM ${table} WHERE (? IS NULL OR id>?) ORDER BY id LIMIT ?`)
.all(afterId, afterId, limit);
}
function readLedgerFacts(request, selected) {
if (!ledger) return request.fallbackFacts;
ledger.exec('BEGIN');
try {
const tip = ledger.prepare('SELECT hash FROM canonical_blocks ORDER BY height DESC LIMIT 1').get()?.hash ?? request.genesisHash;
const height = ledger.prepare('SELECT COALESCE(MAX(height),0) AS n FROM canonical_blocks').get().n;
const eventHead = ledger.prepare('SELECT COALESCE(MAX(sequence),0) AS n FROM canonical_events').get().n;
const pendingTransactions = ledger.prepare('SELECT id,transaction_json FROM pending_transfers ORDER BY position').all()
.map(row => {
const tx = JSON.parse(row.transaction_json);
const cardIds = tx.type === 'trade' ? [...tx.offered, ...tx.requested].map(leg => leg.cardHash) : [tx.cardHash];
return { id: row.id, cardIds };
});
const pendingIds = pendingTransactions.map(tx => tx.id);
const deliveryIds = Object.create(null), transactionIds = new Set(), cardIds = new Set();
for (const row of selected) {
const record = JSON.parse(row.data);
if (request.stage === 'orders') {
const id = createHash('sha256').update(JSON.stringify([request.network, 'nft-transfer', request.chainId,
record.cardHash, record.seller, record.buyer, record.sequence])).digest('hex');
deliveryIds[record.id] = id; transactionIds.add(id); cardIds.add(record.cardHash);
} else cardIds.add(record.cardHash);
}
const deliveryTransactions = Object.create(null);
const readTx = ledger.prepare('SELECT height,block_hash FROM canonical_transactions WHERE id=?');
for (const id of transactionIds) deliveryTransactions[id] = readTx.get(id) ?? null;
const nfts = Object.create(null);
const readNft = ledger.prepare(`SELECT n.owner,n.sequence,n.minted_height,b.block_json FROM canonical_nfts n
JOIN blocks b ON b.hash=n.block_hash WHERE n.token=?`);
for (const card of cardIds) {
const nft = readNft.get(card);
nfts[card] = nft ? { owner: nft.owner, sequence: nft.sequence, confirmations: height - nft.minted_height + 1 } : null;
}
ledger.exec('COMMIT');
return { height,
pendingTransactions, pendingIds, pendingCardIds: [...new Set(pendingTransactions.flatMap(tx => tx.cardIds))],
deliveryIds, deliveryTransactions, nfts,
snapshot: { tip, eventHead, pendingDigest: JSON.stringify([...pendingIds].sort()) } };
} catch (error) { ledger.exec('ROLLBACK'); throw error; }
}
function validCard(facts, cardHash, owner, sequence, maturity) {
const nft = facts.nfts[cardHash];
return !!nft && nft.owner === owner && nft.sequence === sequence && nft.confirmations >= maturity;
@@ -104,14 +147,23 @@ function saveResults(result) {
port.on('message', request => {
try {
let snapshot, nextJob;
if (request.operation === 'reconcile-batch') {
atomic(() => {
assertCurrentJob(request.expectedJob);
const selected = rows(request.stage, request.afterId, request.batchSize);
const result = request.stage === 'orders' ? processOrders(request, selected) : processListings(request, selected);
const facts = readLedgerFacts(request, selected);
snapshot = facts.snapshot;
const prepared = { ...request, ...facts };
const result = request.stage === 'orders' ? processOrders(prepared, selected) : processListings(prepared, selected);
saveResults(result);
nextJob = request.stage === 'orders'
? selected.length < request.batchSize ? { ...request.expectedJob, stage: 'listings', afterId: null }
: { ...request.expectedJob, afterId: selected.at(-1).id }
: selected.length < request.batchSize ? { ...request.expectedJob, stage: 'finalize', afterId: null }
: { ...request.expectedJob, afterId: selected.at(-1).id };
db.prepare("UPDATE market_meta SET value=? WHERE key='canonical_reconciliation_job'")
.run(JSON.stringify(request.nextJob));
.run(JSON.stringify(nextJob));
});
} else if (request.operation === 'finalize') {
atomic(() => {
@@ -124,9 +176,9 @@ port.on('message', request => {
db.prepare("DELETE FROM market_meta WHERE key='canonical_reconciliation_job'").run();
});
} else throw new Error('Unknown reconciliation worker operation');
port.postMessage({ id: request.id, ok: true });
port.postMessage({ id: request.id, ok: true, snapshot, nextJob });
} catch (error) {
port.postMessage({ id: request.id, ok: false, error: error instanceof Error ? error.message : 'Worker batch failed' });
}
});
port.on('close', () => db.close());
port.on('close', () => { ledger?.close(); db.close(); });
+51 -40
View File
@@ -10,7 +10,7 @@ import { ADDITIONAL_MARKET_FEE, marketCommandSchema, marketMessage, type MarketC
import { isXmrListing, isXmrOrder, marketCommandV3Schema, marketMessageV3,
type MarketActivityV3, type MarketCommandV3, type MarketListingRecord, type MarketListingV3,
type MarketOrderRecord, type MarketOrderV3 } from '../../shared/market-v3.ts';
import { CHAIN_ID, transactionId, transferSchema, verifySignature } from '../testnet/consensus.ts';
import { CHAIN_ID, GENESIS_HASH, transactionId, transferSchema, verifySignature } from '../testnet/consensus.ts';
import { mockQuote, mockXmrQuote, paymentMode } from './payments.ts';
import { RpcError } from '../resources.ts';
import type { CanonicalEvents, IndexedNft } from '../testnet/canonical-index.ts';
@@ -21,7 +21,7 @@ type Chain = { snapshot(): TestnetSnapshot; summary?(): { tip: string; revision:
submitTransfer(raw: unknown): Promise<string>; nft?(token: string): IndexedNft | undefined;
canonicalTransaction?(id: string): { height: number; blockHash: string } | undefined;
canonicalHead?(): number; canonicalEvents?(after: number, limit?: number): CanonicalEvents;
pendingTransactions?(): TestnetSnapshot['pendingTransfers'] };
pendingTransactions?(): TestnetSnapshot['pendingTransfers']; reconciliationDatabasePath?(): string };
type ChainView = { height: number; pendingTransfers: TestnetSnapshot['pendingTransfers'];
nft(token: string): IndexedNft | undefined; transaction(id: string): { height: number; blockHash: string } | undefined };
type Config = { feeBps: number; confirmations: number } & MarketPolicy;
@@ -67,8 +67,9 @@ export class Market {
private eventBacklog = false;
private reconciliationJob: z.infer<typeof reconciliationJobSchema> | undefined;
private readonly reconciliationWorker?: Worker;
private readonly workerReadsLedger: boolean;
private workerRequestId = 0;
private readonly workerRequests = new Map<number, { resolve(): void; reject(error: Error): void }>();
private readonly workerRequests = new Map<number, { resolve(message: Record<string, unknown>): void; reject(error: Error): void }>();
private reconciliationWorkerBusy = false;
private reconciliationWorkerError: string | undefined;
private readonly db: DatabaseSync;
@@ -79,11 +80,12 @@ export class Market {
private constructor(db: DatabaseSync, chain: Chain, config: Config, id: string, now: () => number, worker?: Worker) {
this.db = db; this.chain = chain; this.config = config; this.id = id; this.now = now;
this.reconciliationWorker = worker;
this.workerReadsLedger = !!chain.reconciliationDatabasePath;
worker?.on('message', (message: { id: number; ok: boolean; error?: string }) => {
const pending = this.workerRequests.get(message.id);
if (!pending) return;
this.workerRequests.delete(message.id);
if (message.ok) pending.resolve(); else pending.reject(new Error(message.error ?? 'Reconciliation worker failed'));
if (message.ok) pending.resolve(message); else pending.reject(new Error(message.error ?? 'Reconciliation worker failed'));
});
worker?.on('error', error => {
this.reconciliationWorkerError = 'Reconciliation worker failed';
@@ -139,7 +141,7 @@ export class Market {
}
if (process.platform !== 'win32') await chmod(file, 0o600);
worker = reconciliation.worker ? new Worker(new URL('./reconciliation-commit-worker.js', import.meta.url),
{ workerData: { marketFile: file }, execArgv: [] }) : undefined;
{ workerData: { marketFile: file, ledgerFile: chain.reconciliationDatabasePath?.() ?? null }, execArgv: [] }) : undefined;
worker?.unref();
return new Market(db, chain, config, id, now, worker);
} catch (error) { if (worker) await worker.terminate(); db.close(); throw error; }
@@ -215,7 +217,7 @@ export class Market {
return transactionId({ network: NETWORK, chainId: CHAIN_ID, cardHash: order.cardHash,
from: order.seller, to: order.buyer, sequence: order.sequence, publicKey: '', signature: '' });
}
private workerRequest(request: Record<string, unknown>): Promise<void> {
private workerRequest(request: Record<string, unknown>): Promise<Record<string, unknown>> {
const worker = this.reconciliationWorker;
if (!worker) return Promise.reject(new Error('Reconciliation worker is unavailable'));
const id = ++this.workerRequestId;
@@ -272,43 +274,35 @@ export class Market {
if (!this.reconciliationWorker || this.reconciliationWorkerBusy || job.stage === 'finalize') return;
this.reconciliationWorkerBusy = true;
try {
const rows = this.nextReconciliationRows(job.stage, job.afterId);
const s = this.view();
const pendingTransactions = s.pendingTransfers.map(tx => ({ id: transactionId(tx), cardIds: transactionCardIds(tx) }));
const pendingIds = pendingTransactions.map(tx => tx.id);
const pendingCardIds = [...new Set(pendingTransactions.flatMap(tx => tx.cardIds))];
const deliveryIds: Record<string, string> = Object.create(null);
const targetCards = new Set<string>();
if (job.stage === 'orders') {
for (const row of rows) {
let fallbackFacts: Record<string, unknown> | null = null;
if (!this.workerReadsLedger) {
const rows = this.nextReconciliationRows(job.stage, job.afterId), s = this.view();
const pendingTransactions = s.pendingTransfers.map(tx => ({ id: transactionId(tx), cardIds: transactionCardIds(tx) }));
const pendingIds = pendingTransactions.map(tx => tx.id);
const pendingCardIds = [...new Set(pendingTransactions.flatMap(tx => tx.cardIds))];
const deliveryIds: Record<string, string> = Object.create(null), targetCards = new Set<string>();
if (job.stage === 'orders') for (const row of rows) {
const order = JSON.parse(row.data) as MarketOrderRecord;
deliveryIds[order.id] = this.deliveryId(order);
targetCards.add(order.cardHash);
deliveryIds[order.id] = this.deliveryId(order); targetCards.add(order.cardHash);
} else for (const row of rows) targetCards.add((JSON.parse(row.data) as MarketListingRecord).cardHash);
const deliveryTransactions: Record<string, { height: number; blockHash: string } | null> = Object.create(null);
const nfts: Record<string, { owner: string; sequence: number; confirmations: number } | null> = Object.create(null);
if (job.stage === 'orders') for (const id of Object.values(deliveryIds)) deliveryTransactions[id] = s.transaction(id) ?? null;
for (const card of targetCards) {
const nft = s.nft(card); nfts[card] = nft ? { owner: nft.owner, sequence: nft.sequence, confirmations: nft.confirmations } : null;
}
} else {
for (const row of rows) targetCards.add((JSON.parse(row.data) as MarketListingRecord).cardHash);
fallbackFacts = { height: s.height, pendingTransactions, pendingIds, pendingCardIds, deliveryIds, deliveryTransactions, nfts };
}
const deliveryTransactions: Record<string, { height: number; blockHash: string } | null> = Object.create(null);
const nfts: Record<string, { owner: string; sequence: number; confirmations: number } | null> = Object.create(null);
if (job.stage === 'orders') for (const id of Object.values(deliveryIds)) deliveryTransactions[id] = s.transaction(id) ?? null;
for (const card of targetCards) {
const nft = s.nft(card);
nfts[card] = nft ? { owner: nft.owner, sequence: nft.sequence, confirmations: nft.confirmations } : null;
}
const disconnectedIds = job.eventPage?.events.filter(event => event.type === 'disconnect')
.flatMap(event => event.transactionIds) ?? [];
const nextJob = job.stage === 'orders'
? rows.length < MARKET_RECONCILIATION_BATCH_SIZE ? { ...job, stage: 'listings' as const, afterId: null }
: { ...job, afterId: rows.at(-1)!.id }
: rows.length < MARKET_RECONCILIATION_BATCH_SIZE ? { ...job, stage: 'finalize' as const, afterId: null }
: { ...job, afterId: rows.at(-1)!.id };
void this.workerRequest({ operation: 'reconcile-batch', expectedJob: job, nextJob, stage: job.stage,
afterId: job.afterId, batchSize: MARKET_RECONCILIATION_BATCH_SIZE, deliveryIds,
deliveryTransactions, nfts, pendingIds, pendingCardIds, disconnectedIds, height: s.height,
pendingTransactions,
maturity: CONSENSUS.coinbaseMaturity, additionalFee: ADDITIONAL_MARKET_FEE,
paymentWindow: PAYMENT_WINDOW })
.then(() => { this.reconciliationJob = nextJob; this.reconciliationWorkerError = undefined; this.eventBacklog = true; })
const disconnectedIds = job.eventPage?.events.filter(event => event.type === 'disconnect').flatMap(event => event.transactionIds) ?? [];
void this.workerRequest({ operation: 'reconcile-batch', expectedJob: job, stage: job.stage,
afterId: job.afterId, batchSize: MARKET_RECONCILIATION_BATCH_SIZE, fallbackFacts,
genesisHash: GENESIS_HASH, network: NETWORK, chainId: CHAIN_ID, disconnectedIds,
maturity: CONSENSUS.coinbaseMaturity, additionalFee: ADDITIONAL_MARKET_FEE, paymentWindow: PAYMENT_WINDOW })
.then(message => {
const nextJob = reconciliationJobSchema.parse(message.nextJob);
this.reconciliationJob = nextJob; this.reconciliationWorkerError = undefined; this.eventBacklog = true;
if (this.workerReadsLedger) this.refenceWorkerSnapshot(job, message.snapshot as { tip: string; eventHead: number; pendingDigest: string });
})
.catch(error => { this.reconciliationWorkerError = error instanceof Error ? error.message : 'Worker reconciliation failed'; this.eventBacklog = true; })
.finally(() => {
this.reconciliationWorkerBusy = false;
@@ -320,6 +314,23 @@ export class Market {
this.eventBacklog = true;
}
}
private refenceWorkerSnapshot(job: z.infer<typeof reconciliationJobSchema>, snapshot: { tip: string; eventHead: number; pendingDigest: string }): void {
const summary = this.chain.summary?.();
if (!summary) return;
const pendingDigest = this.chain.pendingTransactions
? JSON.stringify(this.chain.pendingTransactions().map(transactionId).sort()) : '';
const pageHead = job.eventPage?.head ?? snapshot.eventHead;
if (summary.tip === snapshot.tip && (job.revision === null || summary.revision === job.revision) && snapshot.eventHead <= pageHead &&
pendingDigest === snapshot.pendingDigest) return;
const events = this.chain.canonicalEvents?.(this.eventCursor, 64);
if (events && this.eventCheckpoint && (events.streamId !== this.eventCheckpoint.streamId ||
events.previousAnchor !== this.eventCheckpoint.anchor)) throw new RpcError(409, 'Canonical event history changed; restore matching ledger and market backups.');
const restarted = reconciliationJobSchema.parse({ version: 1, stage: 'orders', afterId: null, observedAt: this.now(),
tip: summary.tip, revision: summary.revision, eventPage: events ?? null });
this.atomic(() => this.db.prepare('UPDATE market_meta SET value=? WHERE key=?')
.run(JSON.stringify(restarted), RECONCILIATION_JOB_KEY));
this.reconciliationJob = restarted; this.eventBacklog = true;
}
/** Reconcile one durable keyset batch from canonical transaction identity, never mempool acknowledgement. */
private reconcile() {
if (this.reconciliationWorkerBusy || this.reconciliationWorkerError) return;
+2
View File
@@ -257,6 +257,8 @@ export class Ledger {
canonicalHead(): number { return this.index.head(); }
nft(token: string) { return this.index.nft(token, this.height()); }
canonicalTransaction(id: string) { return this.index.transaction(id); }
/** Local read-only observer path for the market worker; never exposed through an HTTP response. */
reconciliationDatabasePath(): string { return this.file; }
legacySnapshot(maxBlocks: number): TestnetSnapshot {
// Reject before any full-chain copy/serialization. Never truncate ownership history silently.
if (this.height() > maxBlocks) throw new RpcError(413, 'Full snapshot disabled at this height; use /api/summary and /api/canonical pages.');
+90 -13
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { generateKeyPairSync, randomUUID, sign } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { DatabaseSync } from 'node:sqlite';
@@ -37,15 +37,44 @@ function chainFixture() {
return id;
} };
}
async function fixture(t: { after(fn: () => Promise<void>): void }, indexed = false, useWorker = false) {
async function fixture(t: { after(fn: () => Promise<void>): void }, indexed = false, useWorker = false, workerLedger = false) {
const dir = await mkdtemp(join(tmpdir(), 'wikideck-market-'));
const chain = chainFixture();
// Test-only indexed adapter: production indexes/events are exercised separately by Ledger tests.
const events: CanonicalEvent[] = [];
const fEvents: CanonicalEvent[] = [];
const streamId = randomUUID();
const ledgerFile = join(dir, 'testnet', 'ledger.sqlite');
if (workerLedger) {
await mkdir(join(dir, 'testnet'), { recursive: true });
const ledgerDb = new DatabaseSync(ledgerFile);
ledgerDb.exec(`PRAGMA journal_mode=WAL; CREATE TABLE blocks(hash TEXT PRIMARY KEY,block_json TEXT NOT NULL);
CREATE TABLE canonical_blocks(height INTEGER PRIMARY KEY,hash TEXT NOT NULL);
CREATE TABLE canonical_nfts(token TEXT PRIMARY KEY,owner TEXT NOT NULL,sequence INTEGER NOT NULL,minted_height INTEGER NOT NULL,block_hash TEXT NOT NULL);
CREATE TABLE canonical_transactions(id TEXT PRIMARY KEY,height INTEGER NOT NULL,block_hash TEXT NOT NULL);
CREATE TABLE canonical_events(sequence INTEGER PRIMARY KEY,type TEXT NOT NULL);
CREATE TABLE pending_transfers(position INTEGER PRIMARY KEY,id TEXT NOT NULL,transaction_json TEXT NOT NULL);`);
const insertBlock = ledgerDb.prepare('INSERT INTO blocks VALUES(?,?)');
const insertCanonicalBlock = ledgerDb.prepare('INSERT INTO canonical_blocks VALUES(?,?)');
const insertNft = ledgerDb.prepare('INSERT INTO canonical_nfts VALUES(?,?,?,?,?)');
chain.state.blocks.forEach((block, i) => {
const hash = i === chain.state.blocks.length - 1 ? chain.state.tip : (i + 1).toString(16).padStart(64, '0');
insertBlock.run(hash, JSON.stringify(block)); insertCanonicalBlock.run(block.height, hash);
insertNft.run(block.tokenId, alice.actor, 0, block.height, hash);
});
ledgerDb.close();
}
const syncWorkerEvents = () => {
if (!workerLedger) return;
const ledgerDb = new DatabaseSync(ledgerFile);
try {
ledgerDb.exec('BEGIN IMMEDIATE'); ledgerDb.exec('DELETE FROM canonical_events');
const insert = ledgerDb.prepare('INSERT INTO canonical_events(sequence,type) VALUES(?,?)');
fEvents.forEach(event => insert.run(event.sequence, event.type)); ledgerDb.exec('COMMIT');
} finally { ledgerDb.close(); }
};
const adapter = indexed ? { ...chain,
snapshot: (): TestnetSnapshot => { throw new Error('Indexed market must not request a full chain snapshot'); },
summary: () => ({ tip: chain.state.tip, revision: events.length, height: chain.state.blocks.length }),
summary: () => ({ tip: chain.state.tip, revision: fEvents.length, height: chain.state.blocks.length }),
pendingTransactions: () => structuredClone(chain.state.pendingTransfers),
nft: (token: string) => {
const b = chain.state.blocks.find(b => b.tokenId === token);
@@ -57,14 +86,15 @@ async function fixture(t: { after(fn: () => Promise<void>): void }, indexed = fa
const b = chain.state.blocks.find(b => b.transactions.some(tx => transferId(tx as NftTransfer) === id));
return b ? { height: b.height, blockHash: b.hash ?? 'f'.repeat(64) } : undefined;
},
canonicalHead: () => events.length,
canonicalHead: () => fEvents.length,
canonicalEvents: (after: number, limit = 64) => {
if (after > events.length) throw new Error('Canonical cursor ahead of restored ledger');
const page = events.slice(after, after + limit), cursor = page.at(-1)?.sequence ?? after;
return { streamId, previousAnchor: after ? events[after - 1].eventHash : GENESIS_HASH,
anchor: page.at(-1)?.eventHash ?? (after ? events[after - 1].eventHash : GENESIS_HASH),
events: page, cursor, head: events.length, caughtUp: cursor === events.length };
if (after > fEvents.length) throw new Error('Canonical cursor ahead of restored ledger');
const page = fEvents.slice(after, after + limit), cursor = page.at(-1)?.sequence ?? after;
return { streamId, previousAnchor: after ? fEvents[after - 1].eventHash : GENESIS_HASH,
anchor: page.at(-1)?.eventHash ?? (after ? fEvents[after - 1].eventHash : GENESIS_HASH),
events: page, cursor, head: fEvents.length, caughtUp: cursor === fEvents.length };
},
...(workerLedger ? { reconciliationDatabasePath: () => ledgerFile } : {}),
} : chain;
let time = 1_800_000_000_000;
let market = await Market.open(dir, adapter, { feeBps: 250, confirmations: 2 }, () => time, { worker: useWorker });
@@ -104,19 +134,20 @@ async function fixture(t: { after(fn: () => Promise<void>): void }, indexed = fa
chain.state.blocks.push({ ...chain.state.blocks[0], height, transactions: [tx] });
chain.state.transfers.push(tx); chain.state.pendingTransfers = [];
};
return { dir, chain, events, envelope, envelopeV3, run, list, buy, orders, delivery, confirm,
const events = fEvents;
return { dir, chain, events, syncWorkerEvents, ledgerFile, envelope, envelopeV3, run, list, buy, orders, delivery, confirm,
get market() { return market; }, get now() { return time; }, advance: (ms: number) => { time += ms; },
restart: async (policy: MarketPolicy = {}) => { await market.close(); market = await Market.open(dir, adapter,
{ feeBps: 500, confirmations: 5, ...policy }, () => time, { worker: useWorker }); } };
}
async function drainWorkerReconciliation(f: Awaited<ReturnType<typeof fixture>>, maxPasses = 100): Promise<void> {
async function drainWorkerReconciliation(f: Awaited<ReturnType<typeof fixture>>, maxPasses = 2000): Promise<void> {
for (let pass = 0; pass < maxPasses; pass++) {
f.market.browse();
if (!f.market.hasReconciliationBacklog() && !f.market.isReconciliationWorkerBusy()) return;
await new Promise(resolve => setTimeout(resolve, 1));
}
assert.fail('Worker reconciliation did not drain within the bounded test wait');
assert.fail(`Worker reconciliation did not drain within the bounded test wait: ${JSON.stringify(f.market.diagnostics())}`);
}
test('mock market switches block new listings/bids/checkout while allowing paid-order delivery and private activity', async t => {
const f = await fixture(t), l = await f.list(), o = await f.buy(l);
@@ -341,6 +372,52 @@ test('worker reconciliation re-fences a canonical event appended during an in-fl
assert.equal(f.market.hasReconciliationBacklog(), false);
});
test('worker reads canonical facts from a separate read-only Ledger snapshot and re-fences event appends', async t => {
const f = await fixture(t, true, true, true), db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
const soldCard = 'a'.repeat(64), deliveryId = transferId({ network: NETWORK, chainId: CHAIN_ID, cardHash: soldCard,
from: alice.actor, to: bob.actor, sequence: 0, publicKey: '', signature: '' });
try {
const insertListing = db.prepare('INSERT INTO listings VALUES(?,?,?,?)');
const insertOrder = db.prepare('INSERT INTO orders VALUES(?,?)');
for (let i = 0; i < 70; i++) {
const id = `40000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`;
const listingId = `50000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`;
const active = i === 0, cardHash = active ? soldCard : (i + 1).toString(16).padStart(64, '0');
const listingStatus = active ? 'reserved' : 'cancelled';
insertListing.run(listingId, cardHash, listingStatus, JSON.stringify({ id: listingId, cardHash, seller: alice.actor,
sequence: 0, kind: 'fixed', status: listingStatus }));
insertOrder.run(id, JSON.stringify({ id, listingId, seller: alice.actor, buyer: bob.actor, cardHash,
sequence: 0, status: active ? 'paid' : 'refunded', payout: active ? 'not-started' : undefined,
createdAt: f.now, payBy: f.now + 1000, deliverBy: f.now + 2000,
requiredConfirmations: 2, deliveryConfirmations: 0 }));
}
} finally { db.close(); }
const ledgerDb = new DatabaseSync(f.ledgerFile);
try {
ledgerDb.prepare('UPDATE canonical_nfts SET owner=?,sequence=1 WHERE token=?').run(bob.actor, soldCard);
ledgerDb.prepare('INSERT INTO canonical_transactions VALUES(?,?,?)').run(deliveryId, 1, 'b'.repeat(64));
} finally { ledgerDb.close(); }
f.events.push({ sequence: 1, type: 'connect', height: 1, hash: 'd'.repeat(64), previousHash: GENESIS_HASH,
transactionIds: [deliveryId], eventHash: '1'.repeat(64) });
f.syncWorkerEvents();
f.market.browse();
assert.equal(f.market.isReconciliationWorkerBusy(), true);
f.events.push({ sequence: 2, type: 'connect', height: 2, hash: 'e'.repeat(64), previousHash: 'd'.repeat(64),
transactionIds: [], eventHash: '2'.repeat(64) });
f.syncWorkerEvents();
await drainWorkerReconciliation(f, 100);
assert.equal(f.market.diagnostics().canonicalEventCursor, 2);
assert.equal(f.market.hasReconciliationBacklog(), false);
const orders = await f.orders();
const completed = orders.find(order => order.cardHash === soldCard);
assert.ok(completed);
assert.equal(completed.status, 'complete'); assert.equal(completed.payout, 'mock-paid');
const verifyDb = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
try {
assert.equal((verifyDb.prepare('SELECT status FROM listings WHERE id=?').get(completed.listingId) as { status: string }).status, 'sold');
} finally { verifyDb.close(); }
});
test('USD and mock XMR accounting use bounded integer amounts and exact fee splitting', () => {
assert.equal(parseUsd('15.01'), 1501); assert.equal(parseUsd('0.01'), 1);
for (const invalid of ['0', '-1', '1e3', '1.001', ' 1', 'NaN', '1000001', '01.00']) assert.throws(() => parseUsd(invalid));