Reviewed release and reorg-hold resolution
This commit is contained in:
@@ -20,7 +20,7 @@ The operator has authorized proceeding with implementation instead of the previo
|
||||
|
||||
**Monero observation and deposit-address allocation groundwork:** [server/market/monero-observer.ts](../server/market/monero-observer.ts) keeps the observer strictly read-only, with bounded requests, strict HTTPS/literal-loopback-only HTTP, no redirects, bounded responses/deadlines, JSON-RPC ID validation and lossless integer handling. A separate, narrow `createMoneroDepositAddressRpc()` capability verifies a mainnet primary address with `get_address` plus `validate_address`, then derives a domain-separated fingerprint. It can inspect one indexed subaddress or request exactly one labeled subaddress; it exposes no transfer, relay, sweep or withdrawal operation. The allocator [server/market/monero-deposit-allocation-store.ts](../server/market/monero-deposit-allocation-store.ts) uses PostgreSQL v3 beneficiary/index reservations: it reserves first, serializes cooperating allocators per wallet/account, checks the live wallet fingerprint and exact reserved index, and recovers a lost `create_address` response by looking up the deterministic label before retrying. Actual v0.18.5.4 testing confirmed `get_wallet_info` is unavailable and an unused address index returns RPC error `-15` (“address index is out of bound”); the client recognizes only that precise unused-index response. Watch-only provisioning remains a deployment requirement. PostgreSQL v4 now records a per-output public key and global index while preserving historical observations without a creditable output identity. The observer joins transaction-level `get_transfers` metadata to bounded `incoming_transfers` output records and identifies each output as `txid:pubkey`; the gateway allows only bounded read parameters. The source-contract parser and fixtures were updated, and the actual v0.18.5.4 Stagenet rehearsal confirmed the gateway permits `incoming_transfers`; for an empty address the wallet emits `{}` without default empty fields, which the parser handles as an empty array. The offline, unfunded rehearsal did not exercise non-empty owned output parsing, so that remains fixture-tested. The app does not start the gateway, observer, or allocator; no output assessment/credit journal, customer deposit API, withdrawal worker or sale-settlement path is wired. No financial journal can be posted by the Monero observer or allocator. This is integration groundwork, not a live payment path.
|
||||
|
||||
**Reviewed pending-credit stage:** an isolated coordinator can stage a signed, reviewer-approved observation into a distinct non-spendable pending liability; it requires an explicitly provisioned coordinator DB role and is not exposed through API/startup. It does not prove treasury backing, release funds to `available`, or provide a path to reverse or resolve reorg holds. The pending credit/reorg tests run only in disposable PostgreSQL; live deposits, customer credit availability, withdrawals, and sale settlement remain unimplemented.
|
||||
**Reviewed pending-deposit stage:** an isolated coordinator can stage an operator-reviewed observation into a distinct non-spendable pending liability, then perform a second signed review to release it into `available`. Both steps use the immutable output identity and beneficiary allocation. Reorg observations create output-scoped holds; new purchases and withdrawals for that beneficiary stop until the output is re-observed as present, unlocked, sufficiently confirmed, and an allow-listed reviewer signs a resolution. Hold resolutions and release journals are append-only; no automatic journal reversal occurs. These functions are not exposed through HTTP or wired to startup, and the required reviewer keys are not provisioned. This is sandbox accounting machinery, not proof of treasury backing or permission to accept live deposits. Automatic scanning/hold operations, production deposit observation, withdrawals, and sale settlement remain unimplemented.
|
||||
|
||||
## 2. Identity and trust boundaries
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Use **PostgreSQL** for financial state. Exact arithmetic, transactional constrai
|
||||
|
||||
This is an integrated backend, not a second disconnected balance demo: [custody storage selection](../server/market/custody-storage.ts) supplies [the existing HTTP custody routes](../server/testnet/http.ts) with [PostgreSQL custody](../server/market/postgres-custody.ts). Signed command domains, user identities, amounts and UI stay unchanged. SQLite remains the default disposable/local sandbox for compatibility; choose PostgreSQL explicitly. Chain DAG/canonical indexes and legacy mock marketplace remain in their original SQLite databases. Moving those independent concerns is not needed to use PostgreSQL for customer liabilities.
|
||||
|
||||
**All balances still represent fictitious sandbox XMR.** Monero observer and subaddress allocation groundwork are implemented separately in additive custody migrations v2-v4, but remain unwired and unavailable to public custody routes. Schema v5 adds an unexposed reviewed pending-deposit coordinator with its own role, immutable credit evidence, and scoped reorg holds. It can post only to a non-spendable pending-liability account; no release-to-available path, wallet worker, real treasury backing, live signer, real-address withdrawal, NFT sale witness or production payout is introduced. The role is provisioned explicitly using `grant-monero-credit-runtime ROLE`; no credentials or reviewer keys are created by the CLI. No mainnet funds are activated.
|
||||
**All balances still represent fictitious sandbox XMR.** Monero observer and subaddress allocation groundwork are implemented separately in additive custody migrations v2-v4, but remain unwired and unavailable to public custody routes. Schema v5 adds an unexposed reviewed pending-deposit coordinator with its own role and immutable credit evidence; v6/v7 add append-only, reviewer-authorized release and reorg-resolution records. A staged credit remains non-spendable until a separate signed review moves it to `available`; detected reorg holds block new spending for that beneficiary until the output is freshly re-observed as present, unlocked, sufficiently confirmed and separately reviewed. There is no API/startup integration, automatic scan/reorg worker, production treasury backing, live signer, real-address withdrawal, NFT sale witness or payout. The role is provisioned explicitly using `grant-monero-credit-runtime ROLE`; no credentials or reviewer keys are created by the CLI. No mainnet funds are activated.
|
||||
|
||||
## Storage and transaction guarantees
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Status: active engineering plan; **not launch or payment authorization**. Update
|
||||
|
||||
**Output failure/reorg test results (2026-10-21):** offline coverage now rejects missing outputs, duplicate public keys, subaddress misattribution, and malformed output records. PostgreSQL coverage follows a stable output identity through not-seen, reappeared, and changed-height re-inclusion states; it rejects output-key reuse across transaction IDs and confirms zero journal postings throughout. The image build passed; offline suite **189/189 passed** and disposable PostgreSQL suite **35/35 passed**. This exercises persistence and review signals only—no customer credit or automatic reversal exists. The actual Stagenet rehearsal against v0.18.5.4 confirms the gateway permits `incoming_transfers`; for a fresh empty address, the RPC returns an empty result object with the default `transfers` and `status` fields omitted. The parser now treats absent `transfers` as empty; no real owned output was available in the offline, unfunded rehearsal, so nonempty RPC output parsing remains fixture-tested.
|
||||
|
||||
**Reviewed pending-deposit ledger stage (2026-10-21):** schema v5 adds immutable per-output credit records and append-only observation-event holds. A separate, non-HTTP coordinator verifies the registered beneficiary allocation, observation freshness/unlock/confirmation/double-spend state, and a short-lived signature from an allow-listed P-256 reviewer; it posts a balanced journal only into a distinct non-spendable `deposit-pending` liability. Concurrent retries serialize and return the original credit; the financial audit checks the saved review evidence and journal. Reorg/disappearance/re-inclusion signals append scoped holds without reversing journals or making funds available. A dedicated role is explicitly provisioned using `grant-monero-credit-runtime`; it remains separate from observer and custody-runtime roles. This is an isolated sandbox ledger path, not proof of treasury backing: the coordinator is not wired to startup/routes, no reviewer key is provisioned, and there is no pending-to-available release, public deposit API, continuous scan worker, withdrawal sender, or live sale settlement. Validation: test image build passed; offline suite **190/190 passed**; disposable PostgreSQL integration suite **36/36 passed**. The disposable database fixture was removed.
|
||||
**Reviewed pending-deposit ledger stage (2026-10-21):** schema v5 adds immutable per-output credit records and append-only observation-event holds; schema v6 adds append-only reviewer-signed release and hold-resolution records; v7 permits one signed release to resolve multiple holds while retaining one-time hold identity. A separate, non-HTTP coordinator verifies registered beneficiary allocation, observation freshness/unlock/confirmation/double-spend state, and an allow-listed P-256 reviewer signature. It can first stage an output into a non-spendable `deposit-pending` liability, then separately release that amount into `available` under another signed review bound to the current output and every unresolved hold. Reorg/disappearance/re-inclusion events create scoped holds; new purchases and withdrawals for that beneficiary are blocked while any remain unresolved. Holds can be resolved only after the output is again observed, unlocked, sufficiently confirmed, and signed off; journals are never reversed automatically. The dedicated coordinator DB role is explicitly provisioned using `grant-monero-credit-runtime` and remains separate from observer and custody-runtime roles. This remains isolated sandbox ledger machinery, not proof of treasury backing: no API/startup integration, reviewer key provisioning, continuous scan/hold worker, production deposit activation, withdrawal signer/worker, or live sale settlement exists. Validation: test image build passed; offline suite **190/190 passed**; disposable PostgreSQL integration suite **36/36 passed**. Tests cover schema upgrades, pending-credit review, concurrent duplicate release/reorg resolution, scoped spending holds, and cleanup of the disposable fixture. No external network or funds used.
|
||||
|
||||
## 1A. MVP release boundary — proposed safe first production-hosted release
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import { allocateXmrSale } from '../../shared/xmr-pricing.ts';
|
||||
import { validateXmrAddress } from './xmr.ts';
|
||||
import { replayReservedSale } from './sale-rehearsal.ts';
|
||||
import { address, verifySignature } from '../testnet/consensus.ts';
|
||||
import { depositCreditApprovalMessage, type DepositCreditReviewSubject } from './monero-deposit-credit.ts';
|
||||
import { depositCreditApprovalMessage, depositCreditReleaseApprovalMessage,
|
||||
depositReorgResolutionMessage, type DepositCreditReleaseSubject, type DepositCreditReviewSubject,
|
||||
type DepositReorgResolutionSubject } from './monero-deposit-credit.ts';
|
||||
|
||||
/** Codes only: never attach customer IDs, payloads, SQL errors or addresses to operational diagnostics. */
|
||||
export class CustodyIntegrityError extends Error {
|
||||
@@ -177,7 +179,11 @@ type DepositCreditRow = { credit_id: string; wallet_fingerprint: string; observa
|
||||
beneficiary: string; amount_atomic_xmr: string; journal_id: string; last_observation_event_sequence: string;
|
||||
observation_digest: string; reviewer_owner: string; reviewer_public_key: string; review_nonce: string;
|
||||
review_expires_at: string; review_signature: string; review_subject: DepositCreditReviewSubject; credited_at: string;
|
||||
review_digest: string; minimum_confirmations: number; balance: string | null; normal: string | null };
|
||||
review_digest: string; minimum_confirmations: number; balance: string | null; normal: string | null;
|
||||
release_id: string | null; release_journal_id: string | null; release_reviewer_owner: string | null;
|
||||
release_reviewer_public_key: string | null; release_review_nonce: string | null; release_review_expires_at: string | null;
|
||||
release_review_signature: string | null; release_review_subject: DepositCreditReleaseSubject | null;
|
||||
release_review_digest: string | null; released_at: string | null };
|
||||
async function checkDepositCredits(client: PoolClient, budget: () => void) {
|
||||
let after: string | null = null;
|
||||
for (;;) {
|
||||
@@ -186,10 +192,15 @@ async function checkDepositCredits(client: PoolClient, budget: () => void) {
|
||||
c.allocation_id::text,c.beneficiary,c.amount_atomic_xmr::text,c.journal_id,c.last_observation_event_sequence::text,
|
||||
c.observation_digest,c.reviewer_owner,c.reviewer_public_key,c.review_nonce,c.review_expires_at::text,
|
||||
c.review_signature,c.review_subject,c.review_digest,c.credited_at::text,c.minimum_confirmations,
|
||||
a.balance::text,a.normal FROM wk_custody.monero_deposit_credit_events c
|
||||
a.balance::text,a.normal,r.release_id::text,r.journal_id AS release_journal_id,r.reviewer_owner AS release_reviewer_owner,
|
||||
r.reviewer_public_key AS release_reviewer_public_key,r.review_nonce::text AS release_review_nonce,
|
||||
r.review_expires_at::text AS release_review_expires_at,r.review_signature AS release_review_signature,
|
||||
r.review_subject AS release_review_subject,r.review_digest AS release_review_digest,r.released_at::text
|
||||
FROM wk_custody.monero_deposit_credit_events c
|
||||
LEFT JOIN wk_custody.accounts a ON a.id='customer:'||c.beneficiary||':deposit-pending:'||c.credit_id::text
|
||||
LEFT JOIN wk_custody.monero_deposit_release_events r ON r.credit_id=c.credit_id
|
||||
WHERE ($1::uuid IS NULL OR c.credit_id>$1::uuid) ORDER BY c.credit_id LIMIT 128`, [after]);
|
||||
const postings = await journals(client, result.rows.map(row => row.journal_id));
|
||||
const postings = await journals(client, result.rows.flatMap(row => [row.journal_id, ...(row.release_journal_id ? [row.release_journal_id] : [])]));
|
||||
for (const row of result.rows) {
|
||||
let approvalIsValid = false;
|
||||
let subjectIsValid = false;
|
||||
@@ -210,11 +221,78 @@ async function checkDepositCredits(client: PoolClient, budget: () => void) {
|
||||
row.review_subject.minimumConfirmations === row.minimum_confirmations;
|
||||
} catch { approvalIsValid = false; }
|
||||
if (!subjectIsValid || !approvalIsValid) fail('deposit-credit-observation-mismatch');
|
||||
if (row.normal !== 'credit' || row.balance !== row.amount_atomic_xmr) fail('deposit-pending-liability-mismatch');
|
||||
const released = row.release_id !== null;
|
||||
if (row.normal !== 'credit' || row.balance !== (released ? '0' : row.amount_atomic_xmr)) fail('deposit-pending-liability-mismatch');
|
||||
expectJournal(postings, row.journal_id, 'monero-deposit-pending-credit', [
|
||||
d('treasury:monero:pending', row.amount_atomic_xmr),
|
||||
c(`customer:${row.beneficiary}:deposit-pending:${row.credit_id}`, row.amount_atomic_xmr),
|
||||
]);
|
||||
if (released) {
|
||||
let releaseValid = false;
|
||||
try {
|
||||
const releaseMessage = depositCreditReleaseApprovalMessage(row.release_review_subject!, row.release_reviewer_owner!,
|
||||
row.release_review_nonce!, Number(row.release_review_expires_at));
|
||||
const releaseSubject = row.release_review_subject!;
|
||||
releaseValid = address(row.release_reviewer_public_key!) === row.release_reviewer_owner &&
|
||||
createHash('sha256').update(releaseMessage).digest('hex') === row.release_review_digest &&
|
||||
Number(row.release_review_expires_at) >= Number(row.released_at) &&
|
||||
verifySignature(row.release_reviewer_public_key!, row.release_reviewer_owner!, releaseMessage, row.release_review_signature!) &&
|
||||
releaseSubject.creditId === row.credit_id && releaseSubject.walletFingerprint === row.wallet_fingerprint &&
|
||||
releaseSubject.observationId === row.observation_id && releaseSubject.beneficiary === row.beneficiary &&
|
||||
releaseSubject.amountAtomicXmr === row.amount_atomic_xmr && releaseSubject.allocationId === row.allocation_id;
|
||||
} catch { releaseValid = false; }
|
||||
if (!releaseValid) fail('deposit-release-review-invalid');
|
||||
expectJournal(postings, row.release_journal_id!, 'monero-deposit-release', [
|
||||
d(`customer:${row.beneficiary}:deposit-pending:${row.credit_id}`, row.amount_atomic_xmr),
|
||||
c(`customer:${row.beneficiary}:available`, row.amount_atomic_xmr),
|
||||
]);
|
||||
const resolutions = await client.query(`SELECT r.resolution_id::text,r.hold_id::text,r.release_id::text,r.resolved_by,r.resolved_at::text,
|
||||
r.disposition,r.resolution_kind,r.reviewer_public_key,r.review_nonce::text,r.review_expires_at::text,
|
||||
r.review_signature,r.review_subject,r.review_digest,h.observation_event_sequence::text AS hold_event_sequence,
|
||||
h.event_type AS hold_event_type
|
||||
FROM wk_custody.monero_deposit_reorg_resolutions r
|
||||
JOIN wk_custody.monero_deposit_reorg_holds h ON h.hold_id=r.hold_id WHERE r.credit_id=$1`, [row.credit_id]);
|
||||
const approvedHoldIds = [...(row.release_review_subject?.unresolvedHolds ?? [])].map(hold => hold.holdId).sort();
|
||||
const releaseResolutions = resolutions.rows.filter(item => item.resolution_kind === 'release-review');
|
||||
const releaseHolds = await client.query(`SELECT h.hold_id::text,h.observation_event_sequence::text,h.event_type
|
||||
FROM wk_custody.monero_deposit_reorg_holds h JOIN wk_custody.monero_deposit_reorg_resolutions r
|
||||
ON r.hold_id=h.hold_id AND r.resolution_kind='release-review'
|
||||
WHERE h.credit_id=$1 AND r.release_id=$2 ORDER BY h.observation_event_sequence`, [row.credit_id, row.release_id]);
|
||||
const signedHoldFacts = [...(row.release_review_subject?.unresolvedHolds ?? [])]
|
||||
.map(hold => JSON.stringify([hold.holdId, hold.eventSequence, hold.eventType])).sort();
|
||||
const persistedHoldFacts = releaseHolds.rows.map(hold => JSON.stringify([
|
||||
hold.hold_id, Number(hold.observation_event_sequence), hold.event_type])).sort();
|
||||
if (releaseResolutions.some(item => item.release_id !== row.release_id || item.resolved_by !== row.release_reviewer_owner ||
|
||||
item.reviewer_public_key !== row.release_reviewer_public_key || item.review_nonce !== row.release_review_nonce ||
|
||||
item.review_expires_at !== row.release_review_expires_at || item.review_signature !== row.release_review_signature ||
|
||||
item.review_digest !== row.release_review_digest || item.disposition !== 'revalidated-for-release') ||
|
||||
releaseResolutions.map(item => item.hold_id).sort().join(',') !== approvedHoldIds.join(',') ||
|
||||
releaseHolds.rows.length !== signedHoldFacts.length || signedHoldFacts.join(',') !== persistedHoldFacts.join(',')) {
|
||||
fail('deposit-reorg-resolution-invalid');
|
||||
}
|
||||
for (const resolution of resolutions.rows.filter(item => item.resolution_kind === 'post-release-review')) {
|
||||
let resolutionValid = false;
|
||||
try {
|
||||
const subject = resolution.review_subject as DepositReorgResolutionSubject;
|
||||
const resolutionMessage = depositReorgResolutionMessage(subject, resolution.resolved_by,
|
||||
resolution.review_nonce, Number(resolution.review_expires_at));
|
||||
resolutionValid = resolution.release_id === row.release_id && resolution.disposition === 'revalidated-for-release' &&
|
||||
subject.creditId === row.credit_id && subject.releaseId === row.release_id && subject.holdId === resolution.hold_id &&
|
||||
subject.eventSequence === Number(resolution.hold_event_sequence) && subject.eventType === resolution.hold_event_type &&
|
||||
subject.walletFingerprint === row.wallet_fingerprint && subject.observationId === row.observation_id &&
|
||||
subject.currentObservation.beneficiary === row.beneficiary &&
|
||||
subject.currentObservation.amountAtomicXmr === row.amount_atomic_xmr &&
|
||||
address(resolution.reviewer_public_key) === resolution.resolved_by &&
|
||||
createHash('sha256').update(resolutionMessage).digest('hex') === resolution.review_digest &&
|
||||
Number(resolution.review_expires_at) >= Number(resolution.resolved_at) &&
|
||||
verifySignature(resolution.reviewer_public_key, resolution.resolved_by, resolutionMessage, resolution.review_signature);
|
||||
} catch { resolutionValid = false; }
|
||||
if (!resolutionValid) fail('deposit-reorg-resolution-invalid');
|
||||
}
|
||||
if (resolutions.rows.some(item => !['release-review','post-release-review'].includes(item.resolution_kind))) {
|
||||
fail('deposit-reorg-resolution-invalid');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.rows.length < 128) break;
|
||||
after = result.rows.at(-1)!.credit_id;
|
||||
@@ -227,6 +305,14 @@ async function checkDepositCredits(client: PoolClient, budget: () => void) {
|
||||
const orphanJournal = await client.query(`SELECT 1 FROM wk_custody.journals j WHERE j.kind='monero-deposit-pending-credit'
|
||||
AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_deposit_credit_events c WHERE c.journal_id=j.id) LIMIT 1`);
|
||||
if (orphanJournal.rowCount) fail('orphan-deposit-credit-journal');
|
||||
const orphanRelease = await client.query(`SELECT 1 FROM wk_custody.journals j WHERE j.kind='monero-deposit-release'
|
||||
AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_deposit_release_events r WHERE r.journal_id=j.id) LIMIT 1`);
|
||||
if (orphanRelease.rowCount) fail('orphan-deposit-release-journal');
|
||||
const orphanResolution = await client.query(`SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions r
|
||||
LEFT JOIN wk_custody.monero_deposit_reorg_holds h ON h.hold_id=r.hold_id
|
||||
LEFT JOIN wk_custody.monero_deposit_release_events e ON e.release_id=r.release_id
|
||||
WHERE h.hold_id IS NULL OR e.release_id IS NULL OR h.credit_id<>r.credit_id OR e.credit_id<>r.credit_id LIMIT 1`);
|
||||
if (orphanResolution.rowCount) fail('orphan-deposit-reorg-resolution');
|
||||
const invalidHold = await client.query(`SELECT 1 FROM wk_custody.monero_deposit_reorg_holds h
|
||||
JOIN wk_custody.monero_deposit_credit_events c ON c.credit_id=h.credit_id
|
||||
WHERE h.wallet_fingerprint<>c.wallet_fingerprint OR h.observation_id<>c.observation_id OR
|
||||
|
||||
@@ -48,6 +48,40 @@ export type DepositCreditApproval = {
|
||||
signature: string;
|
||||
};
|
||||
export type DepositCreditResult = { creditId: string; journalId: string; beneficiary: string; amountAtomicXmr: string; replayed: boolean };
|
||||
export type DepositCreditReleaseSubject = DepositCreditReviewSubject & {
|
||||
creditId: string;
|
||||
pendingJournalId: string;
|
||||
unresolvedHolds: { holdId: string; eventSequence: number; eventType: 'changed' | 'not-seen' | 'reappeared' }[];
|
||||
};
|
||||
export type DepositCreditReleaseApproval = {
|
||||
subject: DepositCreditReleaseSubject;
|
||||
reviewerOwner: string;
|
||||
reviewerPublicKey: string;
|
||||
nonce: string;
|
||||
expiresAt: number;
|
||||
signature: string;
|
||||
};
|
||||
export type DepositCreditReleaseResult = { creditId: string; releaseId: string; journalId: string; beneficiary: string; amountAtomicXmr: string; replayed: boolean };
|
||||
export type DepositReorgResolutionSubject = {
|
||||
policyVersion: 'monero-reorg-resolution-v1';
|
||||
creditId: string;
|
||||
releaseId: string;
|
||||
holdId: string;
|
||||
walletFingerprint: string;
|
||||
observationId: string;
|
||||
eventSequence: number;
|
||||
eventType: 'changed' | 'not-seen' | 'reappeared';
|
||||
currentObservation: DepositCreditReviewSubject;
|
||||
};
|
||||
export type DepositReorgResolutionApproval = {
|
||||
subject: DepositReorgResolutionSubject;
|
||||
reviewerOwner: string;
|
||||
reviewerPublicKey: string;
|
||||
nonce: string;
|
||||
expiresAt: number;
|
||||
signature: string;
|
||||
};
|
||||
export type DepositReorgResolutionResult = { resolutionId: string; holdId: string; replayed: boolean };
|
||||
|
||||
const subjectSchema = z.object({
|
||||
policyVersion: z.literal(POLICY_VERSION), walletFingerprint: fingerprint, observationId: z.string().min(129).max(160),
|
||||
@@ -60,6 +94,17 @@ const subjectSchema = z.object({
|
||||
}).strict();
|
||||
const approvalSchema = z.object({ subject: subjectSchema, reviewerOwner: hex, reviewerPublicKey: publicKey,
|
||||
nonce: uuid, expiresAt: z.number().int().nonnegative().safe(), signature }).strict();
|
||||
const releaseSubjectSchema = subjectSchema.extend({ creditId: uuid, pendingJournalId: z.string().min(1).max(256),
|
||||
unresolvedHolds: z.array(z.object({ holdId: uuid, eventSequence: z.number().int().positive().safe(),
|
||||
eventType: z.enum(['changed', 'not-seen', 'reappeared']) }).strict()).max(1024) }).strict();
|
||||
const releaseApprovalSchema = z.object({ subject: releaseSubjectSchema, reviewerOwner: hex, reviewerPublicKey: publicKey,
|
||||
nonce: uuid, expiresAt: z.number().int().nonnegative().safe(), signature }).strict();
|
||||
const resolutionSubjectSchema = z.object({ policyVersion: z.literal('monero-reorg-resolution-v1'), creditId: uuid,
|
||||
releaseId: uuid, holdId: uuid, walletFingerprint: fingerprint, observationId: z.string().min(129).max(160),
|
||||
eventSequence: z.number().int().positive().safe(), eventType: z.enum(['changed','not-seen','reappeared']),
|
||||
currentObservation: subjectSchema }).strict();
|
||||
const resolutionApprovalSchema = z.object({ subject: resolutionSubjectSchema, reviewerOwner: hex,
|
||||
reviewerPublicKey: publicKey, nonce: uuid, expiresAt: z.number().int().nonnegative().safe(), signature }).strict();
|
||||
|
||||
function digest(value: string): string { return createHash('sha256').update(value).digest('hex'); }
|
||||
function canonicalReviewMessage(subject: DepositCreditReviewSubject, reviewerOwner: string, nonce: string, expiresAt: number): string {
|
||||
@@ -70,6 +115,24 @@ function canonicalReviewMessage(subject: DepositCreditReviewSubject, reviewerOwn
|
||||
subject.observationDigest, subject.observationEventSequence, subject.observationEventHistoryDigest,
|
||||
subject.observerUpdatedAt]);
|
||||
}
|
||||
function canonicalReleaseMessage(subject: DepositCreditReleaseSubject, reviewerOwner: string, nonce: string, expiresAt: number): string {
|
||||
return JSON.stringify(['wikideck-monero-pending-release-v1', reviewerOwner, nonce, expiresAt,
|
||||
subject.policyVersion, subject.creditId, subject.pendingJournalId, subject.walletFingerprint, subject.observationId,
|
||||
subject.outputPublicKey, subject.amountAtomicXmr, subject.blockHeight, subject.confirmations,
|
||||
subject.minimumConfirmations, subject.locked, subject.doubleSpendSeen, subject.allocationId, subject.beneficiary,
|
||||
subject.observationDigest, subject.observationEventSequence, subject.observationEventHistoryDigest,
|
||||
subject.observerUpdatedAt, subject.unresolvedHolds.map(hold => [hold.holdId, hold.eventSequence, hold.eventType])]);
|
||||
}
|
||||
function canonicalResolutionMessage(subject: DepositReorgResolutionSubject, reviewerOwner: string, nonce: string, expiresAt: number): string {
|
||||
const observation = subject.currentObservation;
|
||||
return JSON.stringify(['wikideck-monero-reorg-resolution-v1', reviewerOwner, nonce, expiresAt,
|
||||
subject.policyVersion, subject.creditId, subject.releaseId, subject.holdId, subject.walletFingerprint,
|
||||
subject.observationId, subject.eventSequence, subject.eventType, observation.outputPublicKey,
|
||||
observation.amountAtomicXmr, observation.blockHeight, observation.confirmations, observation.minimumConfirmations,
|
||||
observation.locked, observation.doubleSpendSeen, observation.allocationId, observation.beneficiary,
|
||||
observation.observationDigest, observation.observationEventSequence, observation.observationEventHistoryDigest,
|
||||
observation.observerUpdatedAt]);
|
||||
}
|
||||
|
||||
function validatePolicy(policy: DepositCreditPolicy): void {
|
||||
if (!Array.isArray(policy.approvedReviewerOwners) || !policy.approvedReviewerOwners.length ||
|
||||
@@ -81,7 +144,7 @@ function validatePolicy(policy: DepositCreditPolicy): void {
|
||||
}
|
||||
|
||||
async function readReviewSubject(client: PoolClient, walletFingerprint: string, observationId: string,
|
||||
policy: DepositCreditPolicy, now: number): Promise<DepositCreditReviewSubject> {
|
||||
policy: DepositCreditPolicy, now: number, allowReorgHistory = false): Promise<DepositCreditReviewSubject> {
|
||||
const observation = await client.query(`SELECT o.wallet_fingerprint,o.observation_id,o.txid,o.monero_address,o.account_index,
|
||||
o.address_index,o.output_public_key,o.amount_atomic_xmr::text,o.block_height,o.confirmations,o.locked,
|
||||
o.double_spend_seen,o.payload_digest,o.currently_present,s.updated_at AS observer_updated_at
|
||||
@@ -117,7 +180,7 @@ async function readReviewSubject(client: PoolClient, walletFingerprint: string,
|
||||
FROM wk_custody.monero_observation_events WHERE wallet_fingerprint=$1 AND observation_id=$2
|
||||
ORDER BY sequence LIMIT $3`, [walletFingerprint, observationId, MAX_EVENT_HISTORY + 1]);
|
||||
if ((events.rowCount ?? events.rows.length) > MAX_EVENT_HISTORY) throw new Error('Deposit observation event history exceeds review bound');
|
||||
if (events.rows.some(event => event.event_type === 'not-seen' || event.event_type === 'reappeared')) {
|
||||
if (!allowReorgHistory && events.rows.some(event => event.event_type === 'not-seen' || event.event_type === 'reappeared')) {
|
||||
throw new Error('Deposit observation has prior disappearance/reappearance and requires separate review');
|
||||
}
|
||||
const sequence = events.rows.length ? Number(events.rows.at(-1)!.sequence) : 0;
|
||||
@@ -226,6 +289,203 @@ export async function postReviewedDepositPendingCredit(pool: Pool, rawApproval:
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
async function readReleaseSubject(client: PoolClient, creditId: string, policy: DepositCreditPolicy,
|
||||
now: number): Promise<DepositCreditReleaseSubject> {
|
||||
const credit = await client.query(`SELECT credit_id::text,wallet_fingerprint,observation_id,beneficiary,
|
||||
amount_atomic_xmr::text,journal_id FROM wk_custody.monero_deposit_credit_events WHERE credit_id=$1`, [creditId]);
|
||||
if (credit.rowCount !== 1) throw new Error('Pending deposit credit not found');
|
||||
const row = credit.rows[0];
|
||||
if (await client.query('SELECT 1 FROM wk_custody.monero_deposit_release_events WHERE credit_id=$1', [creditId]).then(r => r.rowCount)) {
|
||||
throw new Error('Pending deposit credit was already released');
|
||||
}
|
||||
const observation = await readReviewSubject(client, row.wallet_fingerprint, row.observation_id, policy, now, true);
|
||||
if (observation.beneficiary !== row.beneficiary || observation.amountAtomicXmr !== row.amount_atomic_xmr) {
|
||||
throw new Error('Pending credit no longer matches the current observed output');
|
||||
}
|
||||
const holds = await client.query(`SELECT h.hold_id::text,h.observation_event_sequence::text,h.event_type
|
||||
FROM wk_custody.monero_deposit_reorg_holds h
|
||||
WHERE h.credit_id=$1 AND NOT EXISTS (
|
||||
SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions r WHERE r.hold_id=h.hold_id)
|
||||
ORDER BY h.observation_event_sequence LIMIT 1025`, [creditId]);
|
||||
if (holds.rows.length > 1024) throw new Error('Deposit reorg hold set exceeds review bound');
|
||||
return { ...observation, creditId, pendingJournalId: row.journal_id,
|
||||
unresolvedHolds: holds.rows.map(hold => ({ holdId: hold.hold_id,
|
||||
eventSequence: Number(hold.observation_event_sequence), eventType: hold.event_type })) };
|
||||
}
|
||||
|
||||
/** Return the current pending liability and unresolved reorg evidence for explicit reviewer approval. */
|
||||
export async function prepareDepositCreditReleaseReview(pool: Pool, creditId: string,
|
||||
policy: DepositCreditPolicy, now = Date.now()): Promise<DepositCreditReleaseSubject> {
|
||||
validatePolicy(policy); uuid.parse(creditId);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'");
|
||||
const subject = await readReleaseSubject(client, creditId, policy, now);
|
||||
await client.query('COMMIT'); return subject;
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
/** Canonical reviewer message for releasing a pending liability and resolving its included scoped holds. */
|
||||
export function depositCreditReleaseApprovalMessage(subject: DepositCreditReleaseSubject, reviewerOwner: string,
|
||||
nonce: string, expiresAt: number): string {
|
||||
return canonicalReleaseMessage(releaseSubjectSchema.parse(subject), hex.parse(reviewerOwner), uuid.parse(nonce), expiresAt);
|
||||
}
|
||||
|
||||
/** Explicit reviewer-authorized, idempotent move from pending to spendable available balance. Not HTTP-wired. */
|
||||
export async function releaseReviewedDepositCredit(pool: Pool, rawApproval: DepositCreditReleaseApproval,
|
||||
policy: DepositCreditPolicy, now = Date.now()): Promise<DepositCreditReleaseResult> {
|
||||
validatePolicy(policy);
|
||||
const approval = releaseApprovalSchema.parse(rawApproval);
|
||||
const message = canonicalReleaseMessage(approval.subject, approval.reviewerOwner, approval.nonce, approval.expiresAt);
|
||||
if (!policy.approvedReviewerOwners.includes(approval.reviewerOwner) ||
|
||||
address(approval.reviewerPublicKey) !== approval.reviewerOwner ||
|
||||
!verifySignature(approval.reviewerPublicKey, approval.reviewerOwner, message, approval.signature)) {
|
||||
throw new Error('Deposit release review signature is invalid');
|
||||
}
|
||||
const reviewDigest = digest(message);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'; SET LOCAL synchronous_commit=on");
|
||||
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
const existing = await client.query(`SELECT release_id,credit_id::text,journal_id,reviewer_owner,review_nonce,
|
||||
review_signature,review_digest,review_subject,released_at FROM wk_custody.monero_deposit_release_events
|
||||
WHERE credit_id=$1`, [approval.subject.creditId]);
|
||||
if (existing.rowCount) {
|
||||
const saved = existing.rows[0];
|
||||
if (saved.reviewer_owner !== approval.reviewerOwner || saved.review_nonce !== approval.nonce ||
|
||||
saved.review_signature !== approval.signature || saved.review_digest !== reviewDigest) {
|
||||
throw new Error('Deposit release was already committed under different reviewed terms');
|
||||
}
|
||||
const credit = await client.query(`SELECT beneficiary,amount_atomic_xmr::text FROM wk_custody.monero_deposit_credit_events
|
||||
WHERE credit_id=$1`, [saved.credit_id]);
|
||||
await client.query('COMMIT');
|
||||
return { creditId: saved.credit_id, releaseId: saved.release_id, journalId: saved.journal_id,
|
||||
beneficiary: credit.rows[0].beneficiary, amountAtomicXmr: credit.rows[0].amount_atomic_xmr, replayed: true };
|
||||
}
|
||||
if (approval.expiresAt < now || approval.expiresAt > now + 5 * 60_000) {
|
||||
throw new Error('Deposit release review signature is expired or outside the allowed window');
|
||||
}
|
||||
const current = await readReleaseSubject(client, approval.subject.creditId, policy, now);
|
||||
if (JSON.stringify(current) !== JSON.stringify(approval.subject)) throw new Error('Deposit release evidence changed after reviewer approval');
|
||||
const pendingAccount = `customer:${current.beneficiary}:deposit-pending:${current.creditId}`;
|
||||
const availableAccount = `customer:${current.beneficiary}:available`;
|
||||
const pending = await client.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [pendingAccount]);
|
||||
if (pending.rowCount !== 1 || pending.rows[0].normal !== 'credit') {
|
||||
throw new Error('Pending deposit liability does not match reviewed release amount');
|
||||
}
|
||||
await client.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,\'credit\') ON CONFLICT(id) DO NOTHING', [availableAccount]);
|
||||
const available = await client.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [availableAccount]);
|
||||
if (available.rows[0]?.normal !== 'credit') throw new Error('Available customer account normal mismatch');
|
||||
|
||||
const releaseId = randomUUID(), journalId = `monero-deposit-release:${current.creditId}`;
|
||||
await client.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)',
|
||||
[journalId, 'monero-deposit-release', now]);
|
||||
await client.query('INSERT INTO wk_custody.entries VALUES($1,0,$2,$3,0),($1,1,$4,0,$3)',
|
||||
[journalId, pendingAccount, current.amountAtomicXmr, availableAccount]);
|
||||
await client.query(`INSERT INTO wk_custody.monero_deposit_release_events(
|
||||
release_id,credit_id,journal_id,reviewer_owner,reviewer_public_key,review_nonce,review_expires_at,
|
||||
review_signature,review_subject,review_digest,released_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
|
||||
[releaseId, current.creditId, journalId, approval.reviewerOwner, approval.reviewerPublicKey, approval.nonce,
|
||||
approval.expiresAt, approval.signature, JSON.stringify(current), reviewDigest, now]);
|
||||
for (const hold of current.unresolvedHolds) {
|
||||
await client.query(`INSERT INTO wk_custody.monero_deposit_reorg_resolutions(
|
||||
resolution_id,hold_id,credit_id,release_id,resolved_by,resolved_at,disposition,resolution_kind,
|
||||
reviewer_public_key,review_nonce,review_expires_at,review_signature,review_subject,review_digest)
|
||||
VALUES($1,$2,$3,$4,$5,$6,'revalidated-for-release','release-review',$7,$8,$9,$10,$11,$12)`,
|
||||
[randomUUID(), hold.holdId, current.creditId, releaseId, approval.reviewerOwner, now,
|
||||
approval.reviewerPublicKey, approval.nonce, approval.expiresAt, approval.signature,
|
||||
JSON.stringify({ subject: current, holdId: hold.holdId }), reviewDigest]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return { creditId: current.creditId, releaseId, journalId, beneficiary: current.beneficiary,
|
||||
amountAtomicXmr: current.amountAtomicXmr, replayed: false };
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
async function readReorgResolutionSubject(client: PoolClient, creditId: string, holdId: string,
|
||||
policy: DepositCreditPolicy, now: number): Promise<DepositReorgResolutionSubject> {
|
||||
const row = await client.query(`SELECT h.hold_id::text,h.credit_id::text,h.wallet_fingerprint,h.observation_id,
|
||||
h.observation_event_sequence::text,h.event_type,c.release_id::text
|
||||
FROM wk_custody.monero_deposit_reorg_holds h
|
||||
JOIN wk_custody.monero_deposit_release_events c ON c.credit_id=h.credit_id
|
||||
WHERE h.hold_id=$1 AND h.credit_id=$2 AND NOT EXISTS(
|
||||
SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions r WHERE r.hold_id=h.hold_id)`, [holdId, creditId]);
|
||||
if (row.rowCount !== 1) throw new Error('Unresolved released-deposit reorg hold not found');
|
||||
const currentObservation = await readReviewSubject(client, row.rows[0].wallet_fingerprint,
|
||||
row.rows[0].observation_id, policy, now, true);
|
||||
return resolutionSubjectSchema.parse({ policyVersion: 'monero-reorg-resolution-v1', creditId,
|
||||
releaseId: row.rows[0].release_id, holdId, walletFingerprint: row.rows[0].wallet_fingerprint,
|
||||
observationId: row.rows[0].observation_id, eventSequence: Number(row.rows[0].observation_event_sequence),
|
||||
eventType: row.rows[0].event_type, currentObservation });
|
||||
}
|
||||
|
||||
/** Prepare an exact current snapshot to resolve one scoped reorg hold after the output reappears. */
|
||||
export async function prepareDepositReorgResolution(pool: Pool, creditId: string, holdId: string,
|
||||
policy: DepositCreditPolicy, now = Date.now()): Promise<DepositReorgResolutionSubject> {
|
||||
validatePolicy(policy); uuid.parse(creditId); uuid.parse(holdId);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'");
|
||||
const subject = await readReorgResolutionSubject(client, creditId, holdId, policy, now);
|
||||
await client.query('COMMIT'); return subject;
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
export function depositReorgResolutionMessage(subject: DepositReorgResolutionSubject, reviewerOwner: string,
|
||||
nonce: string, expiresAt: number): string {
|
||||
return canonicalResolutionMessage(resolutionSubjectSchema.parse(subject), hex.parse(reviewerOwner), uuid.parse(nonce), expiresAt);
|
||||
}
|
||||
|
||||
/** Resolve a reorg hold only after the output is again present, unlocked, sufficiently confirmed, and signed off. */
|
||||
export async function resolveReleasedDepositReorgHold(pool: Pool, rawApproval: DepositReorgResolutionApproval,
|
||||
policy: DepositCreditPolicy, now = Date.now()): Promise<DepositReorgResolutionResult> {
|
||||
validatePolicy(policy);
|
||||
const approval = resolutionApprovalSchema.parse(rawApproval);
|
||||
const message = canonicalResolutionMessage(approval.subject, approval.reviewerOwner, approval.nonce, approval.expiresAt);
|
||||
if (!policy.approvedReviewerOwners.includes(approval.reviewerOwner) ||
|
||||
address(approval.reviewerPublicKey) !== approval.reviewerOwner ||
|
||||
!verifySignature(approval.reviewerPublicKey, approval.reviewerOwner, message, approval.signature)) {
|
||||
throw new Error('Deposit reorg resolution signature is invalid');
|
||||
}
|
||||
const reviewDigest = digest(message), client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on");
|
||||
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
|
||||
const existing = await client.query(`SELECT resolution_id::text,review_nonce,review_signature,review_digest
|
||||
FROM wk_custody.monero_deposit_reorg_resolutions WHERE hold_id=$1`, [approval.subject.holdId]);
|
||||
if (existing.rowCount) {
|
||||
const saved = existing.rows[0];
|
||||
if (saved.review_nonce !== approval.nonce || saved.review_signature !== approval.signature || saved.review_digest !== reviewDigest) {
|
||||
throw new Error('Deposit reorg hold was already resolved under different reviewed terms');
|
||||
}
|
||||
await client.query('COMMIT'); return { resolutionId: saved.resolution_id, holdId: approval.subject.holdId, replayed: true };
|
||||
}
|
||||
if (approval.expiresAt < now || approval.expiresAt > now + 5 * 60_000) {
|
||||
throw new Error('Deposit reorg resolution signature is expired or outside the allowed window');
|
||||
}
|
||||
const current = await readReorgResolutionSubject(client, approval.subject.creditId, approval.subject.holdId, policy, now);
|
||||
if (JSON.stringify(current) !== JSON.stringify(approval.subject)) throw new Error('Deposit reorg evidence changed after review');
|
||||
const resolutionId = randomUUID();
|
||||
await client.query(`INSERT INTO wk_custody.monero_deposit_reorg_resolutions(
|
||||
resolution_id,hold_id,credit_id,release_id,resolved_by,resolved_at,disposition,resolution_kind,
|
||||
reviewer_public_key,review_nonce,review_expires_at,review_signature,review_subject,review_digest)
|
||||
VALUES($1,$2,$3,$4,$5,$6,'revalidated-for-release','post-release-review',$7,$8,$9,$10,$11,$12)`,
|
||||
[resolutionId, approval.subject.holdId, approval.subject.creditId, approval.subject.releaseId,
|
||||
approval.reviewerOwner, now, approval.reviewerPublicKey, approval.nonce, approval.expiresAt,
|
||||
approval.signature, JSON.stringify(approval.subject), reviewDigest]);
|
||||
await client.query('COMMIT'); return { resolutionId, holdId: approval.subject.holdId, replayed: false };
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
/** Append review holds for post-credit Monero reorg signals; this never reverses a journal or releases pending funds. */
|
||||
export async function reconcileDepositCreditReorgHolds(pool: Pool, walletFingerprint: string, now = Date.now()): Promise<number> {
|
||||
fingerprint.parse(walletFingerprint);
|
||||
|
||||
@@ -208,6 +208,13 @@ export class PostgresCustody {
|
||||
check(!incident.rowCount, 'Custody integrity incident is unresolved; operator review required');
|
||||
check(!this.diagnostics().mutationHold, 'Custody financial reconciliation requires operator review; balances remain readable and original receipts can be replayed');
|
||||
check(permitsMarketAction(this.options, a.type), 'New market trading is disabled by this host; existing balances and resolution remain available');
|
||||
if (a.type === 'reserve' || a.type === 'withdraw') {
|
||||
const depositHold = await c.query(`SELECT 1 FROM wk_custody.monero_deposit_credit_events d
|
||||
JOIN wk_custody.monero_deposit_reorg_holds h ON h.credit_id=d.credit_id
|
||||
LEFT JOIN wk_custody.monero_deposit_reorg_resolutions r ON r.hold_id=h.hold_id
|
||||
WHERE d.beneficiary=$1 AND r.hold_id IS NULL LIMIT 1`, [cmd.actor]);
|
||||
check(!depositHold.rowCount, 'Customer deposit is under reorg review; new spending is held');
|
||||
}
|
||||
// Sandbox admission budget matches SQLite. Receipt/history are NEVER pruned; cancellation stays available.
|
||||
if (!['cancel-listing', 'cancel-reservation', 'cancel-withdrawal'].includes(a.type)) {
|
||||
const count = await c.query('SELECT count(*)::integer AS n FROM (SELECT 1 FROM wk_custody.receipts LIMIT 10000) b');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
|
||||
export const CUSTODY_PG_VERSION = 5;
|
||||
export const CUSTODY_PG_VERSION = 7;
|
||||
export const CUSTODY_WRITE_LOCK = '7140824361';
|
||||
export const CUSTODY_SCHEMA_V1 = `
|
||||
CREATE SCHEMA IF NOT EXISTS wk_custody;
|
||||
@@ -254,9 +254,61 @@ REVOKE ALL ON FUNCTION wk_custody.monero_deposit_credit_history_immutable() FROM
|
||||
`;
|
||||
export const CUSTODY_SCHEMA_V5_DIGEST = createHash('sha256')
|
||||
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}\n-- custody migration v5 --\n${CUSTODY_MIGRATION_V5}`).digest('hex');
|
||||
export const CUSTODY_MIGRATION_V6 = `
|
||||
CREATE TABLE wk_custody.monero_deposit_release_events(
|
||||
release_id uuid PRIMARY KEY,
|
||||
credit_id uuid NOT NULL UNIQUE REFERENCES wk_custody.monero_deposit_credit_events(credit_id),
|
||||
journal_id text NOT NULL UNIQUE REFERENCES wk_custody.journals(id),
|
||||
reviewer_owner text NOT NULL CHECK(reviewer_owner ~ '^[a-f0-9]{64}$'),
|
||||
reviewer_public_key text NOT NULL CHECK(length(reviewer_public_key) BETWEEN 1 AND 256),
|
||||
review_nonce uuid NOT NULL,
|
||||
review_expires_at bigint NOT NULL CHECK(review_expires_at>=0),
|
||||
review_signature text NOT NULL CHECK(length(review_signature) BETWEEN 1 AND 256),
|
||||
review_subject jsonb NOT NULL,
|
||||
review_digest text NOT NULL CHECK(review_digest ~ '^[a-f0-9]{64}$'),
|
||||
released_at bigint NOT NULL CHECK(released_at>=0),
|
||||
UNIQUE(reviewer_owner,review_nonce)
|
||||
);
|
||||
CREATE TABLE wk_custody.monero_deposit_reorg_resolutions(
|
||||
resolution_id uuid PRIMARY KEY,
|
||||
hold_id uuid NOT NULL UNIQUE REFERENCES wk_custody.monero_deposit_reorg_holds(hold_id),
|
||||
credit_id uuid NOT NULL REFERENCES wk_custody.monero_deposit_credit_events(credit_id),
|
||||
release_id uuid NOT NULL REFERENCES wk_custody.monero_deposit_release_events(release_id),
|
||||
resolved_by text NOT NULL CHECK(resolved_by ~ '^[a-f0-9]{64}$'),
|
||||
resolved_at bigint NOT NULL CHECK(resolved_at>=0),
|
||||
disposition text NOT NULL CHECK(disposition='revalidated-for-release'),
|
||||
resolution_kind text NOT NULL CHECK(resolution_kind IN ('release-review','post-release-review')),
|
||||
reviewer_public_key text NOT NULL CHECK(length(reviewer_public_key) BETWEEN 1 AND 256),
|
||||
review_nonce uuid NOT NULL,
|
||||
review_expires_at bigint NOT NULL CHECK(review_expires_at>=0),
|
||||
review_signature text NOT NULL CHECK(length(review_signature) BETWEEN 1 AND 256),
|
||||
review_subject jsonb NOT NULL,
|
||||
review_digest text NOT NULL CHECK(review_digest ~ '^[a-f0-9]{64}$'),
|
||||
UNIQUE(hold_id,release_id),
|
||||
UNIQUE(resolved_by,review_nonce)
|
||||
);
|
||||
CREATE INDEX monero_deposit_resolutions_credit ON wk_custody.monero_deposit_reorg_resolutions(credit_id,hold_id);
|
||||
CREATE FUNCTION wk_custody.monero_deposit_release_history_immutable() RETURNS trigger LANGUAGE plpgsql
|
||||
SET search_path=wk_custody,pg_catalog AS $$
|
||||
BEGIN RAISE EXCEPTION 'Monero deposit release/resolution history is append-only'; END $$;
|
||||
CREATE TRIGGER monero_deposit_release_events_no_update BEFORE UPDATE OR DELETE ON wk_custody.monero_deposit_release_events
|
||||
FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_deposit_release_history_immutable();
|
||||
CREATE TRIGGER monero_deposit_reorg_resolutions_no_update BEFORE UPDATE OR DELETE ON wk_custody.monero_deposit_reorg_resolutions
|
||||
FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_deposit_release_history_immutable();
|
||||
REVOKE ALL ON wk_custody.monero_deposit_release_events,wk_custody.monero_deposit_reorg_resolutions FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION wk_custody.monero_deposit_release_history_immutable() FROM PUBLIC;
|
||||
`;
|
||||
export const CUSTODY_SCHEMA_V6_DIGEST = createHash('sha256')
|
||||
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}\n-- custody migration v5 --\n${CUSTODY_MIGRATION_V5}\n-- custody migration v6 --\n${CUSTODY_MIGRATION_V6}`).digest('hex');
|
||||
export const CUSTODY_MIGRATION_V7 = `
|
||||
ALTER TABLE wk_custody.monero_deposit_reorg_resolutions
|
||||
DROP CONSTRAINT monero_deposit_reorg_resolutions_resolved_by_review_nonce_key;
|
||||
`;
|
||||
export const CUSTODY_SCHEMA_V7_DIGEST = createHash('sha256')
|
||||
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}\n-- custody migration v5 --\n${CUSTODY_MIGRATION_V5}\n-- custody migration v6 --\n${CUSTODY_MIGRATION_V6}\n-- custody migration v7 --\n${CUSTODY_MIGRATION_V7}`).digest('hex');
|
||||
/** Retained alias for callers that need the initial schema definition in disposable fixtures. */
|
||||
export const CUSTODY_SCHEMA = CUSTODY_SCHEMA_V1;
|
||||
export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V5_DIGEST;
|
||||
export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V7_DIGEST;
|
||||
|
||||
/** Explicit operator migration; the HTTP runtime never creates or upgrades schema. */
|
||||
export async function migrateCustody(pool: Pool): Promise<void> {
|
||||
@@ -289,6 +341,16 @@ export async function migrateCustody(pool: Pool): Promise<void> {
|
||||
if (current === 4) {
|
||||
await c.query(CUSTODY_MIGRATION_V5);
|
||||
await c.query('INSERT INTO wk_custody.schema_versions VALUES(5,$1)', [CUSTODY_SCHEMA_V5_DIGEST]);
|
||||
current = 5;
|
||||
}
|
||||
if (current === 5) {
|
||||
await c.query(CUSTODY_MIGRATION_V6);
|
||||
await c.query('INSERT INTO wk_custody.schema_versions VALUES(6,$1)', [CUSTODY_SCHEMA_V6_DIGEST]);
|
||||
current = 6;
|
||||
}
|
||||
if (current === 6) {
|
||||
await c.query(CUSTODY_MIGRATION_V7);
|
||||
await c.query('INSERT INTO wk_custody.schema_versions VALUES(7,$1)', [CUSTODY_SCHEMA_V7_DIGEST]);
|
||||
}
|
||||
if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application');
|
||||
await verifyCustodySchema(c);
|
||||
@@ -303,7 +365,7 @@ export async function verifyCustodySchema(c: PoolClient): Promise<void> {
|
||||
async function verifyCustodySchemaHistory(c: PoolClient): Promise<number> {
|
||||
const rows = await c.query('SELECT version,digest FROM wk_custody.schema_versions ORDER BY version');
|
||||
const expected = [CUSTODY_SCHEMA_V1_DIGEST, CUSTODY_SCHEMA_V2_DIGEST, CUSTODY_SCHEMA_V3_DIGEST,
|
||||
CUSTODY_SCHEMA_V4_DIGEST, CUSTODY_SCHEMA_V5_DIGEST];
|
||||
CUSTODY_SCHEMA_V4_DIGEST, CUSTODY_SCHEMA_V5_DIGEST, CUSTODY_SCHEMA_V6_DIGEST, CUSTODY_SCHEMA_V7_DIGEST];
|
||||
if (!rows.rowCount || rows.rowCount > expected.length || rows.rows.some((row, index) =>
|
||||
Number(row.version) !== index + 1 || row.digest !== expected[index])) {
|
||||
throw new Error('Custody PostgreSQL schema version/digest history mismatch; explicit migration required');
|
||||
@@ -386,11 +448,13 @@ export async function grantMoneroDepositCreditRuntime(pool: Pool, role: string,
|
||||
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA wk_custody FROM ${name};
|
||||
GRANT SELECT ON wk_custody.custody_meta,wk_custody.monero_observer_state,wk_custody.monero_transfer_observations,
|
||||
wk_custody.monero_observation_events,wk_custody.monero_deposit_allocations,
|
||||
wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds TO ${name};
|
||||
wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds,
|
||||
wk_custody.monero_deposit_release_events,wk_custody.monero_deposit_reorg_resolutions TO ${name};
|
||||
GRANT SELECT(id,normal) ON wk_custody.accounts TO ${name};
|
||||
GRANT INSERT(id,normal) ON wk_custody.accounts TO ${name};
|
||||
GRANT INSERT(id,kind,created) ON wk_custody.journals TO ${name};
|
||||
GRANT INSERT ON wk_custody.entries,wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds TO ${name};`);
|
||||
GRANT INSERT ON wk_custody.entries,wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds,
|
||||
wk_custody.monero_deposit_release_events,wk_custody.monero_deposit_reorg_resolutions TO ${name};`);
|
||||
await c.query('COMMIT');
|
||||
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { c.release(); }
|
||||
|
||||
@@ -18,8 +18,10 @@ import { migrateCustody, grantCustodyRuntime, grantMoneroObserverRuntime, CUSTOD
|
||||
grantMoneroDepositCreditRuntime, CUSTODY_MIGRATION_V2, CUSTODY_SCHEMA_V2_DIGEST } from '../../server/market/postgres-schema.ts';
|
||||
import { persistMoneroObservationScan } from '../../server/market/monero-observation-store.ts';
|
||||
import { moneroWalletFingerprint } from '../../server/market/monero-observer.ts';
|
||||
import { depositCreditApprovalMessage, postReviewedDepositPendingCredit, prepareDepositCreditReview,
|
||||
reconcileDepositCreditReorgHolds, type DepositCreditPolicy } from '../../server/market/monero-deposit-credit.ts';
|
||||
import { depositCreditApprovalMessage, depositCreditReleaseApprovalMessage, depositReorgResolutionMessage,
|
||||
postReviewedDepositPendingCredit, prepareDepositCreditReleaseReview, prepareDepositCreditReview, prepareDepositReorgResolution,
|
||||
reconcileDepositCreditReorgHolds, releaseReviewedDepositCredit, resolveReleasedDepositReorgHold,
|
||||
type DepositCreditPolicy } from '../../server/market/monero-deposit-credit.ts';
|
||||
import { createOrRecoverMoneroDepositSubaddress, registerMoneroDepositSubaddress,
|
||||
reserveMoneroDepositAllocation } from '../../server/market/monero-deposit-allocation-store.ts';
|
||||
import { importSqliteCustody } from '../../server/market/custody-import.ts';
|
||||
@@ -194,7 +196,7 @@ test('explicit PostgreSQL migrations are idempotent and runtime role cannot alte
|
||||
assert.equal((await f.market.audit()).assetsAtomicXmr, '1000000000000');
|
||||
});
|
||||
|
||||
test('custody schema v1 upgrades additively through v3 without dropping existing metadata', async t => {
|
||||
test('custody schema v1 upgrades additively through v7 without dropping existing metadata', async t => {
|
||||
const admin = new Pool(config);
|
||||
await admin.query('DROP SCHEMA IF EXISTS wk_custody CASCADE');
|
||||
const client = await admin.connect();
|
||||
@@ -208,17 +210,21 @@ test('custody schema v1 upgrades additively through v3 without dropping existing
|
||||
try {
|
||||
await migrateCustody(admin); await migrateCustody(admin);
|
||||
const versions = await admin.query('SELECT version FROM wk_custody.schema_versions ORDER BY version');
|
||||
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5]);
|
||||
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7]);
|
||||
assert.equal((await admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='v1-preserved-marker'")).rows[0].value,
|
||||
'existing-test-data');
|
||||
for (const table of ['monero_observer_state', 'monero_transfer_observations', 'monero_observation_events', 'monero_observation_scans']) {
|
||||
assert.ok((await admin.query('SELECT to_regclass($1) AS name', [`wk_custody.${table}`])).rows[0].name);
|
||||
}
|
||||
assert.ok((await admin.query("SELECT to_regclass('wk_custody.monero_deposit_allocations') AS name")).rows[0].name);
|
||||
for (const table of ['monero_deposit_credit_events', 'monero_deposit_reorg_holds',
|
||||
'monero_deposit_release_events', 'monero_deposit_reorg_resolutions']) {
|
||||
assert.ok((await admin.query('SELECT to_regclass($1) AS name', [`wk_custody.${table}`])).rows[0].name);
|
||||
}
|
||||
} finally { await admin.end(); }
|
||||
});
|
||||
|
||||
test('custody schema v2 upgrades explicitly to v3 address-allocation registry', async t => {
|
||||
test('custody schema v2 upgrades explicitly through v7 deposit lifecycle tables', async t => {
|
||||
const admin = new Pool(config);
|
||||
await admin.query('DROP SCHEMA IF EXISTS wk_custody CASCADE');
|
||||
const client = await admin.connect();
|
||||
@@ -231,8 +237,9 @@ test('custody schema v2 upgrades explicitly to v3 address-allocation registry',
|
||||
await client.query('COMMIT');
|
||||
await migrateCustody(admin); await migrateCustody(admin);
|
||||
const versions = await admin.query('SELECT version FROM wk_custody.schema_versions ORDER BY version');
|
||||
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5]);
|
||||
assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7]);
|
||||
assert.equal((await admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='v2-marker'")).rows[0].value, 'preserve');
|
||||
assert.ok((await admin.query("SELECT to_regclass('wk_custody.monero_deposit_release_events') AS name")).rows[0].name);
|
||||
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { client.release(); await admin.end(); }
|
||||
});
|
||||
@@ -506,6 +513,65 @@ test('reviewed Monero outputs post once to pending liability and reorgs create h
|
||||
observation.amountAtomicXmr);
|
||||
await assert.rejects(creditRuntime.query('UPDATE wk_custody.monero_deposit_credit_events SET amount_atomic_xmr=1'));
|
||||
await assert.rejects(creditRuntime.query('DELETE FROM wk_custody.monero_deposit_reorg_holds'));
|
||||
|
||||
const releaseSubject = await prepareDepositCreditReleaseReview(creditRuntime, first.creditId, policy, now + 220);
|
||||
assert.equal(releaseSubject.unresolvedHolds.length, 2, 'release review must include prior disappearance and reappearance evidence');
|
||||
const releaseNonce = randomUUID(), releaseExpiresAt = now + 280;
|
||||
const releaseApproval = { subject: releaseSubject, reviewerOwner: host.actor, reviewerPublicKey: host.publicKey,
|
||||
nonce: releaseNonce, expiresAt: releaseExpiresAt,
|
||||
signature: host.sign(depositCreditReleaseApprovalMessage(releaseSubject, host.actor, releaseNonce, releaseExpiresAt)) };
|
||||
const releases = await Promise.all([
|
||||
releaseReviewedDepositCredit(creditRuntime, releaseApproval, policy, now + 221),
|
||||
releaseReviewedDepositCredit(secondCreditRuntime, releaseApproval, policy, now + 221),
|
||||
]);
|
||||
assert.equal(releases.filter(item => !item.replayed).length, 1);
|
||||
const released = releases.find(item => !item.replayed)!;
|
||||
assert.equal(released.amountAtomicXmr, observation.amountAtomicXmr);
|
||||
const releaseReplay = await releaseReviewedDepositCredit(creditRuntime, releaseApproval, policy, releaseExpiresAt + 1);
|
||||
assert.equal(releaseReplay.replayed, true, 'exact release retry returns the committed result after signature expiry');
|
||||
assert.equal((await f.admin.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1', [pendingId])).rows[0].balance, '0');
|
||||
assert.equal((await f.admin.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1', [`customer:${buyer.actor}:available`])).rows[0].balance,
|
||||
observation.amountAtomicXmr);
|
||||
assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.monero_deposit_reorg_resolutions WHERE release_id=$1',
|
||||
[released.releaseId])).rows[0].n, 2);
|
||||
assert.equal((await f.market.audit()).assetsAtomicXmr, observation.amountAtomicXmr);
|
||||
|
||||
const laterMissing = await persistMoneroObservationScan(observer, { scanId: randomUUID(), walletFingerprint: wallet,
|
||||
expectedScanHeight: 102, scannedAt: now + 300,
|
||||
page: { ...page, fromHeight: 90, walletHeight: 103, throughHeight: 103, incoming: [] } });
|
||||
assert.equal(laterMissing.notSeen, 1);
|
||||
assert.equal(await reconcileDepositCreditReorgHolds(creditRuntime, wallet, now + 310), 1);
|
||||
await assert.rejects(f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' }),
|
||||
/reorg review/);
|
||||
|
||||
const laterReappeared = await persistMoneroObservationScan(observer, { scanId: randomUUID(), walletFingerprint: wallet,
|
||||
expectedScanHeight: 103, scannedAt: now + 400,
|
||||
page: { ...page, fromHeight: 90, walletHeight: 104, throughHeight: 104,
|
||||
incoming: [{ ...observation, confirmations: 15 }] } });
|
||||
assert.equal(laterReappeared.reappeared, 1);
|
||||
assert.equal(await reconcileDepositCreditReorgHolds(creditRuntime, wallet, now + 410), 1);
|
||||
const unresolved = await f.admin.query(`SELECT hold_id::text FROM wk_custody.monero_deposit_reorg_holds h
|
||||
WHERE h.credit_id=$1 AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions r WHERE r.hold_id=h.hold_id)
|
||||
ORDER BY h.observation_event_sequence`, [first.creditId]);
|
||||
assert.equal(unresolved.rowCount, 2, 'both the later disappearance and reappearance events require explicit resolution');
|
||||
let resolutionClock = now + 420;
|
||||
for (const hold of unresolved.rows) {
|
||||
const resolutionSubject = await prepareDepositReorgResolution(creditRuntime, first.creditId, hold.hold_id, policy, resolutionClock);
|
||||
const resolutionNonce = randomUUID(), resolutionExpiresAt = resolutionClock + 60;
|
||||
const resolutionApproval = { subject: resolutionSubject, reviewerOwner: host.actor, reviewerPublicKey: host.publicKey,
|
||||
nonce: resolutionNonce, expiresAt: resolutionExpiresAt,
|
||||
signature: host.sign(depositReorgResolutionMessage(resolutionSubject, host.actor, resolutionNonce, resolutionExpiresAt)) };
|
||||
const resolved = await resolveReleasedDepositReorgHold(creditRuntime, resolutionApproval, policy, resolutionClock + 1);
|
||||
assert.equal(resolved.replayed, false);
|
||||
assert.equal((await resolveReleasedDepositReorgHold(creditRuntime, resolutionApproval, policy, resolutionClock + 2)).replayed, true);
|
||||
resolutionClock += 10;
|
||||
}
|
||||
assert.equal((await f.admin.query(`SELECT count(*)::int AS n FROM wk_custody.monero_deposit_reorg_holds h
|
||||
WHERE h.credit_id=$1 AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions r WHERE r.hold_id=h.hold_id)`,
|
||||
[first.creditId])).rows[0].n, 0);
|
||||
await f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' });
|
||||
assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.journals')).rows[0].n, 3,
|
||||
'one pending credit, one release, and one authorized sandbox withdrawal are journaled');
|
||||
assert.equal((await f.market.audit()).assetsAtomicXmr, observation.amountAtomicXmr);
|
||||
} finally { await custodyRuntime.end(); await secondCreditRuntime.end(); await creditRuntime.end(); await observer.end(); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user