Per-order terminal reorg linkage

This commit is contained in:
2026-10-20 19:08:19 -04:00
parent e1fbf5e5af
commit 22da9764eb
9 changed files with 98 additions and 24 deletions
+7 -4
View File
@@ -25,7 +25,7 @@ This seed is **caller-supplied**, not discovered in the live node. Blocks have *
## Evidence, audit and review
Three namespaced metadata records per observed order retain its binding, **first** signed observation, and terminal observation when resolved. Runtime has INSERT/SELECT but no UPDATE/DELETE privilege on these metadata records. Repeated observations do not create an unbounded transcript archive. Business audits replay signed retained evidence, check the exact listing rate and binding/state commitment, and compare terminal outcome with the stored order and financial lifecycle.
Namespaced insert-only metadata records retain each order's binding, **first** signed observation, terminal observation, per-order terminal index and (when there is an explicit terminal candidate transaction) candidate-tx-to-order index. Runtime has INSERT/SELECT but no UPDATE/DELETE privilege on these metadata records. Repeated observations do not create an unbounded transcript archive. Business audits replay signed retained evidence, check the exact listing rate and binding/state commitment, and compare terminal outcome, candidate transaction ID and indexes with the stored order and financial lifecycle.
The existing sandbox `mock-sale-settlement` journal and `fixture-complete`/`fixture:<state-root>` outbox format are reused deliberately. They remain **fixture** evidence, not a new assertion of canonical delivery. No schema migration or production evidence format is implied. Existing imported SQLite fixture settlements remain supported without being relabeled as signed rehearsals.
@@ -37,7 +37,9 @@ The same explicitly disposable PostgreSQL instance now has `observeCanonicalEven
The adapter is not automatically scheduled: there is no HTTP route or background worker. PostgreSQL tests include synthetic event pages for malformed/retry cases and one integration using blocks accepted by a local `Ledger` configured with a **test-only fake PoW engine**, including an equal-work side branch and a reorg. This tests the local adapter/API boundary, not real RandomX validation, external provenance or production multi-node behavior. Hash-anchor consistency is not proof of honest observation; the stream ID and unkeyed hashes are stored beside the events, so a schema owner able to coherently rewrite the complete history can also recompute the hashes. The included corruption test detects a partial edit, not a fully rewritten valid history. Production must anchor custody checkpoints outside the database's mutable trust domain. The disposable inbox is capped at 4,096 total events, stores the append-only event history and fully rescans it during audits. That is intentionally a small-fixture design, not production storage/cursor architecture or a scale claim.
Most importantly, the inbox is not connected to candidate transaction IDs, `replayReservedSale()` or settlement decisions. Existing v6 blocks cannot contain candidate sale transactions, and local free transfers are not accepted as sale completion evidence. It therefore **does not create a sale-specific disconnect hold** or detect an unsubmitted intermediate fork that later disappears before a page is observed. The synthetic sale transcript still retains only first/terminal evidence and the redacted incident reason; the caller must retain conflicting transcript evidence. Production must integrate a durable replay-safe canonical consumer with per-order candidate transaction linkage, then persist a review hold for a previously posted outcome. No production sale-observation guarantee is claimed for this increment.
The synthetic PostgreSQL event-page seam now links an explicitly terminal candidate transaction ID to its posted order. A later **submitted disconnect event** containing that exact ID rolls back inbox advancement and commits a durable `posted-sale-terminal-disconnected` hold; the original journal is not reversed or paid again. This exercises order-to-event linkage and atomic review behavior. The hold only detects disconnects actually delivered to this seam; it cannot detect an unseen intermediate fork.
The local-ledger adapter is **not** linked to these terminal candidate IDs: active v6 blocks cannot contain candidate sale transactions, and local free transfers are not accepted as sale completion evidence. The adapter's integration test demonstrates canonical event ingestion/reorg only, not sale rollback handling. The terminal-link test supplies a synthetic canonical page under disposable-only guards. Production still requires the candidate transaction format in a separately reviewed/activated network, an actual local canonical feed containing those IDs, and per-order integration with durable node history. No production sale-observation guarantee is claimed.
## Validation and remaining gates
@@ -50,9 +52,10 @@ TypeScript/Vite Docker build passed; **172 offline regressions and 27 PostgreSQL
- identical-rounded-allocation but different fee-rate rejection;
- one-atomic settlement and signed custom-recipient revenue across current-policy changes;
- conflicting outcome → durable cross-instance/restart hold without a second credit;
- anchored canonical-event connect/disconnect paging, overlapping/stale consumer retries, reconnect, partial history mutation audit, forged disconnect-ancestor rejection and tamper-triggered hold; actual validated local-ledger blocks, excluded equal-work branch and a canonical reorg through the adapter (no candidate sale link);
- anchored canonical-event connect/disconnect paging, overlapping/stale consumer retries, reconnect, partial history mutation audit, forged disconnect-ancestor rejection and tamper-triggered hold; actual locally validated blocks using test-only fake PoW, excluded equal-work branch and a canonical reorg through the adapter (no candidate sale link);
- synthetic exact candidate-terminal transaction disconnect → order-linked durable hold, one preserved seller credit and no second payout;
- injected terminal-evidence failure → complete rollback and safe retry; corrupted evidence → audit hold.
A first bounded-checkpoint implementation attempted metadata UPDATE and was rejected by the real runtime grants. It was corrected to retain insert-only first/terminal evidence; no privilege was widened. Test evidence is for this implementation boundary, not production approval.
**M3 remains open.** Next work is independently reviewed protocol/policy and explicit new-network activation design, then actual PoW/peer/index/undo integration and durable canonical observation tied to financially fenced host-signing intent. Monero deposit confirmation/unlock observation, treasury/signer separation, real withdrawal controls, coordinated recovery, capacity and legal/operational release gates remain separate. The active v6 chain still rejects every candidate sale transaction. Persistent local custody deployments were not switched or restarted by this rehearsal.
**M3 remains open.** Next work is independently reviewed protocol/policy and explicit new-network activation design, then actual PoW/peer/index/undo integration and durable canonical observation tied to financially fenced host-signing intent. The order-link detector is only exercised with synthetic page events until an activated candidate network can produce candidate IDs through the real local ledger. Monero deposit confirmation/unlock observation, treasury/signer separation, real withdrawal controls, coordinated recovery, capacity and legal/operational release gates remain separate. The active v6 chain still rejects every candidate sale transaction. Persistent local custody deployments were not switched or restarted by this rehearsal.
+1 -1
View File
@@ -88,7 +88,7 @@ Blocked on P0 ADR; use P1 amount types and P2 durable events. Consensus work is
**Accounting rehearsal increment, 2026-10-20:** [signed candidate → PostgreSQL reservation resolution](custody-sale-rehearsal.md) now exercises actual restricted-role SQL terminal postings from bounded synthetic transcripts, behind explicit disposable database/instance guards and without an HTTP route. Exact terms/hosting recipients and reservation commitments are checked; mature completion credits seller/fees once, cancellation/non-admission releases buyer funds, and conflicting post-settlement evidence durably holds mutations rather than repaying. Insert-only first/terminal evidence is audited. Final build, 172 offline tests and 22 PostgreSQL integration tests passed. This supersedes the earlier absence of any balance-posting test integration, **not** the production/activation boundary: caller-supplied seeds/blocks are not canonical evidence, intermediate unseen disconnects cannot be detected, and no signer, canonical inbox, live deposit/withdrawal or current-chain paid sale is enabled. M3 remains open.
**Canonical inbox rehearsal increment, 2026-10-20:** the same gated disposable PostgreSQL instance now stores bounded append-only pages from an in-process `Ledger` adapter, checks stream/anchor/hash/tip/ancestor continuity, permits exact/overlapping/stale consumer retries, and verifies retained history during audits. Integration tests use locally validated blocks to exercise an excluded equal-work branch and a real reorg; tampering or stream changes durably hold mutations. Final build and 27 PostgreSQL tests pass; the offline regression count remains 172. The adapter is not scheduled, its stream is not tamper-proof against a schema owner, it does not match candidate transaction IDs or drive per-order settlement/reorg review, and the inbox's 4,096-event cap/full scan make it explicitly non-production. M3 remains blocked on production observer/checkpoint design, candidate network activation, transaction linkage and per-order reorg handling.
**Canonical/per-order reorg rehearsal increment, 2026-10-20:** the gated disposable PostgreSQL observer stores bounded pages from an in-process `Ledger` adapter, checks stream/anchor/hash/tip/ancestor continuity, supports retries, and audits retained history. A local-ledger test using fake PoW covers an excluded equal-work branch and reorg. A separate synthetic-event test links the deterministic candidate terminal transaction ID to its posted order; disconnecting that ID commits a durable review hold without undoing or repeating the payout. Final build and 28 PostgreSQL tests pass; offline regressions remain 172. The worker is not scheduled, active v6 cannot emit candidate IDs, and synthetic linkage is not canonical sale evidence. Stream hashes are not tamper-proof against the schema owner; 4,096-event cap/full scan are non-production. M3 remains blocked on reviewed candidate activation, actual candidate IDs from a production local ledger, observer/checkpoint design and production readiness.
- If a reviewed trustless design is selected: implement its actual cross-chain validity/evidence rules, compatible cryptographic primitives, both-party failure/refund paths and commission semantics. Run maintained reference vectors, negative tests, fuzz/property tests and specialist audit before claiming atomicity.
- If a custodial/attested fallback is explicitly approved: define chain-enforced NFT lock, order identity, authorized releaser, cancellation/refund/expiry and stale-signature rules. Separate host control keys from seller wallet and processor payout keys. Reserve/refund procedures must not permit both buyer refund and later seller delivery/release. Describe who can steal, censor or lock assets and how recovery is authorized.
+2 -2
View File
@@ -66,13 +66,13 @@ An already mature terminal record remains usable evidence if the NFT is later gi
## Evidence and next release gates
Initial candidate evidence: 162 offline tests passed, including 13 candidate tests. Current TypeScript/Vite Docker build and **172 offline tests plus 22 PostgreSQL integration tests passed, zero failures/skips**, including 14 candidate tests in [tests/sale-candidate.test.ts](../tests/sale-candidate.test.ts). Coverage includes current-consensus rejection, field/signature and hosting-recipient tampering, exact height boundaries, sequence exhaustion, native transfer/trade lock bypass, competing-market/reused-order locks, atomic conflicting-outcome rejection, reorg financial decisions, deterministic snapshot restore, state commitments, actual custody-order drafting and enumerated adversarial histories through expiry. The [accounting rehearsal](custody-sale-rehearsal.md) records the additional real-PostgreSQL/fictional-funds evidence and its synthetic-chain limitations. No protocol guarantee is inferred from a test count.
Initial candidate evidence: 162 offline tests passed, including 13 candidate tests. Current TypeScript/Vite Docker build and **172 offline tests plus 28 PostgreSQL integration tests passed, zero failures/skips**, including 14 candidate tests in [tests/sale-candidate.test.ts](../tests/sale-candidate.test.ts). Coverage includes current-consensus rejection, field/signature and hosting-recipient tampering, exact height boundaries, sequence exhaustion, native transfer/trade lock bypass, competing-market/reused-order locks, atomic conflicting-outcome rejection, reorg financial decisions, deterministic snapshot restore, state commitments, actual custody-order drafting and enumerated adversarial histories through expiry. The [accounting rehearsal](custody-sale-rehearsal.md) records actual PostgreSQL/fictitious-funds postings, a local-ledger event adapter test with fake PoW, and synthetic candidate-terminal disconnect linkage. No protocol guarantee is inferred from a test count.
**This does not close M3.** Required next steps:
1. Independent protocol/consensus review and operator approval of authority, cancellation and numeric policy parameters; final specification and test vectors.
2. Versioned network/manifest and explicit migration/activation strategy. No enabling the candidate under the existing v6 chain identity.
3. Integrate new transaction encodings/state commitments with mint/transfer/trade validation, canonical SQL undo/indexes/events, actual RandomX blocks, peer sync, restart and multi-host fork/partition tests. The current model supplies no PoW, issuance or fork-choice implementation.
4. Production PostgreSQL sale witness/terms storage, financially reserved host-signing workflow, outbox fencing, local-ledger canonical observer/inbox, transaction linkage, atomic terminal postings and per-order reorg holds. The disposable signed-transcript rehearsal exercises terminal postings and durable review. A separate bounded PostgreSQL event-inbox rehearsal checks page hashes, anchors, overlapping retries and connect/disconnect tip progression, but is not wired to the ledger and is not linked to a sale. Existing sandbox signatures do not authorize the new sale protocol. No signing before final exact order consent.
4. Production PostgreSQL sale witness/terms storage, financially reserved host-signing workflow, outbox fencing, local-ledger canonical observer/inbox, activated candidate transaction linkage, atomic terminal postings and per-order reorg holds. The disposable signed-transcript rehearsal exercises terminal postings and durable review; a synthetic event-page test links terminal IDs to orders and fences a disconnect. The local-ledger adapter separately reads canonical events but cannot observe candidate IDs while active v6 rejects those transactions. Existing sandbox signatures do not authorize the new sale protocol. No signing before final exact order consent.
5. Publication/review UI, key management/rotation, privacy and public-price disclosure, window/griefing/storage limits and operational recovery. Public terms would reveal sale linkage and amounts; privacy acceptance is a product decision.
6. Monero confirmation/unlock observation, real treasury controls, coordinated financial/node recovery and independently reviewed production release gates. No live wallet, funds or credentials were used in this increment.
+6 -4
View File
@@ -11,7 +11,8 @@ const pageSchema = z.object({ streamId: z.string().uuid(), previousAnchor: 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 type CanonicalInboxResult = { streamId: string; cursor: number; head: number; height: number; tip: string;
replayed: boolean; appendedEvents: z.infer<typeof eventSchema>[] };
export class CanonicalInboxError extends Error {
readonly code: string;
@@ -96,7 +97,7 @@ export async function ingestCanonicalPage(client: PoolClient, raw: unknown): Pro
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 };
tip: stack.at(-1) ?? GENESIS_HASH, replayed: true, appendedEvents: [] };
}
const firstSequence = page.events[0].sequence;
if (firstSequence > cursor + 1) throw new CanonicalInboxError('cursor-gap');
@@ -104,6 +105,7 @@ export async function ingestCanonicalPage(client: PoolClient, raw: unknown): Pro
if (!predecessor || page.previousAnchor !== predecessor) throw new CanonicalInboxError('cursor-anchor-mismatch');
let priorAnchor = predecessor;
let appended = false;
const appendedEvents: z.infer<typeof eventSchema>[] = [];
for (const e of page.events) {
if (e.eventHash !== eventDigest(page.streamId, e, priorAnchor)) throw new CanonicalInboxError('event-hash-invalid');
if (e.sequence <= cursor) {
@@ -113,13 +115,13 @@ export async function ingestCanonicalPage(client: PoolClient, raw: unknown): Pro
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;
cursor = e.sequence; anchor = e.eventHash; appended = true; appendedEvents.push(e);
}
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 };
tip: stack.at(-1) ?? GENESIS_HASH, replayed: !appended, appendedEvents };
}
/** Full append-only inbox verification. Expected to run under the caller's coherent audit snapshot. */
+16 -2
View File
@@ -98,7 +98,8 @@ async function checkOrders(client: PoolClient, rows: OrderRow[]) {
c(`customer:${o.seller}:available`, o.sellerAtomicXmr), c(`author:${o.authorAddress}`, o.authorAtomicXmr),
c(o.hostFeeAddress ? `revenue:host:${o.hostFeeAddress}` : 'revenue:host', o.hostAtomicXmr)] : null);
const evidenceRows = await client.query('SELECT key,value FROM wk_custody.custody_meta WHERE key=ANY($1::text[])',
[[`sale-rehearsal-binding:${o.id}`, `sale-rehearsal-observation:${o.id}`, `sale-rehearsal-terminal:${o.id}`]]);
[[`sale-rehearsal-binding:${o.id}`, `sale-rehearsal-observation:${o.id}`, `sale-rehearsal-terminal:${o.id}`,
`sale-rehearsal-terminal-index:${o.id}`]]);
if (evidenceRows.rowCount) {
try {
const records = new Map<string, string>(evidenceRows.rows.map(r => [r.key, r.value]));
@@ -113,11 +114,24 @@ async function checkOrders(client: PoolClient, rows: OrderRow[]) {
};
verify(`sale-rehearsal-observation:${o.id}`);
if (o.status === 'awaiting-lock') {
if (records.has(`sale-rehearsal-terminal:${o.id}`)) fail('sale-rehearsal-evidence-mismatch');
if (records.has(`sale-rehearsal-terminal:${o.id}`) || records.has(`sale-rehearsal-terminal-index:${o.id}`)) {
fail('sale-rehearsal-evidence-mismatch');
}
} else {
const replay = verify(`sale-rehearsal-terminal:${o.id}`);
if (replay.decision.action !== (o.status === 'settled' ? 'settle' : 'refund') ||
(o.status === 'settled' && row.box_data !== `fixture:${replay.stateRoot}`)) fail('sale-rehearsal-evidence-mismatch');
const index = JSON.parse(records.get(`sale-rehearsal-terminal-index:${o.id}`) ?? '');
if (index.version !== 1 || index.stateRoot !== replay.stateRoot ||
index.terminalTransactionId !== (replay.terminalTransactionId ?? null)) fail('sale-rehearsal-evidence-mismatch');
if (replay.terminalTransactionId) {
const link = await client.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1',
[`sale-rehearsal-tx:${replay.terminalTransactionId}`]);
const parsedLink = JSON.parse(link.rows[0]?.value ?? '');
if (parsedLink.version !== 1 || parsedLink.orderId !== o.id || parsedLink.stateRoot !== replay.stateRoot) {
fail('sale-rehearsal-terminal-index-mismatch');
}
}
}
} catch { fail('sale-rehearsal-evidence-mismatch'); }
}
+19 -1
View File
@@ -334,6 +334,14 @@ export class PostgresCustody {
await this.update(c, 'orders', o); await this.update(c, 'listings', l);
await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2)', [`sale-rehearsal-terminal:${o.id}`,
JSON.stringify({ version: 1, binding: replay.binding, stateRoot: replay.stateRoot, input: replay.input })]);
await c.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)',
[`sale-rehearsal-terminal-index:${o.id}`, JSON.stringify({ version: 1,
terminalTransactionId: replay.terminalTransactionId ?? null, stateRoot: replay.stateRoot })]);
if (replay.terminalTransactionId) {
await c.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)',
[`sale-rehearsal-tx:${replay.terminalTransactionId}`,
JSON.stringify({ version: 1, orderId: o.id, stateRoot: replay.stateRoot })]);
}
await auditOrder(c, o.id);
}
return { order: o, action: replay.decision.action, reservationDigest: replay.reservationDigest, stateRoot: replay.stateRoot };
@@ -345,7 +353,17 @@ export class PostgresCustody {
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); }
try {
const result = await ingestCanonicalPage(c, page);
for (const event of result.appendedEvents) {
if (event.type !== 'disconnect') continue;
for (const txid of event.transactionIds) {
const terminal = await c.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1', [`sale-rehearsal-tx:${txid}`]);
if (terminal.rowCount) throw new CustodyIntegrityError('posted-sale-terminal-disconnected');
}
}
return result;
}
catch (error) {
if (error instanceof CanonicalInboxError) throw new CustodyIntegrityError(`canonical-inbox-${error.code}`);
throw error;
+5 -2
View File
@@ -3,7 +3,7 @@ import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
import type { CustodyOrder } from '../../shared/custody.ts';
import { saleTransactionSchema, saleTermsMessage } from '../../shared/sale-candidate.ts';
import { applySaleBlock, initialSaleState, saleContext, saleDecision, saleId, saleStateRoot,
termsForCustodyOrder, type SaleContext } from '../testnet/sale-candidate.ts';
saleCandidateTransactionId, termsForCustodyOrder, type SaleContext } from '../testnet/sale-candidate.ts';
import { sha256, verifySignature } from '../testnet/consensus.ts';
const height = z.number().int().min(0).max(1000000);
@@ -71,14 +71,17 @@ export function replayReservedSale(order: CustodyOrder, custodianId: string, raw
order.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address, binding]));
let state = initialSaleState({ [order.cardHash]: { owner: order.seller, sequence: order.sequence,
mintedHeight: input.seed.mintedHeight, pageId: input.seed.pageId } }, input.seed.height);
let lockTransactionId: string | undefined, terminalTransactionId: string | undefined;
for (const block of input.blocks) {
for (const tx of block) {
if (tx.type === 'sale-lock' ? saleId(tx.terms) !== saleId(terms) : tx.saleId !== saleId(terms)) throw new Error('Unrelated sale in rehearsal');
if (tx.type === 'sale-complete' && tx.reservationDigest !== reservationDigest) throw new Error('Completion reservation commitment mismatch');
if (tx.type === 'sale-lock') lockTransactionId = saleCandidateTransactionId(tx);
else terminalTransactionId = saleCandidateTransactionId(tx);
}
state = applySaleBlock(state, block, context);
}
const previous = order.status === 'settled' ? 'settled' : order.status === 'cancelled' ? 'refunded' : 'reserved';
return { input, binding, reservationDigest, stateRoot: saleStateRoot(state, context),
return { input, binding, reservationDigest, lockTransactionId, terminalTransactionId, stateRoot: saleStateRoot(state, context),
decision: saleDecision(state, terms, context, previous), context };
}
+12 -4
View File
@@ -128,11 +128,19 @@ export function applySaleBlock(parent: SaleState, raw: unknown[], context: SaleC
}
return state;
}
/** Candidate transaction IDs exclude malleable signature bytes, matching the active chain convention.
* These IDs are an unactivated candidate format and are NOT accepted by the existing Ledger schema.
*/
export function saleCandidateTransactionId(tx: SaleTransaction): string {
if (tx.type === 'sale-lock') return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'lock', saleTermsMessage(tx.terms)]));
if (tx.type === 'sale-complete') return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'complete', saleCompletionMessage(tx)]));
if (tx.type === 'sale-cancel') return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'cancel', saleCancellationMessage(tx)]));
return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'expire', tx.chainId, tx.saleId]));
}
function candidateIdentity(tx: CandidateTransaction): string {
if (tx.type === 'sale-lock') return `lock:${saleId(tx.terms)}`;
if (tx.type === 'sale-complete') return sha256(saleCompletionMessage(tx));
if (tx.type === 'sale-cancel') return sha256(saleCancellationMessage(tx));
if (tx.type === 'sale-expire') return `expire:${tx.chainId}:${tx.saleId}`;
if (tx.type === 'sale-lock' || tx.type === 'sale-complete' || tx.type === 'sale-cancel' || tx.type === 'sale-expire') {
return saleCandidateTransactionId(tx);
}
return sha256(tx.type === 'native-transfer' ? nftTransferMessage(tx.transfer) : nftTradeMessage(tx.trade));
}
function applyNative(state: SaleState, tx: Extract<CandidateTransaction, { type: 'native-transfer' | 'native-trade' }>, context: SaleContext) {
+30 -4
View File
@@ -22,7 +22,7 @@ import { CUSTODY_POLICY, custodyMessage, type CustodyAction, type CustodyCommand
type CustodyListing, type CustodyOrder, type CustodyWithdrawal } from '../../shared/custody.ts';
import { NETWORK } from '../../shared/network.ts';
import { CHAIN_ID, GENESIS_HASH, address, sha256 } from '../../server/testnet/consensus.ts';
import { saleContext, termsForCustodyOrder, saleId } from '../../server/testnet/sale-candidate.ts';
import { saleContext, termsForCustodyOrder, saleCandidateTransactionId, saleId } from '../../server/testnet/sale-candidate.ts';
import { SALE_CANDIDATE, saleTermsMessage, saleCompletionMessage, saleCancellationMessage,
type SaleTransaction } from '../../shared/sale-candidate.ts';
import { replayReservedSale, type SaleRehearsal } from '../../server/market/sale-rehearsal.ts';
@@ -78,7 +78,7 @@ function canonicalPage(streamId: string, all: CanonicalInboxPage['events'], star
const anchor = events.at(-1)?.eventHash ?? previousAnchor;
return { streamId, previousAnchor, anchor, events, cursor, head: all.length, caughtUp: cursor === all.length };
}
function canonicalEvents(streamId: string) {
function canonicalEvents(streamId: string, linkedTxId = '1'.repeat(64)) {
const rows: CanonicalInboxPage['events'] = [];
let anchor = GENESIS_HASH;
const add = (type: 'connect' | 'disconnect', height: number, hash: string, previousHash: string, transactionIds: string[] = []) => {
@@ -88,8 +88,8 @@ function canonicalEvents(streamId: string) {
rows.push({ sequence, type, height, hash, previousHash, transactionIds, eventHash }); anchor = eventHash;
};
const a = 'a'.repeat(64), b = 'b'.repeat(64), c = 'c'.repeat(64);
add('connect', 1, a, GENESIS_HASH, ['1'.repeat(64)]);
add('disconnect', 1, a, GENESIS_HASH, ['1'.repeat(64)]);
add('connect', 1, a, GENESIS_HASH, [linkedTxId]);
add('disconnect', 1, a, GENESIS_HASH, [linkedTxId]);
add('connect', 1, b, GENESIS_HASH, ['2'.repeat(64)]);
add('connect', 2, c, b, ['3'.repeat(64)]);
return rows;
@@ -631,6 +631,32 @@ test('signed candidate completion settles actual PostgreSQL balances once across
} finally { await a.close(); await b.close(); }
});
test('disconnect of the exact posted candidate terminal transaction commits an order-linked review hold', async t => {
const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' });
const l = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '100000000000', feeBps: 250 }) as CustodyListing;
const o = await f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 }) as CustodyOrder;
const s = saleTranscript(o, f.market.id), transcript = { ...s.input, blocks: [[s.input.lock], [], [s.complete], [], []] };
const terminalTxId = saleCandidateTransactionId(s.complete);
const rehearsal = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
const observer = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
const streamId = randomUUID(), events = canonicalEvents(streamId, terminalTxId);
try {
assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'settle');
const credited = await f.activity(seller);
assert.equal(credited.availableAtomicXmr, '96500000000');
const first = await observer.observeCanonicalEvents(canonicalPage(streamId, events, 0, 1));
assert.equal(first.cursor, 1);
await assert.rejects(observer.observeCanonicalEvents(canonicalPage(streamId, events, 1, 1)), /posted-sale-terminal-disconnected/);
await assert.rejects(observer.observeCanonicalEvents(canonicalPage(streamId, events, 1, 1)), /hold|incident unresolved/);
assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'canonical-inbox:v1:event:%'")).rows[0].n, 1);
assert.equal((await f.activity(seller)).availableAtomicXmr, credited.availableAtomicXmr);
assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.journals WHERE id=$1", [`settle:${o.id}`])).rows[0].n, 1);
const incident = await f.admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'");
assert.match(incident.rows[0].value, /posted-sale-terminal-disconnected/);
await assert.rejects(f.execute(other, { type: 'mock-credit' }), /incident is unresolved/);
} finally { await observer.close(); await rehearsal.close(); }
});
test('candidate cancellation and withheld-lock expiry release actual reservations only after depth', async t => {
const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' });
const rehearsal = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });