From 216a419c0b08b5354c673ba1b22677fe2e3266be Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Fri, 23 Oct 2026 10:20:58 -0400 Subject: [PATCH] Observer integration step --- compose.custody-pilot.yaml | 67 ++++++++++++++++++++++ docs/real-xmr-pilot-deployment.md | 14 +++-- docs/real-xmr-pilot-plan.md | 41 +++++++------ scripts/monero-mtls-stagenet-rehearsal.mjs | 2 +- server/market/monero-rpc-gateway.ts | 37 ++++++++++-- tests/monero-observer.test.ts | 34 ++++++----- tests/pilot-deployment.test.ts | 11 +++- 7 files changed, 161 insertions(+), 45 deletions(-) diff --git a/compose.custody-pilot.yaml b/compose.custody-pilot.yaml index 1d75329..3a84fda 100755 --- a/compose.custody-pilot.yaml +++ b/compose.custody-pilot.yaml @@ -129,9 +129,76 @@ services: pids_limit: 32 mem_limit: 128m restart: "no" + pilot-observer: + image: wikideck:private-pilot + profiles: ["pilot-observer"] + build: + context: . + target: runtime + args: + WIKIDECK_NETWORK: pilot + entrypoint: [node, --experimental-strip-types, scripts/pilot-monero-observe-once.ts] + environment: + WIKIDECK_NETWORK: pilot + WIKIDECK_PILOT_MODE: private-mock-only + WIKIDECK_PILOT_CUSTODY_OPERATIONS: pilot-observation-only-no-credit + WIKIDECK_PILOT_CUSTODIAN_ID: "${WIKIDECK_PILOT_CUSTODIAN_ID:-}" + WIKIDECK_MONERO_WALLET_GENERATION_ID: "${WIKIDECK_MONERO_WALLET_GENERATION_ID:-}" + WIKIDECK_MONERO_WALLET_FINGERPRINT: "${WIKIDECK_MONERO_WALLET_FINGERPRINT:-}" + WIKIDECK_PILOT_OBSERVER_PG_URL_FILE: /run/secrets/pilot-observer-db-url + WIKIDECK_PILOT_OBSERVER_PG_CA_FILE: /run/secrets/pilot-observer-db-ca.crt + WIKIDECK_MONERO_WALLET_RPC_CONFIG_FILE: /run/monero/wallet-rpc.json + WIKIDECK_MONERO_MTLS_CONFIG_FILE: /run/monero/mtls.json + WIKIDECK_PILOT_OBSERVER_MAX_BLOCKS: "${WIKIDECK_PILOT_OBSERVER_MAX_BLOCKS:-5000}" + WIKIDECK_PILOT_OBSERVER_OVERLAP: "${WIKIDECK_PILOT_OBSERVER_OVERLAP:-100}" + volumes: + - type: bind + source: "${WIKIDECK_PILOT_OBSERVER_PG_URL_HOST:-./private/monero-pilot/observer-db-url}" + target: /run/secrets/pilot-observer-db-url + read_only: true + bind: {create_host_path: false} + - type: bind + source: "${WIKIDECK_PILOT_OBSERVER_PG_CA_HOST:-./private/monero-pilot/observer-db-ca.crt}" + target: /run/secrets/pilot-observer-db-ca.crt + read_only: true + bind: {create_host_path: false} + - type: bind + source: "${WIKIDECK_PILOT_OBSERVER_WALLET_CONFIG_HOST:-./private/monero-pilot/wallet-rpc.json}" + target: /run/monero/wallet-rpc.json + read_only: true + bind: {create_host_path: false} + - type: bind + source: "${WIKIDECK_PILOT_OBSERVER_MTLS_CONFIG_HOST:-./private/monero-pilot/mtls.json}" + target: /run/monero/mtls.json + read_only: true + bind: {create_host_path: false} + - type: bind + source: "${WIKIDECK_PILOT_OBSERVER_CLIENT_CERT_HOST:-./private/monero-pilot/observer-client.crt}" + target: /run/secrets/observer-client.crt + read_only: true + bind: {create_host_path: false} + - type: bind + source: "${WIKIDECK_PILOT_OBSERVER_CLIENT_KEY_HOST:-./private/monero-pilot/observer-client.key}" + target: /run/secrets/observer-client.key + read_only: true + bind: {create_host_path: false} + - type: bind + source: "${WIKIDECK_PILOT_OBSERVER_GATEWAY_CA_HOST:-./private/monero-pilot/gateway-ca.crt}" + target: /run/secrets/observer-gateway-ca.crt + read_only: true + bind: {create_host_path: false} + networks: [observer-egress] + read_only: true + tmpfs: ["/tmp:mode=1777,size=16m,noexec,nosuid"] + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + pids_limit: 32 + mem_limit: 256m + restart: "no" networks: financial: {internal: true} web: {} + observer-egress: {} volumes: pg-data: pg-admin: diff --git a/docs/real-xmr-pilot-deployment.md b/docs/real-xmr-pilot-deployment.md index 115a525..b769c46 100755 --- a/docs/real-xmr-pilot-deployment.md +++ b/docs/real-xmr-pilot-deployment.md @@ -1,6 +1,6 @@ # Private pilot deployment: mock-only foundation -Status: **application and isolated PostgreSQL sandbox only**. This is not a real-XMR stack, a public node, or an approval to accept deposits. The operator already hosts a Monero full node on the network and intends the pilot to reuse it; this Compose profile neither starts nor connects to that daemon yet. Wallet-RPC, observer, deposit enrollment, and withdrawal execution are not included. +Status: **application, isolated PostgreSQL sandbox, and opt-in one-shot observer definition**. This is not a real-XMR stack, a public node, or approval to accept deposits. The operator already hosts a Monero full node and intends the pilot to reuse it; Compose does not start a daemon or connect to a Wallet-RPC gateway by default. Observer execution requires separately provisioned external services and protected files. Deposit enrollment and withdrawal execution remain unavailable. ## Start the isolated profile @@ -16,7 +16,7 @@ To stop while preserving pilot data, run `docker compose -p wikideck-xmr-pilot - The independent private chain uses a fixed pilot genesis time and its own deterministic chain/genesis identity. The app can run with the current sandbox features and mock balances while test cards and UI flows are exercised. Testnet and mainnet candidate identities and launch gates remain unchanged. -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. +The default stack does **not** connect to the operator's Monero daemon, create deposit addresses, scan a wallet, approve credits, construct or relay withdrawals, or settle NFT sales against active consensus. An opt-in one-shot observer service is defined below, but it cannot run without a separately provisioned watch-only Wallet-RPC gateway, external pilot custody database, and protected credentials. Having a full node supplies only the blockchain daemon; it is not Wallet-RPC, a watch-only deposit wallet, or a spending wallet. Do not expose the service publicly or solicit funds. ### Pilot custody identity and deposit reservations (schema v14) @@ -61,7 +61,7 @@ From the pilot Compose project, run `docker compose -p wikideck-xmr-pilot -f com Before wiring it into observer/gateway services, also verify the daemon's software version and operation policy. Mainnet is required for the real-XMR run; Stagenet rehearsal needs a separate Stagenet daemon endpoint. A daemon that is reachable on the operator's LAN is not automatically reachable from Docker containers. -Keep daemon RPC private to the pilot/operator network, bound to an appropriate private interface, and firewall-allowlisted to the pilot host. Prefer a supported authenticated TLS boundary; do not publish daemon RPC or Wallet-RPC to the Internet. The probe's HTTP option has no credentials and relies on private-network isolation; it is only for an explicitly trusted/firewalled route. Store any later gateway credentials/certificates in protected secret files, never in source, Compose literals, or browser settings. The observer should use a watch-only wallet/RPC boundary; transaction signing and spend keys remain separate and out of the app/observer. The eventual flow still needs a private Wallet-RPC gateway, observer, schema/role integration, reviewed credits, actual withdrawal execution, recovery, and active sale settlement, as described in [the implementation plan](real-xmr-pilot-plan.md). +Keep daemon RPC private to the pilot/operator network, bound to an appropriate private interface, and firewall-allowlisted to the pilot host. Prefer a supported authenticated TLS boundary; do not publish daemon RPC or Wallet-RPC to the Internet. The probe's HTTP option has no credentials and relies on private-network isolation; it is only for an explicitly trusted/firewalled route. Store gateway credentials/certificates in protected secret files, never in source, Compose literals, or browser settings. The observer must use a watch-only wallet/RPC boundary; transaction signing and spend keys remain separate and out of the app/observer. The gateway config now assigns each client-certificate fingerprint explicit roles: `observer` permits only height/address reads and bounded incoming-transfer scans; `address-allocator` permits only address reads/validation and one-subaddress creation. Keep the observer certificate separate from the allocator certificate. Neither role can invoke transaction construction, signing, relay, or sweep methods. The eventual flow still needs reviewed credits, actual withdrawal execution, recovery, and active sale settlement, as described in [the implementation plan](real-xmr-pilot-plan.md). This probe does not establish that the node is honest or independently canonical, and `synchronized: true` is a point-in-time daemon report—not a finality guarantee or credit authorization. No deposit addresses are issued and no financial API is enabled by a successful result. @@ -69,7 +69,13 @@ This probe does not establish that the node is honest or independently canonical Pilot Wallet-RPC output scans are stored in separate pilot tables, not the existing mainnet-custody observation tables. The one-shot observer verifies the configured wallet fingerprint, resumes a bounded durable cursor with overlap, binds recognized outputs only to an exact registered reservation, and records unknown-wallet outputs as `unallocated` suspense observations. Replays are idempotent; missing, changed, and reappeared outputs produce append-only events for operator reorg review. This pipeline never posts a journal, creates a credit, or changes a balance. -The pilot observer role is separate from reservation/address roles and cannot read custody balances/journals or write credit records. Use `custody:db grant-pilot-monero-observer-runtime ` with `WIKIDECK_PILOT_CUSTODY_OPERATIONS=pilot-observation-only-no-credit` after schema migration and identity initialization. Run one operator-controlled scan with `npm run monero:pilot-observe`; it requires the protected pilot observer PostgreSQL URL/CA, Mainnet Wallet-RPC config, matching mTLS gateway config, and wallet generation/fingerprint settings. `WIKIDECK_PILOT_OBSERVER_MAX_BLOCKS` (default 5000; max 100000) bounds each pass; `WIKIDECK_PILOT_OBSERVER_OVERLAP` (default 100; max 10000) requests a rescan window. Re-run manually to advance the cursor. No background service or HTTP endpoint is enabled. +The pilot observer role is separate from reservation/address roles and cannot read custody balances/journals or write credit records. Use `custody:db grant-pilot-monero-observer-runtime ` with `WIKIDECK_PILOT_CUSTODY_OPERATIONS=pilot-observation-only-no-credit` after schema migration and identity initialization. The Compose profile `pilot-observer` runs one bounded scan and exits; invoke it with `docker compose -p wikideck-xmr-pilot -f compose.custody-pilot.yaml --profile pilot-observer run --rm pilot-observer`. It is not part of normal `up`, publishes no ports, uses a separate egress network, and mounts only explicitly supplied observer database and mTLS files read-only. Compose does not enforce outbound host allowlists: firewall policy must limit access to the configured private gateway and external observer database. Do not reuse the mock pilot app database or its credentials. + +The gateway's protected JSON config uses per-certificate policies, for example `clientPolicies: [{"fingerprint":"","roles":["observer"]}]`. Issue a different client certificate/fingerprint to the address allocator and assign only `address-allocator`; do not give the observer certificate that role. A combined role list is only for isolated rehearsals that intentionally exercise both capabilities with one disposable identity. Neither policy includes transaction signing, transfer, relay, or sweep methods. + +Provide these non-secret identity values in the local Compose environment: `WIKIDECK_PILOT_CUSTODIAN_ID`, `WIKIDECK_MONERO_WALLET_GENERATION_ID`, and `WIKIDECK_MONERO_WALLET_FINGERPRINT`. Point the following host-path variables at protected files outside the repository: `WIKIDECK_PILOT_OBSERVER_PG_URL_HOST`, `WIKIDECK_PILOT_OBSERVER_PG_CA_HOST`, `WIKIDECK_PILOT_OBSERVER_WALLET_CONFIG_HOST`, `WIKIDECK_PILOT_OBSERVER_MTLS_CONFIG_HOST`, `WIKIDECK_PILOT_OBSERVER_CLIENT_CERT_HOST`, `WIKIDECK_PILOT_OBSERVER_CLIENT_KEY_HOST`, and `WIKIDECK_PILOT_OBSERVER_GATEWAY_CA_HOST`. The mounted Wallet-RPC config must explicitly say `network: mainnet` and point to the private gateway. The mounted mTLS JSON must reference the container paths `/run/secrets/observer-client.crt`, `/run/secrets/observer-client.key`, and `/run/secrets/observer-gateway-ca.crt`; its URL must match the Wallet-RPC config. The PostgreSQL URL must use the dedicated `pilot_monero_observer` role on the distinct pilot custody database, not the app runtime, schema owner, review, reservation, or allocator role. + +`WIKIDECK_PILOT_OBSERVER_MAX_BLOCKS` (default 5000; max 100000) bounds each pass; `WIKIDECK_PILOT_OBSERVER_OVERLAP` (default 100; max 10000) requests a rescan window. Re-run manually to advance the cursor. There is no background observer, public HTTP endpoint, address issuance, or credit side effect. The Compose service definition is only wiring; connectivity is not verified until an operator supplies the private files and runs the one-shot command. ### Pilot deposit review preparation (read-only, no credit) diff --git a/docs/real-xmr-pilot-plan.md b/docs/real-xmr-pilot-plan.md index 67a5c27..5c739a3 100755 --- a/docs/real-xmr-pilot-plan.md +++ b/docs/real-xmr-pilot-plan.md @@ -4,7 +4,7 @@ Updated: 2026-10-22. Implementation plan for a private, operator-supervised func ## Progress checkpoint — 2026-10-22 -- **Step 1 browser smoke verified; acceptance remains partial.** The isolated Compose stack started with healthy PostgreSQL/app and completed migration; the browser showed `PRIVATE PILOT`, `RPC available`, `wikideck-randomx-pilot-1`, and mock-only/no-real-XMR status. One encrypted local pilot wallet was created and unlocked in the same browser origin. A hardened disposable Linux container, capped at one CPU core, one block, and five minutes, used its encrypted backup read-only and mined/accepted block 1 (article: Portlaoise railway station); the UI then showed height 1 and one confirmed wallet NFT. A second browser wallet was not created, and no external Monero daemon or funds path was tested. Therefore Step 1's planned two-wallet flow is not yet complete. +- **Pilot smoke evidence recorded; two-wallet UI check deferred.** The isolated Compose stack started with healthy PostgreSQL/app and completed migration; the browser showed `PRIVATE PILOT`, `RPC available`, `wikideck-randomx-pilot-1`, and mock-only/no-real-XMR status. One encrypted local pilot wallet was created and unlocked. A disposable Linux container capped at one CPU core, one block, and five minutes used the encrypted backup read-only and mined/accepted block 1 (article: Portlaoise railway station); the UI showed height 1 and one wallet NFT. A second browser wallet and external Monero daemon have not been exercised. The second-wallet demonstration is no longer a prerequisite for funds-enabling work. - **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. 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. @@ -13,10 +13,10 @@ Updated: 2026-10-22. Implementation plan for a private, operator-supervised func - **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. - **Pilot observation persistence implemented:** schema v14 stores pilot-only wallet cursors, scan records, assigned/unallocated outputs, and append-only `seen`/`changed`/`reappeared`/`not-seen` events. The separate observer role matches outputs only to a registered reservation with the same wallet generation, fingerprint, account/index, and address; unknown outputs remain suspense. A one-shot bounded scanner resumes with overlap through the new `monero:pilot-observe` command. This is observation-only: no journal, credit, HTTP route, or background service is enabled. - **Pilot deposit review preparation implemented:** an operator-only CLI action reads persisted pilot observations through a dedicated read-only PostgreSQL role and emits a pilot/Mainnet/custodian/allocation-bound review subject. It checks exact reservation and wallet binding, amount range, confirmations, unlock time, freshness, scan context, current presence, double-spend flag, and bounded append-only event history; changed/disappeared/reappeared evidence fails closed. The action is preparation-only: no signature approval, journal, pending/available balance, credit event, HTTP enrollment, or startup worker is involved. See [the deployment guide](real-xmr-pilot-deployment.md) for the separate role and command boundary. -- **Validation:** focused schema and observer PostgreSQL tests passed; full disposable PostgreSQL suite passed 46/46 and full serial offline suite passed 191/191. Tests cover assigned versus suspense outputs, replay, disappearance/reappearance evidence, exact large atomic amounts, zero financial posting, and observer role denials. No operator database or live node was accessed. -- **Step 1 remains incomplete only for the planned second browser wallet.** The external daemon is still not connected and there is no live-fund path. The stack remains private/mock-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. -- **Step 2 is in progress at review-preparation only.** Actual observer setup/scans against operator infrastructure, signed credit approval/staging/release, withdrawal execution/recovery, and sale settlement remain unavailable. Existing deposit/withdrawal APIs still require the separately gated mainnet profile; sale-lock transactions remain candidate-only and are not accepted by active consensus. +- **Observer connection boundary advanced:** the mTLS gateway now maps each client certificate fingerprint to explicit `observer` or `address-allocator` method roles. Observer credentials cannot create addresses; allocator credentials cannot scan transfers; neither role can call transfer, relay, or sweep methods. Compose now defines an opt-in, no-port, one-shot pilot observer using separate external database and mTLS file mounts, read-only and on a distinct egress network. It is not included in ordinary startup and has not connected to the hosted node; required private endpoint, gateway, wallet and database configuration are not available in the workspace. +- **Validation:** targeted pilot review-preparation PostgreSQL coverage passed; the latest disposable PostgreSQL run passed 47/47 and the latest serial offline run passed 193/193. These are baseline evidence, not a request to keep rerunning broad suites. No operator database or live node was accessed. +- **Funds readiness remains blocked.** The hosted daemon is not connected; no pilot observation scan, real credit, live withdrawal send, or recovery exercise has occurred. The stack remains private/mock-only and must not receive funds. +- **Step 2 is in progress at review-preparation and dormant observer wiring.** Pilot-bound assigned-output review preparation and the one-shot deployment boundary exist, but there has been no connection to operator infrastructure or observation scan. Signed pilot credit staging/release, pilot-bound withdrawal execution/recovery, and sale settlement remain unavailable. Existing deposit/withdrawal APIs still require the separately gated mainnet profile; sale-lock transactions remain candidate-only and are not accepted by active consensus. ## Finish line @@ -50,18 +50,18 @@ 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 v14 binds the pilot chain to Monero Mainnet, durably reserves signed beneficiary-bound address indexes, and stores pilot-only observation/reorg evidence in a dedicated database. A separate read-only CLI review preparer now checks that evidence and emits an unsigned subject without posting. No operator Wallet-RPC address, daemon observation, credit, or real-fund operation has been run. 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 v14 binds the pilot chain to Monero Mainnet, durably reserves signed beneficiary-bound address indexes, and stores pilot-only observation/reorg evidence in a dedicated database. A separate read-only CLI review preparer checks that evidence and emits an unsigned subject without posting. A role-separated mTLS gateway policy and opt-in one-shot Compose observer are wired, but no operator Wallet-RPC address, daemon observation, credit, or real-fund operation has been run. See [pilot deployment status and startup](real-xmr-pilot-deployment.md). -**Observed so far:** the private stack/browser health and first wallet were exercised, and the first native RandomX proof-of-work test card was accepted by the isolated pilot chain. **Still required for this step:** create the second browser wallet and verify the two-wallet UI flow. The hosted daemon's intended network, synchronization and private RPC reachability remain unverified; do not start a duplicate daemon. A Stagenet rehearsal still requires a Stagenet daemon endpoint; an operator's Mainnet daemon cannot substitute for it. +**Observed so far:** the private stack/browser health, first wallet, and one native RandomX proof-of-work test card were exercised. **Deferred:** the second-wallet browser demonstration; it is useful UI evidence but not on the funds-enabling critical path. The hosted daemon's intended network, synchronization and private RPC reachability remain unverified; reuse it rather than starting a duplicate. A Stagenet rehearsal requires a Stagenet endpoint; the hosted Mainnet daemon cannot substitute for it. ### 2. Connect deposits to spendable balances - Add a deposit panel that signs the existing enrollment request and displays the allocated address. - Run the observer against the configured watch-only wallet and operator-hosted daemon; show pending and available balances. Explicitly bind the Monero network and refuse a daemon/wallet network mismatch or stale/unsynchronized RPC state. -- Support the pilot identity in the existing prepare/stage/release workflow. Keep approvals operator-driven rather than adding automation. +- Extend the pilot-specific review subject into explicit operator-signed pending-credit and release/reorg-hold operations. Keep approvals manual; do not inherit or expose mainnet-only APIs through the browser by default. - Report capabilities truthfully as the flow becomes available; keep mock-credit controls unreachable in real-fund mode. -**Result:** a real Stagenet deposit is observed and approved into the buyer's available balance through the running application, using a Stagenet daemon endpoint. For the eventual real-XMR run, use the operator-hosted Monero Mainnet node only after network and synchronization checks pass. This is private development progress, not a reason to solicit real deposits before cash-out works. +**Result:** the operator can observe and reconcile a deposit, then explicitly approve its pending and available-balance transitions. Do not expose addresses or accept funds until Step 3 withdrawal and recovery gates are met. Use Stagenet first if a rehearsal endpoint is available; the eventual real-XMR run must use the operator-hosted Mainnet node only after its network, sync, and private transport are verified. ### 3. Finish withdrawals with an operator tool @@ -71,9 +71,12 @@ One Linux host, one authoritative market writer, one treasury tool/worker, fixed - Record preparation intent before wallet side effects and retain the recoverable signed transaction identity/blob before relay. Reconcile ambiguous preparation before trying again. - Relay and track the stored transaction. Retries reuse that transaction rather than creating another payment. - Settle the reserved liability with the actual fee and return unused fee allowance; keep uncertain sends reserved. +- Implement and rehearse backup/restore and restart recovery for the database, watch-only observer state, and separately controlled treasury wallet. Reconcile ambiguous sends before retrying; never treat a backup file as proof that funds can be restored. - Add browser withdrawal inputs, status and receipt. A CLI command can process the queue; no automatic payout scheduler is needed. -**Result:** deposit → signed withdrawal → operator send → XMR received, with correct balances and fees. +**Hard gate before accepting deposits:** a bounded Stagenet withdrawal, restart/recovery, and ambiguous-send reconciliation succeed through the actual operator path. Only then consider enabling a tightly capped private Mainnet deposit pilot with explicit operator approval. + +**Result:** deposit → signed withdrawal → operator send → XMR received, with correct balances, fees, and recoverable state. ### 4. Connect purchases to NFT delivery and seller earnings @@ -90,11 +93,11 @@ Start this alongside steps 2–3 after defining the pilot identity. Do not wait **Result:** seller lists → buyer buys → card changes owner → exact seller/fee balances appear → seller withdraws, even without a prior seller deposit. -### 5. Exercise the complete deployed flow, then test real XMR +### 5. Exercise the money path narrowly, then consider real XMR -1. Run deposit → purchase → seller withdrawal on Stagenet using the actual browsers and deployment. -2. Include focused implementation checks for duplicate requests, restart after preparation/relay, cancellation versus send and canonical sale disconnect. These protect the flow from duplicate money/asset movement; they are not a separate certification program. -3. With explicit wallet/network/amount approval, configure Monero mainnet and repeat with operator-controlled accounts and a small amount sufficient for fees. +1. Run observer → reviewed deposit → withdrawal on Stagenet using the real gateway/deployment where an endpoint is available; exercise recovery and ambiguous-send handling. +2. Validate only the money-moving failure cases that could duplicate, lose, or misdirect funds (idempotency, restart/recovery, cancel/send race, and reorg holds). Do not make broad suite reruns, second-wallet UI coverage, benchmarks, or certification the critical path. +3. After the withdrawal/recovery gate and explicit operator approval, configure Monero Mainnet and start with tightly capped operator-controlled amounts. Keep incoming funds disabled until those prerequisites are satisfied. **Result:** a functional private real-XMR market with repeatable startup and operator commands. Public launch is outside this increment. @@ -102,11 +105,11 @@ Start this alongside steps 2–3 after defining the pilot identity. Do not wait | Workstream | Start | Delivers | | --- | --- | --- | -| Pilot identity + deployment | First | Running stack and unblocked integration | -| Deposit UI + reviewed credit | After identity/setup | Funded buyer balance | -| Treasury tool + withdrawal UI | Alongside deposits | Actual cash-out | +| Hosted-node verification + private gateway/watch-only setup | Next operational dependency | Verified Mainnet/Stagenet reachability and observation boundary; no duplicate daemon | +| Pilot observation + operator-reviewed credit path | After identity/setup | Persisted evidence and controlled pending/available transitions; remain disabled until cash-out gate | +| Treasury withdrawal + recovery | Implement alongside observer work; gate before inbound funds | Actual cash-out, uncertain-send reconciliation, restorable state | | Sale-lock activation + custody settlement | After identity is defined, alongside payment work | Paid NFT delivery and seller earnings | -| Browser end-to-end flow | As slices connect | Deployable functional pilot | +| Focused deployed money-path exercise | Once slices connect | Evidence against loss/duplication/misdirection; not a broad certification phase | Build each slice with its actual callers. Do not spend another increment adding a disconnected model or endpoint. @@ -125,4 +128,4 @@ Keep the functional essentials: signed authorization, correct network binding, p ## Next coding task -**Next: add an operator-only pilot credit-review preparation path that consumes these pilot-bound assigned observations, validates confirmation/unlock/double-spend and freshness, and produces a review subject without posting a journal.** Keep unallocated outputs in suspense; do not add automatic credit, customer HTTP enrollment, or withdrawal activation. Continue after this with the separate pending-credit posting/release roles and reorg hold resolution, still gated until cash-out and recovery work is ready. +**Next: add a pilot-bound durable withdrawal lifecycle and operator-only no-send preparation review.** Existing mainnet withdrawal intents are bound to a different identity; sandbox `prepared` rows are fixture-only, so neither can be reused as the pilot treasury workflow. Start with a pilot-specific schema/role and a read-only manifest of the exact beneficiary, destination, amount, fee cap, reservation, backing and identity. Then add an exclusive signer handoff, recoverable prepared transaction identity, relay/reconciliation states and a separately controlled treasury boundary. Keep live sends and all real deposits disabled until Stagenet recovery and uncertain-send behavior are operationally demonstrated. The observer is now wired as an opt-in one-shot service; actual connection awaits the operator's protected endpoint/gateway/database configuration. In parallel, add manual pilot pending-credit/release and reorg-hold operations. Defer the second-wallet demo and broad test/certification work; use focused checks for idempotency, cancel/send races, restart recovery, role boundaries and reorgs. diff --git a/scripts/monero-mtls-stagenet-rehearsal.mjs b/scripts/monero-mtls-stagenet-rehearsal.mjs index ce19886..dd40658 100755 --- a/scripts/monero-mtls-stagenet-rehearsal.mjs +++ b/scripts/monero-mtls-stagenet-rehearsal.mjs @@ -117,7 +117,7 @@ try { gateway=await createMoneroRpcGateway({listenHost:'127.0.0.1',listenPort:gatewayPort,walletRpcUrl:rpcBase, server:{certificateFile:certs['server.crt'],privateKeyFile:certs['server.key'],clientCaFile:certs['ca.crt']}, - clientFingerprints:[fingerprint]}); + clientPolicies:[{fingerprint,roles:['observer','address-allocator']}]}); console.log('stage: mTLS gateway listening'); const validTransport=await loadMoneroMtlsTransport({url:gatewayUrl,clientCertificateFile:certs['client.crt'], clientPrivateKeyFile:certs['client.key'],serverCaFile:certs['ca.crt']}); diff --git a/server/market/monero-rpc-gateway.ts b/server/market/monero-rpc-gateway.ts index 12ab638..9f7c1c8 100755 --- a/server/market/monero-rpc-gateway.ts +++ b/server/market/monero-rpc-gateway.ts @@ -10,19 +10,39 @@ const MAX_REQUEST_BYTES = 64 * 1024; const MAX_RESPONSE_BYTES = 1024 * 1024; const tlsFilesSchema = z.object({ certificateFile: z.string().min(1).max(4096), privateKeyFile: z.string().min(1).max(4096), clientCaFile: z.string().min(1).max(4096) }).strict(); +const gatewayClientPolicySchema = z.object({ + fingerprint: z.string().regex(/^[a-f0-9]{64}$/), + roles: z.array(z.enum(['observer', 'address-allocator'])).min(1).max(2), +}).strict(); const gatewayConfigSchema = z.object({ listenHost: z.string().min(1).max(64), listenPort: z.number().int().min(1024).max(65535), walletRpcUrl: z.string().url().max(2048), server: tlsFilesSchema, - clientFingerprints: z.array(z.string().regex(/^[a-f0-9]{64}$/)).min(1).max(32), -}).strict(); + clientPolicies: z.array(gatewayClientPolicySchema).min(1).max(32), +}).strict().superRefine((config, context) => { + const fingerprints = config.clientPolicies.map(client => client.fingerprint); + if (new Set(fingerprints).size !== fingerprints.length) { + context.addIssue({ code: 'custom', message: 'Gateway client fingerprints must be unique', path: ['clientPolicies'] }); + } + for (const client of config.clientPolicies) { + if (new Set(client.roles).size !== client.roles.length) { + context.addIssue({ code: 'custom', message: 'Gateway client roles must be unique', path: ['clientPolicies'] }); + } + } +}); const envelopeSchema = z.object({ jsonrpc: z.literal('2.0'), id: z.union([z.string().max(128), z.number().int().safe()]), method: z.string().max(64), params: z.record(z.string(), z.unknown()).optional() }).strict(); const uint32 = z.number().int().min(0).max(0xffff_ffff).safe(); const height = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).safe(); export type MoneroGatewayConfig = z.infer; +export type MoneroGatewayRole = 'observer' | 'address-allocator'; +type GatewayMethod = 'get_height' | 'get_address' | 'validate_address' | 'create_address' | 'get_transfers' | 'incoming_transfers'; +const METHODS_BY_ROLE: Record = { + observer: ['get_height', 'get_address', 'get_transfers', 'incoming_transfers'], + 'address-allocator': ['get_address', 'validate_address', 'create_address'], +}; function isPrivateBindAddress(value: string): boolean { if (isIP(value) === 4) { @@ -60,8 +80,12 @@ async function readPrivateFile(path: string): Promise { return readFile(path); } -export function validateMoneroRpcGatewayRequest(value: unknown): string { +export function validateMoneroRpcGatewayRequest(value: unknown, roles: readonly MoneroGatewayRole[]): string { const request = envelopeSchema.parse(value); + const parsedRoles = z.array(z.enum(['observer', 'address-allocator'])).min(1).max(2).parse(roles); + if (new Set(parsedRoles).size !== parsedRoles.length) throw new Error('Monero RPC gateway client roles must be unique'); + const allowed = new Set(parsedRoles.flatMap(role => METHODS_BY_ROLE[role])); + if (!allowed.has(request.method as GatewayMethod)) throw new Error('Wallet RPC method is not allowed for this client certificate'); const params = request.params ?? {}; switch (request.method) { case 'get_height': @@ -138,7 +162,7 @@ export async function createMoneroRpcGateway(configInput: unknown): Promise [policy.fingerprint, policy.roles])); const [cert, key, ca] = await Promise.all([readPrivateFile(config.server.certificateFile), readPrivateFile(config.server.privateKeyFile), readPrivateFile(config.server.clientCaFile)]); const server = createServer({ cert, key, ca, requestCert: true, rejectUnauthorized: true, minVersion: 'TLSv1.3' }, (request, response) => { @@ -148,7 +172,8 @@ export async function createMoneroRpcGateway(configInput: unknown): Promise>; try { result = await forwardToLocalWallet(upstream, body); } diff --git a/tests/monero-observer.test.ts b/tests/monero-observer.test.ts index 1d7e1c6..ce68cfa 100755 --- a/tests/monero-observer.test.ts +++ b/tests/monero-observer.test.ts @@ -192,30 +192,36 @@ test('official wallet not-yet-created address-index error is recoverable but unr await assert.rejects(createMoneroDepositAddressRpc(config, unrelated).getSubaddress(0, 1), /method failed \(-15\)/); }); -test('mTLS gateway allowlists only bounded observation and one-subaddress allocation methods', () => { +test('mTLS gateway enforces bounded per-certificate observer and address-allocator methods', () => { const request = (method: string, params: Record = {}) => ({ jsonrpc: '2.0', id: 'fixture-id', method, params }); - assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('get_height'))); - assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('get_address', { account_index: 0, address_index: [0] }))); - assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('validate_address', { address: 'x'.repeat(95) }))); - assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('create_address', { account_index: 0, label: 'fixture', count: 1 }))); + const observer = ['observer'] as const, allocator = ['address-allocator'] as const; + assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('get_height'), observer)); + assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('get_address', { account_index: 0, address_index: [0] }), observer)); + assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('create_address', { account_index: 0, label: 'fixture', count: 1 }), allocator)); + assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('validate_address', { address: 'x'.repeat(95) }), allocator)); assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('get_transfers', { in: true, filter_by_height: true, min_height: 0, max_height: 100, - }))); + }), observer)); assert.doesNotThrow(() => validateMoneroRpcGatewayRequest(request('incoming_transfers', { transfer_type: 'all', account_index: 0, subaddr_indices: [2, 3], - }))); + }), observer)); assert.throws(() => validateMoneroRpcGatewayRequest(request('incoming_transfers', { transfer_type: 'all', account_index: 0, subaddr_indices: [2], key_image: true, - }))); + }), observer)); + assert.throws(() => validateMoneroRpcGatewayRequest(request('create_address', { account_index: 0, label: 'fixture', count: 1 }), observer), /not allowed for this client certificate/); + assert.throws(() => validateMoneroRpcGatewayRequest(request('get_transfers', { + in: true, filter_by_height: true, min_height: 0, max_height: 100, + }), allocator), /not allowed for this client certificate/); for (const method of ['transfer', 'transfer_split', 'sweep_all', 'relay_tx', 'get_tx_key', 'query_key', 'open_wallet', 'get_balance']) { - assert.throws(() => validateMoneroRpcGatewayRequest(request(method)), /not allow-listed/); + assert.throws(() => validateMoneroRpcGatewayRequest(request(method), ['observer', 'address-allocator']), /not allowed for this client certificate/); } - assert.throws(() => validateMoneroRpcGatewayRequest(request('get_address', { account_index: 0, address_index: [0], all: true }))); - assert.throws(() => validateMoneroRpcGatewayRequest(request('create_address', { account_index: 0, label: 'x', count: 2 }))); + assert.throws(() => validateMoneroRpcGatewayRequest(request('get_address', { account_index: 0, address_index: [0], all: true }), observer)); + assert.throws(() => validateMoneroRpcGatewayRequest(request('create_address', { account_index: 0, label: 'x', count: 2 }), allocator)); assert.throws(() => validateMoneroRpcGatewayRequest(request('get_transfers', { in: true, filter_by_height: true, min_height: 0, max_height: 100_001, - })), /range/); + }), observer), /range/); + assert.throws(() => validateMoneroRpcGatewayRequest(request('get_height'), [])); }); test('production mTLS transport config is mandatory and requires a bare HTTPS RPC endpoint', async () => { @@ -223,10 +229,10 @@ test('production mTLS transport config is mandatory and requires a bare HTTPS RP await assert.rejects(loadMoneroRpcGatewayConfig(''), /protected Monero RPC gateway configuration/); await assert.rejects(createMoneroRpcGateway({ listenHost: '203.0.113.10', listenPort: 18085, walletRpcUrl: 'http://127.0.0.1:18083/json_rpc', server: { certificateFile: 'missing', privateKeyFile: 'missing', - clientCaFile: 'missing' }, clientFingerprints: ['a'.repeat(64)] }), /private interface IP/); + clientCaFile: 'missing' }, clientPolicies: [{ fingerprint: 'a'.repeat(64), roles: ['observer'] }] }), /private interface IP/); await assert.rejects(createMoneroRpcGateway({ listenHost: '127.0.0.1', listenPort: 18085, walletRpcUrl: 'http://wallet.example/json_rpc', server: { certificateFile: 'missing', privateKeyFile: 'missing', - clientCaFile: 'missing' }, clientFingerprints: ['a'.repeat(64)] }), /loopback-only/); + clientCaFile: 'missing' }, clientPolicies: [{ fingerprint: 'a'.repeat(64), roles: ['observer'] }] }), /loopback-only/); }); test('observer refuses non-loopback plaintext, URL credentials and malformed height ranges', async () => { diff --git a/tests/pilot-deployment.test.ts b/tests/pilot-deployment.test.ts index eb99e61..f379532 100755 --- a/tests/pilot-deployment.test.ts +++ b/tests/pilot-deployment.test.ts @@ -14,11 +14,20 @@ test('private pilot Compose stack is loopback-bound, isolated, and mock-only', a assert.match(compose, /financial: \{internal: true\}/); assert.match(compose, /pg-runtime:\/secrets\/runtime:ro/); assert.doesNotMatch(compose, /POSTGRES_PASSWORD:\s|5432:5432/i); - assert.doesNotMatch(compose, /^\s+(?:monero|wallet-rpc|observer):/im); + assert.doesNotMatch(compose, /^\s+(?:monero|wallet-rpc):/im); assert.match(compose, /daemon-check:\n\s+image: wikideck:private-pilot/); assert.match(compose, /profiles: \["monero-check"\]/); assert.match(compose, /scripts\/monero-daemon-check\.ts/); assert.match(compose, /target: \/run\/monero\/daemon\.json\n\s+read_only: true/); + assert.match(compose, /pilot-observer:\n\s+image: wikideck:private-pilot\n\s+profiles: \["pilot-observer"\]/); + assert.match(compose, /scripts\/pilot-monero-observe-once\.ts/); + const observer = compose.split(' pilot-observer:')[1].split('\nnetworks:')[0]; + assert.doesNotMatch(observer, /ports:|depends_on:|pg-runtime:|pg-admin:|pg-owner:/); + assert.match(observer, /pilot-observation-only-no-credit/); + assert.match(observer, /target: \/run\/secrets\/pilot-observer-db-url\n\s+read_only: true/); + assert.match(observer, /target: \/run\/secrets\/observer-client\.key\n\s+read_only: true/); + assert.match(observer, /networks: \[observer-egress\]/); + assert.match(observer, /restart: "no"/); const database = compose.split(' database:')[1].split(' migrate:')[0]; assert.doesNotMatch(database, /ports:/); const app = compose.split(' wikideck:')[1].split('networks:\n financial:')[0];