diff --git a/docs/custodial-market-design.md b/docs/custodial-market-design.md index 07c8ad8..c066571 100755 --- a/docs/custodial-market-design.md +++ b/docs/custodial-market-design.md @@ -19,7 +19,7 @@ The operator has authorized proceeding with implementation instead of the previo **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. -**Read-only Monero observation groundwork:** [server/market/monero-observer.ts](../server/market/monero-observer.ts) defines a narrow mainnet wallet-RPC client for mainnet primary-address verification and bounded incoming-transfer scans. It requires explicit opt-in and a protected external config file, uses authenticated requests with strict HTTPS/literal-loopback-only HTTP, no redirects, bounded response/deadline, JSON-RPC request-ID validation and lossless JSON integer handling. Each observed transfer includes its validated destination address. It exposes no subaddress creation, credit posting, transfer construction, relay, sweep or withdrawal operation and is not wired into server startup/routes. [server/market/monero-observation-store.ts](../server/market/monero-observation-store.ts) persists scan manifests, exact incoming-transfer observations, cursor progress and append-only seen/changed/not-seen/reappeared events in PostgreSQL schema v2, restricted to a dedicated observer DB role separate from the custody API runtime. Confirmation growth updates a projection without emitting an event every poll. A missing output is only a review signal; no automatic journal posting, debit, reversal or customer credit exists. Schema v1→v2 is an explicit additive migration, not an HTTP-runtime migration. The 33-test PostgreSQL suite covers v1 upgrade preservation, least-privilege separation, scan replay/cursor fencing and reorg/reappearance observations. No wallet endpoint, credentials or funds were accessed. This is observation/audit groundwork, not a deposit-address allocator, live deposit processor, or approved mainnet switch. The application only calls read methods, but operators must still use a restricted view-only wallet RPC instance/account; the code cannot prove credential scope. +**Read-only Monero observation and allocation-registry groundwork:** [server/market/monero-observer.ts](../server/market/monero-observer.ts) defines a narrow mainnet wallet-RPC client for mainnet primary-address verification and bounded incoming-transfer scans. It requires explicit opt-in and a protected external config file, uses authenticated requests with strict HTTPS/literal-loopback-only HTTP, no redirects, bounded response/deadline, JSON-RPC request-ID validation and lossless JSON integer handling. Each observed transfer includes its validated destination address. It exposes no subaddress creation, credit posting, transfer construction, relay, sweep or withdrawal operation and is not wired into server startup/routes. [server/market/monero-observation-store.ts](../server/market/monero-observation-store.ts) persists scan manifests, exact incoming-transfer observations, cursor progress and append-only seen/changed/not-seen/reappeared events. **PostgreSQL schema v3** adds [server/market/monero-deposit-allocation-store.ts](../server/market/monero-deposit-allocation-store.ts), which can durably reserve a unique subaddress index for a beneficiary before a future wallet side effect and register a validated mainnet subaddress once. Reservations and beneficiary/index bindings cannot be deleted or reassigned; a registered address cannot be replaced. The registry is currently unreachable to both the custody API and observer roles, and the store is not wired to startup, routes, UI or wallet RPC. A wallet-generation UUID/fingerprint supplied to this groundwork is not proof of wallet ownership. It does not solve wallet-RPC/database crash reconciliation or establish the pinned RPC's per-output identity model. Confirmation growth updates an observation projection without emitting an event every poll. A missing output is only a review signal; no automatic journal posting, debit, reversal or customer credit exists. Schema v1→v3 is an explicit additive migration, not an HTTP-runtime migration. The final disposable PostgreSQL suite passed **35/35**, covering v1/v2 upgrades, concurrent reservations across independent pools, idempotency, immutable registration, role isolation and zero journal postings. The offline suite passed **183/183**, and the test-target build succeeded. No wallet endpoint, credentials or funds were accessed. This is not a working deposit-address allocator, live deposit processor, or approved mainnet switch. The application only calls read methods, but operators must still use a restricted view-only wallet RPC instance/account; the code cannot prove credential scope. Validation: TypeScript/Vite build and 144 offline tests passed in the serial native-RandomX test run; the initial parallel run encountered a peer-gossip timeout. Browser smoke verified a disposable wallet's signed test credit, withdrawal hold and cancellation restoring the exact balance, plus a 390px viewport without horizontal overflow. No real funds were used. Tests and remaining acceptance gates are below; this is not launch readiness. diff --git a/docs/production-plan.md b/docs/production-plan.md index 437a509..17232bf 100755 --- a/docs/production-plan.md +++ b/docs/production-plan.md @@ -20,7 +20,7 @@ Status: active engineering plan; **not launch or payment authorization**. Update **Current design deliverable:** [custodial market design](custodial-market-design.md). Read the workstreams below with this update: pooled customer liabilities replace per-order incoming invoices and external sale payouts in the proposed architecture. Earlier trustless-first research gates are historical, not a prerequisite to investigating custody. Exact ledger, withdrawal, fee, custody/legal and host-attested NFT consensus rules still require approval and evidence; the existing mock domain cannot authorize real balance debits. -**Read-only XMR groundwork:** a disabled-by-default Monero wallet-RPC observer client is implemented for mainnet address validation and bounded incoming scans; an explicit PostgreSQL v1→v2 migration adds durable scan cursors, exact observation records and append-only reorg/reappearance markers under a separate observer database role. Each record includes a validated destination address. The observer/store remain disconnected from startup/routes and cannot allocate deposit addresses, post credits, reverse balances or spend. Missing outputs create review evidence only. Offline fixtures cover strict RPC safety, scan retry idempotency and cursor fencing; the PostgreSQL suite passed 33/33, including upgrade and reorg-observation persistence. No live RPC, wallet credential or funds were used. Remaining deposit milestone: backed-up non-reusable subaddress allocation, binding each credited output to a registered beneficiary/address, authoritative chain inclusion and reorg policy, exact idempotent journal crediting plus audited reversal/hold handling, controlled stagenet rehearsal, and independent accounting/security review before real deposits. +**Read-only XMR groundwork:** a disabled-by-default Monero wallet-RPC observer client is implemented for mainnet address validation and bounded incoming scans. Explicit PostgreSQL v1→v3 migrations add durable scan cursors, exact observation records, append-only reorg/reappearance markers, and a private beneficiary-bound subaddress-index registry. The registry can reserve a never-reused index before a future wallet RPC side effect, then register a validated mainnet subaddress once. It is not wired to startup/routes/UI/RPC; existing custody and observer roles cannot read or write it. The registry does not prove wallet-generation identity, solve RPC/database crash recovery, or determine unique output identity. Observation records include validated destination addresses. Missing outputs create review evidence only; neither observer nor registry can post credits, reverse balances or spend. Verification passed: Docker test-target build; **183/183** offline tests; **35/35** disposable PostgreSQL integration tests, including v1/v2 upgrades, cross-pool concurrent index reservation, replay, immutable registration, least-privilege isolation and zero journal postings. No live RPC, wallet credential or funds were used. Remaining deposit milestone: reconcile reserved allocations against the pinned wallet RPC and backed-up wallet generation; establish stable per-output identity and canonical inclusion/lock/reorg semantics; then design exact idempotent journal crediting plus audited reversal/hold handling, controlled stagenet rehearsal, and independent accounting/security review before any real deposits. ## 1A. MVP release boundary — proposed safe first production-hosted release diff --git a/server/market/monero-deposit-allocation-store.ts b/server/market/monero-deposit-allocation-store.ts new file mode 100755 index 0000000..d1926c8 --- /dev/null +++ b/server/market/monero-deposit-allocation-store.ts @@ -0,0 +1,196 @@ +import { createHash, randomUUID } from 'node:crypto'; +import type { Pool, PoolClient } from 'pg'; +import { z } from 'zod'; +import { validateXmrSubaddress } from './xmr.ts'; + +const uuid = z.string().uuid(); +const hex64 = z.string().regex(/^[a-f0-9]{64}$/); +const index = z.number().int().min(0).max(0xffff_ffff).safe(); +const reserveInputSchema = z.object({ + requestId: uuid, + custodianId: uuid, + marketNetwork: z.enum(['wikideck-randomx-mainnet-1', 'wikideck-randomx-testnet-6']), + marketChainId: hex64, + beneficiary: hex64, + walletGenerationId: uuid, + walletFingerprint: hex64, + accountIndex: index, + reservedAt: z.number().int().nonnegative().safe(), +}).strict(); +const registerInputSchema = z.object({ + allocationId: uuid, + walletGenerationId: uuid, + moneroAddress: z.string().length(95), + registeredAt: z.number().int().nonnegative().safe(), +}).strict(); + +export type MoneroDepositAllocation = { + allocationId: string; + requestId: string; + custodianId: string; + marketNetwork: 'wikideck-randomx-mainnet-1' | 'wikideck-randomx-testnet-6'; + marketChainId: string; + beneficiary: string; + walletGenerationId: string; + walletFingerprint: string; + moneroNetwork: 'mainnet'; + accountIndex: number; + addressIndex: number; + status: 'reserved' | 'registered'; + moneroAddress: string | null; + reservedAt: number; + registeredAt: number | null; +}; + +type AllocationRow = { + allocation_id: string; + request_id: string; + custodian_id: string; + market_network: 'wikideck-randomx-mainnet-1' | 'wikideck-randomx-testnet-6'; + market_chain_id: string; + beneficiary: string; + wallet_generation_id: string; + wallet_fingerprint: string; + monero_network: 'mainnet'; + account_index: string; + address_index: string; + status: 'reserved' | 'registered'; + monero_address: string | null; + reserved_at: string; + registered_at: string | null; + request_digest: string; +}; + +function toAllocation(row: AllocationRow): MoneroDepositAllocation { + return { + allocationId: row.allocation_id, + requestId: row.request_id, + custodianId: row.custodian_id, + marketNetwork: row.market_network, + marketChainId: row.market_chain_id, + beneficiary: row.beneficiary, + walletGenerationId: row.wallet_generation_id, + walletFingerprint: row.wallet_fingerprint, + moneroNetwork: row.monero_network, + accountIndex: Number(row.account_index), + addressIndex: Number(row.address_index), + status: row.status, + moneroAddress: row.monero_address, + reservedAt: Number(row.reserved_at), + registeredAt: row.registered_at === null ? null : Number(row.registered_at), + }; +} + +function requestDigest(input: z.infer): string { + return createHash('sha256').update(JSON.stringify([ + 'wikideck-monero-deposit-allocation-v1', input.custodianId, input.marketNetwork, + input.marketChainId, input.beneficiary, input.walletGenerationId, + input.walletFingerprint, input.accountIndex, + ])).digest('hex'); +} + +async function finishTransaction(client: PoolClient, commit: boolean): Promise { + if (commit) await client.query('COMMIT'); + else await client.query('ROLLBACK').catch(() => {}); +} + +/** + * Durably reserve a never-reused wallet subaddress index and bind it to a beneficiary before + * any wallet RPC address-generation side effect. This module is deliberately not wired to + * startup, HTTP, a wallet RPC, or financial posting. A reserved row is never reclaimed. + */ +export async function reserveMoneroDepositAllocation( + pool: Pool, + input: unknown, +): Promise<{ allocation: MoneroDepositAllocation; replayed: boolean }> { + const parsed = reserveInputSchema.parse(input); + const digest = requestDigest(parsed); + 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'"); + // Per-wallet/account serialization makes MAX(index)+1 safe across independent processes. + await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))', + [`wikideck-monero-deposit-allocation-v1:${parsed.walletGenerationId}:${parsed.accountIndex}`]); + + const byRequest = await client.query(`SELECT * FROM wk_custody.monero_deposit_allocations + WHERE request_id=$1 FOR UPDATE`, [parsed.requestId]); + if (byRequest.rowCount) { + if (byRequest.rows[0].request_digest !== digest) throw new Error('Deposit allocation request ID was reused with different terms'); + await finishTransaction(client, true); + return { allocation: toAllocation(byRequest.rows[0]), replayed: true }; + } + + const byBeneficiary = await client.query(`SELECT * FROM wk_custody.monero_deposit_allocations + WHERE custodian_id=$1 AND market_network=$2 AND market_chain_id=$3 AND beneficiary=$4 + AND wallet_generation_id=$5 FOR UPDATE`, [parsed.custodianId, parsed.marketNetwork, parsed.marketChainId, + parsed.beneficiary, parsed.walletGenerationId]); + if (byBeneficiary.rowCount) { + if (byBeneficiary.rows[0].request_digest !== digest) throw new Error('Beneficiary already has a different deposit allocation for this wallet generation'); + await finishTransaction(client, true); + return { allocation: toAllocation(byBeneficiary.rows[0]), replayed: true }; + } + + const next = await client.query<{ next_index: string }>(`SELECT (COALESCE(MAX(address_index),-1)+1)::text AS next_index + FROM wk_custody.monero_deposit_allocations WHERE wallet_generation_id=$1 AND account_index=$2`, + [parsed.walletGenerationId, parsed.accountIndex]); + const addressIndex = BigInt(next.rows[0].next_index); + if (addressIndex > 0xffff_ffffn) throw new Error('Monero subaddress index space is exhausted for this account'); + + const inserted = await client.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,request_digest,reserved_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,'mainnet',$9,$10,'reserved',$11,$12) RETURNING *`, + [randomUUID(), parsed.requestId, parsed.custodianId, parsed.marketNetwork, parsed.marketChainId, + parsed.beneficiary, parsed.walletGenerationId, parsed.walletFingerprint, parsed.accountIndex, + addressIndex.toString(), digest, parsed.reservedAt]); + await finishTransaction(client, true); + return { allocation: toAllocation(inserted.rows[0]), replayed: false }; + } catch (error) { + await finishTransaction(client, false); + if (typeof error === 'object' && error !== null && 'code' in error && error.code === '23505') { + throw new Error('Deposit allocation conflicts with an existing beneficiary, wallet index, or address'); + } + throw error; + } finally { + client.release(); + } +} + +/** Register the address returned by a separately managed wallet operation exactly once. */ +export async function registerMoneroDepositSubaddress( + pool: Pool, + input: unknown, +): Promise<{ allocation: MoneroDepositAllocation; replayed: boolean }> { + const parsed = registerInputSchema.parse(input); + const address = await validateXmrSubaddress(parsed.moneroAddress); + 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'"); + const found = await client.query(`SELECT * FROM wk_custody.monero_deposit_allocations + WHERE allocation_id=$1 AND wallet_generation_id=$2 FOR UPDATE`, [parsed.allocationId, parsed.walletGenerationId]); + if (!found.rowCount) throw new Error('Deposit allocation reservation was not found for this wallet generation'); + const row = found.rows[0]; + if (row.status === 'registered') { + if (row.monero_address !== address) throw new Error('Registered deposit address cannot be replaced'); + await finishTransaction(client, true); + return { allocation: toAllocation(row), replayed: true }; + } + if (parsed.registeredAt < Number(row.reserved_at)) throw new Error('Deposit address registration predates its durable reservation'); + const registered = await client.query(`UPDATE wk_custody.monero_deposit_allocations + SET status='registered',monero_address=$3,registered_at=$4 + WHERE allocation_id=$1 AND wallet_generation_id=$2 RETURNING *`, + [parsed.allocationId, parsed.walletGenerationId, address, parsed.registeredAt]); + await finishTransaction(client, true); + return { allocation: toAllocation(registered.rows[0]), replayed: false }; + } catch (error) { + await finishTransaction(client, false); + if (typeof error === 'object' && error !== null && 'code' in error && error.code === '23505') { + throw new Error('Monero deposit address is already registered to an allocation'); + } + throw error; + } finally { + client.release(); + } +} diff --git a/server/market/postgres-schema.ts b/server/market/postgres-schema.ts index 742c8ff..c14710b 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 = 2; +export const CUSTODY_PG_VERSION = 3; export const CUSTODY_WRITE_LOCK = '7140824361'; export const CUSTODY_SCHEMA_V1 = ` CREATE SCHEMA IF NOT EXISTS wk_custody; @@ -127,6 +127,54 @@ CREATE TRIGGER monero_observation_scans_no_update BEFORE UPDATE OR DELETE ON wk_ `; export const CUSTODY_SCHEMA_V2_DIGEST = createHash('sha256') .update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}`).digest('hex'); +export const CUSTODY_MIGRATION_V3 = ` +CREATE TABLE wk_custody.monero_deposit_allocations( + allocation_id uuid PRIMARY KEY, + request_id uuid NOT NULL UNIQUE, + custodian_id uuid NOT NULL, + market_network text NOT NULL CHECK(market_network IN ('wikideck-randomx-mainnet-1','wikideck-randomx-testnet-6')), + market_chain_id text NOT NULL CHECK(market_chain_id ~ '^[a-f0-9]{64}$'), + beneficiary text NOT NULL CHECK(beneficiary ~ '^[a-f0-9]{64}$'), + wallet_generation_id uuid NOT NULL, + wallet_fingerprint text NOT NULL CHECK(wallet_fingerprint ~ '^[a-f0-9]{64}$'), + monero_network text NOT NULL CHECK(monero_network='mainnet'), + account_index bigint NOT NULL CHECK(account_index BETWEEN 0 AND 4294967295), + address_index bigint NOT NULL CHECK(address_index BETWEEN 0 AND 4294967295), + status text NOT NULL CHECK(status IN ('reserved','registered')), + monero_address text UNIQUE CHECK(monero_address IS NULL OR length(monero_address)=95), + request_digest text NOT NULL CHECK(request_digest ~ '^[a-f0-9]{64}$'), + reserved_at bigint NOT NULL CHECK(reserved_at>=0), + registered_at bigint CHECK(registered_at IS NULL OR registered_at>=reserved_at), + CHECK((status='reserved' AND monero_address IS NULL AND registered_at IS NULL) OR + (status='registered' AND monero_address IS NOT NULL AND registered_at IS NOT NULL)), + UNIQUE(wallet_generation_id,account_index,address_index), + UNIQUE(custodian_id,market_network,market_chain_id,beneficiary,wallet_generation_id) +); +CREATE INDEX monero_deposit_allocations_beneficiary + ON wk_custody.monero_deposit_allocations(custodian_id,market_network,market_chain_id,beneficiary); +CREATE FUNCTION wk_custody.monero_deposit_allocations_immutable() RETURNS trigger LANGUAGE plpgsql + SET search_path=wk_custody,pg_catalog AS $$ +BEGIN + IF TG_OP='DELETE' THEN RAISE EXCEPTION 'Monero deposit allocation history cannot be deleted'; END IF; + IF OLD.status<>'reserved' OR NEW.status<>'registered' OR OLD.monero_address IS NOT NULL OR NEW.monero_address IS NULL OR + NEW.allocation_id IS DISTINCT FROM OLD.allocation_id OR NEW.request_id IS DISTINCT FROM OLD.request_id OR + NEW.custodian_id IS DISTINCT FROM OLD.custodian_id OR NEW.market_network IS DISTINCT FROM OLD.market_network OR + NEW.market_chain_id IS DISTINCT FROM OLD.market_chain_id OR NEW.beneficiary IS DISTINCT FROM OLD.beneficiary OR + NEW.wallet_generation_id IS DISTINCT FROM OLD.wallet_generation_id OR NEW.wallet_fingerprint IS DISTINCT FROM OLD.wallet_fingerprint OR + NEW.monero_network IS DISTINCT FROM OLD.monero_network OR NEW.account_index IS DISTINCT FROM OLD.account_index OR + NEW.address_index IS DISTINCT FROM OLD.address_index OR NEW.request_digest IS DISTINCT FROM OLD.request_digest OR + NEW.reserved_at IS DISTINCT FROM OLD.reserved_at OR NEW.registered_at IS NULL OR NEW.registered_at { if (current === 1) { await c.query(CUSTODY_MIGRATION_V2); await c.query('INSERT INTO wk_custody.schema_versions VALUES(2,$1)', [CUSTODY_SCHEMA_V2_DIGEST]); + current = 2; + } + if (current === 2) { + await c.query(CUSTODY_MIGRATION_V3); + await c.query('INSERT INTO wk_custody.schema_versions VALUES(3,$1)', [CUSTODY_SCHEMA_V3_DIGEST]); } if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application'); await verifyCustodySchema(c); @@ -160,7 +213,7 @@ 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]; + const expected = [CUSTODY_SCHEMA_V1_DIGEST, CUSTODY_SCHEMA_V2_DIGEST, CUSTODY_SCHEMA_V3_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'); @@ -189,6 +242,7 @@ export async function grantCustodyRuntime(pool: Pool, role: string): Promise {}); throw e; } diff --git a/server/market/xmr.ts b/server/market/xmr.ts index c0c7dce..3a7d01b 100755 --- a/server/market/xmr.ts +++ b/server/market/xmr.ts @@ -4,7 +4,7 @@ import { parseXmr } from '../../shared/xmr-amount.ts'; const alphabet='123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; const decodedSizes:Record={2:1,3:2,5:3,6:4,7:5,9:6,10:7,11:8}; /** Support only mainnet standard/subaddresses. Integrated/payment-ID addresses need a separate policy. */ -export async function validateXmrAddress(address:string):Promise{ +async function validateMainnetAddressPrefix(address:string):Promise{ if(typeof address!=='string'||address.length!==95)throw new Error('Expected a mainnet standard XMR address or subaddress'); const bytes:number[]=[]; for(let start=0;start{ if(bytes.length!==69||![18,42].includes(bytes[0]))throw new Error('XMR address network/type is not supported'); const decoded=Uint8Array.from(bytes),expected=(await keccak(decoded.slice(0,-4),256)).slice(0,8); if(Buffer.from(decoded.slice(-4)).toString('hex')!==expected)throw new Error('XMR checksum mismatch'); + return bytes[0]; +} +export async function validateXmrAddress(address:string):Promise{ + await validateMainnetAddressPrefix(address); + return address; +} +export async function validateXmrSubaddress(address:string):Promise{ + if(await validateMainnetAddressPrefix(address)!==42)throw new Error('Expected a Monero mainnet subaddress'); return address; } export function atomicXmr(value:string):bigint{ diff --git a/tests/postgres/custody.test.ts b/tests/postgres/custody.test.ts index e28d913..04b017e 100755 --- a/tests/postgres/custody.test.ts +++ b/tests/postgres/custody.test.ts @@ -14,8 +14,10 @@ import { PostgresCustody, auditPostgres } from '../../server/market/postgres-cus 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 } from '../../server/market/postgres-schema.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'; import { persistMoneroObservationScan } from '../../server/market/monero-observation-store.ts'; +import { registerMoneroDepositSubaddress, reserveMoneroDepositAllocation } from '../../server/market/monero-deposit-allocation-store.ts'; import { importSqliteCustody } from '../../server/market/custody-import.ts'; import { postgresConfig, custodyStorage } from '../../server/market/postgres-config.ts'; import { openCustodyStorage } from '../../server/market/custody-storage.ts'; @@ -62,6 +64,19 @@ async function syntheticFeeAddress() { } return encoded; } +async function syntheticSubaddress(fill = 3) { + const prefix = Uint8Array.from([42, ...Array(64).fill(fill)]); + const data = Buffer.concat([prefix, Buffer.from((await keccak(prefix, 256)).slice(0, 8), 'hex')]); + const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + const sizes: Record = { 8: 11, 5: 7 }; + let encoded = ''; + for (let i = 0; i < data.length; i += 8) { + const chunk = data.subarray(i, i + 8); let n = BigInt(`0x${chunk.toString('hex')}`), text = ''; + while (n) { text = alphabet[Number(n % 58n)] + text; n /= 58n; } + encoded += text.padStart(sizes[chunk.length], '1'); + } + return encoded; +} function saleTranscript(order: CustodyOrder, custodianId: string, feeBps = 250) { const policy = { coinbaseMaturity: 2, lockDepth: 2, outcomeDepth: 3, maxLockBlocks: 30, maxTransactions: 64 }; const context = saleContext(policy); @@ -175,7 +190,7 @@ test('explicit PostgreSQL migrations are idempotent and runtime role cannot alte assert.equal((await f.market.audit()).assetsAtomicXmr, '1000000000000'); }); -test('custody schema v1 upgrades additively to observation-only v2 without dropping existing metadata', async t => { +test('custody schema v1 upgrades additively through v3 without dropping existing metadata', async t => { const admin = new Pool(config); await admin.query('DROP SCHEMA IF EXISTS wk_custody CASCADE'); const client = await admin.connect(); @@ -189,18 +204,96 @@ test('custody schema v1 upgrades additively to observation-only v2 without dropp 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]); + assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3]); assert.equal((await admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='v1-preserved-marker'")).rows[0].value, 'existing-test-data'); for (const table of ['monero_observer_state', 'monero_transfer_observations', 'monero_observation_events', 'monero_observation_scans']) { assert.ok((await admin.query('SELECT to_regclass($1) AS name', [`wk_custody.${table}`])).rows[0].name); } + assert.ok((await admin.query("SELECT to_regclass('wk_custody.monero_deposit_allocations') AS name")).rows[0].name); } finally { await admin.end(); } }); +test('custody schema v2 upgrades explicitly to v3 address-allocation registry', async t => { + const admin = new Pool(config); + await admin.query('DROP SCHEMA IF EXISTS wk_custody CASCADE'); + const client = await admin.connect(); + try { + await client.query('BEGIN'); await client.query(CUSTODY_SCHEMA_V1); + await client.query('INSERT INTO wk_custody.schema_versions VALUES(1,$1)', [CUSTODY_SCHEMA_V1_DIGEST]); + await client.query(CUSTODY_MIGRATION_V2); + await client.query('INSERT INTO wk_custody.schema_versions VALUES(2,$1)', [CUSTODY_SCHEMA_V2_DIGEST]); + await client.query("INSERT INTO wk_custody.custody_meta VALUES('v2-marker','preserve')"); + 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]); + 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(); } +}); + +test('deposit allocation reserves unique beneficiary-bound indexes before RPC and only registers an immutable subaddress', async t => { + const f = await fixture(t); + await f.admin.query("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='monero_allocation_observer') THEN CREATE ROLE monero_allocation_observer LOGIN; END IF; END $$"); + await grantMoneroObserverRuntime(f.admin, 'monero_allocation_observer'); + const observer = new Pool({ ...config, user: 'monero_allocation_observer' }); + const custodyRuntime = new Pool(f.runtime); + const secondAllocatorProcess = new Pool(config); + const generation = randomUUID(), wallet = 'f'.repeat(64), reservedAt = 1_800_000_000; + const input = (requestId: string, beneficiary: string) => ({ requestId, custodianId: f.market.id, + marketNetwork: NETWORK, marketChainId: CHAIN_ID, beneficiary, walletGenerationId: generation, + walletFingerprint: wallet, accountIndex: 0, reservedAt }); + try { + const firstRequest = randomUUID(); + const firstInput = input(firstRequest, buyer.actor); + const first = await reserveMoneroDepositAllocation(f.admin, firstInput); + assert.equal(first.allocation.addressIndex, 0); + assert.equal(first.allocation.status, 'reserved'); + assert.equal(first.allocation.moneroAddress, null, 'reservation is committed before any address is available'); + assert.equal(first.replayed, false); + const replay = await reserveMoneroDepositAllocation(f.admin, firstInput); + assert.equal(replay.replayed, true); assert.deepEqual(replay.allocation, first.allocation); + assert.deepEqual((await reserveMoneroDepositAllocation(f.admin, input(randomUUID(), buyer.actor))).allocation, + first.allocation, 'beneficiary receives the same reserved allocation rather than a new index'); + await assert.rejects(reserveMoneroDepositAllocation(f.admin, input(firstRequest, seller.actor)), /reused with different terms/); + await assert.rejects(reserveMoneroDepositAllocation(f.admin, { ...firstInput, accountIndex: 1 }), /reused with different terms/); + + const concurrent = await Promise.all([ + reserveMoneroDepositAllocation(f.admin, input(randomUUID(), other.actor)), + reserveMoneroDepositAllocation(secondAllocatorProcess, input(randomUUID(), seller.actor)), + ]); + assert.deepEqual(concurrent.map(item => item.allocation.addressIndex).sort((a, b) => a - b), [1, 2]); + + const subaddress = await syntheticSubaddress(); + await assert.rejects(registerMoneroDepositSubaddress(f.admin, { allocationId: concurrent[0].allocation.allocationId, + walletGenerationId: generation, moneroAddress: await syntheticFeeAddress(), registeredAt: reservedAt + 1 }), /Expected a Monero mainnet subaddress/); + const registered = await registerMoneroDepositSubaddress(f.admin, { allocationId: first.allocation.allocationId, + walletGenerationId: generation, moneroAddress: subaddress, registeredAt: reservedAt + 1 }); + assert.equal(registered.allocation.status, 'registered'); + assert.equal(registered.allocation.moneroAddress, subaddress); + assert.equal((await registerMoneroDepositSubaddress(f.admin, { allocationId: first.allocation.allocationId, + walletGenerationId: generation, moneroAddress: subaddress, registeredAt: reservedAt + 2 })).replayed, true); + await assert.rejects(registerMoneroDepositSubaddress(f.admin, { allocationId: first.allocation.allocationId, + walletGenerationId: generation, moneroAddress: await syntheticSubaddress(4), registeredAt: reservedAt + 3 }), /cannot be replaced/); + await assert.rejects(registerMoneroDepositSubaddress(f.admin, { allocationId: concurrent[0].allocation.allocationId, + walletGenerationId: generation, moneroAddress: subaddress, registeredAt: reservedAt + 4 }), /already registered/); + + await assert.rejects(custodyRuntime.query('SELECT * FROM wk_custody.monero_deposit_allocations'), /permission denied/); + await assert.rejects(observer.query('SELECT * FROM wk_custody.monero_deposit_allocations'), /permission denied/); + await assert.rejects(observer.query("INSERT INTO wk_custody.monero_deposit_allocations(allocation_id) VALUES($1)", [randomUUID()]), /permission denied/); + await assert.rejects(f.admin.query('DELETE FROM wk_custody.monero_deposit_allocations WHERE allocation_id=$1', + [first.allocation.allocationId]), /cannot be deleted/); + await assert.rejects(f.admin.query('UPDATE wk_custody.monero_deposit_allocations SET beneficiary=$2 WHERE allocation_id=$1', + [first.allocation.allocationId, other.actor]), /identity is immutable/); + assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.journals')).rows[0].n, 0, + 'address allocation must never create financial postings'); + } finally { await secondAllocatorProcess.end(); await custodyRuntime.end(); await observer.end(); } +}); + test('Monero scan persistence is idempotent, reorg-visible and never posts customer credit', async t => { const f = await fixture(t); - await f.admin.query('CREATE ROLE monero_observer LOGIN'); + 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; END $$"); await grantMoneroObserverRuntime(f.admin, 'monero_observer'); await assert.rejects(grantMoneroObserverRuntime(f.admin, 'custody_runtime'), /separate role/); const runtime = new Pool({ ...f.runtime, user: 'monero_observer' });