Disposable canonical-event inbox rehearsal
This commit is contained in:
Executable
+128
@@ -0,0 +1,128 @@
|
||||
import type { PoolClient } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { GENESIS_HASH, sha256 } from '../testnet/consensus.ts';
|
||||
|
||||
const hex = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const MAX_REHEARSAL_EVENTS = 4096;
|
||||
const eventSchema = z.object({ sequence: z.number().int().positive().safe(), type: z.enum(['connect', 'disconnect']),
|
||||
height: z.number().int().positive().safe(), hash: hex, previousHash: hex,
|
||||
transactionIds: z.array(hex).max(64), eventHash: hex }).strict();
|
||||
const pageSchema = z.object({ streamId: z.string().uuid(), previousAnchor: hex, anchor: hex,
|
||||
events: z.array(eventSchema).max(64), cursor: z.number().int().nonnegative().safe(),
|
||||
head: z.number().int().nonnegative().safe(), caughtUp: z.boolean() }).strict();
|
||||
export type CanonicalInboxPage = z.infer<typeof pageSchema>;
|
||||
export type CanonicalInboxResult = { streamId: string; cursor: number; head: number; height: number; tip: string; replayed: boolean };
|
||||
|
||||
export class CanonicalInboxError extends Error {
|
||||
readonly code: string;
|
||||
constructor(code: string) { super(`Canonical event inbox rejected input: ${code}`); this.code = code; }
|
||||
}
|
||||
const streamKey = 'canonical-inbox:v1:stream';
|
||||
const eventKey = (sequence: number) => `canonical-inbox:v1:event:${sequence.toString().padStart(16, '0')}`;
|
||||
function storedEvent(text: string): z.infer<typeof eventSchema> {
|
||||
let decoded: unknown;
|
||||
try { decoded = JSON.parse(text); } catch { throw new CanonicalInboxError('stored-event-json-invalid'); }
|
||||
const parsed = eventSchema.safeParse(decoded);
|
||||
if (!parsed.success) throw new CanonicalInboxError('stored-event-invalid');
|
||||
return parsed.data;
|
||||
}
|
||||
async function anchorAt(client: PoolClient, sequence: number): Promise<string | undefined> {
|
||||
if (sequence === 0) return GENESIS_HASH;
|
||||
const row = await client.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1', [eventKey(sequence)]);
|
||||
return row.rows[0] ? storedEvent(row.rows[0].value).eventHash : undefined;
|
||||
}
|
||||
function eventDigest(streamId: string, e: z.infer<typeof eventSchema>, previousAnchor: string): string {
|
||||
return sha256(JSON.stringify(['wikideck-canonical-event-v1', streamId, e.sequence, e.type,
|
||||
e.height, e.hash, e.previousHash, e.transactionIds, previousAnchor]));
|
||||
}
|
||||
function applyTip(stack: string[], e: z.infer<typeof eventSchema>): void {
|
||||
if (e.type === 'connect') {
|
||||
if (e.height !== stack.length + 1 || e.previousHash !== (stack.at(-1) ?? GENESIS_HASH)) throw new CanonicalInboxError('connect-parent-mismatch');
|
||||
stack.push(e.hash); return;
|
||||
}
|
||||
if (e.height !== stack.length || e.hash !== stack.at(-1) || e.height < 1 ||
|
||||
e.previousHash !== (stack.at(-2) ?? GENESIS_HASH)) throw new CanonicalInboxError('disconnect-ancestry-mismatch');
|
||||
stack.pop();
|
||||
}
|
||||
async function readCanonicalHistory(client: PoolClient) {
|
||||
const rows = await client.query(`SELECT key,value FROM wk_custody.custody_meta
|
||||
WHERE key=$1 OR key LIKE 'canonical-inbox:v1:event:%' ORDER BY key LIMIT $2`, [streamKey, MAX_REHEARSAL_EVENTS + 2]);
|
||||
if (rows.rows.length > MAX_REHEARSAL_EVENTS + 1) throw new CanonicalInboxError('rehearsal-event-budget-exceeded');
|
||||
const stream = rows.rows.find(r => r.key === streamKey)?.value as string | undefined;
|
||||
if (rows.rows.length && (!stream || !z.string().uuid().safeParse(stream).success)) throw new CanonicalInboxError('stream-record-invalid');
|
||||
const stack: string[] = [];
|
||||
let cursor = 0, anchor = GENESIS_HASH;
|
||||
for (const row of rows.rows) {
|
||||
if (row.key === streamKey) continue;
|
||||
const match = /^canonical-inbox:v1:event:(\d{16})$/.exec(row.key);
|
||||
const e = storedEvent(row.value);
|
||||
if (!match || Number(match[1]) !== cursor + 1 || e.sequence !== cursor + 1) throw new CanonicalInboxError('history-gap-or-invalid-record');
|
||||
if (e.eventHash !== eventDigest(stream!, e, anchor)) throw new CanonicalInboxError('event-anchor-invalid');
|
||||
applyTip(stack, e); cursor++; anchor = e.eventHash;
|
||||
}
|
||||
return { streamId: stream, cursor, anchor, stack };
|
||||
}
|
||||
function assertPageShape(page: CanonicalInboxPage): void {
|
||||
const finalCursor = page.events.at(-1)?.sequence ?? page.cursor;
|
||||
if ((page.events.length && page.events[0].sequence < 1) || page.cursor !== finalCursor ||
|
||||
page.head < page.cursor || page.caughtUp !== (page.cursor === page.head) ||
|
||||
(!page.events.length && !page.caughtUp) ||
|
||||
page.anchor !== (page.events.at(-1)?.eventHash ?? page.previousAnchor) || page.events.some((e, i) =>
|
||||
i > 0 && e.sequence !== page.events[i - 1].sequence + 1)) throw new CanonicalInboxError('page-shape-invalid');
|
||||
}
|
||||
|
||||
/** Persist only events returned by the local ledger's validated canonical-event API. This verifies
|
||||
* continuity/integrity, not PoW or provenance; callers must not pass peer-supplied, unvalidated data.
|
||||
*/
|
||||
export async function ingestCanonicalPage(client: PoolClient, raw: unknown): Promise<CanonicalInboxResult> {
|
||||
const parsed = pageSchema.safeParse(raw);
|
||||
if (!parsed.success) throw new CanonicalInboxError('page-schema-invalid');
|
||||
const page = parsed.data;
|
||||
assertPageShape(page);
|
||||
if (page.head > MAX_REHEARSAL_EVENTS || page.cursor > MAX_REHEARSAL_EVENTS) throw new CanonicalInboxError('rehearsal-event-budget-exceeded');
|
||||
const history = await readCanonicalHistory(client);
|
||||
const storedStream = history.streamId;
|
||||
if (storedStream && storedStream !== page.streamId) throw new CanonicalInboxError('stream-changed');
|
||||
let { cursor, anchor } = history;
|
||||
const stack = history.stack;
|
||||
if (!storedStream) {
|
||||
if (cursor !== 0 || page.previousAnchor !== GENESIS_HASH ||
|
||||
(page.events.length ? page.events[0].sequence !== 1 : page.cursor !== 0)) {
|
||||
throw new CanonicalInboxError('initial-history-required');
|
||||
}
|
||||
await client.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)', [streamKey, page.streamId]);
|
||||
}
|
||||
if (!page.events.length) {
|
||||
const savedAnchor = await anchorAt(client, page.cursor);
|
||||
if (page.cursor > cursor || savedAnchor !== page.anchor || page.previousAnchor !== page.anchor) throw new CanonicalInboxError('empty-cursor-mismatch');
|
||||
return { streamId: page.streamId, cursor, head: Math.max(page.head, cursor), height: stack.length,
|
||||
tip: stack.at(-1) ?? GENESIS_HASH, replayed: true };
|
||||
}
|
||||
const firstSequence = page.events[0].sequence;
|
||||
if (firstSequence > cursor + 1) throw new CanonicalInboxError('cursor-gap');
|
||||
const predecessor = await anchorAt(client, firstSequence - 1);
|
||||
if (!predecessor || page.previousAnchor !== predecessor) throw new CanonicalInboxError('cursor-anchor-mismatch');
|
||||
let priorAnchor = predecessor;
|
||||
let appended = false;
|
||||
for (const e of page.events) {
|
||||
if (e.eventHash !== eventDigest(page.streamId, e, priorAnchor)) throw new CanonicalInboxError('event-hash-invalid');
|
||||
if (e.sequence <= cursor) {
|
||||
const saved = await client.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1', [eventKey(e.sequence)]);
|
||||
if (!saved.rowCount || JSON.stringify(storedEvent(saved.rows[0].value)) !== JSON.stringify(e)) throw new CanonicalInboxError('replayed-event-mismatch');
|
||||
} else {
|
||||
if (e.sequence !== cursor + 1 || priorAnchor !== anchor) throw new CanonicalInboxError('cursor-anchor-mismatch');
|
||||
applyTip(stack, e);
|
||||
await client.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)', [eventKey(e.sequence), JSON.stringify(e)]);
|
||||
cursor = e.sequence; anchor = e.eventHash; appended = true;
|
||||
}
|
||||
priorAnchor = e.eventHash;
|
||||
}
|
||||
if (page.cursor !== page.events.at(-1)!.sequence || page.anchor !== priorAnchor) throw new CanonicalInboxError('cursor-anchor-mismatch');
|
||||
return { streamId: page.streamId, cursor, head: Math.max(page.head, cursor), height: stack.length,
|
||||
tip: stack.at(-1) ?? GENESIS_HASH, replayed: !appended };
|
||||
}
|
||||
|
||||
/** Full append-only inbox verification. Expected to run under the caller's coherent audit snapshot. */
|
||||
export async function auditCanonicalInbox(client: PoolClient): Promise<void> {
|
||||
await readCanonicalHistory(client);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Pool, type PoolClient, type PoolConfig } from 'pg';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { IS_MAINNET, NETWORK } from '../../shared/network.ts';
|
||||
import { CHAIN_ID, verifySignature } from '../testnet/consensus.ts';
|
||||
import { Ledger } from '../testnet/ledger.ts';
|
||||
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
|
||||
import { allocateXmrSale } from '../../shared/xmr-pricing.ts';
|
||||
import { parseAtomicXmr } from '../../shared/xmr-amount.ts';
|
||||
@@ -13,6 +14,7 @@ import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts';
|
||||
import { resolvedMarketPolicy, validateMarketPolicy, permitsMarketAction, type MarketPolicy } from './policy.ts';
|
||||
import { CustodyIntegrityError, auditBusinessLiabilities, auditOrder, auditWithdrawal } from './custody-integrity.ts';
|
||||
import { replayReservedSale } from './sale-rehearsal.ts';
|
||||
import { CanonicalInboxError, auditCanonicalInbox, ingestCanonicalPage } from './canonical-inbox.ts';
|
||||
|
||||
export type CustodyOptions = { feeBps: number; card(hash: string, owner: string, sequence: number): { title: string } | undefined;
|
||||
now?: () => number; disposableSaleRehearsal?: boolean } & MarketPolicy;
|
||||
@@ -337,6 +339,38 @@ export class PostgresCustody {
|
||||
return { order: o, action: replay.decision.action, reservationDigest: replay.reservationDigest, stateRoot: replay.stateRoot };
|
||||
});
|
||||
}
|
||||
/** Disposable observer rehearsal only; input must come from Ledger.canonicalEvents(), never peers/HTTP. */
|
||||
observeCanonicalEvents(page: unknown) {
|
||||
check(this.options.disposableSaleRehearsal === true, 'Canonical event inbox rehearsal is unavailable on this custody instance');
|
||||
return this.transaction(true, async c => {
|
||||
check(!this.diagnostics().mutationHold, 'Custody reconciliation hold');
|
||||
check(!(await c.query("SELECT 1 FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'")).rowCount, 'Custody integrity incident unresolved');
|
||||
try { return await ingestCanonicalPage(c, page); }
|
||||
catch (error) {
|
||||
if (error instanceof CanonicalInboxError) throw new CustodyIntegrityError(`canonical-inbox-${error.code}`);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
/** Consume one bounded page directly from this process's locally validated ledger; no HTTP/peer feed. */
|
||||
async observeLedgerCanonicalEvents(ledger: Ledger) {
|
||||
check(this.options.disposableSaleRehearsal === true, 'Canonical event inbox rehearsal is unavailable on this custody instance');
|
||||
check(ledger instanceof Ledger, 'Canonical inbox requires a local Ledger instance');
|
||||
const summary = ledger.summary();
|
||||
check(summary.network === NETWORK && summary.chainId === CHAIN_ID, 'Canonical ledger identity mismatch');
|
||||
const cursor = await this.transaction(false, async c => {
|
||||
const row = await c.query(`SELECT key FROM wk_custody.custody_meta
|
||||
WHERE key LIKE 'canonical-inbox:v1:event:%' ORDER BY key DESC LIMIT 1`);
|
||||
if (!row.rowCount) return 0;
|
||||
const match = /^canonical-inbox:v1:event:(\d{16})$/.exec(row.rows[0].key);
|
||||
if (!match) throw new CustodyIntegrityError('canonical-inbox-cursor-invalid');
|
||||
return Number(match[1]);
|
||||
});
|
||||
let page: unknown;
|
||||
try { page = ledger.canonicalEvents(cursor, 64); }
|
||||
catch { throw new CustodyIntegrityError('canonical-ledger-cursor-mismatch'); }
|
||||
return this.observeCanonicalEvents(page);
|
||||
}
|
||||
audit(): Promise<CustodyAudit> {
|
||||
if (this.auditInFlight) return this.auditInFlight;
|
||||
this.auditState.lastAttemptAt = Date.now();
|
||||
@@ -359,6 +393,11 @@ export class PostgresCustody {
|
||||
export async function auditPostgres(c: PoolClient): Promise<CustodyAudit> {
|
||||
const incident = await c.query("SELECT 1 FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'");
|
||||
if (incident.rowCount) throw new CustodyIntegrityError('unresolved-integrity-incident');
|
||||
try { await auditCanonicalInbox(c); }
|
||||
catch (error) {
|
||||
if (error instanceof CanonicalInboxError) throw new CustodyIntegrityError(`canonical-inbox-${error.code}`);
|
||||
throw error;
|
||||
}
|
||||
const bad = await c.query(`SELECT 1 FROM wk_custody.journals j LEFT JOIN wk_custody.entries e ON e.journal=j.id
|
||||
GROUP BY j.id HAVING count(e.position)=0 OR sum(e.debit-e.credit)<>0 LIMIT 1`);
|
||||
if (bad.rowCount) throw new CustodyIntegrityError('unbalanced-journal');
|
||||
|
||||
@@ -81,4 +81,4 @@ export function replayReservedSale(order: CustodyOrder, custodianId: string, raw
|
||||
const previous = order.status === 'settled' ? 'settled' : order.status === 'cancelled' ? 'refunded' : 'reserved';
|
||||
return { input, binding, reservationDigest, stateRoot: saleStateRoot(state, context),
|
||||
decision: saleDecision(state, terms, context, previous), context };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user