V2.1 #21

Merged
eric merged 76 commits from v2.1 into main 2026-11-01 22:07:57 +00:00
13 changed files with 605 additions and 17 deletions
Showing only changes of commit 9f82f050b4 - Show all commits
+3 -1
View File
@@ -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. - 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. - **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 ## 2. Identity and trust boundaries
+1 -1
View File
@@ -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. 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 ## Storage and transaction guarantees
+3 -1
View File
@@ -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. **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 ## 1A. MVP release boundary — proposed safe first production-hosted release
+6 -2
View File
@@ -2,7 +2,7 @@ import { Pool } from 'pg';
import { open, unlink, mkdir } from 'node:fs/promises'; import { open, unlink, mkdir } from 'node:fs/promises';
import { resolve, join } from 'node:path'; import { resolve, join } from 'node:path';
import { postgresConfig } from '../server/market/postgres-config.ts'; 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 { importSqliteCustody } from '../server/market/custody-import.ts';
import { PostgresCustody } from '../server/market/postgres-custody.ts'; import { PostgresCustody } from '../server/market/postgres-custody.ts';
import { IS_MAINNET } from '../shared/network.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. // 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'); if (IS_MAINNET) throw new Error('Only sandbox financial schema operations are implemented');
const [action, argument] = process.argv.slice(2); 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 { try {
const config = await postgresConfig(); const config = await postgresConfig();
if (action === 'audit') { if (action === 'audit') {
@@ -22,6 +22,10 @@ try {
try { try {
if (action === 'migrate') { await migrateCustody(pool); console.log('Custody PostgreSQL schema verified/migrated. No funds activated.'); } 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-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 === 'resolve-integrity') console.log(JSON.stringify(await resolveCustodyIncident(pool, argument ?? '')));
if (action === 'import-sqlite') { if (action === 'import-sqlite') {
if (!argument) throw new Error('Stopped source node DATA_DIR required'); if (!argument) throw new Error('Stopped source node DATA_DIR required');
+5 -3
View File
@@ -67,7 +67,7 @@ function startWallet(walletFile) {
walletRpc = spawn(binary,args,{shell:false,windowsHide:true,stdio:'ignore',cwd:temp}); walletRpc.on('error',()=>{}); walletRpc = spawn(binary,args,{shell:false,windowsHide:true,stdio:'ignore',cwd:temp}); walletRpc.on('error',()=>{});
} }
async function waitWallet() { async function waitWallet() {
const deadline=Date.now()+15_000; const deadline=Date.now()+60_000;
while(Date.now()<deadline) { while(Date.now()<deadline) {
if(walletRpc.exitCode!==null) throw new Error(`Wallet RPC exited during startup (${walletRpc.exitCode})`); if(walletRpc.exitCode!==null) throw new Error(`Wallet RPC exited during startup (${walletRpc.exitCode})`);
try { await rawRpc('get_version'); return; } catch { await new Promise(r=>setTimeout(r,100)); } try { await rawRpc('get_version'); return; } catch { await new Promise(r=>setTimeout(r,100)); }
@@ -128,9 +128,11 @@ try {
console.log('stage: authorized mTLS subaddress creation'); console.log('stage: authorized mTLS subaddress creation');
assert.equal(sub.status,200); assert.equal(sub.data.result.address_index,1); 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]}); 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'); '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']); 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'); assert.equal(forbiddenStatus,400,'gateway must reject transfer before Wallet RPC');
console.log('stage: forbidden transfer blocked'); console.log('stage: forbidden transfer blocked');
+23
View File
@@ -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;
+20
View File
@@ -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
+65
View File
@@ -1,10 +1,13 @@
import { z } from 'zod'; import { z } from 'zod';
import { createHash } from 'node:crypto';
import type { PoolClient, QueryResult } from 'pg'; import type { PoolClient, QueryResult } from 'pg';
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts'; import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
import { parseAtomicXmr } from '../../shared/xmr-amount.ts'; import { parseAtomicXmr } from '../../shared/xmr-amount.ts';
import { allocateXmrSale } from '../../shared/xmr-pricing.ts'; import { allocateXmrSale } from '../../shared/xmr-pricing.ts';
import { validateXmrAddress } from './xmr.ts'; import { validateXmrAddress } from './xmr.ts';
import { replayReservedSale } from './sale-rehearsal.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. */ /** Codes only: never attach customer IDs, payloads, SQL errors or addresses to operational diagnostics. */
export class CustodyIntegrityError extends Error { 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); 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<DepositCreditRow> = await client.query<DepositCreditRow>(`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, 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 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 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<void
await validateWallets(wallets); await validateWallets(wallets);
if (result.rows.length < 128) break; after = result.rows.at(-1)!.id; if (result.rows.length < 128) break; after = result.rows.at(-1)!.id;
} }
await checkDepositCredits(client, budget);
// Check both directions: a correct order cannot excuse orphan reserves, tasks or terminal postings. // Check both directions: a correct order cannot excuse orphan reserves, tasks or terminal postings.
const checks: [string, string][] = [ const checks: [string, string][] = [
['orphan-purchase-reserve', `SELECT 1 FROM wk_custody.accounts a WHERE a.id LIKE 'customer:%:purchase:%' ['orphan-purchase-reserve', `SELECT 1 FROM wk_custody.accounts a WHERE a.id LIKE 'customer:%:purchase:%'
+254
View File
@@ -0,0 +1,254 @@
import { createHash, randomUUID } from 'node:crypto';
import type { Pool, PoolClient } from 'pg';
import { z } from 'zod';
import { assertXmrRpcAmount, parseAtomicXmr } from '../../shared/xmr-amount.ts';
import { address, verifySignature } from '../testnet/consensus.ts';
import { CUSTODY_WRITE_LOCK } from './postgres-schema.ts';
const hex = z.string().regex(/^[a-f0-9]{64}$/);
const fingerprint = hex;
const publicKey = z.string().min(1).max(256);
const signature = z.string().min(1).max(256);
const uuid = z.string().uuid();
const POLICY_VERSION = 'monero-pending-credit-v1';
const MAX_EVENT_HISTORY = 256;
export type DepositCreditPolicy = {
/** Reviewed key-owner hashes; the signing key remains operator-held and is never stored here. */
approvedReviewerOwners: readonly string[];
/** Provisional floor; a production value still requires explicit approval. */
minimumConfirmations: number;
/** Refuse credit review when observation scans have gone stale. */
maximumObservationAgeMs: number;
};
export type DepositCreditReviewSubject = {
policyVersion: typeof POLICY_VERSION;
walletFingerprint: string;
observationId: string;
outputPublicKey: string;
amountAtomicXmr: string;
blockHeight: number;
confirmations: number;
minimumConfirmations: number;
locked: boolean;
doubleSpendSeen: boolean;
allocationId: string;
beneficiary: string;
observationDigest: string;
observationEventSequence: number;
observationEventHistoryDigest: string;
observerUpdatedAt: number;
};
export type DepositCreditApproval = {
subject: DepositCreditReviewSubject;
reviewerOwner: string;
reviewerPublicKey: string;
nonce: string;
expiresAt: number;
signature: string;
};
export type DepositCreditResult = { creditId: string; journalId: string; beneficiary: string; amountAtomicXmr: string; replayed: boolean };
const subjectSchema = z.object({
policyVersion: z.literal(POLICY_VERSION), walletFingerprint: fingerprint, observationId: z.string().min(129).max(160),
outputPublicKey: hex, amountAtomicXmr: z.string().regex(/^[1-9]\d{0,19}$/),
blockHeight: z.number().int().positive().safe(), confirmations: z.number().int().positive().safe(),
minimumConfirmations: z.number().int().min(10).max(100_000), locked: z.boolean(), doubleSpendSeen: z.boolean(),
allocationId: uuid, beneficiary: hex, observationDigest: hex,
observationEventSequence: z.number().int().nonnegative().safe(), observationEventHistoryDigest: hex,
observerUpdatedAt: z.number().int().nonnegative().safe(),
}).strict();
const approvalSchema = z.object({ subject: subjectSchema, 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 {
return JSON.stringify(['wikideck-monero-pending-credit-review-v1', reviewerOwner, nonce, expiresAt,
subject.policyVersion, 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]);
}
function validatePolicy(policy: DepositCreditPolicy): void {
if (!Array.isArray(policy.approvedReviewerOwners) || !policy.approvedReviewerOwners.length ||
policy.approvedReviewerOwners.length > 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<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
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<DepositCreditReviewSubject> {
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<DepositCreditResult> {
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<number> {
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(); }
}
+3 -2
View File
@@ -54,8 +54,9 @@ const incomingOutputSchema = z.object({
pubkey: z.string().regex(/^[a-f0-9]{64}$/), pubkey: z.string().regex(/^[a-f0-9]{64}$/),
}).passthrough(); }).passthrough();
const incomingOutputsResultSchema = z.object({ const incomingOutputsResultSchema = z.object({
transfers: z.array(incomingOutputSchema).max(MAX_TRANSFERS), // v0.18.5.4 omits default-valued empty arrays/status from JSON-RPC serialization.
status: z.literal('OK'), transfers: z.array(incomingOutputSchema).max(MAX_TRANSFERS).default([]),
status: z.literal('OK').optional(),
}).passthrough(); }).passthrough();
export type MoneroIncomingObservation = { export type MoneroIncomingObservation = {
+97 -3
View File
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import type { Pool, PoolClient } from 'pg'; 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_WRITE_LOCK = '7140824361';
export const CUSTODY_SCHEMA_V1 = ` export const CUSTODY_SCHEMA_V1 = `
CREATE SCHEMA IF NOT EXISTS wk_custody; 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') 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'); .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. */ /** Retained alias for callers that need the initial schema definition in disposable fixtures. */
export const CUSTODY_SCHEMA = CUSTODY_SCHEMA_V1; 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. */ /** Explicit operator migration; the HTTP runtime never creates or upgrades schema. */
export async function migrateCustody(pool: Pool): Promise<void> { export async function migrateCustody(pool: Pool): Promise<void> {
@@ -228,6 +284,11 @@ export async function migrateCustody(pool: Pool): Promise<void> {
if (current === 3) { if (current === 3) {
await c.query(CUSTODY_MIGRATION_V4); await c.query(CUSTODY_MIGRATION_V4);
await c.query('INSERT INTO wk_custody.schema_versions VALUES(4,$1)', [CUSTODY_SCHEMA_V4_DIGEST]); 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'); if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application');
await verifyCustodySchema(c); await verifyCustodySchema(c);
@@ -241,7 +302,8 @@ export async function verifyCustodySchema(c: PoolClient): Promise<void> {
} }
async function verifyCustodySchemaHistory(c: PoolClient): Promise<number> { async function verifyCustodySchemaHistory(c: PoolClient): Promise<number> {
const rows = await c.query('SELECT version,digest FROM wk_custody.schema_versions ORDER BY version'); 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) => if (!rows.rowCount || rows.rowCount > expected.length || rows.rows.some((row, index) =>
Number(row.version) !== index + 1 || row.digest !== expected[index])) { Number(row.version) !== index + 1 || row.digest !== expected[index])) {
throw new Error('Custody PostgreSQL schema version/digest history mismatch; explicit migration required'); 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; } } catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
finally { c.release(); } 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<void> {
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(); }
}
+14
View File
@@ -114,6 +114,20 @@ test('observer fails closed on missing, duplicated, or misattributed incoming ou
await assert.rejects(createMoneroWalletObserver(config, malformed).scanIncoming(90)); 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 () => { test('deposit-address RPC client is narrowly allow-listed and creates only one verified subaddress', async () => {
const subaddress = await syntheticSubaddress(); const subaddress = await syntheticSubaddress();
const calls: { method: string; params: unknown }[] = []; const calls: { method: string; params: unknown }[] = [];
+111 -4
View File
@@ -15,9 +15,11 @@ import { CustodyIntegrityError } from '../../server/market/custody-integrity.ts'
import { resolveCustodyIncident } from '../../server/market/custody-incident.ts'; import { resolveCustodyIncident } from '../../server/market/custody-incident.ts';
import { CustodyMarket } from '../../server/market/custody.ts'; import { CustodyMarket } from '../../server/market/custody.ts';
import { migrateCustody, grantCustodyRuntime, grantMoneroObserverRuntime, CUSTODY_SCHEMA_V1, CUSTODY_SCHEMA_V1_DIGEST, 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 { persistMoneroObservationScan } from '../../server/market/monero-observation-store.ts';
import { moneroWalletFingerprint } from '../../server/market/monero-observer.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, import { createOrRecoverMoneroDepositSubaddress, registerMoneroDepositSubaddress,
reserveMoneroDepositAllocation } from '../../server/market/monero-deposit-allocation-store.ts'; reserveMoneroDepositAllocation } from '../../server/market/monero-deposit-allocation-store.ts';
import { importSqliteCustody } from '../../server/market/custody-import.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 { try {
await migrateCustody(admin); await migrateCustody(admin); await migrateCustody(admin); await migrateCustody(admin);
const versions = await admin.query('SELECT version FROM wk_custody.schema_versions ORDER BY version'); 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, assert.equal((await admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='v1-preserved-marker'")).rows[0].value,
'existing-test-data'); 'existing-test-data');
for (const table of ['monero_observer_state', 'monero_transfer_observations', 'monero_observation_events', 'monero_observation_scans']) { 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 client.query('COMMIT');
await migrateCustody(admin); await migrateCustody(admin); await migrateCustody(admin); await migrateCustody(admin);
const versions = await admin.query('SELECT version FROM wk_custody.schema_versions ORDER BY version'); 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'); 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; } } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
finally { client.release(); await admin.end(); } 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(); } } 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 => { 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 f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' });
const c = await f.admin.connect(); 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'; const c = await f.admin.connect(), large = '9007199254740993';
try { try {
await c.query('BEGIN'); 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.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("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'); await c.query('COMMIT');