From 2ecb42c0198161f23d24672905a0f1a7c71c99df Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Thu, 22 Oct 2026 17:26:13 -0400 Subject: [PATCH] Durable, beneficiary-bound subaddress index reservation --- docs/real-xmr-pilot-deployment.md | 13 +- docs/real-xmr-pilot-plan.md | 12 +- scripts/custody-db.ts | 17 +- server/market/custody-db-args.ts | 9 +- server/market/pilot-custody-identity.ts | 37 ++-- .../market/pilot-monero-deposit-allocation.ts | 168 ++++++++++++++++++ server/market/postgres-schema.ts | 101 ++++++++++- tests/custody-db-args.test.ts | 5 + tests/postgres/custody.test.ts | 96 +++++++++- 9 files changed, 416 insertions(+), 42 deletions(-) create mode 100755 server/market/pilot-monero-deposit-allocation.ts diff --git a/docs/real-xmr-pilot-deployment.md b/docs/real-xmr-pilot-deployment.md index 3ad01c4..85d3656 100755 --- a/docs/real-xmr-pilot-deployment.md +++ b/docs/real-xmr-pilot-deployment.md @@ -18,9 +18,11 @@ The independent private chain uses a fixed pilot genesis time and its own determ This stack does **not** connect to the operator's Monero daemon, create real Monero deposit addresses, scan a wallet, approve real credits, construct or relay withdrawals, or settle NFT sales against active consensus. Having a full node supplies only the blockchain daemon; it is not a Wallet-RPC service, watch-only deposit wallet, or spending wallet. Deposit enrollment and withdrawal request flags remain rejected outside the mainnet profile, and mainnet remains separately gated. Do not expose the service publicly or solicit funds. -### Pilot custody identity groundwork (schema v10) +### Pilot custody identity and durable deposit reservations (schema v11) -Schema v10 adds an immutable, identity-only binding with separate fields for the Wikideck market network/chain (`wikideck-randomx-pilot-1` and the pilot chain ID), Monero network (`mainnet`), custodian UUID, policy version and initialization time. It intentionally does not widen the existing allocation or withdrawal table constraints, initialize the mock sandbox's `custody_meta` identity, create addresses, grant runtime privileges, or enable any HTTP funding routes. +Schema v10 added the immutable identity binding with separate fields for Wikideck market network/chain (`wikideck-randomx-pilot-1` and pilot chain ID) and Monero network (`mainnet`). Schema v11 adds a separate pilot reservation table. A signed beneficiary/wallet request can now reserve a never-reused subaddress index transactionally, with request replay, same-beneficiary recovery, concurrent index allocation, and conflict protection. Mainnet allocation/withdrawal constraints and v1-v10 migration definitions remain unchanged. + +The reservation table has a composite foreign key to the immutable identity row, so direct SQL inserts cannot bind a reservation to a different custodian, Wikideck chain, or Monero network. The focused disposable PostgreSQL lifecycle test verifies index sequencing, replay, concurrent reservations, beneficiary conflict, and denied financial/address access for the restricted role. This implementation still does not create any address. Use a **separate fresh custody database** for this identity exercise. The existing pilot Compose database contains mock sandbox custody and is not eligible; importing or reinterpreting its mock journals as Monero liabilities is prohibited. An operator migration is explicitly gated; identity initialization also refuses populated financial, mock, observer, allocation, credit, release or withdrawal tables. @@ -28,10 +30,13 @@ After an operator creates/provisions a distinct empty database and its schema-ow 1. With the `pilot` profile selected, run `custody:db migrate-pilot-schema` with `WIKIDECK_PILOT_SCHEMA_MIGRATION=explicit-pilot-schema-migration-only`. 2. Run `custody:db initialize-pilot-custody-identity ` with `WIKIDECK_PILOT_MODE=private-mock-only`, `WIKIDECK_PILOT_CUSTODY_IDENTITY=initialize-pilot-custody-v1`, and `WIKIDECK_PILOT_CUSTODY_OPERATIONS=identity-only-no-funds`. +3. If using the reservation library against that database, create a separate least-privilege role out of band and run `custody:db grant-pilot-deposit-reservation-runtime ` with `WIKIDECK_PILOT_CUSTODY_OPERATIONS=reservation-only-no-addresses`. -The schema migration does not grant runtime roles. The identity command is one-time and replay-idempotent only for the same pilot chain and custodian. The CLI refuses other custody actions on the pilot profile; mainnet keeps its existing separate controls. This is groundwork only: no customer deposits, financial posting, withdrawal intent or send is enabled by these commands. +The schema migration does not grant runtime roles. The reservation role can read schema-version digests and the immutable pilot identity, execute a fixed isolation predicate, and read/insert immutable reservation rows. It cannot read custody metadata, balances, journals, mock custody, observer data, withdrawal intents, or alter reservation history. The isolation predicate uses a fixed-search-path schema-owner function; the role does not receive broad SELECT access just to check that the dedicated database remains unpopulated. -The signed pilot deposit-request contract is also implemented as a validation-only library boundary. It explicitly signs both the pilot market identity and Monero Mainnet identity, then verifies the initialized identity and wallet-generation/fingerprint/account context. It returns an allocation intent digest only; it does not reserve an index, call Wallet-RPC, return an address, persist an allocation, or expose an HTTP route. Existing mainnet enrollment remains unchanged. Do not treat a valid signature or this validator as approval to accept funds. +The CLI refuses other custody actions on the pilot profile; mainnet keeps its existing separate controls. + +The library path verifies the signed pilot request, checks both network identities and configured wallet-generation/fingerprint/account, then reserves an index. It intentionally stops there: no Wallet-RPC call, address creation/registration, address response, observer, credit, withdrawal, or HTTP enrollment route exists. A durable reserved index is never reclaimed; wallet-side index advancement or lost RPC responses must be reconciled before address-generation support is added. Existing mainnet enrollment remains unchanged. Do not treat a reserved index or valid signature as approval to accept funds. ### Reusing the operator-hosted daemon diff --git a/docs/real-xmr-pilot-plan.md b/docs/real-xmr-pilot-plan.md index a66fc0b..9cba6a4 100755 --- a/docs/real-xmr-pilot-plan.md +++ b/docs/real-xmr-pilot-plan.md @@ -7,9 +7,9 @@ Updated: 2026-10-22. Implementation plan for a private, operator-supervised func - **Step 1 started; mock-only foundation implemented.** Added a distinct `pilot` profile with its own network/genesis identity, explicit private-mock-only startup opt-in, separate local ledger/market/custody paths and browser wallet database, and a loopback-bound PostgreSQL sandbox stack. Setup and limits are in [the pilot deployment guide](real-xmr-pilot-deployment.md). Testnet v6 and mainnet candidate identities/gates were left intact. - **Operator infrastructure update:** a Monero full node is hosted on the operator's network and is intended to be reused; the pilot does not need to deploy or synchronize another daemon. Its network mode, RPC reachability from the pilot host/container, authentication/transport boundary and sync health still need verification. This does not provide Wallet-RPC, deposit observation, address allocation or a spending wallet. - **Next integration slice implemented:** added an operator-triggered, read-only `monerod get_info` probe, opt-in Compose check service, and mocked regression coverage. The Linux test image builds and focused probe/deployment tests pass (6/6). The probe validates expected Mainnet/Stagenet identity and synchronization, but has not been run against the operator's node because its private endpoint/configuration has not been provided. It does not enable wallet access, addresses, deposits, or credits. See [the probe setup and security boundary](real-xmr-pilot-deployment.md). -- **Pilot identity/schema slice implemented:** schema v10 additively records a separate identity binding `wikideck-randomx-pilot-1` + current pilot chain ID to Monero `mainnet`, with an immutable `identity-only` activation status. Explicit pilot-only schema-migration and identity-initialization CLI actions require exact no-funds acknowledgements, reject non-pilot profiles, and refuse any target DB with pre-existing mock/financial/deposit data. No runtime roles/grants or deposit/withdrawal routes were added. Existing v1-v9 migration definitions/digests remain unchanged. -- **Pilot deposit-contract slice implemented:** added a separate signed pilot request schema that explicitly binds the Wikideck pilot network/chain, Monero Mainnet, custodian, beneficiary and watch-only wallet generation. Its validator checks signature, expiry, identity-only status and wallet context, and returns only a validated intent digest—no address/index or RPC/DB side effect. It is not connected to HTTP or an allocator. -- **Validation:** the latest serial offline suite passed 189/189; the new pilot contract tests passed 3/3. PostgreSQL integration suite passed 42/42 after schema v10, including v1/v2 upgrades, identity replay/mismatch checks, dirty-database rejection, and no role access to the identity table. The isolated pilot identity operation itself requires a separate fresh database and has not been run outside disposable tests. +- **Pilot identity/schema slice implemented:** schema v10 additively records a separate identity binding `wikideck-randomx-pilot-1` + current pilot chain ID to Monero `mainnet`, with an immutable `identity-only` activation status. Schema v11 adds separate immutable pilot reservation rows. Explicit pilot-only migration, identity, and reservation-role CLI actions require exact acknowledgements, reject non-pilot profiles, and refuse reused financial databases. +- **Durable pilot allocation implemented:** a signed pilot request now reserves the next never-reused index transactionally, serializes concurrent allocators, replays idempotently, and rejects request/beneficiary conflicts. PostgreSQL enforces the reservation's custodian, market-chain, and Monero-network binding with a composite foreign key to the immutable pilot identity. A dedicated role can read schema digests and pilot identity, execute the isolation check, and read/insert reservations only; it cannot read balances, register addresses, or access observation, credit, or withdrawal tables. This code does not call Wallet-RPC, return a Monero address, or connect to HTTP. +- **Validation:** focused PostgreSQL allocator lifecycle passed, covering first reservation, request replay, same-beneficiary recovery, concurrent index allocation, conflict rejection, and least-privilege denials. The schema v1-to-v11 migration test passed and the Linux test image builds. The serial offline suite passed 189/189 after the allocator implementation; no operator database or real node was accessed. - **Step 1 is not complete.** The current pilot stack does not connect to that external daemon and still has no Wallet-RPC gateway, observer, real-fund deposit or withdrawal path. It is useful for isolated UI/mock-flow work only and must not receive funds. - **Validation history:** earlier pilot-only checks passed 10/10. An earlier full-suite attempt had a transient RandomX peer-gossip failure; the most recent serial offline suite subsequently exited 0. - **Steps 2–5:** not started. Existing deposit/withdrawal APIs still require the separately gated mainnet profile; the new pilot record is identity metadata only. Sale-lock transactions remain candidate-only and are not accepted by active consensus. @@ -25,7 +25,7 @@ Use the existing pooled-custody architecture, PostgreSQL accounting, native Mone ## Reuse what exists - Exact atomic-XMR prices and fee calculations. -- PostgreSQL schema v10 and accounting/reorg records. +- PostgreSQL schema v11 and accounting/reorg records. - Signed deposit enrollment, observation worker and reviewed credit/release commands. - Signed balance reads and withdrawal reservation/cancellation APIs. - Marketplace/custody UI and executable sale-lock/accounting models. @@ -46,7 +46,7 @@ One Linux host, one authoritative market writer, one treasury tool/worker, fixed - Add one pilot deployment definition using existing Docker patterns: app, PostgreSQL, observer and private Wallet-RPC gateway. Reuse the operator-hosted full node over a verified private network connection; do not add a duplicate daemon service. Connect the observer/watch-only wallet and separate spending wallet explicitly. Keep daemon and Wallet-RPC endpoints private, authenticated and unavailable from the public Internet; supply credentials through protected files/secrets, not checked-in Compose values. - Include schema initialization, role grants and required configuration in a short setup procedure. Keep spending keys outside the web app and Wallet RPC off the public Internet. -**Progress:** A separate mock-only pilot network/profile, launch opt-in, profile-isolated local storage, PostgreSQL deployment, and opt-in read-only daemon probe now exist. The probe awaits the operator's protected endpoint config. Additive schema v10 and a pilot-only CLI bind the separate Wikideck pilot chain to Monero Mainnet in a fresh identity-only database; this grants no runtime access and cannot be used to enroll or credit deposits. Observer/Wallet-RPC roles and all real-fund integrations remain outstanding. See [pilot deployment status and startup](real-xmr-pilot-deployment.md). +**Progress:** A separate mock-only pilot network/profile, launch opt-in, profile-isolated local storage, PostgreSQL deployment, and opt-in read-only daemon probe now exist. The probe awaits the operator's protected endpoint config. Additive schema v11 binds the pilot chain to Monero Mainnet and durably reserves signed beneficiary-bound address indexes in a dedicated database/role. The allocator stops before Wallet-RPC: it does not create/register or return addresses, start observation, credit funds, or enable HTTP enrollment. Observer, Wallet-RPC and real-fund integrations remain outstanding. See [pilot deployment status and startup](real-xmr-pilot-deployment.md). **Result:** start the stack, open the UI, create two wallets and obtain a test card; verify the hosted daemon's intended network, synchronization and private RPC reachability from the pilot runtime. Do not start a duplicate daemon. Prepare the watch-only/observer and separately controlled spending-wallet services while application development proceeds. A Stagenet rehearsal still requires a Stagenet daemon endpoint; an operator's Mainnet daemon cannot substitute for it. @@ -121,4 +121,4 @@ Keep the functional essentials: signed authorization, correct network binding, p ## Next coding task -**Next: add the separate durable pilot allocation and observer identity path, then test it only with disposable fixtures—still without enabling an HTTP enrollment route or creating addresses.** The signed pilot request contract now defines the dual-network authorization boundary. Next migration/role work must preserve `identity-only`, separate pilot records from mock custody, and reject credit until reviewed funding, withdrawal execution and recovery are ready. Run the read-only daemon check when the protected endpoint config is available; no duplicate daemon is planned. Continue treasury execution and active sale-lock settlement as parallel tracks once their shared identity boundary is defined. Completion means a buyer purchasing a card and the seller receiving withdrawable XMR—not another scaffold or release checklist. +**Next: implement Wallet-RPC create/recover for durable pilot reservations through the existing private mTLS wallet gateway, with lost-response recovery and out-of-band index-consumption tests.** Do not expose enrollment over HTTP or enable credits. Keep addresses private and operator-triggered until actual withdrawals, recovery, and financial review are ready. Run the daemon health check when protected endpoint config is available; no duplicate daemon is planned. Keep the deployment mock-only until network binding, reviewed credit, actual withdrawal execution, recovery, and sale settlement are connected. Continue treasury execution and active sale-lock settlement as parallel tracks once their shared identity boundary is defined. Completion means a buyer purchasing a card and the seller receiving withdrawable XMR—not another scaffold or release checklist. diff --git a/scripts/custody-db.ts b/scripts/custody-db.ts index 4f5867c..1267da8 100755 --- a/scripts/custody-db.ts +++ b/scripts/custody-db.ts @@ -3,7 +3,8 @@ import { open, unlink, mkdir } from 'node:fs/promises'; import { resolve, join } from 'node:path'; import { postgresConfig } from '../server/market/postgres-config.ts'; import { migrateCustody, grantCustodyRuntime, grantMoneroObserverRuntime, - grantMoneroDepositCreditRuntime, grantMoneroDepositAllocatorRuntime, grantMoneroWithdrawalRuntime } from '../server/market/postgres-schema.ts'; + grantMoneroDepositCreditRuntime, grantMoneroDepositAllocatorRuntime, grantMoneroWithdrawalRuntime, + grantPilotDepositReservationRuntime } from '../server/market/postgres-schema.ts'; import { initializeMainnetCustodyIdentity, parseMainnetCustodianId } from '../server/market/mainnet-custody-identity.ts'; import { importSqliteCustody } from '../server/market/custody-import.ts'; import { PostgresCustody } from '../server/market/postgres-custody.ts'; @@ -13,7 +14,8 @@ import { resolveCustodyIncident } from '../server/market/custody-incident.ts'; import { loadMoneroDepositReviewApproval, loadMoneroDepositReviewPolicy } from '../server/market/monero-deposit-review-config.ts'; import { assertCustodyDbActionAllowedOnNetwork, assertMainnetSchemaMigrationAcknowledged, assertMainnetCustodyOperationAcknowledged, assertPilotCustodyActionAllowed, assertPilotSchemaMigrationAcknowledged, - assertPilotCustodyIdentityAcknowledged, parseCustodyDbArguments } from '../server/market/custody-db-args.ts'; + assertPilotCustodyIdentityAcknowledged, assertPilotReservationGrantAcknowledged, + parseCustodyDbArguments } from '../server/market/custody-db-args.ts'; import { prepareDepositCreditReview, postReviewedDepositPendingCredit, prepareDepositCreditReleaseReview, releaseReviewedDepositCredit, reconcileDepositCreditReorgHolds, prepareDepositReorgResolution, resolveReleasedDepositReorgHold, type DepositCreditApproval, type DepositCreditReleaseApproval, @@ -25,6 +27,7 @@ const depositActions = ['deposit-prepare-credit', 'deposit-stage-credit', 'depos 'deposit-reconcile-holds', 'deposit-prepare-resolve-hold', 'deposit-resolve-hold']; const custodyActions = ['migrate', 'migrate-mainnet-schema', 'initialize-mainnet-custody-identity', 'migrate-pilot-schema', 'initialize-pilot-custody-identity', 'grant-runtime', 'grant-monero-observer-runtime', + 'grant-pilot-deposit-reservation-runtime', 'grant-monero-withdrawal-runtime', 'grant-monero-deposit-allocator-runtime', 'grant-monero-credit-runtime', 'import-sqlite', 'audit', 'resolve-integrity']; @@ -36,6 +39,7 @@ assertMainnetCustodyOperationAcknowledged(action, process.env.WIKIDECK_MAINNET_C assertPilotSchemaMigrationAcknowledged(action, process.env.WIKIDECK_PILOT_SCHEMA_MIGRATION); assertPilotCustodyIdentityAcknowledged(action, process.env.WIKIDECK_PILOT_CUSTODY_IDENTITY, process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS); +assertPilotReservationGrantAcknowledged(action, process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS); if (action === 'initialize-mainnet-custody-identity' && process.env.WIKIDECK_MAINNET_CUSTODY_IDENTITY !== 'initialize-mainnet-custody-v1') { throw new Error('Mainnet custody identity initialization requires WIKIDECK_MAINNET_CUSTODY_IDENTITY=initialize-mainnet-custody-v1'); } @@ -72,6 +76,15 @@ try { custodianId: identity.custodianId, activation: identity.activationStatus, deposits: 'disabled', withdrawals: 'disabled' })); } + if (action === 'grant-pilot-deposit-reservation-runtime') { + if (!IS_PILOT || process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS !== 'reservation-only-no-addresses' || extraArgument) { + throw new Error('Pilot reservation grant requires the pilot profile, reservation-only acknowledgement, and one role name'); + } + const identity = await pool.query('SELECT 1 FROM wk_custody.pilot_custody_identity WHERE singleton=1'); + if (!identity.rowCount) throw new Error('Initialize the separate pilot custody identity before granting reservation access'); + await grantPilotDepositReservationRuntime(pool, argument ?? ''); + console.log('Pilot durable index-reservation role granted; no Wallet-RPC address creation, credit, or withdrawal access.'); + } if (action === 'grant-runtime') { await grantCustodyRuntime(pool, argument ?? ''); console.log('Runtime grants applied; no credentials printed.'); } if (action === 'grant-monero-observer-runtime') { await grantMoneroObserverRuntime(pool, argument ?? ''); diff --git a/server/market/custody-db-args.ts b/server/market/custody-db-args.ts index 0db3a81..033f84a 100755 --- a/server/market/custody-db-args.ts +++ b/server/market/custody-db-args.ts @@ -4,6 +4,7 @@ export const CUSTODY_DB_ACTION_ARGUMENT_COUNTS = { 'initialize-mainnet-custody-identity': 1, 'migrate-pilot-schema': 0, 'initialize-pilot-custody-identity': 1, + 'grant-pilot-deposit-reservation-runtime': 1, 'grant-runtime': 1, 'grant-monero-observer-runtime': 1, 'grant-monero-deposit-allocator-runtime': 1, @@ -53,7 +54,7 @@ export function assertMainnetCustodyOperationAcknowledged(action: CustodyDbActio } export function assertPilotCustodyActionAllowed(action: CustodyDbAction, isPilot: boolean): void { - const pilotActions = ['migrate-pilot-schema', 'initialize-pilot-custody-identity']; + const pilotActions = ['migrate-pilot-schema', 'initialize-pilot-custody-identity', 'grant-pilot-deposit-reservation-runtime']; if ((isPilot && !pilotActions.includes(action)) || (!isPilot && pilotActions.includes(action))) { throw new Error(isPilot ? 'Pilot profile permits only pilot schema migration and identity-only initialization through this CLI' @@ -61,6 +62,12 @@ export function assertPilotCustodyActionAllowed(action: CustodyDbAction, isPilot } } +export function assertPilotReservationGrantAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void { + if (action === 'grant-pilot-deposit-reservation-runtime' && acknowledgement !== 'reservation-only-no-addresses') { + throw new Error('Pilot reservation role grant requires explicit reservation-only/no-addresses acknowledgement'); + } +} + export function assertPilotSchemaMigrationAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void { if (action === 'migrate-pilot-schema' && acknowledgement !== 'explicit-pilot-schema-migration-only') { throw new Error('Pilot schema migration requires explicit pilot schema-migration acknowledgement'); diff --git a/server/market/pilot-custody-identity.ts b/server/market/pilot-custody-identity.ts index c64ed76..6745d7a 100755 --- a/server/market/pilot-custody-identity.ts +++ b/server/market/pilot-custody-identity.ts @@ -42,30 +42,25 @@ function identityFromRow(row: Record): PilotCustodyIdentity { return identity; } -async function requireEmptyFinancialDatabase(client: PoolClient): Promise { - const result = await client.query(`SELECT - EXISTS(SELECT 1 FROM wk_custody.custody_meta) OR - EXISTS(SELECT 1 FROM wk_custody.accounts) OR - EXISTS(SELECT 1 FROM wk_custody.journals) OR - EXISTS(SELECT 1 FROM wk_custody.entries) OR - EXISTS(SELECT 1 FROM wk_custody.listings) OR - EXISTS(SELECT 1 FROM wk_custody.orders) OR - EXISTS(SELECT 1 FROM wk_custody.withdrawals) OR - EXISTS(SELECT 1 FROM wk_custody.receipts) OR - EXISTS(SELECT 1 FROM wk_custody.outbox) OR - EXISTS(SELECT 1 FROM wk_custody.credits) OR - EXISTS(SELECT 1 FROM wk_custody.monero_observer_state) OR - EXISTS(SELECT 1 FROM wk_custody.monero_transfer_observations) OR - EXISTS(SELECT 1 FROM wk_custody.monero_observation_scans) OR - EXISTS(SELECT 1 FROM wk_custody.monero_deposit_allocations) OR - EXISTS(SELECT 1 FROM wk_custody.monero_deposit_credit_events) OR - EXISTS(SELECT 1 FROM wk_custody.monero_deposit_reorg_holds) OR - EXISTS(SELECT 1 FROM wk_custody.monero_deposit_release_events) OR - EXISTS(SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions) OR - EXISTS(SELECT 1 FROM wk_custody.monero_withdrawal_intents) AS populated`); +async function requireEmptyFinancialDatabase(client: PoolClient, allowPilotIdentity = false): Promise { + const result = await client.query(`SELECT NOT wk_custody.pilot_reservation_database_isolated() AS populated, + (SELECT count(*) FROM wk_custody.pilot_custody_identity) AS pilot_identities`); if (result.rows[0].populated) { throw new Error('Pilot identity requires a separate empty custody database; existing mock or financial data cannot be reused'); } + const identities = Number(result.rows[0].pilot_identities); + if ((!allowPilotIdentity && identities !== 0) || (allowPilotIdentity && identities !== 1)) { + throw new Error('Pilot custody database identity state is inconsistent'); + } + if (!allowPilotIdentity) { + const reservations = await client.query('SELECT EXISTS(SELECT 1 FROM wk_custody.pilot_monero_deposit_reservations) AS populated'); + if (reservations.rows[0].populated) throw new Error('Pilot identity requires an empty pilot reservation table'); + } +} + +/** Recheck the dedicated database boundary before each reservation; only its single identity row is allowed. */ +export async function assertPilotReservationDatabaseEmpty(client: PoolClient): Promise { + await requireEmptyFinancialDatabase(client, true); } /** One-time identity binding only. It does not enable deposit allocation, observation, credit, or withdrawal roles. */ diff --git a/server/market/pilot-monero-deposit-allocation.ts b/server/market/pilot-monero-deposit-allocation.ts new file mode 100755 index 0000000..a4337ba --- /dev/null +++ b/server/market/pilot-monero-deposit-allocation.ts @@ -0,0 +1,168 @@ +import { createHash, randomUUID } from 'node:crypto'; +import type { Pool, PoolClient } from 'pg'; +import { z } from 'zod'; +import { IS_PILOT, NETWORK } from '../../shared/network.ts'; +import { CHAIN_ID } from '../testnet/consensus.ts'; +import type { PilotCustodyIdentity } from './pilot-custody-identity.ts'; +import { assertPilotReservationDatabaseEmpty, verifyPilotCustodyIdentity } from './pilot-custody-identity.ts'; +import { validatePilotMoneroDepositRequest, type PilotDepositRequestContext } from './pilot-monero-deposit-request.ts'; +import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts'; + +const MAX_INDEX = 0xffff_ffff; +const uuidPattern = /^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i; +const hexPattern = /^[a-f0-9]{64}$/; + +export type PilotDepositReservation = { + allocationId: string; + requestId: string; + custodianId: string; + marketNetwork: 'wikideck-randomx-pilot-1'; + marketChainId: string; + moneroNetwork: 'mainnet'; + beneficiary: string; + walletGenerationId: string; + walletFingerprint: string; + accountIndex: number; + addressIndex: number; + requestDigest: string; + allocationDigest: string; + reservedAt: number; +}; + +type ReservationRow = { + allocation_id: string; request_id: string; custodian_id: string; + market_network: 'wikideck-randomx-pilot-1'; market_chain_id: string; monero_network: 'mainnet'; + beneficiary: string; wallet_generation_id: string; wallet_fingerprint: string; + account_index: string; address_index: string; request_digest: string; allocation_digest: string; reserved_at: string; +}; + +export type PilotDepositAllocationIntent = { + requestId: string; custodianId: string; marketNetwork: 'wikideck-randomx-pilot-1'; marketChainId: string; + moneroNetwork: 'mainnet'; beneficiary: string; walletGenerationId: string; walletFingerprint: string; + accountIndex: number; requestDigest: string; +}; + +function toReservation(row: ReservationRow): PilotDepositReservation { + return { allocationId: row.allocation_id, requestId: row.request_id, custodianId: row.custodian_id, + marketNetwork: row.market_network, marketChainId: row.market_chain_id, moneroNetwork: row.monero_network, + beneficiary: row.beneficiary, walletGenerationId: row.wallet_generation_id, walletFingerprint: row.wallet_fingerprint, + accountIndex: Number(row.account_index), addressIndex: Number(row.address_index), requestDigest: row.request_digest, + allocationDigest: row.allocation_digest, reservedAt: Number(row.reserved_at) }; +} + +function validateIdentity(identity: PilotCustodyIdentity, intent: PilotDepositAllocationIntent, reservedAt: number): void { + if (!IS_PILOT || NETWORK !== 'wikideck-randomx-pilot-1' || identity.version !== 1 || identity.mode !== 'pilot-custody' || + identity.policy !== 'pilot-custody-v1' || identity.marketNetwork !== NETWORK || identity.marketChainId !== CHAIN_ID || + identity.moneroNetwork !== 'mainnet' || identity.activationStatus !== 'identity-only') { + throw new Error('Pilot deposit reservation requires the configured identity-only pilot custody profile'); + } + if (!Number.isSafeInteger(reservedAt) || reservedAt < 0 || !uuidPattern.test(intent.requestId) || + !uuidPattern.test(intent.custodianId) || !uuidPattern.test(intent.walletGenerationId) || + intent.custodianId.toLowerCase() !== identity.custodianId.toLowerCase() || + intent.marketNetwork !== identity.marketNetwork || intent.marketChainId !== identity.marketChainId || + intent.moneroNetwork !== identity.moneroNetwork || !hexPattern.test(intent.beneficiary) || + !hexPattern.test(intent.walletFingerprint) || !hexPattern.test(intent.requestDigest) || + !Number.isSafeInteger(intent.accountIndex) || intent.accountIndex < 0 || intent.accountIndex > MAX_INDEX) { + throw new Error('Invalid pilot deposit allocation intent'); + } +} + +function allocationDigest(intent: PilotDepositAllocationIntent): string { + return createHash('sha256').update(JSON.stringify([ + 'wikideck-pilot-monero-deposit-allocation-v1', intent.custodianId, intent.marketNetwork, + intent.marketChainId, intent.moneroNetwork, intent.beneficiary, intent.walletGenerationId, + intent.walletFingerprint, intent.accountIndex, + ])).digest('hex'); +} + +async function assertIdentityRecord(client: PoolClient, identity: PilotCustodyIdentity): Promise { + const result = await client.query(`SELECT version,mode,policy,market_network,market_chain_id,monero_network, + custodian_id,activation_status FROM wk_custody.pilot_custody_identity WHERE singleton=1`); + if (!result.rowCount) throw new Error('Pilot custody identity is not initialized'); + const row = result.rows[0]; + if (Number(row.version) !== identity.version || row.mode !== identity.mode || row.policy !== identity.policy || + row.market_network !== identity.marketNetwork || row.market_chain_id !== identity.marketChainId || + row.monero_network !== identity.moneroNetwork || row.custodian_id !== identity.custodianId || + row.activation_status !== identity.activationStatus) { + throw new Error('Pilot custody identity changed or does not match the database binding'); + } +} + +/** + * Persist a never-reused Monero address index for a signed, validated pilot request. + * This does not call Wallet-RPC, create/register an address, or permit crediting funds. + */ +export async function reservePilotMoneroDepositIndex(pool: Pool, identity: PilotCustodyIdentity, + intentInput: PilotDepositAllocationIntent, reservedAt = Date.now()): Promise<{ reservation: PilotDepositReservation; replayed: boolean }> { + const intent = { ...intentInput, custodianId: intentInput.custodianId.toLowerCase() }; + validateIdentity(identity, intent, reservedAt); + const allocationHash = allocationDigest(intent); + 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]); + await verifyCustodySchema(client); + await assertPilotReservationDatabaseEmpty(client); + await assertIdentityRecord(client, identity); + // Serialize index allocation across app processes using the same watch-only wallet/account. + await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))', [ + `wikideck-pilot-monero-reservation-v1:${intent.walletGenerationId}:${intent.accountIndex}`, + ]); + + const byRequest = await client.query(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations + WHERE request_id=$1`, [intent.requestId]); + if (byRequest.rowCount) { + const row = byRequest.rows[0]; + if (row.request_digest !== intent.requestDigest || row.allocation_digest !== allocationHash) { + throw new Error('Pilot deposit request ID was reused with different signed terms'); + } + await client.query('COMMIT'); + return { reservation: toReservation(row), replayed: true }; + } + + const byBeneficiary = await client.query(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations + WHERE custodian_id=$1 AND market_network=$2 AND market_chain_id=$3 AND beneficiary=$4 + AND wallet_generation_id=$5`, [intent.custodianId, intent.marketNetwork, intent.marketChainId, + intent.beneficiary, intent.walletGenerationId]); + if (byBeneficiary.rowCount) { + const row = byBeneficiary.rows[0]; + if (row.allocation_digest !== allocationHash) { + throw new Error('Beneficiary already has a different pilot reservation for this wallet generation'); + } + await client.query('COMMIT'); + return { reservation: toReservation(row), replayed: true }; + } + + const next = await client.query<{ next_index: string }>(`SELECT (COALESCE(MAX(address_index),-1)+1)::text AS next_index + FROM wk_custody.pilot_monero_deposit_reservations WHERE wallet_generation_id=$1 AND account_index=$2`, + [intent.walletGenerationId, intent.accountIndex]); + const index = BigInt(next.rows[0].next_index); + if (index > BigInt(MAX_INDEX)) throw new Error('Pilot Monero subaddress index space is exhausted for this account'); + const inserted = await client.query(`INSERT INTO wk_custody.pilot_monero_deposit_reservations( + allocation_id,request_id,custodian_id,market_network,market_chain_id,monero_network,beneficiary, + wallet_generation_id,wallet_fingerprint,account_index,address_index,request_digest,allocation_digest,reserved_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`, [randomUUID(), intent.requestId, + intent.custodianId, intent.marketNetwork, intent.marketChainId, intent.moneroNetwork, intent.beneficiary, + intent.walletGenerationId, intent.walletFingerprint, intent.accountIndex, index.toString(), intent.requestDigest, + allocationHash, reservedAt]); + await client.query('COMMIT'); + return { reservation: toReservation(inserted.rows[0]), replayed: false }; + } catch (error) { + await client.query('ROLLBACK').catch(() => {}); + if (typeof error === 'object' && error !== null && 'code' in error && error.code === '23505') { + throw new Error('Pilot deposit reservation conflicts with an existing request, beneficiary, or wallet index'); + } + throw error; + } finally { client.release(); } +} + +/** Full signed-request path: verify durable identity, cryptographically validate, then reserve an index. */ +export async function reservePilotMoneroDepositRequest(pool: Pool, + context: Omit, + rawRequest: unknown, reservedAt = Date.now()) { + const custodianId = z.object({ custodianId: z.string().uuid() }).passthrough().parse(rawRequest).custodianId; + const identity = await verifyPilotCustodyIdentity(pool, custodianId); + const intent = validatePilotMoneroDepositRequest({ ...context, identity }, rawRequest); + return reservePilotMoneroDepositIndex(pool, identity, intent, reservedAt); +} diff --git a/server/market/postgres-schema.ts b/server/market/postgres-schema.ts index 0096238..17fefec 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 = 10; +export const CUSTODY_PG_VERSION = 11; export const CUSTODY_WRITE_LOCK = '7140824361'; export const MONERO_OBSERVER_WORKER_LOCK = '7140824362'; export const CUSTODY_SCHEMA_V1 = ` @@ -383,7 +383,8 @@ CREATE TABLE wk_custody.pilot_custody_identity( monero_network text NOT NULL CHECK(monero_network='mainnet'), custodian_id uuid NOT NULL UNIQUE, activation_status text NOT NULL CHECK(activation_status='identity-only'), - initialized_at bigint NOT NULL CHECK(initialized_at>=0) + initialized_at bigint NOT NULL CHECK(initialized_at>=0), + UNIQUE(custodian_id,market_network,market_chain_id,monero_network) ); CREATE FUNCTION wk_custody.pilot_custody_identity_immutable() RETURNS trigger LANGUAGE plpgsql SET search_path=wk_custody,pg_catalog AS $$ @@ -395,6 +396,65 @@ REVOKE ALL ON FUNCTION wk_custody.pilot_custody_identity_immutable() FROM PUBLIC `; export const CUSTODY_SCHEMA_V10_DIGEST = createHash('sha256') .update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}\n-- custody migration v5 --\n${CUSTODY_MIGRATION_V5}\n-- custody migration v6 --\n${CUSTODY_MIGRATION_V6}\n-- custody migration v7 --\n${CUSTODY_MIGRATION_V7}\n-- custody migration v8 --\n${CUSTODY_MIGRATION_V8}\n-- custody migration v9 --\n${CUSTODY_MIGRATION_V9}\n-- custody migration v10 --\n${CUSTODY_MIGRATION_V10}`).digest('hex'); +export const CUSTODY_MIGRATION_V11 = ` +CREATE TABLE wk_custody.pilot_monero_deposit_reservations( + allocation_id uuid PRIMARY KEY, + request_id uuid NOT NULL UNIQUE, + custodian_id uuid NOT NULL, + market_network text NOT NULL CHECK(market_network='wikideck-randomx-pilot-1'), + market_chain_id text NOT NULL CHECK(market_chain_id ~ '^[a-f0-9]{64}$'), + monero_network text NOT NULL CHECK(monero_network='mainnet'), + 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}$'), + account_index bigint NOT NULL CHECK(account_index BETWEEN 0 AND 4294967295), + address_index bigint NOT NULL CHECK(address_index BETWEEN 0 AND 4294967295), + request_digest text NOT NULL CHECK(request_digest ~ '^[a-f0-9]{64}$'), + allocation_digest text NOT NULL CHECK(allocation_digest ~ '^[a-f0-9]{64}$'), + reserved_at bigint NOT NULL CHECK(reserved_at>=0), + UNIQUE(wallet_generation_id,account_index,address_index), + UNIQUE(custodian_id,market_network,market_chain_id,beneficiary,wallet_generation_id), + FOREIGN KEY(custodian_id,market_network,market_chain_id,monero_network) + REFERENCES wk_custody.pilot_custody_identity(custodian_id,market_network,market_chain_id,monero_network) +); +CREATE INDEX pilot_deposit_reservations_wallet_index + ON wk_custody.pilot_monero_deposit_reservations(wallet_generation_id,account_index,address_index); +CREATE FUNCTION wk_custody.pilot_monero_deposit_reservations_immutable() RETURNS trigger LANGUAGE plpgsql + SET search_path=wk_custody,pg_catalog AS $$ +BEGIN RAISE EXCEPTION 'Pilot Monero deposit reservation history is immutable'; END $$; +CREATE TRIGGER pilot_monero_deposit_reservations_immutable + BEFORE UPDATE OR DELETE ON wk_custody.pilot_monero_deposit_reservations + FOR EACH ROW EXECUTE FUNCTION wk_custody.pilot_monero_deposit_reservations_immutable(); +REVOKE ALL ON wk_custody.pilot_monero_deposit_reservations FROM PUBLIC; +REVOKE ALL ON FUNCTION wk_custody.pilot_monero_deposit_reservations_immutable() FROM PUBLIC; +CREATE FUNCTION wk_custody.pilot_reservation_database_isolated() RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER + SET search_path=wk_custody,pg_catalog AS $$ + SELECT NOT ( + EXISTS(SELECT 1 FROM wk_custody.custody_meta) OR + EXISTS(SELECT 1 FROM wk_custody.accounts) OR + EXISTS(SELECT 1 FROM wk_custody.journals) OR + EXISTS(SELECT 1 FROM wk_custody.entries) OR + EXISTS(SELECT 1 FROM wk_custody.listings) OR + EXISTS(SELECT 1 FROM wk_custody.orders) OR + EXISTS(SELECT 1 FROM wk_custody.withdrawals) OR + EXISTS(SELECT 1 FROM wk_custody.receipts) OR + EXISTS(SELECT 1 FROM wk_custody.outbox) OR + EXISTS(SELECT 1 FROM wk_custody.credits) OR + EXISTS(SELECT 1 FROM wk_custody.monero_observer_state) OR + EXISTS(SELECT 1 FROM wk_custody.monero_transfer_observations) OR + EXISTS(SELECT 1 FROM wk_custody.monero_observation_scans) OR + EXISTS(SELECT 1 FROM wk_custody.monero_deposit_allocations) OR + EXISTS(SELECT 1 FROM wk_custody.monero_deposit_credit_events) OR + EXISTS(SELECT 1 FROM wk_custody.monero_deposit_reorg_holds) OR + EXISTS(SELECT 1 FROM wk_custody.monero_deposit_release_events) OR + EXISTS(SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions) OR + EXISTS(SELECT 1 FROM wk_custody.monero_withdrawal_intents) + ) +$$; +REVOKE ALL ON FUNCTION wk_custody.pilot_reservation_database_isolated() FROM PUBLIC; +`; +export const CUSTODY_SCHEMA_V11_DIGEST = createHash('sha256') + .update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}\n-- custody migration v5 --\n${CUSTODY_MIGRATION_V5}\n-- custody migration v6 --\n${CUSTODY_MIGRATION_V6}\n-- custody migration v7 --\n${CUSTODY_MIGRATION_V7}\n-- custody migration v8 --\n${CUSTODY_MIGRATION_V8}\n-- custody migration v9 --\n${CUSTODY_MIGRATION_V9}\n-- custody migration v10 --\n${CUSTODY_MIGRATION_V10}\n-- custody migration v11 --\n${CUSTODY_MIGRATION_V11}`).digest('hex'); /** Retained alias for callers that need the initial schema definition in disposable fixtures. */ export const CUSTODY_SCHEMA = CUSTODY_SCHEMA_V1; export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V10_DIGEST; @@ -455,6 +515,11 @@ export async function migrateCustody(pool: Pool): Promise { if (current === 9) { await c.query(CUSTODY_MIGRATION_V10); await c.query('INSERT INTO wk_custody.schema_versions VALUES(10,$1)', [CUSTODY_SCHEMA_V10_DIGEST]); + current = 10; + } + if (current === 10) { + await c.query(CUSTODY_MIGRATION_V11); + await c.query('INSERT INTO wk_custody.schema_versions VALUES(11,$1)', [CUSTODY_SCHEMA_V11_DIGEST]); } if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application'); await verifyCustodySchema(c); @@ -470,7 +535,7 @@ async function verifyCustodySchemaHistory(c: PoolClient): Promise { const rows = await c.query('SELECT version,digest FROM wk_custody.schema_versions ORDER BY version'); const expected = [CUSTODY_SCHEMA_V1_DIGEST, CUSTODY_SCHEMA_V2_DIGEST, CUSTODY_SCHEMA_V3_DIGEST, CUSTODY_SCHEMA_V4_DIGEST, CUSTODY_SCHEMA_V5_DIGEST, CUSTODY_SCHEMA_V6_DIGEST, CUSTODY_SCHEMA_V7_DIGEST, - CUSTODY_SCHEMA_V8_DIGEST, CUSTODY_SCHEMA_V9_DIGEST, CUSTODY_SCHEMA_V10_DIGEST]; + CUSTODY_SCHEMA_V8_DIGEST, CUSTODY_SCHEMA_V9_DIGEST, CUSTODY_SCHEMA_V10_DIGEST, CUSTODY_SCHEMA_V11_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'); @@ -598,6 +663,36 @@ export async function grantMoneroDepositAllocatorRuntime(pool: Pool, role: strin finally { c.release(); } } +/** Pilot reservation-only role. It can allocate durable indexes but cannot register addresses or access financial tables. */ +export async function grantPilotDepositReservationRuntime(pool: Pool, role: string, + custodyRuntimeRole = 'custody_runtime', observerRole = 'monero_observer', + creditRole = 'monero_deposit_credit', mainnetAllocatorRole = 'monero_deposit_allocator'): Promise { + if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid pilot deposit reservation role identifier'); + if ([custodyRuntimeRole, observerRole, creditRole, mainnetAllocatorRole].includes(role)) { + throw new Error('Pilot deposit reservation role must be separate from other custody roles'); + } + 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('Pilot reservation 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('Pilot reservation 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('Pilot reservation 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.schema_versions TO ${name}; + GRANT SELECT ON wk_custody.pilot_custody_identity TO ${name}; + GRANT SELECT,INSERT ON wk_custody.pilot_monero_deposit_reservations TO ${name}; + GRANT EXECUTE ON FUNCTION wk_custody.pilot_reservation_database_isolated() TO ${name};`); + await c.query('COMMIT'); + } catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; } + finally { c.release(); } +} + /** Mainnet customer withdrawal-intent role. It can reserve/cancel liabilities but has no signing or relay authority. */ export async function grantMoneroWithdrawalRuntime(pool: Pool, role: string, custodyRuntimeRole = 'custody_runtime', observerRole = 'monero_observer', diff --git a/tests/custody-db-args.test.ts b/tests/custody-db-args.test.ts index 900599b..f1e2cd0 100755 --- a/tests/custody-db-args.test.ts +++ b/tests/custody-db-args.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { assertCustodyDbActionAllowedOnNetwork, assertMainnetSchemaMigrationAcknowledged, assertMainnetCustodyOperationAcknowledged, assertPilotCustodyActionAllowed, assertPilotSchemaMigrationAcknowledged, assertPilotCustodyIdentityAcknowledged, + assertPilotReservationGrantAcknowledged, parseCustodyDbArguments } from '../server/market/custody-db-args.ts'; test('custody database CLI parses two-argument deposit operations without shifting arguments', () => { @@ -61,6 +62,7 @@ test('mainnet reviewed deposit operations require exact credit-only acknowledgem test('pilot custody CLI permits only additive schema migration and identity-only initialization', () => { assert.doesNotThrow(() => assertPilotCustodyActionAllowed('migrate-pilot-schema', true)); assert.doesNotThrow(() => assertPilotCustodyActionAllowed('initialize-pilot-custody-identity', true)); + assert.doesNotThrow(() => assertPilotCustodyActionAllowed('grant-pilot-deposit-reservation-runtime', true)); assert.throws(() => assertPilotCustodyActionAllowed('grant-monero-observer-runtime', true), /only pilot schema migration/); assert.throws(() => assertPilotCustodyActionAllowed('deposit-stage-credit', true), /only pilot schema migration/); assert.throws(() => assertPilotCustodyActionAllowed('initialize-pilot-custody-identity', false), /requires the pilot profile/); @@ -72,6 +74,9 @@ test('pilot custody CLI permits only additive schema migration and identity-only 'initialize-pilot-custody-v1', 'reviewed-credit-only'), /no-funds acknowledgements/); assert.doesNotThrow(() => assertPilotCustodyIdentityAcknowledged('initialize-pilot-custody-identity', 'initialize-pilot-custody-v1', 'identity-only-no-funds')); + assert.throws(() => assertPilotReservationGrantAcknowledged('grant-pilot-deposit-reservation-runtime', undefined), /acknowledgement/); + assert.throws(() => assertPilotReservationGrantAcknowledged('grant-pilot-deposit-reservation-runtime', 'reviewed-credit-only'), /acknowledgement/); + assert.doesNotThrow(() => assertPilotReservationGrantAcknowledged('grant-pilot-deposit-reservation-runtime', 'reservation-only-no-addresses')); }); test('custody database CLI rejects unknown, missing, empty, and excess arguments', () => { diff --git a/tests/postgres/custody.test.ts b/tests/postgres/custody.test.ts index a02ede8..dd09802 100755 --- a/tests/postgres/custody.test.ts +++ b/tests/postgres/custody.test.ts @@ -15,10 +15,12 @@ import { CustodyIntegrityError, auditBusinessLiabilities } from '../../server/ma 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, - grantMoneroDepositCreditRuntime, grantMoneroDepositAllocatorRuntime, grantMoneroWithdrawalRuntime, CUSTODY_MIGRATION_V2, + grantMoneroDepositCreditRuntime, grantMoneroDepositAllocatorRuntime, grantMoneroWithdrawalRuntime, + grantPilotDepositReservationRuntime, CUSTODY_MIGRATION_V2, CUSTODY_SCHEMA_V2_DIGEST } from '../../server/market/postgres-schema.ts'; import { parseMainnetCustodianId } from '../../server/market/mainnet-custody-identity.ts'; import { initializePilotCustodyIdentity, parsePilotCustodianId } from '../../server/market/pilot-custody-identity.ts'; +import { reservePilotMoneroDepositRequest } from '../../server/market/pilot-monero-deposit-allocation.ts'; import { readMainnetCustodyBalance, reserveMainnetWithdrawal, cancelMainnetWithdrawal } from '../../server/market/mainnet-withdrawal.ts'; import { mainnetCustodyBalanceMessage, mainnetWithdrawalCancellationMessage, mainnetWithdrawalMessage, type MainnetCustodyBalanceRequestUnsigned, type MainnetWithdrawalRequestUnsigned, @@ -206,7 +208,7 @@ test('explicit PostgreSQL migrations are idempotent and runtime role cannot alte assert.equal((await f.market.audit()).assetsAtomicXmr, '1000000000000'); }); -test('custody schema v1 upgrades additively through v10 without dropping existing metadata', async t => { +test('custody schema v1 upgrades additively through v11 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(); @@ -220,7 +222,7 @@ test('custody schema v1 upgrades additively through v10 without dropping existin try { await migrateCustody(admin); await migrateCustody(admin); const versions = await admin.query('SELECT version FROM wk_custody.schema_versions ORDER BY version'); - assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); 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']) { @@ -228,6 +230,7 @@ test('custody schema v1 upgrades additively through v10 without dropping existin } assert.ok((await admin.query("SELECT to_regclass('wk_custody.monero_deposit_allocations') AS name")).rows[0].name); assert.ok((await admin.query("SELECT to_regclass('wk_custody.pilot_custody_identity') AS name")).rows[0].name); + assert.ok((await admin.query("SELECT to_regclass('wk_custody.pilot_monero_deposit_reservations') AS name")).rows[0].name); for (const table of ['monero_deposit_credit_events', 'monero_deposit_reorg_holds', 'monero_deposit_release_events', 'monero_deposit_reorg_resolutions']) { assert.ok((await admin.query('SELECT to_regclass($1) AS name', [`wk_custody.${table}`])).rows[0].name); @@ -235,7 +238,7 @@ test('custody schema v1 upgrades additively through v10 without dropping existin } finally { await admin.end(); } }); -test('custody schema v2 upgrades explicitly through v10 deposit and withdrawal lifecycle tables', async t => { +test('custody schema v2 upgrades explicitly through v11 deposit and withdrawal lifecycle tables', async t => { const admin = new Pool(config); await admin.query('DROP SCHEMA IF EXISTS wk_custody CASCADE'); const client = await admin.connect(); @@ -248,11 +251,12 @@ test('custody schema v2 upgrades explicitly through v10 deposit and withdrawal l await client.query('COMMIT'); await migrateCustody(admin); await migrateCustody(admin); const versions = await admin.query('SELECT version FROM wk_custody.schema_versions ORDER BY version'); - assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); assert.equal((await admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='v2-marker'")).rows[0].value, 'preserve'); assert.ok((await admin.query("SELECT to_regclass('wk_custody.monero_deposit_release_events') AS name")).rows[0].name); assert.ok((await admin.query("SELECT to_regclass('wk_custody.monero_withdrawal_intents') AS name")).rows[0].name); assert.ok((await admin.query("SELECT to_regclass('wk_custody.pilot_custody_identity') AS name")).rows[0].name); + assert.ok((await admin.query("SELECT to_regclass('wk_custody.pilot_monero_deposit_reservations') AS name")).rows[0].name); } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } finally { client.release(); await admin.end(); } }); @@ -324,6 +328,88 @@ test('pilot custody identity refuses a database containing pre-existing mock or } finally { await target.end(); await admin.query(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`); await admin.end(); } }); +test('pilot signed deposit requests reserve unique durable indexes, replay safely, and cannot access funds or addresses', async t => { + const admin = new Pool(config), database = `pilot_reservations_${randomUUID().replaceAll('-', '')}`; + const role = `pilot_allocator_${randomUUID().replaceAll('-', '')}`; + await admin.query(`CREATE DATABASE "${database}"`); + const owner = new Pool({ ...config, database }), run = promisify(execFile); + const script = `import { Pool } from 'pg'; + import { generateKeyPairSync, sign, randomUUID } from 'node:crypto'; + import { address, CHAIN_ID } from './server/testnet/consensus.ts'; + import { pilotMoneroDepositRequestMessage } from './shared/pilot-monero-deposit.ts'; + import { reservePilotMoneroDepositRequest } from './server/market/pilot-monero-deposit-allocation.ts'; + const pool=new Pool({host:'127.0.0.1',database:process.env.DB,user:process.env.DB_ROLE,ssl:false,max:4}); + const generation='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', fingerprint='c'.repeat(64), now=1800000000000; + function newPair() { return generateKeyPairSync('ec',{namedCurve:'prime256v1'}); } + function request(pair, accountIndex=0) { + const publicKey=pair.publicKey.export({type:'spki',format:'der'}).toString('base64'), beneficiary=address(publicKey); + const unsigned={version:1,marketNetwork:'wikideck-randomx-pilot-1',marketChainId:CHAIN_ID,moneroNetwork:'mainnet', + custodianId:process.env.CUSTODIAN,beneficiary,publicKey,requestId:randomUUID(),walletGenerationId:generation, + walletFingerprint:fingerprint,accountIndex,expiresAt:now+60000}; + return {...unsigned,signature:sign('sha256',Buffer.from(pilotMoneroDepositRequestMessage(unsigned)), + {key:pair.privateKey,dsaEncoding:'ieee-p1363'}).toString('base64')}; + } + try { + const pairA=newPair(), a=request(pairA); + const first=await reservePilotMoneroDepositRequest(pool,{walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:0,now:()=>now},a,now); + const replay=await reservePilotMoneroDepositRequest(pool,{walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:0,now:()=>now},a,now); + const fresh=await reservePilotMoneroDepositRequest(pool,{walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:0,now:()=>now},request(pairA),now); + const b=request(newPair()), c=request(newPair()); + const concurrent=await Promise.all([b,c].map(r=>reservePilotMoneroDepositRequest(pool, + {walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:0,now:()=>now},r,now))); + const conflict=request(pairA,1); + let conflictRejected=false; try { await reservePilotMoneroDepositRequest(pool, + {walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:1,now:()=>now},conflict,now); } + catch { conflictRejected=true; } + console.log(JSON.stringify({first,replay,fresh,concurrent,conflictRejected})); + } finally { await pool.end(); }`; + const custodianId = randomUUID(); + const bootstrap = `import { Pool } from 'pg'; import { initializePilotCustodyIdentity } from './server/market/pilot-custody-identity.ts'; + const pool=new Pool({host:'127.0.0.1',database:process.env.DB,user:'postgres',ssl:false}); + try { await initializePilotCustodyIdentity(pool,process.env.CUSTODIAN,1234); } finally { await pool.end(); }`; + const env = { ...process.env, WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: 'private-mock-only', + DB: database, CUSTODIAN: custodianId }; + try { + await migrateCustody(owner); + await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', bootstrap], { env }); + await admin.query(`CREATE ROLE "${role}" LOGIN`); + await grantPilotDepositReservationRuntime(owner, role); + assert.equal((await owner.query("SELECT has_table_privilege($1,'wk_custody.schema_versions','SELECT') AS allowed", [role])).rows[0].allowed, true); + assert.equal((await owner.query("SELECT has_table_privilege($1,'wk_custody.custody_meta','SELECT') AS allowed", [role])).rows[0].allowed, false); + const result = JSON.parse((await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', script], { + env: { ...env, DB_ROLE: role }, + })).stdout.trim()); + assert.equal(result.first.reservation.addressIndex, 0); + assert.equal(result.first.replayed, false); + assert.equal(result.replay.reservation.allocationId, result.first.reservation.allocationId); + assert.equal(result.replay.replayed, true); + assert.equal(result.fresh.reservation.allocationId, result.first.reservation.allocationId); + assert.equal(result.fresh.replayed, true); + const indexes = result.concurrent.map((entry: { reservation: { addressIndex: number } }) => entry.reservation.addressIndex).sort(); + assert.deepEqual(indexes, [1, 2]); + assert.ok(result.concurrent.every((entry: { replayed: boolean }) => !entry.replayed)); + assert.equal(result.conflictRejected, true); + assert.equal(Object.hasOwn(result.first.reservation, 'address'), false); + assert.equal(Object.hasOwn(result.first.reservation, 'moneroAddress'), false); + assert.equal((await owner.query('SELECT count(*)::int AS n FROM wk_custody.pilot_monero_deposit_reservations')).rows[0].n, 3); + assert.equal((await owner.query('SELECT count(*)::int AS n FROM wk_custody.journals')).rows[0].n, 0); + assert.equal((await owner.query('SELECT count(*)::int AS n FROM wk_custody.monero_deposit_allocations')).rows[0].n, 0); + assert.equal((await owner.query("SELECT has_table_privilege($1,'wk_custody.accounts','SELECT') AS allowed", [role])).rows[0].allowed, false); + assert.equal((await owner.query("SELECT has_table_privilege($1,'wk_custody.monero_withdrawal_intents','SELECT') AS allowed", [role])).rows[0].allowed, false); + const runtime = new Pool({ ...config, database, user: role }); + try { + await assert.rejects(runtime.query('UPDATE wk_custody.pilot_monero_deposit_reservations SET address_index=9')); + await assert.rejects(runtime.query('DELETE FROM wk_custody.pilot_monero_deposit_reservations')); + await assert.rejects(runtime.query('SELECT * FROM wk_custody.accounts')); + } finally { await runtime.end(); } + } finally { + await owner.end(); + await admin.query(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`); + await admin.query(`DROP ROLE IF EXISTS "${role}"`); + await admin.end(); + } +}); + test('mainnet custody identity bootstrap is one-time, chain-bound, and does not open withdrawals', async t => { assert.equal(parseMainnetCustodianId('AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA'), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); assert.throws(() => parseMainnetCustodianId('not-a-uuid'), /Invalid mainnet custodian UUID/);