From 9f82f050b4d5fe16cf5e88a3b649aeddf762817b Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Wed, 21 Oct 2026 21:34:46 -0400 Subject: [PATCH] Verified incoming transfers against Monero Wallet PRC --- docs/custodial-market-design.md | 4 +- docs/postgres-financial-storage.md | 2 +- docs/production-plan.md | 4 +- scripts/custody-db.ts | 8 +- scripts/monero-mtls-stagenet-rehearsal.mjs | 8 +- scripts/run-monero-mtls-current-source.mjs | 23 ++ scripts/run-monero-mtls-current-source.ps1 | 20 ++ server/market/custody-integrity.ts | 65 ++++++ server/market/monero-deposit-credit.ts | 254 +++++++++++++++++++++ server/market/monero-observer.ts | 5 +- server/market/postgres-schema.ts | 100 +++++++- tests/monero-observer.test.ts | 14 ++ tests/postgres/custody.test.ts | 115 +++++++++- 13 files changed, 605 insertions(+), 17 deletions(-) create mode 100755 scripts/run-monero-mtls-current-source.mjs create mode 100755 scripts/run-monero-mtls-current-source.ps1 create mode 100755 server/market/monero-deposit-credit.ts diff --git a/docs/custodial-market-design.md b/docs/custodial-market-design.md index c54d10e..d63f227 100755 --- a/docs/custodial-market-design.md +++ b/docs/custodial-market-design.md @@ -18,7 +18,9 @@ The operator has authorized proceeding with implementation instead of the previo - Offline SQLite fixture methods exercise fee allocation and prepared-withdrawal identity/retry/cancellation boundaries. They never access Monero and are not callable through HTTP. Public reservations stop at `awaiting-lock`; there is **no automatic NFT delivery or settled sale** in this increment. Monero address allocation and read-only observation groundwork now exist, but no deposit scan worker, customer credit, live withdrawal, signer, author payout or reorg compensation is wired or enabled. - **Financial storage update:** [ADR 003](postgres-financial-storage.md) selects PostgreSQL and records the integrated asynchronous custody backend, restricted runtime/schema roles, deferred accounting constraints, explicit SQLite freeze/import, and full dump/restore rehearsal. SQLite remains an opt-in-compatible local sandbox default, not an automatic fallback. Real custody and production operating acceptance remain blocked. -**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, but this output-level method has not yet been tested against the actual Stagenet wallet binary. 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. +**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. ## 2. Identity and trust boundaries diff --git a/docs/postgres-financial-storage.md b/docs/postgres-financial-storage.md index d9787a2..4eaed81 100755 --- a/docs/postgres-financial-storage.md +++ b/docs/postgres-financial-storage.md @@ -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, non-posting and unavailable to public custody routes. No deposit credit journal, live signer, real-address withdrawal, NFT sale witness or production payout is introduced. The PostgreSQL backend supports sandbox listing/reservation/cancellation/withdrawal-hold commands; old prepared fixture withdrawals are preserved by import but cannot be cancelled or paid through HTTP. Old SQLite fixture settlement methods are not production PostgreSQL payment workers. +**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. ## Storage and transaction guarantees diff --git a/docs/production-plan.md b/docs/production-plan.md index a969aae..05c2abf 100755 --- a/docs/production-plan.md +++ b/docs/production-plan.md @@ -26,7 +26,9 @@ Status: active engineering plan; **not launch or payment authorization**. Update **Actual Wallet RPC + mTLS gateway rehearsal completed (2026-10-21):** the test image obtains Monero CLI v0.18.5.4 only from the official downloads host, verifies the `binaryFate` fingerprint `81AC 591F E9C4 B65C 5806 AFC3 F0AF 4D46 2A0B DF92`, verifies the GPG-signed canonical hashes file and checks the Linux x64 archive digest `ba2928962c101b609434bf8c1b43ad59ebe96c5525977284ca599d8bf0f02d10` before extraction. Its rehearsal creates ephemeral CA/server/approved-client/untrusted-client certificates, then runs `monero-wallet-rpc` in Docker `--network none`, `--stagenet --offline`, loopback-only, with login disabled behind the tested TLS 1.3 mTLS gateway. The real wallet was verified watch-only by the `query_key(spend_key)` denial. The configured client cert successfully read the primary address and created one subaddress; the gateway rejected a transfer request (HTTP 400 before upstream), a CA-signed but unapproved client certificate, and a no-certificate request. Wallet files were stopped and deleted with the ephemeral container. No `monerod`, peer, network, live credential, mainnet or funds were involved. This proves protocol interoperability in that isolated single-container topology; it does **not** prove Kubernetes/apartment firewall isolation, certificate issuance/rotation, deployment configuration, hard power-loss durability, deposits/credits/withdrawals, or production readiness. The current app still does not start this gateway or observer; HTTP auth with Monero's own `--rpc-login` remains intentionally unused. Full offline suite after these changes: **187/187 passed**; disposable PostgreSQL suite: **35/35 passed**; test image build succeeded. -**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 updated method has not yet been tested against the actual Stagenet wallet binary. +**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. ## 1A. MVP release boundary — proposed safe first production-hosted release diff --git a/scripts/custody-db.ts b/scripts/custody-db.ts index d179348..94b69f6 100755 --- a/scripts/custody-db.ts +++ b/scripts/custody-db.ts @@ -2,7 +2,7 @@ import { Pool } from 'pg'; import { open, unlink, mkdir } from 'node:fs/promises'; import { resolve, join } from 'node:path'; import { postgresConfig } from '../server/market/postgres-config.ts'; -import { migrateCustody, grantCustodyRuntime } from '../server/market/postgres-schema.ts'; +import { migrateCustody, grantCustodyRuntime, grantMoneroDepositCreditRuntime } from '../server/market/postgres-schema.ts'; import { importSqliteCustody } from '../server/market/custody-import.ts'; import { PostgresCustody } from '../server/market/postgres-custody.ts'; import { IS_MAINNET } from '../shared/network.ts'; @@ -11,7 +11,7 @@ import { resolveCustodyIncident } from '../server/market/custody-incident.ts'; // Explicit operator task, never invoked by node startup. No passwords or database URLs printed. if (IS_MAINNET) throw new Error('Only sandbox financial schema operations are implemented'); const [action, argument] = process.argv.slice(2); -if (!['migrate', 'grant-runtime', 'import-sqlite', 'audit', 'resolve-integrity'].includes(action)) throw new Error('Expected migrate, grant-runtime ROLE, import-sqlite DATA_DIR, audit, or resolve-integrity EVIDENCE_UUID'); +if (!['migrate', 'grant-runtime', 'grant-monero-credit-runtime', 'import-sqlite', 'audit', 'resolve-integrity'].includes(action)) throw new Error('Expected migrate, grant-runtime ROLE, grant-monero-credit-runtime ROLE, import-sqlite DATA_DIR, audit, or resolve-integrity EVIDENCE_UUID'); try { const config = await postgresConfig(); if (action === 'audit') { @@ -22,6 +22,10 @@ try { try { if (action === 'migrate') { await migrateCustody(pool); console.log('Custody PostgreSQL schema verified/migrated. No funds activated.'); } if (action === 'grant-runtime') { await grantCustodyRuntime(pool, argument ?? ''); console.log('Runtime grants applied; no credentials printed.'); } + if (action === 'grant-monero-credit-runtime') { + await grantMoneroDepositCreditRuntime(pool, argument ?? ''); + console.log('Separate reviewed pending-credit coordinator grants applied; no credentials printed and no live deposits enabled.'); + } if (action === 'resolve-integrity') console.log(JSON.stringify(await resolveCustodyIncident(pool, argument ?? ''))); if (action === 'import-sqlite') { if (!argument) throw new Error('Stopped source node DATA_DIR required'); diff --git a/scripts/monero-mtls-stagenet-rehearsal.mjs b/scripts/monero-mtls-stagenet-rehearsal.mjs index 2e3db6f..ce19886 100755 --- a/scripts/monero-mtls-stagenet-rehearsal.mjs +++ b/scripts/monero-mtls-stagenet-rehearsal.mjs @@ -67,7 +67,7 @@ function startWallet(walletFile) { walletRpc = spawn(binary,args,{shell:false,windowsHide:true,stdio:'ignore',cwd:temp}); walletRpc.on('error',()=>{}); } async function waitWallet() { - const deadline=Date.now()+15_000; + const deadline=Date.now()+60_000; while(Date.now()setTimeout(r,100)); } @@ -128,9 +128,11 @@ try { console.log('stage: authorized mTLS subaddress creation'); assert.equal(sub.status,200); assert.equal(sub.data.result.address_index,1); const outputs=await gatewayCall(validTransport,'incoming_transfers',{transfer_type:'all',account_index:0,subaddr_indices:[1]}); - assert.equal(outputs.status,200); assert.deepEqual(outputs.data.result.transfers,[], + assert.equal(outputs.status,200); + assert.equal(outputs.data.error,undefined,'incoming_transfers must be a supported JSON-RPC method'); + assert.deepEqual(outputs.data.result?.transfers ?? [],[], 'a fresh watch-only Stagenet address must return an empty owned-output list'); - console.log('stage: authorized bounded incoming-output read'); + console.log('stage: incoming_transfers allowed; pinned RPC omits default empty result fields'); const forbiddenStatus=await rawGatewayStatus('transfer',{destinations:[]},certs['client.crt'],certs['client.key'],certs['ca.crt']); assert.equal(forbiddenStatus,400,'gateway must reject transfer before Wallet RPC'); console.log('stage: forbidden transfer blocked'); diff --git a/scripts/run-monero-mtls-current-source.mjs b/scripts/run-monero-mtls-current-source.mjs new file mode 100755 index 0000000..bb949fe --- /dev/null +++ b/scripts/run-monero-mtls-current-source.mjs @@ -0,0 +1,23 @@ +import { execFileSync } from 'node:child_process'; + +const containerArgs = ['create', '--network', 'none', '--entrypoint', 'node', 'wikideck-monero-mtls-test', + '--experimental-strip-types', 'scripts/monero-mtls-stagenet-rehearsal.mjs', '/opt/monero/monero-wallet-rpc']; +const containerId = execFileSync('docker', containerArgs, { encoding: 'utf8' }).trim(); +if (!/^[a-f0-9]{64}$/.test(containerId)) throw new Error('Docker returned an invalid rehearsal container identity'); + +let exitCode = 1; +try { + execFileSync('docker', ['cp', 'Z:\\Repositories\\wikideck\\server', `${containerId}:/app/`], { stdio: 'inherit' }); + execFileSync('docker', ['cp', 'Z:\\Repositories\\wikideck\\shared', `${containerId}:/app/`], { stdio: 'inherit' }); + execFileSync('docker', ['cp', 'Z:\\Repositories\\wikideck\\scripts\\monero-mtls-stagenet-rehearsal.mjs', + `${containerId}:/app/scripts/monero-mtls-stagenet-rehearsal.mjs`], { stdio: 'inherit' }); + try { + execFileSync('docker', ['start', '--attach', containerId], { stdio: 'inherit' }); + exitCode = 0; + } catch (error) { + exitCode = typeof error.status === 'number' ? error.status : 1; + } +} finally { + execFileSync('docker', ['rm', '--force', containerId], { stdio: 'ignore' }); +} +process.exitCode = exitCode; diff --git a/scripts/run-monero-mtls-current-source.ps1 b/scripts/run-monero-mtls-current-source.ps1 new file mode 100755 index 0000000..6f17476 --- /dev/null +++ b/scripts/run-monero-mtls-current-source.ps1 @@ -0,0 +1,20 @@ +$ErrorActionPreference = 'Stop' +$containerId = & docker create --network none --entrypoint node wikideck-monero-mtls-test ` + --experimental-strip-types scripts/monero-mtls-stagenet-rehearsal.mjs /opt/monero/monero-wallet-rpc +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +$exitCode = 1 +try { + & docker cp 'Z:\Repositories\wikideck\server' "${containerId}:/app/" + if ($LASTEXITCODE -ne 0) { throw 'Copying current server source into the isolated rehearsal container failed.' } + & docker cp 'Z:\Repositories\wikideck\shared' "${containerId}:/app/" + if ($LASTEXITCODE -ne 0) { throw 'Copying current shared source into the isolated rehearsal container failed.' } + & docker cp 'Z:\Repositories\wikideck\scripts\monero-mtls-stagenet-rehearsal.mjs' ` + "${containerId}:/app/scripts/monero-mtls-stagenet-rehearsal.mjs" + if ($LASTEXITCODE -ne 0) { throw 'Copying the current rehearsal script failed.' } + & docker start --attach $containerId + $exitCode = $LASTEXITCODE +} finally { + & docker rm --force $containerId | Out-Null +} +exit $exitCode \ No newline at end of file diff --git a/server/market/custody-integrity.ts b/server/market/custody-integrity.ts index 0ac36d4..1c09208 100755 --- a/server/market/custody-integrity.ts +++ b/server/market/custody-integrity.ts @@ -1,10 +1,13 @@ import { z } from 'zod'; +import { createHash } from 'node:crypto'; import type { PoolClient, QueryResult } from 'pg'; 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'; +import { address, verifySignature } from '../testnet/consensus.ts'; +import { depositCreditApprovalMessage, type DepositCreditReviewSubject } from './monero-deposit-credit.ts'; /** Codes only: never attach customer IDs, payloads, SQL errors or addresses to operational diagnostics. */ export class CustodyIntegrityError extends Error { @@ -169,6 +172,67 @@ async function checkWithdrawals(client: PoolClient, rows: WithdrawalRow[]) { c(available, (BigInt(w.maxFeeAtomicXmr) - BigInt(w.feeAtomicXmr!)).toString())] : null); } } + +type DepositCreditRow = { credit_id: string; wallet_fingerprint: string; observation_id: string; allocation_id: string; + 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 }; +async function checkDepositCredits(client: PoolClient, budget: () => void) { + let after: string | null = null; + for (;;) { + budget(); + const result: QueryResult = await client.query(`SELECT c.credit_id::text,c.wallet_fingerprint,c.observation_id, + 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 + LEFT JOIN wk_custody.accounts a ON a.id='customer:'||c.beneficiary||':deposit-pending:'||c.credit_id::text + 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)); + for (const row of result.rows) { + let approvalIsValid = false; + let subjectIsValid = false; + try { + const message = depositCreditApprovalMessage(row.review_subject, row.reviewer_owner, + row.review_nonce, Number(row.review_expires_at)); + approvalIsValid = address(row.reviewer_public_key) === row.reviewer_owner && + createHash('sha256').update(message).digest('hex') === row.review_digest && + Number(row.review_expires_at) >= Number(row.credited_at) && + verifySignature(row.reviewer_public_key, row.reviewer_owner, message, row.review_signature); + subjectIsValid = row.observation_id === `${row.review_subject.observationId.split(':')[0]}:${row.review_subject.outputPublicKey}` && + row.review_subject.walletFingerprint === row.wallet_fingerprint && + row.review_subject.observationDigest === row.observation_digest && + row.review_subject.observationId === row.observation_id && + row.review_subject.amountAtomicXmr === row.amount_atomic_xmr && row.review_subject.beneficiary === row.beneficiary && + row.review_subject.allocationId === row.allocation_id && + row.review_subject.observationEventSequence === Number(row.last_observation_event_sequence) && + 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'); + 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 (result.rows.length < 128) break; + after = result.rows.at(-1)!.credit_id; + } + const orphanAccount = await client.query(`SELECT 1 FROM wk_custody.accounts a + WHERE a.id LIKE 'customer:%:deposit-pending:%' AND NOT EXISTS ( + SELECT 1 FROM wk_custody.monero_deposit_credit_events c + WHERE a.id='customer:'||c.beneficiary||':deposit-pending:'||c.credit_id::text) LIMIT 1`); + if (orphanAccount.rowCount) fail('orphan-deposit-pending-liability'); + 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 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 + h.observation_event_sequence<=c.last_observation_event_sequence LIMIT 1`); + if (invalidHold.rowCount) fail('deposit-reorg-hold-mismatch'); +} const withdrawalSelect = `SELECT w.id::text,w.actor,w.status,w.data,a.balance::text,a.normal, b.kind AS box_kind,b.status AS box_status,b.data AS box_data FROM wk_custody.withdrawals w LEFT JOIN wk_custody.accounts a ON a.id='customer:'||w.actor||':withdrawal:'||w.id::text @@ -212,6 +276,7 @@ export async function auditBusinessLiabilities(client: PoolClient): Promise 8 || policy.approvedReviewerOwners.some(owner => !hex.safeParse(owner).success) || + !Number.isSafeInteger(policy.minimumConfirmations) || policy.minimumConfirmations < 10 || policy.minimumConfirmations > 100_000 || + !Number.isSafeInteger(policy.maximumObservationAgeMs) || policy.maximumObservationAgeMs < 10_000 || policy.maximumObservationAgeMs > 15 * 60_000) { + throw new Error('Invalid private Monero deposit-credit policy'); + } +} + +async function readReviewSubject(client: PoolClient, walletFingerprint: string, observationId: string, + policy: DepositCreditPolicy, now: number): Promise { + 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 + FROM wk_custody.monero_transfer_observations o + JOIN wk_custody.monero_observer_state s USING(wallet_fingerprint) + WHERE o.wallet_fingerprint=$1 AND o.observation_id=$2`, [walletFingerprint, observationId]); + if (observation.rowCount !== 1) throw new Error('Deposit observation not found'); + const row = observation.rows[0]; + if (!row.output_public_key || row.observation_id !== `${row.txid}:${row.output_public_key}` || !row.currently_present || + row.double_spend_seen || row.locked || Number(row.confirmations) < policy.minimumConfirmations) { + throw new Error('Deposit observation is not eligible for reviewed pending credit'); + } + if (now - Number(row.observer_updated_at) < 0 || now - Number(row.observer_updated_at) > policy.maximumObservationAgeMs) { + throw new Error('Monero observer state is stale'); + } + const amount = BigInt(row.amount_atomic_xmr); + assertXmrRpcAmount(amount); + parseAtomicXmr(amount.toString()); + + const allocation = await client.query(`SELECT allocation_id,beneficiary,custodian_id,market_network,market_chain_id + FROM wk_custody.monero_deposit_allocations + WHERE wallet_fingerprint=$1 AND account_index=$2 AND address_index=$3 AND monero_address=$4 AND status='registered'`, + [walletFingerprint, row.account_index, row.address_index, row.monero_address]); + if (allocation.rowCount !== 1) throw new Error('Deposit output has no unique registered beneficiary allocation'); + const identity = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'"); + if (identity.rowCount !== 1) throw new Error('Custodian identity is unavailable'); + const custodian = JSON.parse(identity.rows[0].value) as { id?: string; network?: string; chainId?: string; mode?: string }; + const mapped = allocation.rows[0]; + if (custodian.mode !== 'sandbox' || mapped.custodian_id !== custodian.id || mapped.market_network !== custodian.network || + mapped.market_chain_id !== custodian.chainId) throw new Error('Deposit allocation is outside this custody identity'); + + const events = await client.query(`SELECT sequence,event_type,payload_digest,wallet_height,observed_at + 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')) { + 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; + const eventDigest = digest(JSON.stringify(events.rows.map(event => [Number(event.sequence), event.event_type, + event.payload_digest, Number(event.wallet_height), Number(event.observed_at)]))); + return { + policyVersion: POLICY_VERSION, walletFingerprint, observationId, outputPublicKey: row.output_public_key, + amountAtomicXmr: amount.toString(), blockHeight: Number(row.block_height), confirmations: Number(row.confirmations), + minimumConfirmations: policy.minimumConfirmations, locked: row.locked, doubleSpendSeen: row.double_spend_seen, + allocationId: mapped.allocation_id, beneficiary: mapped.beneficiary, observationDigest: row.payload_digest, + observationEventSequence: sequence, observationEventHistoryDigest: eventDigest, + observerUpdatedAt: Number(row.observer_updated_at), + }; +} + +/** Return the exact current snapshot an authorized operator must review and sign. */ +export async function prepareDepositCreditReview(pool: Pool, walletFingerprint: string, observationId: string, + policy: DepositCreditPolicy, now = Date.now()): Promise { + validatePolicy(policy); fingerprint.parse(walletFingerprint); + const c = await pool.connect(); + try { + await c.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'); + await c.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'"); + const subject = await readReviewSubject(c, walletFingerprint, observationId, policy, now); + await c.query('COMMIT'); return subject; + } catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; } + finally { c.release(); } +} + +/** Canonical message an allow-listed reviewer signs after examining the exact output/allocation snapshot. */ +export function depositCreditApprovalMessage(subject: DepositCreditReviewSubject, reviewerOwner: string, nonce: string, + expiresAt: number): string { + return canonicalReviewMessage(subjectSchema.parse(subject), hex.parse(reviewerOwner), uuid.parse(nonce), expiresAt); +} + +/** + * Posts an operator-reviewed credit into a non-spendable pending-liability account. This service is not + * wired to HTTP/startup; release to available balance is a separate, not-yet-implemented approval step. + */ +export async function postReviewedDepositPendingCredit(pool: Pool, rawApproval: DepositCreditApproval, + policy: DepositCreditPolicy, now = Date.now()): Promise { + validatePolicy(policy); + const approval = approvalSchema.parse(rawApproval); + if (!policy.approvedReviewerOwners.includes(approval.reviewerOwner) || + address(approval.reviewerPublicKey) !== approval.reviewerOwner || + !verifySignature(approval.reviewerPublicKey, approval.reviewerOwner, + canonicalReviewMessage(approval.subject, approval.reviewerOwner, approval.nonce, approval.expiresAt), approval.signature)) { + throw new Error('Deposit credit review signature is invalid'); + } + const reviewDigest = digest(canonicalReviewMessage(approval.subject, approval.reviewerOwner, approval.nonce, approval.expiresAt)); + 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 credit_id,journal_id,beneficiary,amount_atomic_xmr::text,reviewer_owner, + review_nonce,observation_digest,allocation_id::text,reviewer_public_key,review_expires_at,review_signature, + minimum_confirmations,review_digest FROM wk_custody.monero_deposit_credit_events + WHERE wallet_fingerprint=$1 AND observation_id=$2`, + [approval.subject.walletFingerprint, approval.subject.observationId]); + if (existing.rowCount) { + const saved = existing.rows[0]; + if (saved.reviewer_owner !== approval.reviewerOwner || saved.review_nonce !== approval.nonce || + saved.observation_digest !== approval.subject.observationDigest || saved.amount_atomic_xmr !== approval.subject.amountAtomicXmr || + saved.allocation_id !== approval.subject.allocationId || saved.minimum_confirmations !== approval.subject.minimumConfirmations || + saved.reviewer_public_key !== approval.reviewerPublicKey || Number(saved.review_expires_at) !== approval.expiresAt || + saved.review_signature !== approval.signature || saved.review_digest !== reviewDigest) { + throw new Error('Deposit output was already staged under different reviewed terms'); + } + await client.query('COMMIT'); + return { creditId: saved.credit_id, journalId: saved.journal_id, beneficiary: saved.beneficiary, + amountAtomicXmr: saved.amount_atomic_xmr, replayed: true }; + } + if (approval.expiresAt < now || approval.expiresAt > now + 5 * 60_000) { + throw new Error('Deposit credit review signature is expired or outside the allowed window'); + } + const current = await readReviewSubject(client, approval.subject.walletFingerprint, approval.subject.observationId, policy, now); + if (JSON.stringify(current) !== JSON.stringify(approval.subject)) throw new Error('Deposit observation changed after reviewer approval'); + const creditId = randomUUID(); + const journalId = `monero-deposit-pending:${digest(`${current.walletFingerprint}:${current.observationId}`)}`; + const assetAccount = 'treasury:monero:pending'; + const liabilityAccount = `customer:${current.beneficiary}:deposit-pending:${creditId}`; + await client.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)', + [journalId, 'monero-deposit-pending-credit', now]); + const lines = [[0, assetAccount, 'debit'], [1, liabilityAccount, 'credit']] as const; + for (const [position, accountId, normal] of lines) { + await client.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,$2) ON CONFLICT(id) DO NOTHING', [accountId, normal]); + const account = await client.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [accountId]); + if (account.rows[0]?.normal !== normal) throw new Error('Deposit pending account normal mismatch'); + await client.query('INSERT INTO wk_custody.entries VALUES($1,$2,$3,$4,$5)', + [journalId, position, accountId, normal === 'debit' ? current.amountAtomicXmr : '0', + normal === 'credit' ? current.amountAtomicXmr : '0']); + } + await client.query(`INSERT INTO wk_custody.monero_deposit_credit_events( + credit_id,wallet_fingerprint,observation_id,allocation_id,beneficiary,amount_atomic_xmr,journal_id, + observation_digest,last_observation_event_sequence,reviewer_owner,reviewer_public_key,review_nonce, + review_expires_at,review_signature,review_subject,review_digest,credited_at,minimum_confirmations) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`, + [creditId, current.walletFingerprint, current.observationId, current.allocationId, current.beneficiary, + current.amountAtomicXmr, journalId, current.observationDigest, current.observationEventSequence, + approval.reviewerOwner, approval.reviewerPublicKey, approval.nonce, approval.expiresAt, approval.signature, + JSON.stringify(current), reviewDigest, now, current.minimumConfirmations]); + await client.query('COMMIT'); + return { creditId, journalId, beneficiary: current.beneficiary, amountAtomicXmr: current.amountAtomicXmr, 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 { + fingerprint.parse(walletFingerprint); + 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 synchronous_commit=on"); + await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]); + const reorgs = await client.query(`SELECT c.credit_id,c.observation_id,e.sequence,e.event_type + FROM wk_custody.monero_deposit_credit_events c + JOIN wk_custody.monero_observation_events e ON e.wallet_fingerprint=c.wallet_fingerprint + AND e.observation_id=c.observation_id AND e.sequence>c.last_observation_event_sequence + WHERE c.wallet_fingerprint=$1 AND e.event_type IN ('changed','not-seen','reappeared') + AND NOT EXISTS (SELECT 1 FROM wk_custody.monero_deposit_reorg_holds h + WHERE h.credit_id=c.credit_id AND h.observation_event_sequence=e.sequence) + ORDER BY e.sequence LIMIT 1024`, [walletFingerprint]); + for (const row of reorgs.rows) { + await client.query(`INSERT INTO wk_custody.monero_deposit_reorg_holds( + hold_id,credit_id,wallet_fingerprint,observation_id,observation_event_sequence,event_type,created_at) + VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(credit_id,observation_event_sequence) DO NOTHING`, + [randomUUID(), row.credit_id, walletFingerprint, row.observation_id, row.sequence, row.event_type, now]); + } + await client.query('COMMIT'); return reorgs.rowCount ?? 0; + } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } + finally { client.release(); } +} diff --git a/server/market/monero-observer.ts b/server/market/monero-observer.ts index 02891e5..cebaddc 100755 --- a/server/market/monero-observer.ts +++ b/server/market/monero-observer.ts @@ -54,8 +54,9 @@ const incomingOutputSchema = z.object({ pubkey: z.string().regex(/^[a-f0-9]{64}$/), }).passthrough(); const incomingOutputsResultSchema = z.object({ - transfers: z.array(incomingOutputSchema).max(MAX_TRANSFERS), - status: z.literal('OK'), + // v0.18.5.4 omits default-valued empty arrays/status from JSON-RPC serialization. + transfers: z.array(incomingOutputSchema).max(MAX_TRANSFERS).default([]), + status: z.literal('OK').optional(), }).passthrough(); export type MoneroIncomingObservation = { diff --git a/server/market/postgres-schema.ts b/server/market/postgres-schema.ts index e30c469..ab2e3cc 100755 --- a/server/market/postgres-schema.ts +++ b/server/market/postgres-schema.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import type { Pool, PoolClient } from 'pg'; -export const CUSTODY_PG_VERSION = 4; +export const CUSTODY_PG_VERSION = 5; export const CUSTODY_WRITE_LOCK = '7140824361'; export const CUSTODY_SCHEMA_V1 = ` CREATE SCHEMA IF NOT EXISTS wk_custody; @@ -198,9 +198,65 @@ CREATE UNIQUE INDEX monero_observations_output_key `; export const CUSTODY_SCHEMA_V4_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}`).digest('hex'); +export const CUSTODY_MIGRATION_V5 = ` +-- Deferred balance enforcement runs with the schema owner's read access; the +-- fixed search_path and static table reference keep the trigger safe for the +-- narrowly privileged pending-credit coordinator. +ALTER FUNCTION wk_custody.balanced_journal() SECURITY DEFINER; +CREATE TABLE wk_custody.monero_deposit_credit_events( + credit_id uuid PRIMARY KEY, + wallet_fingerprint text NOT NULL, + observation_id text NOT NULL, + allocation_id uuid NOT NULL REFERENCES wk_custody.monero_deposit_allocations(allocation_id), + beneficiary text NOT NULL CHECK(beneficiary ~ '^[a-f0-9]{64}$'), + amount_atomic_xmr numeric(20,0) NOT NULL CHECK(amount_atomic_xmr>0 AND amount_atomic_xmr<=18446744073709551615), + journal_id text NOT NULL UNIQUE REFERENCES wk_custody.journals(id), + observation_digest text NOT NULL CHECK(observation_digest ~ '^[a-f0-9]{64}$'), + last_observation_event_sequence bigint NOT NULL CHECK(last_observation_event_sequence>=0), + 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}$'), + credited_at bigint NOT NULL CHECK(credited_at>=0), + minimum_confirmations integer NOT NULL CHECK(minimum_confirmations>=10), + UNIQUE(wallet_fingerprint,observation_id), + UNIQUE(reviewer_owner,review_nonce), + FOREIGN KEY(wallet_fingerprint,observation_id) + REFERENCES wk_custody.monero_transfer_observations(wallet_fingerprint,observation_id) +); +ALTER TABLE wk_custody.monero_observation_events + ADD CONSTRAINT monero_observation_event_identity UNIQUE(sequence,wallet_fingerprint,observation_id,event_type); +CREATE TABLE wk_custody.monero_deposit_reorg_holds( + hold_id uuid PRIMARY KEY, + credit_id uuid NOT NULL REFERENCES wk_custody.monero_deposit_credit_events(credit_id), + wallet_fingerprint text NOT NULL, + observation_id text NOT NULL, + observation_event_sequence bigint NOT NULL REFERENCES wk_custody.monero_observation_events(sequence), + event_type text NOT NULL CHECK(event_type IN ('changed','not-seen','reappeared')), + created_at bigint NOT NULL CHECK(created_at>=0), + UNIQUE(credit_id,observation_event_sequence), + FOREIGN KEY(observation_event_sequence,wallet_fingerprint,observation_id,event_type) + REFERENCES wk_custody.monero_observation_events(sequence,wallet_fingerprint,observation_id,event_type) +); +CREATE INDEX monero_deposit_holds_credit ON wk_custody.monero_deposit_reorg_holds(credit_id,observation_event_sequence); +CREATE FUNCTION wk_custody.monero_deposit_credit_history_immutable() RETURNS trigger LANGUAGE plpgsql + SET search_path=wk_custody,pg_catalog AS $$ +BEGIN RAISE EXCEPTION 'Monero deposit credit/reorg history is append-only'; END $$; +CREATE TRIGGER monero_deposit_credit_events_no_update BEFORE UPDATE OR DELETE ON wk_custody.monero_deposit_credit_events + FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_deposit_credit_history_immutable(); +CREATE TRIGGER monero_deposit_reorg_holds_no_update BEFORE UPDATE OR DELETE ON wk_custody.monero_deposit_reorg_holds + FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_deposit_credit_history_immutable(); +REVOKE ALL ON wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds FROM PUBLIC; +REVOKE ALL ON FUNCTION wk_custody.monero_deposit_credit_history_immutable() FROM PUBLIC; +`; +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'); /** 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_V4_DIGEST; +export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V5_DIGEST; /** Explicit operator migration; the HTTP runtime never creates or upgrades schema. */ export async function migrateCustody(pool: Pool): Promise { @@ -228,6 +284,11 @@ export async function migrateCustody(pool: Pool): Promise { if (current === 3) { await c.query(CUSTODY_MIGRATION_V4); await c.query('INSERT INTO wk_custody.schema_versions VALUES(4,$1)', [CUSTODY_SCHEMA_V4_DIGEST]); + current = 4; + } + 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]); } if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application'); await verifyCustodySchema(c); @@ -241,7 +302,8 @@ export async function verifyCustodySchema(c: PoolClient): Promise { } async function verifyCustodySchemaHistory(c: PoolClient): Promise { 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]; + const expected = [CUSTODY_SCHEMA_V1_DIGEST, CUSTODY_SCHEMA_V2_DIGEST, CUSTODY_SCHEMA_V3_DIGEST, + CUSTODY_SCHEMA_V4_DIGEST, CUSTODY_SCHEMA_V5_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'); @@ -301,3 +363,35 @@ export async function grantMoneroObserverRuntime(pool: Pool, role: string, custo } catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; } finally { c.release(); } } + +/** Separate least-privilege role for reviewed pending-credit posting and deposit reorg evidence. */ +export async function grantMoneroDepositCreditRuntime(pool: Pool, role: string, + custodyRuntimeRole = 'custody_runtime', observerRole = 'monero_observer'): Promise { + if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid deposit-credit role identifier'); + if (role === custodyRuntimeRole || role === observerRole) { + throw new Error('Deposit-credit coordinator requires a separate role from custody runtime and observer'); + } + const name = `"${role}"`; + const c = await pool.connect(); + try { + await c.query('BEGIN'); await verifyCustodySchema(c); + const r = await c.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolreplication,rolbypassrls FROM pg_roles WHERE rolname=$1', [role]); + if (!r.rowCount || Object.values(r.rows[0]).some(Boolean)) throw new Error('Deposit-credit role must exist without administrative privileges'); + const memberships = await c.query('SELECT 1 FROM pg_auth_members WHERE member=(SELECT oid FROM pg_roles WHERE rolname=$1) LIMIT 1', [role]); + if (memberships.rowCount) throw new Error('Deposit-credit role must not inherit privileges through role membership'); + const owner = await c.query("SELECT pg_get_userbyid(nspowner) AS owner FROM pg_namespace WHERE nspname='wk_custody'"); + if (owner.rows[0].owner === role) throw new Error('Deposit-credit role must not own the custody schema'); + await c.query(`GRANT USAGE ON SCHEMA wk_custody TO ${name}; + REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA wk_custody FROM ${name}; + 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}; + 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};`); + await c.query('COMMIT'); + } catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; } + finally { c.release(); } +} diff --git a/tests/monero-observer.test.ts b/tests/monero-observer.test.ts index aba68ef..040ba4a 100755 --- a/tests/monero-observer.test.ts +++ b/tests/monero-observer.test.ts @@ -114,6 +114,20 @@ test('observer fails closed on missing, duplicated, or misattributed incoming ou await assert.rejects(createMoneroWalletObserver(config, malformed).scanIncoming(90)); }); +test('observer tolerates v0.18.5.4 omission of empty incoming_transfers result fields', async () => { + const txid = 'd'.repeat(64); + const transport: MoneroFixtureTransport = async (_url, init) => { + const body = JSON.parse(init.body as string) as { id: string; method: string }; + if (body.method === 'get_height') return response(body.id, { height: 100, status: 'OK' }); + if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' }); + if (body.method === 'get_transfers') return response(body.id, { in: [{ txid, address: ADDITIONAL_MARKET_FEE.address, + amount: '1', height: 91, timestamp: 1, unlock_time: 0, locked: false, double_spend_seen: false, + subaddr_index: { major: 0, minor: 7 } }], status: 'OK' }); + return response(body.id, {}); // Actual empty result shape from the pinned Wallet RPC build. + }; + await assert.rejects(createMoneroWalletObserver(config, transport).scanIncoming(90), /transaction amount/); +}); + test('deposit-address RPC client is narrowly allow-listed and creates only one verified subaddress', async () => { const subaddress = await syntheticSubaddress(); const calls: { method: string; params: unknown }[] = []; diff --git a/tests/postgres/custody.test.ts b/tests/postgres/custody.test.ts index 9becffc..ff6cebe 100755 --- a/tests/postgres/custody.test.ts +++ b/tests/postgres/custody.test.ts @@ -15,9 +15,11 @@ import { CustodyIntegrityError } from '../../server/market/custody-integrity.ts' import { resolveCustodyIncident } from '../../server/market/custody-incident.ts'; import { CustodyMarket } from '../../server/market/custody.ts'; import { migrateCustody, grantCustodyRuntime, grantMoneroObserverRuntime, CUSTODY_SCHEMA_V1, CUSTODY_SCHEMA_V1_DIGEST, - CUSTODY_MIGRATION_V2, CUSTODY_SCHEMA_V2_DIGEST } from '../../server/market/postgres-schema.ts'; + 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 { createOrRecoverMoneroDepositSubaddress, registerMoneroDepositSubaddress, reserveMoneroDepositAllocation } from '../../server/market/monero-deposit-allocation-store.ts'; import { importSqliteCustody } from '../../server/market/custody-import.ts'; @@ -206,7 +208,7 @@ 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]); + assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5]); 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']) { @@ -229,7 +231,7 @@ 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]); + assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5]); assert.equal((await admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='v2-marker'")).rows[0].value, 'preserve'); } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } finally { client.release(); await admin.end(); } @@ -404,6 +406,110 @@ test('Monero scan persistence is idempotent, reorg-visible and never posts custo } finally { await custodyRuntime.end(); await runtime.end(); } }); +test('reviewed Monero outputs post once to pending liability and reorgs create holds without reversal', async t => { + const f = await fixture(t); + await f.admin.query(`DO $$ BEGIN + IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='monero_observer') THEN CREATE ROLE monero_observer LOGIN; END IF; + IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='monero_deposit_credit') THEN CREATE ROLE monero_deposit_credit LOGIN; END IF; + END $$`); + await grantMoneroObserverRuntime(f.admin, 'monero_observer'); + await grantMoneroDepositCreditRuntime(f.admin, 'monero_deposit_credit'); + const creditPrivileges = await f.admin.query(`SELECT has_table_privilege('monero_deposit_credit', + 'wk_custody.monero_deposit_credit_events','SELECT') AS can_read_credit, + has_table_privilege('monero_deposit_credit','wk_custody.monero_observation_events','SELECT') AS can_read_events`); + assert.equal(creditPrivileges.rows[0].can_read_credit, true); + assert.equal(creditPrivileges.rows[0].can_read_events, true); + const observer = new Pool({ ...config, user: 'monero_observer' }); + const creditRuntime = new Pool({ ...config, user: 'monero_deposit_credit' }); + const secondCreditRuntime = new Pool({ ...config, user: 'monero_deposit_credit' }); + const custodyRuntime = new Pool(f.runtime); + const wallet = '8'.repeat(64), txid = '9'.repeat(64), outputPublicKey = 'a'.repeat(64); + const observationId = `${txid}:${outputPublicKey}`, now = Date.now(), scanId = randomUUID(); + const generationId = randomUUID(), allocationId = randomUUID(), requestId = randomUUID(); + const observation = { observationId, transactionId: txid, amountAtomicXmr: '9007199254740993', height: 90, + address: ADDITIONAL_MARKET_FEE.address, confirmations: 11, timestamp: now, unlockTime: '0', + accountIndex: 0, addressIndex: 4, outputPublicKey, globalOutputIndex: 123, locked: false, doubleSpendSeen: false }; + const page = { network: 'mainnet' as const, walletHeight: 100, fromHeight: 0, throughHeight: 100, + primaryAddress: ADDITIONAL_MARKET_FEE.address, incoming: [observation] }; + const policy: DepositCreditPolicy = { approvedReviewerOwners: [host.actor], minimumConfirmations: 10, + maximumObservationAgeMs: 120_000 }; + try { + await f.admin.query(`INSERT INTO wk_custody.monero_deposit_allocations( + allocation_id,request_id,custodian_id,market_network,market_chain_id,beneficiary,wallet_generation_id, + wallet_fingerprint,monero_network,account_index,address_index,status,monero_address,request_digest,reserved_at,registered_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,'mainnet',0,4,'registered',$9,$10,$11,$12)`, + [allocationId, requestId, f.market.id, NETWORK, CHAIN_ID, buyer.actor, generationId, wallet, + ADDITIONAL_MARKET_FEE.address, 'b'.repeat(64), now - 1, now]); + await persistMoneroObservationScan(observer, { scanId, walletFingerprint: wallet, expectedScanHeight: 0, + scannedAt: now, page }); + await assert.rejects(custodyRuntime.query('SELECT * FROM wk_custody.monero_transfer_observations')); + await assert.rejects(observer.query('SELECT * FROM wk_custody.accounts')); + await assert.rejects(creditRuntime.query('SELECT balance FROM wk_custody.accounts')); + await assert.rejects(creditRuntime.query('UPDATE wk_custody.monero_transfer_observations SET currently_present=false')); + await assert.rejects(creditRuntime.query('DELETE FROM wk_custody.journals')); + await assert.rejects(creditRuntime.query('SELECT * FROM wk_custody.entries')); + await assert.rejects(creditRuntime.query('UPDATE wk_custody.accounts SET balance=0')); + + await assert.rejects(prepareDepositCreditReview(creditRuntime, wallet, observationId, + { ...policy, minimumConfirmations: 12 }, now), /not eligible/); + const subject = await prepareDepositCreditReview(creditRuntime, wallet, observationId, policy, now); + const nonce = randomUUID(), expiresAt = now + 60_000; + const approval = { subject, reviewerOwner: host.actor, reviewerPublicKey: host.publicKey, nonce, expiresAt, + signature: host.sign(depositCreditApprovalMessage(subject, host.actor, nonce, expiresAt)) }; + const unapproved = { ...approval, reviewerOwner: buyer.actor, reviewerPublicKey: buyer.publicKey, + signature: buyer.sign(depositCreditApprovalMessage(subject, buyer.actor, nonce, expiresAt)) }; + await assert.rejects(postReviewedDepositPendingCredit(creditRuntime, unapproved, policy, now), /signature is invalid/); + const expiredNonce = randomUUID(), expiredAt = now - 1; + await assert.rejects(postReviewedDepositPendingCredit(creditRuntime, { ...approval, nonce: expiredNonce, + expiresAt: expiredAt, signature: host.sign(depositCreditApprovalMessage(subject, host.actor, expiredNonce, expiredAt)) }, + policy, now), /expired or outside/); + + const concurrent = await Promise.all([ + postReviewedDepositPendingCredit(creditRuntime, approval, policy, now), + postReviewedDepositPendingCredit(secondCreditRuntime, approval, policy, now), + ]); + assert.equal(concurrent.filter(result => !result.replayed).length, 1, 'parallel retries must commit one pending credit'); + const first = concurrent.find(result => !result.replayed)!; + assert.equal(first.amountAtomicXmr, observation.amountAtomicXmr); + const replay = await postReviewedDepositPendingCredit(creditRuntime, approval, policy, now + 1); + assert.equal(replay.replayed, true); assert.equal(replay.journalId, first.journalId); + const lateReplay = await postReviewedDepositPendingCredit(creditRuntime, approval, policy, expiresAt + 1); + assert.equal(lateReplay.replayed, true, 'a retry of an already committed review returns its receipt after expiry'); + await assert.rejects(postReviewedDepositPendingCredit(creditRuntime, + { ...approval, subject: { ...subject, amountAtomicXmr: '1' } }, policy, now + 2), /signature/); + + const pendingId = `customer:${buyer.actor}:deposit-pending:${first.creditId}`; + const posted = await f.admin.query('SELECT id,normal,balance::text AS balance FROM wk_custody.accounts WHERE id=ANY($1::text[])', + [[pendingId, 'treasury:monero:pending', `customer:${buyer.actor}:available`]]); + assert.deepEqual(posted.rows.map(row => [row.id, row.normal, row.balance]).sort((a,b) => String(a[0]).localeCompare(String(b[0]))), [ + [pendingId, 'credit', observation.amountAtomicXmr], ['treasury:monero:pending', 'debit', observation.amountAtomicXmr], + ].sort((a,b) => String(a[0]).localeCompare(String(b[0]))), 'pending credit must never enter spendable available balance'); + assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.journals WHERE kind='monero-deposit-pending-credit'")).rows[0].n, 1); + assert.equal((await f.market.audit()).assetsAtomicXmr, observation.amountAtomicXmr); + + const missing = await persistMoneroObservationScan(observer, { scanId: randomUUID(), walletFingerprint: wallet, + expectedScanHeight: 100, scannedAt: now + 100, + page: { ...page, fromHeight: 90, walletHeight: 101, throughHeight: 101, incoming: [] } }); + assert.equal(missing.notSeen, 1); + assert.equal(await reconcileDepositCreditReorgHolds(creditRuntime, wallet, now + 110), 1); + assert.equal(await reconcileDepositCreditReorgHolds(creditRuntime, wallet, now + 111), 0, 'hold replay is idempotent'); + const reappeared = await persistMoneroObservationScan(observer, { scanId: randomUUID(), walletFingerprint: wallet, + expectedScanHeight: 101, scannedAt: now + 200, + page: { ...page, fromHeight: 90, walletHeight: 102, throughHeight: 102, + incoming: [{ ...observation, confirmations: 13 }] } }); + assert.equal(reappeared.reappeared, 1); + assert.equal(await reconcileDepositCreditReorgHolds(creditRuntime, wallet, now + 210), 1); + assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.monero_deposit_reorg_holds')).rows[0].n, 2); + assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.journals')).rows[0].n, 1, + 'reorg/reappearance must not reverse or duplicate the pending credit journal'); + assert.equal((await f.admin.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1', [pendingId])).rows[0].balance, + 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')); + assert.equal((await f.market.audit()).assetsAtomicXmr, observation.amountAtomicXmr); + } finally { await custodyRuntime.end(); await secondCreditRuntime.end(); await creditRuntime.end(); await observer.end(); } +}); + test('database rejects unbalanced, fractional, negative, historical append and oversized postings at commit', async t => { const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); const c = await f.admin.connect(); @@ -466,7 +572,8 @@ test('exact amounts above JS safe integer survive NUMERIC, JSON signatures and a const c = await f.admin.connect(), large = '9007199254740993'; try { await c.query('BEGIN'); - await c.query("INSERT INTO wk_custody.accounts(id,normal) VALUES('treasury:mock','debit'),($1,'credit')", [`customer:${buyer.actor}:available`]); + await c.query("INSERT INTO wk_custody.accounts(id,normal) VALUES('treasury:mock','debit'),($1,'credit')", + [`customer:${buyer.actor}:available`]); await c.query("INSERT INTO wk_custody.journals(id,kind,created) VALUES('large','fixture',0)"); await c.query("INSERT INTO wk_custody.entries VALUES('large',0,'treasury:mock',$1,0),('large',1,$2,0,$1)", [large, `customer:${buyer.actor}:available`]); await c.query('COMMIT');