diff --git a/Dockerfile b/Dockerfile index 1a3ed8c..6951774 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,6 +47,7 @@ COPY scripts/monero-observer-worker.ts ./scripts/monero-observer-worker.ts COPY scripts/custody-deployment-init.ts ./scripts/custody-deployment-init.ts COPY scripts/custody-deployment-check.ts ./scripts/custody-deployment-check.ts COPY scripts/monero-daemon-check.ts ./scripts/monero-daemon-check.ts +COPY scripts/pilot-monero-deposit-address.ts ./scripts/pilot-monero-deposit-address.ts COPY --from=randomx-build /native/build/wikideck-randomx ./native/wikideck-randomx RUN mkdir -p /app/data && chown node:node /app/data USER node diff --git a/docs/real-xmr-pilot-deployment.md b/docs/real-xmr-pilot-deployment.md index c89e8c3..6af3003 100755 --- a/docs/real-xmr-pilot-deployment.md +++ b/docs/real-xmr-pilot-deployment.md @@ -68,3 +68,5 @@ This probe does not establish that the node is honest or independently canonical ## Initial checks The network manifest reports `profile: pilot`, `network: wikideck-randomx-pilot-1`, and `launchStatus: private-pilot-mock-only`. Verify startup refuses a missing pilot-mode opt-in and any non-mock payment setting. Deployment tests check loopback binding, distinct profile selection, database secret isolation, and absence of an in-stack Monero daemon. A successful mock deployment is only evidence for this first slice, not for daemon connectivity or real-XMR readiness. + +The operator-only command is packaged as `npm run monero:pilot-address -- /absolute/path/to/signed-request.json` (or `node --experimental-strip-types scripts/pilot-monero-deposit-address.ts /absolute/path/to/signed-request.json` inside the runtime image). It requires `WIKIDECK_NETWORK=pilot`, `WIKIDECK_PILOT_MODE=private-mock-only`, `WIKIDECK_PILOT_CUSTODY_OPERATIONS=operator-triggered-addresses-only`, and `WIKIDECK_PILOT_CUSTODIAN_ID` matching the signed request. Provide the dedicated address-allocator PostgreSQL connection through `WIKIDECK_PILOT_ADDRESS_PG_URL_FILE` and `WIKIDECK_PILOT_ADDRESS_PG_CA_FILE`; provide the protected watch-only wallet generation ID, fingerprint, and account index through `WIKIDECK_MONERO_WALLET_GENERATION_ID`, `WIKIDECK_MONERO_WALLET_FINGERPRINT`, and `WIKIDECK_MONERO_DEPOSIT_ACCOUNT_INDEX`; mount the protected `WIKIDECK_MONERO_WALLET_RPC_CONFIG_FILE` and `WIKIDECK_MONERO_MTLS_CONFIG_FILE` files. The config's endpoint URLs must match. The command is not in app startup, has no HTTP route, and emits the registered address only to this explicit operator invocation. It does not credit balances or declare the deposit accepted. diff --git a/docs/real-xmr-pilot-plan.md b/docs/real-xmr-pilot-plan.md index 2c24072..c44c3e9 100755 --- a/docs/real-xmr-pilot-plan.md +++ b/docs/real-xmr-pilot-plan.md @@ -10,6 +10,7 @@ Updated: 2026-10-22. Implementation plan for a private, operator-supervised func - **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. - **Wallet-RPC address create/recover implemented:** the operator/library-only function verifies the configured wallet fingerprint, checks the reserved index and stable allocation label, persists a create intent before `create_address`, then reads back and validates the exact Mainnet subaddress before registering it once. Lost responses recover by the stable label without repeating creation; ambiguous missing responses remain in a durable `creating` state and stop for operator reconciliation. No HTTP enrollment, observer, credit, withdrawal, or sale path is enabled. +- **Operator address command implemented:** `monero:pilot-address` loads an owner-only signed request file, requires the pilot profile plus exact operator-action acknowledgement, checks the configured custodian and watch-only wallet generation/fingerprint/account, loads the protected Wallet-RPC and mTLS configs, verifies the wallet fingerprint, and invokes the durable create/recover library. It returns the address to that one-shot operator caller only; it is not wired into server startup, HTTP, or the browser. - **Validation:** serial offline suite passed 189/189; disposable PostgreSQL suite passed 44/44, including schema v13 migration and the pilot reservation/address lifecycle tests. The focused address test verifies lost-response recovery and that an unresolved create is not sent twice. No operator database or real Monero 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. @@ -122,4 +123,4 @@ Keep the functional essentials: signed authorization, correct network binding, p ## Next coding task -**Next: add an operator-only CLI entrypoint for the pilot allocation/create-or-recover path, then test it end-to-end with the private mTLS Wallet-RPC gateway.** Keep HTTP enrollment disabled. Review actual protected wallet configuration, wallet type, and external-daemon network binding before creating any addresses. Deposits remain unusable until observation, reviewed credit, withdrawal execution, and recovery are all connected. +**Next: exercise `monero:pilot-address` against a protected Wallet-RPC mTLS gateway in a disposable operator-controlled wallet, then add pilot-bound observation for those registered allocations.** First test wallet type, network, external-daemon reachability, and recovery behavior; do not use a funded wallet. Keep HTTP enrollment and customer credit disabled. Do not accept deposits until observer/reorg handling, reviewed credit, actual withdrawal execution, and recovery are connected. diff --git a/package.json b/package.json index 577426f..8419738 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "custody:db": "node --experimental-strip-types scripts/custody-db.ts", "custody:backup": "node --experimental-strip-types scripts/custody-pair-backup.ts", "monero:daemon-check": "node --experimental-strip-types scripts/monero-daemon-check.ts", + "monero:pilot-address": "node --experimental-strip-types scripts/pilot-monero-deposit-address.ts", "node:backup": "node --experimental-strip-types scripts/node-backup.ts", "ops:check": "node --experimental-strip-types scripts/ops-check.ts", "miner": "tsx server/testnet/miner.ts", diff --git a/scripts/pilot-monero-deposit-address.ts b/scripts/pilot-monero-deposit-address.ts new file mode 100755 index 0000000..eb88de8 --- /dev/null +++ b/scripts/pilot-monero-deposit-address.ts @@ -0,0 +1,64 @@ +import { Pool } from 'pg'; +import { z } from 'zod'; +import { IS_PILOT } from '../shared/network.ts'; +import { createMoneroDepositAddressRpc, loadMoneroObserverConfig } from '../server/market/monero-observer.ts'; +import { loadMoneroMtlsTransport, loadMoneroMtlsTransportConfig } from '../server/market/monero-mtls-transport.ts'; +import { postgresConfig } from '../server/market/postgres-config.ts'; +import { createOrRecoverPilotMoneroDepositAddress } from '../server/market/pilot-monero-deposit-allocation.ts'; +import { parsePilotCustodianId } from '../server/market/pilot-custody-identity.ts'; +import { loadPilotMoneroDepositRequestFile } from '../server/market/pilot-monero-deposit-request-file.ts'; + +if (!IS_PILOT || process.env.WIKIDECK_PILOT_MODE !== 'private-mock-only') { + throw new Error('Pilot address command requires the private mock-only pilot profile'); +} +if (process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS !== 'operator-triggered-addresses-only') { + throw new Error('Set WIKIDECK_PILOT_CUSTODY_OPERATIONS=operator-triggered-addresses-only for this explicit operator action'); +} +if (process.argv.length !== 3) throw new Error('Usage: pilot-monero-deposit-address.ts SIGNED_REQUEST_JSON_FILE'); +if (process.platform === 'win32') throw new Error('Pilot address command requires POSIX protected-file permission checks'); + +const request = await loadPilotMoneroDepositRequestFile(process.argv[2]); +const custodianId = parsePilotCustodianId(process.env.WIKIDECK_PILOT_CUSTODIAN_ID ?? ''); +const requestCustodian = z.object({ custodianId: z.string().uuid() }).passthrough().parse(request).custodianId.toLowerCase(); +if (requestCustodian !== custodianId) throw new Error('Signed request does not match the configured pilot custodian'); +const walletGenerationId = process.env.WIKIDECK_MONERO_WALLET_GENERATION_ID ?? ''; +const walletFingerprint = process.env.WIKIDECK_MONERO_WALLET_FINGERPRINT ?? ''; +const accountText = process.env.WIKIDECK_MONERO_DEPOSIT_ACCOUNT_INDEX; +if (!/^[a-f0-9-]{36}$/i.test(walletGenerationId) || !/^[a-f0-9]{64}$/.test(walletFingerprint) || + !accountText || !/^(0|[1-9]\d{0,9})$/.test(accountText) || Number(accountText) > 0xffff_ffff) { + throw new Error('Invalid protected pilot watch-only wallet generation, fingerprint, or account configuration'); +} + +const observerConfig = await loadMoneroObserverConfig({ ...process.env, WIKIDECK_MONERO_OBSERVER: '1' }); +const mtlsConfig = await loadMoneroMtlsTransportConfig(); +if (observerConfig.network !== 'mainnet' || new URL(observerConfig.url).href !== new URL(mtlsConfig.url).href) { + throw new Error('Pilot Wallet-RPC config must match the protected Mainnet mTLS gateway endpoint'); +} +const transport = await loadMoneroMtlsTransport(mtlsConfig); +const rpc = createMoneroDepositAddressRpc(observerConfig, transport); +const actualFingerprint = await rpc.verifyWallet(); +if (actualFingerprint !== walletFingerprint) throw new Error('Configured pilot wallet fingerprint does not match the protected Wallet-RPC wallet'); + +const pool = new Pool(await postgresConfig({ + ...process.env, + WIKIDECK_CUSTODY_PG_URL_FILE: process.env.WIKIDECK_PILOT_ADDRESS_PG_URL_FILE, + WIKIDECK_CUSTODY_PG_CA_FILE: process.env.WIKIDECK_PILOT_ADDRESS_PG_CA_FILE, + WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls', +})); +pool.on('error', () => console.error(JSON.stringify({ component: 'pilot-address', event: 'database-unavailable' }))); +try { + const result = await createOrRecoverPilotMoneroDepositAddress(pool, rpc, { + walletGenerationId, + walletFingerprint: actualFingerprint, + accountIndex: Number(accountText), + }, request); + // This is an operator-invoked address response only; no balance credit or deposit acceptance occurs here. + console.log(JSON.stringify({ network: 'mainnet', marketNetwork: result.reservation.marketNetwork, + marketChainId: result.reservation.marketChainId, custodianId: result.reservation.custodianId, + allocationId: result.reservation.allocationId, beneficiary: result.reservation.beneficiary, + addressIndex: result.reservation.addressIndex, address: result.address, replayed: result.replayed, + crediting: 'disabled' })); +} catch { + console.error('Pilot address allocation failed or requires manual reconciliation; no funding/credit action was performed.'); + process.exitCode = 1; +} finally { await pool.end(); } diff --git a/server/market/pilot-monero-deposit-request-file.ts b/server/market/pilot-monero-deposit-request-file.ts new file mode 100755 index 0000000..d9d3684 --- /dev/null +++ b/server/market/pilot-monero-deposit-request-file.ts @@ -0,0 +1,33 @@ +import { constants } from 'node:fs'; +import { open } from 'node:fs/promises'; +import { isAbsolute } from 'node:path'; + +const MAX_REQUEST_BYTES = 64 * 1024; + +/** Read a signed wallet request only from a bounded owner-only regular file. */ +export async function loadPilotMoneroDepositRequestFile(path: string): Promise { + if (process.platform === 'win32') throw new Error('Pilot address command requires POSIX protected-file permission checks'); + if (!isAbsolute(path)) throw new Error('Pilot address request file path must be absolute'); + let file; + try { file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); } + catch { throw new Error('Pilot address request file is inaccessible or is a symbolic link'); } + try { + const info = await file.stat(); + if (!info.isFile() || info.size < 1 || info.size > MAX_REQUEST_BYTES || (info.mode & 0o077) !== 0) { + throw new Error('Pilot address request file must be bounded and owner-only'); + } + const chunks: Buffer[] = []; + let total = 0; + for (;;) { + const chunk = Buffer.allocUnsafe(Math.min(4096, MAX_REQUEST_BYTES + 1 - total)); + const { bytesRead } = await file.read(chunk); + if (bytesRead === 0) break; + total += bytesRead; + if (total > MAX_REQUEST_BYTES) throw new Error('Pilot address request file exceeds size limit'); + chunks.push(chunk.subarray(0, bytesRead)); + } + const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)); + try { return JSON.parse(text) as unknown; } + catch { throw new Error('Pilot address request file does not contain valid JSON'); } + } finally { await file.close(); } +} diff --git a/tests/pilot-monero-deposit.test.ts b/tests/pilot-monero-deposit.test.ts index bc2c446..afdfa85 100755 --- a/tests/pilot-monero-deposit.test.ts +++ b/tests/pilot-monero-deposit.test.ts @@ -1,6 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { loadPilotMoneroDepositRequestFile } from '../server/market/pilot-monero-deposit-request-file.ts'; function run(code: string) { return JSON.parse(execFileSync(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], { @@ -74,3 +78,30 @@ test('pilot deposit authorization rejects malformed and unknown fields', () => { console.log(JSON.stringify({malformed,unknown}));`); assert.deepEqual(result, { malformed: true, unknown: true }); }); + +test('operator address CLI requires explicit consent before reading wallet or database configuration', () => { + const result = spawnSync(process.execPath, ['--experimental-strip-types', 'scripts/pilot-monero-deposit-address.ts', '/missing/request.json'], { + cwd: process.cwd(), encoding: 'utf8', timeout: 30_000, + env: { ...process.env, WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: 'private-mock-only', + WIKIDECK_PILOT_CUSTODY_OPERATIONS: '' }, + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /operator-triggered-addresses-only/); + assert.doesNotMatch(result.stderr, /database configuration|Wallet-RPC config/); +}); + +test('signed pilot request files must be absolute, bounded, owner-only regular files', async t => { + if (process.platform === 'win32') { + await assert.rejects(loadPilotMoneroDepositRequestFile('C:\\private\\request.json'), /POSIX protected-file/); + return; + } + const directory = await mkdtemp(join(tmpdir(), 'wikideck-pilot-address-request-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'request.json'); + await writeFile(path, JSON.stringify({ version: 1, signature: 'signed-fixture' }), { mode: 0o600 }); + await chmod(path, 0o600); + assert.deepEqual(await loadPilotMoneroDepositRequestFile(path), { version: 1, signature: 'signed-fixture' }); + await chmod(path, 0o644); + await assert.rejects(loadPilotMoneroDepositRequestFile(path), /owner-only/); + await assert.rejects(loadPilotMoneroDepositRequestFile('relative.json'), /absolute/); +});