diff --git a/docs/custody-sale-rehearsal.md b/docs/custody-sale-rehearsal.md new file mode 100755 index 0000000..3028308 --- /dev/null +++ b/docs/custody-sale-rehearsal.md @@ -0,0 +1,51 @@ +# Signed sale → PostgreSQL accounting rehearsal + +Status: **disposable, synthetic evidence only; no live settlement or consensus activation**. Evidence: 2026-10-20. Related: [sale-lock candidate](sale-lock-candidate.md), [financial reconciliation](financial-reconciliation.md), [production plan](production-plan.md). + +## What is integrated + +[The replay bridge](../server/market/sale-rehearsal.ts) validates trader-signed candidate terms against an actual sandbox purchase reservation. [The PostgreSQL custody implementation](../server/market/postgres-custody.ts) can consume this evidence and atomically resolve its reserved balance. This exercises the real restricted runtime role, financial writer lock, journals, balance projection, listing/order/outbox updates and business audit—not an in-memory imitation of SQL accounting. + +`observeSaleRehearsal()` is callable only on an instance explicitly opened with `disposableSaleRehearsal: true`, with `WIKIDECK_PG_DISPOSABLE_TEST=1`, a configured database named `custody_test`, and a matching actual connected database name. There is **no HTTP route, production environment enable switch, host signer, Monero adapter or public checkout integration**. These guards prevent accidental use; a database name/environment flag does not independently prove infrastructure isolation. The supplied test Compose deployment is disposable/internal/tmpfs, and must never contain funds or valuable assets. + +The evidence supplies one synthetic seller-owned NFT seed and up to 128 candidate blocks, each containing at most 64 sale transactions. Replay uses the existing candidate state machine, signatures and depth rules. JSON traversal has collection/depth/node limits and a 256 KiB evidence ceiling. Seed, policy, host identity, signed windows and exact economics are bound on the first successful observation. Changing that binding is rejected; a different block history under the same binding can be observed to test reorg handling. + +This seed is **caller-supplied**, not discovered in the live node. Blocks have **no RandomX proof, parent hashes, fork choice, issuance verification, peer validation or canonical event provenance**. Signed terms authorize the simulated transitions; signatures do not prove the chain is canonical or that real Monero backs the reservation. + +## Exact authorization and financial behavior + +- Both seller and buyer sign exact order/custodian/card/sequence, trading identities, host authority, candidate policy/domain, gross XMR amount, fee policy, recipient and admission/expiry heights. Existing sandbox reservation signatures alone are not candidate sale authorization. +- The signed hosting rate must equal the stored listing rate, even where two rates round to identical commissions on a tiny sale. The allocation and host recipient must match the reserved order/listing. All amounts remain canonical atomic-unit strings and `bigint`; there is no float or USD conversion. +- Optional hosting recipient terms use a tagged signature extension. Omission retains the earlier candidate message bytes. New drafts preserve the order's snapshotted recipient; changing current host policy does not redirect revenue. The candidate only checks address shape; actual custody policy/business audits additionally check Monero network and checksum. +- The reservation commitment binds the order's exact buyer/seller, card/sequence, gross and split amounts, author and hosting recipient, custodian and rehearsal binding. Completion must contain that commitment and the bound host's valid signature. There is no private key or automatic signature generation in the accounting implementation. +- Before maturity, gross remains in the buyer's reserved account. Once authorization has been bound, ordinary buyer `cancel-reservation` is denied, including through another ordinary custody instance. Synthetic mature terminal/non-admission evidence is required instead. +- Mature completion debits reserved gross and credits the seller's available balance, fixed author liability and snapshotted hosting revenue in one balanced transaction. Zero commission legs are omitted, not rounded up; a one-atomic sale still credits one atomic unit to the seller. +- Mature cancellation/expiry or the safe never-admitted boundary debits the reservation and returns gross to the buyer. There is no seller/fee credit or Monero transfer on this branch. +- Deterministic journal IDs plus the shared database writer lock prevent duplicate terminal postings across pools, replay and restart. An identical outcome returns `none` rather than posting again. Failure while storing terminal evidence rolls the entire financial mutation back. + +## 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. + +The existing sandbox `mock-sale-settlement` journal and `fixture-complete`/`fixture:` 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. + +A submitted history that conflicts with, removes or makes immature an already posted outcome triggers `sale-rehearsal-outcome-reorg`. Business changes roll back, and a redacted durable integrity incident commits while retaining the shared writer lock. All custody instances then reject new mutations; private reads and original signed receipts remain available. The original seller credit/refund stays recorded: no automatic compensation, reversal or second payout. Re-submitting the old mature history cannot clear the incident. Existing schema-owner incident review procedures are not a substitute for resolving chain risk; production resolution requires retained canonical evidence and approved loss policy. + +This rehearsal is **not a durable connect/disconnect observer**. It retains first/terminal evidence and the redacted incident reason, not every submitted fork transcript. It cannot detect an intermediate disconnect that is never submitted, even if an outcome later reappears. The caller must retain conflicting evidence for investigation. Production must integrate a replay-safe canonical event inbox/cursor, history anchors, disconnect-before-reconnect handling and persistent per-order review. No monotonic canonical-height guarantee is claimed for this caller-driven seam. + +## Validation and remaining gates + +TypeScript/Vite Docker build passed; **172 offline regressions and 22 PostgreSQL integration tests passed, zero failures/skips**. [Candidate tests](../tests/sale-candidate.test.ts) cover signature compatibility/recipient tampering and candidate transition rules. [PostgreSQL tests](../tests/postgres/custody.test.ts) add: + +- explicit instance/database consent and exact signed reservation matching; +- seed-binding changes, malformed/cyclic/oversized collections, and denial of ordinary cancellation after binding; +- immature completion hold, concurrent exactly-once seller/author/host credit, replay and reconnect; +- mature cancellation and never-admitted-lock refund boundaries; +- 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; +- 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. \ No newline at end of file diff --git a/docs/market-host-configuration.md b/docs/market-host-configuration.md index 9887df6..5743457 100755 --- a/docs/market-host-configuration.md +++ b/docs/market-host-configuration.md @@ -30,6 +30,6 @@ New native-XMR listings and custody listings snapshot the configured host fee ad Existing signature bytes remain valid: omission retains the original message; explicitly supplied recipients add a domain-tagged fee tuple. Old USD/native-XMR records are not rewritten. Changing the environment changes **new listings**, not existing listings/orders or accumulated revenue. New checkout against an old listing follows its original recipient. For a new recipient, cancel/relist with fresh seller authorization. Hosting rate and the fixed 1% author policy remain separate; defaulting both recipients to the same wallet does not merge the rates. -The custody SQLite fixture records newly settled simulated host revenue under its snapshotted recipient. PostgreSQL production settlement/host withdrawals are still unimplemented: configuring an address does not create automatic transfers. Historical unallocated host revenue remains historical; it is not silently reassigned. The offline sale-lock candidate currently refuses custom-recipient drafts until its separate reviewed terms extension is specified, rather than dropping that authorization. +The custody SQLite fixture records newly settled simulated host revenue under its snapshotted recipient. PostgreSQL production settlement/host withdrawals are still unimplemented: configuring an address does not create automatic transfers. Historical unallocated host revenue remains historical; it is not silently reassigned. The offline sale-lock candidate now preserves the hosting recipient in a tagged signed-terms extension. A [disposable PostgreSQL accounting rehearsal](custody-sale-rehearsal.md) verifies that the signed snapshot receives simulated revenue even after current host policy changes; this is not active-chain delivery or a live host withdrawal service. -Validation: TypeScript/Vite build, 168 regression tests and 11 PostgreSQL integration tests passed. The full 168-test regression run was repeated after the candidate fail-closed guard. Tests cover strict flag/address validation, paused/disabled behavior, continuing existing-order delivery, preserved balances/cancellations, signed-recipient tampering and recipient snapshots across config changes. Real XMR remains disabled. +Validation: current TypeScript/Vite build, 172 regression tests and 22 PostgreSQL integration tests passed. Tests cover strict flag/address validation, paused/disabled behavior, continuing existing-order delivery, preserved balances/cancellations, signed-recipient tampering and recipient snapshots across config changes, including the isolated accounting rehearsal. Real XMR remains disabled. diff --git a/docs/production-plan.md b/docs/production-plan.md index c2a0068..e649e89 100755 --- a/docs/production-plan.md +++ b/docs/production-plan.md @@ -86,6 +86,8 @@ Blocked on P0 ADR; use P1 amount types and P2 durable events. Consensus work is **Candidate increment, 2026-10-20:** [ADR 004 — sale locks](sale-lock-candidate.md) now specifies and implements an offline deterministic signed lock/completion/cancel/expiry model, exact height boundaries, native transfer/trade lock guards, replay-resistant sequences, state commitments and a non-posting financial observation policy. A pure bridge checks terms drafted from actual sandbox reservations. Final build and 162 offline tests passed. Active v6 consensus still rejects the candidate and no host signer or balance-posting integration is enabled. Independent review, policy approval, versioned network activation, actual PoW/peer/index integration and PostgreSQL terminal workflow remain open; M3 is not accepted. +**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. + - 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. - Completed-sale inclusion must require the approved evidence that payment is sufficiently confirmed. A peer must not insert a completed sale merely because its local database says paid. Define threshold/policy version, proof/attestation authorization and deterministic validation; no external HTTP calls in consensus validation. diff --git a/docs/sale-lock-candidate.md b/docs/sale-lock-candidate.md index b5e2b24..11cafc9 100755 --- a/docs/sale-lock-candidate.md +++ b/docs/sale-lock-candidate.md @@ -6,7 +6,7 @@ Status: **offline executable candidate; NOT activated or independently reviewed* Specify the missing asset-control rules before enabling real custodial purchases. An ordinary signed NFT transfer can survive a website refund deadline; balance reservations alone cannot prevent that late delivery. This candidate couples sale authorization to explicit canonical locks and terminal outcomes. -[Signed schemas/messages](../shared/sale-candidate.ts) and [deterministic state machine](../server/testnet/sale-candidate.ts) are deliberately separate from active consensus. The existing transaction schema rejects all candidate transactions. The candidate domain derives from the existing chain identity, explicit policy and candidate version; it is an offline signature domain, **not a reviewed network manifest/genesis**. No current chain identity, miner, peer endpoint, database balance or custody signing capability is changed. +[Signed schemas/messages](../shared/sale-candidate.ts) and [deterministic state machine](../server/testnet/sale-candidate.ts) are deliberately separate from active consensus. The existing transaction schema rejects all candidate transactions. The candidate domain derives from the existing chain identity, explicit policy and candidate version; it is an offline signature domain, **not a reviewed network manifest/genesis**. No current chain identity, miner, peer endpoint or custody signing capability is changed. A separately gated [disposable PostgreSQL accounting rehearsal](custody-sale-rehearsal.md) now changes fictitious reservations using signed synthetic transcripts; it is not mined-chain integration. The pure `termsForCustodyOrder()` bridge drafts terms from an actual awaiting-lock reservation and checks exact seller/author/host allocations. It does not sign, persist, relay or debit anything. Tests exercise this bridge against the current custody engine. Public custody orders continue stopping at `awaiting-lock`. @@ -14,6 +14,8 @@ The pure `termsForCustodyOrder()` bridge drafts terms from an actual awaiting-lo Terms bind candidate protocol, network, chain/policy digest, custodian/order IDs, exact card/sequence, seller, buyer, host signing identity, native-XMR gross, hosting rate, fixed author policy, lock-admission deadline and final expiry height. Independent downward commission rounding remains unchanged. No production threshold is invented: maturity/depth/window limits are explicit policy inputs committed by the candidate digest. +Optional `hostFeeAddress` appends a tagged hosting-recipient tuple, preserving historical candidate signature bytes when omitted. Drafting preserves the reserved order's snapshot; the PostgreSQL rehearsal additionally checks the exact listing rate, not just rounded allocation equality. The candidate performs address-shape validation only, with checksum/network validation supplied by custody admission and business audits. This extension is still unactivated and requires the same protocol review as the candidate. + - **Lock:** both seller and buyer sign the complete terms. Host signing identity must differ from both trading identities. Distinct keys are role separation, not proof of independent humans/operators. - **Completion:** the terms' exact host signs the sale ID plus a reservation digest, attesting that sufficiently confirmed/unlocked funds were reserved under the approved policy. Host cannot select another card/recipient/price. Validators would verify the host's authorization, **not Monero truth**. The reservation digest is an accounting commitment, not a cryptographic proof of backing or solvency. - **Cancellation:** host and buyer sign the sale ID. Neither can unilaterally cancel early. Seller already consented to this branch in signed lock terms; seller retains card ownership on cancellation. @@ -64,13 +66,13 @@ An already mature terminal record remains usable evidence if the NFT is later gi ## Evidence and next release gates -TypeScript/Vite Docker build passed. **162 offline tests passed, zero failures/skips**, including 13 candidate tests in [tests/sale-candidate.test.ts](../tests/sale-candidate.test.ts). Coverage includes current-consensus rejection, field/signature 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. One initial enumeration-count expectation was corrected by extending its horizon through the expiry boundary; no protocol guarantee was inferred from an arbitrary test count. +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. **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. Durable PostgreSQL sale witness/terms storage, financially reserved host-signing workflow, outbox fencing, canonical observer/inbox, atomic terminal postings and reorg holds. 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, canonical observer/inbox, atomic terminal postings and reorg holds. The disposable signed-transcript rehearsal exercises terminal postings and durable review, but supplies none of the canonical observer/host-signing integration. 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. diff --git a/server/market/custody-integrity.ts b/server/market/custody-integrity.ts index a2a2c57..c40e26c 100755 --- a/server/market/custody-integrity.ts +++ b/server/market/custody-integrity.ts @@ -4,6 +4,7 @@ import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts'; import { parseAtomicXmr } from '../../shared/xmr-amount.ts'; import { allocateXmrSale } from '../../shared/xmr-pricing.ts'; import { validateXmrAddress } from './xmr.ts'; +import { replayReservedSale } from './sale-rehearsal.ts'; /** Codes only: never attach customer IDs, payloads, SQL errors or addresses to operational diagnostics. */ export class CustodyIntegrityError extends Error { @@ -96,6 +97,30 @@ async function checkOrders(client: PoolClient, rows: OrderRow[]) { expectJournal(posting, `settle:${o.id}`, 'mock-sale-settlement', o.status === 'settled' ? [d(account, o.grossAtomicXmr), 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}`]]); + if (evidenceRows.rowCount) { + try { + const records = new Map(evidenceRows.rows.map(r => [r.key, r.value])); + const binding = records.get(`sale-rehearsal-binding:${o.id}`); + const identity = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'"); + const verify = (key: string) => { + const evidence = JSON.parse(records.get(key) ?? ''); + const replay = replayReservedSale({ ...o, status: 'awaiting-lock' }, JSON.parse(identity.rows[0].value).id, evidence.input, l.feeBps); + if (evidence.version !== 1 || evidence.binding !== binding || replay.binding !== binding || + replay.stateRoot !== evidence.stateRoot) fail('sale-rehearsal-evidence-mismatch'); + return replay; + }; + verify(`sale-rehearsal-observation:${o.id}`); + if (o.status === 'awaiting-lock') { + if (records.has(`sale-rehearsal-terminal:${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'); + } + } catch { fail('sale-rehearsal-evidence-mismatch'); } + } } await validateWallets(wallets); } diff --git a/server/market/postgres-custody.ts b/server/market/postgres-custody.ts index 0269faa..d5bee02 100755 --- a/server/market/postgres-custody.ts +++ b/server/market/postgres-custody.ts @@ -12,8 +12,10 @@ import { CustodyError } from './custody.ts'; 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'; -export type CustodyOptions = { feeBps: number; card(hash: string, owner: string, sequence: number): { title: string } | undefined; now?: () => number } & MarketPolicy; +export type CustodyOptions = { feeBps: number; card(hash: string, owner: string, sequence: number): { title: string } | undefined; + now?: () => number; disposableSaleRehearsal?: boolean } & MarketPolicy; type Result = CustodyActivity | CustodyListing | CustodyOrder | CustodyWithdrawal; export type CustodyAudit = { journals: number; assetsAtomicXmr: string; claimsAndRevenueAtomicXmr: string }; type Line = { account: string; normal?: 'debit' | 'credit'; debit?: bigint; credit?: bigint }; @@ -42,6 +44,9 @@ export class PostgresCustody { } static async open(config: PoolConfig, options: CustodyOptions): Promise { if (IS_MAINNET) throw new Error('Custody sandbox cannot run on mainnet'); + if (options.disposableSaleRehearsal && (config.database !== 'custody_test' || process.env.WIKIDECK_PG_DISPOSABLE_TEST !== '1')) { + throw new Error('Sale rehearsal requires the explicitly isolated disposable database'); + } await validateMarketPolicy(options); if (!Number.isSafeInteger(options.feeBps) || options.feeBps < 0 || options.feeBps > 5000) throw new Error('Invalid custody fee'); const pool = new Pool(config); @@ -51,6 +56,9 @@ export class PostgresCustody { const c = await pool.connect(); let id: string; try { + if (options.disposableSaleRehearsal && (await c.query('SELECT current_database() AS name')).rows[0].name !== 'custody_test') { + throw new Error('Sale rehearsal requires the explicitly isolated disposable database'); + } await c.query('BEGIN'); await c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]); await verifyCustodySchema(c); @@ -255,6 +263,8 @@ export class PostgresCustody { } if (a.type === 'cancel-reservation') { const o = await this.record(c, 'orders', a.orderId); + check(!(await c.query('SELECT 1 FROM wk_custody.custody_meta WHERE key=$1', [`sale-rehearsal-binding:${o.id}`])).rowCount, + 'Rehearsal sale authorization exists; cancellation requires replayed terminal evidence'); const intent = await c.query('SELECT status FROM wk_custody.outbox WHERE id=$1 FOR UPDATE', [o.id]); check(o.buyer === actor && o.status === 'awaiting-lock' && intent.rows[0]?.status === 'blocked', 'Only an unstarted reservation can be cancelled by its buyer'); await this.post(c, `cancel:${o.id}`, 'purchase-cancellation', [{ account: reserved(o), debit: BigInt(o.grossAtomicXmr) }, { account: available(actor), credit: BigInt(o.grossAtomicXmr) }]); @@ -280,6 +290,53 @@ export class PostgresCustody { } throw new CustodyError('Unsupported custody action'); } + /** Disposable database ONLY. Not wired to HTTP, configuration flags, live chain or a signer. */ + observeSaleRehearsal(orderId: string, transcript: unknown) { + check(this.options.disposableSaleRehearsal === true, 'Sale 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'); + const o = await this.record(c, 'orders', orderId); + await auditOrder(c, o.id); + const l = await this.record(c, 'listings', o.listingId); + const replay = replayReservedSale(o, this.id, transcript, l.feeBps); + const key = `sale-rehearsal-binding:${o.id}`; + const old = await c.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1', [key]); + if (old.rowCount) check(old.rows[0].value === replay.binding, 'Rehearsal authorization changed'); + else { + check(o.status === 'awaiting-lock', 'Cannot bind an already resolved order to a rehearsal'); + await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2)', [key, replay.binding]); + } + if (replay.decision.action === 'review') throw new CustodyIntegrityError('sale-rehearsal-outcome-reorg'); + // Retain the first signed observation plus terminal evidence, not a canonical event inbox. + // Runtime has INSERT but intentionally no UPDATE privilege on these metadata records. + const evidenceKey = `sale-rehearsal-observation:${o.id}`; + await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2) ON CONFLICT(key) DO NOTHING', [evidenceKey, + JSON.stringify({ version: 1, binding: replay.binding, stateRoot: replay.stateRoot, input: replay.input })]); + if (replay.decision.action === 'settle' || replay.decision.action === 'refund') { + check(o.status === 'awaiting-lock', 'Rehearsal order already resolved'); + if (replay.decision.action === 'settle') { + await this.post(c, `settle:${o.id}`, 'mock-sale-settlement', [ + { account: reserved(o), debit: BigInt(o.grossAtomicXmr) }, + { account: available(o.seller), credit: BigInt(o.sellerAtomicXmr) }, + { account: `author:${o.authorAddress}`, credit: BigInt(o.authorAtomicXmr) }, + { account: o.hostFeeAddress ? `revenue:host:${o.hostFeeAddress}` : 'revenue:host', credit: BigInt(o.hostAtomicXmr) }]); + o.status = 'settled'; l.status = 'sold'; + await c.query("UPDATE wk_custody.outbox SET status='fixture-complete',data=$1 WHERE id=$2", [`fixture:${replay.stateRoot}`, o.id]); + } else { + await this.post(c, `cancel:${o.id}`, 'purchase-cancellation', [ + { account: reserved(o), debit: BigInt(o.grossAtomicXmr) }, { account: available(o.buyer), credit: BigInt(o.grossAtomicXmr) }]); + o.status = 'cancelled'; l.status = 'cancelled'; + await c.query("UPDATE wk_custody.outbox SET status='cancelled' WHERE id=$1", [o.id]); + } + 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 auditOrder(c, o.id); + } + return { order: o, action: replay.decision.action, reservationDigest: replay.reservationDigest, stateRoot: replay.stateRoot }; + }); + } audit(): Promise { if (this.auditInFlight) return this.auditInFlight; this.auditState.lastAttemptAt = Date.now(); diff --git a/server/market/sale-rehearsal.ts b/server/market/sale-rehearsal.ts new file mode 100755 index 0000000..1447552 --- /dev/null +++ b/server/market/sale-rehearsal.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; +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'; +import { sha256, verifySignature } from '../testnet/consensus.ts'; + +const height = z.number().int().min(0).max(1000000); +export const rehearsalSchema = z.object({ + policy: z.object({ coinbaseMaturity: height.positive(), lockDepth: height.positive(), outcomeDepth: height.positive(), + maxLockBlocks: height.positive(), maxTransactions: z.number().int().min(1).max(64) }).strict(), + seed: z.object({ height, mintedHeight: height, pageId: height.positive() }).strict(), + lock: saleTransactionSchema.options[0], + blocks: z.array(z.array(saleTransactionSchema).max(64)).min(1).max(128), +}).strict(); +export type SaleRehearsal = z.infer; + +// This is an internal JSON-only seam, but still bound traversal before Zod or serialization. +function checkEvidenceBudget(raw: unknown): void { + let nodes = 0, bytes = 0; + const seen = new WeakSet(); + const visit = (value: unknown, depth: number): void => { + if (++nodes > 32768 || depth > 12) throw new Error('Rehearsal evidence exceeds complexity budget'); + if (typeof value === 'string') { + if (value.length > 256 * 1024) throw new Error('Rehearsal evidence exceeds budget'); + bytes += Buffer.byteLength(value) + 2; + } else if (value !== null && typeof value === 'object') { + if (seen.has(value)) throw new Error('Rehearsal evidence must be acyclic JSON'); + seen.add(value); + if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new Error('Rehearsal evidence must be JSON'); + const keys = Object.keys(value); + if (keys.length > (Array.isArray(value) ? 128 : 64)) throw new Error('Rehearsal evidence exceeds collection budget'); + for (const key of keys) { + bytes += Buffer.byteLength(key) + 4; + const property = Object.getOwnPropertyDescriptor(value, key)!; + if (!('value' in property)) throw new Error('Rehearsal evidence must not contain accessors'); + visit(property.value, depth + 1); + } + seen.delete(value); + } else if (value !== undefined && value !== null && typeof value !== 'boolean' && + !(typeof value === 'number' && Number.isFinite(value))) throw new Error('Rehearsal evidence must be JSON'); + if (bytes > 256 * 1024) throw new Error('Rehearsal evidence exceeds budget'); + }; + visit(raw, 0); +} + +/** Replays signed synthetic blocks from a caller-supplied seed, NOT a canonical-chain verifier. + * No RPC/PoW, Monero, clock, signing key or HTTP route. Limited to one actual sandbox reservation. + */ +export function replayReservedSale(order: CustodyOrder, custodianId: string, raw: unknown, listingFeeBps: number) { + checkEvidenceBudget(raw); + const input = rehearsalSchema.parse(raw), context: SaleContext = saleContext(input.policy); + if (Buffer.byteLength(JSON.stringify(input)) > 256 * 1024) throw new Error('Rehearsal evidence exceeds budget'); + if (input.lock.type !== 'sale-lock') throw new Error('Rehearsal requires signed lock authorization'); + const terms = input.lock.terms; + if (terms.feeBps !== listingFeeBps) throw new Error('Rehearsal fee rate does not match reserved listing'); + const expected = termsForCustodyOrder({ ...order, status: 'awaiting-lock' }, context, { custodianId, + host: terms.host, feeBps: terms.feeBps, lockUntilHeight: terms.lockUntilHeight, expiresHeight: terms.expiresHeight }); + if (saleTermsMessage(expected) !== saleTermsMessage(terms)) throw new Error('Rehearsal does not match reserved order terms'); + if (input.seed.mintedHeight > input.seed.height) throw new Error('Invalid rehearsal issuance height'); + const message = saleTermsMessage(terms); + if (!verifySignature(input.lock.sellerAuthorization.publicKey, order.seller, message, input.lock.sellerAuthorization.signature) || + !verifySignature(input.lock.buyerAuthorization.publicKey, order.buyer, message, input.lock.buyerAuthorization.signature)) { + throw new Error('Rehearsal requires trader consent'); + } + const binding = sha256(JSON.stringify(['custody-sale-rehearsal-v1', custodianId, message, input.seed, input.policy])); + const reservationDigest = sha256(JSON.stringify(['custody-reservation-rehearsal-v1', custodianId, order.id, + order.buyer, order.seller, order.cardHash, order.sequence, order.grossAtomicXmr, + order.sellerAtomicXmr, order.authorAtomicXmr, order.hostAtomicXmr, order.authorAddress, + 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); + 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'); + } + state = applySaleBlock(state, block, context); + } + 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 }; +} \ No newline at end of file diff --git a/server/testnet/sale-candidate.ts b/server/testnet/sale-candidate.ts index 3b27df3..bf19fb4 100755 --- a/server/testnet/sale-candidate.ts +++ b/server/testnet/sale-candidate.ts @@ -45,9 +45,6 @@ export function termsForCustodyOrder(order: CustodyOrder, context: SaleContext, custodianId: string; host: string; feeBps: number; lockUntilHeight: number; expiresHeight: number; }): SaleTerms { check(order.status === 'awaiting-lock', 'Only awaiting-lock custody orders can draft candidate terms'); - // The unactivated candidate predates configurable hosting recipients. Never silently discard one. - check(!order.hostFeeAddress || order.hostFeeAddress === ADDITIONAL_MARKET_FEE.address, - 'Custom host fee recipient requires a reviewed sale-candidate terms extension'); const allocation = allocateXmrSale(parseAtomicXmr(order.grossAtomicXmr), options.feeBps); check(order.sellerAtomicXmr === allocation.sellerBeforeNetworkCosts.toString() && order.hostAtomicXmr === allocation.hosting.toString() && order.authorAtomicXmr === allocation.author.toString() && order.authorAddress === ADDITIONAL_MARKET_FEE.address, @@ -55,7 +52,8 @@ export function termsForCustodyOrder(order: CustodyOrder, context: SaleContext, return saleTermsSchema.parse({ protocol: SALE_CANDIDATE, network: NETWORK, chainId: context.chainId, policyDigest: context.policyDigest, custodianId: options.custodianId, orderId: order.id, cardHash: order.cardHash, sequence: order.sequence, seller: order.seller, buyer: order.buyer, host: options.host, - priceAtomicXmr: order.grossAtomicXmr, feeBps: options.feeBps, lockUntilHeight: options.lockUntilHeight, expiresHeight: options.expiresHeight }); + priceAtomicXmr: order.grossAtomicXmr, feeBps: options.feeBps, hostFeeAddress: order.hostFeeAddress, + lockUntilHeight: options.lockUntilHeight, expiresHeight: options.expiresHeight }); } export function initialSaleState(nfts: NftState, height = 0): SaleState { check(Number.isSafeInteger(height) && height >= 0, 'Invalid candidate height'); diff --git a/shared/sale-candidate.ts b/shared/sale-candidate.ts index fb36723..b2117c1 100755 --- a/shared/sale-candidate.ts +++ b/shared/sale-candidate.ts @@ -12,6 +12,7 @@ export const saleTermsSchema = z.object({ protocol: z.literal(SALE_CANDIDATE), n chainId: hex, policyDigest: hex, custodianId: z.string().uuid(), orderId: z.string().uuid(), cardHash: hex, sequence: height, seller: hex, buyer: hex, host: hex, priceAtomicXmr: amount, feeBps: z.number().int().min(0).max(5000), + hostFeeAddress: z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{95}$/).optional(), lockUntilHeight: height, expiresHeight: height }).strict(); export type SaleTerms = z.infer; export const saleSignatureSchema = z.object({ publicKey: z.string().min(1).max(256), @@ -28,10 +29,12 @@ export const saleTransactionSchema = z.discriminatedUnion('type', [ ]); export type SaleTransaction = z.infer; export function saleTermsMessage(t: SaleTerms): string { - return JSON.stringify([SALE_CANDIDATE, 'terms', t.network, t.chainId, t.policyDigest, + const tuple: unknown[] = [SALE_CANDIDATE, 'terms', t.network, t.chainId, t.policyDigest, t.custodianId, t.orderId, t.cardHash, t.sequence, t.seller, t.buyer, t.host, 'XMR', t.priceAtomicXmr, t.feeBps, ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address, - t.lockUntilHeight, t.expiresHeight]); + t.lockUntilHeight, t.expiresHeight]; + if (t.hostFeeAddress !== undefined) tuple.push(['host-fee-recipient-v1', t.hostFeeAddress]); + return JSON.stringify(tuple); } export function saleCompletionMessage(t: Pick, 'chainId' | 'saleId' | 'reservationDigest'>): string { return JSON.stringify([SALE_CANDIDATE, 'complete-funded-reservation', t.chainId, t.saleId, t.reservationDigest]); diff --git a/tests/postgres/custody.test.ts b/tests/postgres/custody.test.ts index 4019096..11f4533 100755 --- a/tests/postgres/custody.test.ts +++ b/tests/postgres/custody.test.ts @@ -22,6 +22,11 @@ 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, address } from '../../server/testnet/consensus.ts'; +import { saleContext, termsForCustodyOrder, 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'; +import { keccak } from 'hash-wasm'; if (process.env.WIKIDECK_PG_DISPOSABLE_TEST !== '1') throw new Error('PostgreSQL tests require the isolated disposable compose fixture'); const config: PoolConfig = { host: '127.0.0.1', database: 'custody_test', user: 'postgres', ssl: false, max: 4, @@ -33,6 +38,35 @@ function identity() { { key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') }; } const buyer = identity(), seller = identity(), other = identity(), hash = 'a'.repeat(64); +const host = identity(); +async function syntheticFeeAddress() { + const prefix = Uint8Array.from([18, ...Array(64).fill(2)]); + const data = Buffer.concat([prefix, Buffer.from((await keccak(prefix, 256)).slice(0, 8), 'hex')]); + const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + let encoded = ''; + for (let i = 0; i < data.length; i += 8) { + const chunk = data.subarray(i, i + 8); let n = BigInt(`0x${chunk.toString('hex')}`), text = ''; + while (n) { text = alphabet[Number(n % 58n)] + text; n /= 58n; } + encoded += text.padStart(chunk.length === 8 ? 11 : 7, '1'); + } + return encoded; +} +function saleTranscript(order: CustodyOrder, custodianId: string, feeBps = 250) { + const policy = { coinbaseMaturity: 2, lockDepth: 2, outcomeDepth: 3, maxLockBlocks: 30, maxTransactions: 64 }; + const context = saleContext(policy); + const terms = termsForCustodyOrder(order, context, { custodianId, host: host.actor, feeBps, lockUntilHeight: 12, expiresHeight: 20 }); + const authorization = (who: typeof buyer, message: string) => ({ publicKey: who.publicKey, signature: who.sign(message) }); + const lock: Extract = { type: 'sale-lock', terms, + sellerAuthorization: authorization(seller, saleTermsMessage(terms)), buyerAuthorization: authorization(buyer, saleTermsMessage(terms)) }; + const input: SaleRehearsal = { policy, seed: { height: 9, mintedHeight: 1, pageId: 1 }, lock, blocks: [[lock]] }; + const reservationDigest = replayReservedSale(order, custodianId, input, feeBps).reservationDigest; + const reference = { protocol: SALE_CANDIDATE, chainId: context.chainId, saleId: saleId(terms) } as const; + const completion = { ...reference, reservationDigest }; + const complete: SaleTransaction = { type: 'sale-complete', ...completion, hostAuthorization: authorization(host, saleCompletionMessage(completion)) }; + const cancel: SaleTransaction = { type: 'sale-cancel', ...reference, + hostAuthorization: authorization(host, saleCancellationMessage(reference)), buyerAuthorization: authorization(buyer, saleCancellationMessage(reference)) }; + return { input, complete, cancel, reservationDigest }; +} const options = { feeBps: 250, card: (token: string, owner: string, sequence: number) => token === hash && owner === seller.actor && sequence === 0 ? { title: 'PostgreSQL fixture' } : undefined }; function envelope(id: string, who: typeof buyer, action: CustodyAction, nonce: string = randomUUID()): CustodyCommand { @@ -380,3 +414,135 @@ test('imported fixture completion and cancellation states reconcile their exact try { assert.deepEqual(await pg.audit(), expected); } finally { await pg.close(); } }); + +test('sale rehearsal requires explicit disposable instance consent and exact trader-signed reservation terms', async t => { + const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); + await assert.rejects(PostgresCustody.open({ ...f.runtime, database: 'postgres' }, { ...options, disposableSaleRehearsal: true }), /disposable database/); + 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 t1 = saleTranscript(o, f.market.id); + assert.throws(() => f.market.observeSaleRehearsal(o.id, t1.input), /unavailable/); + const rehearsal = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); + try { + const wrong = structuredClone(t1.input); wrong.lock = { ...t1.input.lock, terms: { ...t1.input.lock.terms, priceAtomicXmr: '1' } } as typeof wrong.lock; + await assert.rejects(rehearsal.observeSaleRehearsal(o.id, wrong), /operation failed/); + const locked = await rehearsal.observeSaleRehearsal(o.id, t1.input); + assert.equal(locked.action, 'hold'); assert.equal(locked.reservationDigest, t1.reservationDigest); + await assert.rejects(rehearsal.observeSaleRehearsal(o.id, { ...t1.input, seed: { ...t1.input.seed, pageId: 2 } }), /authorization changed/); + await assert.rejects(rehearsal.observeSaleRehearsal(o.id, { ...t1.input, blocks: Array(129).fill([]) }), /operation failed/); + const cycle: Record = {}; cycle.self = cycle; + await assert.rejects(rehearsal.observeSaleRehearsal(o.id, cycle), /operation failed/); + await assert.rejects(f.execute(buyer, { type: 'cancel-reservation', orderId: o.id }), /authorization exists/); + assert.equal((await f.activity()).purchaseReservedAtomicXmr, o.grossAtomicXmr); + assert.equal((await f.activity(seller)).availableAtomicXmr, '0'); + assert.equal(rehearsal.info().nftSettlement, false); + await rehearsal.audit(); + } finally { await rehearsal.close(); } +}); + +test('signed candidate completion settles actual PostgreSQL balances once across pools and reconnect', 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 transcript = saleTranscript(o, f.market.id); + const a = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); + const b = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); + const mature = { ...transcript.input, blocks: [[transcript.input.lock], [], [transcript.complete], [], []] }; + try { + const immature = { ...mature, blocks: mature.blocks.slice(0, 3) }; + assert.equal((await a.observeSaleRehearsal(o.id, immature)).action, 'hold'); + const results = await Promise.all([a.observeSaleRehearsal(o.id, mature), b.observeSaleRehearsal(o.id, mature)]); + assert.deepEqual(results.map(r => r.action).sort(), ['none', 'settle']); + assert.equal((await f.activity(seller)).availableAtomicXmr, '96500000000'); + assert.equal((await f.activity()).purchaseReservedAtomicXmr, '0'); + const claims = await f.admin.query("SELECT id,balance::text FROM wk_custody.accounts WHERE id LIKE 'author:%' OR id LIKE 'revenue:host:%'"); + assert.deepEqual(claims.rows.map(r => r.balance).sort(), ['1000000000', '2500000000']); + assert.equal((await a.audit()).journals, 3); + const reopened = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); + try { assert.equal((await reopened.observeSaleRehearsal(o.id, mature)).action, 'none'); await reopened.audit(); } + finally { await reopened.close(); } + // Fork replaces completion with valid cancellation. Financial history stays intact and incident is durable. + const fork = { ...mature, blocks: [[transcript.input.lock], [], [transcript.cancel], [], []] }; + await assert.rejects(a.observeSaleRehearsal(o.id, fork), /outcome-reorg/); + await assert.rejects(b.observeSaleRehearsal(o.id, mature), /incident unresolved/); + assert.equal((await f.activity(seller)).availableAtomicXmr, '96500000000'); + assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.journals WHERE kind='mock-sale-settlement'")).rows[0].n, 1); + await assert.rejects(PostgresCustody.open(f.runtime, options), /unresolved-integrity/); + } finally { await a.close(); await b.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 }); + try { + for (const withheld of [false, true]) { + const l = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '1000', 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); + const transcript = { ...s.input, blocks: withheld ? [[], [], [], [], []] : [[s.input.lock], [s.cancel], []] }; + assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'hold'); + transcript.blocks.push([]); + assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'refund'); + assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'none'); + assert.equal((await f.activity()).availableAtomicXmr, '1000000000000'); + await rehearsal.audit(); + } + } finally { await rehearsal.close(); } +}); + +test('rehearsal rejects a different fee rate even when tiny-order commission rounding is identical', 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: '1000', 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, 251); + assert.throws(() => replayReservedSale(o, f.market.id, s.input, l.feeBps), /fee rate/); + const rehearsal = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); + try { + await assert.rejects(rehearsal.observeSaleRehearsal(o.id, s.input), /operation failed/); + assert.equal((await f.activity()).purchaseReservedAtomicXmr, '1000'); + assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'sale-rehearsal-%'")).rows[0].n, 0); + } finally { await rehearsal.close(); } +}); + +test('rehearsal preserves one-atomic sales and the signed custom host recipient across policy changes', async t => { + const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); + const recipient = await syntheticFeeAddress(); + const custom = await PostgresCustody.open(f.runtime, { ...options, hostFeeAddress: recipient }); + const rehearsal = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); + try { + for (const price of ['1', '1000']) { + const l = await custom.execute(envelope(custom.id, seller, { type: 'list', cardHash: hash, sequence: 0, + priceAtomicXmr: price, feeBps: 250, hostFeeAddress: recipient })) as CustodyListing; + const o = await f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: price, feeBps: 250, hostFeeAddress: recipient }) as CustodyOrder; + const s = saleTranscript(o, f.market.id); + const changed = structuredClone(s.input); changed.lock.terms.hostFeeAddress = f.market.info().hostFeeAddress; + await assert.rejects(rehearsal.observeSaleRehearsal(o.id, changed), /operation failed/); + const transcript = { ...s.input, blocks: [[s.input.lock], [], [s.complete], [], []] }; + assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'settle'); + assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'none'); + await rehearsal.audit(); + } + assert.equal((await f.activity(seller)).availableAtomicXmr, '966'); + assert.equal((await f.admin.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1', [`revenue:host:${recipient}`])).rows[0].balance, '25'); + assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'sale-rehearsal-observation:%'")).rows[0].n, 2); + } finally { await custom.close(); await rehearsal.close(); } +}); + +test('sale terminal-posting evidence failure rolls back seller credit and remains safely retryable', 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: '1000', 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 rehearsal = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); + try { + await f.admin.query("CREATE FUNCTION wk_custody.fail_terminal() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.key LIKE 'sale-rehearsal-terminal:%' THEN RAISE EXCEPTION 'fixture terminal failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER fail_terminal BEFORE INSERT ON wk_custody.custody_meta FOR EACH ROW EXECUTE FUNCTION wk_custody.fail_terminal()"); + await assert.rejects(rehearsal.observeSaleRehearsal(o.id, transcript), /operation failed/); + assert.equal((await f.activity(seller)).availableAtomicXmr, '0'); + assert.equal((await f.activity()).purchaseReservedAtomicXmr, '1000'); + await f.admin.query('DROP TRIGGER fail_terminal ON wk_custody.custody_meta'); + assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'settle'); + await rehearsal.audit(); + await f.admin.query("UPDATE wk_custody.custody_meta SET value='{}' WHERE key=$1", [`sale-rehearsal-terminal:${o.id}`]); + await assert.rejects(rehearsal.audit(), /evidence-mismatch/); + } finally { await rehearsal.close(); } +}); diff --git a/tests/sale-candidate.test.ts b/tests/sale-candidate.test.ts index 799846a..df0ec72 100755 --- a/tests/sale-candidate.test.ts +++ b/tests/sale-candidate.test.ts @@ -11,6 +11,7 @@ import { applySaleBlock, initialSaleState, saleContext, saleDecision, saleId, sa type SaleState, type CandidateTransaction } from '../server/testnet/sale-candidate.ts'; import { address, transactionSchema, CHAIN_ID } from '../server/testnet/consensus.ts'; import { CustodyMarket } from '../server/market/custody.ts'; +import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts'; import { CUSTODY_POLICY, custodyMessage, type CustodyCommand, type CustodyAction, type CustodyOrder, type CustodyListing } from '../shared/custody.ts'; function identity() { @@ -99,6 +100,18 @@ test('host cannot redirect delivery or alter signed reservation evidence, and co assert.equal(saleDecision(at(done, 14), f.terms, context, 'settled').action, 'none'); }); +test('candidate host recipient extension is signed and omission preserves historical terms bytes', () => { + const legacy = fixture(), t = legacy.terms; + assert.equal(saleTermsMessage(t), JSON.stringify([SALE_CANDIDATE, 'terms', t.network, t.chainId, t.policyDigest, + t.custodianId, t.orderId, t.cardHash, t.sequence, t.seller, t.buyer, t.host, 'XMR', t.priceAtomicXmr, + t.feeBps, ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address, t.lockUntilHeight, t.expiresHeight])); + const custom = fixture({ hostFeeAddress: '8'.repeat(95) }); // Syntax-only candidate, no wallet/RPC claim. + assert.notEqual(saleId(custom.terms), saleId({ ...custom.terms, hostFeeAddress: undefined })); + assert.equal(apply(custom.state, [custom.lock]).sales[saleId(custom.terms)].terms.hostFeeAddress, '8'.repeat(95)); + assert.throws(() => apply(custom.state, [{ ...custom.lock, terms: { ...custom.terms, hostFeeAddress: ADDITIONAL_MARKET_FEE.address } }]), /both trader/); + assert.throws(() => apply(custom.state, [{ ...custom.lock, terms: { ...custom.terms, hostFeeAddress: undefined } }]), /both trader/); +}); + test('locked cards cannot escape through free transfers or any leg of an atomic trade', () => { const f = fixture(), locked = apply(f.state, [f.lock]); assert.throws(() => apply(locked, [gift(1)]), /locked/); @@ -206,7 +219,7 @@ test('candidate drafts from real sandbox reservations without signing, changing assert.equal(market.info().nftSettlement, false); assert.throws(() => termsForCustodyOrder({ ...order, authorAtomicXmr: '1' }, context, options), /allocations/); assert.throws(() => termsForCustodyOrder({ ...order, status: 'cancelled' }, context, options), /awaiting-lock/); - assert.throws(() => termsForCustodyOrder({ ...order, hostFeeAddress: 'different-recipient' }, context, options), /terms extension/); + assert.throws(() => termsForCustodyOrder({ ...order, hostFeeAddress: 'different-recipient' }, context, options)); }); test('enumerated adversarial histories preserve exclusivity and deterministic replay across snapshot restores', () => {