diff --git a/Dockerfile b/Dockerfile index d2965fa..a46411a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ ENV VITE_WIKIDECK_NETWORK=$WIKIDECK_NETWORK RUN npm run build FROM build AS test -RUN apk add --no-cache libstdc++ postgresql-client openssl +RUN apk add --no-cache libstdc++ COPY --from=randomx-build /native/build/wikideck-randomx /app/native/wikideck-randomx ENV RANDOMX_BINARY=/app/native/wikideck-randomx CMD ["npm", "test"] @@ -42,13 +42,6 @@ COPY server ./server COPY shared ./shared COPY scripts/node-backup.ts ./scripts/node-backup.ts COPY scripts/ops-check.ts ./scripts/ops-check.ts -COPY scripts/custody-db.ts ./scripts/custody-db.ts -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 scripts/pilot-monero-observe-once.ts ./scripts/pilot-monero-observe-once.ts COPY --from=randomx-build /native/build/wikideck-randomx ./native/wikideck-randomx RUN mkdir -p /app/data && chown node:node /app/data USER node @@ -58,10 +51,3 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ # Node 24 strips the server's erasable TypeScript; no tsx/dev dependencies at runtime. CMD ["node", "--experimental-strip-types", "server/index.ts"] -# Explicit one-shot maintenance image, derived from runtime so it has no development dependencies. -FROM runtime AS custody-backup -USER root -RUN apk add --no-cache postgresql-client -COPY scripts/custody-pair-backup.ts ./scripts/custody-pair-backup.ts -USER node -ENTRYPOINT ["node", "--experimental-strip-types", "scripts/custody-pair-backup.ts"] diff --git a/Dockerfile.lockgen b/Dockerfile.lockgen new file mode 100755 index 0000000..14db763 --- /dev/null +++ b/Dockerfile.lockgen @@ -0,0 +1,4 @@ +FROM node:24-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install --package-lock-only --ignore-scripts diff --git a/README.md b/README.md index f5fb87a..5fbc931 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Wikideck -Wikideck is an experimental blockchain game about **mining, collecting, and trading illustrated Wikipedia cards**. Mine a block with the native RandomX CPU miner to earn a card, build a collection in a local wallet, and trade cards directly with another player. The default app has no marketplace, prices, or checkout. +Wikideck is an experimental blockchain game about **mining, collecting, and trading illustrated Wikipedia cards**. Mine a block with the native RandomX CPU miner to earn a card, build a collection in a local wallet, and trade cards directly with another player. + +**Production readiness:** see the [card-only production plan](docs/card-only-production-plan.md). A public beta and a valuable mainnet launch have separate release gates; neither is approved by a successful local build. Mining uses **RandomX, the proof-of-work algorithm also used by Monero**, designed to keep general-purpose consumer CPUs competitive. Wikideck is an independent blockchain, not part of Monero or its mining/security network. Hardware, total network hashpower and difficulty determine results; no particular mining reward rate or profitability is guaranteed. @@ -13,19 +15,15 @@ Mining uses **RandomX, the proof-of-work algorithm also used by Monero**, design - **Trade directly.** Look up another public wallet and exchange up to eight cards on each side in one signed, atomic trade. Offers are shared between the two players; the app does not list cards for sale or process payments. - **Gameplay is not included yet.** The current release supports mining, collecting, transfers, and direct trades. Playable card games are future work. -## Optional legacy market modules - -Market and custody code remains in the repository for separate experiments, but it is **not part of the default card-trading experience**. The server does not open a market journal, expose market APIs, or run its reconciliation worker unless `WIKIDECK_MARKET_RUNTIME_ENABLED=1` is explicitly set. Enabling it is an operator decision; market orders are local to the node, checkout is simulated, and no real XMR is accepted or sent. See the [market implementation notes](docs/marketplace.md) and [security boundaries](docs/market-transparency.md) before enabling it. Existing market data is not deleted when the feature is off, but disabling an existing market pauses order resolution; review outstanding orders before switching modes. - **Licensing status:** this checkout does not yet contain a project LICENSE file. Source availability alone does not grant open-source modification/redistribution rights. Wikipedia text/images and dependencies retain their separate licenses. ## What's available now -| Available in the testnet | Not included in the default experience | +| Available in the testnet | Not included | | --- | --- | -| RandomX mining and illustrated Wikipedia card rewards | Marketplace listings, auctions, and checkout | -| Encrypted local wallets, collections, transfers, and atomic card trades | Real cryptocurrency payments or custody | -| Local single-node or optional three-node testnet | Playable card games and production network launch | +| RandomX mining and illustrated Wikipedia card rewards | Playable card games | +| Encrypted local wallets, collections, transfers, and atomic card trades | Production network launch | +| Local single-node or optional three-node testnet | — | ## Quick start: one local node (Docker) @@ -36,7 +34,7 @@ docker compose up --build -d docker compose ps ``` -Open **http://127.0.0.1:3001**. The first run builds the node and native RandomX engine. The node does not mine automatically. Open the wallet panel to create a local wallet and encrypted backup, then follow [Mine Wikipedia cards with RandomX](#mine-wikipedia-cards-with-randomx) to run the miner. The default stack leaves marketplace startup and APIs disabled. Stop the node with `docker compose down`; the ledger volume remains. **Do not add `-v` unless you intend to delete that data.** +Open **http://127.0.0.1:3001**. The first run builds the node and native RandomX engine. The node does not mine automatically. Open the wallet panel to create a local wallet and encrypted backup, then follow [Mine Wikipedia cards with RandomX](#mine-wikipedia-cards-with-randomx) to run the miner. Stop the node with `docker compose down`; the ledger volume remains. **Do not add `-v` unless you intend to delete that data.** ## Optional: three-node testnet (Docker) @@ -48,7 +46,7 @@ docker compose -f compose.testnet.yaml ps docker compose -f compose.testnet.yaml logs -f node-a ``` -Open **http://127.0.0.1:3101**. The other nodes are at **http://127.0.0.1:3102** and **http://127.0.0.1:3103**. Each node validates blocks independently and stores its ledger in a separate Docker volume. Marketplace runtime is disabled by default; mining does not start automatically. A healthy node is not necessarily connected to its peers; check the peer status in the interface or request `/api/peers`. Nodes need outbound access to Wikipedia when generating mining templates and capturing mint-time statistics. If that data is unavailable, the node returns an error and mining can be retried. +Open **http://127.0.0.1:3101**. The other nodes are at **http://127.0.0.1:3102** and **http://127.0.0.1:3103**. Each node validates blocks independently and stores its ledger in a separate Docker volume. Mining does not start automatically. A healthy node is not necessarily connected to its peers; check the peer status in the interface or request `/api/peers`. Nodes need outbound access to Wikipedia when generating mining templates and capturing mint-time statistics. If that data is unavailable, the node returns an error and mining can be retried. Stop the nodes with: @@ -62,8 +60,6 @@ For an existing host install, keep the old data directory intact and set `WIKIDE ## Run on your host -The legacy market can be explicitly enabled with `WIKIDECK_MARKET_RUNTIME_ENABLED=1`; do not enable it for a node that has unresolved orders without reviewing its local journal first. When disabled, the market journal is left untouched, but the node does not reconcile it or expose market APIs. The separate custody and Monero experiments require dedicated configurations and are not needed to mine or trade cards. Their deployment instructions remain in the `docs/` directory, but are outside the supported quick-start path. - Requirements: Node.js 24, npm, Git, CMake 3.24+, and a C/C++17 toolchain. The first native-engine configuration downloads the pinned RandomX source over HTTPS. On Windows, install a compatible toolchain (for example, MinGW-w64 and make) and use the matching CMake generator. Do not change generators in an existing `native/build` directory; use a fresh build directory when switching toolchains. Install dependencies and build the native RandomX engine: @@ -108,7 +104,7 @@ Light mode needs a 256 MiB cache plus scratchpad and process overhead. `--fast` - **Discover** shows preview examples until the node has mined blocks; previews are not owned NFTs. Minted cards can reference the same page, but each carries its own captured stats snapshot. - **My collection** shows NFTs owned by the active local wallet on the connected node's canonical chain. - **Block explorer** distinguishes block IDs, NFT token IDs, and RandomX proof hashes. A submitted transfer is pending until a block confirms it. Reorganizations can change canonical ownership and confirmations. -- **Trade decks** lets you look up another public wallet by receive address, select up to eight cards on each side, prepare a signed offer, and accept another player's offer. Offers are shared as JSON for the other player to review; they are not submitted until accepted. The two parties sign the same card list, and validators apply the whole bundle atomically or reject it entirely. Offers are not reservations: before acceptance the app re-checks ownership, maturity, sequences, and pending activity; stale offers must be recreated. +- **Trade cards** lets you look up another public wallet by receive address, select up to eight cards on each side, prepare a signed offer, and accept another player's offer. Offers are shared as JSON for the other player to review; they are not submitted until accepted. The two parties sign the same card list, and validators apply the whole bundle atomically or reject it entirely. Offers are not reservations: before acceptance the app re-checks ownership, maturity, sequences, and pending activity; stale offers must be recreated. - Each minted card shows current Wikipedia statistics alongside its immutable at-mint snapshot: pageviews, article size, language links, revision, and derived scores. Current values may change; at-mint values are committed to the block. Missing pageview data stays unavailable, not zero. - The appearance selector offers Catppuccin Mocha, Macchiato, Frappé, and Latte themes. @@ -118,7 +114,7 @@ The browser interface trusts the connected node's reported state. It does not in **Production status: NO-GO pending remediation and independent review.** See the [security review](docs/security-review.md) and [operator launch checklist](docs/operator-launch.md). A read-only `launch:preflight` script reports blockers and local gate status without launching or granting approval. -A separate mainnet profile has its own network/genesis identity, ledger and market directories, browser wallet, backup checks, and opt-in [Compose definition](compose.mainnet.yaml). It preserves the existing testnet chain and does not migrate testnet cards. **No production network has been launched or certified by this work.** Startup requires explicit chain-specific approval, an operator review attestation, the candidate genesis time, HTTPS origins and multiple configured peers. Real payments remain blocked even then. See [the candidate launch gates and production checklist](docs/mainnet-launch.md). +A separate mainnet profile has its own network/genesis identity, ledger directory, browser wallet, backup checks, and opt-in [Compose definition](compose.mainnet.yaml). It preserves the existing testnet chain and does not migrate testnet cards. **No production network has been launched or certified by this work.** Startup requires explicit chain-specific approval, an operator review attestation, the candidate genesis time, HTTPS origins and multiple configured peers. See [the candidate launch gates and production checklist](docs/mainnet-launch.md). ## Tests and development checks @@ -144,13 +140,11 @@ This mines three real blocks using disposable identities. It changes the testnet ## Network and safety limits -Live XMR payments are not implemented. The selected custody design uses direct Monero wallet/daemon observation and a pooled liability ledger; no real deposits, credits, withdrawals, or sale settlement are enabled. See the [custodial market design](docs/custodial-market-design.md) and [launch hold points](docs/operator-launch.md). - -[Deployment and recovery preparation](docs/operations-recovery.md) includes explicit trusted-proxy configuration, opt-in loopback-only diagnostics and `ops:check`, plus stopped-node paired `node:backup` create/verify/new-directory restore tooling. Backups contain sensitive market data and require external encryption/access control. Tools do not configure TLS/alerts or authorize production launch/restore. +[Deployment and recovery preparation](docs/operations-recovery.md) includes explicit trusted-proxy configuration, opt-in loopback-only diagnostics and `ops:check`, plus stopped-node `node:backup` create/verify/new-directory restore tooling. New backups contain the ledger only; creation refuses to proceed if legacy financial journals or bindings remain, so archive those separately before retiring old deployments. Tools do not configure TLS/alerts or authorize production launch/restore. [Stage-1 node resource controls](docs/node-resources.md) bound global RPC admission, response bytes and canonical page sizes. The UI reuses paged history; legacy full-state responses stop beyond 128 blocks. A consent-gated loopback-only smoke-load script is available as `test:rpc-load`. This does not resolve long-chain/DAG storage limits or establish production capacity. -Nodes use only explicitly configured peers; there is no discovery service or public bootstrap network. The peer, storage, and HTTP configuration is documented in [docs/testnet-consensus.md](docs/testnet-consensus.md). The implementation is experimental: it has no consensus transaction fees or established economic security (market fees are off-chain simulations), and remains vulnerable to resource exhaustion, isolation/eclipsing, censorship, majority-hashpower attacks, and reorganizations. The ledger now uses transactional SQLite/WAL storage and sparse per-block NFT-state deltas, but nodes still retain DAG block records in memory, rebuild state roots over the full NFT set, and revalidate all proofs at startup. It is not yet an archival-scale production design. HTTPS, container hardening, and passing tests do not constitute a security audit. +Nodes use only explicitly configured peers; there is no discovery service or public bootstrap network. The peer, storage, and HTTP configuration is documented in [docs/testnet-consensus.md](docs/testnet-consensus.md). The implementation is experimental: it has no established economic security and remains vulnerable to resource exhaustion, isolation/eclipsing, censorship, majority-hashpower attacks, and reorganizations. The ledger now uses transactional SQLite/WAL storage and sparse per-block NFT-state deltas, but nodes still retain DAG block records in memory, rebuild state roots over the full NFT set, and revalidate all proofs at startup. It is not yet an archival-scale production design. HTTPS, container hardening, and passing tests do not constitute a security audit. Before considering any broader deployment, the protocol and cryptography would need independent audits, adversarial network testing, hardened abuse controls, and tested recovery procedures. Do not expose a public node or use valuable assets on this experimental network. diff --git a/compose.custody-backup.yaml b/compose.custody-backup.yaml deleted file mode 100755 index 982de0d..0000000 --- a/compose.custody-backup.yaml +++ /dev/null @@ -1,48 +0,0 @@ -# Explicit local sandbox backup helper. Start the persistent stack first, then stop only wikideck. -# Requires WIKIDECK_BACKUP_DIR (existing restricted host directory) and WIKIDECK_BACKUP_NAME (new leaf name). -# For verification, override the service command with: verify --source /backups/. -services: - backup: - image: wikideck:custody-backup - build: - context: . - dockerfile: Dockerfile - target: custody-backup - environment: - WIKIDECK_NETWORK: testnet - WIKIDECK_CUSTODY_STORAGE: postgres - WIKIDECK_CUSTODY_PG_URL_FILE: /secrets/runtime/url - WIKIDECK_CUSTODY_PG_BACKUP_URL_FILE: /secrets/backup/url - WIKIDECK_CUSTODY_PG_CA_FILE: /secrets/backup/ca.crt - command: ["create", "--source", "/app/data", "--destination", "/backups/${WIKIDECK_BACKUP_NAME:?Set a new backup leaf name}", "--consent"] - volumes: - - custody-node:/app/data - - pg-runtime:/secrets/runtime:ro - - pg-backup:/secrets/backup:ro - - type: bind - source: ${WIKIDECK_BACKUP_DIR:?Set an existing restricted host backup directory} - target: /backups - bind: - create_host_path: false - networks: [financial] - read_only: true - tmpfs: ["/tmp:mode=1777,size=32m,noexec,nosuid"] - cap_drop: [ALL] - security_opt: [no-new-privileges:true] - pids_limit: 32 - mem_limit: 512m - restart: "no" -networks: - financial: - external: true - name: wikideck-custody-local_financial -volumes: - custody-node: - external: true - name: wikideck-custody-local_custody-node - pg-runtime: - external: true - name: wikideck-custody-local_pg-runtime - pg-backup: - external: true - name: wikideck-custody-local_pg-backup diff --git a/compose.custody-pilot.yaml b/compose.custody-pilot.yaml deleted file mode 100755 index 8ab0e06..0000000 --- a/compose.custody-pilot.yaml +++ /dev/null @@ -1,210 +0,0 @@ -# Private mock-only pilot profile. Start with a distinct Compose project name; -# this stack intentionally contains no Monero daemon, Wallet-RPC, or observer. -services: - provision: - image: wikideck:pilot-provision - build: - context: . - dockerfile: deploy/postgres/Dockerfile - volumes: - - pg-admin:/secrets/admin - - pg-owner:/secrets/owner - - pg-runtime:/secrets/runtime - - pg-backup:/secrets/backup - - pg-tls:/secrets/tls - network_mode: none - read_only: true - tmpfs: ["/tmp:mode=1777,size=16m"] - restart: "no" - database: - image: wikideck:postgres-pilot - build: - context: . - dockerfile: deploy/postgres/database.Dockerfile - depends_on: - provision: - condition: service_completed_successfully - environment: - POSTGRES_DB: wikideck_custody - POSTGRES_USER: postgres - POSTGRES_PASSWORD_FILE: /secrets/admin/password - POSTGRES_INITDB_ARGS: --auth-host=scram-sha-256 - command: [postgres, -c, ssl=on, -c, ssl_min_protocol_version=TLSv1.2, - -c, ssl_cert_file=/tls/server.crt, -c, ssl_key_file=/tls/server.key, - -c, hba_file=/etc/wikideck/pg_hba.conf, -c, log_statement=none, - -c, log_min_error_statement=panic, -c, log_parameter_max_length_on_error=0] - volumes: - - pg-data:/var/lib/postgresql/data - - pg-admin:/secrets/admin:ro - - pg-tls:/tls:ro - networks: [financial] - healthcheck: - test: [CMD-SHELL, "pg_isready -U postgres -d wikideck_custody"] - interval: 3s - timeout: 3s - retries: 30 - restart: unless-stopped - shm_size: 128m - stop_grace_period: 30s - migrate: - image: wikideck:private-pilot - build: - context: . - target: runtime - args: - WIKIDECK_NETWORK: pilot - depends_on: - database: - condition: service_healthy - environment: - WIKIDECK_NETWORK: pilot - WIKIDECK_LOCAL_PG_BOOTSTRAP: sandbox-only - command: [node, --experimental-strip-types, scripts/custody-deployment-init.ts] - volumes: - - pg-admin:/secrets/admin:ro - - pg-owner:/secrets/owner:ro - - pg-runtime:/secrets/runtime:ro - - pg-backup:/secrets/backup:ro - networks: [financial] - read_only: true - cap_drop: [ALL] - security_opt: [no-new-privileges:true] - restart: "no" - wikideck: - image: wikideck:private-pilot - build: - context: . - target: runtime - args: - WIKIDECK_NETWORK: pilot - depends_on: - migrate: - condition: service_completed_successfully - ports: ["127.0.0.1:${WIKIDECK_PILOT_PORT:-3423}:3001"] - environment: - WIKIDECK_NETWORK: pilot - WIKIDECK_PILOT_MODE: private-mock-only - WIKIDECK_PAYMENT_MODE: mock - WIKIDECK_MARKET_ENABLED: "${WIKIDECK_MARKET_ENABLED:-1}" - WIKIDECK_MARKET_TRADING_ENABLED: "${WIKIDECK_MARKET_TRADING_ENABLED:-1}" - WIKIDECK_MARKET_HOST_FEE_ADDRESS: "${WIKIDECK_MARKET_HOST_FEE_ADDRESS:-44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv}" - WIKIDECK_CUSTODY_MODE: sandbox - WIKIDECK_MARKET_RUNTIME_ENABLED: "${WIKIDECK_MARKET_RUNTIME_ENABLED:-1}" - WIKIDECK_CUSTODY_STORAGE: postgres - WIKIDECK_CUSTODY_PG_URL_FILE: /secrets/runtime/url - WIKIDECK_CUSTODY_PG_CA_FILE: /secrets/runtime/ca.crt - PUBLIC_ORIGIN: "http://127.0.0.1:${WIKIDECK_PILOT_PORT:-3423}" - volumes: - - custody-node:/app/data - - pg-runtime:/secrets/runtime:ro - networks: [financial, web] - init: true - restart: unless-stopped - read_only: true - tmpfs: ["/tmp:mode=1777,size=32m,noexec,nosuid"] - cap_drop: [ALL] - security_opt: [no-new-privileges:true] - pids_limit: 128 - mem_limit: 1g - stop_grace_period: 30s - daemon-check: - image: wikideck:private-pilot - profiles: ["monero-check"] - build: - context: . - target: runtime - args: - WIKIDECK_NETWORK: pilot - entrypoint: [node, --experimental-strip-types, scripts/monero-daemon-check.ts] - environment: - WIKIDECK_MONERO_DAEMON_CONFIG_FILE: /run/monero/daemon.json - volumes: - - type: bind - source: "${WIKIDECK_MONERO_DAEMON_CONFIG_HOST:-./private/monero-daemon.json}" - target: /run/monero/daemon.json - read_only: true - networks: [web] - read_only: true - cap_drop: [ALL] - security_opt: [no-new-privileges:true] - 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: - pg-owner: - pg-runtime: - pg-backup: - pg-tls: - custody-node: diff --git a/compose.custody.yaml b/compose.custody.yaml deleted file mode 100755 index 874b83d..0000000 --- a/compose.custody.yaml +++ /dev/null @@ -1,110 +0,0 @@ -# Persistent LOCAL testnet sandbox. Separate stack/data from existing single/three-node compose files. -# Invoke this file explicitly. Never combine it with the disposable PostgreSQL test fixture. -services: - provision: - build: - context: . - dockerfile: deploy/postgres/Dockerfile - volumes: - - pg-admin:/secrets/admin - - pg-owner:/secrets/owner - - pg-runtime:/secrets/runtime - - pg-backup:/secrets/backup - - pg-tls:/secrets/tls - network_mode: none - read_only: true - tmpfs: ["/tmp:mode=1777,size=16m"] - restart: "no" - database: - image: wikideck:postgres-sandbox - build: - context: . - dockerfile: deploy/postgres/database.Dockerfile - depends_on: - provision: - condition: service_completed_successfully - environment: - POSTGRES_DB: wikideck_custody - POSTGRES_USER: postgres - POSTGRES_PASSWORD_FILE: /secrets/admin/password - POSTGRES_INITDB_ARGS: --auth-host=scram-sha-256 - command: [postgres, -c, ssl=on, -c, ssl_min_protocol_version=TLSv1.2, - -c, ssl_cert_file=/tls/server.crt, -c, ssl_key_file=/tls/server.key, - -c, hba_file=/etc/wikideck/pg_hba.conf, -c, log_statement=none, - -c, log_min_error_statement=panic, -c, log_parameter_max_length_on_error=0] - volumes: - - pg-data:/var/lib/postgresql/data - - pg-admin:/secrets/admin:ro - - pg-tls:/tls:ro - networks: [financial] - healthcheck: - test: [CMD-SHELL, "pg_isready -U postgres -d wikideck_custody"] - interval: 3s - timeout: 3s - retries: 30 - restart: unless-stopped - shm_size: 128m - stop_grace_period: 30s - migrate: - image: wikideck:custody-sandbox - build: {context: ., target: runtime} - depends_on: - database: - condition: service_healthy - environment: - WIKIDECK_NETWORK: testnet - WIKIDECK_LOCAL_PG_BOOTSTRAP: sandbox-only - command: [node, --experimental-strip-types, scripts/custody-deployment-init.ts] - volumes: - - pg-admin:/secrets/admin:ro - - pg-owner:/secrets/owner:ro - - pg-runtime:/secrets/runtime:ro - - pg-backup:/secrets/backup:ro - networks: [financial] - read_only: true - cap_drop: [ALL] - security_opt: [no-new-privileges:true] - restart: "no" - wikideck: - image: wikideck:custody-sandbox - build: {context: ., target: runtime} - depends_on: - migrate: - condition: service_completed_successfully - ports: ["127.0.0.1:${WIKIDECK_CUSTODY_PORT:-3422}:3001"] - environment: - WIKIDECK_NETWORK: testnet - WIKIDECK_PAYMENT_MODE: mock - WIKIDECK_MARKET_ENABLED: "${WIKIDECK_MARKET_ENABLED:-1}" - WIKIDECK_MARKET_TRADING_ENABLED: "${WIKIDECK_MARKET_TRADING_ENABLED:-1}" - WIKIDECK_MARKET_HOST_FEE_ADDRESS: "${WIKIDECK_MARKET_HOST_FEE_ADDRESS:-44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv}" - WIKIDECK_CUSTODY_MODE: sandbox - WIKIDECK_MARKET_RUNTIME_ENABLED: "${WIKIDECK_MARKET_RUNTIME_ENABLED:-1}" - WIKIDECK_CUSTODY_STORAGE: postgres - WIKIDECK_CUSTODY_PG_URL_FILE: /secrets/runtime/url - WIKIDECK_CUSTODY_PG_CA_FILE: /secrets/runtime/ca.crt - PUBLIC_ORIGIN: "http://127.0.0.1:${WIKIDECK_CUSTODY_PORT:-3422}" - volumes: - - custody-node:/app/data - - pg-runtime:/secrets/runtime:ro - networks: [financial, web] - init: true - restart: unless-stopped - read_only: true - tmpfs: ["/tmp:mode=1777,size=32m,noexec,nosuid"] - cap_drop: [ALL] - security_opt: [no-new-privileges:true] - pids_limit: 128 - mem_limit: 1g - stop_grace_period: 30s -networks: - financial: {internal: true} - web: {} -volumes: - pg-data: - pg-admin: - pg-owner: - pg-runtime: - pg-backup: - pg-tls: - custody-node: diff --git a/compose.mainnet.yaml b/compose.mainnet.yaml index f31b66a..f916c90 100755 --- a/compose.mainnet.yaml +++ b/compose.mainnet.yaml @@ -1,5 +1,5 @@ # Gated candidate profile, NOT an announcement of a production-ready or launched network. -# Keep payments mocked. Review docs/mainnet-launch.md before attempting activation. +# Review docs/mainnet-launch.md before attempting activation. services: mainnet: profiles: [mainnet] @@ -12,11 +12,6 @@ services: - "127.0.0.1:${WIKIDECK_MAINNET_PORT:-3201}:3001" environment: WIKIDECK_NETWORK: mainnet - WIKIDECK_MARKET_RUNTIME_ENABLED: "${WIKIDECK_MARKET_RUNTIME_ENABLED:-1}" - WIKIDECK_PAYMENT_MODE: mock - WIKIDECK_MARKET_ENABLED: "${WIKIDECK_MARKET_ENABLED:-1}" - WIKIDECK_MARKET_TRADING_ENABLED: "${WIKIDECK_MARKET_TRADING_ENABLED:-1}" - WIKIDECK_MARKET_HOST_FEE_ADDRESS: "${WIKIDECK_MARKET_HOST_FEE_ADDRESS:-44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv}" PUBLIC_ORIGIN: "${WIKIDECK_MAINNET_ORIGIN:-}" WIKIDECK_PEERS: "${WIKIDECK_MAINNET_PEERS:-}" WIKIDECK_MAINNET_LAUNCH: "${WIKIDECK_MAINNET_LAUNCH:-}" @@ -25,8 +20,6 @@ services: # Empty by default; set only the actual edge-to-container source IP/CIDR after topology review. WIKIDECK_TRUSTED_PROXIES: "${WIKIDECK_TRUSTED_PROXIES:-}" # Diagnostics are deliberately disabled here. Never publish an operations port. - WIKIDECK_MARKET_FEE_BPS: "${WIKIDECK_MARKET_FEE_BPS:-250}" - WIKIDECK_MARKET_CONFIRMATIONS: "${WIKIDECK_MARKET_CONFIRMATIONS:-10}" volumes: - mainnet-candidate-data:/app/data init: true diff --git a/compose.postgres-test.yaml b/compose.postgres-test.yaml deleted file mode 100755 index 7b287c8..0000000 --- a/compose.postgres-test.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# Disposable isolated integration fixture ONLY. No persistent/host database ports or credentials. -# Trust auth is confined to this temporary internal network and is never a deployment example. -services: - database: - image: postgres:17-alpine - environment: - POSTGRES_DB: custody_test - POSTGRES_HOST_AUTH_METHOD: trust - tmpfs: - - /var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d custody_test"] - interval: 1s - timeout: 3s - retries: 30 - networks: [fixture] - tests: - image: wikideck-custody-test - network_mode: service:database - depends_on: - database: - condition: service_healthy - environment: - WIKIDECK_PG_DISPOSABLE_TEST: "1" - WIKIDECK_CUSTODY_CANONICAL_REHEARSAL: "1" - NODE_ENV: test - command: ["npx", "tsx", "--test", "--test-concurrency=1", "tests/postgres/*.test.ts"] -networks: - fixture: - internal: true diff --git a/compose.testnet.yaml b/compose.testnet.yaml index 2ec60a7..8e91551 100644 --- a/compose.testnet.yaml +++ b/compose.testnet.yaml @@ -1,13 +1,6 @@ -# Three independent validators, separate volumes, no miner or market auto-starts. +# Three independent validators, separate volumes, no miner auto-starts. x-node-environment: &node-environment WIKIDECK_NETWORK: testnet - WIKIDECK_PAYMENT_MODE: mock - WIKIDECK_MARKET_RUNTIME_ENABLED: "${WIKIDECK_MARKET_RUNTIME_ENABLED:-0}" - WIKIDECK_MARKET_ENABLED: "${WIKIDECK_MARKET_ENABLED:-1}" - WIKIDECK_MARKET_TRADING_ENABLED: "${WIKIDECK_MARKET_TRADING_ENABLED:-1}" - WIKIDECK_MARKET_HOST_FEE_ADDRESS: "${WIKIDECK_MARKET_HOST_FEE_ADDRESS:-44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv}" - WIKIDECK_MARKET_FEE_BPS: "${WIKIDECK_MARKET_FEE_BPS:-250}" - WIKIDECK_MARKET_CONFIRMATIONS: "${WIKIDECK_MARKET_CONFIRMATIONS:-10}" services: node-a: image: wikideck:testnet diff --git a/compose.yaml b/compose.yaml index 5a493a4..5902d27 100644 --- a/compose.yaml +++ b/compose.yaml @@ -11,13 +11,6 @@ services: PUBLIC_ORIGIN: "${PUBLIC_ORIGIN:-http://127.0.0.1:${WIKIDECK_PORT:-3001}}" WIKIDECK_PEERS: "${WIKIDECK_PEERS:-}" WIKIDECK_ALLOW_PRIVATE_PEERS: "${WIKIDECK_ALLOW_PRIVATE_PEERS:-0}" - WIKIDECK_PAYMENT_MODE: mock - WIKIDECK_MARKET_RUNTIME_ENABLED: "${WIKIDECK_MARKET_RUNTIME_ENABLED:-0}" - WIKIDECK_MARKET_ENABLED: "${WIKIDECK_MARKET_ENABLED:-1}" - WIKIDECK_MARKET_TRADING_ENABLED: "${WIKIDECK_MARKET_TRADING_ENABLED:-1}" - WIKIDECK_MARKET_HOST_FEE_ADDRESS: "${WIKIDECK_MARKET_HOST_FEE_ADDRESS:-44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv}" - WIKIDECK_MARKET_FEE_BPS: "${WIKIDECK_MARKET_FEE_BPS:-250}" - WIKIDECK_MARKET_CONFIRMATIONS: "${WIKIDECK_MARKET_CONFIRMATIONS:-10}" volumes: - wikideck-data:/app/data init: true diff --git a/deploy/postgres/Dockerfile b/deploy/postgres/Dockerfile deleted file mode 100755 index 6040460..0000000 --- a/deploy/postgres/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM node:24-alpine -RUN apk add --no-cache openssl -COPY deploy/postgres/setup.mjs /setup.mjs -CMD ["node", "/setup.mjs"] diff --git a/deploy/postgres/database.Dockerfile b/deploy/postgres/database.Dockerfile deleted file mode 100755 index d2b9b9b..0000000 --- a/deploy/postgres/database.Dockerfile +++ /dev/null @@ -1,2 +0,0 @@ -FROM postgres:17-alpine -COPY deploy/postgres/pg_hba.conf /etc/wikideck/pg_hba.conf diff --git a/deploy/postgres/pg_hba.conf b/deploy/postgres/pg_hba.conf deleted file mode 100755 index 44575d9..0000000 --- a/deploy/postgres/pg_hba.conf +++ /dev/null @@ -1,8 +0,0 @@ -# Unix socket is for the official local bootstrap only; no database host port is published. -local all all trust -hostnossl all all all reject -hostssl wikideck_custody postgres all scram-sha-256 -hostssl wikideck_custody custody_owner all scram-sha-256 -hostssl wikideck_custody custody_runtime all scram-sha-256 -hostssl wikideck_custody custody_backup all scram-sha-256 -host all all all reject diff --git a/deploy/postgres/setup.mjs b/deploy/postgres/setup.mjs deleted file mode 100755 index 78d5da4..0000000 --- a/deploy/postgres/setup.mjs +++ /dev/null @@ -1,61 +0,0 @@ -import { randomBytes } from 'node:crypto'; -import { execFileSync } from 'node:child_process'; -import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, chownSync, openSync, fsyncSync, closeSync, renameSync } from 'node:fs'; - -// Local SANDBOX provisioning only. No secrets pass through logs, command arguments or source control. -const roots = ['/secrets/admin', '/secrets/owner', '/secrets/runtime', '/secrets/backup', '/secrets/tls']; -for (const root of roots) mkdirSync(root, { recursive: true, mode: 0o755 }); -const files = ['/secrets/admin/password', '/secrets/admin/url', '/secrets/owner/url', '/secrets/runtime/url', - '/secrets/tls/server.key', '/secrets/tls/server.crt', '/secrets/owner/ca.crt', '/secrets/runtime/ca.crt', '/secrets/admin/ca.crt']; -const backupFiles = ['/secrets/backup/url', '/secrets/backup/ca.crt']; -const marker = '/secrets/admin/complete'; -const put = (file, text, mode = 0o600) => { - writeFileSync(file, text, { flag: 'wx', mode }); chownSync(file, 1000, 1000); - const fd = openSync(file, 'r'); try { fsyncSync(fd); } finally { closeSync(fd); } -}; -const provisionBackupRole = () => { - if (backupFiles.some(existsSync)) throw new Error('Partial backup-role secret initialization; preserve volumes for inspection'); - const backup = randomBytes(32).toString('hex'); - put('/secrets/backup/url', `postgresql://custody_backup:${backup}@database:5432/wikideck_custody`); - put('/secrets/backup/ca.crt', readFileSync('/secrets/admin/ca.crt'), 0o644); -}; -if (existsSync(marker)) { - const version = readFileSync(marker, 'utf8'); - if (!['wikideck-local-pg-v1', 'wikideck-local-pg-v2'].includes(version) || files.some(f => !existsSync(f))) { - throw new Error('Incomplete secret volumes; restore the matching volumes, never regenerate over an existing database'); - } - execFileSync('openssl', ['x509', '-checkend', '86400', '-noout', '-in', '/secrets/tls/server.crt'], { stdio: 'ignore' }); - if (version === 'wikideck-local-pg-v1') { - provisionBackupRole(); - const tempMarker = `${marker}.upgrade`; - writeFileSync(tempMarker, 'wikideck-local-pg-v2', { flag: 'wx', mode: 0o644 }); - renameSync(tempMarker, marker); - } else if (backupFiles.some(f => !existsSync(f))) { - throw new Error('Backup-role secret volumes are incomplete; preserve existing database and inspect volumes'); - } - console.log('Existing local sandbox credentials/certificate retained; read-only backup role available.'); -} else { - if (files.some(existsSync) || backupFiles.some(existsSync)) throw new Error('Partial secret initialization; inspect preserved volumes before retrying'); - const admin = randomBytes(32).toString('hex'), owner = randomBytes(32).toString('hex'), runtime = randomBytes(32).toString('hex'), backup = randomBytes(32).toString('hex'); - // PostgreSQL's unprivileged entrypoint also reads POSTGRES_PASSWORD_FILE. This separate scoped - // admin volume is never mounted into the application; the password file must be readable there. - put('/secrets/admin/password', admin, 0o644); - put('/secrets/admin/url', `postgresql://postgres:${admin}@database:5432/wikideck_custody`); - put('/secrets/owner/url', `postgresql://custody_owner:${owner}@database:5432/wikideck_custody`); - put('/secrets/runtime/url', `postgresql://custody_runtime:${runtime}@database:5432/wikideck_custody`); - put('/secrets/backup/url', `postgresql://custody_backup:${backup}@database:5432/wikideck_custody`); - execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:3072', '-sha256', '-nodes', '-days', '30', - '-subj', '/CN=Wikideck local sandbox CA', '-keyout', '/tmp/ca.key', '-out', '/tmp/ca.crt', - '-addext', 'basicConstraints=critical,CA:TRUE', '-addext', 'keyUsage=critical,keyCertSign,cRLSign'], { stdio: 'ignore' }); - execFileSync('openssl', ['req', '-new', '-newkey', 'rsa:3072', '-nodes', '-subj', '/CN=database', - '-keyout', '/secrets/tls/server.key', '-out', '/tmp/server.csr'], { stdio: 'ignore' }); - writeFileSync('/tmp/extensions', 'subjectAltName=DNS:database\nbasicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n'); - execFileSync('openssl', ['x509', '-req', '-in', '/tmp/server.csr', '-CA', '/tmp/ca.crt', '-CAkey', '/tmp/ca.key', - '-CAcreateserial', '-days', '30', '-sha256', '-extfile', '/tmp/extensions', '-out', '/secrets/tls/server.crt'], { stdio: 'ignore' }); - for (const root of ['admin', 'owner', 'runtime', 'backup']) put(`/secrets/${root}/ca.crt`, readFileSync('/tmp/ca.crt'), 0o644); - // postgres:17-alpine runs as UID/GID 70. Private key never mounted into Wikideck or migrator. - chownSync('/secrets/tls/server.key', 70, 70); chmodSync('/secrets/tls/server.key', 0o600); - chmodSync('/secrets/tls/server.crt', 0o644); - put(marker, 'wikideck-local-pg-v2', 0o644); - console.log('Provisioned fresh local sandbox credentials and verified-TLS certificate; no secrets logged.'); -} diff --git a/deploy/systemd/README.md b/deploy/systemd/README.md deleted file mode 100755 index a96dfb6..0000000 --- a/deploy/systemd/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Monero observer service (single Linux host) - -This is a supervised **observation-only** process for a dedicated Linux host. It is not a public API, deposit-address service, credit worker, withdrawal signer, or production-launch approval. It requires an independently deployed PostgreSQL instance and a private TLS 1.3 mTLS Wallet-RPC gateway. Do not point it at public Wallet RPC or expose either dependency to the Internet. - -## Deployment prerequisites - -- Linux x86-64 with systemd, Node.js 24, a dedicated `wikideck-observer` user/group, and a reviewed immutable application release installed read-only at `/opt/wikideck/current`. -- A production PostgreSQL database reachable only on a private network, with verified TLS, encryption at rest, restricted administrative access, encrypted backups and a tested restore. Do not reuse the local sandbox database or runtime credential. -- A mainnet Monero full node and watch-only Wallet RPC behind the already-tested mTLS method-allowlisting gateway. Network policy/firewall must permit the observer host to reach only the intended PostgreSQL and gateway addresses/ports. The gateway must not expose Wallet RPC directly. -- Protected wallet observer/mTLS JSON and credential files owned by `wikideck-observer`, mode `0600`. Keep the mTLS private key and PostgreSQL URL out of shell history, process arguments, environment values, source control and logs. Store host disks and backups encrypted; define and test certificate rotation before use. -- The chosen wallet is independently verified as the intended mainnet watch-only wallet; its spending key is not present on this host. A config endpoint match is not proof that the remote service is the intended wallet. - -The example JSON files in this directory are templates only. Copy them to `/etc/wikideck/monero-observer/`, replace the gateway hostname, and install the matching client certificate/key and gateway CA there. The endpoint in both configs must be identical and the certificate chain must be checked for validity dates, key strength and SHA-2 signatures. The application's certificate loader requires owner-only POSIX file permissions. - -## One-time database preparation - -Use the dedicated schema-owner PostgreSQL credential file and CA file in a restricted operator session. The operation below is explicit, transactional, digest-checked and idempotent; it does **not** create the observer login, add a runtime grant, or enable deposits. Do not run it against a database with unrelated or valuable data before reviewing its schema and backup. - -```sh -WIKIDECK_NETWORK=mainnet \ -WIKIDECK_CUSTODY_PG_URL_FILE=/secure/operator/schema-owner-url \ -WIKIDECK_CUSTODY_PG_CA_FILE=/secure/operator/postgres-ca.crt \ -WIKIDECK_MAINNET_SCHEMA_MIGRATION=explicit-schema-migration-only \ -node --experimental-strip-types scripts/custody-db.ts migrate-mainnet-schema -``` - -Have the database administrator create a separate `LOGIN` role with no administrative attributes and no role memberships, and place its TLS PostgreSQL URL in `postgres-url`. Then issue only the observer grants using the schema-owner credential: - -```sh -WIKIDECK_NETWORK=mainnet \ -WIKIDECK_CUSTODY_PG_URL_FILE=/secure/operator/schema-owner-url \ -WIKIDECK_CUSTODY_PG_CA_FILE=/secure/operator/postgres-ca.crt \ -node --experimental-strip-types scripts/custody-db.ts grant-monero-observer-runtime wikideck_observer -``` - -The role name in the command must exactly match the separately provisioned login. The worker role cannot read journals or balances, allocate addresses, post credits/releases, or submit transfers. Restrict the schema-owner credential to this one-time operation and remove it from the host afterward. `custody-db` continues to refuse all other mainnet financial actions. - -## Install and operate - -Install the service unit from this directory at `/etc/systemd/system/wikideck-monero-observer.service`. Create `/etc/wikideck/monero-observer/` with owner `wikideck-observer` and mode `0700`. Install the two runtime JSON files and all referenced certificates/keys plus `postgres-url` and `postgres-ca.crt`, each owned by that user and mode `0600`. The PostgreSQL URL must use the observer role and must not contain TLS downgrade parameters. Ensure the `current` application release is root-owned and not writable by the service account. - -After reviewing the unit against the host's systemd version, reload systemd and start the unit. Confirm the process is running and that logs contain only bounded scan aggregates; logs intentionally omit wallet addresses, transaction IDs, amounts and credentials. Configure an alert outside this host for service exits, sustained scan lag, database/gateway unavailability, WAL/disk pressure and unexpected worker restarts. The worker itself does not deliver alerts. - -The service uses a PostgreSQL session advisory lock so a second instance cannot scan concurrently. SIGTERM/SIGINT stops its polling loop and releases the lock. A failed RPC/database batch is retried; a failed scan does not advance the durable cursor. Logs are available through the system journal under identifier `wikideck-monero-observer`. - -## Release boundary - -This deployment only persists read-only chain observations and scan progress. It does **not** issue customer deposit addresses, expose an address to a user, calculate customer credit, release pending liabilities, enable marketplace checkout, or move XMR. There is no public endpoint or UI connected to these rows. A scan is not proof of solvency or authorization to accept deposits. - -The repository still lacks a production Monero daemon/Wallet-RPC/gateway deployment, certificate and secret lifecycle automation, nonempty owned-output validation against the deployed topology, external monitoring/restore evidence, a reviewed customer deposit/withdrawal policy, and live settlement integration. The mainnet candidate is independently marked NO-GO and its current candidate genesis time is 2027-01-01; deploying this worker does not clear any launch gate. The service file is a starting deployment contract, not a certification or instruction to accept funds. diff --git a/deploy/systemd/mtls.example.json b/deploy/systemd/mtls.example.json deleted file mode 100755 index e2d1d1a..0000000 --- a/deploy/systemd/mtls.example.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "url": "https://monero-rpc.internal.example/json_rpc", - "clientCertificateFile": "/etc/wikideck/monero-observer/client.crt", - "clientPrivateKeyFile": "/etc/wikideck/monero-observer/client.key", - "serverCaFile": "/etc/wikideck/monero-observer/gateway-ca.crt" -} diff --git a/deploy/systemd/wallet-rpc.example.json b/deploy/systemd/wallet-rpc.example.json deleted file mode 100755 index 1df32d5..0000000 --- a/deploy/systemd/wallet-rpc.example.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "network": "mainnet", - "url": "https://monero-rpc.internal.example/json_rpc" -} diff --git a/deploy/systemd/wikideck-monero-observer.service b/deploy/systemd/wikideck-monero-observer.service deleted file mode 100755 index 949584e..0000000 --- a/deploy/systemd/wikideck-monero-observer.service +++ /dev/null @@ -1,50 +0,0 @@ -[Unit] -Description=Wikideck read-only Monero mainnet observation worker -Wants=network-online.target -After=network-online.target -StartLimitIntervalSec=300 -StartLimitBurst=5 - -[Service] -Type=simple -User=wikideck-observer -Group=wikideck-observer -WorkingDirectory=/opt/wikideck/current -Environment=WIKIDECK_MONERO_OBSERVER_WORKER=1 -Environment=WIKIDECK_MONERO_OBSERVER=1 -Environment=WIKIDECK_MONERO_WALLET_RPC_CONFIG_FILE=/etc/wikideck/monero-observer/wallet-rpc.json -Environment=WIKIDECK_MONERO_MTLS_CONFIG_FILE=/etc/wikideck/monero-observer/mtls.json -Environment=WIKIDECK_CUSTODY_PG_URL_FILE=/etc/wikideck/monero-observer/postgres-url -Environment=WIKIDECK_CUSTODY_PG_CA_FILE=/etc/wikideck/monero-observer/postgres-ca.crt -Environment=WIKIDECK_MONERO_OBSERVER_POLL_MS=30000 -ExecStart=/usr/bin/node --experimental-strip-types /opt/wikideck/current/scripts/monero-observer-worker.ts -Restart=on-failure -RestartSec=10s -TimeoutStopSec=40s -KillSignal=SIGTERM -UMask=0077 -StandardOutput=journal -StandardError=journal -SyslogIdentifier=wikideck-monero-observer - -NoNewPrivileges=true -CapabilityBoundingSet= -AmbientCapabilities= -PrivateTmp=true -PrivateDevices=true -ProtectSystem=strict -ProtectHome=true -ProtectKernelTunables=true -ProtectKernelModules=true -ProtectKernelLogs=true -ProtectControlGroups=true -ProtectClock=true -ProtectHostname=true -RestrictSUIDSGID=true -RestrictRealtime=true -RestrictNamespaces=true -LockPersonality=true -RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX - -[Install] -WantedBy=multi-user.target diff --git a/docs/canonical-indexes.md b/docs/canonical-indexes.md index abd1051..c8cb727 100755 --- a/docs/canonical-indexes.md +++ b/docs/canonical-indexes.md @@ -1,58 +1,17 @@ -# M2 increment — canonical indexes and recoverable market observation +# Canonical ledger indexes -Status: implemented local infrastructure, **not full M2 acceptance or production authorization**. Evidence date: 2026-10-21. Related: [production plan](production-plan.md), [custodial design](custodial-market-design.md). +The ledger maintains SQLite indexes for canonical NFT ownership/sequence, mint ordering, block identity, and transaction inclusion. The UI uses these indexes for wallet decks, NFT metadata, and current-chain pagination; they do not change consensus rules or replace proof validation. -## Delivered +## Persistence and reorganization behavior -- [Canonical index](../server/testnet/canonical-index.ts) stores current NFT ownership/sequence, canonical mint/block identity and transaction inclusion in SQLite. Owner/mint ordering and transaction indexes replace whole-chain scans for deck, NFT and marketplace queries. -- [Ledger persistence](../server/testnet/ledger.ts) commits accepted blocks, pending transactions, index connect/disconnect undo and canonical events in **one SQLite transaction**. A losing side branch or mempool submission does not create a canonical event. Disconnects run tip-first; connects run ancestor-first. No await separates the database commit from updating the in-memory tip. -- Events contain sequence, connect/disconnect type, block identity/height/parent, transaction IDs and a hash chained to the previous event. An independently generated local stream ID binds the history; it is **not consensus data or a public proof of honest observation**. Pages contain at most 64 events and no article payloads. Financial/chain history is not silently pruned. -- On first open, original blocks are replayed and independently proof/state validated before additive local index backfill. Later opens compare indexed block identity, ownership, transaction inclusion and event continuity/anchors to that validated chain. Inconsistent state fails closed; existing consensus version, genesis, signatures and free-transfer/trade rules are unchanged. -- [Market reconciliation](../server/market/store.ts) uses indexed current delivery/ownership queries and a persisted `(stream ID, sequence, anchor)` checkpoint. Every observed disconnect of a previously completed order causes a durable review hold, even if delivery reappeared before polling. Review is never automatically repaid. Hold updates and checkpoint advancement commit together in the market database. -- Order and listing review now uses stable-ID keyset batches capped at 32 rows each. A strict, durable job record stores stage, continuation ID, observed time, chain tip/revision, and the bounded canonical event page. Each batch commits row effects and its continuation atomically; the canonical event cursor advances only after both tables finish. A chain tip/revision change during a multi-batch scan rewinds the job and captures a fresh event page, preventing a mixed-view scan from being marked caught up. Completion checks the current event head so events appended during the scan remain behind the write fence. -- While any job/event backlog exists, ordinary market mutations and time-dependent payout/expiry decisions stop; diagnostic/read access remains available. Requests advance one bounded slice, and an unref'd HTTP-lifecycle pump drains remaining work without depending on page loads. The production HTTP process enables a single persistent Node worker thread to select rows, read canonical transaction/NFT/mempool facts from its own SQLite read-only Ledger connection under a consistent read transaction, evaluate order/listing transitions, and atomically persist market row changes plus job progress. The main thread retains event-page acquisition and post-batch tip/revision/event-head/mempool fencing; if observations changed, it rewinds before cursor advancement. This moves fact collection and per-row JSON/status work off-loop, but final fences still run on the request event loop. Worker failure leaves the durable job/cursor intact, fails closed, and requires process restart to retry; there is no horizontal-worker safety claim. -- Reconciliation restart, mid-job event append/reorg fencing, disconnect review, and injected SQLite batch/cursor failures are tested. A failed batch leaves both order effects and durable keyset progress unchanged; restart resumes committed work. Restore mismatch (different stream, different history anchor or a cursor ahead of the restored ledger) is refused rather than resetting the cursor. Restore the matching stopped-node ledger/market backup; any alternative recovery requires reviewed evidence, not deleting a checkpoint. -- [HTTP NFT lookup and custody card checks](../server/testnet/http.ts) use the same index. Wallet deck pagination accepts an optional `tip` query; stale tips return 409. Existing callers without `tip` remain compatible. There is no new public event/admin endpoint, no payment observer and no NFT-lock signer. +Accepted blocks, pending transactions, index connect/disconnect updates, undo records, and canonical event history commit in one SQLite transaction. Losing side branches and mempool submissions do not create canonical ownership events. On a reorganization, disconnects apply tip-first and connects ancestor-first. A failed write leaves both the stored ledger and in-memory tip unchanged. -## Tests and measurements +On initial open, stored blocks are replayed and validated before the local index is built or checked. Later opens verify indexed block identity, ownership, transaction inclusion, and event continuity against that validated chain. Inconsistent state fails closed; no automatic reset or history pruning occurs. These local indexes and event records are not consensus data or a public proof that a node observed an honest network. -The final Docker test-target build passed TypeScript/Vite compilation. All **183 offline tests passed, zero failures/skips**, including native RandomX multi-process HTTP/peer/restart, backup rehearsal and five offline Monero observer contract/security tests. The focused market suite passed **27 tests**. The disposable PostgreSQL suite most recently passed **33/33**, including v1→v2 schema migration and observation persistence. +Wallet-deck results are paged and can be pinned to a canonical tip; stale tips return HTTP 409. Canonical block pages are bounded. Direct trades continue to be validated as complete atomic transactions against the canonical ownership state. No market journal, reconciliation worker, payment observer, or NFT sale-lock signer is part of this design. -- indexed ownership/sequence versus full replay for transfers, multi-card trades and forks; -- append/disconnect/reconnect ordering, side-branch/mempool exclusion, bounded defensive-copy pages and durable restart; -- injected event-write failure rolling back blocks, indexes and mempool together; -- additive migration of a prior-schema copy, refusing corrupt ownership and event records; -- production market adapter exercised with full snapshots forbidden; -- multi-page catch-up and persisted restart checkpoint; -- 32-row order/listing keyset progress, restart continuation, and a new canonical event arriving during an in-flight job; -- chain revision change fencing that resets continuation and refreshes the event page before the scan continues; -- injected batch-write failure rolling back order effects and keyset progress, plus cursor-write failure rolling back checkpoint advancement while preserving committed review holds; -- worker-owned row evaluation/batch/finalize transaction with injected failure, mutation fence and restart continuation; -- canonical event arrival during an in-flight worker batch forces a fresh bounded event page before the cursor reaches the new head; -- separate worker read-only Ledger connection supplies canonical NFT and delivery facts and re-fences when an event arrives during that snapshot; -- main-thread timer fires while a large worker reconciliation backlog remains in flight; -- numeric cursor reuse with a different historical hash rejected; -- real HTTP NFT results and stale-tip deck pagination. +## Validation and capacity limits -[Benchmark script](../scripts/benchmark-canonical-index.ts), available as `npm run benchmark:index -- 10000`, creates/removes its own temporary SQL database. Input is bounded to 1–100,000 synthetic blocks; **these are not valid mined blocks** and are never submitted to a ledger/peer. It measures only derived index behavior, not PoW, network throughput or operating capacity. +The regression suite covers indexed ownership and sequence against replay for transfers, atomic multi-card trades and forks; append/disconnect/reconnect ordering; side-branch and mempool exclusion; durable restart; failed writes; and stale-tip pagination. -Observed in the development Docker environment: - -| Metric | Result | -| --- | --- | -| Fixture | 10,000 NFTs/blocks, 9,998 transfers, 16 owner identities | -| SQL fixture build (single transaction) | 5,096 ms | -| 1,000 query groups (NFT + 24-item owner page + optional transaction lookup), median / p95 | 0.619 / 1.167 ms | -| Read all events, maximum 64 per page | 157 pages, 54.5 ms total | -| Verify derived indexes and anchored history | 524 ms | -| Database plus WAL before close | 76,856,784 bytes | - -These numbers are a reproducible **baseline**, not acceptance SLOs: host CPU/storage and production concurrency were not selected, outputs include synthetic article payloads, warm caches apply, one transaction amortizes insertion fsync, and real validation/startup/HTTP/market workloads are excluded. They do not establish a supported production envelope. - -The synthetic market-journal harness [benchmark](../scripts/benchmark-market-reconciliation.ts), available as `npm run benchmark:market -- 10000 [terminal|confirmed-delivery] [worker] [http]`, measures terminal and confirmed-delivery reconciliation in its own temporary SQLite directory. Both modes use the same Ledger-shaped fixture; worker mode reads it through a separate read-only SQLite connection and performs market row selection, canonical transaction/NFT fact reads, status evaluation and market commits on the worker. In the latest development Docker run, synchronous 32-row batches processed 10,000 terminal orders plus 10,000 listings in 4,100 ms (about 4,878 records/s; sampled event-loop-delay p95/max 18.43/21.95 ms), and confirmed delivery in 5,853 ms (about 3,417 records/s; 21.64/55.67 ms). Worker-owned selection/evaluation/fact reads/commits took 4,508 ms for terminal rows (about 4,437 records/s; delay p95/max 10.93/13.40 ms) and 4,088 ms for confirmed delivery (about 4,893 records/s; 10.80/12.48 ms). Thus worker execution reduced sampled delay, improved this delivery fixture, and slightly slowed terminal-only work. A new optional loopback HTTP mode issues 160 health/market requests during catch-up: worker run request p50/p95/max was 285/322/324 ms, versus 1,416/2,844/2,971 ms for synchronous reconciliation. These two runs are preliminary, local, synthetic, and lack repeated confidence intervals or a representative arrival distribution; the latency is not an SLO. SQLite fixture data is not validated Ledger evidence. All measurements exclude real concurrent users, PostgreSQL and adversarial workloads. - -## Remaining gates - -M2 still needs target-hardware/data/SLO decisions, repeated concurrent HTTP tests with realistic request mix/rates, multi-instance/restart/in-flight-worker fencing evidence, production financial database/schema and measured recovery/retention/storage growth. The worker removes row selection, canonical fact reads and per-row evaluation from the request event loop, but event-page acquisition and post-batch consistency fencing remain on-loop; horizontally shared job ownership has not been implemented. Ledger PoW replay and DAG state memory still scale with retained history; indexed queries do not solve those costs. Startup validation checks undo-record counts but not every possible future undo mutation against a second reconstructed undo model; comprehensive corruption/fuzz coverage and independent review remain required. - -Custodial reservations still stop at `awaiting-lock`. This increment does **not** consume ordinary transfers as custodial settlement evidence: doing that would bypass the reviewed sale-lock/cancel protocol. Real Monero deposit/withdrawal integration, production treasury keys, host attestations and a reviewed versioned NFT lock/completion/cancellation specification remain blocked. No real funds, credentials, external processor or genesis change were used. +The [canonical-index benchmark](../scripts/benchmark-canonical-index.ts), available as `npm run benchmark:index -- 10000`, creates and removes its own temporary SQL fixture. Its synthetic results are a baseline only—not production acceptance limits. Real PoW validation, startup replay, full DAG memory, concurrent HTTP load, and the intended deployment hardware must be measured independently. Indexes improve lookups but do not solve the retained-DAG and full-state costs described in the [consensus reference](testnet-consensus.md) and [production plan](card-only-production-plan.md). diff --git a/docs/card-only-production-plan.md b/docs/card-only-production-plan.md new file mode 100755 index 0000000..2779be1 --- /dev/null +++ b/docs/card-only-production-plan.md @@ -0,0 +1,97 @@ +# Card-only production readiness plan + +**Status:** proposed readiness plan, updated 2026-10-28. This is not launch approval, security certification, or permission to expose a public node. + +## Product and release boundary + +Wikideck's current product scope is **RandomX mining, collecting cards, and direct player-to-player card transfers/trades**. This release will not implement or offer a marketplace, auctions, sales, custody, XMR deposits/withdrawals, or real-money checkout. Direct card trades are signed and applied atomically by the chain; they are not paid orders. + +The nearest reasonable release target is a **publicly hosted, non-monetary testnet beta**. It must be labeled experimental and valueless. It is not a mainnet launch, a promise of ownership permanence, an investment product, or an independently secured network. Mining consumes compute, electricity, and time; no reward rate or profitability is promised. + +Mainnet or valuable-card use is a separate, higher bar. It remains **NO-GO** until consensus, wallet, network, capacity, deployment, and independent-review gates below have evidence and named sign-off. The mainnet candidate's launch date and approval flags do not substitute for that evidence. + +## Current starting point + +- Mining, local encrypted wallets, collections, transfers, and atomic card-for-card trades are implemented for the experimental testnet. +- Marketplace, custody, sale-settlement, and Monero payment source, UI, APIs, deployment profiles, active tests, and operator documentation have been removed from the supported repository. Direct on-chain card transfers/trades remain core functionality. +- Existing runtime volumes and historical backup packages may contain local financial journals or external database bindings. Removing source code does not delete or resolve those records; preserve them separately and do not claim new ledger-only backups include them. +- The current network has no public bootnodes or automatic peer discovery. Peers must be configured by operators; containers on one host do not provide independent network security. +- The inherited initial proof-of-work target accepts one in sixteen hashes, and the ten-block retarget is not production-calibrated. Nodes retain the DAG in memory, rebuild state roots across the NFT set, and revalidate stored proofs at startup. +- Browser-wallet encryption is implemented, but same-origin code can abuse an unlocked wallet; old plaintext copies, cross-browser recovery, and independent wallet review remain concerns. + +See the [mainnet launch limitations](mainnet-launch.md), [consensus reference](testnet-consensus.md), [wallet limitations](wallet-security.md), and [security review](security-review.md). Existing passing tests and local rehearsals are useful evidence, not production certification. + +## Ordered release gates + +### Gate 0 — freeze the card-only scope · implemented + +- Publish the scope above in the product and operator docs. Market/custody/payment source, UI, APIs, dependencies, tests, deployment files, and operator tasks are removed from the supported repository. +- Preserve existing node and financial data during retirement. Do not remove Compose volumes, old journals, PostgreSQL schemas, or historical backups. Review/archive any existing market journal before stopping a market deployment; the card-only node will not reconcile or resolve it. +- Version the node backup contract for ledger-only backups. Refuse to create a ledger-only package if legacy financial data or a PostgreSQL custody binding remains in the selected data directory. Keep old paired backup verification/restore available as a data-preservation path; restored legacy files are inert and are not reopened by the card-only application. +- Update the production plan, operator launch checklist, security scope, support text, and release notes so they no longer imply that market, custody, or payment work is part of this release. +- Choose and document the only supported launch target: testnet beta or a separately reviewed mainnet. Do not mix the two identities or reuse testnet data/backups as mainnet state. + +**Exit evidence:** scope owner approval is still required. Source/deployment removals are complete; API regression checks cover removed routes while preserving card trades; backup v2 and legacy-data refusal are covered; old paired backups remain portable. No active product docs promise sales or payments. + +### Gate 1 — independent protocol and application review + +- Commission independent review of consensus serialization, signatures, state roots, fork choice, timestamp/retarget logic, peer message validation, RandomX integration/native boundary, and wallet signing/recovery. +- Test competing branches, deep reorgs, clock skew, eclipse/partition behavior, malformed traffic, mempool/resource exhaustion, and crash/restart recovery on disposable networks. +- Fix critical/high findings and add regression evidence. Publish the exact reviewed protocol manifest and residual risks; do not set review acknowledgements merely to pass startup gates. + +**Exit evidence:** review against the exact release candidate, issue dispositions, regression tests, and named reviewer sign-off. An internal code review or test count is not independent assurance. + +### Gate 2 — establish a credible network + +- Measure expected and adversarial hashpower on intended hardware; quantify rewrite costs and concentration risk. Choose and review initial difficulty, retarget, timestamp, and stall-recovery behavior before freezing a mainnet identity. +- Design the bootstrap and peer-operations model: independently administered nodes, supported peer discovery or a documented trusted bootstrap list, upgrade coordination, and abuse/isolation response. +- Demonstrate actual connectivity and independent mining across failure domains. Peer URL count alone is not evidence that peers are online, independent, or secure. +- State the finality/reorganization limits honestly. There is no formal finality today; a confirmation count is not an absolute guarantee. + +**Exit evidence:** reviewed launch manifest/genesis for mainnet (or explicit testnet-only beta decision), multi-host partition/reorg/stall rehearsal, measured hashpower assumptions, and an operator-owned peer/upgrade plan. + +### Gate 3 — demonstrate capacity and recovery limits + +- Define the expected chain length, block rate, peer count, user/API load, disk budget, memory ceiling, startup time, and latency/error objectives. +- Run repeated tests on populated long-chain fixtures and the intended hardware, with concurrent mining, peer sync, wallet/deck reads, transfers/trades, reorgs, and hostile traffic. Measure tail latency, memory, CPU, disk/WAL growth, and restart/revalidation time. +- Test clean shutdown, process crash, disk pressure, corrupted/stale state, isolated restore, and replay using the exact release binary. Do not treat `/healthz` alone as readiness. +- Define and test an archival, pruning, or capacity transition before resource growth can make nodes unable to sync or restart. Do not alter consensus history silently to reclaim storage. + +**Exit evidence:** reproducible benchmark report with workload and limits, enforced resource budgets, failure/reorg results, and a successful isolated restore/replay drill. + +### Gate 4 — wallet, trades, and user protection + +- Test create, lock, unlock, backup, restore in a fresh browser/profile, and recovery after app/browser upgrade across the supported browser matrix. Test signed transfer/trade review, stale offers, pending state, and reorg outcomes. +- Review same-origin script compromise, extensions, password entry, old plaintext-wallet migration, downloaded backup handling, and key-loss support. Decide whether production needs hardware/isolated signing; document residual risks if it does not. +- Publish experimental/no-value disclosures, mining resource risks, irreversible-transfer warnings, privacy/data-retention terms, Wikipedia text/image attribution and license handling, support contact, abuse-report path, and stop conditions. +- Select and publish an appropriate project software license before public distribution. A Wikideck license does not replace Wikipedia or dependency licenses. + +**Exit evidence:** independent wallet review, verified recovery drill, tested trade flows, reviewed participant disclosures, public support/abuse contacts, and license decision. + +### Gate 5 — operate and release safely + +- Prefer a managed host unless the intended self-hosted site proves ISP permission, reachable ingress/CGNAT handling, DNS/TLS renewal, trusted-proxy correctness, no direct backend exposure, firewalling, restricted administration, patching, and power/network resilience. +- Deploy independent monitoring and alert delivery for peer/tip freshness, reorgs, native miner failures, latency/queue saturation, disk/WAL growth, and service availability. Assign a real responder and incident/rollback procedure; do not imply HA or 24/7 response without it. +- Encrypt backups off-host and rehearse cold restore. Define and meet recovery point/time objectives; protect wallet recovery separately from node data. Preserve database/WAL consistency and verify restored proofs/ownership before resuming service. +- Freeze the release artifact: reviewed base-image digest, dependency/native versions, SBOM, vulnerability and native-code scans, build provenance/signature, configuration manifest, and rollback image. Re-run the complete suite and smoke test against that exact artifact. +- Run a limited, non-monetary soak and record go/no-go, known risks, release hashes, operator, reviewer, and stop criteria. Do not convert a beta into mainnet by changing an environment variable or changing the chain identity after launch. + +**Exit evidence:** intended-host deployment test, alert drill, off-host restore result with measured RPO/RTO, signed/frozen artifacts, exact-release test report, and recorded operator/reviewer approval for the beta's limited exposure. + +## Release decisions + +### Non-monetary public beta + +May be considered only after all applicable gates pass, the node remains on the explicitly labeled experimental testnet, cards are not represented as valuable, and the operator accepts the single-host or stated availability limits. This is a production-hosted beta, not production chain security. + +### Mainnet or valuable cards + +Requires all gates plus independent consensus/economic-security review, calibrated and frozen consensus parameters, a credible independent peer/miner topology, reviewed wallet protections, sustained capacity and adversarial evidence, and explicit governance/upgrade and incident procedures. Mainnet activation is a separate authorization. Until then, retain **NO-GO**. + +### Markets and real money + +Out of scope for this product plan. No market/custody/payment milestone is a prerequisite to the card-only beta. Any later decision to add sales or funds requires a new product scope, threat model, legal review, settlement/custody design, and separately approved release plan; it must not be inferred from this document. + +## Evidence record + +For every gate, record an owner, exact release/manifest, test or rehearsal, measured result, artifact location/hash, reviewer, residual risk, and disposition. Missing evidence is **unverified**, not passed. A successful build, local container startup, peer count, test total, or environment flag is never launch approval by itself. diff --git a/docs/consensus-calibration.md b/docs/consensus-calibration.md index 805dd76..039f294 100755 --- a/docs/consensus-calibration.md +++ b/docs/consensus-calibration.md @@ -1,6 +1,6 @@ # Consensus calibration and adversarial analysis — stage 3 -**Decision remains NO-GO.** This stage supplies reproducible offline tools and bounded measurements, not new launch parameters, an independent audit or an operational network. Consensus, initial target, genesis, chain identity and approval/payment gates are unchanged. +**Decision remains NO-GO.** This stage supplies reproducible offline tools and bounded measurements, not new launch parameters, an independent audit or an operational network. Consensus, initial target, genesis, and chain identity are unchanged. ## Tools and safety boundaries @@ -17,7 +17,7 @@ Wikideck interprets the 256-bit RandomX output as a big-endian integer and accep The current `0fff…ffff` maximum/initial target requires **16 expected hashes**. The 15-minute setting controls the retarget goal; it does not force miners to wait 15 minutes before submitting an initially easy block. For a specified integer network rate H and goal S seconds, the analysis computes `floor(2^256 / (H*S)) - 1`, bounded by the existing allowed target range. This formula assumes stable aggregate honest work and does not choose a safe launch rate, security budget, or rounding-independent exact interval. -The report also includes `(q/(1-q))^z` for q < 0.5 (1 for q >= 0.5), under an idealized fixed-difficulty Poisson race with attacker share q and a **known current deficit** of z blocks. This is the probability of ever catching up under those assumptions, **not** double-spend probability after z observed confirmations: attacker progress during confirmation waiting is not modeled. There is no network latency, eclipse, selfish mining, timestamp manipulation, adaptive difficulty, economic cost or attack detection model. Do not derive a payment finality policy from this table. +The report also includes `(q/(1-q))^z` for q < 0.5 (1 for q >= 0.5), under an idealized fixed-difficulty Poisson race with attacker share q and a **known current deficit** of z blocks. This is the probability of ever catching up under those assumptions, **not** a guarantee that a block is final: attacker progress while a block accumulates confirmations is not modeled. There is no network latency, eclipse, selfish mining, timestamp manipulation, adaptive difficulty, economic cost or attack detection model. ## Local native measurement (2026-10-20) @@ -57,6 +57,6 @@ Before production calibration can be approved: - Establish credible aggregate honest hashrate, launch ramp/surge/drop ranges and attack/rewrite cost assumptions; independently review target choice and the inherited ten-block/clamped endpoint retarget, including gaps across windows and timestamp attacks. - Add stochastic mining/partition/clock/adaptive-hashpower models and sustained multi-host rehearsals with valid reference proofs, full state commitments, mempool/reorg persistence, networking and realistic resource pressure. - Freeze reviewed rules and actual genesis date with a new identity where required. Publish the manifest/artifact hashes and obtain independent review and explicit operator sign-off. Do not patch an active chain's rules in place. -- Decide payment holds/reserves/disputes independently; ten confirmations cannot be sold as absolute finality on an unbootstrapped PoW chain. Real payments remain blocked. +- Document reorganization and confirmation limitations honestly; no finite confirmation count is absolute finality on this unbootstrapped PoW chain. See [security review](security-review.md), [launch evidence register](operator-launch.md) and the read-only preflight. The calibration blocker remains open. diff --git a/docs/custodial-market-design.md b/docs/custodial-market-design.md deleted file mode 100755 index 5973cbd..0000000 --- a/docs/custodial-market-design.md +++ /dev/null @@ -1,158 +0,0 @@ -# ADR 002 — Custodial XMR balances and marketplace settlement - -Status: **implementation authorized; sandbox accounting foundation implemented, live custody disabled**. Updated 2026-10-20. Supersedes the preferred research direction in [ADR 001](settlement-feasibility.md), not its evidence or security warnings. Related: [production plan](production-plan.md). - -## 1. Recommendation and scope - -Use one accountable custodian per market, a pooled Monero treasury with deposit subaddresses, and an internal double-entry XMR ledger. Purchases move liabilities between ledger accounts; only deposits, withdrawals and treasury movements touch Monero. Start with fixed-price sales, one authoritative financial writer and no cross-host credit, user-to-user balance transfers, lending or live auctions. - -The operator has authorized proceeding with implementation instead of the previously preferred arbitrated multisig route. The first increment below is an isolated sandbox; no wallets, payable addresses, live deposits, keys or funds were accessed. Production treasury infrastructure and new NFT consensus rules are not activated. Current mock v3 terms and historical v2 orders remain unchanged. - -### Implemented first increment - -- [Custody command domain](../shared/custody.ts): strict versioned sandbox consent, exact atomic amounts, custodian/network/actor/expiry/nonce binding and fixed author policy. Existing market signatures cannot debit custody balances. -- [Custody engine](../server/market/custody.ts): isolated testnet SQLite/WAL/FULL-sync financial model, transactional double-entry postings, immutable-entry triggers, startup replay audits, durable receipts, one-time fictitious credits, exclusive fixed-price reservations, blocked outbox and cancellation of unstarted intents. Resource budgets stop new work without blocking release of existing holds. PostgreSQL remains the planned production store; this SQLite implementation is not approved for real custody. -- [Custody UI](../src/CustodyPanel.tsx): marketplace “Custody preview” tab with reviewed signatures, private/paged balances and activity, test funding, listing/reservation and withdrawal-hold cancellation. A lost response retains the original signed envelope for replay rather than creating a new debit nonce. -- HTTP endpoints `/api/custody` and `/api/custody/command` are off by default; explicit `WIKIDECK_CUSTODY_MODE=sandbox` enables testnet only. All other enabled modes and mainnet sandbox are rejected before node resources open. Requests use the node's origin/admission/body limits, per-route mutation throttling and sanitized errors. No observer/admin settlement or balance-credit endpoint exists. -- [Stopped-node recovery](../server/recovery.ts) includes the optional custody sandbox database and its identity while continuing to accept existing two-database backups. Financial receipts are not pruned. Restore/startup checks do not substitute for production reconciliation. -- Offline SQLite fixture methods exercise fee allocation and prepared-withdrawal identity/retry/cancellation boundaries. They never access Monero and are not callable through HTTP. Public reservations stop at `awaiting-lock`; there is **no automatic NFT delivery or settled sale** in this increment. Monero address allocation and read-only observation groundwork now exist, but no deposit scan worker, customer credit, live withdrawal, signer, author payout or reorg compensation is wired or enabled. -- **Financial storage update:** [ADR 003](postgres-financial-storage.md) selects PostgreSQL and records the integrated asynchronous custody backend, restricted runtime/schema roles, deferred accounting constraints, explicit SQLite freeze/import, and full dump/restore rehearsal. SQLite remains an opt-in-compatible local sandbox default, not an automatic fallback. Real custody and production operating acceptance remain blocked. - -**Monero observation and deposit-address allocation groundwork:** [server/market/monero-observer.ts](../server/market/monero-observer.ts) keeps the observer strictly read-only, with bounded requests, strict HTTPS/literal-loopback-only HTTP, no redirects, bounded responses/deadlines, JSON-RPC ID validation and lossless integer handling. A separate, narrow `createMoneroDepositAddressRpc()` capability verifies a mainnet primary address with `get_address` plus `validate_address`, then derives a domain-separated fingerprint. It can inspect one indexed subaddress or request exactly one labeled subaddress; it exposes no transfer, relay, sweep or withdrawal operation. The allocator [server/market/monero-deposit-allocation-store.ts](../server/market/monero-deposit-allocation-store.ts) uses PostgreSQL v3 beneficiary/index reservations: it reserves first, serializes cooperating allocators per wallet/account, checks the live wallet fingerprint and exact reserved index, and recovers a lost `create_address` response by looking up the deterministic label before retrying. Actual v0.18.5.4 testing confirmed `get_wallet_info` is unavailable and an unused address index returns RPC error `-15` (“address index is out of bound”); the client recognizes only that precise unused-index response. Watch-only provisioning remains a deployment requirement. PostgreSQL v4 now records a per-output public key and global index while preserving historical observations without a creditable output identity. The observer joins transaction-level `get_transfers` metadata to bounded `incoming_transfers` output records and identifies each output as `txid:pubkey`; the gateway allows only bounded read parameters. The source-contract parser and fixtures were updated, and the actual v0.18.5.4 Stagenet rehearsal confirmed the gateway permits `incoming_transfers`; for an empty address the wallet emits `{}` without default empty fields, which the parser handles as an empty array. The offline, unfunded rehearsal did not exercise non-empty owned output parsing, so that remains fixture-tested. The app does not start the gateway, observer, or allocator; no output assessment/credit journal, customer deposit API, withdrawal worker or sale-settlement path is wired. No financial journal can be posted by the Monero observer or allocator. This is integration groundwork, not a live payment path. - -**Reviewed pending-deposit stage:** an isolated coordinator can stage an operator-reviewed observation into a distinct non-spendable pending liability, then perform a second signed review to release it into `available`. Both steps use the immutable output identity and beneficiary allocation. Reorg observations create output-scoped holds; new purchases and withdrawals for that beneficiary stop until the output is re-observed as present, unlocked, sufficiently confirmed, and an allow-listed reviewer signs a resolution. Hold resolutions and release journals are append-only; no automatic journal reversal occurs. These functions are not exposed through HTTP or wired to startup, and the required reviewer keys are not provisioned. This is sandbox accounting machinery, not proof of treasury backing or permission to accept live deposits. Automatic scanning/hold operations, production deposit observation, withdrawals, and sale settlement remain unimplemented. - -## 2. Identity and trust boundaries - -- A balance belongs to `(custodianId, Wikideck network, chainId, Wikideck public-key identity, Monero network)`. Identical user keys at different hosts do not create shared balances. The UI must name the custodian and networks before any deposit. -- Allocate a deposit subaddress lazily after authenticated proof of Wikideck key possession and custody-term acceptance. Store wallet generation/ID, account index, subaddress index, address and immutable beneficiary mapping **before** displaying it. Never reassign an old address; rotated addresses remain monitored. Bound allocations to prevent lookahead/storage abuse. -- Subaddresses identify incoming outputs; wallet RPC can report output balances per subaddress, but these are **not the marketplace entitlement ledger**. After internal sales or pooled withdrawals those figures no longer represent what a customer is owed. There is no on-chain subaddress-to-subaddress purchase transfer. -- Keep Wikideck wallet identity keys client-held. Monero spending keys are operator-held, separate from the market attestation key, admin credentials and application deployment credentials. A deposit proof does not authenticate its sender; deposits credit the preassigned beneficiary, not an arbitrary claimant supplying a txid. -- Lost Wikideck keys are not recovered by publishing a Monero proof. Recommend an optional separately enrolled recovery credential and delayed, audited dual-control recovery; absent an approved recovery policy, no unilateral support reassignment. Withdrawal authorization requires fresh key proof and an independently enrolled second factor. Browser key storage/session security must be reviewed before custody. - -## 3. Components and storage - -| Component | Responsibility / access | -| --- | --- | -| Market API | Validates signed, versioned intents; presents balances, deposit details and receipts. No treasury spending key or generic wallet RPC proxy. | -| Ledger and order coordinator | Sole authoritative transactional writer for reservations, entries, order transitions, inbox/outbox and receipt/idempotency records. | -| Deposit observer | Private wallet scanner and owned Monero daemon; observes output identity, canonical inclusion and unlock state. Prefer view-only operation; spent-state reconciliation requires current signed key images or another reviewed spend-aware source. | -| Wikideck observer / relayer | Tracks canonical lock, completion, cancellation and reorg events using durable cursors; retries exact stored messages. Cannot edit financial balances. | -| Treasury worker / isolated signer | Serializes spend construction, validates approved withdrawal intents, destination, amount, fee cap and policy; persists prepared transactions before relay. Independent limits and approvals, not blind signing for API requests. | -| Reconciler and operations | Independently compares ledger, wallet and chain observations; alerts/halts affected operations; administers reviewed compensations, not arbitrary balance edits. | - -PostgreSQL is now selected and integrated for the custody sandbox under [ADR 003](postgres-financial-storage.md): exact `NUMERIC` with explicit integer checks (avoiding silent scale rounding), transactional row/advisory locking, unique constraints and separately reviewed backup/PITR operations. SQLite remains suitable for disposable compatibility models; no automatic promotion/fallback occurs. Financial order transitions and journal entries share one PostgreSQL transaction; external chain/wallet effects require a durable outbox, never a cross-database pretend transaction. One financial writer at a time initially; readers use coherent snapshots. Future treasury failover must fence the old worker before spending resumes. - -Minimal entities: custodians/network bindings, authenticated customer identities/recovery enrollment, deposit address mappings, observed outputs and inclusion history, ledger accounts/journals/entries, immutable sale terms, purchase reservations, chain events/cursors, withdrawal intents/attempts/prepared transactions, outbox/inbox, signed receipts and audited cases/approvals. Mutable projections are rebuildable; history and external-effect identity are not discarded. - -Use canonical atomic-unit strings in APIs and signatures and `bigint` internally; never parse RPC monetary JSON through JavaScript `Number`. Pin and test an exact-integer RPC codec. Individual amounts and aggregate balances need explicit bounds; aggregate liabilities must not inherit a per-order bound accidentally. - -## 4. Accounting model and invariants - -Customer balances are liabilities, partitioned into `available`, `purchase-reserved`, `sale-pending` and `withdrawal-reserved`. Show uncredited deposit observations separately. Keep author commission liabilities, operating revenue, treasury assets, withdrawal-in-flight clearing, network expense and operator capital distinct. Pending credits are not spendable. - -Example postings (debits/credits follow normal asset/liability conventions): - -| Event | Debit | Credit | -| --- | --- | --- | -| Eligible deposit | Treasury XMR asset | Customer available liability | -| Purchase reservation | Buyer available liability | Buyer order-reserved liability | -| Mature canonical sale | Buyer order-reserved liability | Seller available liability + author payable liability + earned host commission | -| Safe cancellation | Buyer order-reserved liability | Buyer available liability | -| Withdrawal reserve | Customer available liability | Customer withdrawal-reserved liability | -| Confirmed withdrawal | Customer withdrawal-reserved liability | Treasury XMR asset (recipient amount + approved actual network fee) | - -For the withdrawal row, reserve recipient amount plus a signed maximum fee first, release unused fee allowance in a balanced posting, and retain an in-flight asset/liability reconciliation bridge between relay and confirmation. A treasury movement between operator wallets is an asset reclassification, not a customer deposit. The detailed chart of accounts, fee clearing and accounting recognition timing require review before implementation. - -Mandatory invariants: - -- Every posted journal balances exactly in atomic XMR; journals are immutable. Corrections are linked compensating entries with reason and approval. -- No spendable customer account goes negative; reservation checks, posting and order state change commit together. Concurrent purchases/withdrawals cannot spend the same available balance. -- Every order reserves at most one gross and settles or cancels once. A nonce reused with different content is rejected; retries return the original receipt. -- Count each economic deposit once using wallet/network plus transaction/output identity (not txid alone or a reorg-sensitive global index). Pin the supported RPC representation and output-key/index mapping in fixtures; aggregated reports must not duplicate individual outputs. Spending the pooled output later does not erase its customer's credited liability. -- Maintain at least 100% backing of recognized customer/author liabilities, plus separately funded operating/fee/loss capital. Reconcile assets and liabilities on the same snapshot with in-flight withdrawals, locked change and internal treasury transfers accounted exactly once. Locked assets can provide backing but not immediate withdrawal liquidity. -- Never borrow customer funds to fund expenses, reserves or promised compensation. Revenue withdrawal is blocked if backing/liquidity limits fail. Reserve proofs alone do not prove liabilities or solvency. - -## 5. Deposit lifecycle - -`observed → confirming/locked → available`, or `orphaned / quarantined`. - -Scan authoritative wallet-owned outputs, not buyer screenshots, tx proofs or callbacks. Require healthy daemon/wallet synchronization, accepted canonical inclusion, a versioned confirmation threshold and actual wallet-reported unlock eligibility. No mempool credit; no wall-clock-only maturity. Persist inclusion block hashes and rescan overlapping ranges after restarts/reorgs; maintain wallet allocation lookahead from backed-up mappings. Unknown deposits go to a suspense queue, not operating revenue. - -Multiple/partial deposits simply accumulate when individually eligible; deposits are not invoices and do not reserve a listing. Credit the actual valid amount, with no conversion or hidden deposit fee in the proposed baseline. Tiny output handling must be resource-bounded without silently confiscating legitimate funds. Disabling new deposit displays cannot stop payments to previously issued addresses: continue monitoring and handling those liabilities. - -If a previously credited deposit is orphaned, append a reversal/restricted-balance case and freeze affected spending. Do not silently debit unrelated customers or pretend a completed withdrawal can be undone. Record any already-consumed shortfall as an operator loss/receivable under approved policy, with incident exposure capped by funded capital. Deep reorg risk is residual, not eliminated by confirmations. - -## 6. Fixed-price sale lifecycle and NFT coordination - -Proposed order states: `funds-reserved → lock-pending → locked → completion-submitted → delivery-confirming → settled`; alternatives: `cancel-pending → cancelled`, or `review` at any uncertain boundary. Seller proceeds are not spendable before `settled`. - -1. Seller signs fresh custody-protocol listing terms: exact card/sequence, allowed host settlement key, exact XMR gross, fee split/beneficiaries, protocol/network/order domain and validity rules. Buyer reviews and signs the immutable checkout quote, including the custodian, recipient identity, maturity and cancellation policy. V3 mock signatures do not authorize debiting real balances; use a new domain/version with explicit consent. -2. In one financial transaction, validate terms/availability, reserve funded **available** buyer balance, allocate an order ID and store the lock outbox intent. A local reservation protects buyer funds, not the NFT. If the seller has already transferred the card elsewhere, fail safely and release only after no live completion authority remains. -3. A new, separately reviewed Wikideck sale-lock transaction binds card/sequence, seller, buyer, order/market/chain IDs, terms digest, host public key and consensus-height deadlines. Ownership remains with the seller, but conflicting transfers and locks are rejected. Obtain the seller's exact authorization; the host must not be able to redirect the card to itself or another buyer. -4. After lock maturity and a durable reservation, the host signs a narrowly scoped funding/release attestation and relays the matching sale completion. Validators verify deterministic lock/signature/deadline rules, **not** Monero RPC or internal balances. They trust the host's assertion that customer funds were reserved under its deposit policy. A compromised host can lie about funding; seller assent and host accountability are the remaining protections. -5. On canonical completion at the selected Wikideck depth, commit once: consume buyer reservation and allocate seller, author and host amounts. The outbox/inbox and unique completion identity recover a crash after chain delivery but before journal commit. No Monero transaction occurs for this purchase. Display inclusion and maturity separately; the existing roughly 15-minute block cadence can make this slow. -6. Cancellation releases buyer funds only after canonical cancellation/expiry is mature and all previously signed completion paths are invalid under consensus. Never refund on a website timer, mempool absence, RPC timeout or current lack of delivery alone. If completion and cancellation race, canonical rules choose one; an uncertain fork remains reserved/reviewed until resolved. - -NFT lock/completion/cancel rules are **not implemented or fully specified here**. Exact boundary heights, expiry, in-block ordering, seller authorization lifetime, host-key rotation/revocation, third-party submission of stored witnesses and fork behavior need a dedicated consensus specification and tests. Existing ordinary signed transfers cannot safely substitute because they can be submitted after a refund. - -If the host disappears, a reviewed height-expiry path should eventually return NFT control without a host signature. It cannot recover custodial XMR; financial recovery still depends on operator backups/keys. A stalled chain delays safe cancellation. This is a recoverable saga with explicit trust, not an atomic commit across chains. - -**Reorg after settlement:** pause affected withdrawals and resale, preserve the original postings, and enter review. If the same completion rematures, resolve without a second credit. If it is invalidated permanently, compensate with linked entries and prevent both a new delivery and buyer reimbursement. If proceeds have already left custody, use approved operator capital/loss procedures; never assume reversing a database entry recovers XMR or automatically confiscate unrelated balances. Confirmation/hold depths and funded exposure limits must reflect measured Wikideck chain security before launch. - -## 7. Fees and small prices — proposed policy change - -Retain exact native-XMR listing prices, no economic listing minimum, current independent downward rounding and fixed 1% author allocation. Gross equals seller credit plus author credit plus host commission. Internal purchase settlement has **zero Monero network fee**; one-atomic-unit sales are possible when rounded commissions are zero. Resource/rate limits still apply. - -Propose: depositor pays their external sending fee; withdrawing beneficiary pays the quoted actual withdrawal fee; operator funds treasury sweeps/consolidation from disclosed operating capital. This replaces the earlier per-sale network-cost model **only if approved**. Do not run the existing per-sale payout-budget rejection unchanged for ledger purchases. - -Author fees accrue as separately identified, fully backed liabilities associated with the fixed policy recipient; do not assume a Monero address is a Wikideck login or let an admin silently redirect it. A reviewed address-control enrollment or fixed-destination payout procedure is required. They may accumulate until economical to pay, but cannot become host revenue; timing, fee payer and small/dormant balance treatment need explicit terms. This accumulation is proposed, not already authorized. - -No hidden withdrawal minimum or flat fee. Baseline: exact recipient amount plus a user-signed maximum fee; decline/defer before signing if it cannot be funded. A tiny balance remains owed even if a standalone withdrawal is currently uneconomic. Define fee-aware full-balance withdrawal, closure and unclaimed-funds handling before launch; do not promise every atomic balance can be withdrawn alone economically. Defer batching initially because it complicates fee allocation, privacy and partial outcomes. - -## 8. Withdrawal protocol - -`requested → reserved → approved → preparing → prepared → relay-unknown/submitted → confirming → complete`, with `review` for uncertainty. Cancellation is straightforward only before creation of an externally usable signed transaction. - -- Fresh signed authorization binds custodian, both network identities, actor, destination, exact recipient amount, max fee, withdrawal ID/nonce, policy version and expiry. Verify Monero network/checksum; initially accept literal standard/subaddresses only, no DNS/OpenAlias or integrated-address ambiguity. Show full destination and total debit; use step-up authentication, new-destination cooling and independent approval above reviewed risk limits. -- Atomically reserve amount plus max fee. A single fenced treasury worker allocates an attempt before wallet I/O. Check pooled unlocked liquidity, not the depositor's original outputs. Do not promise immediate withdrawals when change is locked or cold treasury replenishment is required. -- Construct without relay, check actual fee/destination/amount/change against authorization, and durably encrypt/store signed transaction identity, metadata, selected-input identifiers and recovery material before broadcasting. `transfer` with `do_not_relay`/`get_tx_metadata` followed by `relay_tx` is an RPC capability, **not** a database transaction or automatic idempotency guarantee. Test the pinned wallet's construction/cache behavior, lost responses and input reservation explicitly. -- On relay timeout/crash, reconcile or retransmit the **same** prepared transaction. Do not construct another payment or release the reservation just because an RPC says failed/not found. A signed transaction may still be relayed by someone holding it; replacement/cancellation requires a reviewed proof that the old spend can no longer succeed, including reorg exposure. Uncertain attempts stay quarantined. -- After canonical withdrawal maturity, close liability once, account actual fee and release unused allowance. Preserve transaction keys/metadata securely for support; never expose secrets in logs. Outgoing reorgs reopen tracking, not a second withdrawal. No batch/automatic split in the first release; if construction yields multiple transactions, reject before relay or require an explicitly reviewed multi-transaction attempt model. - -## 9. Custody operations, privacy and recovery - -- Recommend a view-only deposit observer plus an isolated, policy-constrained spending service and offline-signing/dual-control cold treasury. Cold storage and hot liquidity caps limit routine exposure; old deposit addresses may continue receiving into a hot wallet, so sweeping/rotation does not create a cryptographic total-deposit cap. A compromised signer can spend its wallet regardless of application limits. Pin and review the actual signing technology; do not assume arbitrary HSM/threshold compatibility with Monero. -- Private fixed-origin authenticated RPC only; no public wallet RPC, browser credentials, arbitrary URL fetches or unrestricted proxy. Least privilege, method allowlists, independent signer policy, bounded bodies/timeouts, service authentication and encrypted transport where crossing hosts. Disable production capability by default and fail closed on network/profile mismatch. -- Track daemon/wallet lag, canonical tip disagreement, reconciliation differences, queued/uncertain withdrawals, locked liquidity, deposit/reservation concentration and cold/hot exposure. Maintain separate switches for new deposits, new orders, author payouts and withdrawals. During incidents continue monitoring existing liabilities and provide a support/recovery path; blanket freezes are not a substitute for reconciliation. -- Back up the financial journal/WAL, immutable customer-to-subaddress mapping and allocation high-water marks, wallet files/cache/seed under separate encrypted custody, policy versions, attestation keys, signed intents and prepared/outgoing transactions. Wallet seed alone cannot restore internal customer balances or all outgoing destination metadata. Restore into an isolated, non-spending environment; reconcile external transactions before enabling the single fenced signer. Drill full recovery and key compromise response. -- Custodian learns deposit-to-Wikideck-wallet, purchase and withdrawal linkage despite Monero's public-chain privacy. Keep balances, addresses and history private/actor-scoped; minimize access/retention and redact analytics/logs. Audit exports and legal disclosure need controlled procedures. Hash-chained journal exports help tamper detection but do not prove honest custody or prevent privileged tampering without independent copies/review. -- No automatic global wallet bans. Distinguish account-security restrictions, disputed cases and evidence-backed local service decisions; publish response, appeal and correction rules. -- Obtain qualified US custody/money-transmission, sanctions, consumer, privacy and tax advice before funds are accepted. Decide customer asset segregation, insolvency treatment, loss allocation, closure and support obligations with counsel. No claim of insurance, legal exemption or guaranteed reimbursement without actual reviewed arrangements and funding. - -## 10. Integration and staged acceptance - -Reuse [exact XMR amounts](../shared/xmr-amount.ts), [pricing splits](../shared/xmr-pricing.ts), [v3 mock domain](../shared/market-v3.ts) and receipt concepts in [market store](../server/market/store.ts). There is no connected live payment adapter. The selected implementation path is direct pinned Monero wallet/daemon observation for deposits, a pooled PostgreSQL liability ledger, and an isolated, policy-constrained treasury signer for withdrawals; each component requires production integration and review before funds are accepted. - -1. **Approve scope and economics:** custodian identity, custody disclosure, account recovery, beneficiary enrollment, withdrawal/author fee policy, balance accumulation, loss/reserve obligations and numerical risk/confirmation/hold policies. These remain open; no fabricated thresholds or support SLA. -2. **Offline financial model:** property-test conservation, nonnegative available balances, concurrency, replay, output deduplication, tiny amounts, duplicate workers, crash at every journal/outbox boundary and repeat restoration. Keep mocks isolated; no mock balances become real opening liabilities. -3. **Separate NFT consensus specification:** lock/completion/cancel/expiry/host-attestation semantics, cross-market double sales, stale signatures, host disappearance and both fork races. Independent review, versioned network activation and migration approval; no silent changes to existing chain identity. -4. **Disposable Monero integration:** only with separate network/budget/access approval. Verify lossless RPC, output identity, wallet restore/lookahead, locked/double-output deposits, reorgs, construction-response loss, signer restrictions, relay ambiguity and withdrawal recovery. Independent security/accounting review and measured load/capacity tests. -5. **Operational release:** funded reserves and liquidity, counsel/terms, penetration test, audited restore/incident drills, customer statements/support and operator sign-off. Start with declared low exposure and manual withdrawal approvals; expand only after measured evidence. No live auction release. - -Required failure outcomes: duplicate deposit credits are impossible; insufficient/unlocked-pending funds cannot buy; two concurrent buys/withdrawals cannot overspend; delivery and refund cannot both mature; completed-sale replay cannot mint ledger credit; a lost relay response never causes a second payment; restored journals cannot resend old withdrawals; operator compensation never silently drains another customer; deep reorgs create visible cases rather than imaginary rollback of Monero. - -## Primary sources and remaining evidence - -- [Monero subaddresses](https://docs.getmonero.org/public-address/subaddress/) and [wallet RPC `create_address`](https://docs.getmonero.org/rpc-library/wallet-rpc/#create_address): address allocation/indexing, not an internal financial ledger. -- [Wallet-owned outputs](https://docs.getmonero.org/rpc-library/wallet-rpc/#incoming_transfers), [transfers](https://docs.getmonero.org/rpc-library/wallet-rpc/#get_transfers), [balances](https://docs.getmonero.org/rpc-library/wallet-rpc/#get_balance): output identity, subaddress association, synchronization, locking and balance observations. Exact production-version fields and stability must be fixture-tested. -- [Constructing transactions](https://docs.getmonero.org/rpc-library/wallet-rpc/#transfer), [relay](https://docs.getmonero.org/rpc-library/wallet-rpc/#relay_tx), [offline signing](https://docs.getmonero.org/rpc-library/wallet-rpc/#sign_transfer): preparation/signing/relay separation; signed payloads must be checked, protected and recovered. -- [Transfer history](https://docs.getmonero.org/rpc-library/wallet-rpc/#get_transfer_by_txid) and [rescan](https://docs.getmonero.org/rpc-library/wallet-rpc/#rescan_blockchain): wallet restore can lose outgoing destinations, transaction keys and notes. Blockchain replay is not a backup for customer accounting. - -Source review: 2026-10-20; documentation is capability evidence, not an audited implementation of this design. No wallet/RPC credentials or funds were accessed. - -**Actual mTLS rehearsal result:** against the authenticated official v0.18.5.4 binary in Docker `--network none` on offline Stagenet, the approved ephemeral client certificate succeeded, a CA-trusted but non-allowlisted certificate and a client without a certificate were rejected, and a forbidden transfer method was rejected before Wallet RPC. This is an actual protocol handshake and allowlist test in one isolated container, not production network isolation or certificate lifecycle proof. Gateway/app wiring, deployment firewall policies, cert rotation, deposit observation/crediting and any payment capability remain unimplemented/disabled. - -**Actual RPC integration blocker:** The v0.18.5.4 process rehearsal confirmed a login-protected Wallet RPC returns HTTP 401 with a Digest challenge; Basic auth also returns 401. The app does not implement legacy MD5 Digest. New groundwork replaces that idea with a local TLS 1.3 mTLS gateway and a client transport that emits no Authorization header. The gateway binds only to an explicitly configured private IP (never a wildcard/public interface), authenticates an allowlisted client certificate, permits only schema-bounded address/observation methods, and forwards to a fixed loopback Wallet RPC upstream. Wallet RPC would run without its own HTTP login only inside that protected boundary. This gateway is not deployed or wired to the application. Its isolated end-to-end certificate handshake has now passed against the official Wallet RPC; intended-host/network isolation, certificate provisioning/rotation, and production configuration remain unverified. Do not enable Wallet RPC outside the tested disposable boundary until those deployment controls pass review. - -**Signed deposit-address enrollment slice (2026-10-22):** a server-side service now validates a short-lived customer-wallet signature over mainnet network/chain, custodian, beneficiary, wallet-generation fingerprint, account index and request ID, then uses the existing durable reserve-before-RPC allocator and deterministic-label recovery to return that customer's registered subaddress. A dedicated allocator database role is separated from custody-runtime, observer and credit roles. Schema v8 adds a database trigger preventing that role from inserting an already-registered/payable address; registration must follow a reserved row. PostgreSQL integration tests exercise replay, beneficiary uniqueness, wallet identity, role boundaries, no financial journals and attempted trigger bypass. This service is **not yet wired to an HTTP route or UI**, all wallet tests use fake RPC, and no deposits, credits or withdrawals are enabled. Do not display addresses to users until the complete credit/withdrawal/recovery flow has been validated. diff --git a/docs/custody-sale-rehearsal.md b/docs/custody-sale-rehearsal.md deleted file mode 100755 index 18ef56d..0000000 --- a/docs/custody-sale-rehearsal.md +++ /dev/null @@ -1,64 +0,0 @@ -# Signed sale → PostgreSQL accounting rehearsal - -Status: **disposable, synthetic evidence only; no live settlement or consensus activation**. Evidence: 2026-10-20. Related: [sale-lock candidate](sale-lock-candidate.md), [financial reconciliation](financial-reconciliation.md), [production plan](production-plan.md). - -## What is integrated - -[The replay bridge](../server/market/sale-rehearsal.ts) validates trader-signed candidate terms against an actual sandbox purchase reservation. [The PostgreSQL custody implementation](../server/market/postgres-custody.ts) can consume this evidence and atomically resolve its reserved balance. This exercises the real restricted runtime role, financial writer lock, journals, balance projection, listing/order/outbox updates and business audit—not an in-memory imitation of SQL accounting. - -`observeSaleRehearsal()` is callable only on an instance explicitly opened with `disposableSaleRehearsal: true`, with `WIKIDECK_PG_DISPOSABLE_TEST=1`, a configured database named `custody_test`, and a matching actual connected database name. There is **no HTTP route, production environment enable switch, host signer, Monero adapter or public checkout integration**. These guards prevent accidental use; a database name/environment flag does not independently prove infrastructure isolation. The supplied test Compose deployment is disposable/internal/tmpfs, and must never contain funds or valuable assets. - -The evidence supplies one synthetic seller-owned NFT seed and up to 128 candidate blocks, each containing at most 64 sale transactions. Replay uses the existing candidate state machine, signatures and depth rules. JSON traversal has collection/depth/node limits and a 256 KiB evidence ceiling. Seed, policy, host identity, signed windows and exact economics are bound on the first successful observation. Changing that binding is rejected; a different block history under the same binding can be observed to test reorg handling. - -This seed is **caller-supplied**, not discovered in the live node. Blocks have **no RandomX proof, parent hashes, fork choice, issuance verification, peer validation or canonical event provenance**. Signed terms authorize the simulated transitions; signatures do not prove the chain is canonical or that real Monero backs the reservation. - -## Exact authorization and financial behavior - -- Both seller and buyer sign exact order/custodian/card/sequence, trading identities, host authority, candidate policy/domain, gross XMR amount, fee policy, recipient and admission/expiry heights. Existing sandbox reservation signatures alone are not candidate sale authorization. -- The signed hosting rate must equal the stored listing rate, even where two rates round to identical commissions on a tiny sale. The allocation and host recipient must match the reserved order/listing. All amounts remain canonical atomic-unit strings and `bigint`; there is no float or USD conversion. -- Optional hosting recipient terms use a tagged signature extension. Omission retains the earlier candidate message bytes. New drafts preserve the order's snapshotted recipient; changing current host policy does not redirect revenue. The candidate only checks address shape; actual custody policy/business audits additionally check Monero network and checksum. -- The reservation commitment binds the order's exact buyer/seller, card/sequence, gross and split amounts, author and hosting recipient, custodian and rehearsal binding. Completion must contain that commitment and the bound host's valid signature. There is no private key or automatic signature generation in the accounting implementation. -- Before maturity, gross remains in the buyer's reserved account. Once authorization has been bound, ordinary buyer `cancel-reservation` is denied, including through another ordinary custody instance. Synthetic mature terminal/non-admission evidence is required instead. -- Mature completion debits reserved gross and credits the seller's available balance, fixed author liability and snapshotted hosting revenue in one balanced transaction. Zero commission legs are omitted, not rounded up; a one-atomic sale still credits one atomic unit to the seller. -- Mature cancellation/expiry or the safe never-admitted boundary debits the reservation and returns gross to the buyer. There is no seller/fee credit or Monero transfer on this branch. -- Deterministic journal IDs plus the shared database writer lock prevent duplicate terminal postings across pools, replay and restart. An identical outcome returns `none` rather than posting again. Failure while storing terminal evidence rolls the entire financial mutation back. - -## Evidence, audit and review - -Namespaced insert-only metadata records retain each order's binding, **first** signed observation, terminal observation, per-order terminal index and (when there is an explicit terminal candidate transaction) candidate-tx-to-order index. Runtime has INSERT/SELECT but no UPDATE/DELETE privilege on these metadata records. Repeated observations do not create an unbounded transcript archive. Business audits replay signed retained evidence, check the exact listing rate and binding/state commitment, and compare terminal outcome, candidate transaction ID and indexes with the stored order and financial lifecycle. - -The existing sandbox `mock-sale-settlement` journal and `fixture-complete`/`fixture:` outbox format are reused deliberately. They remain **fixture** evidence, not a new assertion of canonical delivery. No schema migration or production evidence format is implied. Existing imported SQLite fixture settlements remain supported without being relabeled as signed rehearsals. - -A submitted history that conflicts with, removes or makes immature an already posted outcome triggers `sale-rehearsal-outcome-reorg`. Business changes roll back, and a redacted durable integrity incident commits while retaining the shared writer lock. All custody instances then reject new mutations; private reads and original signed receipts remain available. The original seller credit/refund stays recorded: no automatic compensation, reversal or second payout. Re-submitting the old mature history cannot clear the incident. Existing schema-owner incident review procedures are not a substitute for resolving chain risk; production resolution requires retained canonical evidence and approved loss policy. - -## Local canonical-event inbox foundation - -The same explicitly disposable PostgreSQL instance now has `observeCanonicalEvents()` and `observeLedgerCanonicalEvents(ledger)`. The ledger adapter requires an actual in-process `Ledger` object, checks its network/chain identity, and fetches one page at a time from its locally validated [canonical event API](../server/testnet/canonical-index.ts)—no caller/HTTP/peer page can enter through that adapter. The inbox is append-only and bounded. It checks stream identity, page limits, predecessor anchors, event hashes, sequence continuity, connect parent/tip and tip-first disconnect rules against a reconstructed canonical hash stack. It accepts exact retries and overlapping pages from concurrent consumers only after matching their persisted overlap; stale retries report the durable cursor/head, not the stale page head. The startup/periodic financial audit replays and verifies the stored inbox; a changed stream, gap or malformed/tampered event creates the normal durable integrity hold. Inbox records use namespaced `custody_meta` keys and the existing runtime role; there is no schema migration or mutable cursor. - -An opt-in `CanonicalObserver` now schedules one bounded poll cycle on the server runtime, resumes from its durable cursor after restart, and stops/drains before PostgreSQL and the ledger close. Each cycle processes at most four pages of 64 events; lag is exposed on the private operations listener. Poll failures or sale-review events stop the loop fail-closed; it does not retry a failed observer automatically. The runtime opt-in requires `WIKIDECK_CUSTODY_CANONICAL_REHEARSAL=1`, PostgreSQL custody and `WIKIDECK_PG_DISPOSABLE_TEST=1`; the configured and actual database must also be `custody_test`, and `NODE_ENV=production` is explicitly rejected. Invalid/missing combinations fail closed, including direct construction of the polling controller. Shutdown stops scheduling immediately and drains the current page before custody/ledger close. This is **not enabled in the persistent sandbox or any regular deployment** and has no public HTTP control endpoint. - -PostgreSQL tests include synthetic event pages for malformed/retry cases, local `Ledger` blocks configured with a **test-only fake PoW engine** for equal-work side-branch/reorg transitions, and one separately mined and independently revalidated block using the **native RandomX engine** before its canonical event is consumed. This proves the real PoW validation → local event API → PostgreSQL inbox path for a single block; it does not establish production multi-node reliability or sale-candidate transaction support. Hash-anchor consistency is not proof of honest observation; the stream ID and unkeyed hashes are stored beside the events, so a schema owner able to coherently rewrite the complete history can also recompute the hashes. The included corruption test detects a partial edit, not a fully rewritten valid history. Production must anchor custody checkpoints outside the database's mutable trust domain. The disposable inbox is capped at 4,096 total events, stores the append-only event history and fully rescans it during audits. That is intentionally a small-fixture design, not production storage/cursor architecture or a scale claim. - -The synthetic PostgreSQL event-page seam now links an explicitly terminal candidate transaction ID to its posted order. A later **submitted disconnect event** containing that exact ID commits both the canonical event page and a durable per-order `sale-reorg-review` record plus global `posted-sale-terminal-disconnected` mutation hold in one transaction; the original journal is not reversed or paid again. This remains true if the transaction reappears later in the same replacement-branch page: review does not auto-clear on apparent re-inclusion. Generic incident resolution re-audits the retained review record and therefore cannot clear it. This exercises order-to-event linkage and atomic review behavior. The hold only detects disconnects actually delivered to this seam; it cannot detect an unseen intermediate fork. - -The local-ledger adapter is **not** linked to these terminal candidate IDs: active v6 blocks cannot contain candidate sale transactions, and local free transfers are not accepted as sale completion evidence. The adapter's integration test demonstrates canonical event ingestion/reorg only, not sale rollback handling. The terminal-link test supplies a synthetic canonical page under disposable-only guards. Production still requires the candidate transaction format in a separately reviewed/activated network, an actual local canonical feed containing those IDs, and per-order integration with durable node history. No production sale-observation guarantee is claimed. - -## Validation and remaining gates - -TypeScript/Vite Docker build passed; **172 offline regressions and 31 PostgreSQL integration tests passed, zero failures/skips**. [Candidate tests](../tests/sale-candidate.test.ts) cover signature compatibility/recipient tampering and candidate transition rules. [PostgreSQL tests](../tests/postgres/custody.test.ts) add: - -- explicit instance/database consent and exact signed reservation matching; -- seed-binding changes, malformed/cyclic/oversized collections, and denial of ordinary cancellation after binding; -- immature completion hold, concurrent exactly-once seller/author/host credit, replay and reconnect; -- mature cancellation and never-admitted-lock refund boundaries; -- identical-rounded-allocation but different fee-rate rejection; -- one-atomic settlement and signed custom-recipient revenue across current-policy changes; -- conflicting outcome → durable cross-instance/restart hold without a second credit; -- anchored canonical-event connect/disconnect paging, overlapping/stale consumer retries, reconnect, partial history mutation audit, forged disconnect-ancestor rejection and tamper-triggered hold; actual native-RandomX block validation and event ingestion plus test-only fake-PoW excluded branch/reorg fixture (no active-chain candidate sale link); -- opt-in HTTP runtime startup, initial observer checkpoint, 64-event bounded catch-up, clean shutdown, durable restart resume and invalid-flag rejection; -- synthetic exact candidate-terminal transaction disconnect/reappearance in one page → committed event history plus order-linked durable hold, generic incident resolution denied, one preserved seller credit and no second payout; -- injected terminal-evidence failure → complete rollback and safe retry; corrupted evidence → audit hold. - -A first bounded-checkpoint implementation attempted metadata UPDATE and was rejected by the real runtime grants. It was corrected to retain insert-only first/terminal evidence; no privilege was widened. Test evidence is for this implementation boundary, not production approval. - -**M3 remains open.** Next work is independently reviewed protocol/policy and explicit new-network activation design, then actual PoW/peer/index/undo integration and durable canonical observation tied to financially fenced host-signing intent. The order-link detector is only exercised with synthetic page events until an activated candidate network can produce candidate IDs through the real local ledger. Monero deposit confirmation/unlock observation, treasury/signer separation, real withdrawal controls, coordinated recovery, capacity and legal/operational release gates remain separate. The active v6 chain still rejects every candidate sale transaction. Persistent local custody deployments were not switched or restarted by this rehearsal. diff --git a/docs/financial-reconciliation.md b/docs/financial-reconciliation.md deleted file mode 100755 index f23f412..0000000 --- a/docs/financial-reconciliation.md +++ /dev/null @@ -1,48 +0,0 @@ -# Custodial business-liability reconciliation - -Status: implemented PostgreSQL **sandbox** integrity checks; not external proof of Monero backing or production settlement acceptance. Related: [production plan](production-plan.md), [financial storage](postgres-financial-storage.md). Updated 2026-10-20. - -## Why totals are insufficient - -A balanced journal and matching account projections can coexist with a corrupted order: its buyer, amount, status or pending task may disagree with the funds actually reserved. Aggregate assets equalling liabilities alone cannot detect a substituted recipient or orphan task. - -[Business integrity checks](../server/market/custody-integrity.ts) now validate both directions: - -- Strict persisted listing/order/withdrawal encodings, indexed identity/status columns and JSON record agreement. -- Order card, seller, buyer, sequence, price and historical hosting recipient against the original listing; exact independently rounded seller/author/host allocation and fixed author policy. Current operator fee configuration never rewrites history. -- Each awaiting-lock order has its exact credit-normal purchase reservation; cancelled/fixture-settled orders have zero reservation. The actual reservation and terminal journal lines must match the expected lifecycle, accounts and amounts, not just net to zero. -- Each withdrawal hold equals recipient amount plus maximum approved fee. Prepared/complete fixture records require an attempt ID, unique transaction identity and fee within cap. Cancelled/complete holds are zero; terminal postings and returned unused fee allowance match exactly. -- Blocked/cancelled/prepared/completed outbox state and payload correspond to the business record. Imported SQLite fixture terminal evidence is recognized explicitly; no real NFT/Monero settlement is inferred. -- Reverse checks reject orphan reservation accounts, pending work and lifecycle postings, duplicate listing orders, and reused prepared attempt/transaction identities. - -Reads use the caller's coherent transaction. Record batches use UUID keyset pagination, maximum 128 per batch, with no growing process-wide customer cache. A 15-second business-scan budget is checked between batches/queries; the existing per-query timeout can extend the final boundary. This bounds application memory and failure behavior, **not a production capacity claim**. Very large histories may require indexed normalized schemas, incremental auditing and a dedicated database connection/worker budget before release. - -The existing exact journal/projection/backing audit remains, and now invokes these checks at startup, explicit audit, SQLite import validation and deployment checks. Targeted pre/post checks run inside order/withdrawal mutation transactions. No database schema version or old financial record is rewritten by this increment. - -## Runtime scheduling and failure behavior - -The PostgreSQL backend schedules one nonoverlapping audit every 60 seconds. Mutations are held if the latest audit failed or the last success is over 120 seconds old. A transient availability failure can recover after a successful later audit; it does not automatically classify customer behavior as fraud. - -A confirmed integrity mismatch latches the detecting process and stores a redacted `integrity_incident_v1` record in custody metadata. Subsequent writers check that marker under the existing cross-process advisory lock. Startup refuses it. Private activity and original signed receipt replay remain available; no new posting, refund, cancellation or withdrawal reservation is allowed while integrity is uncertain. This is deliberately stricter than an ordinary market-disabled flag, which still permits normal resolution. - -For a targeted write-path mismatch, a savepoint rolls back business work while retaining the writer lock; the incident is committed before another cooperating writer can proceed. A background/read-only audit or startup failure records its incident separately. **Process crash or database unavailability can prevent that marker from committing.** A redacted error explicitly requests fencing all writers if marker persistence fails. A periodic audit is not an atomic fraud-prevention barrier for every unrelated write, nor protection from a privileged operator or fully compromised application. - -Private loopback operations now expose only backend, mutation/incident hold, audit timestamps, freshness and a stable failure code. No customer ID, order ID, address, SQL statement, balance or connection secret is attached. [Operations checks](../server/operations-check.ts) emit `custody-financial-review` and `custody-audit-stale` warnings. No new public admin or financial audit endpoint was added. - -## Controlled resolution — never reset journals - -1. Stop/fence all custody writers and preserve matching database/node evidence and backups. Diagnose privately; a public-facing error intentionally does not disclose the affected customer. -2. Have the responsible operator/reviewer establish the correct state from immutable receipts/journals, trusted backup evidence and any external effects. This increment supplies **no automatic repair, compensation, destructive reset or payout retry**. Repair/restore authorization is separate from acknowledging an incident. -3. Using the schema-owner credential, run the existing database CLI action `resolve-integrity EVIDENCE_UUID`. The UUID refers to the externally maintained reviewed incident case; the tool does not certify the truth of that case or supply a second human approval. -4. [Resolution code](../server/market/custody-incident.ts) takes the financial writer lock, verifies schema-owner membership, tentatively removes the unresolved marker and runs the full accounting/business audit in the same transaction. Failure rolls everything back, preserving the hold. Success appends a resolution record with evidence UUID, timestamp and incident digest; it changes no journal/account/business rows. -5. Restart previously latched instances only after controlled resolution, then verify successful startup/audit and operations state. A detecting instance stays locally held even after a successful later audit. The restricted runtime role cannot acknowledge incidents or delete the marker. - -This process is intentionally not a “clear error” button. Manual SQL balance edits, deleting a marker outside the reviewed procedure, restoring stale isolated financial state or inventing journal resets can create duplicate payouts or loss of liabilities. An external database owner retains administrative power; independent evidence retention remains necessary. - -## Evidence and limitations - -Tests cover business-record corruptions that leave all journal totals balanced; bad allocations/fee metadata; missing/mismatched outbox; orphan reserves/tasks; targeted mutation rollback; durable cross-instance/startup holds; private reads/original replay during a hold; restricted-role resolution denial; failed-resolution rollback; successful reviewed acknowledgement plus restart; traversal beyond 128 records; and import of historical fixture completions/cancellations. No actual fund transfer, incident on deployed user data or consensus change was performed. - -Final validation: TypeScript/Vite Docker build passed; **168 offline regression tests and 16 real-PostgreSQL integration tests passed**, zero failures/skips. The first corruption-test run exposed an unguarded chained amount refinement on malformed stored decimals; the parser was corrected to emit a redacted integrity incident and all suites were rerun. Deployed sandbox services were not recreated or subjected to deliberate corruption; new code takes effect only after the selected runtime image is rebuilt/recreated. - -SQLite remains a disposable compatibility model with its original journal audit; this richer runtime checking is implemented in the PostgreSQL backend, including imported SQLite histories. Source-freeze/import procedures are unchanged. Production live-deposit attribution, treasury asset reconciliation, normalized financial states, compensation, observer/signing services, independent review and measured load/SLOs remain open. A successful internal audit is **not** proof of external XMR reserves, a guarantee of solvency, or launch permission. diff --git a/docs/mainnet-launch.md b/docs/mainnet-launch.md index 1f00e5c..d6b6286 100755 --- a/docs/mainnet-launch.md +++ b/docs/mainnet-launch.md @@ -1,6 +1,6 @@ # Gated mainnet candidate -This repository now has a **separate mainnet candidate profile**, not an audited or launched production network. Shipping code does not establish independent miners, economic security, an operational public network, or a safe real-money market. **Marketplace payments remain mocked on every profile.** There is opt-in mainnet code for operator-reviewed pending deposit credits and signed withdrawal reservations, but it does not expose customer deposits by default or construct/relay Monero transactions; it is not a live payment service. +This repository has a **separate mainnet candidate profile**, not an audited or launched production network. Shipping code does not establish independent miners, economic security, or an operational public network. Market and payment features have been removed from the card-only product. **Current production decision: NO-GO.** See the [2026-10-20 security review](security-review.md) and [operator evidence register and launch procedure](operator-launch.md). The read-only `launch:preflight` package script (or node entry-point `--launch-preflight`) reports local gates and unresolved blockers without starting a node or setting approvals. It deliberately exits nonzero while production readiness is blocked. @@ -12,9 +12,8 @@ This repository now has a **separate mainnet candidate profile**, not an audited | Network ID | `wikideck-randomx-testnet-6` | `wikideck-randomx-mainnet-1` | | Protocol | Existing version 6 | Version 6 under a distinct chain identity | | Genesis timestamp | Unchanged | 2027-01-01 00:00 UTC, **candidate only, not a promised launch** | -| Ledger/market subdirectory | testnet | mainnet | +| Ledger subdirectory | testnet | mainnet | | Browser wallet | Existing testnet store, unchanged | Separate mainnet store | -| Payment mode | Mock only | Mock only | Both profiles use real RandomX validation, cumulative-work branch selection, the 15-minute target, mint maturity, signed NFT transfers, atomic card trades, and SQLite persistence. The mainnet candidate has no premine, token migration, hidden checkpoint, or fake-PoW option. Changing the network identifier and candidate genesis derives distinct chain/genesis IDs, RandomX initial keys, signature domains, and consensus manifest checks. Existing testnet values and data remain unchanged. @@ -36,8 +35,6 @@ Starting the candidate node or native miner requires all of: 2. `WIKIDECK_MAINNET_REVIEW_ACK=independent-review-complete` records the operator's review attestation. This is **not cryptographic proof of an audit**, and the manifest still honestly reports `audited: false`. 3. The candidate genesis timestamp has arrived. If review changes the timestamp or consensus manifest before launch, the chain ID also changes and the old approval is invalidated. Freeze and publish the final manifest only after review. 4. An HTTPS `PUBLIC_ORIGIN`, at least two distinct configured HTTPS peer origins, and no private-peer bypass. Configuration count cannot prove the peers are online, independent, or sufficiently distributed; verify that operationally. Existing peer DNS/origin controls still apply. -5. `WIKIDECK_PAYMENT_MODE=mock`. Live modes fail closed independently of launch approval. - Unknown profile names fail closed. A direct import of the HTTP entry point is also guarded. Library-level manifest/consensus/storage inspection remains possible for tests and review; activation gates are accident prevention, not a security boundary against someone who edits the source. ## Required before an actual production launch @@ -46,12 +43,10 @@ Unknown profile names fail closed. A direct import of the HTTP entry point is al - **Security model:** quantify hashpower, chain-rewrite cost and majority attacks. The inherited easy initial target and ten-block retarget are **not production-calibrated**. Choose reviewed launch difficulty/retarget behavior and test start-up/stall/recovery scenarios. Independent mining participation and sufficient hashpower are not provided by running multiple containers on one host. - **Adversarial rehearsal:** sustained multi-host simulations, partitions/eclipses, competing branches, deep reorganizations, clock skew, bad peers, load/mempool saturation, corrupted state and restart recovery. Test the candidate rules with no valuable assets. - **Operations:** independently administered public HTTPS nodes and miners across failure domains, pinned release artifacts and dependencies, firewall/reverse proxy/rate-limit policies, bounded storage and sync strategy, metrics, alerts, rotation and emergency-response procedures. -- **Recovery:** verified offline wallet recovery, consistent ledger/market snapshots, full replay/revalidation, documented incident and fork handling. No silent ledger reset or reuse of testnet data. +- **Recovery:** verified offline wallet recovery, consistent ledger snapshots, full replay/revalidation, documented incident and fork handling. No silent ledger reset or reuse of testnet data. - **Launch governance:** select the actual genesis date, freeze the reviewed identity/configuration, publish binaries and configuration hashes, agree peer/miner readiness and upgrade policy, and explicitly authorize launch. No operator should attest review merely to bypass the gates. -- **Economics and legal review:** no monetary value or security is promised. NFT rights do not imply article/image copyright ownership. A real-money custodial market requires a separate risk, custody and payment review. +- **User and legal review:** no monetary value or security is promised. NFT rights do not imply article/image copyright ownership. Publish experimental-use, privacy, support, and abuse-report terms appropriate to the release. ## Still not production-ready -This remains experimental software. Nodes retain the DAG and overlays in memory, rebuild state roots over the full NFT set, and revalidate proofs at startup. The HTTP UI trusts its connected node rather than independently validating the chain. There is no automatic peer discovery, established bootstrap infrastructure, formal finality, independent audit, built-in anti-shill mechanism or production abuse system. The marketplace is node-local, not consensus escrow. Mainnet selection does not remove any of these limitations. - -See [marketplace sandbox and future live-custody boundary](marketplace.md) for the mock settlement model and the additional work required before any real XMR can be accepted or paid out. +This remains experimental software. Nodes retain the DAG and overlays in memory, rebuild state roots over the full NFT set, and revalidate proofs at startup. The HTTP UI trusts its connected node rather than independently validating the chain. There is no automatic peer discovery, established bootstrap infrastructure, formal finality, independent audit, or production abuse system. Mainnet selection does not remove any of these limitations. See the [card-only production plan](card-only-production-plan.md) for the current readiness gates. diff --git a/docs/market-host-configuration.md b/docs/market-host-configuration.md deleted file mode 100755 index bd1e16b..0000000 --- a/docs/market-host-configuration.md +++ /dev/null @@ -1,36 +0,0 @@ -# Optional market hosting and host fee wallet - -These are **server runtime** environment variables, not browser settings. The single-node and three-node Compose profiles default the market runtime to off; the gated mainnet and dedicated custody profiles opt in. They do not enable real payments. - -| Variable | Default | Effect | -| --- | --- | --- | -| `WIKIDECK_MARKET_RUNTIME_ENABLED` | `0` | `1` opens the local market journal, exposes market APIs, and starts reconciliation. `0` leaves market data untouched, does not reconcile it, and returns 404 for market APIs. Resolve existing orders before disabling a market with unresolved activity. | -| `WIKIDECK_MARKET_ENABLED` | `1` | `0` removes public listings and rejects new listings, bids, purchases, custody reservations and fictitious funding. Node validation, mining, peer sync and free transfers/trades continue unchanged. | -| `WIKIDECK_MARKET_TRADING_ENABLED` | `1` | `0` pauses new trading/funding but keeps public listings browsable. Useful for maintenance/read-only operation. | -| `WIKIDECK_MARKET_HOST_FEE_ADDRESS` | Fixed project author XMR address | Public Monero recipient for the **hosting commission**, independently of the fixed additional author commission. | -| `WIKIDECK_MARKET_FEE_BPS` | `250` | Existing hosting commission rate, in basis points; range 0–5000. | -| `WIKIDECK_CUSTODY_MODE` | `off` in the application | Existing custody-service control: `off` or testnet-only `sandbox`. The dedicated custody Compose stack deliberately enables `sandbox` and provisions PostgreSQL. | - -Booleans accept exactly `0`/`1`; malformed or empty flags fail startup. The fee address must be a checksum-valid **mainnet Monero standard address or subaddress** under the current XMR policy; integrated addresses, URLs, other networks and blank values are rejected. It is not a private key, buyer deposit address, or proof of ownership. No RPC/wallet access is needed to validate it. - -Default wallet: - -44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv - -Set supported variables in the shell/Compose environment or a local Compose environment file, then recreate the intended app container. They are not browser build variables. Merely restarting a container does not replace its environment. The quick-start single-node and three-node profiles deliberately leave the market runtime off; explicitly enable it only for an operator-managed market. In the three-node configuration shared values apply to all nodes; per-service environment overrides allow different hosts/fees. Do not point independent markets at a shared financial schema. - -## Existing obligations remain available - -Turning off the market runtime is **not a funds freeze or deletion**. The market journal is preserved, but APIs and reconciliation are unavailable until the runtime is enabled again; this can pause existing-order resolution. Do not disable or stop an active market with unresolved orders or custody liabilities. PostgreSQL remains required if that node has PostgreSQL-backed liabilities; the switch is not a shortcut to abandoning custody responsibilities. The separate `WIKIDECK_MARKET_ENABLED=0` policy is different: it pauses new activity while retaining the market runtime and its existing resolution controls. - -A peer that never wants custody can leave `WIKIDECK_CUSTODY_MODE=off`; mining, peer sync, and free card transfers/trades do not require the market runtime or dedicated PostgreSQL stack. - -## Fee recipient and signature compatibility - -New native-XMR listings and custody listings snapshot the configured host fee address. Buyer checkout/reservation must authorize that same recipient. Both UIs show it during review and include it in signed terms. Recipient tampering invalidates the signature, and an unsigned/omitted recipient is accepted **only as the historical default author address**, never as authorization for an arbitrary configured wallet. - -Existing signature bytes remain valid: omission retains the original message; explicitly supplied recipients add a domain-tagged fee tuple. Old USD/native-XMR records are not rewritten. Changing the environment changes **new listings**, not existing listings/orders or accumulated revenue. New checkout against an old listing follows its original recipient. For a new recipient, cancel/relist with fresh seller authorization. Hosting rate and the fixed 1% author policy remain separate; defaulting both recipients to the same wallet does not merge the rates. - -The custody SQLite fixture records newly settled simulated host revenue under its snapshotted recipient. PostgreSQL production settlement/host withdrawals are still unimplemented: configuring an address does not create automatic transfers. Historical unallocated host revenue remains historical; it is not silently reassigned. The offline sale-lock candidate now preserves the hosting recipient in a tagged signed-terms extension. A [disposable PostgreSQL accounting rehearsal](custody-sale-rehearsal.md) verifies that the signed snapshot receives simulated revenue even after current host policy changes; this is not active-chain delivery or a live host withdrawal service. - -Validation: current TypeScript/Vite build, 172 offline regressions and 31 PostgreSQL integration tests passed. Tests cover strict flag/address validation, paused/disabled behavior, continuing existing-order delivery, preserved balances/cancellations, signed-recipient tampering and recipient snapshots across config changes, including the isolated accounting rehearsal. Real XMR remains disabled. diff --git a/docs/market-transparency.md b/docs/market-transparency.md deleted file mode 100755 index 436ed0a..0000000 --- a/docs/market-transparency.md +++ /dev/null @@ -1,133 +0,0 @@ -# Market payments, buyer protections and seller protections - -**Read this before buying or delivering a card.** Trust should come from knowing what a market does, who controls it and where its protections stop—not from a promise that every trade is safe. - -## Current status: no real payments - -**The running Wikideck market is a testnet sandbox. It does not process real cryptocurrency payments.** New listing prices are native XMR amounts; payment confirmations, fees, seller payouts and refunds are simulated. Historical USD orders retain their original terms and are labeled as legacy. Checkout provides **no payable Monero address**. Do not send cryptocurrency to a host, author-fee address or address someone provides in a message to pay a sandbox order. - -**Card delivery is real on the selected blockchain.** When a seller signs delivery, they authorize an actual NFT transfer, even though the buyer only clicked a simulated-payment button. A `paid` or `mock-paid` label here is **not evidence of real money received**. Do not deliver valuable assets in exchange for mock payment. - -There is no live payment integration or supported setting that enables live checkout today. - -## Who controls what? - -| Item | Controller and trust boundary | -| --- | --- | -| Your NFT signing key | Your local wallet. New/migrated local storage is password-encrypted; an unlocked tab can sign. Never give the host your password, backup or private key. | -| A listed card | The seller keeps control until a signed transfer is mined. **Listing is not escrow** and does not lock the card on-chain. | -| Confirmed card ownership | The selected network's canonical blockchain, subject to reorganizations. The browser trusts the connected node's reported chain; it is not an independent validating wallet. | -| Listings, auction bids, reservations and orders | The particular hosting peer's local marketplace database. They are not replicated as blockchain consensus records or shared with every other market. | -| Payment money today | No money is collected, held or sent by this release. Amounts are accounting simulations. | -| Money in the proposed live design | The selected direction is a pooled Monero treasury and internal liability ledger controlled by the market operator. **That would be custodial**, not trustless escrow or a non-custodial exchange. It is not active. | - -Your NFT wallet address is not an XMR payment address. Sharing the RandomX algorithm with Monero does not merge the two networks, payments or security budgets. - -## What happens in today's checkout? - -### 1. Seller lists a card - -The seller reviews and signs the card, ownership sequence, price, sale format, auction deadline where applicable and hosting-fee rate. Listing/checkout signatures also bind the hardcoded author-fee policy and recipient. The node checks that the seller owns a sufficiently mature card at the current sequence and that no conflicting card transaction is pending. Only one active listing for that card is allowed **on that market host**, not globally across all hosts. - -The seller may cancel an available fixed-price listing. An auction cannot be cancelled after its first bid. The seller still controls the card and can transfer it elsewhere; subsequent checks can invalidate the listing. These checks reduce stale sales but cannot prevent a seller from breaking an off-chain promise. - -### 2. Buyer reserves the sale or wins an auction - -Fixed-price checkout reserves the local listing for **30 minutes from reservation**. An unpaid reservation expires and can reopen if the card is still eligible. A seller cannot cancel the reserved listing through the normal cancellation action. - -Sandbox auctions last one minute to 30 days. Bids are signed but **unfunded**; each new v3 bid must meet the opening price and exceed an existing highest bid by at least one atomic unit. Historical USD auctions are read-only and retain their original bid terms. The seller's own address cannot bid, but alternate-wallet shill bidding is not prevented. There are no funded bid deposits, identity checks or automatic nonpayment penalties. Live auctions remain a separate release gate. - -The auction winner must check out and complete mock payment within **30 minutes after auction close**, not 30 minutes after clicking checkout. Without bids the auction expires at closing. An unpaid winner causes expiry; there is no automatic runner-up sale. The seller can relist later. - -### 3. Buyer simulates payment - -For a new version-3 order, the buyer sees the exact signed XMR gross, host fee, author fee, seller net and payment deadline. No USD conversion or exchange rate is applied. Legacy version-2 USD orders retain their previously signed USD price and snapshotted mock-XMR quote; they are not reinterpreted as XMR. Quote amounts, fee allocations and delivery-confirmation requirements remain stored with each order. - -Only the order's buyer can sign **Simulate XMR payment**, and eligibility is checked again. This action changes an accounting status; it does not verify a Monero transaction. Other users cannot mark the order paid through the normal participant-authorized API, but the buyer's signature alone never proves real payment. - -### 4. Seller reviews and signs delivery - -The seller must return to review the exact card, recipient and net proceeds. The market accepts only a valid seller transfer matching that order's card, seller, buyer and ownership sequence. The node validates the transfer under the chain rules. Submission means **pending**, not delivered. - -Seller delivery is due **48 hours after mock payment**. The node recognizes the exact agreed transfer if submitted through the marketplace, the normal transfer endpoint or peer gossip. Merely seeing the buyer hold some card is not the settlement criterion. Direct delivery without recorded mock payment is held for review rather than automatically paying the seller. - -The market never signs NFT delivery on the seller's behalf. Locking a wallet stops new session signing; it does **not** revoke an already issued signature or undo a mined transfer. - -### 5. Canonical delivery unlocks simulated payouts - -The market waits for the exact sale transfer to appear in the canonical chain at the order's required depth—**10 confirmations by default**, configurable for new orders. It then records the seller net and author-fee leg as `mock-paid`, with the hosting allocation retained in simulated host accounting. These are not XMR transaction receipts. - -No finite depth guarantees proof-of-work finality. If a reorganization removes or reduces the depth of an already settled transfer, the order enters **review** and is not paid a second time automatically. This prevents blind duplicate accounting; it does not recover money or reverse a transfer. An order's `review` state is not automatically cleared just because the chain later changes back. - -## Fees: who receives them and how much? - -| Allocation | Rate | Recipient / treatment | -| --- | --- | --- | -| Hosting fee | Operator-configurable, default **2.5%** | Hosting peer's market operator. Retained from gross proceeds in the intended merchant-wallet design. Currently simulated. | -| Project author fee | Hardcoded **1%** in the distributed implementation | Fixed author XMR address below. Separate allocation/payout, currently simulated. | -| Seller net | Gross minus both fees | Seller. Currently simulated; no XMR payout is sent. | - -Both fees are calculated independently from gross XMR, not compounded. They are deducted from seller proceeds, not secretly added to the buyer's listed price. At default rates a **0.100000000000 XMR** simulated sale allocates: - -- Host: **0.002500000000 XMR**. -- Author: **0.001000000000 XMR**. -- Seller: **0.096500000000 XMR**. - -The author recipient is: - -44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv - -**This is a payout recipient, not a checkout address.** Setting hosting fees to zero does not disable the author allocation. Checksum verification does not prove recipient ownership. The author fee is source-level policy, not a guarantee that every independently modified host runs the same release; verify the host and its disclosed terms. - -New v3 XMR prices and fees use integer 12-decimal atomic units. Buyer gross is the exact signed listed amount; there is no USD conversion or gross-up. Each fee rounds down separately; seller net is exactly gross minus both fees. A fee below one atomic unit rounds to zero. Historical v2 orders retain their original USD cents, mock-XMR quote and accounting, including legacy orders predating the author fee. Eligible old open listings are read-only until the seller retires and relists with fresh v3 authorization. - -Fees apply to completed marketplace sales—not listing creation, auction bids, free NFT transfers or card-for-card trades. Failed/expired/refunded orders do not produce successful payout accounting. There is **no finalized live processor/network-fee or refund-cost policy**; one must be disclosed before real payments are enabled. The test numbers are not a promise about future all-in costs. - -## What if something goes wrong? - -| Situation | Current behavior | What is not guaranteed | -| --- | --- | --- | -| Buyer does not pay | Reservation or winner payment window expires; eligible fixed listings reopen; auctions expire. | Funded bid enforcement, penalties or a guaranteed replacement buyer. | -| Seller transfers the card elsewhere | Ownership/sequence/pending checks can invalidate the listing or put a paid order into `refund-required`. | An on-chain card lock, automatic replacement or compensation. | -| Seller does not deliver | After the delivery deadline, an order without an exact pending/canonical delivery may become `refund-required`. | A real cash refund or an operator response deadline. | -| Exact sale transfer is still pending | The deadline alone does not authorize a refund while that transfer remains pending. | A maximum mining time; network congestion/stalls can prolong the wait. | -| Buyer requests a mock refund | Only the buyer can act, and only in `refund-required`, with no pending card transaction and no exact canonical delivery. The journal records `refunded` and cancels the listing. | A real cryptocurrency refund, cancellation of existing signatures, or future delivery prevention. | -| Delivery is reorged after settlement | Hold for review; no automatic second payout. | Finality, rollback of payouts or loss insurance. | -| Host becomes unavailable | Local orders and reconciliation wait until the host recovers. The chain may keep operating elsewhere. | Order portability, replication to another market or a project-run recovery service. | -| Request times out | Outcome may be unknown; inspect refreshed activity before signing a new action. Replays of the same signed nonce return its stored result where applicable. | That timeout means failure, or that a new nonce cannot create a new action. | -| Wrong address or compromised key | Valid signatures can transfer cards; verify recipients and protect backups. | Chargebacks, key resets or reversal of confirmed ownership. | - -**A refund check cannot revoke a signed NFT transfer.** A delayed transaction can arrive later, including after a reorganization or a proposed refund. No automatic real-money refund mechanism solves that risk here. This is one reason live settlement remains disabled. - -There is currently **no built-in arbitration service, buyer insurance, chargeback system, guaranteed reimbursement, operator override/payment-recovery endpoint, or dispute-resolution SLA**. A `review` label means investigation is needed—not that a resolver or repayment fund exists. Do not infer those protections from words such as “market,” “confirmation” or “settlement.” - -## Future live custody is not active - -The intended direction is direct wallet/daemon observation into a pooled liability ledger; it does not depend on a third-party invoice processor. An opt-in mainnet operator workflow can stage observed outputs as pending and release them to a customer's available ledger balance after signed review. A separate opt-in API accepts signed withdrawal requests and reserves funds, but does not create or relay Monero transactions. Neither flow has a customer UI, and the address-enrollment API does not itself credit funds. No customer-facing deposit address should be issued until withdrawals, outgoing reconciliation, backup recovery, and custody policy are complete. No confirmation threshold, funded reserve, loss allocation, refund policy or dispute process has been approved. This document describes current behavior and does not promise buyer protection. - -## Privacy, evidence and host accountability - -Public listings reveal seller/highest-bidder wallet addresses, card IDs, prices and auction state. Participant activity requires a wallet signature through the application, but **the hosting operator and anyone with its database/backups can read orders, counterparties and amounts**. The host can also observe network requests. An eventual XMR payment would not make this application-level activity anonymous. Never send seed phrases, private keys, passwords or complete wallet backups to a market/support operator. - -The market uses signed actions, short request expiry, stored nonce receipts, serialized mutations and SQLite transactions to reduce unauthorized actions, accidental replay and inconsistent updates. These controls do not make the operator's database tamper-proof. Card ownership/transfer records are on-chain; order terms, payment simulation and payout markers are not blockchain-enforced. The browser still relies on its connected host to report them honestly. - -Before any future live market can earn trust, its operator must publish: - -- Operator identity/contact and which domain, network/chain and software release it runs; any local modifications and responsibility for the merchant wallet. -- Clear live-versus-test status, supported currency, exact quote/expiry and fee policy, payment-confirmation requirements and disclosure of custodial control. -- Seller delivery terms, payment/payout holds, network/processor costs, cancellation/late/partial/overpayment treatment, refund eligibility/costs and who bears reorg or processor losses. -- A workable dispute/reconciliation process with evidence requirements and actual response targets, including ambiguous payouts and partial success. Do not invent a support address or SLA before it exists. -- Privacy/retention policy, operational monitoring, consistent off-host backup/restore evidence, incident communication and independent security-review evidence. - -Those are **requirements for a future offering**, not assurances already fulfilled by this project or every peer. The project author fee does not make the author the guarantor, custodian or arbitrator of independently hosted markets. - -## A practical checklist for users - -1. Check the host's domain, chain and **mock/live status**. Today's release is mock-only: do not pay real XMR. -2. Review the exact card ID, counterparty, native-XMR price (or clearly labeled historical USD price), both fees, net proceeds and deadlines before signing. -3. Keep the order/listing ID, host origin, agreed terms, status and NFT transfer identity for your records. A screenshot or status label is not independently verified proof of payment; never include secret keys/passwords in evidence. -4. Use **Your activity** on the same market host to inspect progress. Confirmations refer to canonical inclusion, not a money guarantee. Refresh after a timeout; contact the actual host through a verified channel if one is published. -5. Sellers: never equate a simulated or buyer-supplied receipt with real payment. Buyers: do not assume an off-chain reservation locks the card or guarantees a refund. -6. Protect and test your [wallet backups](wallet-security.md). If you cannot accept the host/custody/reorg risks, do not transact with valuable assets. - -For operational details see [market rules](marketplace.md), [security findings](security-review.md) and [backup/recovery boundaries](operations-recovery.md). These disclosures describe the current implementation; future live features require an updated policy and explicit review before use. diff --git a/docs/marketplace.md b/docs/marketplace.md deleted file mode 100755 index b5c11e9..0000000 --- a/docs/marketplace.md +++ /dev/null @@ -1,94 +0,0 @@ -# Peer-hosted markets - -**Before buying or delivering:** read [Market payments, buyer protections and seller protections](market-transparency.md) for the complete flow, host/author fees, custody model, refund limits, privacy and dispute responsibilities. Today's payments are simulated; NFT delivery is real. The market is not trustless escrow and does not provide guaranteed reimbursement or arbitration. - -Every Wikideck peer can host its own market for cards on the shared NFT blockchain. A host can serve collectors broadly or build a community around a particular collection, theme or subject. Card ownership and confirmed transfers follow the shared chain; each host keeps its own listings, bids and order journal. Buyers and sellers must use the same host to complete a sale. - -The current market provides fixed-price sales and timed auctions. Running a collection-focused community is possible today, but built-in collection-specific admission, moderation and curation controls are not implemented. Peer-hosted custom trading card games are also planned, not available yet. Operators can adapt the source and presentation subject to licensing; the project still needs a published software license before open-source reuse rights can be claimed. See [project direction and licensing](../README.md#peer-ownership-and-customization). - -## Cryptocurrency payments and the two sale fees - -New marketplace terms use **native XMR prices** represented exactly in atomic units (12 decimal places). There is no USD conversion, exchange-rate field or live payment route. Historical version-2 USD orders remain in their original currency and signatures; USD listings are read-only until the seller closes them and signs a fresh XMR listing. Mining and payments are separate: RandomX secures Wikideck's own blockchain, while any future XMR settlement would use Monero. - -Each sale allocates a configurable hosting fee to the peer's market operator (default **2.5%**) plus a hardcoded **1% author fee** to the project author's fixed XMR address. Both are deducted from the seller's gross proceeds, not compounded or secretly added to the buyer price. At the default rates the seller keeps 96.5% before any future disclosed processor/network-fee policy. The host retains the hosting allocation; the author allocation is a separate payout. **All allocations and payouts are currently simulated.** - -## Current testnet sandbox - -Open **Marketplace** in the interface. New prices and checkout amounts are **XMR-native**; fees, refunds, and automatic seller payouts are simulated. Historical USD orders are explicitly labeled. There is no payable address, payment processor connection, or movement of real Monero in this release. No exchange rate is applied. **Signing delivery transfers the actual card on the selected chain**, even though payment is mocked. - -## Try a sale - -1. Mine enough blocks to own a mature NFT. Open Marketplace → **Sell a card**. -2. Choose a card, a fixed price or timed auction, and review the seller fee before signing. -3. In a different browser profile/wallet on the **same marketplace node**, buy the fixed-price listing, or bid and wait for the auction to close. -4. Open **Your activity** as the buyer, review the order, and choose **Simulate XMR payment**. -5. Return as the seller, review the buyer/card/proceeds, and **Sign and confirm** delivery. This creates an ordinary signed NFT transfer; mine it into the canonical chain. -6. After the order's required confirmation depth, reconciliation records automatic **mock-paid** seller and additional-fee payouts. The configured market fee is retained in the simulated accounting. No operator payout click is needed. - -Wallet backup/recovery remains local; never send private keys or backup passwords to the node. A Wikideck NFT wallet is not a Monero wallet. - -## Sale rules - -- **Fixed price:** stays listed until sold, cancelled, or invalidated by changing ownership/sequence. Checkout exclusively reserves it for 30 minutes; an unpaid reservation expires and the listing reopens if still valid. -- **Auction:** one minute to 30 days in the sandbox. Bids must meet the start price and exceed the highest bid by at least one atomic unit. Bids are signed but **unfunded**. Sellers cannot bid on their own auction or cancel after its first bid. This does not prevent alternate-wallet shill bidding or nonpayment. Live auctions remain a separate unreleased gate. -- No bids are accepted at or after the deadline. If there are bids, the listing remains available only for winner checkout until **30 minutes after auction close**, not 30 minutes after checkout. With no bids it expires at closing. An unpaid winner causes expiry; there is no automatic runner-up sale. The seller may relist. -- Seller delivery is due **48 hours after mock payment**. Failed delivery can enter **refund-required**. The buyer can simulate a full refund only if the sale transfer is neither pending nor canonical. Pending/late/ambiguous delivery requires review, not an automatic refund that might also release the card. -- Listings are **not consensus escrow**: they do not lock cards on-chain. A seller can transfer or trade elsewhere; availability is rechecked when listing, reserving, paying, and reconciling delivery. Payment and NFT delivery are **not an atomic cross-chain exchange**. -- Fee basis points are fixed when the seller lists and explicitly reviewed at checkout. The XMR quote, fee, net, and delivery-confirmation requirement are pinned to the order. Changing configuration does not reprice an existing order. -- Settlement matches the **exact canonical transfer identity**, not merely the buyer's current holdings. Unconfirmed submission never counts as delivery. A buyer may transfer onward after delivery without confusing its identity. -- If a reorganization affects an already settled order, it enters **review** permanently and does not auto-pay again. The operator must investigate; this sandbox has no administrative recovery/payment override endpoint. - -New sale prices use canonical atomic-unit strings and integer arithmetic; JSON/signature payloads never use floating-point XMR. Each fee rounds down independently; seller net is gross minus both allocations. No economic listing minimum is imposed. Historical USD records retain original cents and are never converted or repriced. - -## Configuration - -| Variable | Default | Meaning | -| --- | --- | --- | -| `WIKIDECK_PAYMENT_MODE` | `mock` | Only accepted value. Other values fail startup, including on the mainnet candidate. | -| `WIKIDECK_MARKET_FEE_BPS` | `250` | Seller fee: 250 basis points = 2.5%; integer 0–5000. | -| `WIKIDECK_MARKET_CONFIRMATIONS` | `10` | Canonical delivery confirmations before simulated payout; integer 2–1000. No finite depth guarantees PoW finality. | - -The single-node and three-node testnet Compose configurations pass these hosting-fee and delivery-confirmation settings. Choose one market host and use its origin consistently. Node marketplaces have different identities and journals: **listing, bid, checkout, and order state are not gossiped**. NFT transfers/blocks are. Buyers and sellers must use the same market host. - -New listings, bids, and checkout store native XMR atomic-unit amounts (12 decimal places); they are not derived from fiat. Historical v2 records retain USD cents and their originally snapshotted mock XMR amounts. The application bound on newly entered atomic gross is documented in the [amount domain specification](xmr-domain.md); it is not a wallet-balance or exposure approval. - -## Hardcoded 1% project author fee - -Every new marketplace sale (fixed price or winning auction) includes a **hardcoded 1% project author fee**, on top of the configurable fee for the hosting peer's operator. Both fees are calculated independently on gross sale proceeds, not compounded. At the default 2.5% hosting fee the combined deduction is **3.5%**. Setting the hosting fee to zero does **not** disable the author fee. - -The fixed public XMR payout recipient is: - -44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv - -This is a payout destination, **not a buyer checkout address**. Do not send real XMR to it to pay for a sandbox order. No real transfer is submitted; the journal only records a simulated fee allocation and payout after canonical NFT delivery. Both seller and additional-fee payouts enter review if a settled delivery is reorganized. Refunded or expired sales do not pay either recipient. The fee applies to marketplace sales, not free NFT transfers, card-for-card trades, bids or listing creation, which have no XMR sale proceeds. - -The additional fee is rounded down to an atomic unit separately from the hosting fee. Seller net is exactly gross minus **both** fees. For example, a 0.1 XMR sale with 2.5% hosting allocates 0.0025 XMR hosting fee, 0.001 XMR additional fee, and 0.0965 XMR seller net. Rounding below one atomic unit produces zero rather than increasing the charge above 1%. - -The recipient and percentage are snapshotted into each new order and disclosed in the UI. They are bound to v3 listing/checkout signatures and are neither environment-configurable nor request-overridable. **Existing reserved/paid/completed v2 orders are not retroactively repriced.** Open USD listings remain read-only until eligible sellers close them and relist with fresh v3 XMR authorization; historical bids are not converted. The v2 domain remains for compatibility with historical orders and receipts; new USD listings, bids, and checkout are rejected. - -## API and storage - -- `GET /api/market?offset=0` returns up to 24 public listings, pagination, server clock, fee policy, and market identity. -- `POST /api/market/command` accepts strict, signed commands. New XMR terms use `market-command-v3`; historical `market-command-v2` is retained only to service existing orders and eligible seller cancellation. Actions include `list`, `bid`, `buy`, `cancel`, `retire-legacy-listing`, `mock-pay`, `deliver`, `refund`, and `activity` under their allowed version. -- Every command binds the network, chain ID, **market instance ID**, actor/public key, UUID nonce, short expiry, action, and all versioned terms. New prices sign the currency and canonical atomic XMR gross. Private activity requires a wallet signature and includes only orders involving that actor. Signed requests expire within five minutes; keep clocks synchronized. -- Nonce receipts are persisted and replay returns the same result. A different command cannot reuse that nonce. Refresh activity to obtain the latest order state rather than interpreting an old receipt as current state. -- Public listings reveal seller/highest bidder wallet addresses and card holdings. Orders reveal counterparties/amounts only to their participants, but the **market operator and its database backups can see all orders**. This is not private Monero trading. -- The marketplace journal is stored alongside the ledger in the selected network subdirectory, uses SQLite/WAL and full synchronous commits, and binds its metadata to the active chain. Mutations are serialized. There is one active local listing per card, at most 100 active listings per seller, and 10,000 total listings/orders per journal. Reaching a journal cap fails closed; operator archival work is required, not deleting active records. -- HTTP rate limits and bounded mutation queues apply. Read/reconciliation work still scans the bounded journal and chain; this is not a high-volume production market. - -## Recovery and operations - -Back up the ledger, marketplace SQLite databases and their WAL state consistently, plus the independent wallet backups. Prefer a clean node shutdown before copying the data directory; do not copy a live SQLite file without SQLite's backup facilities. Do not delete the journal to clear a stuck order: this loses reservations, invoice identities and idempotency history. - -The service writes delivery intent before submitting to the ledger. A crash between these steps is reconciled by deterministic transfer ID; the seller can resubmit the same delivery if still valid. Background reconciliation runs every 15 seconds, and marketplace commands reconcile before mutation. A closed server does not send payouts or advance its local journal until restarted; deadline enforcement still uses absolute timestamps. - -Canonical and pending sale delivery is recognized by the agreed seller/buyer/card/sequence even if submitted through the normal transfer endpoint or peer gossip rather than the marketplace command. Direct delivery without a recorded mock payment enters review instead of paying the seller. A refund check cannot cancel a previously signed transfer that arrives later or after a reorganization; this is a [production settlement blocker](security-review.md), not a solved atomic-exchange guarantee. - -For **review**, preserve backups and inspect canonical blocks, pending transfers, signed order terms and transfer ID. Do not clear a payout marker, auto-refund, or blindly resubmit a payout. This release intentionally has no real-fund recovery controls. - -## Future live XMR custody - -The selected direction is a pooled custodial ledger, not per-sale processor invoices. Real deposits would be assigned to durable Monero subaddresses and reconciled against wallet-owned outputs; purchases would transfer liabilities internally. Withdrawals would require a separate, policy-constrained treasury signer and durable reconciliation. A mainnet-only address-enrollment API now exists behind `WIKIDECK_MONERO_DEPOSIT_ENROLLMENT=1`, but it is off by default and has no customer UI; its response explicitly says the address is not credited. There is still no customer credit worker, withdrawal signer, or paid-sale settlement path. **Do not enable the enrollment API or publish its addresses until customers can be credited, withdraw, and recover funds end-to-end.** -The selected direction is a pooled custodial ledger, not per-sale processor invoices. Deposits are associated with durable Monero subaddresses and an operator-reviewed observation can now be staged as a pending liability, then separately released to a customer's available balance. A separate mainnet-only API can accept signed withdrawal requests, reserve the exact amount plus maximum fee from that balance, and let the customer cancel before signer handoff. Both mainnet paths require dedicated PostgreSQL roles and explicit startup/operator configuration. **They are not a complete payment system:** the current withdrawal endpoint only reserves liabilities; there is no transaction construction, signer, relay, outgoing-chain confirmation or recovery process, and no customer UI. Deposits and withdrawals remain off by default. Do not publish addresses or solicit funds until the XMR send and recovery lifecycle is proven. - -Do not send real XMR to this sandbox. Live activation requires an end-to-end reviewed implementation, funded-liability and custody policies, production wallet/daemon isolation, withdrawal/reorg recovery, backups and operations evidence. No payment-mode setting is an activation path. diff --git a/docs/node-resources.md b/docs/node-resources.md index a151018..eb91f78 100755 --- a/docs/node-resources.md +++ b/docs/node-resources.md @@ -1,6 +1,6 @@ # Node resource controls — stage 1 -This stage bounds public RPC transport/admission and browser state loading without changing consensus, chain identity, launch approvals or the mock-only payment boundary. **It does not make the node production-ready.** +This stage bounds public RPC transport/admission and browser state loading without changing consensus, chain identity, or launch approvals. **It does not make the node production-ready.** ## Admission and transport @@ -34,18 +34,12 @@ The browser and trade signing refresh use bounded streaming response reads (2 Mi The existing snapshot-based UI still assembles complete ownership history, capped at **10,000 blocks or 64 MiB of encoded history** with a 30-second fetch deadline. If exceeded, it fails explicitly rather than presenting partial ownership as complete. That is a client resource guard, not a solution for an archival-sized wallet/explorer. Full state objects and parsed JS memory cost more than encoded JSON. A truly indexed wallet and fully paged explorer remain required before closing SEC-01. An unchanged refresh requests two summaries and no block pages; append loading verifies the cached prefix and requests only new blocks. -## Marketplace read improvements - -Public listing pages query 25 indexed matching rows for a 24-row page plus next-page detection instead of loading the entire listing table. Actor activity uses indexed seller/buyer expressions. Browse/activity reconciliation results are reused for at most one second while the production ledger tip/revision are unchanged. Any observed ledger revision invalidates reuse. Every state-changing market command still reconciles freshly before validating terms; no cached eligibility authorizes a sale. - -Canonical transaction identities are indexed once per reconciliation pass rather than searched through the full chain for each order. Unchanged rows are not rewritten to SQLite. These reduce repeated work, but **reconciliation still clones/scans canonical history and all journal rows**, ownership checks still scan history, private activity is not paginated, and receipt/journal lifecycle needs further work. The response cap can reject an actor's oversized activity response; it is not silently truncated. These limitations keep SEC-06 open. - ## Tests and safe load tooling Final validation: **86 tests passed, 0 failed, 0 skipped** in the Node 24 Docker test image, including real RandomX HTTP integration. TypeScript/Vite build passed. The suite includes deterministic token refill/write/active/byte limits; a held-response 40-request loopback burst (4 admitted, 36 shed); health/P2P-lane availability during API saturation; canonical paging defensive copies/tip conflicts/legacy guards; client append caching, malformed pages/continuity, bounded history, single-flight loading and response size checks; non-recursive oversized-response errors; and paging against the real RandomX HTTP fixture. -`test:rpc-load` invokes the read-only [local smoke-load script](../scripts/rpc-load.ts). It requires `--consent`, accepts only **literal loopback IP origins** (not remote hosts or DNS names), at most 32 workers and 1,000 total requests. Options: `--node`, `--concurrency`, `--requests`. It reads summary/market endpoints and checks health, with response/time bounds. It never submits a block, bid, payment or transfer. Use only a disposable isolated node: even read traffic consumes CPU/IO. Output includes status counts, bytes and p50/p95 latency; rejected load is visible, not counted as successful capacity. +`test:rpc-load` invokes the read-only [local smoke-load script](../scripts/rpc-load.ts). It requires `--consent`, accepts only **literal loopback IP origins** (not remote hosts or DNS names), at most 32 workers and 1,000 total requests. Options: `--node`, `--concurrency`, `--requests`. It reads summary and canonical-chain endpoints and checks health, with response/time bounds. It never submits a block or transaction. Use only a disposable isolated node: even read traffic consumes CPU/IO. Output includes status counts, bytes and p50/p95 latency; rejected load is visible, not counted as successful capacity. -Initial smoke measurement on a disposable **empty-chain/empty-market**, loopback-only Docker node limited to **1 CPU / 1 GiB**: 100 read requests, 8 workers, 100 HTTP 200s, 40,348 JSON bytes, 1,837 ms total, p50 99 ms, p95 463 ms, final health HTTP 200. This is a single local run, not a performance guarantee; it does not measure hashing load, a populated journal, long-chain costs or hostile Internet traffic. The deterministic 40-request held-response test separately verifies overload shedding (4 admitted, 36 rejected) and peer/health-lane availability. Final test totals are recorded in the completion report. +Initial smoke measurements on a disposable empty-chain loopback Docker node are historical local baselines, not performance guarantees. They do not measure hashing load, long-chain costs, or hostile Internet traffic. The deterministic held-response test separately verifies overload shedding and peer/health-lane availability. Final test totals must be rerun against the selected release artifact. Neither measurement establishes that a realistic long-chain public deployment meets its latency/throughput SLO. Production-scale chain/journal fixtures, slow clients, adversarial payloads, sustained concurrency, disk pressure and multi-host recovery measurements remain outstanding. No launch-readiness finding is marked resolved by this stage. diff --git a/docs/operations-recovery.md b/docs/operations-recovery.md index cca99ee..9c665ba 100755 --- a/docs/operations-recovery.md +++ b/docs/operations-recovery.md @@ -1,90 +1,42 @@ -# Deployment, private monitoring and recovery — stage 4 +# Deployment, private monitoring, and recovery -**Production remains NO-GO.** This stage provides tools and a local recovery rehearsal, not an installed public edge, configured alerts or independent operational sign-off. No mainnet approval, node launch, real payment, production backup/restore or existing data modification was performed. Tools are available on Node 24 and included in the runtime image where noted. +**Production remains NO-GO.** These procedures are operator preparation, not an installed public edge, configured alerts, independent review, or deployment approval. Do not remove persistent node volumes as part of source cleanup. -## Explicit reverse-proxy trust +## Reverse-proxy trust -`WIKIDECK_TRUSTED_PROXIES` is empty/untrusted by default. It accepts up to 16 comma-separated **literal IPs or CIDRs** (IPv4 prefix 8–32; IPv6 32–128). It rejects booleans, hop counts, names and universal /0 ranges. Set only the actual source address/subnet of the trusted edge connection. A large permitted subnet can still be unsafe: do not include unrelated tenants/containers. The candidate Compose file forwards the setting but leaves it empty. +`WIKIDECK_TRUSTED_PROXIES` is empty/untrusted by default. It accepts up to 16 comma-separated literal IPs or bounded CIDRs. Configure only the actual source address of a trusted edge connection. The edge must overwrite forwarded headers, and firewall rules must prevent direct backend access. A trusted-proxy setting does not configure the firewall. -The [nginx example](../deploy/nginx.conf.example) is **not installed or deployment-validated**. It illustrates TLS, request/body/connection/time limits and overwriting `X-Forwarded-For` with the actual remote client address rather than appending attacker input. Replace the reserved invalid hostname and certificate paths; review TLS/HSTS/ciphers, renewals, compression, static caching and per-IP/global admission for your edge. Validate nginx configuration on the intended version with real certificates before use. No actual TLS/certificate test was performed here. - -**Topology matters:** nginx on the host forwarding to a container's published loopback port may appear inside the container as a Docker gateway, not `127.0.0.1`. Determine the source and use a reviewed dedicated network; do not blindly copy a loopback trust list. Firewall direct backend access. Anyone who can connect from a trusted source can forge forwarded IPs unless the edge overwrites headers and isolates the backend. Application trust settings do not configure the firewall. Existing global RPC budgets remain independent of forwarded client IPs. +The [nginx example](../deploy/nginx.conf.example) is not installed or deployment-validated. Replace its reserved hostname and certificate paths, and validate TLS, renewal, forwarded-IP handling, body/connection/time limits, and edge admission controls on the intended host. Container proxy source addresses may differ from host loopback; inspect the actual topology instead of copying a trust list blindly. ## Private diagnostics -Set `WIKIDECK_OPS_PORT` to an unused integer port (1–65535) to enable a **separate HTTP listener bound to `127.0.0.1` only**. Unset/empty disables it. Do not reuse the main RPC port. The default/candidate Compose definitions do not enable or publish this port. For container monitoring, execute the checker inside that container/network namespace; publishing a container port does not make a loopback listener safely accessible. +Set `WIKIDECK_OPS_PORT` to an unused port (1–65535) to enable a separate HTTP listener bound to `127.0.0.1`; leave it unset to disable it. Never publish or reverse-proxy this port. Only `GET /status` is supported. It returns bounded, no-store aggregate JSON; any local process with loopback access can read it. -Only `GET /status` is supported. Responses are no-store JSON capped at 64 KiB; connection count/timeouts are bounded. No password/token is required because access is confined to the local-process trust boundary. Therefore **all users/processes with loopback access can read it**; it is not suitable for an untrusted multi-user host without additional isolation. Never reverse proxy, tunnel broadly, add it to public routing, or publish it to the Internet. The listener has no administrative/mutation operations. +Reported diagnostics include node uptime/RSS, event-loop delay, canonical height and tip age, DAG and pending counts, native-engine status, configured/connected peers, RPC budgets, and available disk plus ledger database/WAL sizes. Native subprocess memory is not included in Node RSS. Peer count is not a security score. Health is liveness, not sync readiness or consensus assurance. -Reported aggregates: +`ops:check -- --port ` reads only literal loopback with bounded time and response size. It emits warnings for stale/empty chain, peer isolation, native-engine failure, queue pressure, event-loop delay, and unavailable/low disk. Thresholds are starting points, not production SLOs. Route alerts to an external system and test delivery; this repository does not install a scheduler or alert destination. -- Node uptime and Node-process RSS; **RSS excludes the RandomX subprocess**, so monitor container/cgroup/host totals externally. -- Event-loop mean/max since listener startup (not rolling percentiles). -- Canonical height, DAG block count, pending transactions, ledger mutation queue, process-local reorg count, tip timestamp/age. Tip age is miner-timestamp-based, not proof of local receipt freshness or synchronization. -- Native subprocess failure/active/queue signals. `available` means no recorded failure/exit; it is **not** a startup known-vector test or proof of successful current hashing. -- Configured/connected peer counts, API/P2P active/rejected counts. Peer count is connectivity, not independently administered hashpower or consensus security. Counters reset on restart. -- Market mutation queue, last successful reconciliation timestamp and counts grouped by order state. No addresses, order IDs, balances or signed transactions are included. Diagnostics still reveal operational activity and must remain private. -- Disk bytes available and ledger/market DB/WAL sizes, sampled asynchronously every 30 seconds. Missing sample/failure is explicit; absence of `disk` is not healthy. First startup may precede the first successful sample. Sampling does not impose a quota or prevent exhaustion. +## Stopped-node ledger backup -`ops:check -- --port ` reads only literal local loopback with a five-second deadline and 64 KiB streaming cap, emitting JSON plus a nonzero exit for warning conditions. No remote target or credentials are accepted. Built-in **starting-point** warning thresholds: observation clock/freshness >60s; no peers; empty/stale chain (>1h by tip timestamp); native unavailable; queue pressure (native/market >=48 or ledger >=192); mean event-loop delay >250ms; stale market reconciliation >60s; `review`/`refund-required` orders; disk sample >90s old/unavailable or <512 MiB free. A fresh empty isolated testnet correctly warns. These thresholds are not production SLOs and must be reviewed against real workload/chain timing. +`node:backup` supports `create`, `verify`, and `restore`. New version-2 packages contain only the selected network's ledger database. Stop the node cleanly first; the tool takes the same exclusive network lock as startup, uses SQLite `VACUUM INTO`, checks SQLite integrity and chain metadata, hashes the snapshot, and writes the manifest last. It refuses to overwrite a destination, restore in place, or run when a node lock exists. Restore targets must not exist. -Run the checker with your restricted local scheduler/monitoring agent and route warning codes to the **actual on-call system**. This repository does not create an alert account, install a scheduler or verify delivery. Externally monitor cumulative counter changes (reorgs/rejections), process/container totals, disk growth rate, peer/tip freshness, native failures and checker availability itself. A failed/unreachable operations listener must alert, not silently pass. Keep `/healthz` as liveness only; HTTP 200 is not launch/readiness evidence. +Example workflow, using the `node:backup` package command with explicit `--source`, `--destination`, and `--consent` as required by the CLI: -## Stopped-node pair backup +1. Stop the node and confirm all writers to its data directory are stopped. +2. Create the backup in a new directory outside the node data directory. Keep wallet backups separately; this tool does not include browser wallets, secrets, configuration, binaries, or logs. +3. Run `verify` against the backup before copying it off-host. +4. Restore only to a new isolated data directory. The tool reports `proofsRevalidated: false`; reopen with the trusted RandomX implementation and verify the chain before resuming service. -The `node:backup` script supports `create`, `verify`, `restore`. It deliberately does **not** offer online pair backup or overwrite/force/reset. Choose `WIKIDECK_NETWORK` explicitly to match the data; it validates the derived chain/network identity without enabling launch. The runtime includes the script and server helpers; it does not require dev dependencies. +Backups are not encrypted or authenticated by this tool. Store them in restricted, encrypted off-host storage, protect the manifest separately, and test cold restoration. Hashes detect accidental corruption, not a coherent malicious replacement. Filesystem, power-loss, Windows ACL, and network-share durability need validation on the actual storage platform. -### Create +## Retiring legacy financial data safely -1. Stop the node cleanly; verify all processes/writers using that data are stopped. Do not run `Ledger`/`Market` library writers directly while maintenance is active. Do not rely solely on a lock file for an arbitrary unmanaged process. -2. Invoke `node:backup` with action `create`, explicit `--source `, **new** `--destination ` and `--consent`. Source is the parent of the selected testnet/mainnet folder, not the individual SQLite file. Destination must be outside that data directory and must not exist. Its parent must already exist. -3. The tool acquires **the same exclusive network node lock** as normal startup. Any lock, even stale, refuses maintenance. The tool never deletes a preexisting lock. Inspect stale ownership/process status manually before separately authorized removal. -4. Validate SQLite quick checks and network/market metadata, then use SQLite `VACUUM INTO` for **both ledger and market** to produce standalone snapshot files (including committed source WAL contents, not a naive live-file copy). A symlinked network directory or database file is refused. Pair consistency relies on all writers staying stopped for the entire operation. -5. Database copies are checked, hashed with SHA-256 and flushed. A strict versioned manifest records names/sizes/hashes/network/profile/chain and creation time. The manifest completion marker is written/flushed **last**. POSIX directory entries are flushed; Windows/remote filesystem and storage-controller power-loss behavior requires real environment validation. Partial output is deliberately retained for investigation, not automatically trusted or deleted. +Removing market/custody source code does **not** delete or resolve data left by an older deployment. The card-only node does not open or reconcile those files. If the selected network directory contains `market.sqlite`, `custody-sandbox.sqlite`, or a `custody-postgres.json` binding, new ledger-only backup creation refuses to proceed rather than silently omit those records. Preserve the existing volume and any PostgreSQL database; arrange an operator-reviewed archive before retiring it. -Only the two databases and manifest are included: **not** browser wallets, backup passwords, deployment configuration, secrets, certificates, peer settings, native binaries or logs. Keep each category independently backed up with versioned recovery instructions. Backups contain sensitive marketplace participant/order information and are **not encrypted by this tool**. Store in restricted, encrypted, access-audited off-host storage with tested retention; do not put them in Git/build contexts. Unix modes are restrictive where supported; Windows ACLs and network-share permissions must be enforced by the operator. +Historical version-1 paired backups containing `ledger.sqlite` and `market.sqlite` remain verifiable and restorable as opaque files for preservation. Restoration does not reactivate market services or imply that financial obligations have been resolved. External PostgreSQL data is not contained in a SQLite package. Do not delete old volumes, journals, database credentials, or backups as part of this source cleanup. -### Verify +An operator must choose retention, recovery point/time objectives, off-host storage, and restore ownership. Never remove a lock or replace a running volume just to make recovery succeed. -Action `verify` needs `--source ` only. It reads a bounded strict manifest, requires exactly one ledger and one market filename, checks regular files/sizes/hashes and refuses unexpected SQLite WAL/SHM/journal sidecars, then checks SQLite and metadata. It does not launch or initialize a new node. Verification opens SQLite read-only, but OS access timestamps and filesystem-specific read behavior are not a forensic immutability guarantee. +## Remaining operational evidence -**Hashes detect corruption, not authenticity:** an attacker able to replace both manifest and files can recompute hashes. Sign/protect the backup package externally, keep trusted manifests off-host and restrict directory ownership. No wallet-control or signature proof is inferred from a checksum. This tool assumes trusted local filesystem administration; it does not defend against a privileged concurrent file-replacement attacker or hostile mounted storage. - -### Restore to a new isolated directory - -1. Action `restore` requires explicit `--source `, a **nonexistent** `--destination `, and `--consent`. Existing destinations are refused even when empty. There is no in-place reset or deletion. -2. Verify the package before creating output, copy only fixed database names exclusively, recheck hashes/SQLite metadata and flush. No stale lock, user secrets or deployment approval variables are restored. A partial failed restore is retained; do not try to start it or merge it into a working node. -3. The tool reports `proofsRevalidated: false`: database/hash verification is not chain verification. Reopen/replay with the trusted reference RandomX release in an **isolated reviewed rehearsal** before declaring it usable. Preserve market instance identity, orders, fees, payout markers and nonce receipts together. Compare the expected tip/work/ownership/order state against the trusted recovery point. -4. Reconcile later chain events, payment state and any external processors before any future resumption of valuable activity. Restoring stale databases does not roll back on-chain transfers or real payouts. Live payments remain disabled and have no reviewed disaster recovery process in this release. - -An operator must choose recovery point/retention/RPO/RTO, practice off-host recovery and approve any production restore explicitly. Never remove a lock or replace a running volume just to make a recovery test pass. - -## Coordinated PostgreSQL + node custody backup rehearsal - -The prior file-only `node:backup` still refuses PostgreSQL-bound node directories. A separate explicit [paired backup helper](../compose.custody-backup.yaml) and [utility](../server/coordinated-backup.ts) now create a small-sandbox rehearsal package containing the SQLite ledger/market pair and a custom-format PostgreSQL database dump. Creation holds the exact node-directory lock, then a dedicated PostgreSQL session advisory writer lock, checks the local `custody-postgres.json` binding against the database custodian identity, verifies any retained SQLite cutover source is frozen and its digest matches PostgreSQL's `import_source`, audits PostgreSQL liabilities under the lock, snapshots the active node files and runs `pg_dump`. A frozen SQLite source is deliberately omitted from the active backup pair; PostgreSQL is authoritative after cutover. The pair manifest is written last and binds network/chain/profile/custodian ID, the node manifest digest, PostgreSQL database name and dump size/hash. No restore/reset operation is included. - -The PostgreSQL dump uses a **separate read-only `custody_backup` login**, not the app runtime or schema owner. The local Compose provisioner now creates its credential volume, the one-shot bootstrap grants only database connect, schema usage, table SELECT and sequence SELECT, and the database HBA permits only TLS/SCRAM for this role. Existing local v1 secret volumes get an additive backup-role secret upgrade without rotating database credentials. This changes the next explicit persistent-stack provision/migrate cycle; no running stack was restarted here. The helper image has `pg_dump` and is a one-shot maintenance container; those tools and backup credentials are not added to the long-running app image. The disposable integration test checks that the role can dump but cannot update balances, delete entries or advance journal sequences. A cold test restore also replays the paired node SQLite database through the native RandomX verifier and checks tip/NFT ownership; the test-only fake-PoW fixtures are not used for that proof-revalidation assertion. - -### Local sandbox invocation - -This procedure is **not yet a production backup runbook**. Use only after reviewing the exact Compose project/volume names and destination permissions: - -1. Stop only the app service and verify no other node/application process writes the same data directory. Leave the PostgreSQL service running. Do not stop/restart or remove any user deployment as part of development tests. -2. With the matching stopped-node directory, run the one-shot provision and migration services under an approved maintenance window so older v1 local secret volumes receive the additive read-only backup credential and database role. Verify existing passwords, CA and custodian ID were preserved; do not regenerate volumes. -3. Set `WIKIDECK_BACKUP_DIR` to an **existing, restricted** host directory on encrypted storage and `WIKIDECK_BACKUP_NAME` to a new leaf name. Run `docker compose -p wikideck-custody-local -f compose.custody-backup.yaml run --rm backup`. The helper requires an existing destination parent and refuses overwrite. The node lock blocks startup during the entire paired snapshot. A PostgreSQL session lock fences all compliant Wikideck custody writers; privileged/manual DB writers must also be operationally fenced. -4. Run the helper with `verify --source /backups/` (overriding the service command) to check the strict pair manifest, exact node/root directory contents, hashes, recorded server/client versions, and that a compatible `pg_restore --list` parses the custom archive and includes required custody accounting tables. This is structural/content-presence validation, not a full restore or authenticity proof. Separately rehearse restoration into a new isolated PostgreSQL database; never point `pg_restore` at the authoritative database. Use matching or newer PostgreSQL client tools; this helper refuses a restore client older than the archived dump utility. -5. Store an encrypted off-host copy with separately protected authenticity metadata. This package is **not encrypted** by the utility, and colocating it with the source host does not satisfy disaster recovery. - -Creation holds locks and runs a full financial audit; a large journal may exceed configured query/audit budgets and fail closed, so this needs measured long-history tests before production use. A database superuser/schema owner can bypass application advisory-lock discipline or rewrite records; external writer fencing remains mandatory. Hashes and archive parsing detect accidental damage, not a malicious coherent replacement; the manifest is unsigned. The `pg_dump` subprocess uses only a whitelisted connection environment, so inherited `PGHOSTADDR` or TLS overrides cannot redirect it away from the audited/writer-fenced endpoint. The PostgreSQL dump is not atomic with arbitrary unmanaged ledger or schema-owner activity. There is no WAL/PITR, remote scheduler, retention, restore orchestration, RPO/RTO claim, encryption, signature or production failover process in this increment. The local Compose backup service mounts the external host destination read-write into a container with a stopped-node source lock; review Docker volume access and the helper image before using it around any valuable data. - -## Evidence and remaining hold points - -Automated tests cover default proxy distrust/spoofed-header rejection, explicit loopback proxy recognition, invalid trust/port settings, private listener and threshold checks, paired backup/restore identity preservation, refusal to overwrite/ignore locks/back up within data, and rejection of tampering, wrong network, incomplete packages and unexpected sidecars. - -The native HTTP integration now cleanly stops its disposable nodes, backs up their **real three-block RandomX chain and market journal**, restores to a fresh disposable directory, opens/revalidates copied proofs with the actual reference engine, and checks canonical ownership, market instance, order buyer and nonce receipt history. It then restarts the original isolated fixture. This is a local automated drill, not an off-host operational recovery or crash/power-loss certification. - -**Coordinated custody recovery evidence (2026-10-20):** the latest build passed 172 offline regressions and 31 isolated PostgreSQL integration tests. The paired-backup test mines a valid native-RandomX source block, writes fictitious PostgreSQL custody state, freezes/binds the simulated SQLite cutover source, creates and verifies the pair, restores PostgreSQL into a scratch database, and restores/replays the node database into a new directory with the reference RandomX engine. It verifies backup-login restrictions, mismatched identity/cutover detection, stale node-lock refusal, failed `pg_dump` cleanup/writer-lock release, output overwrite refusal, incomplete package handling, exact directory contents, rejection of a valid unrelated empty-database archive and a corrupt dump even when its unsigned manifest hash is recomputed. A hostile inherited `PGHOSTADDR`/TLS environment also fails to redirect the dump child. Tests use tmpfs/internal trust-auth databases and synthetic local files; this does not demonstrate protected persistent-volume provisioning, host-backed destination ACLs, off-host encryption or production operational recovery. The persistent sandbox was not restarted or migrated for this work. - -Final Node 24 TypeScript/Vite and production runtime builds passed. The suite passed **98 tests, 0 failed, 0 skipped**. A separate packaged-runtime smoke drill ran with no external network, read-only root, dropped capabilities and a disposable 64 MiB tmpfs: monitoring correctly returned nonzero for isolation, empty chain, not-yet-reconciled market and low disk; clean stopped-node backup, verification and new-directory restore each exited zero. That empty-chain CLI drill reported proof revalidation as false, as designed; nonempty real-proof replay is covered by the integration drill above. No host volume or production wallet was used. - -SEC-07/SEC-09 are only partially addressed: actual edge/source-IP/TLS checks, a production monitoring agent and verified alert delivery, off-host backup storage, long-history restore timing, filesystem/power-loss testing, independent peer/miner topology and named operators remain unverified. See [operator launch register](operator-launch.md) and [security review](security-review.md). No startup or payment approval is granted by these tools. +Verify the intended HTTPS/proxy/firewall topology, peer and tip freshness, alert delivery, disk growth, backup encryption/retention, and restore/replay time on the actual host. Assign an incident responder and document stop/rollback criteria. See the [card-only production plan](card-only-production-plan.md), [operator launch register](operator-launch.md), and [security review](security-review.md). No startup or payment approval is granted by these tools. diff --git a/docs/operator-launch.md b/docs/operator-launch.md index de37e51..9936e1f 100755 --- a/docs/operator-launch.md +++ b/docs/operator-launch.md @@ -1,78 +1,60 @@ # Operator launch preparation and hold points -The [production delivery plan](production-plan.md) defines the next engineering milestones, acceptance criteria and operator decisions. Its XMR-native pricing direction supersedes earlier production USD-price plans; existing sandbox behavior and historical order terms remain unchanged until an integrated versioned migration. Trustless settlement is a research gate, and any host-custodial fallback requires explicit approval. +The [card-only production readiness plan](card-only-production-plan.md) defines the product scope and release gates. The supported product is mining, collecting, and direct card-for-card trades. Market, custody, and payment source has been removed. **Current decision: HOLD / NO-GO for mainnet, valuable assets, or public exposure until the relevant evidence below is reviewed.** -**Current decision: HOLD / NO-GO for mainnet, valuable assets, or payments.** The production plan now distinguishes a proposed non-monetary testnet beta hosted from the operator's apartment. That beta would not be mainnet production or permission to custody funds. Review [the security findings](security-review.md) before any public exposure. The automated preflight intentionally exits nonzero for mainnet while required blockers remain. An environment variable, successful container startup or genesis timestamp cannot substitute for closing findings. +A successful build, local container startup, peer URL count, genesis timestamp, or configured approval flag is not production evidence. The read-only launch preflight reports local blockers; it is not an independent audit or approval. -[Stage-1 node resource controls and local load tooling](node-resources.md) are implemented. This is partial remediation, not capacity certification or closure of the public-node hold point. +## Safe inspection -[Stage-2 encrypted wallet storage/lifecycle](wallet-security.md) is implemented. Require explicit legacy migration, verified portable recovery, closure of old tabs and review of residual unlocked-page risks before considering the production wallet hold point satisfied. Local implementation/tests are not independent sign-off. +Use Node.js 24 and a clean, reviewed checkout. For mainnet assessment, select `WIKIDECK_NETWORK=mainnet` and run `launch:preflight`. Use `--network-manifest` for chain identity inspection. These commands do not start a node, create a ledger, start RandomX, or contact peers. -## 1. Safe inspection — does not launch anything +Mainnet startup is gated by a chain-specific approval, a review acknowledgement, the candidate genesis time, HTTPS origin, and multiple configured HTTPS peers. These are accident-prevention checks only; they do not prove that review occurred, peers are online/independent, or a network is secure. Keep deployment and release permissions restricted. -Use Node.js 24 and a clean reviewed checkout. For mainnet assessment on a host, select `WIKIDECK_NETWORK=mainnet` in the process environment, then run the `launch:preflight` package script. It runs the node entry point with `--launch-preflight`, prints JSON and exits. `--network-manifest` remains available for identity inspection only. +## Evidence required before a public non-monetary testnet beta -The preflight: +A beta is not mainnet production, valuable-asset use, or a claim of independent network security. Keep testnet explicitly selected, cards valueless, and the deployment described as experimental. Before public exposure: -- Selects the pinned profile and reports network, chain/genesis, fixed fee policy and local gate status. -- Does **not** open/create a ledger or wallet, start a RandomX engine/listener, contact peers or external payment services, or write approval settings. -- Does not print environment values or payment credentials. Reports are still operational information; review them before publishing. -- Lists code blockers and missing external evidence. `productionReady` remains false even if startup approval variables are supplied. Fixes and independently reviewed evidence must be reflected in a future reviewed release; do not turn the report green by deleting findings. +- Freeze the card-only scope, testnet identity, known risks, privacy and data-retention terms, user support/contact, abuse-report path, and stop conditions. +- Prefer managed hosting unless the intended host proves ISP permission/reachability, TLS renewal, trusted-proxy correctness, no direct backend exposure, firewall and admin controls, patching, and power/network resilience. +- Run repeated load, mining, peer-sync, reorganization, crash/restart, and restore tests on the exact release candidate and intended hardware. Set measurable latency, error, memory, CPU, disk, and recovery limits. +- Review wallet migration, lock/unlock, signing, backup, and fresh-profile recovery. Explain same-origin compromise and irreversible-transfer risks. +- Deploy external monitoring and verify alert delivery for peer/tip freshness, reorgs, native failures, overload, disk growth, and service availability. Name a responder and test incident/rollback procedures. +- Encrypt backups off-host and rehearse a cold restore/replay. Define recovery objectives and keep wallet backups separate from node backups. +- Freeze image/dependency/native versions, generate an SBOM, scan the final artifacts, and record exact release hashes and a beta go/no-go. Publish a project license before public distribution. -The runtime image can execute the entry point with `--launch-preflight` as well; it does not include the package-script dev runner or need `tsx`. A Docker `test` target is provided for Node 24 build/test validation with Linux-native RandomX. No production launch command is supplied here while the decision is NO-GO. +If these conditions cannot be verified on a self-hosted site, do not expose it publicly; use a managed host or remain local-only. -**Important:** the report is an assessment, not an additional enforcement layer. Existing startup approval checks are still accident-prevention gates; an operator editing source or setting all approvals can bypass the intended process. Keep release/deployment permissions restricted. +## Mainnet and valuable-card blockers -## 2. Evidence register — all required before production sign-off +Mainnet or valuable-card use requires the beta evidence plus independent protocol/application review, measured and reviewed consensus parameters, adversarial multi-host partition/reorg/hashpower testing, a credible bootstrap and independent-peer/miner plan, production capacity evidence, and explicit governance/upgrade and incident procedures. The current initial target is deliberately easy and the retarget is not production-calibrated. Nodes retain the DAG in memory and replay proofs at startup. There is no formal finality or automatic peer discovery. Keep mainnet **NO-GO** until these gaps are independently reviewed and signed off. -For the proposed **non-monetary testnet beta only**, use a separate scoped decision: the chain/network is explicitly testnet, no valuable cards are offered, real payment/custody settings remain off, mock market delivery is disabled or the deployment is demonstrably valueless, and experimental wallet/data-loss limitations are disclosed. This does not close any mainnet/live-payment hold point below. Apartment-hosting evidence must include ISP/CGNAT/reachability, TLS/proxy/backend isolation, firewall/admin/patch controls, off-host encrypted backup and restore, and external alert delivery. If these cannot be verified, do not expose the apartment host publicly; choose a managed host. +## Hold-point register -The real-money path is still not end-to-end. Mainnet can now be configured for operator-reviewed deposit staging/release and signed customer withdrawal reservations, but those paths remain opt-in and there is no external treasury signer, relay, outgoing transaction confirmation/reorg reconciliation, customer UI or complete recovery procedure. Do not provide addresses or accept funds until a customer can actually withdraw and that send is reconciled against the chain. - -[Stage-4 deployment and recovery tooling](operations-recovery.md) adds explicit proxy trust, private loopback diagnostics/checks and stopped-node pair backup/verification/non-overwriting restore. A local native proof-replay restore drill is automated, but actual-host TLS/proxy verification, off-host recovery, alert delivery and operational ownership still require external evidence. - -[Stage-3 consensus calibration evidence](consensus-calibration.md) contains a bounded local native measurement and deterministic timestamp/retarget scenarios. It is not the missing representative network-hashpower or independent consensus review evidence. Do not populate launch approvals from its illustrative targets. - -For each item record **owner, artifact/release ID, date, evidence location, reviewer and disposition**. Empty means unverified, not passed. +Record owner, exact release/manifest, evidence location and hash, test/rehearsal, reviewer, residual risk, and disposition. Empty or unverified means **not passed**. | Hold point | Required evidence | Current status | | --- | --- | --- | -| Independent protocol/application review | Report against the exact release; critical/high findings fixed and retested; reviewed accepted residual risks | Missing | -| Consensus launch design | Final genesis and manifest; measured starting target/hashpower; reviewed timestamp/retarget/partition/reorg attack analysis | Missing | -| Node availability | Bounded public APIs/client; global resource/admission controls; realistic long-chain and hostile traffic capacity report | Missing | -| Production wallet | At-rest/unlock/signing policy, same-origin compromise analysis, user confirmation and offline recovery drill | Missing | -| Real-money market, if offered | Direct wallet deposit observation and exact liability crediting, customer-authorized withdrawals, isolated treasury signing/relay, outgoing-chain reconciliation, custody policy, refund/reorg dispute controls | Partial reservation and reviewed-credit code only; no XMR send; keep real payments blocked | -| Mock-market policy | Ensure public users cannot mistake buyer-triggered mock payment for money; disable sandbox selling/delivery for valuable mainnet cards unless explicitly confined to a valueless trial | Unresolved | -| Peer/miner topology | Multiple independently administered hosts/operators/failure domains; actual online matching manifests and sufficient independent hashpower, not just two URL strings | Unverified | -| Edge and host security | TLS/certificate renewal, firewall, loopback backend, reviewed proxy IP trust, connection/global limits, restricted administration, patched hosts, time synchronization | Unverified | -| Monitoring/on-call | Alert routes and successful drills for node isolation/tip lag, native failure, deep reorgs, WAL/disk exhaustion, queue/latency saturation and market review states | Unverified | -| Restore/reconciliation | Consistent ledger + market SQLite/WAL backup and clean restore/revalidation; original market identity and idempotency history preserved; offline wallet recovery verified | Unverified | -| Release supply chain | Lockfile, reviewed base/native/toolchain versions, runtime image digest, SBOM/advisory/native/container scans, signed provenance and restricted release access | Partial: npm advisory queries and local tests only | -| Incident/governance | Named decision owners, market stop/containment procedure, fork/upgrade policy, communications, rollback criteria; custody/legal review if real money | Missing | +| Independent protocol/application review | Review against the exact release; critical/high findings fixed and retested | Missing | +| Consensus launch design | Final manifest; measured starting target/hashpower; timestamp, retarget, partition, and reorg analysis | Missing | +| Node availability | Realistic long-chain and hostile-traffic capacity report with explicit limits | Missing | +| Production wallet | Same-origin threat model, user confirmation, supported-browser lifecycle and offline recovery drill | Unverified | +| Peer/miner topology | Independently administered hosts/failure domains, verified online peers, and sufficient independent hashpower | Unverified | +| Edge and host security | TLS/certificate renewal, firewall, backend isolation, proxy trust, patching and restricted admin | Unverified | +| Monitoring/on-call | External alert routes and successful drills for isolation, reorg, native failure, overload and disk pressure | Unverified | +| Restore/reconciliation | Encrypted off-host ledger backup, isolated restore and proof replay; separate wallet recovery | Unverified | +| Release supply chain | Frozen image digest, SBOM, dependency/native/container scans, provenance, and restricted release access | Partial; repeat for final artifact | +| Incident/governance | Named decision owners, upgrade/fork policy, communications, rollback and stop criteria | Missing | -## 3. Freeze and rehearse before authorization +## Freeze and authorize -1. Close the security-review blockers in a separate reviewed release. Do not change consensus rules/difficulty under an existing launched chain ID. Prelaunch changes must generate a new pinned manifest and approval target where applicable. -2. Freeze the actual UTC genesis date. **2027-01-01 is a candidate placeholder, not an announced launch.** On the review date (2026-10-20), that time gate is not satisfied. -3. Freeze the exact image digest and review artifact hashes, network manifest, selected build/runtime profile, fee policy and intended public origins/peers. Confirm testnet data/backups are not reused. -4. Rehearse on isolated, disposable nonvaluable networks with the reviewed rules: initial synchronization, competing miners, partitions/reconnects, invalid traffic, restart after crash, disk pressure and full restore. Never delete production data or lower confirmations to make a rehearsal pass. -5. Verify the **intended production hosts**: DNS/TLS, proxy forwarded-IP handling, no direct backend access, matching chain IDs, data permissions, disk/memory/CPU capacity and observable peer freshness. `/healthz` alone is not acceptance evidence. -6. Confirm fee address control out of band, without requesting or transmitting any seed/private key. Checksum validation alone does not prove control. No live test payment is authorized by this document. +1. Fix critical findings in a separately reviewed release; do not change consensus rules under a launched chain ID. +2. Freeze the reviewed genesis/manifest, exact build/runtime profile, image digest, peer origins, and artifact hashes. +3. Rehearse initial sync, independent mining, partitions/reconnects, invalid traffic, disk pressure, restart, and isolated full restore. Do not delete production data or weaken rules to pass a drill. +4. Verify DNS/TLS, proxy forwarded-IP handling, no direct backend access, permissions, resource capacity, and monitoring on intended hosts. `/healthz` is liveness only. +5. Record a go/no-go with operator/reviewer names, evidence, known residual risks, change window, recovery objectives, and stop conditions in the controlled operations system. -## 4. Explicit operator go/no-go meeting +Do not set review acknowledgements merely to bypass gates. This internal code work is not the independent review required for launch. -Record the final release digest, immutable manifest, all hold-point dispositions, change window, operator/reviewer names, monitoring/on-call contacts, recovery point/RTO, stop conditions and signatures/approvals in your controlled operations system. +## Stop conditions -Only after independent evidence has been reviewed should the authorized operator intentionally supply the existing chain-specific startup approval and review attestation. **This internal coding review is not the independent review referenced by that attestation.** Do not put private keys, seed phrases, payout credentials or backup passwords in the approval record, source tree or browser environment. - -The launch preflight must not be repurposed to auto-populate approvals. No approval variables have been written by this work. Payments remain mocked even if network startup is later authorized; changing that requires a separately reviewed live-settlement release and explicit payment activation process. - -## 5. Stop conditions and containment - -- Halt new marketplace activity and investigate if invoice identity/amounts disagree, delivery is ambiguous, a settled order enters review, or payout status is unknown. Do not replay an ambiguous payout or automatically clear payout markers. -- Treat deep reorgs, isolation, divergent manifests, sustained queue/latency overload, disk/WAL exhaustion, native-engine failure, key compromise or loss of monitoring as incidents. Stop accepting new risk using the approved containment procedure. -- Stopping a node does not undo confirmed transactions or cancel previously signed transfers. Do not promise financial rollback from a software rollback. -- Preserve logs and consistent backups. Never delete the ledger/market database, shorten history or change genesis to get a node to start. Remove a stale lock only after verifying that its process is stopped and completing the documented recovery review. -- Resolve conflicting chains by the reviewed consensus/incident policy; do not silently install an ad hoc checkpoint or restore stale payment state against a newer chain. - -After a controlled launch, observe the agreed soak period and obtain a second operational acceptance before expanding exposure. Real payments must not be part of a mock-market soak. +Treat deep reorgs, isolation, divergent manifests, sustained overload, disk exhaustion, native-engine failure, key compromise, or loss of monitoring as incidents. Stop accepting new exposure using the approved procedure. Stopping a node does not undo confirmed transactions. Preserve logs and backups; never delete the ledger, shorten history, or change genesis to get a node to start. Remove a stale lock only after verifying all owning processes have exited. Resolve chain conflicts under a reviewed incident policy, not with an ad hoc checkpoint or stale restore. diff --git a/docs/persistent-custody-sandbox.md b/docs/persistent-custody-sandbox.md deleted file mode 100755 index 2b55e51..0000000 --- a/docs/persistent-custody-sandbox.md +++ /dev/null @@ -1,54 +0,0 @@ -# Persistent local PostgreSQL custody sandbox - -Status: implemented deployment rehearsal, **not a real-money or public production deployment**. Date: 2026-10-20. Related: [financial storage](postgres-financial-storage.md), [production plan](production-plan.md). - -## Start and inspect - -Use [compose.custody.yaml](../compose.custody.yaml) **by itself**, with a stable Compose project name. Existing single-node/three-node Compose files remain unchanged and do not start PostgreSQL. Do not combine this file with [the disposable test fixture](../compose.postgres-test.yaml). - -The VS Code **Start persistent custody sandbox** task runs the fresh/persistent stack using project `wikideck-custody-local`. Equivalent CLI invocation is `docker compose -p wikideck-custody-local -f compose.custody.yaml up -d --build`. Browser: [http://127.0.0.1:3422/](http://127.0.0.1:3422/), then **Marketplace → Custody preview**. Override `WIKIDECK_CUSTODY_PORT` if needed; origin configuration follows that port. The default bind is loopback only. - -Services: - -| Service | Lifecycle / purpose | -| --- | --- | -| `provision` | One-shot, network disabled. Generates random passwords and local CA/server certificate into scoped named volumes. Reuses complete credentials on later starts; partial/missing sets fail instead of silently rotating them. | -| `database` | Persistent PostgreSQL 17 with no published database port, internal network, TLS-only TCP HBA, SCRAM passwords, private key mounted only here. Health-gated startup. | -| `migrate` | Explicit one-shot local bootstrap: separate owner/runtime roles, versioned schema, restricted grants. Holds admin secrets unavailable to Wikideck. Unknown schema/credential state fails closed. | -| `wikideck` | Existing non-root application runtime with only the runtime credential and public CA mounted read-only, its own persistent node volume, mock payments and PostgreSQL-backed custody sandbox. | - -Completed one-shot services are expected to show **Exited (0)**. PostgreSQL and Wikideck should remain running; a stopped migrator with exit zero is not a missing database. - -## Persistent volumes and isolation - -Named volumes separately retain PostgreSQL data, node data, admin credentials, schema-owner credentials, runtime credentials, a read-only `custody_backup` login, and database TLS material. Generated secrets are not written into source, command arguments, Compose environment or browser variables. The app receives no admin/owner/backup credential or server private key. The separate one-shot migration provisions backup role grants limited to SELECT on custody tables/sequences; the role cannot advance sequence values or update/delete financial records. The admin password file is readable inside the trusted database/provisioning containers for the PostgreSQL entrypoint, not mounted in the app. - -This is a **new independent market**, not a migration of any existing node or wallet balance. Do not attach an existing SQLite custody volume and expect automatic import. Follow the stopped-node, backup and source-freezing procedure in [ADR 003](postgres-financial-storage.md) for that case. Do not point multiple independent market identities at this one custody schema. - -Keep the same project name. A different project name creates different volumes and therefore a new database/custodian. Existing preview containers/ports and user data were not changed by the rehearsal. - -## TLS and credentials - -The database presents a certificate with SAN `DNS:database`; the driver validates the chain and hostname using the mounted CA. No insecure transport override is set. Server-side HBA rejects plaintext TCP even with correct credentials. Unix-socket local trust remains only for the official local PostgreSQL bootstrap/health path; it is not a published network endpoint. PostgreSQL logs are configured not to print SQL error statements/parameters that could contain bootstrap credentials. - -Certificates expire after **30 days**. This is deliberate local-rehearsal material, not a certificate lifecycle solution for production. Provisioning refuses certificates with less than one day remaining and does not regenerate passwords or certificates over an existing database. The CA private key is temporary during first provisioning and not retained; renewal therefore requires a deliberately reviewed replacement CA/certificate and coordinated trust update. Do not delete random secret volumes to “fix” renewal. A running database may keep a certificate loaded beyond expiry; connection verification still fails closed for new clients. External monitoring/expiry alerts and a tested rotation procedure are required before longer operation. - -Passwords are not automatically rotated on restart. Missing credentials paired with existing PostgreSQL storage will not reset database roles. Preserve matching secret and data volumes securely; losing them requires controlled recovery. Docker administrators can read volumes; this is not protection against host compromise. Pin intended images/digests, configure encrypted backups and host firewall, and review actual deployment infrastructure before public/valuable use. - -## Check and stop - -The **Verify persistent custody after recreation** task runs [the deployment check](../scripts/custody-deployment-check.ts) in the app container. It verifies TLS, plaintext rejection, runtime role, sandbox API flags and exact journal accounting; prints only the public custodian ID and aggregate fictitious amounts. It does not reveal credentials or customer records. - -The optional **Check persistent custody TLS and credit** task adds a single 1-XMR **fictitious** test credit under an ephemeral test identity before auditing. It does not create Monero outputs, touch existing wallets or transfer NFTs. Repeating that task creates another fictitious customer credit; use the read-only verification task for routine checks. Test identities are not recoverable users and should never be used with valuable funds. - -The **Stop persistent custody without deleting data** task uses Compose `down` without volume removal. Restarting with the same project/file recreates containers and reuses database, credentials, TLS and node state. **Never add `--volumes`/`-v` to normal shutdown:** it destroys custody history and credentials for this stack. Unclean node shutdown may leave the existing exclusive node lock; verify the old process is gone and follow the stale-lock review procedure—do not clear it automatically. The provisioner preserves existing v1 secret volumes, then additively creates the backup-only role credential volume and upgrades its completion marker; role SQL grants are applied by the explicit one-shot migrator. Do not run this provisioning/migration upgrade during unreviewed production use. - -Existing SQLite-only node backup intentionally refuses PostgreSQL-bound nodes. An explicit [coordinated backup rehearsal](operations-recovery.md#coordinated-postgresql--node-custody-backup-rehearsal) is now available as a separate one-shot compose file; it does not run continuously and is not invoked on ordinary app startup. After stopping the app, it takes a node+PostgreSQL snapshot under both writer fences and a full custody audit. This small-sandbox helper is not an encrypted/off-host backup system, WAL/PITR, restore orchestrator or RPO/RTO claim. Named-volume persistence is **not a backup**, and Docker Desktop resets, disk loss or volume removal can still lose data. A v1 secret-volume upgrade adds the backup-only credential without rotating existing secrets, but should first be rehearsed on a copy of the intended deployment volumes. - -## Evidence and limits - -Rehearsal: provisioned a fresh isolated stack, observed healthy PostgreSQL and successful migrations, checked an actual **TLSv1.3** session under `custody_runtime`, and submitted a signed fictitious credit. Removed all four containers and both networks using `down`, retained volumes, recreated with `up`, and verified the **same custodian ID, one journal and 1,000,000,000,000 atomic units** in both assets and claims. Re-running setup retained existing credentials, role grants and schema; no second credit was added. Actual plaintext TCP authentication was rejected by the database. The browser served the app at port 3422. - -TypeScript/Vite runtime/test builds pass. Final offline regression run: **164 tests passed, zero failures/skips**. Deployment regression tests verify sandbox configuration, no published database port, runtime secret isolation, persistent data, TLS/SCRAM HBA and fail-closed partial provisioning. This is not independent certification of secrets provisioning or production hardening. - -Still absent: real Monero observer/signer/deposits/withdrawals, NFT candidate activation/settlement, managed certificates/rotation, automatic coordinated backups, production capacity/availability guarantees and release approval. The existing production plan remains authoritative. diff --git a/docs/postgres-financial-storage.md b/docs/postgres-financial-storage.md deleted file mode 100755 index c249141..0000000 --- a/docs/postgres-financial-storage.md +++ /dev/null @@ -1,117 +0,0 @@ -# ADR 003 — PostgreSQL financial storage - -Status: **selected and integrated for the custody sandbox; real-money release not authorized**. Evidence date: 2026-10-20. Related: [custody design](custodial-market-design.md), [production plan](production-plan.md). - -## Decision and scope - -Use **PostgreSQL** for financial state. Exact arithmetic, transactional constraints, concurrent coherent reads, cross-process locks, established backup/restore tooling and future WAL/PITR operations make it preferable to extending an embedded financial database indefinitely. PostgreSQL 17.11 was exercised in the disposable fixture; pin and review the intended deployed release/image separately. - -This is an integrated backend, not a second disconnected balance demo: [custody storage selection](../server/market/custody-storage.ts) supplies [the existing HTTP custody routes](../server/testnet/http.ts) with [PostgreSQL custody](../server/market/postgres-custody.ts). Signed command domains, user identities, amounts and UI stay unchanged. SQLite remains the default disposable/local sandbox for compatibility; choose PostgreSQL explicitly. Chain DAG/canonical indexes and legacy mock marketplace remain in their original SQLite databases. Moving those independent concerns is not needed to use PostgreSQL for customer liabilities. - -**All balances still represent fictitious sandbox XMR.** Monero observer and subaddress allocation groundwork are implemented separately in additive custody migrations v2-v4, but remain unwired and unavailable to public custody routes. Schema v5 adds an unexposed reviewed pending-deposit coordinator with its own role and immutable credit evidence; v6/v7 add append-only, reviewer-authorized release and reorg-resolution records. A staged credit remains non-spendable until a separate signed review moves it to `available`; detected reorg holds block new spending for that beneficiary until the output is freshly re-observed as present, unlocked, sufficiently confirmed and separately reviewed. There is no API/startup integration, automatic scan/reorg worker, production treasury backing, live signer, real-address withdrawal, NFT sale witness or payout. The role is provisioned explicitly using `grant-monero-credit-runtime ROLE`; no credentials or reviewer keys are created by the CLI. No mainnet funds are activated. - -## Storage and transaction guarantees - -- Dedicated `wk_custody` schema with version/digest-checked **explicit migration**, not DDL at HTTP startup. A runtime connection with administrative privileges or schema-owner membership is rejected. -- Money uses unconstrained-precision PostgreSQL `NUMERIC` plus checks for integral, nonnegative, bounded atomic values. Using `NUMERIC(30,0)` alone could silently round a fractional input before checking; this implementation rejects fractions instead. Account balances and individual entry legs remain below $10^{30}$ atomic units. The driver returns monetary values as strings; no global floating-point parser override. -- Journals/entries and replay receipts are immutable to the application. Deferred constraint triggers reject empty/unbalanced journals at commit, including entry inserts. Committed journals reject appended entries. An isolated `SECURITY DEFINER` entry trigger with a fixed schema search path locks accounts and updates balance projections; the runtime cannot directly update balances. -- Reservations, listing/order state, withdrawal holds, journal entries, blocked outbox and receipts commit in the **same database transaction**. Exactly the same signed nonce returns its original receipt; changed terms fail. No receipt pruning or reinterpretation of mock balances as live deposits. -- One writer per financial domain initially: transaction-scoped advisory lock shared across pools/processes, plus row locks and constraints. Reads use a repeatable-read snapshot. This improves separation/concurrency of readers and durable server operations, **not a claim of unlimited concurrent writers**. Sharding/partitioning or finer write locking requires measurements and another reviewed change. -- Pool maximum four connections per service; per-instance active admission sixteen; finite connect/statement/lock/idle transaction timeouts; synchronous commit explicitly on. Scale the total pool budget across instances deliberately. No wallet/network side effect is performed in a database transaction. -- No automatic retry after an uncertain commit. A client refreshes or resends the original signed envelope, letting durable receipts establish whether it committed. PostgreSQL failure never causes SQLite fallback. Startup audits compare every account projection with exact journal sums and check backing totals; these are internal accounting checks, not proof that real Monero assets exist. - -**Reconciliation hardening:** [financial business audits](financial-reconciliation.md) additionally check order/withdrawal encodings, exact allocations and held amounts, lifecycle journal lines, task payloads and orphan/duplicate records. They run at startup/import, on a nonoverlapping periodic schedule and on affected mutation boundaries. Persistent integrity incidents block writers and survive restart; only schema-owner acknowledgement with reviewed evidence and successful full audit can resolve them. External database/process loss still requires operational writer fencing. These checks do not prove real Monero backing. - -These controls do not make a compromised application harmless: it still has posting privileges, and an administrative database owner can alter schema/history. Restricted roles prevent accidental/direct mutation, not fraudulent balanced postings by a fully compromised service. Independent monitoring, statement review, external backups and authorization/treasury isolation remain required. - -## Configuration and operator workflow - -The following are configuration values, not credentials: - -| Setting | Behavior | -| --- | --- | -| `WIKIDECK_CUSTODY_MODE=sandbox` | Enables the existing testnet-only sandbox. Live/mainnet custody stays disabled. | -| `WIKIDECK_CUSTODY_STORAGE=postgres` | Selects PostgreSQL; default `sqlite` is retained for existing sandbox users. | -| `WIKIDECK_CUSTODY_PG_URL_FILE` | Absolute path to a private secret file containing a PostgreSQL URL. Never a Vite/browser variable. | -| `WIKIDECK_CUSTODY_PG_CA_FILE` | Optional private file with trusted PEM CA certificate(s); otherwise use system trust. TLS hostname/certificate verification remains enabled. | -| `WIKIDECK_CUSTODY_PG_TRANSPORT` | Default `tls`: verified PostgreSQL TLS. Optional `infrastructure`: the driver uses plaintext and relies on an independently enforced encrypted/authenticated infrastructure path; see conditions below. | -| `WIKIDECK_CUSTODY_PG_INFRASTRUCTURE_ACK` | Required **only** in infrastructure mode, exactly `encrypted-authenticated-path-enforced`. An operator acknowledgement, NOT automatic verification of infrastructure security. | -| `WIKIDECK_CUSTODY_PG_LOCAL_TEST=1` | **Disposable loopback tests only:** disables TLS solely for literal loopback/localhost. Never use for production. | - -URL query parameters/fragments are rejected, preventing `sslmode` options from overriding transport policy. Missing/malformed configuration fails with a generic error, not a URL/password dump. Secret files must be regular non-symlink bounded files; mount them read-only with restricted filesystem ACLs. Production network firewall, verified TLS, password/secret rotation and external database logs need an operator review. The automated fixture tests configuration policy, not an actual production certificate renewal/handshake drill. - -### Kubernetes/infrastructure-managed transport - -Prefer the default verified PostgreSQL TLS, with certificate issuance/renewal provided by the database operator, managed service or cluster infrastructure. **Wikideck does not need a bespoke certificate lifecycle service.** The local Docker certificate generator is a sandbox convenience, not the Kubernetes production architecture. - -If an enforced service-mesh mTLS path or equivalent infrastructure authenticates the intended database peer and encrypts the complete inter-workload path, deployments may explicitly select `infrastructure` plus the exact acknowledgement above. This sets the PostgreSQL driver's `ssl` to false: application-to-proxy and proxy-to-database plaintext segments must be local/isolated within the reviewed boundary. A firewall, ordinary Kubernetes networking or NetworkPolicy alone does not satisfy that condition. Ensure sidecar interception cannot be bypassed, plaintext inter-pod access is denied, destination identities are restricted, and policies remain enforced after restarts/failover. Protect migration, audit and recovery clients too—not just the HTTP application. - -Infrastructure mode cannot be combined with a CA file or the loopback-test switch; a stray acknowledgement in TLS mode is rejected. URL TLS overrides remain forbidden and file-based password credentials remain required. No insecure “accept any certificate” TLS option is added. Wikideck cannot verify external mesh enforcement; the acknowledgement is not proof and does not authorize live funds. Cluster evidence/review remains a release gate. - -Validation for this transport increment: TypeScript/Vite build, **171 offline tests and 16 PostgreSQL integration tests passed**, zero failures/skips. Configuration tests cover secure defaults, exact opt-in, missing acknowledgement/password, invalid mode, conflicting CA/test settings and URL override rejection. No Kubernetes cluster or actual service-mesh enforcement was tested or certified. - -Existing Compose stacks remain configured for verified native PostgreSQL TLS. Their TLS-only database HBA and deployment smoke probe intentionally reject infrastructure/plaintext mode: do not change just the client flag on that stack. This option is for a separately configured infrastructure deployment, not automatic TLS fallback. No Kubernetes resources or running database/container settings were changed. Custom certificate renewal implementation is deferred to infrastructure; the local sandbox's documented short-lived certificates still need controlled operational replacement if retained. - -The operator CLI is [scripts/custody-db.ts](../scripts/custody-db.ts), available through `npm run custody:db -- ACTION`. Credentials are provided only by the secret-file environment setting, never positional arguments or browser UI: - -1. Provision a dedicated database, a migration/schema-owner identity, and a separate existing login role without superuser/create-role/create-db/replication/bypass-RLS privileges. Create passwords privately outside this tool; no credentials are generated or displayed by the CLI. -2. With migration credentials, run action `migrate`. It checks the pinned version/digest or creates a fresh schema transactionally. Unknown versions are refused; no reset/drop/rebuild mode exists. -3. With migration credentials, run action `grant-runtime ROLE`. It validates the literal role identifier and grants schema usage, reads, account/journal column-specific inserts, entry/receipt inserts and operational record updates. It never grants direct balance updates, history deletion, schema ownership or migrations. Use a **new dedicated role**; the helper grants required privileges but is not a scrubber for inherited privileges from an existing unrelated role. -4. If the data directory has existing SQLite custody, stop the node, preserve a stopped-node backup, and run `import-sqlite DATA_DIR` against a migrated **empty** target using migration credentials. See cutover behavior below. -5. Switch the server to runtime credentials and explicit PostgreSQL selection. Startup verifies schema, restricted identity, custodian binding and accounting before serving. A new directory can initialize a fresh sandbox identity; it does not inherit another host's credit. -6. Action `audit`, using runtime credentials, prints exact aggregate sandbox totals only. It does not print customer histories or database credentials. Running it on a previously uninitialized schema initializes a sandbox custodian identity just like startup, so do not use it as a supposedly read-only probe of an empty migration target. - -The CLI is included in the runtime image. No existing deployment, database secret or fund wallet was changed automatically by this implementation. - -**Persistent local deployment now available:** [deployment guide](persistent-custody-sandbox.md) and [standalone Compose stack](../compose.custody.yaml) provision isolated named volumes, scoped generated credentials, verified PostgreSQL TLS/SCRAM, separate roles and a one-shot migration service. Recreated containers retained a signed fictitious credit and custodian identity in rehearsal. This explicitly started a new sandbox, not a cutover of existing node data or a live-custody deployment; production backup, certificate rotation and activation gates remain open. - -## Explicit SQLite cutover and rollback limits - -[Importer](../server/market/custody-import.ts) is bounded to the existing sandbox source size (128 MiB), not a general live-money migration utility. The CLI takes the same exclusive stopped-node directory lock as node startup. Calling the underlying library assumes the caller has already stopped all source writers. - -1. Acquire SQLite immediate transaction and take a consistent snapshot. Verify source identity/integrity and canonical amount encoding. Hash identity and all logical rows in deterministic insertion order. -2. **Freeze source first:** commit its `postgres_cutover` digest before contacting the target. SQLite startup refuses this marker. This closes the crash window where PostgreSQL might commit while SQLite remains writable. -3. Lock PostgreSQL financial domain. Require an empty migrated target. Import accounts initially at zero and replay original journal entries through the database posting trigger; compare rebuilt balances to source projections. Preserve custodian identity, all receipts/digests, fictitious-credit claims, listing/order/withdrawal order, prepared attempt/transaction identities and blocked outbox state. -4. Validate accounting, then commit the imported source digest. Repeating the **same** frozen-source import returns success without duplicating balances. A changed source/different imported target is rejected. Failure before commit rolls target rows back; the source stays frozen. Correct the privately diagnosed issue and resume the same import—do not delete markers to restart SQLite. -5. PostgreSQL startup records a private fsynced local binding of custodian ID. A directory with that binding refuses SQLite even if no source database exists. An imported source must match the target's stored source digest. - -There is no live dual-write, automatic migration or automatic rollback. An old pre-cutover backup cannot know about a later cutover: restoring it and running SQLite would fork accounting operationally. Fence old hosts/credentials and archive old backups as **historical, non-spending**. This cannot be solved by a local marker alone; coordinated restore and authority controls are release gates. - -## Backup, restore and failure handling - -Existing node backup packages cover local SQLite databases only. They now **refuse** a PostgreSQL-bound data directory or frozen cutover source instead of claiming to capture all customer liabilities. A separate [coordinated stopped-node rehearsal](operations-recovery.md#coordinated-postgresql--node-custody-backup-rehearsal) can now package node SQLite plus a PostgreSQL custom dump while holding the node lock, the database advisory writer lock and an audit snapshot. It uses a separate read-only backup role in the local persistent sandbox. This is not an automatic production coordinator, and uncooperative privileged writers still require external fencing. - -Recommended operations before any production release: - -- Managed PostgreSQL storage with encrypted backups, WAL archival/PITR, monitored replica/backup lag, checked restore retention and selected RPO/RTO. Define synchronous-replica requirements based on acceptable loss/failover risk; `synchronous_commit=on` alone does not configure a synchronous replica. -- Fence writers/treasury services during backup/restore cutover and record matching node ledger/market checkpoints, custodian identity, database dump/backup identity and external withdrawal state. Do not restore a financial snapshot in isolation and start spending. -- Use full-database dumps that include schema/functions/triggers/ACLs, plus separately protected role configuration; a schema-filtered dump may omit necessary trigger dependencies. Restore into an isolated database first, using administrative credentials; connect only the restricted runtime for audit/replay checks after restore. -- Reconcile Monero external history before resuming real spending once that integration exists. No local backup can reverse an external payment. Database timeouts/connection loss leave requests failed closed; retry the original signed command, not a new withdrawal. -- Monitor application and database errors without logging statements/parameters containing private customer data in production. Fixture logs deliberately show failed constraint probes on fictitious data; do not adopt their logging/auth configuration. - -The integration suite performs both a PostgreSQL `pg_dump`/isolated `pg_restore` rehearsal preserving ID, exact liabilities and receipt replay, and a paired node/PostgreSQL archive-creation/verification/restore test using the actual runtime adapter plus a read-only backup role. Pair verification records PostgreSQL server/dump/restore versions, checks exact package entries and checksums, and parses the custom archive using `pg_restore --list` while requiring core custody tables and a compatible restore client. The dump subprocess is isolated from inherited libpq endpoint/TLS overrides. This does **not** certify managed backup schedules, PITR, encrypted off-host custody, signature/authenticity, power-loss/fsync behavior, long-history timing or production failover. - -## Evidence and remaining gates - -[Disposable test compose](../compose.postgres-test.yaml) runs PostgreSQL on tmpfs in an isolated internal network, no published ports; test runner shares the database network namespace and uses loopback. Trust authentication is **fixture-only**, not a deployment template. [Tests](../tests/postgres/custody.test.ts) are a separate explicit suite and refuse to run without `WIKIDECK_PG_DISPOSABLE_TEST=1`. - -Verified: restricted runtime authorization; deferred balance constraints; fractional/negative/oversized rejection; committed journal append rejection; independent-pool simultaneous nonce/listing/withdrawal races; insufficient-balance rollback; injected receipt failure atomicity; exact amounts beyond JavaScript safe integer; reconnect/replay; import of prepared withdrawals and pending orders; failed-import freeze/rollback/resume; real HTTP backend integration; configuration policy; fallback/backup refusal; full dump/restore audit. - -Final evidence: TypeScript/Vite test-image build passed; **149 existing regression tests and 10 isolated PostgreSQL integration tests passed**, zero failures/skips. PostgreSQL fixture resources were removed after testing. Existing user data directories and deployed node database settings were not switched. This verifies the current sandbox implementation, not production acceptance. - -**Not closed:** live Monero and NFT settlement; production dataset/SLOs and pool contention benchmarks; audit scheduling and very-large-history startup cost; normalized durable real-deposit/withdrawal/compensation states; account recovery/second factor; external security/accounting review; coordinated node/financial/treasury backup; deployment TLS drills, PITR and failover rehearsal; funded exposure/fee/loss policies; legal/operations sign-off. The scalable database choice does not turn the existing sandbox into a production custodian. - -### Reviewed Monero pending-credit operations (sandbox only) - -`npm run custody:db -- ACTION` now supports these explicit, non-HTTP actions for the pending-credit ledger stage: - -- `deposit-prepare-credit WALLET_FINGERPRINT OBSERVATION_ID` emits the exact review subject for an eligible, currently observed output. -- `deposit-stage-credit APPROVAL_JSON_FILE` verifies the allow-listed reviewer signature and records the amount in a non-spendable pending liability. -- `deposit-prepare-release CREDIT_UUID` emits the current output and unresolved-hold snapshot. -- `deposit-release APPROVAL_JSON_FILE` moves the matching liability to `available` after a distinct reviewer signature. -- `deposit-reconcile-holds WALLET_FINGERPRINT` records unseen/changed/reappeared observations as append-only holds. -- `deposit-prepare-resolve-hold CREDIT_UUID HOLD_UUID` and `deposit-resolve-hold APPROVAL_JSON_FILE` resolve a hold only after a fresh eligible observation and a new reviewer signature. - -The operator must separately provision the coordinator role with action `grant-monero-credit-runtime ROLE`; database migration remains explicit. The review policy comes from `WIKIDECK_MONERO_DEPOSIT_REVIEW_POLICY_FILE`, an owner-only JSON file containing only approved reviewer owner public-key hashes, a confirmation floor (at least 10 in this sandbox policy), and maximum observer age. Approval JSON files are bounded, absolute-path, regular files opened without following symlinks, and owner-only on POSIX. **These review-file commands currently refuse Windows** because the implementation cannot verify Windows ACLs; run them only in a secured POSIX environment. No reviewer private key is loaded by the application: snapshots are emitted for external signing and signed approval files are then submitted back to the CLI. Two-argument commands parse both identifiers explicitly. These CLI actions do not enable scanning, an HTTP endpoint, an unattended worker, or live deposits. - -The added `tests/custody-db-args.test.ts` verifies two-argument IDs are preserved in the proper positions and rejects missing, empty, unknown, or extra arguments. `tests/monero-deposit-review-config.test.ts` checks strict policy parsing, approval-file bounds, POSIX owner-only permissions, no-follow/symlink rejection, and fail-closed Windows behavior. Full offline validation passed **194/194**; PostgreSQL integration passed **36/36** after the operator workflow changes. These tests verify offline command/config boundaries only; no operator keys or live RPC were used. diff --git a/docs/production-plan.md b/docs/production-plan.md deleted file mode 100755 index 7310191..0000000 --- a/docs/production-plan.md +++ /dev/null @@ -1,236 +0,0 @@ -# Production delivery plan - -Status: active engineering plan; **not launch or payment authorization**. Updated 2026-10-21. The objective is a production service with explicit guarantees, measurable limits and accountable operations—not relabeling the existing testnet. Existing implementations are reusable groundwork; findings are closed only by reviewed implementation and evidence. - -## 1. Decisions agreed with the operator - -| Topic | Decision / boundary | -| --- | --- | -| Initial MVP hosting | One operator (the project owner) in a US apartment, one x86 host and low initial volume. Assume a single failure domain, no on-call team and no HA. Confirm ISP terms, static/reachable ingress or CGNAT, power/network reliability, DDoS exposure and off-site recovery before choosing this over a small managed host. Do not imply independent-host security or availability. | -| Settlement architecture | Trustless and arbitrated routes were investigated; the operator has now authorized **implementing the custodial pooled-balance market**. [ADR 002](custodial-market-design.md) tracks the first isolated sandbox accounting/UI increment and remaining treasury/consensus work. Implementation authorization is not live deposit, key access, consensus activation or launch authorization. | -| Host responsibility | Host responsibility, reserves and exposure caps are the selected policy direction. Exact liability, amounts and funding remain undecided. This is not authorization for custody and does not make settlement trustless or guarantee solvency. The author fee does not make the author a guarantor of independent markets. | -| First live market | Fixed-price sales first. Live auctions remain disabled until their funding, nonpayment and abuse controls pass a separate release gate. Existing sandbox auctions can remain clearly labeled. | -| Pricing | **Native XMR prices**, replacing the earlier USD-price decision. No economic listing minimum. Represent nonzero prices as integer atomic units: the smallest representable positive XMR amount is 10^-12 XMR, not an imposed USD/XMR minimum. Zero-price gifts should use free transfers unless separately designed. | -| Sending/network fees | Earlier model: buyer pays sending fee, seller pays disclosed per-sale disbursement costs. Custodial proposal: no per-purchase Monero transfer; depositor pays external sending, withdrawing beneficiary pays approved actual withdrawal fee, operator pays treasury movements. Author-fee withdrawal costs and overruns require explicit policy approval. No fee change is implemented. | -| Uneconomic small sales | No economic listing minimum. Custodial proposal allows tiny internal settlements and explicitly disclosed backed balance accumulation; withdrawal economics are separate. This changes the earlier per-sale payout-budget model and requires approval under ADR 002. Never silently discard a tiny liability or subsidize a fee. | -| Payment before sale finalization | A paid sale must not complete or be included as a completed ownership sale on Wikideck until the agreed XMR payment has satisfied its confirmation/unlock policy. Payment notification/mempool presence is insufficient. | -| Existing boundaries | No real processor access, credentials, invoices, transfers, genesis change, destructive migration or launch approval is authorized by this plan. Software licensing still requires an operator choice and publication. | - -**Production settlement remains disabled.** The public sandbox now prices new v3 listings/orders in native XMR atomic units, with mock-only confirmations, fees, refunds and payouts; legacy v2 USD records retain their original terms. No real XMR invoice, payable address, deposit, credit, withdrawal, or settlement path is enabled. Existing records are not silently reinterpreted as XMR. - -**Current design deliverable:** [custodial market design](custodial-market-design.md). Read the workstreams below with this update: pooled customer liabilities replace per-order incoming invoices and external sale payouts in the proposed architecture. Earlier trustless-first research gates are historical, not a prerequisite to investigating custody. Exact ledger, withdrawal, fee, custody/legal and host-attested NFT consensus rules still require approval and evidence; the existing mock domain cannot authorize real balance debits. - -**Monero observation and allocation groundwork:** the disabled-by-default observer remains read-only. The separate address RPC capability verifies a mainnet primary address using `get_address` and `validate_address` and derives a wallet fingerprint; it does not claim to attest wallet type. Actual testing found `get_wallet_info` is unavailable as JSON-RPC in v0.18.5.4, and `--rpc-login` requires legacy HTTP Digest/MD5. The app does not implement Digest or send Basic credentials; the new mTLS gateway/transport is not deployed or wired. Its isolated certificate handshake, method allowlist and denial cases passed the rehearsal below; do not enable Wallet RPC until the intended deployment’s network isolation, certificate lifecycle and firewall controls are also verified. Do not enable Wallet RPC until that end-to-end path passes its certificate and isolation tests. Watch-only wallet provisioning and network isolation must be enforced by deployment. The internal allocator checks the primary-address fingerprint against the durable generation before creating, reserves beneficiary/index first, serializes cooperating allocators, and recovers create-success/lost-response by checking a stable label at the reserved index. It recognizes only the observed “address index is out of bound” error as an unused slot and fails closed on other RPC errors. Actual RPC verification is limited to the offline disposable rehearsal below; no application startup/routes/UI/scans or financial posting are wired, and the registry remains inaccessible to custody API and observer roles. Operational assumptions still include a dedicated wallet RPC, controlled generation enrollment/backups, and wallet/database reconciliation. The registry cannot establish unique output identity. Missing scans remain review evidence, not automatic balance changes. Verification passed: Docker test-target build; **187/187** offline tests; **35/35** disposable PostgreSQL tests, including v1/v2 migrations, allocator concurrency, replay, immutable registration, role isolation, zero journal postings, wallet mismatch rejection, the official -15 index response, and simulated response-loss recovery. New gateway allowlist/config tests pass; no actual mTLS certificate handshake has yet been tested. No live RPC credentials, funds or mainnet wallet were used. Remaining deposit milestone: complete the mTLS gateway interoperability/isolation rehearsal, establish durable wallet-generation enrollment and recovery, pin per-output identity and canonical inclusion/lock/reorg semantics, then implement idempotent credit journals and audited reversal/hold handling before stagenet deposit-credit rehearsal or any real deposits. - -**Initial actual-wallet rehearsal blocker (2026-10-21, superseded below):** no Monero wallet binary or pinned image was present locally, so the RPC contract could not yet be exercised against a real process. This was resolved by obtaining and verifying the official v0.18.5.4 archive and running the disposable offline Stagenet rehearsal recorded below. The recorded evidence does not authorize mainnet access, deposits, credits, or payments. - -**Actual Wallet RPC + mTLS gateway rehearsal completed (2026-10-21):** the test image obtains Monero CLI v0.18.5.4 only from the official downloads host, verifies the `binaryFate` fingerprint `81AC 591F E9C4 B65C 5806 AFC3 F0AF 4D46 2A0B DF92`, verifies the GPG-signed canonical hashes file and checks the Linux x64 archive digest `ba2928962c101b609434bf8c1b43ad59ebe96c5525977284ca599d8bf0f02d10` before extraction. Its rehearsal creates ephemeral CA/server/approved-client/untrusted-client certificates, then runs `monero-wallet-rpc` in Docker `--network none`, `--stagenet --offline`, loopback-only, with login disabled behind the tested TLS 1.3 mTLS gateway. The real wallet was verified watch-only by the `query_key(spend_key)` denial. The configured client cert successfully read the primary address and created one subaddress; the gateway rejected a transfer request (HTTP 400 before upstream), a CA-signed but unapproved client certificate, and a no-certificate request. Wallet files were stopped and deleted with the ephemeral container. No `monerod`, peer, network, live credential, mainnet or funds were involved. This proves protocol interoperability in that isolated single-container topology; it does **not** prove Kubernetes/apartment firewall isolation, certificate issuance/rotation, deployment configuration, hard power-loss durability, deposits/credits/withdrawals, or production readiness. The current app still does not start this gateway or observer; HTTP auth with Monero's own `--rpc-login` remains intentionally unused. Full offline suite after these changes: **187/187 passed**; disposable PostgreSQL suite: **35/35 passed**; test image build succeeded. - -**Output failure/reorg test results (2026-10-21):** offline coverage now rejects missing outputs, duplicate public keys, subaddress misattribution, and malformed output records. PostgreSQL coverage follows a stable output identity through not-seen, reappeared, and changed-height re-inclusion states; it rejects output-key reuse across transaction IDs and confirms zero journal postings throughout. The image build passed; offline suite **189/189 passed** and disposable PostgreSQL suite **35/35 passed**. This exercises persistence and review signals only—no customer credit or automatic reversal exists. The actual Stagenet rehearsal against v0.18.5.4 confirms the gateway permits `incoming_transfers`; for a fresh empty address, the RPC returns an empty result object with the default `transfers` and `status` fields omitted. The parser now treats absent `transfers` as empty; no real owned output was available in the offline, unfunded rehearsal, so nonempty RPC output parsing remains fixture-tested. - -**Reviewed pending-deposit ledger stage (2026-10-21):** schema v5 adds immutable per-output credit records and append-only observation-event holds; schema v6 adds append-only reviewer-signed release and hold-resolution records; v7 permits one signed release to resolve multiple holds while retaining one-time hold identity. A separate, non-HTTP coordinator verifies registered beneficiary allocation, observation freshness/unlock/confirmation/double-spend state, and an allow-listed P-256 reviewer signature. It can first stage an output into a non-spendable `deposit-pending` liability, then separately release that amount into `available` under another signed review bound to the current output and every unresolved hold. Reorg/disappearance/re-inclusion events create scoped holds; new purchases and withdrawals for that beneficiary are blocked while any remain unresolved. Holds can be resolved only after the output is again observed, unlocked, sufficiently confirmed, and signed off; journals are never reversed automatically. The dedicated coordinator DB role is explicitly provisioned using `grant-monero-credit-runtime` and remains separate from observer and custody-runtime roles. This remains isolated sandbox ledger machinery, not proof of treasury backing: no API/startup integration, reviewer key provisioning, continuous scan/hold worker, production deposit activation, withdrawal signer/worker, or live sale settlement exists. Validation: test image build passed; offline suite **190/190 passed**; disposable PostgreSQL integration suite **36/36 passed**. Tests cover schema upgrades, pending-credit review, concurrent duplicate release/reorg resolution, scoped spending holds, and cleanup of the disposable fixture. No external network or funds used. - -**Offline deposit-review operator commands (2026-10-21):** `custody-db` now has explicit prepare/stage/release/reorg-hold/review-resolution actions, with exact argument-count checks and idempotent coordinator calls. They consume an owner-protected review-policy file and bounded signed-approval files; reviewer private keys are never loaded by the app. POSIX owner-only permissions and no-follow opens are required; these file operations refuse Windows until ACL verification is implemented. The existing `IS_MAINNET` guard keeps the CLI sandbox-only. This is an operator rehearsal interface, not a public deposit endpoint or live payment activation. Added argument-parser and protected-file tests; full offline suite passed **194/194** and PostgreSQL integration **36/36**. - -**Separate Monero observation worker (2026-10-21):** added a standalone opt-in process, durable bounded catch-up (100,000-block pages, four pages per batch, 1,000-block overlap), PostgreSQL session-lock leadership, restart resumption, and redacted aggregate progress logs. It persists observations only: it has no journal, balance, credit, release, address-allocation, or transfer capability. It is not started by the API and is not included in the sandbox stack as a running service. A separate role grant is available; on mainnet the `custody-db` CLI now permits only this exact observer-role grant and continues to reject migrations, audits, coordinator grants, and all financial actions. The operator must separately create a non-admin, no-membership database login, provide its protected PostgreSQL URL and the protected mTLS client/observer configuration, and run the worker under a supervised POSIX service. This is not yet a complete deployable Monero topology: no mainnet Wallet RPC/gateway/network-policy service, secret provisioning, monitoring destination, key lifecycle, nonempty owned-output test, or deposit-credit automation is provided. Validation: production test-image build succeeded; offline suite **195/195** and disposable PostgreSQL suite **37/37** passed. Worker evidence is synthetic catch-up/restart/leader fencing and journal exclusion only; it does not authorize mainnet deposits or payments. - -**Single-host worker deployment increment (2026-10-22):** added a hardened systemd unit and protected configuration templates for the stated apartment-host model. Added a distinct mainnet-only, exact-acknowledgement PostgreSQL schema migration action; it runs the existing transactional, digest-checked migrations and grants no runtime permissions. PostgreSQL URL/CA files now require POSIX owner-only permissions. The worker remains non-root, private-network-only by host firewall contract, and separate from the API. This advances deployment preparation, not live service: no external host/DB/gateway was contacted, no credentials were provisioned, and no service was started. The private Monero daemon/Wallet-RPC/gateway, external monitoring and recovery, production database provisioning and independent review remain unprovided. Validation: image build passed, offline suite **196/196**, PostgreSQL suite **37/37**. See [the systemd deployment contract](../deploy/systemd/README.md). - -**Signed deposit-enrollment slice (2026-10-22):** added a customer-wallet-signed request contract and server-side enrollment service that verifies mainnet profile, chain, custodian, beneficiary ownership, wallet-generation fingerprint, account index and a five-minute expiry before reserving a never-reused subaddress index and recovering/registering exactly one address. Added a separate allocator DB role, and schema v8 now forbids inserting pre-registered addresses directly; role permissions permit only allocation storage and the guarded reserved→registered fields. Replay returns the existing allocation; wallet mismatch/invalid signature fail before address RPC. PostgreSQL tests cover signed enrollment, replay, beneficiary uniqueness, role isolation, no journal writes and database-trigger bypass attempts. This is not exposed through HTTP/UI and no real wallet was contacted; it does not credit funds or establish a withdrawal path. Validation: build passed, offline suite **175/175**, PostgreSQL suite **38/38**. - -**Mainnet enrollment API wiring (2026-10-22):** added `GET /api/monero/deposit-config` and signed `POST /api/monero/deposit-address`, only mounted when `WIKIDECK_MONERO_DEPOSIT_ENROLLMENT=1` on the gated mainnet profile. Startup pins the operator-provided custodian ID, wallet-generation UUID, expected wallet fingerprint, account index, matching mTLS endpoint and separate TLS allocator PostgreSQL role. Enrollment requests are rate-limited and signed by the beneficiary; responses explicitly say `address-enrolled-not-credited`, with crediting and withdrawals reported disabled. The flag defaults off; this API must not be enabled for customers until deposit crediting, withdrawals and recovery are end-to-end functional. No real wallet or database was contacted. The API has no customer UI yet. Validation: test-image build passed and offline suite **176/176**; the preceding complete PostgreSQL suite passed **38/38** after schema-v8 allocator protection. - -**Mainnet reviewed credit and withdrawal-reservation path (2026-10-22):** added one-time `initialize-mainnet-custody-identity CUSTODIAN_UUID`, guarded by exact identity and `reviewed-credit-only` acknowledgements. It binds a migrated v9 custody database to the selected mainnet chain/custodian and refuses rebinding or existing financial/allocation records. The existing signed review workflow can stage an observed output as pending, then release it into the customer's available balance; it is restricted to registered beneficiary allocations, current observations, allow-listed reviewer signatures, and reorg holds. Added signed customer-only balance reads, withdrawal requests that atomically reserve recipient amount plus max-fee against that beneficiary's available balance, and customer cancellation before signer handoff. New withdrawal reservations cannot exceed that customer's cumulative reviewed, released deposits. A separate no-membership withdrawal database role has ledger-intent privileges only; no Monero signing or relay capability is exposed. **This is not a completed withdrawal/payment system:** reserved requests are not signed, broadcast, or confirmed on Monero, there is no customer UI, and no funded-treasury reconciliation or recovery process. Do not solicit deposits until the isolated treasury signer and end-to-end send/confirmation/reorg/recovery path exists. Validation: image build passed; offline suite **177/177** and disposable PostgreSQL suite **40/40** passed. No mainnet database, wallet, or funds were contacted. -The API-side switches are `WIKIDECK_MONERO_DEPOSIT_ENROLLMENT=1` for address allocation and `WIKIDECK_MONERO_WITHDRAWAL_REQUESTS=1` for signed balance/withdrawal intents; both require `WIKIDECK_NETWORK=mainnet`, protected wallet/DB config, and a separately initialized custody identity. Both default to off. Withdrawal endpoints never call Wallet RPC. There is intentionally no environment switch for transfer signing, transaction relay, or live sale settlement. - -## 1A. MVP release boundary — proposed safe first production-hosted release - -The apartment/single-operator constraint changes the rollout and availability claims, not the safety evidence required to hold customer funds or valuable assets. Recommended MVP: **a public, clearly labeled non-monetary beta on the existing testnet**, with real XMR completely disabled, no custodial balances/deposits/withdrawals, and marketplace checkout/delivery disabled for valuable assets. Keep mining, browsing and wallet use only if they are explicitly described as experimental and users accept that data/ownership may be lost. This would be a production-hosted beta, **not mainnet production**, a custody MVP, or an independently secured chain. - -If “MVP” instead means paid fixed-price sales or valuable mainnet NFTs, do not use the reduced beta checklist: P0/P3/P4/P5/P6 and the full NO-GO hold points still apply. Small volume, one host, mock confirmations, or a low exposure cap do not substitute for a reviewed lock/settlement protocol, real deposit/withdrawal reconciliation, funded loss policy, and legal/operational acceptance. In particular, the active v6 consensus still rejects the sale-lock candidate. - -For the non-monetary beta, the remaining engineering/operations sequence is: - -1. Freeze scope and disclosures: testnet identity, no-value policy, marketplace disabled for valuable cards, experimental wallet/data-loss notice, support contact, abuse/report channel and clear stop conditions. -2. Prove the apartment can safely serve traffic: ISP permission and reachable ingress/CGNAT, DNS/TLS renewal, trusted proxy and no direct backend access, firewall, patched host, restricted SSH/admin, power/network monitoring, and a documented way to stop accepting traffic. If these cannot be demonstrated, use a managed host rather than weakening controls. -3. Repeat load/reorg/restart/restore tests on the exact release candidate. The current one-shot 160-request benchmark is preliminary; define request mix/rate, run repeated trials with concurrent API traffic during reconciliation/mining/peer sync, set explicit latency/error/resource limits, and do a clean isolated restore. Keep assets valueless until consensus and recovery review passes. -4. Freeze artifacts and get independent review appropriate to the exposure. Pin image/base/native/dependency digests, generate SBOM and scans, verify the launch preflight remains NO-GO for mainnet/live payments, and record the operator’s limited beta go/no-go separately from mainnet/payment authorization. -5. Operate conservatively: encrypted off-host backups and restore drill, disk/WAL and peer/tip/reorg/worker alerts delivered somewhere other than the apartment host, patch/incident/rollback procedure, and a person responsible for responding. A single operator cannot promise 24/7 response or HA. - -This scope is a proposed release boundary, not an approval to deploy. The operator must explicitly accept the beta’s non-monetary/experimental limits and sign off on apartment-host evidence before a public beta. No real funds, valuable NFT launch, mainnet activation, or consensus identity change is authorized by this proposal. - -## 2. Resolve the cross-chain requirement before selecting architecture - -### What “confirmed before on-chain completion” requires - -RandomX verifies work on Wikideck; it proves nothing about an XMR transfer. Monero privacy also means a Wikideck validator cannot infer recipient/amount/payment validity from a transaction ID alone. A payment service's status is a trusted attestation, not a trustless cross-chain proof. Calling an external service or mutable Monero RPC from block validation would make consensus depend on credentials, service availability and potentially different observations; **do not implement that**. - -The design must specify what every validator can verify deterministically, which Monero chain/confirmation evidence is used, how its recipient/amount/order binding is established, what private information or keys must be disclosed, and how both chains' reorganizations are handled. Any relayer, committee, market operator, oracle or custodian introduces a named trust assumption. “Non-custodial,” “atomic,” and “trustless” are distinct claims and must be justified separately. - -Mining one new card per roughly 15 minutes is independent of Monero's block schedule, but today's transfers are included in the same Wikideck blocks. Therefore XMR confirmation, Wikideck inclusion, Wikideck confirmation depth and any final XMR disbursement/unlock are **separate timing milestones**. Fast end-to-end settlement or a separate transaction-finality layer would need a reviewed consensus design, not just a faster payment poll. The operator has specified ordering, not yet approved changing Wikideck's block schedule. - -Pre-payment lock/reservation transactions might be needed to prevent double-selling. They must not transfer completed-sale ownership, and they would appear on-chain before XMR payment. **The operator approved preparatory locks/reservations for design research on 2026-10-20.** This does not approve an escrow authority, custody model, expiry policy or consensus implementation. - -### P0 — architecture research and decision record (first critical path) - -Deliverables: - -1. A threat model: dishonest buyer/seller/host/miner/relayer, compromised keys, eclipse/partition, one-chain stall/reorg, late payment, stale signature, disputed quote and processor partial-send/timeout. -2. A comparison of (a) feasible researched XMR-compatible atomic/adaptor-signature or other non-custodial approaches, (b) deterministic verification/light-client or attestation approaches, and (c) host-custodial settlement with consensus-enforced NFT locks as a fallback. Research compatibility with this chain's P-256 signatures and Monero's transaction model; do not assume a Bitcoin-style HTLC works for Monero or that an existing swap library supports arbitrary NFTs. -3. Exact protocol messages/state transitions, release/refund ordering, timeouts, fee-collection enforcement, replay/domain separation, key-loss recovery and what happens if either party stops cooperating. Identify whether selling can bypass host/author commissions and what can realistically be enforced by a modifiable independent peer. -4. Primary-source/release evidence, maintained dependency/license assessment, feasibility spike using disposable fixtures, attack/failure tests and independent cryptographic review requirements. An unevaluated paper or toy demonstration is not production feasibility. -5. A recommendation with explicit guarantees, trust assumptions, costs, latency and unresolved risks. If trustless settlement is infeasible in scope, present a fallback decision for the operator; keep live sales blocked until approval. - -Acceptance: operator approves an architecture decision record (ADR), with external review where required. No live adapter/consensus release is connected merely because the research finishes. Discovery of an impractical design is a valid outcome, not a reason to hide trust assumptions. - -## 3. Workstreams, dependencies and acceptance criteria - -### P1 — native-XMR domain model and migration - -Can begin independently of P0, but must not hardwire a settlement architecture. - -- Define canonical decimal input/output backed by integer atomic units, bounded values/precision, exact comparisons and independent commission rounding. No float-based amounts or implicit USD conversion. Optional fiat display, if added, is informational and never settlement authority. -- Replace USD listing/bid/checkout fields and UI with versioned XMR-native terms. Sign currency, gross, commissions, destinations, fee policy/estimate limits, expiry, order/market/network identity and selected settlement protocol version. Update invoice/checkout review and participant disclosures. -- Migration plan: preserve existing USD/mock order history in its original version. Close/relist old open listings with fresh seller authorization; define disposition of bids/reservations before migration. No automated reinterpretation, deletion or mainnet asset migration. -- Use the selected pooled-custody model: direct Monero wallet/daemon observation and durable subaddress allocation for deposits, internal liability transfers for sales, and a separately constrained treasury withdrawal path. No third-party processor is selected or required. Preserve exact native-XMR prices; do not introduce fiat conversion as settlement authority. -- Separate payment-sending fees, hosting/author commissions and payout network fees in quotes/accounting. Define who pays author-leg network costs and fee-overrun caps. Handle sub-atomic commissions without overcharging. Preserve the fixed 1% address/rate in the distributed policy. -- Checkout preflight determines economic viability under the selected architecture before accepting an order. For the custodial proposal, internal sales have no per-sale Monero payout cost; deposit eligibility, available balance and withdrawal economics are separate checks. Accumulated balances are proposed in ADR 002 but not implemented/approved for funds; batching remains deferred. Disclose uneconomic withdrawals before deposit rather than silently forfeiting balances. - -Acceptance: exact-amount property/boundary tests, versioned signature tests, zero/tiny/large-price and fee-spike tests; migration dry run/rollback on copied data; all displayed totals reconcile; no USD-authoritative live path; user review of fee semantics. No live payments yet. - -### P2 — production-scale state and transaction infrastructure - -Can run alongside P0/P1. Addresses SEC-01/SEC-06 rather than adding more fixed caps. - -**Implemented M2 increments, 2026-10-20–21:** [canonical indexes and durable market observation](canonical-indexes.md). Canonical ownership/transaction SQL indexes now serve NFT, deck, custody availability and mock-market queries; per-block undo and anchored connect/disconnect events commit with ledger updates. Market checkpoint/reorg holds persist atomically and bounded catch-up blocks new mutations. Order/listing reconciliation advances in durable, stable-ID keyset batches of at most 32 records per table, resumes after restart, and re-fences when the chain tip/revision changes. The HTTP process now uses a single worker thread to select/evaluate bounded row batches, read canonical facts using its own read-only Ledger connection, and atomically persist changes with cursor progress. The offline suite has **187 passing tests**; focused market tests passed 27/27; the disposable PostgreSQL suite passed 31/31 in the previous run. Regressions verify a main-loop timer runs during worker backlog and that a second server process cannot start against the same data directory. A synthetic 10,000 confirmed-delivery run with 160 loopback health/market requests measured worker HTTP p50/p95/max of 285/322/324 ms versus 1,416/2,844/2,971 ms synchronously. This single local run is preliminary (no repeated confidence intervals or realistic request arrivals), not an SLO. Main-thread event-page acquisition/post-batch fencing, realistic concurrent HTTP load, multi-process fencing beyond the existing exclusive directory lock, target hardware/data, production financial storage/recovery, retained-DAG capacity and operator SLO decisions remain open. - -**Next financial-storage increment:** [ADR 003 — PostgreSQL](postgres-financial-storage.md) selects and integrates the custody SQL backend with existing HTTP callers, exact constrained accounting, explicit migration/runtime roles, source-freezing SQLite import and dump/restore rehearsal. The adapter remains on the fictitious sandbox domain; it is not a production deposit/withdrawal worker. SQLite node-only backup deliberately refuses PostgreSQL-bound financial state. Production hardware/SLO tests, coordinated backup/PITR/failover, real payment schema/worker controls and independent review remain open, so this does not close M2/M4 or authorize funds. - -**Financial reconciliation increment:** [business-liability checks](financial-reconciliation.md) now tie each order/withdrawal to its exact reservation, journal lifecycle and pending work, including reverse orphan/duplicate checks. PostgreSQL startup/import/explicit/periodic audits and targeted mutations use these checks; redacted durable incident holds fence new postings, while private reads and original receipt replay remain available. Private operations reports audit freshness/holds, and schema-owner incident acknowledgement requires a successful locked full audit plus evidence reference. No automatic repair, real payment, schema reset or consensus activation is enabled. Production capacity, coordinated recovery and external review remain open. - -**Deployment increment:** [persistent local custody stack](persistent-custody-sandbox.md) now starts PostgreSQL and the actual app together, with private TLS/SCRAM networking, separate owner/runtime roles, explicit migrations and scoped persistent credential/data volumes. A signed fictitious credit and custodian identity survived container/network removal and recreation; the server rejected plaintext authentication. Existing deployments and SQLite custody were not switched. Production certificate rotation, coordinated backup/restore and external operating review remain open; all XMR remains simulated. - -- Indexed canonical ownership, NFT/transfer lookup and paged wallet/explorer APIs: browser signing must not require loading all history. Tip-pinned queries detect reorgs and preserve complete ownership semantics. -- Incremental market reconciliation from durable canonical/reorg events. **Local bounded implementation delivered:** rollback-safe canonical event pages, durable 32-row order/listing keyset jobs, tip/revision re-fencing, restart continuation, mutation fencing, and a single worker thread that selects/evaluates batches and reads its own read-only canonical SQLite snapshot before atomically committing results. Regression tests cover snapshot reads, reorg/event append, injected worker commit failures and restart recovery. **Still open:** move event-page acquisition and post-batch consistency checks off the request event loop, measure concurrent HTTP latency and queue lag, coordinate/fence processes, and establish throughput/overload/recovery limits at declared order/event volumes. -- A versioned production market/settlement schema with append-only financial events and balanced accounting entries for buyer liabilities, host revenue, author liabilities, seller liabilities, paid/refunded amounts and actual network fees. Derived views are rebuildable; financial history and idempotency keys are not discarded to reclaim space. -- Durable inbox/outbox, processor attempts and reconciliation states; no network operation inside a long DB transaction. Store intent before external side effects; unknown POST outcomes stay quarantined until evidenced resolution. Per-market isolation, actor-scoped paged reads, queue/backpressure and tenant/global exposure budgets. -- Database architecture ADR: retain a measured single-writer SQLite deployment if it meets declared limits, or choose a production DB/worker topology and supported migration. Do not claim horizontal safety merely by launching multiple workers against local state. Start with one authoritative writer per market. -- Ledger/DAG storage and startup-replay capacity plan: disk usage budgets, archival/reindex strategy, fork handling, retention rules that do not silently alter consensus or lose recovery evidence. - -Acceptance: indexed-vs-full-replay differential tests; adversarial reorg/crash/cursor tests; invariant/property tests for accounting; benchmark populated long-chain/journal fixtures at a declared target dataset and SLO; overload preserves validation/health capacity; explicit supported storage/node/operator envelope. Target numbers must be selected from measurements, not invented here. - -### P3 — implement the approved exchange protocol - -Blocked on P0 ADR; use P1 amount types and P2 durable events. Consensus work is a separately reviewed change. - -**Candidate increment, 2026-10-20:** [ADR 004 — sale locks](sale-lock-candidate.md) now specifies and implements an offline deterministic signed lock/completion/cancel/expiry model, exact height boundaries, native transfer/trade lock guards, replay-resistant sequences, state commitments and a non-posting financial observation policy. A pure bridge checks terms drafted from actual sandbox reservations. Final build and 162 offline tests passed. Active v6 consensus still rejects the candidate and no host signer or balance-posting integration is enabled. Independent review, policy approval, versioned network activation, actual PoW/peer/index integration and PostgreSQL terminal workflow remain open; M3 is not accepted. - -**Accounting rehearsal increment, 2026-10-20:** [signed candidate → PostgreSQL reservation resolution](custody-sale-rehearsal.md) now exercises actual restricted-role SQL terminal postings from bounded synthetic transcripts, behind explicit disposable database/instance guards and without an HTTP route. Exact terms/hosting recipients and reservation commitments are checked; mature completion credits seller/fees once, cancellation/non-admission releases buyer funds, and conflicting post-settlement evidence durably holds mutations rather than repaying. Insert-only first/terminal evidence is audited. Final build, 172 offline tests and 22 PostgreSQL integration tests passed. This supersedes the earlier absence of any balance-posting test integration, **not** the production/activation boundary: caller-supplied seeds/blocks are not canonical evidence, intermediate unseen disconnects cannot be detected, and no signer, canonical inbox, live deposit/withdrawal or current-chain paid sale is enabled. M3 remains open. - -**Canonical/per-order reorg rehearsal increment, 2026-10-20:** the gated disposable PostgreSQL observer stores bounded pages from an in-process `Ledger` adapter, checks stream/anchor/hash/tip/ancestor continuity, supports retries, and audits retained history. Tests cover a block mined and independently verified with native RandomX flowing through the local ledger into PostgreSQL, plus a fake-PoW equal-work branch/reorg. A separate synthetic-event test links the deterministic candidate terminal transaction ID to its posted order; disconnecting that ID (even followed by same-page re-inclusion) commits the event history and durable order review hold atomically, without undoing or repeating the payout. Generic incident resolution cannot clear a sale-reorg record. The server now has an opt-in polling lifecycle, restricted to PostgreSQL `custody_test`, both disposable flags and non-production mode; it drains on shutdown, resumes the persistent cursor after restart and stops on review/failure. Final build, 172 offline regressions and 30 PostgreSQL tests pass. The worker remains disabled in all regular deployments, active v6 cannot emit candidate IDs, and synthetic linkage is not canonical sale evidence. Stream hashes are not tamper-proof against the schema owner; 4,096-event cap/full scan are non-production. M3 remains blocked on reviewed candidate activation, actual candidate IDs from an activated local ledger, production observer/checkpoint design and production readiness. - -**Coordinated-backup rehearsal increment, 2026-10-20:** the stopped-node maintenance utility holds the node startup lock and PostgreSQL custody advisory writer lock, checks local/database custodian identity and frozen cutover digest, performs a full liability audit, snapshots active node SQLite state and writes a custom-format PostgreSQL dump through a dedicated SELECT-only backup login. The strict pair manifest is written last. Its integration mines and independently validates a genuine native-RandomX block, creates the archive, restores the node files into a new directory and reopens them through the reference validator (checking tip and NFT owner); it also restores PostgreSQL into a separate database and compares custodian ID, import digest, balances, audit and receipt state. A post-cutover frozen SQLite source is checked against PostgreSQL `import_source` and omitted as historical rather than misrepresented as current custody. Lock, identity mismatch, failed dump, incomplete output, overwrite, least-privilege and tamper cases are covered. Final build, 172 offline regressions and 31 PostgreSQL tests pass. The persistent sandbox received only an additive backup-role implementation; it was not restarted or migrated. The package is not encrypted/off-host/signed/scheduled and provides no PITR, retention, measured long-history timing, RPO/RTO or production recovery certification. Coordinated recovery remains open. - -- If a reviewed trustless design is selected: implement its actual cross-chain validity/evidence rules, compatible cryptographic primitives, both-party failure/refund paths and commission semantics. Run maintained reference vectors, negative tests, fuzz/property tests and specialist audit before claiming atomicity. -- If a custodial/attested fallback is explicitly approved: define chain-enforced NFT lock, order identity, authorized releaser, cancellation/refund/expiry and stale-signature rules. Separate host control keys from seller wallet and processor payout keys. Reserve/refund procedures must not permit both buyer refund and later seller delivery/release. Describe who can steal, censor or lock assets and how recovery is authorized. -- Completed-sale inclusion must require the approved evidence that payment is sufficiently confirmed. A peer must not insert a completed sale merely because its local database says paid. Define threshold/policy version, proof/attestation authorization and deterministic validation; no external HTTP calls in consensus validation. -- Define lock/release race outcomes, multi-market double-listing, expiry at exact heights/times, unavailability of the host/parties, reorg on either network, network upgrades and replay across canceled/relisted orders. -- If protocol data/rules change, use a reviewed new network/manifest/version and migration strategy. Do not change existing testnet/production rules under the old identity. Existing free transfers/card trades must not accidentally inherit false payment-proof claims. - -Acceptance: safety/liveness matrix covers every state transition and adversary; impossibility/trust limitations disclosed; reference-engine multi-host partition/stall/reorg and recovery tests; explicit protocol freeze and independent review. Delayed inclusion is displayed honestly—no “Monero-speed finality” claim. - -### P4 — production payment integration, reconciliation and controls - -Depends on the pooled-custody decision in ADR 002 and the separately reviewed NFT lock/completion/cancellation protocol. - -- Deposit enrollment and allocation: authenticate the Wikideck beneficiary, create a durable one-to-one mapping to a Monero subaddress before displaying it, and keep old allocations monitored. The spend-capable wallet must be isolated from the public API. -- Observation and credit: consume wallet-owned output records with stable per-output identity, canonical inclusion, versioned confirmation/unlock policy, exact atomic amounts and idempotent ledger writes. Unknown, late, duplicate, partial and reorged outputs require defined suspense/hold treatment; observations alone never authorize spendable credit. -- Marketplace settlement: reserve available customer liability and order state atomically in PostgreSQL; release seller/author/host liabilities only from the agreed canonical sale outcome. A user-signed mock-payment action is never payment evidence. -- Withdrawals: authorize exact destination, amount and maximum fee; reserve liabilities; fence one treasury worker; durably store prepared-transaction identity before relay; reconcile chain status and actual fee. Ambiguous outcomes must not trigger a second transaction or release of the reservation. -- Recovery and operations: include customer-to-subaddress mapping, wallet generation/high-water marks, journal, pending outputs, signed approvals and prepared transactions in encrypted backups. Restore without spending, reconcile with Monero state, then explicitly re-enable the treasury worker. -- Set measured liability/exposure caps and independent alert/stop controls. Stopping new deposits or orders must not abandon existing liabilities. Separate observation, ledger, operator review and spend-signing privileges. - -Acceptance: full end-to-end staging against an isolated Monero daemon and watch-only wallet, unauthorized-action tests, injected outages/timeouts/crashes/reorgs, complete deposit-to-ledger and withdrawal-to-chain reconciliation, and reviewed secret/signing controls. Any real-fund movement requires a separate explicit scope, budget and network authorization. This plan authorizes none. - -### P5 — buyer/seller policy, disputes and abuse - -Policy is a product deliverable, not a footer disclaimer; implementation and legal review must match it. - -- Publish host responsibility, contact/support channel, response targets, eligibility/evidence and who decides a dispute. Reserves and caps must be funded and enforceable operationally before they are advertised. No unqualified loss insurance or guaranteed reimbursement without a real funded obligation. -- Define ownership/delivery acceptance, payout hold, cancel/refund/release states and which party bears fees, reorg, key-loss, and late/partial/duplicate deposit outcomes. Prevent double compensation. An expired payment request does not erase a legitimate incoming payment. -- Host and author fees remain separately visible. Decide revenue recognition on settled sales versus refunds and who bears sending costs if seller payout succeeded but author payout failed. The author's fee does not imply author arbitration or host underwriting. -- US operator engages qualified counsel for applicable custody/money-transmission, sanctions, consumer, privacy and tax obligations before accepting real funds. Applicability is not decided by the software author/assistant or by calling the product “decentralized.” Do not assume identity collection is required or unnecessary without that review. -- Anti-abuse for fixed-price launch: short bounded reservations, address/account/request limits, deposit or admission policy only if approved, fee-aware dust handling, suspicious-activity and order-churn controls. Consider Sybil attacks; one wallet is not one person. -- Auctions later: dedicated decision on funded bids/deposits, outbid refunds, nonpayment/shill/bid-sniping policy and privacy, with accounting and dispute tests before enabling live auctions. Avoid forcing a large custodial balance system just to ship auctions. - -Acceptance: executable policy-to-state-machine tests, published versioned participant terms, working support/incident routing, operator sign-off on costs/loss allocations, required legal review. No fabricated SLA/support address or unfunded reserve claim. - -### P6 — release, wallet, deployment and independent review - -Run throughout development; final release gates depend on P2–P5. - -- Complete wallet review: same-origin threat model, migration/lock/unlock and recovery across supported browsers, signing review/session invalidation and stale handles; decide isolated/hardware signing support. Close old plaintext copies as a user migration/risk procedure, not a secure-erasure claim. -- Real host/edge verification: HTTPS/cert renewal, forwarded-header trust, firewall, admin access, secrets, egress, CPU/RAM/disk/WAL budgets, time sync, on-call alerts and monitoring of actual peer/tip and payment-worker progress. -- Deployment clarification: production target is behind a firewall in Kubernetes. Database certificate issuance/renewal belongs to the database/cluster infrastructure, not a bespoke application service. Verified PostgreSQL TLS remains the default; explicit [infrastructure-managed transport](postgres-financial-storage.md) is available only with acknowledged externally enforced encryption/peer authentication. A firewall/NetworkPolicy alone is insufficient evidence. Local Docker TLS provisioning remains sandbox-specific; no certificate-rotation feature is being activated. -- Backups/restore with complete financial journals and off-host encrypted copies: measured RPO/RTO and successful cold restore/reconciliation; test losses of host, journal, wallet key and processor reachability. Recovery of customer keys and recovery of merchant funds are different responsibilities. -- Repeat consensus/hashpower studies on intended miner/validator hardware. Quantify production attack and recovery costs; finalize difficulty/retarget/actual genesis after review. One powerful operator server is not multiple independent validators/miners or Monero-level security. -- Pin release artifacts/dependencies/base images, SBOM, secret/dependency/container/native scans, fuzz/sanitizer runs, reproducible/provenance evidence and signed artifacts. Publish the selected project license and respect dependency/Wikipedia licensing. -- External consensus/application/cryptography review against the exact release; close critical/high findings with regression evidence. This coding session is not independent certification. Provide a versioned technical and participant-facing disclosure set describing real guarantees, not stale mock behavior. - -Acceptance: signed release evidence packet; recorded unresolved accepted risks with owners, limits and expiry; named operator/reviewer go/no-go. Runtime preflight reflects verified evidence rather than blindly trusting a boolean operator attestation. - -## 4. Delivery order and parallel work - -| Milestone | Deliverable | Dependency / decision gate | -| --- | --- | --- | -| M0 | Threat model + trustless feasibility ADR + preparatory-lock decision | First critical-path design work; fallback requires operator approval | -| M1 | Exact XMR types, versioned signed terms, migration dry-run, fee-viability specification | Can start during research; no production processor assumptions | -| M2 | Canonical indexes/paged wallet, incremental workers, production accounting schema | Parallel infrastructure; measured dataset/SLO envelope required | -| M3 | Approved chain exchange protocol and deterministic payment evidence | M0 + M1; reviewed consensus/version/identity decision | -| M4 | Integrated payment/staging workflow, operator reconciliation and full backup coverage | M2 + M3 + verified native-XMR processor capability | -| M5 | Fixed-price policy/abuse/fee/custody acceptance and external review | M4; US legal/risk decisions; funded caps/reserves if applicable | -| M6 | Multi-host rehearsal, release artifact evidence, explicit launch meeting | All required findings/evidence resolved; network and payments separately approved | -| Later | Live auctions, broader independent-host rollout, higher exposure | Separate scope/capacity/abuse gates; not automatic launch follow-ons | - -First implementation increment: write the M0 threat model/ADR research questions and P1 XMR domain specification, then build exact amount/property tests. Do not spend the next increment adding another disconnected “live” scaffold. Each milestone must integrate with its actual callers, persistence, recovery, UI and failure tests before being described as delivered. - -Current increment status: the operator selected custodial implementation after [settlement research](settlement-feasibility.md). M1 has a signed v3 XMR-native mock path, with historical v2 USD orders preserved. [ADR 002](custodial-market-design.md) records the implemented isolated custody accounting/UI foundation; production custody is not complete. [Canonical indexing and durable reconciliation](canonical-indexes.md) now includes bounded, restartable, chain-revision-fenced market catch-up and has passed focused, offline, and disposable PostgreSQL regression suites. This closes one incremental-scheduling implementation gap, **not** M2 acceptance. Next gates include target-scale benchmarks, off-event-loop worker design, production accounting/recovery envelope and the separately reviewed NFT lock/completion/cancellation specification (M3); no live Monero integration or consensus activation is authorized by passing these tests. Migration dry-run, final fee/risk policy, capacity SLOs and independent review remain open. - -## 5. Acceptance evidence and release discipline - -For each requirement record: identifier, owner, ADR/policy version, code/schema version, tests and adversarial cases, measured limits, reviewed artifact hash, known residual risk and approving party. A test count alone is insufficient. Historical 122-suite/targeted-doc test results remain useful regression evidence, not proof of current production readiness; rerun against the frozen release. - -Release stages: - -1. Local deterministic/property/fuzz tests and migration on copied disposable data. -2. Isolated multi-process and multi-host staging, no valuable assets, partitions/stalls/reorgs/crash injection and full recovery. -3. Verified processor integration under separately approved network/credential/fund-testing limits, only where architecture and law allow it. -4. Independent review/remediation, policy/legal/operations sign-off and frozen manifest/release digest. -5. Explicit network authorization and separately explicit payment authorization. Initial exposure caps remain restrictive until a measured soak and reconciliation review succeeds. - -Never remove a NO-GO finding to make a dashboard green, reset a journal to clear payout uncertainty, or promise a launch date before dependency decisions and review. An architectural dead end is escalated with evidence and alternatives, not worked around with unannounced custody. - -## 6. Remaining operator questions — ask at the relevant milestone - -Not every value is needed to begin engineering; each becomes a blocking decision before its related activation. - -| Decision | Needed by | Why | -| --- | --- | --- | -| Preparatory on-chain NFT lock/reservation without completed ownership transfer | M0 — permission agreed | Approved for design research on 2026-10-20; exact rules and authority remain subject to ADR review. | -| What exactly is the XMR payment destination and amount condition: seller receives net directly, or gross into approved conditional/custodial structure? How are host/author fees enforced? | M0 | Buyer payment confirmation, seller proceeds and disbursement may be different events. | -| Acceptable trustless protocol liveness/cancellation delay, privacy disclosures and emergency recovery authority? | M0 | Cryptography cannot resolve an unstated trust/recovery policy. | -| Author-leg payout costs: charged to seller; what cap/estimate tolerance and quote lifetime? | M1 | Net viability and buyer/seller signatures must agree before money moves. | -| Tiny sale treatment: block checkout, subsidize under a published budget, or separately approved accumulation/batching? | M1 | No listing minimum does not mean every amount can economically settle directly. Default planning assumption is block uneconomic checkout, pending explicit confirmation. | -| Per-sale, daily and total unsettled exposure caps; initial reserve and funding source; who bears which reorg/processor losses? | M4–M5 | Cannot claim buyer/seller loss coverage or fee funding without numbers and funded responsibilities. | -| CPU model/resources, storage, concurrency/user/order dataset targets, operators expected at each scale milestone and latency SLOs? | M2 | “Powerful server”/“large volume” is not a benchmark acceptance criterion. Operator count is independent of per-host capacity. | -| XMR confirmation/unlock and Wikideck finalization policy; payment/disbursement timing UX? | M3–M4 | No universal “fully confirmed” threshold; distinguish ordering, inclusion and risk. | -| Dispute/support SLA, authenticated operator approvals, backup RPO/RTO and incident on-call? | M5–M6 | Procedures must work before promising them to customers. | -| License, actual US legal operating entity/jurisdiction scope and required legal advice? | Before public distribution/live funds | These cannot be selected or certified by code changes alone. | - -## References - -- [Current participant payment disclosures](market-transparency.md) — describes existing behavior, not future promises. -- [Security findings](security-review.md) and [operator hold-point register](operator-launch.md). -- [Resource controls](node-resources.md), [wallet lifecycle](wallet-security.md), [calibration evidence](consensus-calibration.md), [operations/recovery](operations-recovery.md). - -This plan supersedes the earlier USD production-pricing direction. New mock listings already use native XMR; historical USD terms remain intact. Custody and M2 implementation status are explicitly bounded above; no sandbox or infrastructure milestone implies live settlement or launch readiness. diff --git a/docs/real-xmr-pilot-deployment.md b/docs/real-xmr-pilot-deployment.md deleted file mode 100755 index b769c46..0000000 --- a/docs/real-xmr-pilot-deployment.md +++ /dev/null @@ -1,94 +0,0 @@ -# Private pilot deployment: mock-only foundation - -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 - -Use [compose.custody-pilot.yaml](../compose.custody-pilot.yaml) with the stable project name `wikideck-xmr-pilot`. Do not combine it with [the existing local custody sandbox](../compose.custody.yaml), the disposable PostgreSQL fixture, or another pilot project name. Project-scoped volumes hold the pilot's PostgreSQL database, credentials, and node state separately. - -Build/start with `docker compose -p wikideck-xmr-pilot -f compose.custody-pilot.yaml up -d --build`. Open `http://127.0.0.1:3423/` and confirm the page identifies **PRIVATE PILOT**. The service binds to loopback by default; set `WIKIDECK_PILOT_PORT` only when changing the local port. - -The app and browser bundle both select the `pilot` identity. Startup requires `WIKIDECK_PILOT_MODE=private-mock-only`; payments are fixed to `mock`. The market sandbox and PostgreSQL schema are freshly bound to this distinct chain ID. Do not point this stack at existing testnet/mainnet node or custody volumes. - -To stop while preserving pilot data, run `docker compose -p wikideck-xmr-pilot -f compose.custody-pilot.yaml down` without volume removal. Do not use `--volumes` unless the database, credentials, and node history are intentionally being destroyed. - -## What this enables—and does not - -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. - -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) - -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 the separate pilot reservation table; v12 adds one-time address registration; v13 persists a create-intent state before the Wallet-RPC side effect. A signed beneficiary/wallet request reserves a never-reused subaddress index transactionally, with request replay, same-beneficiary recovery, concurrent index allocation, and conflict protection. Mainnet allocation/withdrawal constraints and v1-v12 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. - -After an operator creates/provisions a distinct empty database and its schema-owner connection, the CLI workflow is: - -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`. -4. Before any operator-triggered address exercise, create a separate Wallet-RPC role out of band and run `custody:db grant-pilot-deposit-address-runtime ` with `WIKIDECK_PILOT_CUSTODY_OPERATIONS=operator-triggered-addresses-only`. - -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 CLI refuses other custody actions on the pilot profile; mainnet keeps its existing separate controls. - -The operator/library-only address path verifies the signed pilot request, checks both network identities and configured wallet-generation/fingerprint/account, and uses the narrow Wallet-RPC client to inspect/create at the exact reserved index. It requires the stable reservation label and independently reads back/validates the returned mainnet subaddress before recording it. A create-intent timestamp is committed before the RPC side effect. If the process loses the response but the address exists at that exact index/label, a retry recovers it without creating another address. If no matching address exists after a create intent, retries stop with an uncertain outcome for manual reconciliation; they do **not** issue another create call. An unexpected consumed index is likewise held for operator investigation. - -There is still no HTTP enrollment route, observer startup, customer credit, withdrawal, or sale activation. The function returns an address only to its operator/library caller. Keep Wallet-RPC off the public Internet and the credentials private. Existing mainnet enrollment remains unchanged. Do not treat an allocated address as approval to accept funds. - -### Reusing the operator-hosted daemon - -The next integration should point the observer/gateway to the existing daemon rather than deploy or synchronize another one. An operator-triggered, read-only `get_info` probe is now available as a first connectivity check. It checks the expected Monero network, synchronized/local height, and daemon status; it does not open a wallet or provide a financial proof. The probe is opt-in and does not run on app startup. - -Create a protected JSON config file outside the repository with `network` set to `mainnet` (or `stagenet` for a rehearsal), `url` set to the daemon's private literal-IP `/json_rpc` endpoint with its explicit port, and `allowPrivateHttp` set to `true` only when deliberately using HTTP on a trusted private network. For example: - -```json -{ - "network": "mainnet", - "url": "http://192.168.1.20:18081/json_rpc", - "allowPrivateHttp": true -} -``` - -Replace the example address with the operator's actual daemon address. Protect the file with owner-only permissions/ACLs. No credentials belong in this file. The endpoint is deliberately restricted to a private literal IP; public hosts and DNS names are rejected. - -From the pilot Compose project, run `docker compose -p wikideck-xmr-pilot -f compose.custody-pilot.yaml --profile monero-check run --rm daemon-check`; set `WIKIDECK_MONERO_DAEMON_CONFIG_HOST` to the config file path if it is not at the documented default. This runs from the Compose network, making it a better reachability check than testing only from the host. It emits the network, height, version and result, but not the endpoint. The current probe does not implement Monero's Digest `--rpc-login`; if the daemon requires it, do not disable auth or publish RPC to make this check work. Put a separately reviewed, authenticated TLS proxy on the private path or defer the check until a secure supported transport is configured. - -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 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. - -### Pilot-bound observation persistence (schema v14) - -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. 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) - -An operator can now prepare a review subject from **already persisted** pilot observations. This does not run Wallet-RPC, scan the daemon, approve the output, stage a pending liability, create a journal, change balances, or expose an HTTP endpoint. It is not authorization to accept or spend funds. The separate `pilot_deposit_review` role reads only schema digests, the pilot identity/reservations, and pilot observer state/scans/observations/events; it cannot write pilot data or read financial tables. Do not reuse the observer, address, reservation, app-runtime, or credit role. The grant refuses roles with administrative powers, role memberships, custody object ownership, or database `CREATE` privilege. - -After creating that role out of band, use the schema-owner connection to run `custody:db grant-pilot-monero-review-runtime ` with `WIKIDECK_NETWORK=pilot`, `WIKIDECK_PILOT_MODE=private-mock-only`, and `WIKIDECK_PILOT_CUSTODY_OPERATIONS=read-only-pilot-review-role-only`. The separate database should already have the pilot schema and immutable identity. The grant does not issue addresses or enable crediting. - -Configure a separate review-role PostgreSQL connection via protected `WIKIDECK_PILOT_REVIEW_PG_URL_FILE` and `WIKIDECK_PILOT_REVIEW_PG_CA_FILE` files; TLS and CA validation are mandatory, and the URL's role must equal `WIKIDECK_PILOT_REVIEW_DB_ROLE`. Then run the operator-only action `custody:db pilot-deposit-prepare-review ` with `WIKIDECK_PILOT_CUSTODY_OPERATIONS=pilot-deposit-review-preparation-only`. The default confirmation floor is 10 and can be set with `WIKIDECK_PILOT_DEPOSIT_REVIEW_MIN_CONFIRMATIONS` (bounded 10–100000). Maximum observation age defaults to 300000 ms and can be set with `WIKIDECK_PILOT_DEPOSIT_REVIEW_MAX_OBSERVATION_AGE_MS` (bounded 10000–900000). The command fails closed for unallocated/mismatched outputs, low confirmations, future unlocks, stale or inconsistent scans, double-spend flags, missing/reorged/changed history, malformed atomic amounts, and identity mismatch. - -The JSON result is an unsigned operator evidence snapshot with an integrity digest—not a credit approval or finality proof. It includes public wallet/allocation identifiers and a Monero address; protect terminal capture and any saved output accordingly. Even a successful preparation proves only what the persisted observer reported at scan time. Current chain canonicality, daemon trust, wallet correctness, and finality still need independent operational checks. No posting/release action is available under the pilot profile, and cash-out/recovery remains a prerequisite before any real deposit can be accepted. - -## 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 deleted file mode 100755 index 5c739a3..0000000 --- a/docs/real-xmr-pilot-plan.md +++ /dev/null @@ -1,131 +0,0 @@ -# Get the real-XMR market running - -Updated: 2026-10-22. Implementation plan for a private, operator-supervised functional pilot—not a public production release. Wallet access and fund movement require separate explicit approval. - -## Progress checkpoint — 2026-10-22 - -- **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. -- **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. -- **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. -- **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 - -Two browser wallets complete this flow on one deployed stack: - -**Buyer deposits XMR → available balance → fixed-price purchase → card ownership changes → seller receives proceeds → seller withdraws XMR.** - -Use the existing pooled-custody architecture, PostgreSQL accounting, native Monero integration and UI. Do not restart architecture research, introduce another payment provider or build a general-purpose payment platform. - -## Reuse what exists - -- Exact atomic-XMR prices and fee calculations. -- PostgreSQL schema v14, including pilot identity, reservations, persisted observation, and append-only reorg evidence. -- 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. - -The missing work is integration plus two substantial features: **actual withdrawal execution and activated NFT sale settlement**. Current market commands remain sandbox-only; reserved withdrawals never send XMR; active consensus rejects sale-lock transactions. - -## Smallest useful scope - -One Linux host, one authoritative market writer, one treasury tool/worker, fixed-price sales and operator-controlled accounts initially. Manual signed deposit approvals and operator-triggered sends are sufficient. Reuse existing UI components; no admin dashboard, unattended treasury or auction system is needed. - -## Implementation sequence - -### 1. Make one pilot stack runnable - -- Add an explicitly identified private pilot profile for the sale protocol, distinct from existing v6 and the final mainnet candidate. -- Bind signed market/payment requests to the pilot chain and custodian; select the Monero network explicitly. Real XMR requires Monero mainnet, not a public Wikideck launch. -- Give the pilot its own explicit genesis/startup configuration rather than inheriting the candidate's January 1, 2027 delay and public-peer requirements. Leave existing mainnet protections unchanged; do not fake review acknowledgements or alter v6 in place. -- 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 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, 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. -- 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:** 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 - -- Extend signed reservations with durable preparation, prepared-transaction, relay and confirmation states, including an uncertain/review outcome. -- Claim a request exclusively and block cancellation once preparation begins. -- Construct/sign using the separate treasury wallet, enforcing destination, amount and maximum fee. -- 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. - -**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 - -Start this alongside steps 2–3 after defining the pilot identity. Do not wait for a separate release-hardening phase. - -- Integrate the existing lock/completion/cancellation/expiry model into pilot transaction validation, state commitments, mempool, canonical indexes/events, peer processing and restart replay. Use the existing RandomX ledger, not a toy substitute. -- Adapt custody listing/purchase commands for real balances and fresh signatures. Atomically reserve buyer funds with the order; exclude mock funding/payment authorizations. -- Collect seller/buyer lock consent, submit the lock and have the host attest completion only against the funded reservation. -- Feed actual canonical outcomes into PostgreSQL settlement instead of synthetic rehearsal witnesses. -- Credit seller, author and host once after configured confirmation depth; release buyer funds only after safe cancellation/expiry/non-admission evidence. Hold conflicting reorg outcomes rather than paying/refunding again. -- Allow withdrawal of legitimate sale and fee earnings. The current own-deposit backing limit prevents a seller who never deposited from cashing out. Replace it with pooled-backing accounting covering liabilities, pending sends and fees—not merely removal of the check. -- Apply existing accounting checks and incident holds to the connected sale and treasury writers. -- Adapt the existing marketplace UI to show funded checkout, lock/completion progress, delivered ownership and proceeds. - -**Result:** seller lists → buyer buys → card changes owner → exact seller/fee balances appear → seller withdraws, even without a prior seller deposit. - -### 5. Exercise the money path narrowly, then consider real XMR - -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. - -## Parallel work - -| Workstream | Start | Delivers | -| --- | --- | --- | -| 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 | -| 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. - -## Move out of the functional critical path - -Track these separately as public-release/hardening follow-ups, rather than front-loading them as engineering phases: - -- Broad audit/certification and release evidence packets. -- Production-scale benchmarks, SLOs, HA, multi-host operations and additional orchestrators. -- SBOM/provenance automation, comprehensive scanning programs and artifact signing. -- Automated certificate rotation, backup scheduling, off-site recovery certification and full on-call systems. -- Automatic credit approval, unattended signing, batching and admin dashboards. -- Auctions, fiat conversion, alternate processors and final public-mainnet launch preparation. - -Keep the functional essentials: signed authorization, correct network binding, private wallet access, exact balanced accounting, one-time settlement and recoverable sends. Deferring broader hardening does not make this production-ready; public/customer-fund rollout still needs the separate policy/legal/security decisions in [the production plan](production-plan.md). - -## Next coding task - -**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/docs/sale-lock-candidate.md b/docs/sale-lock-candidate.md deleted file mode 100755 index 76f9853..0000000 --- a/docs/sale-lock-candidate.md +++ /dev/null @@ -1,78 +0,0 @@ -# ADR 004 — Custodial NFT sale-lock candidate - -Status: **offline executable candidate; NOT activated or independently reviewed**. Evidence: 2026-10-20. Related: [production plan](production-plan.md), [custodial model](custodial-market-design.md), [PostgreSQL storage](postgres-financial-storage.md). - -## Purpose and activation boundary - -Specify the missing asset-control rules before enabling real custodial purchases. An ordinary signed NFT transfer can survive a website refund deadline; balance reservations alone cannot prevent that late delivery. This candidate couples sale authorization to explicit canonical locks and terminal outcomes. - -[Signed schemas/messages](../shared/sale-candidate.ts) and [deterministic state machine](../server/testnet/sale-candidate.ts) are deliberately separate from active consensus. The existing transaction schema rejects all candidate transactions. The candidate domain derives from the existing chain identity, explicit policy and candidate version; it is an offline signature domain, **not a reviewed network manifest/genesis**. No current chain identity, miner, peer endpoint or custody signing capability is changed. A separately gated [disposable PostgreSQL accounting rehearsal](custody-sale-rehearsal.md) now changes fictitious reservations using signed synthetic transcripts; it is not mined-chain integration. - -The pure `termsForCustodyOrder()` bridge drafts terms from an actual awaiting-lock reservation and checks exact seller/author/host allocations. It does not sign, persist, relay or debit anything. Tests exercise this bridge against the current custody engine. Public custody orders continue stopping at `awaiting-lock`. - -## Signed authorization - -Terms bind candidate protocol, network, chain/policy digest, custodian/order IDs, exact card/sequence, seller, buyer, host signing identity, native-XMR gross, hosting rate, fixed author policy, lock-admission deadline and final expiry height. Independent downward commission rounding remains unchanged. No production threshold is invented: maturity/depth/window limits are explicit policy inputs committed by the candidate digest. - -Optional `hostFeeAddress` appends a tagged hosting-recipient tuple, preserving historical candidate signature bytes when omitted. Drafting preserves the reserved order's snapshot; the PostgreSQL rehearsal additionally checks the exact listing rate, not just rounded allocation equality. The candidate performs address-shape validation only, with checksum/network validation supplied by custody admission and business audits. This extension is still unactivated and requires the same protocol review as the candidate. - -- **Lock:** both seller and buyer sign the complete terms. Host signing identity must differ from both trading identities. Distinct keys are role separation, not proof of independent humans/operators. -- **Completion:** the terms' exact host signs the sale ID plus a reservation digest, attesting that sufficiently confirmed/unlocked funds were reserved under the approved policy. Host cannot select another card/recipient/price. Validators would verify the host's authorization, **not Monero truth**. The reservation digest is an accounting commitment, not a cryptographic proof of backing or solvency. -- **Cancellation:** host and buyer sign the sale ID. Neither can unilaterally cancel early. Seller already consented to this branch in signed lock terms; seller retains card ownership on cancellation. -- **Expiry:** permissionless explicit transaction, without a private key, once the canonical expiry height is reached. Website clocks/RPC errors never cause implicit release. - -## State transitions and exact heights - -Let $h$ be the block executing a transaction, $L$ the accepted lock height, $A$ the last lock-admission height, $E$ the expiry height, $d_L$ the lock-depth policy and $d_O$ the outcome-depth policy. - -| Transition | Validity / result | -| --- | --- | -| Unlocked → locked | Both signatures, mature current seller ownership/sequence, no existing lock, unused custodian/order; $h\le A$, $A+d_L&offset=0&limit=16` | Bounded state metadata and current-tip-pinned canonical pages. Full legacy snapshots are limited to 128 blocks and the response-byte budget. See [resource controls](node-resources.md). | -| `GET /api/network` | Selected profile, pinned chain/genesis, candidate launch status and mock-payment boundary. | -| `GET /api/market?offset=0`, `POST /api/market/command` | Node-local mock-payment marketplace; strict signed listing, bid, checkout, delivery and private activity commands. Not consensus escrow. See [marketplace operations](marketplace.md). | +| `GET /api/network` | Selected profile, pinned chain/genesis, and candidate launch status. | | `GET /api/nfts/:id`, `/api/peers` | Canonical NFT owner/sequence/confirmations, article and at-mint statistics; configured peer status. | | `GET /api/decks/:address?offset=0&limit=24` | Public canonical deck for a wallet address at one tip; paged, maximum 24 cards per response. This reveals public address holdings. | | `GET /api/wikipedia/:pageId` | Cached current enrichment for positive English Wikipedia page IDs: categories, views, size, languages and display-only scores. Never a consensus input. | @@ -92,6 +91,6 @@ Sync polls every **5 seconds**, checks network/chain ID, imports at most **32** | `GET /p2p/status`, `/p2p/blocks?offset=0`, `/p2p/mempool` | Manifest/size/tip, 32-block inventory pages, pending transactions. | | `POST /p2p/block`, `/p2p/transaction` | Same validation as local block/transaction submission; transaction endpoint accepts a transfer or atomic trade. | -POSTs require JSON, enforce supplied browser origins and signatures where applicable; peers without an Origin header are not authenticated by this check. HTTP parsing is capped at 68 KiB; API/P2P rate limits are 300/minute and block/transaction/trade submissions additionally 30/minute. Marketplace commands additionally allow 60/minute. There are no consensus fees, peer authentication or economic spam deterrents; market fees are off-chain simulations only. Same-origin browser RPC trusts the node; it does not independently verify these commitments. Deploy no funds: majority-hashpower, eclipse, censorship, reorg and resource-exhaustion risks require independent audits and adversarial testing before any broader launch. +POSTs require JSON, enforce supplied browser origins and signatures where applicable; peers without an Origin header are not authenticated by this check. HTTP parsing is capped at 68 KiB; API/P2P rate limits are 300/minute and block/transaction/trade submissions additionally 30/minute. There are no consensus fees, peer authentication, or economic spam deterrents. Same-origin browser RPC trusts the node; it does not independently verify these commitments. Deploy no funds: majority-hashpower, eclipse, censorship, reorg and resource-exhaustion risks require independent audits and adversarial testing before any broader launch. -Testnet storage and chain identities above remain unchanged. The [gated mainnet candidate](mainnet-launch.md) selects separate chain/signature domains, storage, lock and wallet identities; it is not a running or certified production network and still rejects real payment modes. +Testnet storage and chain identities above remain unchanged. The [gated mainnet candidate](mainnet-launch.md) selects separate chain/signature domains, storage, lock and wallet identities; it is not a running or certified production network. diff --git a/docs/wallet-security.md b/docs/wallet-security.md index b89ba7b..2dfb01d 100755 --- a/docs/wallet-security.md +++ b/docs/wallet-security.md @@ -23,7 +23,7 @@ Replacing a record does **not** securely erase previous browser storage pages, O ## Session and locking -UI components receive a public `Wallet` handle only; private keys remain inside the wallet module. All NFT transfer, trade and marketplace signatures now go through its session signer. A stale handle from before locking/re-unlocking is rejected even for the same address. +UI components receive a public `Wallet` handle only; private keys remain inside the wallet module. All NFT transfer and trade signatures go through its session signer. A stale handle from before locking/re-unlocking is rejected even for the same address. - Explicit **Lock wallet now** clears the module's session references immediately. - Five minutes without trusted pointer/keyboard input locks the session. RPC polling and automated signing do **not** reset idle time. diff --git a/docs/xmr-domain.md b/docs/xmr-domain.md deleted file mode 100755 index 9ccff57..0000000 --- a/docs/xmr-domain.md +++ /dev/null @@ -1,57 +0,0 @@ -# Native-XMR domain and migration specification — first increment - -Status: exact-amount primitives and version-3 **native-XMR mock marketplace pricing** are implemented. No live settlement architecture/payment, production accounting migration or approved network-cost policy is delivered. See [production plan](production-plan.md) and [settlement investigation](settlement-feasibility.md). - -## Implemented amount contract - -[Shared amount primitives](../shared/xmr-amount.ts) use bigint internally and strings at JSON/signature/storage boundaries. One XMR is 1,000,000,000,000 atomic units. No float parsing, rounding, exponent notation, signs, whitespace, leading-zero whole numbers, coercion or more than twelve decimal places is accepted. Zero is valid for fee/observation amounts; [sale allocation](../shared/xmr-pricing.ts) requires a positive gross, including a one-atomic-unit price. Gifts remain ordinary free transfers. - -- Decimal input: e.g. `1`, `1.2`, `0.000000000001`. Display/processor output has exactly twelve fractional places, e.g. `1.200000000000`. -- Canonical atomic wire value: integer string, e.g. `"1200000000000"`; zero is `"0"`. Never a JSON number or serialized bigint. Alternative spellings are rejected rather than silently normalized inside signed records. -- Encoding bound: 9,999,999,999.999999999999 XMR (22 atomic digits), retaining the former processor parser's bound. This is a bounded application representation, **not Monero's supply limit, a spendable amount, an exposure cap or approval of a large checkout**. -- Wallet-RPC individual amounts have a separate unsigned-64-bit bound; `assertXmrRpcAmount` checks it. An eventual adapter must also enforce operational/available-funds limits and lossless JSON encoding/decoding. JavaScript `JSON.parse` of large numeric RPC amounts loses precision; converting a rounded Number to bigint does not recover it. No wallet-RPC transport is added here. - -Existing `atomicXmr` and `xmrString` callers now reuse the shared parser/formatter. The mock quote and offline settlement lab reuse the same independent commission allocation. Valid existing amounts, USD semantics, message domains, database records and displayed mock totals remain unchanged. Trailing newline acceptance from JavaScript's `$` regex anchor is deliberately removed; amounts require absolute end-of-input. - -## Commission and budget arithmetic - -Given atomic gross G, hosting basis points H (0..5000), and fixed author basis points A=100: - -- hosting = floor(G × H / 10000) -- author = floor(G × A / 10000) -- seller before network costs = G − hosting − author - -Each commission rounds independently down; every residual atomic unit remains in seller proceeds. Do not round a sub-atomic 1% fee up to one atomic unit. A zero author allocation is not a deferred balance. The existing fixed public author destination remains unchanged in [market policy](../shared/market.ts). - -`assessXmrSaleBudget` evaluates a supplied total seller-funded maximum network-cost budget B. It reports viable only when seller proceeds before network costs exceed B, leaving at least one atomic unit. A blocked result contains zero minimum net and a funding shortfall, not a negative payable amount. Shortfall is **additional funding at the current allocation**, not a recommended price increase (changing gross can also change commissions). - -This pure result is not a payable quote, does not estimate real network fees, and does not silently approve fee policy. Caller must include every applicable seller-funded leg exactly once. Buyer wallet sending fees are outside G and B. The budget is not a host fee or revenue; approved accounting must record actual costs and disposition of unused budget. Fee spikes cannot authorize spending beyond signed limits. - -Pending operator decisions: author-leg cost allocation/caps, estimator/quote expiry, overrun handling and explicit tiny-checkout policy. The planning assumption is to block uneconomic checkout **before** accepting payment, with no subsidy, pooling or accumulated customer balances. No live checkout currently calls the budget helper. - -## Versioned terms - -New commands use `market-command-v3` and strictly bind network/chain, market, actor/public key, nonce, expiry, action, currency `XMR`, and canonical atomic gross for listing/bid/buy. List and buy terms bind the fixed author rate/address; listing terms bind hosting basis points. New public sale prices no longer use USD or a conversion rate. The current signed model is intentionally a **mock-market terms schema**, not yet the full production economic settlement commitment. The eventual production terms need a reviewed new protocol/domain version (not a silent reinterpretation of v3) binding at least: - -- network and chain identity, market and order/listing identity, nonce and expiry; -- card hash/ownership sequence, seller and buyer authorization identities; -- currency `XMR`, canonical gross atomic string, hosting basis points/allocation and destination; -- fixed author basis points/address and exact independent allocation; -- seller destination, seller minimum net, total/leg-specific network-cost caps and cost policy version; -- payment destination/conditional-output commitment, payment-evidence/confirmation/unlock policy and selected exchange protocol version; -- lock identity, release/refund/cancel conditions and relevant deadlines. - -The buyer must review immutable exact terms and addresses; the seller must authorize network-cost exposure. Validate supplied allocations by recomputation, not trust them because they are signed. A changed quote needs fresh authorization; callback status cannot amend it. The mock v3 checkout currently derives the quote only from the exact gross and hosting rate; it does not set live addresses or use an exchange rate. - -## Non-destructive migration specification - -1. Inventory copied, stopped-node market data by schema/order state; record counts, original terms/receipts and hashes. Keep existing recovery contract until a reviewed new schema includes all financial journals. The v3 runtime reads mixed old/new JSON rows without rewriting old USD rows. No destructive database migration is run here. -2. Keep historical USD/mock listings/orders and `market-command-v2` authorization material in their original version. Never convert `priceCents` into XMR, infer an old seller XMR address, synthesize a signature or issue a real invoice from old orders. -3. New v2 USD listing creation, bids and checkout are rejected. Existing v2 reserved orders can still complete their old **mock** pay/refund/delivery route with v2 signatures. This is compatibility, not an external payment obligation. Do not relist their cards while an order is reserved/in flight. -4. Old open USD listings are displayed as legacy/read-only; where eligible (seller-owned and no bid), the seller can close the listing with a fresh v3 authorization. Auctions with bids cannot be retired through this action and expire under the original sandbox rule. Sellers create a new XMR listing with a new v3 signature. Old records and receipts remain; no price conversion or automatic bid transfer. -5. Fixed-price and sandbox-auction v3 terms are native-XMR mock routes. No real payment processor can use them. Before live fixed-price delivery, selected settlement evidence, index/accounting/recovery controls, policy and disclosures still require integrated implementation and review; live auctions remain a separate later gate. -6. Full stopped-copy migration dry-run, complete mixed-record upgrade/restart tests, rollback rehearsal and journal hash/count evidence remain outstanding. No external effects occur in this mock path. Before any future real external effect, a local DB rollback alone is unsafe: reconcile external history first and retain authoritative intents/events. - -Versioned XMR signature tests, direct HTTP v3 integration, one-atomic-unit no-minimum fee tests, and legacy-order/listing compatibility tests cover this increment. A full stop-copy data migration dry-run, interrupted-upgrade/rollback evidence, approved network-cost policy, live processor integration and settlement are not covered. [Amount tests](../tests/xmr-amount.test.ts) cover boundary/coercion/newline/precision cases, RPC range, independent rounding, fee-budget exhaustion/spikes, 4,000 deterministic generated conservation/round-trip cases and original mock caller behavior. They are not settlement feasibility or launch evidence. - -Validation for this increment (2026-10-20): Docker test-target build passed TypeScript and Vite compilation; the full suite ran with external networking disabled and passed **133 tests, 0 failures, 0 skips**. Includes API v3 signing, one-atomic minimum, legacy read-only listing retirement, and in-flight USD order restart compatibility. This evidence does not close the remaining M0 settlement, fee-policy, data-copy dry-run or launch gates. diff --git a/package-lock.json b/package-lock.json old mode 100644 new mode 100755 index 36c137d..b5eca26 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "hash-wasm": "^4.12.0", "helmet": "^8.1.0", "lucide-react": "^0.468.0", - "pg": "^8.16.3", "react": "^19.1.0", "react-dom": "^19.1.0", "zod": "^4.1.0" @@ -21,7 +20,6 @@ "devDependencies": { "@types/express": "^5.0.3", "@types/node": "^24.0.0", - "@types/pg": "^8.15.5", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^5.0.0", @@ -31,85 +29,6 @@ "vite": "^7.1.0" } }, - "node_modules/@types/pg": { - "version": "8.23.1", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", - "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", - "dev": true, - "license": "MIT", - "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } - }, - "node_modules/pg": { - "version": "8.23.1", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", - "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", - "license": "MIT", - "dependencies": { "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.1", "pg-types": "2.2.0", "pgpass": "1.0.5" }, - "engines": { "node": ">= 16.0.0" }, - "optionalDependencies": { "pg-cloudflare": "^1.4.1" }, - "peerDependencies": { "pg-native": ">=3.0.1" }, - "peerDependenciesMeta": { "pg-native": { "optional": true } } - }, - "node_modules/pg-cloudflare": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", - "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", - "license": "MIT", "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", - "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", - "license": "MIT" - }, - "node_modules/pg-int8": { - "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", "engines": { "node": ">=4.0.0" } - }, - "node_modules/pg-pool": { - "version": "3.14.0", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", - "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", - "license": "MIT", "peerDependencies": { "pg": ">=8.0" } - }, - "node_modules/pg-protocol": { - "version": "1.16.1", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", - "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" }, "engines": { "node": ">=4" } - }, - "node_modules/pgpass": { - "version": "1.0.5", "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", "dependencies": { "split2": "^4.1.0" } - }, - "node_modules/postgres-array": { - "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", "license": "MIT", "engines": { "node": ">=4" } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } - }, - "node_modules/postgres-date": { - "version": "1.0.7", "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", "license": "MIT", "engines": { "node": ">=0.10.0" } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "license": "MIT", "dependencies": { "xtend": "^4.0.0" }, "engines": { "node": ">=0.10.0" } - }, - "node_modules/split2": { - "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "license": "ISC", "engines": { "node": ">= 10.x" } - }, - "node_modules/xtend": { - "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "license": "MIT", "engines": { "node": ">=0.4" } - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -253,9 +172,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", "dev": true, "license": "MIT", "engines": { @@ -297,13 +216,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", - "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz", + "integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.8" + "@babel/types": "^7.29.11" }, "bin": { "parser": "bin/babel-parser.js" @@ -379,13 +298,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-string-parser": "^7.29.11", "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { @@ -892,6 +811,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -909,9 +831,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.10.tgz", - "integrity": "sha512-oJ9ZfVIZjSjts0ZIYA48+5T0vurE5fQfX/Liad2U52Y6EN99x/OCdjGGuKMDP6jSk2i2lY6jwwA3X0drEc4PBg==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.66.0.tgz", + "integrity": "sha512-7y4XeoUHPecjfUDRmap8U0q6bjfLAPn7uOGT8BKyqy8uNDOjsGS8rlBhOx6sZLVwwjN6hapvGl2mW9/IS6UNww==", "cpu": [ "arm" ], @@ -923,9 +845,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.10.tgz", - "integrity": "sha512-LylBINgYYwgHNz8OMCy5+Y0w/LEgRYE0PcAIZfEuTuLNMf0S40GSLX6SFwqZsBLplS7RIHHy4zOAbl8Y5cQEvg==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.66.0.tgz", + "integrity": "sha512-e8WugYZXpgqrlMXLCudYVRAxAcK7AIY36JxRDwFRD38L9q62G1VsEUW6ijXxzjxYjNB8i1zo3B/ycIQ/qjLC/Q==", "cpu": [ "arm64" ], @@ -937,9 +859,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.10.tgz", - "integrity": "sha512-vbuT3VT1GBI2fAOoUJUrsHVoOg9nbrFXTM7e3DSnb9PYf4n6jZCN8v4Vo8O6x4u5B67+/c803b8BtCZe2nIeqA==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.66.0.tgz", + "integrity": "sha512-6vkDCfyDjNLokh+Rt1D2KtNgFFMEPdkWha/mbtP4cSjh9xiN8T/JBS/A7nDYKjmMAXHrvR9J3AzUk2LtFbIBLg==", "cpu": [ "arm64" ], @@ -951,9 +873,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.10.tgz", - "integrity": "sha512-qYUp/qYvM3T4cF+VWalPl+przBUESILxi+qZ1m1MVRHpg06vS+jHaxXubjCaRpXjLJPDFdXzOZ0FABDghHOhNQ==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.66.0.tgz", + "integrity": "sha512-/mzZAX0+Q6eXxvkR0HB0RZCCrsbmFD8DpsvCQoMYABDfq/DjqkbfytuJ7GvZ4AhR/Mgis2fXbZrGqfbcK2Z5fA==", "cpu": [ "x64" ], @@ -965,9 +887,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.10.tgz", - "integrity": "sha512-v9NRQG+JztbP4DP/0yuhSyDcEJk047lYbqeNnqIgbXITcVDjGDS7OpkW5nadSY3tJ7MY8W1qIMMqP4Q3Q8BDjQ==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.66.0.tgz", + "integrity": "sha512-JQJx9E8tUjGqed1RevDmoHaS3V30ODdV8uyvGxlwd4K5se6bkPrMeltSybqAtpJ+ltDRfo7FORQaJG2Hd5nGog==", "cpu": [ "arm64" ], @@ -979,9 +901,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.10.tgz", - "integrity": "sha512-CHc9LBHdxhn6/NXfb9IegXCWoz5VlvcDOx9QrzZ9q+XYhFlfzwymSWJjOlZcEMMtC1zgMV7qxDe6U18AI1SG0A==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.66.0.tgz", + "integrity": "sha512-GEaUQQjMaG7X1304qMgqLjzCKv6CdDirzJSS+UqUs3xhJ9X2Kjzu0JDbLoUU42FWxwmZqz+SsgU1SlQv9GNRVA==", "cpu": [ "x64" ], @@ -993,13 +915,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.10.tgz", - "integrity": "sha512-ble2WR7kWfzZFJUd3ODMGCJn1XDszptfRC0QvWc0h9/EA6MxnrQ1mvbjPZifEdgHyfHhDrwWeKeJONDITcrHSg==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.66.0.tgz", + "integrity": "sha512-DgmAU9N0NhDQDzHgFUocDio7zRO8jR/nY/obDGvCR8zbJ6J77Fg6X49yPuExJUpYPbC82EOaRhG969pWzUSnHQ==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1007,13 +932,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.10.tgz", - "integrity": "sha512-DMCYegihxBMKvLpODdk4OsvIgxtHTi+Oddf1Pm3d6wMVNjcBiG2pqyXjMPy55HgG1gCdEFr9VfhB1CmvuKn1lw==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.66.0.tgz", + "integrity": "sha512-zG09YZ+sk/HoHq+P9gYB0EseQ3M20RbDuKGWey6fbiMDV1LxfXvhgSW9jDaCcvFPMgfLXpimzXY+UYvA8R2mLQ==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1021,13 +949,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.10.tgz", - "integrity": "sha512-HQ6HuVH9TmhvPWIas7MA/KeAROgdwqH3REpUU6hZq47NW7m1b1QCrHgM+M3+N/B8DfMt88x1/z79ppRc9RflCQ==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.66.0.tgz", + "integrity": "sha512-vlArhgyXbcVc1D4WZPoiYuqJIaMiSHBj6etRXua0KlfjlZeuiQyVAPYi0/N49O4mSp3u1iGUBLEjNa8ec81XZg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1035,13 +966,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.10.tgz", - "integrity": "sha512-avBhxba1DdctX0kxKkN3JWdTSg72QsxSn6FdfhZ9MkL3TTYDOXqxY1GqbC7VNttlgviK7tCe2No5btfRkFgeIg==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.66.0.tgz", + "integrity": "sha512-RxJZtk0G87YPotDaRDvRqStV8cbqLSC5TroHvQHHKsqBg0ok64RxKgtXeFsa7FBcy7mb4crBrG3xK8USWIqWNA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1049,13 +983,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.10.tgz", - "integrity": "sha512-UtQMex7fiXq0W/GqcWRpy6XKqUbXgQeGKB+Bd5rJngmHzpI+3kjwpWFLAhKg02onFsIMSh+Zx0zp9JIMQ7/05A==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.66.0.tgz", + "integrity": "sha512-qlGcw959PrLLkZhiBxF3OZ6SQvj+OKY85mCqGNgSRs4LZlDJ6ICqmz6UdxtINdhcXYwNIbZoapQVBAmZlD2hUQ==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1063,13 +1000,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.10.tgz", - "integrity": "sha512-0Aum2pUsCSuOopi6oka+nR1DTj/nfvc1xjL979lvh6kXUjNa30TCX7sKFjyMsrX7bK3bNVs0FsJwQcE2HCWX1Q==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.66.0.tgz", + "integrity": "sha512-TvTI/7Cy0WvRA1SXgwzu6bwXBnMpmeT/ln5q7WvSpqmXy1VbmEnY8HVIDQNBiaHJvoWqRr0M4FNo7qiqtyMaFw==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1077,13 +1017,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.10.tgz", - "integrity": "sha512-4dgsouKal3qfMFORgtNigLPt7K3HXiO6jTr7+nFJv1GWu1OkZx0kvQIXbK4sjrIGnaLBk0s80eQCQ9hPIelLrw==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.66.0.tgz", + "integrity": "sha512-UZEDkSoDRZ50NyLj70nO6mpIK3dvs2cPTTvQb1bA/PBDmr9fvn5KiESuGfh6srd7Zi6Lt9Sv2sVrFr1QoHLvPQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1091,13 +1034,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.10.tgz", - "integrity": "sha512-d7NE89try3jisYzBD7J9M5iwfgcrXYC4dwHEjQ3HIWBftvwch1U/cy3iZ26SthtUOyClKiu7iqYiFjQEwiOlqQ==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.66.0.tgz", + "integrity": "sha512-VRbxTvagLh3HRcysx1CNq+HHcvJbSebNI6fCbk7jwmmWHkXjrSIV3igXBpRHUTFdg0pagWGu50xoSZXnrZHjVQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1105,13 +1051,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.10.tgz", - "integrity": "sha512-dqB5ChSBp91JWXxpCKAS5ZVAE38esKQvdfyqANm0lF5JPiQrXnYwgG3oVBQmuNRcQ1WHkA3j5qYLsrl2CtfhCA==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.66.0.tgz", + "integrity": "sha512-uWdJpvYqh0LE/i+YBL3sMX0a2kUkICENjmrPmyBmMOFc3jMrs6MSQH/pMD+VIpGEoUcr3XkHGLLfymzIqPSRRg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1119,13 +1068,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.10.tgz", - "integrity": "sha512-7GMf3vbQQslorWLmfsbi5fdt9RVnglk205XpOMFvW6UY5jE5oJc6JLieQJPHtsYlZ2EO0PtC06nY9Hz/IG8guw==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.66.0.tgz", + "integrity": "sha512-3FUd88pxgWAjK60OgCrika1AcNG+u8zaCdWjg4TlcZTDkt5TGyci8ZU64l3OJ9ax0MEjw3P80B/3PoSQDCLwJQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1133,13 +1085,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.10.tgz", - "integrity": "sha512-tuKbkB23DKc5LG+irPSUnZZztRHqC3CNmyxeWloueBteHB+i+1HkRCMzAE41lseLDlarybeLhqfibFitKOxKMg==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.66.0.tgz", + "integrity": "sha512-qT2rMmgyej62g5AdwktK+Bt2lBu497pzWZe12TTQ5JmZjOYPnEwcPEAOSLTBxBV+6gIES75rXm219v/QkT397A==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1147,13 +1102,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.10.tgz", - "integrity": "sha512-ntHtQOMw7oIshHRF1swV3vyBw+beQt9PeZ04O3D32EeDZUMoHMJuZKp4/fDllH/+xogeK0GUCONVfjjKusqKLg==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.66.0.tgz", + "integrity": "sha512-E46rnZgp7J84Qwe8RMlF+rgimnDo/9fuiGbUArGB0ZkMWf97O7yeSCfKls5x/YJOF0RcuSe9RctuKe1hlxICiA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1161,13 +1119,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.10.tgz", - "integrity": "sha512-Dlh8q8r145aYgh+N3gNIG1dz35Fz/FCxe5hs/43orDsffjP8S1P4Xz5I4FpSJsDS4LpQw1QmswZkQlUbkCldjQ==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.66.0.tgz", + "integrity": "sha512-Uz1HiZe+qBzqgdPsON+z0wRx3WdATuyzwvGT6cr2VZVrqqko0ZJVfkrQJtwDMuDcAfXYdo7XucaxJ7lTLjEcgg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1175,9 +1136,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.10.tgz", - "integrity": "sha512-VBJhHWz/55u45CWFv9OmfSRDgJDVENESgFxSAn13gIKJpTuFJbP4/7ymMZhDfeKFx1zGn1XBsLnNB7Ryy71qww==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.66.0.tgz", + "integrity": "sha512-Ph7TX3Ex5qdfcb7Tc6eEPugVkRfJ501eTovO4T5029YObfpZ7dzn3RiwpFvGp50nk2QUhvBMZ0PFGlEj6pOYTg==", "cpu": [ "x64" ], @@ -1189,9 +1150,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.10.tgz", - "integrity": "sha512-Vjcazg+EEzUfIz+LcaxS6YtbQHo+sv0sCD+5VbwhY504bCKNW4OOreVgKq61pHT2esRQ/+AXYVPStDwxSvAOdw==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.66.0.tgz", + "integrity": "sha512-nZYPEya7ZkeNxUEiF7qmNZbyLTyRN3MK5cO22KqsaoneUzXJuzBIQRw+7BEQy9LGj2O5vrCE9V3EpK06FYo1tQ==", "cpu": [ "arm64" ], @@ -1203,9 +1164,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.10.tgz", - "integrity": "sha512-jJAaP4El+HWxYkM0jeJghASaFgfnluY0FRHhyHXxU6STs/nBhKMIxIMDrJEFg2ftKz6orX9WAo4diVan5NugNA==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.66.0.tgz", + "integrity": "sha512-u7aTfS3R6C4hRsqXSzaO6qLZYLVuMkHMvcWlux4IM8IJif1VCl2cgPBSo5gSNoFoStQypQP+qwq8W1IyFBf1QQ==", "cpu": [ "arm64" ], @@ -1217,9 +1178,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.10.tgz", - "integrity": "sha512-bXXdRUfRx9PaHCToKt8R/7wjDV6Tbk/hmSHK0uQBKmD3rj4jkuYndct8uGX77ZuZsePrVkQbWUgPR2jJhCTMpQ==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.66.0.tgz", + "integrity": "sha512-e+i9ueHrH1Bafb0cf0woAIVJbYcoC8vRaD+vz+Xi8w6jz9cYJSV3vyKO6ELGSZ6g4W4MJOm2WPLN4/nQf77h7w==", "cpu": [ "ia32" ], @@ -1231,9 +1192,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.10.tgz", - "integrity": "sha512-57Idx+VkEOQeG6Ys6sZOBFT7FfbeyxGv9A3k1m+Lf/z2SwqT40rNWbAINzjPHSMoCCBvY7Fnb9RGZWQin/HHAw==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.66.0.tgz", + "integrity": "sha512-KfTCbrtr2zXTqH2E1F+uYlFTrYibxHKqPAHobpd8Kfg49cEHmuHebOidCTDRgqG1suaPFX7oejFQeGE71zWKfA==", "cpu": [ "x64" ], @@ -1245,9 +1206,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.10.tgz", - "integrity": "sha512-vrvuasIpm/xf+BpZbzgirku0WnrQZMAc+H2Ai26jzqBxYEmQQE4DdJb7h8erkJMfRRipx0ma2Cty8jJwUm9ocQ==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.66.0.tgz", + "integrity": "sha512-xA+neBh1bqkqaS3b48T2UHZnDsTIpb9B0h+TdBzynjF/ymCpYndhfY10RqW0qSKB1HqqQA83mINVhAdwA+mETg==", "cpu": [ "x64" ], @@ -1489,9 +1450,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.29", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.29.tgz", - "integrity": "sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==", + "version": "2.11.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz", + "integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1502,18 +1463,18 @@ } }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", - "qs": "^6.15.2", + "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" }, @@ -1526,9 +1487,9 @@ } }, "node_modules/browserslist": { - "version": "4.29.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", - "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", + "version": "4.29.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", + "integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==", "dev": true, "funding": [ { @@ -1546,10 +1507,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.29", - "caniuse-lite": "^1.0.30001816", - "electron-to-chromium": "^1.5.459", - "node-releases": "^2.0.59", + "baseline-browser-mapping": "^2.11.33", + "caniuse-lite": "^1.0.30001819", + "electron-to-chromium": "^1.5.464", + "node-releases": "^2.0.60", "update-browserslist-db": "^1.3.4" }, "bin": { @@ -1598,9 +1559,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001816", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", - "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", + "version": "1.0.30001819", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", + "integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==", "dev": true, "funding": [ { @@ -1813,9 +1774,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.459", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.459.tgz", - "integrity": "sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg==", + "version": "1.5.467", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.467.tgz", + "integrity": "sha512-Nw9X371rtZmYcBSnEijnCDONILjs93biCZO34juC7MBOmK8E+GFnyD4T/WvyrlJkppul7oxg6TwvtKst9SODTw==", "dev": true, "license": "ISC" }, @@ -2239,9 +2200,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.7.4", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", - "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", + "version": "10.7.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz", + "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==", "license": "MIT", "engines": { "node": ">= 12" @@ -2425,9 +2386,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.59", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", - "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", + "version": "2.0.60", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz", + "integrity": "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==", "dev": true, "license": "MIT", "engines": { @@ -2447,15 +2408,19 @@ } }, "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", "license": "MIT", "dependencies": { "ee-first": "1.1.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/once": { @@ -2507,9 +2472,9 @@ } }, "node_modules/postcss": { - "version": "8.5.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", - "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "dev": true, "funding": [ { @@ -2553,9 +2518,9 @@ } }, "node_modules/qs": { - "version": "6.16.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", - "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.0.tgz", + "integrity": "sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -2638,9 +2603,9 @@ } }, "node_modules/rollup": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.10.tgz", - "integrity": "sha512-8N52YH1B5JzBP+B1rkcBERG7rfRTGAM0RmUpyd76yyRfTljuG452GCNbVm6MPzs1+gXBha6Rw5/oHRFohOpfDg==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.66.0.tgz", + "integrity": "sha512-Onq6/qO4ARhxIs+jYiHE0pYehadLyMECAiGEqQ+0/aWND3+2PmifJIIqBLYtuBODqHTlOUEQFtIVPyCy0Lck7A==", "dev": true, "license": "MIT", "dependencies": { @@ -2655,31 +2620,31 @@ }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.64.10", - "@rollup/rollup-android-arm64": "4.64.10", - "@rollup/rollup-darwin-arm64": "4.64.10", - "@rollup/rollup-darwin-x64": "4.64.10", - "@rollup/rollup-freebsd-arm64": "4.64.10", - "@rollup/rollup-freebsd-x64": "4.64.10", - "@rollup/rollup-linux-arm-gnueabihf": "4.64.10", - "@rollup/rollup-linux-arm-musleabihf": "4.64.10", - "@rollup/rollup-linux-arm64-gnu": "4.64.10", - "@rollup/rollup-linux-arm64-musl": "4.64.10", - "@rollup/rollup-linux-loong64-gnu": "4.64.10", - "@rollup/rollup-linux-loong64-musl": "4.64.10", - "@rollup/rollup-linux-ppc64-gnu": "4.64.10", - "@rollup/rollup-linux-ppc64-musl": "4.64.10", - "@rollup/rollup-linux-riscv64-gnu": "4.64.10", - "@rollup/rollup-linux-riscv64-musl": "4.64.10", - "@rollup/rollup-linux-s390x-gnu": "4.64.10", - "@rollup/rollup-linux-x64-gnu": "4.64.10", - "@rollup/rollup-linux-x64-musl": "4.64.10", - "@rollup/rollup-openbsd-x64": "4.64.10", - "@rollup/rollup-openharmony-arm64": "4.64.10", - "@rollup/rollup-win32-arm64-msvc": "4.64.10", - "@rollup/rollup-win32-ia32-msvc": "4.64.10", - "@rollup/rollup-win32-x64-gnu": "4.64.10", - "@rollup/rollup-win32-x64-msvc": "4.64.10", + "@rollup/rollup-android-arm-eabi": "4.66.0", + "@rollup/rollup-android-arm64": "4.66.0", + "@rollup/rollup-darwin-arm64": "4.66.0", + "@rollup/rollup-darwin-x64": "4.66.0", + "@rollup/rollup-freebsd-arm64": "4.66.0", + "@rollup/rollup-freebsd-x64": "4.66.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.66.0", + "@rollup/rollup-linux-arm-musleabihf": "4.66.0", + "@rollup/rollup-linux-arm64-gnu": "4.66.0", + "@rollup/rollup-linux-arm64-musl": "4.66.0", + "@rollup/rollup-linux-loong64-gnu": "4.66.0", + "@rollup/rollup-linux-loong64-musl": "4.66.0", + "@rollup/rollup-linux-ppc64-gnu": "4.66.0", + "@rollup/rollup-linux-ppc64-musl": "4.66.0", + "@rollup/rollup-linux-riscv64-gnu": "4.66.0", + "@rollup/rollup-linux-riscv64-musl": "4.66.0", + "@rollup/rollup-linux-s390x-gnu": "4.66.0", + "@rollup/rollup-linux-x64-gnu": "4.66.0", + "@rollup/rollup-linux-x64-musl": "4.66.0", + "@rollup/rollup-openbsd-x64": "4.66.0", + "@rollup/rollup-openharmony-arm64": "4.66.0", + "@rollup/rollup-win32-arm64-msvc": "4.66.0", + "@rollup/rollup-win32-ia32-msvc": "4.66.0", + "@rollup/rollup-win32-x64-gnu": "4.66.0", + "@rollup/rollup-win32-x64-msvc": "4.66.0", "fsevents": "~2.3.2" } }, diff --git a/package.json b/package.json index a3b4886..2e998f8 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,6 @@ "analyze:consensus": "node --experimental-strip-types scripts/consensus-analysis.ts", "benchmark:randomx": "node --experimental-strip-types scripts/benchmark-randomx.ts", "benchmark:index": "node --experimental-strip-types scripts/benchmark-canonical-index.ts", - "benchmark:market": "node --experimental-strip-types scripts/benchmark-market-reconciliation.ts", - "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", - "monero:pilot-observe": "node --experimental-strip-types scripts/pilot-monero-observe-once.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", @@ -32,7 +26,6 @@ "hash-wasm": "^4.12.0", "helmet": "^8.1.0", "lucide-react": "^0.468.0", - "pg": "^8.16.3", "react": "^19.1.0", "react-dom": "^19.1.0", "zod": "^4.1.0" @@ -40,7 +33,6 @@ "devDependencies": { "@types/express": "^5.0.3", "@types/node": "^24.0.0", - "@types/pg": "^8.15.5", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^5.0.0", diff --git a/scripts/Dockerfile.monero-mtls-test b/scripts/Dockerfile.monero-mtls-test deleted file mode 100755 index 94762ae..0000000 --- a/scripts/Dockerfile.monero-mtls-test +++ /dev/null @@ -1,28 +0,0 @@ -FROM node:24-trixie-slim -WORKDIR /app -RUN apt-get update -qq \ - && apt-get upgrade -y -qq \ - && apt-get install -y -qq --no-install-recommends ca-certificates curl gnupg openssl bzip2 \ - && rm -rf /var/lib/apt/lists/* -COPY package.json package-lock.json ./ -RUN npm ci -COPY . . -RUN set -eu; \ - mkdir -m 700 -p /tmp/monero-verify /opt/monero; \ - cd /tmp/monero-verify; \ - curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ - 'https://downloads.getmonero.org/cli/monero-linux-x64-v0.18.5.4.tar.bz2' -o monero-linux-x64-v0.18.5.4.tar.bz2; \ - curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ - 'https://www.getmonero.org/downloads/hashes.txt' -o hashes.txt; \ - curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ - 'https://raw.githubusercontent.com/monero-project/monero/master/utils/gpg_keys/binaryfate.asc' -o binaryfate.asc; \ - actual_fingerprint="$(gpg --batch --show-keys --with-colons binaryfate.asc | awk -F: '$1 == "fpr" {print $10; exit}')"; \ - test "$actual_fingerprint" = '81AC591FE9C4B65C5806AFC3F0AF4D462A0BDF92'; \ - export GNUPGHOME=/tmp/monero-verify/gnupg; mkdir -m 700 -p "$GNUPGHOME"; \ - gpg --batch --import binaryfate.asc >/dev/null 2>&1; \ - gpg --batch --output hashes.verified.txt --decrypt hashes.txt >/dev/null 2>&1; \ - grep -Fx 'ba2928962c101b609434bf8c1b43ad59ebe96c5525977284ca599d8bf0f02d10 monero-linux-x64-v0.18.5.4.tar.bz2' hashes.verified.txt >/dev/null; \ - printf '%s %s\n' 'ba2928962c101b609434bf8c1b43ad59ebe96c5525977284ca599d8bf0f02d10' 'monero-linux-x64-v0.18.5.4.tar.bz2' | sha256sum --check --status; \ - tar -xjf monero-linux-x64-v0.18.5.4.tar.bz2 -C /opt/monero --strip-components=1; \ - rm -rf /tmp/monero-verify -CMD ["node", "--experimental-strip-types", "scripts/monero-mtls-stagenet-rehearsal.mjs", "/opt/monero/monero-wallet-rpc"] diff --git a/scripts/benchmark-market-reconciliation.ts b/scripts/benchmark-market-reconciliation.ts deleted file mode 100755 index 9d056a3..0000000 --- a/scripts/benchmark-market-reconciliation.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { DatabaseSync } from 'node:sqlite'; -import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; -import { randomUUID } from 'node:crypto'; -import { setImmediate as yieldToEventLoop } from 'node:timers/promises'; -import { createServer } from 'node:http'; -import { MARKET_RECONCILIATION_BATCH_SIZE, Market } from '../server/market/store.ts'; -import { CHAIN_ID, GENESIS_HASH, transactionId } from '../server/testnet/consensus.ts'; -import { NETWORK } from '../shared/testnet.ts'; - -// Synthetic market journal only. It is isolated in a temporary SQLite database and never -// connects to a Ledger, peer, payment adapter, wallet, or production data directory. -const text = process.argv[2] ?? '10000'; -if (!/^[1-9]\d{0,5}$/.test(text) || Number(text) > 100000) throw new Error('Fixture size must be 1..100000 orders'); -const count = Number(text), scenario = process.argv[3] ?? 'terminal'; -if (!['terminal', 'confirmed-delivery'].includes(scenario)) throw new Error('Scenario must be terminal or confirmed-delivery'); -const executionMode = process.argv[4] ?? 'sync'; -if (!['sync', 'worker'].includes(executionMode)) throw new Error('Execution mode must be sync or worker'); -const workerEnabled = executionMode === 'worker'; -const httpLoadEnabled = process.argv[5] === 'http'; -const directory = await mkdtemp(join(tmpdir(), 'wikideck-market-reconciliation-')); -const streamId = randomUUID(), tip = 'a'.repeat(64); -const ledgerFile = join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet', 'ledger.sqlite'); -const seller = '1'.repeat(64), buyer = '2'.repeat(64); -let market: Market | undefined; -let ledgerDb: DatabaseSync | undefined; -let loadServer: ReturnType | undefined; -try { - // Indexed-only adapter: the benchmark must not fall back to materializing chain snapshots. - await mkdir(join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet'), { recursive: true }); - ledgerDb = new DatabaseSync(ledgerFile); - ledgerDb.exec(`PRAGMA journal_mode=WAL; CREATE TABLE blocks(hash TEXT PRIMARY KEY,block_json TEXT NOT NULL); - CREATE TABLE canonical_blocks(height INTEGER PRIMARY KEY,hash TEXT NOT NULL); - CREATE TABLE canonical_nfts(token TEXT PRIMARY KEY,owner TEXT NOT NULL,sequence INTEGER NOT NULL,minted_height INTEGER NOT NULL,block_hash TEXT NOT NULL); - CREATE TABLE canonical_transactions(id TEXT PRIMARY KEY,height INTEGER NOT NULL,block_hash TEXT NOT NULL); - CREATE TABLE canonical_events(sequence INTEGER PRIMARY KEY,type TEXT NOT NULL); - CREATE TABLE pending_transfers(position INTEGER PRIMARY KEY,id TEXT NOT NULL,transaction_json TEXT NOT NULL);`); - const insertBlock = ledgerDb.prepare('INSERT INTO blocks VALUES(?,?)'); - const insertTip = ledgerDb.prepare('INSERT INTO canonical_blocks VALUES(?,?)'); - const insertNft = ledgerDb.prepare('INSERT INTO canonical_nfts VALUES(?,?,?,?,?)'); - const priorHash = 'b'.repeat(64); - for (const [height, hash] of [[1, priorHash], [2, tip]] as const) { - insertBlock.run(hash, JSON.stringify({ height, hash, tokenId: '', owner: seller, transactions: [] })); - insertTip.run(height, hash); - } - const chain = { - snapshot: () => { throw new Error('Benchmark must not request a full chain snapshot'); }, - summary: () => ({ tip, revision: 0, height: 2 }), - nft: (token: string) => { - const row = ledgerDb!.prepare(`SELECT n.owner,n.sequence,n.minted_height,b.block_json FROM canonical_nfts n - JOIN blocks b ON b.hash=n.block_hash WHERE n.token=?`).get(token); - if (!row) return undefined; - return { block: JSON.parse(row.block_json), owner: row.owner, sequence: row.sequence, - confirmations: 2 - row.minted_height + 1 }; - }, - canonicalTransaction: (id: string) => ledgerDb!.prepare('SELECT height,block_hash AS blockHash FROM canonical_transactions WHERE id=?').get(id), - pendingTransactions: () => ledgerDb!.prepare('SELECT transaction_json FROM pending_transfers ORDER BY position').all() - .map(row => JSON.parse(row.transaction_json)), - canonicalHead: () => 0, - canonicalEvents: (after: number) => ({ streamId, previousAnchor: GENESIS_HASH, anchor: GENESIS_HASH, - events: [], cursor: after, head: 0, caughtUp: true }), - ...(workerEnabled ? { reconciliationDatabasePath: () => ledgerFile } : {}), - submitTransfer: async () => { throw new Error('Benchmark never submits transfers'); }, - }; - market = await Market.open(directory, chain, { feeBps: 250, confirmations: 2 }, Date.now, { worker: workerEnabled }); - const db = new DatabaseSync(join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet', 'market.sqlite')); - try { - const insertListing = db.prepare('INSERT INTO listings(id,card,status,data) VALUES(?,?,?,?)'); - const insertOrder = db.prepare('INSERT INTO orders(id,data) VALUES(?,?)'); - const insertNft = ledgerDb?.prepare('INSERT INTO canonical_nfts VALUES(?,?,?,?,?)'); - const insertTransaction = ledgerDb?.prepare('INSERT INTO canonical_transactions VALUES(?,?,?)'); - db.exec('BEGIN IMMEDIATE'); - try { - for (let i = 0; i < count; i++) { - const id = i.toString().padStart(12, '0'); - const listingId = `listing-${id}`, orderId = `order-${id}`, cardHash = i.toString(16).padStart(64, '0'); - const terminal = scenario === 'terminal'; - const listingStatus = terminal ? 'cancelled' : 'reserved'; - const listing = { id: listingId, cardHash, seller, sequence: 0, kind: 'fixed', status: listingStatus }; - const order = { id: orderId, listingId, seller, buyer, cardHash, sequence: 0, - status: terminal ? 'refunded' : 'paid', payout: null, deliveryConfirmations: 0, - requiredConfirmations: 2, createdAt: 1, payBy: 2, deliverBy: 3 }; - if (!terminal) { - const delivery = { network: NETWORK, chainId: CHAIN_ID, cardHash, from: seller, to: buyer, - sequence: 0, publicKey: '', signature: '' }; - const id = transactionId(delivery); - insertTransaction?.run(id, 1, 'b'.repeat(64)); - } - insertNft?.run(cardHash, terminal ? seller : buyer, terminal ? 0 : 1, 1, 'b'.repeat(64)); - insertListing.run(listingId, cardHash, listingStatus, JSON.stringify(listing)); - insertOrder.run(orderId, JSON.stringify(order)); - } - db.exec('COMMIT'); - } catch (error) { db.exec('ROLLBACK'); throw error; } - } finally { db.close(); } - let httpLatencies: number[] = [], httpRequests = 0; - let httpLoadPromise: Promise | undefined; - if (httpLoadEnabled) { - loadServer = createServer((request, response) => { - try { - if (request.url === '/health') { - response.writeHead(200, { 'content-type': 'application/json' }); response.end('{"ok":true}'); return; - } - if (request.url === '/market') { - const result = market!.browse(); - response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ listingCount: result.listings.length, backlog: market!.hasReconciliationBacklog() })); - return; - } - response.writeHead(404); response.end(); - } catch { - response.writeHead(503, { 'retry-after': '1' }); response.end('{"error":"reconciling"}'); - } - }); - await new Promise(resolve => loadServer!.listen(0, '127.0.0.1', resolve)); - const address = loadServer.address(); - if (!address || typeof address === 'string') throw new Error('Benchmark HTTP listener did not bind TCP'); - const baseUrl = `http://127.0.0.1:${address.port}`; - httpLoadPromise = Promise.all(Array.from({ length: 160 }, (_, i) => (async () => { - const before = performance.now(); - const response = await fetch(`${baseUrl}${i % 4 === 0 ? '/market' : '/health'}`); - if (response.status !== 200) throw new Error(`Loopback benchmark endpoint returned ${response.status}`); - await response.arrayBuffer(); - httpLatencies.push(performance.now() - before); - })())).then(() => { httpLatencies.sort((a, b) => a - b); httpRequests = httpLatencies.length; }); - } - const latencies: number[] = []; - const deadline = Date.now() + Math.max(30_000, count * 20); - const eventLoopDelay = monitorEventLoopDelay({ resolution: 10 }); - eventLoopDelay.enable(); - const start = performance.now(); - let started = false; - try { - while (Date.now() < deadline && (!started || market.hasReconciliationBacklog() || market.isReconciliationWorkerBusy() || - (!!httpLoadPromise && httpRequests === 0))) { - if (!workerEnabled || !market.isReconciliationWorkerBusy()) { - const before = performance.now(); - market.browse(); - latencies.push(performance.now() - before); - started = true; - } - await yieldToEventLoop(); // Permit the delay monitor to observe synchronous batch stalls. - if (workerEnabled && market.isReconciliationWorkerBusy()) await new Promise(resolve => setTimeout(resolve, 1)); - } - } finally { eventLoopDelay.disable(); } - const elapsedMs = performance.now() - start; - if (!started || market.hasReconciliationBacklog() || market.isReconciliationWorkerBusy()) throw new Error('Reconciliation did not finish before benchmark deadline'); - await httpLoadPromise; - await market.close(); market = undefined; - ledgerDb.close(); ledgerDb = undefined; - const bytes = (await stat(join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet', 'market.sqlite'))).size; - latencies.sort((a, b) => a - b); - const percentile = (p: number) => latencies[Math.min(latencies.length - 1, Math.ceil(latencies.length * p) - 1)]; - const httpPercentile = (p: number) => httpLatencies.length ? httpLatencies[Math.min(httpLatencies.length - 1, Math.ceil(httpLatencies.length * p) - 1)] : null; - console.log(JSON.stringify({ fixture: `synthetic-${scenario}-orders-and-listings`, workerEnabled, httpLoadEnabled, orders: count, listings: count, - batchSizePerTable: MARKET_RECONCILIATION_BATCH_SIZE, reconciliationPasses: latencies.length, elapsedMs, - rowsPerSecond: Math.round((count * 2 * 1000) / elapsedMs), passLatencyP50Ms: percentile(0.50), - passLatencyP95Ms: percentile(0.95), passLatencyMaxMs: latencies.at(-1), sqliteBytes: bytes, - eventLoopDelayResolutionMs: 10, eventLoopDelayP95Ms: eventLoopDelay.percentile(95) / 1e6, - eventLoopDelayMaxMs: eventLoopDelay.max / 1e6, - httpRequests, httpLatencyP50Ms: httpPercentile(0.5), httpLatencyP95Ms: httpPercentile(0.95), - httpLatencyMaxMs: httpLatencies.at(-1) ?? null, - warning: 'Synthetic fixture on this development host only; SQLite Ledger-shaped facts are not mined or validated chain evidence. Excludes concurrent HTTP load, PostgreSQL, and production SLO certification.' }, null, 2)); -} finally { - if (loadServer) await new Promise(resolve => loadServer!.close(() => resolve())); - if (market) await market.close(); - ledgerDb?.close(); - await rm(directory, { recursive: true, force: true }); -} diff --git a/scripts/custody-db.ts b/scripts/custody-db.ts deleted file mode 100755 index 6746155..0000000 --- a/scripts/custody-db.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { Pool } from 'pg'; -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, - grantPilotDepositReservationRuntime, grantPilotDepositAddressRuntime, - grantPilotMoneroObserverRuntime, grantPilotMoneroDepositReviewRuntime } 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'; -import { IS_MAINNET, IS_PILOT } from '../shared/network.ts'; -import { initializePilotCustodyIdentity, parsePilotCustodianId } from '../server/market/pilot-custody-identity.ts'; -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, assertPilotReservationGrantAcknowledged, - assertPilotAddressGrantAcknowledged, - assertPilotObserverGrantAcknowledged, - assertPilotDepositReviewAcknowledged, - parseCustodyDbArguments } from '../server/market/custody-db-args.ts'; -import { prepareDepositCreditReview, postReviewedDepositPendingCredit, prepareDepositCreditReleaseReview, - releaseReviewedDepositCredit, reconcileDepositCreditReorgHolds, prepareDepositReorgResolution, - resolveReleasedDepositReorgHold, type DepositCreditApproval, type DepositCreditReleaseApproval, - type DepositReorgResolutionApproval } from '../server/market/monero-deposit-credit.ts'; -import { preparePilotMoneroDepositReview } from '../server/market/pilot-monero-deposit-review.ts'; - -// Explicit operator task, never invoked by node startup. No passwords or database URLs printed. -const commandArgs = process.argv.slice(2); -const depositActions = ['deposit-prepare-credit', 'deposit-stage-credit', 'deposit-prepare-release', 'deposit-release', - 'deposit-reconcile-holds', 'deposit-prepare-resolve-hold', 'deposit-resolve-hold']; -const pilotReviewActions = ['pilot-deposit-prepare-review']; -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-pilot-deposit-address-runtime', - 'grant-pilot-monero-observer-runtime', - 'grant-pilot-monero-review-runtime', - 'grant-monero-withdrawal-runtime', - 'grant-monero-deposit-allocator-runtime', 'grant-monero-credit-runtime', - 'import-sqlite', 'audit', 'resolve-integrity']; -const { action, argument, extraArgument } = parseCustodyDbArguments(commandArgs); -assertCustodyDbActionAllowedOnNetwork(action, IS_MAINNET); -assertPilotCustodyActionAllowed(action, IS_PILOT); -assertMainnetSchemaMigrationAcknowledged(action, process.env.WIKIDECK_MAINNET_SCHEMA_MIGRATION); -assertMainnetCustodyOperationAcknowledged(action, process.env.WIKIDECK_MAINNET_CUSTODY_OPERATIONS); -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); -assertPilotAddressGrantAcknowledged(action, process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS); -assertPilotObserverGrantAcknowledged(action, process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS); -assertPilotDepositReviewAcknowledged(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'); -} -if (!custodyActions.includes(action) && !depositActions.includes(action) && !pilotReviewActions.includes(action)) { - throw new Error('Unsupported custody operation'); -} - -function boundedPilotReviewSetting(name: string, fallback: number, minimum: number, maximum: number): number { - const value = process.env[name]; - if (value === undefined) return fallback; - if (!/^(0|[1-9]\d{0,8})$/.test(value)) throw new Error(`Invalid ${name}`); - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) throw new Error(`Invalid ${name}`); - return parsed; -} - -try { - const reviewRole = pilotReviewActions.includes(action) ? process.env.WIKIDECK_PILOT_REVIEW_DB_ROLE : undefined; - let config; - if (pilotReviewActions.includes(action)) { - const reviewUrlFile = process.env.WIKIDECK_PILOT_REVIEW_PG_URL_FILE; - const reviewCaFile = process.env.WIKIDECK_PILOT_REVIEW_PG_CA_FILE; - if (!IS_PILOT || process.env.WIKIDECK_PILOT_MODE !== 'private-mock-only' || !reviewRole || - !/^[a-z][a-z0-9_]{0,62}$/.test(reviewRole) || !reviewUrlFile || !reviewCaFile) { - throw new Error('Pilot review requires the private mock-only profile, exact review-role name, and dedicated PostgreSQL TLS secret files'); - } - config = await postgresConfig({ ...process.env, - WIKIDECK_CUSTODY_PG_URL_FILE: reviewUrlFile, - WIKIDECK_CUSTODY_PG_CA_FILE: reviewCaFile, - WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls', - WIKIDECK_CUSTODY_PG_LOCAL_TEST: undefined, - WIKIDECK_CUSTODY_PG_INFRASTRUCTURE_ACK: undefined, - }); - if (config.user !== reviewRole) throw new Error('Pilot review PostgreSQL secret does not use the configured read-only review role'); - } else config = await postgresConfig(); - if (action === 'audit') { - const market = await PostgresCustody.open(config, { feeBps: 250, card: () => undefined }); - try { console.log(JSON.stringify(await market.audit())); } finally { await market.close(); } - } else { - const pool = new Pool(config); - try { - if (action === 'migrate' || action === 'migrate-mainnet-schema' || action === 'migrate-pilot-schema') { - await migrateCustody(pool); - console.log(action === 'migrate-mainnet-schema' - ? 'Mainnet custody schema verified/migrated; no runtime grants, deposits, or payments enabled.' - : action === 'migrate-pilot-schema' - ? 'Pilot custody schema verified/migrated; no pilot identity, runtime grants, deposits, or payments enabled.' - : 'Custody PostgreSQL schema verified/migrated. No funds activated.'); - } - if (action === 'initialize-mainnet-custody-identity') { - if (!IS_MAINNET || extraArgument) throw new Error('Expected mainnet initialize-mainnet-custody-identity CUSTODIAN_UUID'); - const identity = await initializeMainnetCustodyIdentity(pool, parseMainnetCustodianId(argument ?? '')); - console.log(JSON.stringify({ initialized: true, network: identity.network, chainId: identity.chainId, - custodianId: identity.id, crediting: 'reviewed-pending-only', withdrawals: 'disabled' })); - } - if (action === 'initialize-pilot-custody-identity') { - if (!IS_PILOT || process.env.WIKIDECK_PILOT_MODE !== 'private-mock-only' || extraArgument) { - throw new Error('Expected pilot profile, private-mock-only mode, and one custodian UUID'); - } - const identity = await initializePilotCustodyIdentity(pool, parsePilotCustodianId(argument ?? '')); - console.log(JSON.stringify({ initialized: true, marketNetwork: identity.marketNetwork, - marketChainId: identity.marketChainId, moneroNetwork: identity.moneroNetwork, - 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-pilot-deposit-address-runtime') { - if (!IS_PILOT || process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS !== 'operator-triggered-addresses-only' || extraArgument) { - throw new Error('Pilot address grant requires pilot profile, operator-triggered 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 pilot custody identity before granting address allocation access'); - await grantPilotDepositAddressRuntime(pool, argument ?? ''); - console.log('Pilot Wallet-RPC address allocator role granted for explicit operator action only; no HTTP enrollment or financial posting.'); - } - if (action === 'grant-pilot-monero-observer-runtime') { - if (!IS_PILOT || process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS !== 'pilot-observation-only-no-credit' || extraArgument) { - throw new Error('Pilot observer grant requires pilot profile, observation-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 pilot custody identity before granting observation access'); - await grantPilotMoneroObserverRuntime(pool, argument ?? ''); - console.log('Pilot Monero observation-only role granted; no address allocation, credit, withdrawal, or public enrollment privileges.'); - } - if (action === 'grant-pilot-monero-review-runtime') { - if (!IS_PILOT || process.env.WIKIDECK_PILOT_MODE !== 'private-mock-only' || - process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS !== 'read-only-pilot-review-role-only' || extraArgument) { - throw new Error('Pilot review role grant requires the private pilot profile, read-only-role 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 pilot custody identity before granting review access'); - await grantPilotMoneroDepositReviewRuntime(pool, argument ?? ''); - console.log('Pilot deposit review role granted read-only pilot evidence access; financial tables and all writes remain denied.'); - } - 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 ?? ''); - console.log('Separate Monero observation-only grants applied; journal access and deposit posting remain denied.'); - } - if (action === 'grant-monero-deposit-allocator-runtime') { - await grantMoneroDepositAllocatorRuntime(pool, argument ?? ''); - console.log('Separate Monero deposit-allocation grants applied; balance, journal, observation, and credit tables remain denied.'); - } - if (action === 'grant-monero-withdrawal-runtime') { - await grantMoneroWithdrawalRuntime(pool, argument ?? ''); - console.log('Separate customer-signed withdrawal-reservation grants applied; transaction signing and relay are not granted.'); - } - if (action === 'grant-monero-credit-runtime') { - await grantMoneroDepositCreditRuntime(pool, argument ?? ''); - console.log('Separate reviewed pending-credit coordinator grants applied; no credentials printed and no live deposits enabled.'); - } - if (action === 'resolve-integrity') console.log(JSON.stringify(await resolveCustodyIncident(pool, argument ?? ''))); - if (depositActions.includes(action)) { - const policy = await loadMoneroDepositReviewPolicy(); - if (action === 'deposit-prepare-credit') { - if (!argument || !extraArgument) throw new Error('Expected deposit-prepare-credit WALLET_FINGERPRINT OBSERVATION_ID'); - console.log(JSON.stringify(await prepareDepositCreditReview(pool, argument, extraArgument, policy))); - } - if (action === 'deposit-stage-credit') { - if (!argument || extraArgument) throw new Error('Expected deposit-stage-credit APPROVAL_JSON_FILE'); - const approval = await loadMoneroDepositReviewApproval(argument); - // The coordinator validates the exact approval schema/signature before any write. - console.log(JSON.stringify(await postReviewedDepositPendingCredit(pool, approval as DepositCreditApproval, policy))); - } - if (action === 'deposit-prepare-release') { - if (!argument || extraArgument) throw new Error('Expected deposit-prepare-release CREDIT_UUID'); - console.log(JSON.stringify(await prepareDepositCreditReleaseReview(pool, argument, policy))); - } - if (action === 'deposit-release') { - if (!argument || extraArgument) throw new Error('Expected deposit-release APPROVAL_JSON_FILE'); - const approval = await loadMoneroDepositReviewApproval(argument); - // The coordinator validates the exact approval schema/signature before any write. - console.log(JSON.stringify(await releaseReviewedDepositCredit(pool, approval as DepositCreditReleaseApproval, policy))); - } - if (action === 'deposit-reconcile-holds') { - if (!argument || extraArgument) throw new Error('Expected deposit-reconcile-holds WALLET_FINGERPRINT'); - console.log(JSON.stringify({ holdsCreated: await reconcileDepositCreditReorgHolds(pool, argument) })); - } - if (action === 'deposit-prepare-resolve-hold') { - if (!argument || !extraArgument) throw new Error('Expected deposit-prepare-resolve-hold CREDIT_UUID HOLD_UUID'); - console.log(JSON.stringify(await prepareDepositReorgResolution(pool, argument, extraArgument, policy))); - } - if (action === 'deposit-resolve-hold') { - if (!argument || extraArgument) throw new Error('Expected deposit-resolve-hold APPROVAL_JSON_FILE'); - const approval = await loadMoneroDepositReviewApproval(argument); - // The coordinator validates the exact approval schema/signature before any write. - console.log(JSON.stringify(await resolveReleasedDepositReorgHold(pool, approval as DepositReorgResolutionApproval, policy))); - } - } - if (pilotReviewActions.includes(action)) { - if (!IS_PILOT || process.env.WIKIDECK_PILOT_MODE !== 'private-mock-only' || !argument || !extraArgument || - process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS !== 'pilot-deposit-review-preparation-only') { - throw new Error('Pilot review requires the private pilot profile, preparation-only acknowledgement, wallet generation, and observation ID'); - } - const currentUser = await pool.query('SELECT current_user AS role'); - if (currentUser.rows[0]?.role !== reviewRole) throw new Error('Pilot deposit review must use the dedicated read-only review role'); - const subject = await preparePilotMoneroDepositReview(pool, { - walletGenerationId: argument, - observationId: extraArgument, - }, { - minimumConfirmations: boundedPilotReviewSetting('WIKIDECK_PILOT_DEPOSIT_REVIEW_MIN_CONFIRMATIONS', 10, 10, 100_000), - maximumObservationAgeMs: boundedPilotReviewSetting('WIKIDECK_PILOT_DEPOSIT_REVIEW_MAX_OBSERVATION_AGE_MS', 300_000, 10_000, 15 * 60_000), - }); - console.log(JSON.stringify(subject)); - } - if (action === 'import-sqlite') { - if (!argument) throw new Error('Stopped source node DATA_DIR required'); - const directory = resolve(argument); - await mkdir(directory, { recursive: true, mode: 0o700 }); - const lockPath = join(directory, 'testnet-node.lock'), lock = await open(lockPath, 'wx', 0o600); - try { - await lock.writeFile(`custody-import:${process.pid}`); - console.log(JSON.stringify(await importSqliteCustody(join(directory, 'testnet', 'custody-sandbox.sqlite'), pool))); - } finally { await lock.close(); await unlink(lockPath); } - } - } finally { await pool.end(); } - } -} catch { - console.error('Custody database operation failed. Check configuration, schema, stopped-node lock and migration state privately. No automatic fallback/reset was performed.'); - process.exitCode = 1; -} diff --git a/scripts/custody-deployment-check.ts b/scripts/custody-deployment-check.ts deleted file mode 100755 index baba08a..0000000 --- a/scripts/custody-deployment-check.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Pool } from 'pg'; -import { generateKeyPairSync, randomUUID, sign } from 'node:crypto'; -import { postgresConfig } from '../server/market/postgres-config.ts'; -import { auditPostgres } from '../server/market/postgres-custody.ts'; -import { custodyMessage, CUSTODY_POLICY, type CustodyCommand, type CustodyPage } from '../shared/custody.ts'; -import { address } from '../server/testnet/consensus.ts'; - -// Run inside the new sandbox application container. Optional explicit fictitious credit probe only. -const credit = process.argv.slice(2); -if (credit.length && (credit.length !== 1 || credit[0] !== '--credit')) throw new Error('Expected no arguments or --credit'); -const configuration = await postgresConfig(); -const pool = new Pool(configuration); -try { - // Verify server policy too: correct credentials must NOT authenticate over plaintext TCP. - const plaintext = new Pool({ ...configuration, ssl: false, connectionTimeoutMillis: 3000 }); - try { - let rejected = false; - try { await plaintext.query('SELECT 1'); } catch { rejected = true; } - if (!rejected) throw new Error('Database unexpectedly permits plaintext authentication'); - } finally { await plaintext.end(); } - const c = await pool.connect(); - try { - const tls = await c.query('SELECT ssl,version FROM pg_stat_ssl WHERE pid=pg_backend_pid()'); - if (tls.rows[0]?.ssl !== true) throw new Error('Database connection is not encrypted'); - const role = await c.query('SELECT current_user AS role'); - if (role.rows[0].role !== 'custody_runtime') throw new Error('Expected restricted sandbox runtime role'); - const page = await fetch('http://127.0.0.1:3001/api/custody', { signal: AbortSignal.timeout(10000) }); - if (!page.ok) throw new Error('Custody API unavailable'); - const data = await page.json() as CustodyPage; - if (data.info.mode !== 'sandbox' || data.info.liveDeposits || data.info.liveWithdrawals || data.info.nftSettlement) throw new Error('Unexpected custody capabilities'); - if (credit.length) { - const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); - const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); - const terms: Omit = { version: 1, mode: 'sandbox', policy: CUSTODY_POLICY, - network: data.info.network, chainId: data.info.chainId, custodianId: data.info.custodianId, - actor: address(publicKey), publicKey, nonce: randomUUID(), expiresAt: Date.now() + 60000, action: { type: 'mock-credit' } }; - const signature = sign('sha256', Buffer.from(custodyMessage(terms)), { key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64'); - const result = await fetch('http://127.0.0.1:3001/api/custody/command', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...terms, signature }), signal: AbortSignal.timeout(10000) }); - if (!result.ok || (await result.json()).availableAtomicXmr !== '1000000000000') throw new Error('Fictitious credit probe failed'); - // Test key never printed/persisted. The resulting fictitious liability remains in the sandbox journal. - } - await c.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'); - const audit = await auditPostgres(c); await c.query('COMMIT'); - console.log(JSON.stringify({ custodianId: data.info.custodianId, tls: tls.rows[0].version, - role: role.rows[0].role, sandbox: true, ...audit })); - } finally { c.release(); } -} catch { - console.error('Sandbox deployment check failed; no connection secrets logged.'); process.exitCode = 1; -} finally { await pool.end(); } diff --git a/scripts/custody-deployment-init.ts b/scripts/custody-deployment-init.ts deleted file mode 100755 index 0338b2f..0000000 --- a/scripts/custody-deployment-init.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Pool } from 'pg'; -import { postgresConfig } from '../server/market/postgres-config.ts'; -import { migrateCustody, grantCustodyRuntime } from '../server/market/postgres-schema.ts'; -import { IS_MAINNET } from '../shared/network.ts'; - -// Explicit one-shot service in the local persistent sandbox stack, NOT HTTP-startup migrations. -if (IS_MAINNET || process.env.WIKIDECK_LOCAL_PG_BOOTSTRAP !== 'sandbox-only') throw new Error('Local sandbox bootstrap only'); -const ca = '/secrets/admin/ca.crt'; -const config = (role: string) => postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: `/secrets/${role}/url`, WIKIDECK_CUSTODY_PG_CA_FILE: ca }); -try { - const admin = new Pool(await config('admin')); - try { - const ownerConfig = await config('owner'), runtimeConfig = await config('runtime'); - const backupConfig = await postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: '/secrets/backup/url', - WIKIDECK_CUSTODY_PG_CA_FILE: '/secrets/backup/ca.crt' }); - for (const [role, password] of [['custody_owner', ownerConfig.password], ['custody_runtime', runtimeConfig.password], - ['custody_backup', backupConfig.password]] as const) { - // Static role allowlist; password is a generated hex token. Values are never printed, even on error. - if (typeof password !== 'string' || !/^[a-f0-9]{64}$/.test(password)) throw new Error('Invalid generated credential'); - const exists = await admin.query('SELECT 1 FROM pg_roles WHERE rolname=$1', [role]); - if (!exists.rowCount) await admin.query(`CREATE ROLE ${role} LOGIN PASSWORD '${password}' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`); - } - await admin.query('REVOKE CREATE ON SCHEMA public FROM PUBLIC; REVOKE ALL ON DATABASE wikideck_custody FROM PUBLIC'); - await admin.query(`GRANT CONNECT,CREATE ON DATABASE wikideck_custody TO custody_owner; - GRANT CONNECT ON DATABASE wikideck_custody TO custody_runtime,custody_backup`); - } finally { await admin.end(); } - const owner = new Pool(await config('owner')); - try { - await migrateCustody(owner); await grantCustodyRuntime(owner, 'custody_runtime'); - await owner.query(`GRANT USAGE ON SCHEMA wk_custody TO custody_backup; - GRANT SELECT ON ALL TABLES IN SCHEMA wk_custody TO custody_backup; - GRANT SELECT ON ALL SEQUENCES IN SCHEMA wk_custody TO custody_backup`); - } - finally { await owner.end(); } - console.log('Local PostgreSQL schema and restricted runtime grants ready. Sandbox payments only.'); -} catch { - console.error('Local PostgreSQL bootstrap failed; inspect roles, secret volumes, TLS and schema privately. No reset or fallback performed.'); - process.exitCode = 1; -} diff --git a/scripts/custody-pair-backup.ts b/scripts/custody-pair-backup.ts deleted file mode 100755 index 67f6207..0000000 --- a/scripts/custody-pair-backup.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createCoordinatedCustodyBackup, verifyCoordinatedCustodyBackup } from '../server/coordinated-backup.ts'; - -const [action, ...args] = process.argv.slice(2); -if (action !== 'create' && action !== 'verify') throw new Error('Expected create or verify'); -const allowed = new Set(['--source', '--destination', '--consent', '--pg-restore-binary']); -for (let index = 0; index < args.length; index++) { - if (!allowed.has(args[index]) || (args[index] !== '--consent' && (!args[index + 1] || args[index + 1].startsWith('--')))) { - throw new Error('Invalid coordinated backup arguments'); - } - if (args[index] !== '--consent') index++; -} -const value = (name: string) => { const index = args.indexOf(name); return index < 0 ? undefined : args[index + 1]; }; -const source = value('--source'); -if (!source) throw new Error('Explicit --source is required'); -let result: unknown; -try { - if (action === 'create') { - const destination = value('--destination'); - if (!destination || !args.includes('--consent')) throw new Error('Create requires --destination and explicit --consent'); - result = await createCoordinatedCustodyBackup(source, destination); - } else { - if (value('--destination') || args.includes('--consent')) throw new Error('Verify accepts --source and optional --pg-restore-binary'); - result = await verifyCoordinatedCustodyBackup(source, value('--pg-restore-binary')); - } - console.log(JSON.stringify(result, null, 2)); -} catch { - console.error('Coordinated backup failed; check stopped-node state, separate backup credentials, PostgreSQL tools, destination permissions and partial output privately. No source database was reset.'); - process.exitCode = 1; -} diff --git a/scripts/monero-daemon-check.ts b/scripts/monero-daemon-check.ts deleted file mode 100755 index cd746d1..0000000 --- a/scripts/monero-daemon-check.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { loadMoneroDaemonProbeConfig, probeMoneroDaemon } from '../server/market/monero-daemon-probe.ts'; - -try { - const config = await loadMoneroDaemonProbeConfig(); - const result = await probeMoneroDaemon(config); - console.log(JSON.stringify({ component: 'monero-daemon-probe', ready: true, ...result })); -} catch (error) { - console.error(JSON.stringify({ component: 'monero-daemon-probe', ready: false, - reason: error instanceof Error ? error.message : 'Probe failed' })); - process.exitCode = 1; -} diff --git a/scripts/monero-mtls-stagenet-rehearsal.mjs b/scripts/monero-mtls-stagenet-rehearsal.mjs deleted file mode 100755 index dd40658..0000000 --- a/scripts/monero-mtls-stagenet-rehearsal.mjs +++ /dev/null @@ -1,155 +0,0 @@ -import assert from 'node:assert/strict'; -import { createHash, randomBytes, randomUUID, X509Certificate } from 'node:crypto'; -import { execFile as execFileCallback, spawn } from 'node:child_process'; -import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { promisify } from 'node:util'; -import { request as httpsRequest } from 'node:https'; -import { createMoneroRpcGateway } from '../server/market/monero-rpc-gateway.ts'; -import { loadMoneroMtlsTransport } from '../server/market/monero-mtls-transport.ts'; - -const execFile = promisify(execFileCallback); -const walletRpcBinary = process.argv[2]; -if (!walletRpcBinary || !/monero-wallet-rpc(?:\.exe)?$/i.test(walletRpcBinary)) { - throw new Error('Pass the verified monero-wallet-rpc binary path'); -} -const binary = resolve(walletRpcBinary), temp = await mkdtemp(join(tmpdir(), 'wikideck-mtls-stagenet-')); -const walletPort = 18083, gatewayPort = 18085; -const rpcBase = `http://127.0.0.1:${walletPort}/json_rpc`; -const gatewayUrl = `https://127.0.0.1:${gatewayPort}/json_rpc`; -const walletDir = join(temp, 'wallets'), walletName = 'mtls-watch-only'; -const walletPassword = randomBytes(32).toString('hex'); -let walletRpc, gateway, passed = false; -const certs = Object.fromEntries(['ca.key','ca.crt','server.key','server.csr','server.crt','server.ext', - 'client.key','client.csr','client.crt','client.ext','rogue.key','rogue.csr','rogue.crt','rogue.ext'] - .map(name => [name, join(temp, name)])); - -async function runOpenSsl(args) { - await execFile('openssl', args, { cwd: temp, windowsHide: true, timeout: 15_000 }); -} -async function makeCerts() { - await runOpenSsl(['req','-x509','-newkey','rsa:2048','-nodes','-sha256','-days','1','-keyout',certs['ca.key'], - '-out',certs['ca.crt'],'-subj','/CN=Wikideck Disposable Test CA']); - await runOpenSsl(['req','-newkey','rsa:2048','-nodes','-sha256','-keyout',certs['server.key'], - '-out',certs['server.csr'],'-subj','/CN=127.0.0.1']); - await writeFile(certs['server.ext'], 'subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\nkeyUsage=digitalSignature,keyEncipherment\n'); - await runOpenSsl(['x509','-req','-sha256','-days','1','-in',certs['server.csr'],'-CA',certs['ca.crt'], - '-CAkey',certs['ca.key'],'-CAcreateserial','-out',certs['server.crt'],'-extfile',certs['server.ext']]); - for (const name of ['client','rogue']) { - await runOpenSsl(['req','-newkey','rsa:2048','-nodes','-sha256','-keyout',certs[`${name}.key`], - '-out',certs[`${name}.csr`],'-subj',`/CN=wikideck-${name}`]); - await writeFile(certs[`${name}.ext`], 'extendedKeyUsage=clientAuth\nkeyUsage=digitalSignature,keyEncipherment\n'); - await runOpenSsl(['x509','-req','-sha256','-days','1','-in',certs[`${name}.csr`],'-CA',certs['ca.crt'], - '-CAkey',certs['ca.key'],'-CAcreateserial','-out',certs[`${name}.crt`],'-extfile',certs[`${name}.ext`]]); - } - for (const file of Object.keys(certs)) await chmod(certs[file], 0o600); - return new X509Certificate(await readFile(certs['client.crt'])).fingerprint256.replaceAll(':','').toLowerCase(); -} - -async function rawRpc(method, params = {}) { - console.log(`wallet-rpc: ${method}`); - const id = randomUUID(); - const response = await fetch(rpcBase, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30_000), - headers: { 'content-type':'application/json', accept:'application/json' }, - body: JSON.stringify({ jsonrpc:'2.0', id, method, params }) }); - assert.equal(response.status, 200); - const data = await response.json(); assert.equal(data.id,id); - if (data.error) { const error = new Error('wallet RPC method returned an error'); error.rpcCode = data.error.code; throw error; } - console.log(`wallet-rpc: ${method} complete`); - return data.result; -} -function startWallet(walletFile) { - const args = ['--stagenet','--offline','--rpc-bind-ip=127.0.0.1',`--rpc-bind-port=${walletPort}`, - '--disable-rpc-login',`--wallet-dir=${walletDir}`,'--non-interactive','--no-initial-sync', - '--max-concurrency=1','--log-level=0',`--log-file=${join(temp,'wallet-rpc.log')}`]; - if (walletFile) { args.splice(args.indexOf(`--wallet-dir=${walletDir}`),1); args.push(`--wallet-file=${walletFile}`,`--password=${walletPassword}`); } - walletRpc = spawn(binary,args,{shell:false,windowsHide:true,stdio:'ignore',cwd:temp}); walletRpc.on('error',()=>{}); -} -async function waitWallet() { - const deadline=Date.now()+60_000; - while(Date.now()setTimeout(r,100)); } - } - throw new Error('Wallet RPC readiness timeout'); -} -async function stopWallet() { - if(!walletRpc||walletRpc.exitCode!==null) return; - await rawRpc('store').catch(()=>{}); await rawRpc('stop_wallet').catch(()=>{}); - await Promise.race([new Promise(r=>walletRpc.once('exit',r)),new Promise(r=>setTimeout(r,5000))]); - if(walletRpc.exitCode===null) walletRpc.kill('SIGKILL'); -} -async function gatewayCall(transport,method,params={}) { - const id=randomUUID(); - const response=await transport(gatewayUrl,{method:'POST',redirect:'error',headers:{'content-type':'application/json',accept:'application/json'}, - body:JSON.stringify({jsonrpc:'2.0',id,method,params})}); - const data=await response.json(); - return {status:response.status,data}; -} -async function rawGatewayStatus(method, params, cert, key, ca) { - const body=Buffer.from(JSON.stringify({jsonrpc:'2.0',id:randomUUID(),method,params})); - const [certPem,keyPem,caPem]=await Promise.all([readFile(cert),readFile(key),readFile(ca)]); - return new Promise((resolveStatus,reject)=>{ - const request=httpsRequest({hostname:'127.0.0.1',port:gatewayPort,path:'/json_rpc',method:'POST',cert:certPem,key:keyPem,ca:caPem, - rejectUnauthorized:true,minVersion:'TLSv1.3',headers:{'content-type':'application/json','content-length':body.length}},response=>{ - response.resume(); response.once('end',()=>resolveStatus(response.statusCode)); - }); - request.once('error',reject); request.end(body); - }); -} - -try { - console.log('stage: generate ephemeral TLS certificates'); - const fingerprint = await makeCerts(); - console.log('stage: start offline Stagenet Wallet RPC'); - startWallet(); await waitWallet(); - console.log('stage: create watch-only disposable wallet'); - await rawRpc('create_wallet',{filename:'source',password:walletPassword,language:'English'}); - const primary=(await rawRpc('get_address',{account_index:0})).address; - const viewKey=(await rawRpc('query_key',{key_type:'view_key'})).key; - const watch=await rawRpc('generate_from_keys',{filename:walletName,address:primary,viewkey:viewKey, - password:walletPassword,restore_height:0,autosave_current:true}); - assert.equal(watch.address,primary); - await assert.rejects(rawRpc('query_key',{key_type:'spend_key'})); - await stopWallet(); startWallet(join(walletDir,walletName)); await waitWallet(); - console.log('stage: wallet is watch-only after restart'); - - 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']}, - 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']}); - const info=await gatewayCall(validTransport,'get_address',{account_index:0,address_index:[0]}); - console.log('stage: authorized mTLS address read'); - assert.equal(info.status,200); assert.equal(info.data.result.address,primary); - const sub=await gatewayCall(validTransport,'create_address',{account_index:0,label:`wikideck-deposit-v1:${randomUUID()}`,count:1}); - console.log('stage: authorized mTLS subaddress creation'); - assert.equal(sub.status,200); assert.equal(sub.data.result.address_index,1); - const outputs=await gatewayCall(validTransport,'incoming_transfers',{transfer_type:'all',account_index:0,subaddr_indices:[1]}); - assert.equal(outputs.status,200); - assert.equal(outputs.data.error,undefined,'incoming_transfers must be a supported JSON-RPC method'); - assert.deepEqual(outputs.data.result?.transfers ?? [],[], - 'a fresh watch-only Stagenet address must return an empty owned-output list'); - console.log('stage: incoming_transfers allowed; pinned RPC omits default empty result fields'); - const forbiddenStatus=await rawGatewayStatus('transfer',{destinations:[]},certs['client.crt'],certs['client.key'],certs['ca.crt']); - assert.equal(forbiddenStatus,400,'gateway must reject transfer before Wallet RPC'); - console.log('stage: forbidden transfer blocked'); - - const rogueTransport=await loadMoneroMtlsTransport({url:gatewayUrl,clientCertificateFile:certs['rogue.crt'], - clientPrivateKeyFile:certs['rogue.key'],serverCaFile:certs['ca.crt']}); - await assert.rejects(gatewayCall(rogueTransport,'get_address',{account_index:0,address_index:[0]})); - console.log('stage: untrusted client certificate rejected'); - await assert.rejects(fetch(gatewayUrl,{method:'POST',headers:{'content-type':'application/json'},body:'{}', - signal:AbortSignal.timeout(3000)}),'gateway must reject clients without a certificate'); - console.log('stage: missing client certificate rejected'); - passed=true; - console.log(JSON.stringify({result:'passed',moneroWalletRpc:'0.18.5.4',network:'Stagenet',containerNetwork:'none', - rpcAuthentication:'mTLS',tlsMinimum:'1.3',approvedClientAccepted:true,untrustedClientRejected:true, - noCertificateRejected:true,forbiddenTransferRejected:true,allowedAddressCreation:true,allowedOutputRead:true,watchOnly:true, - realFundsUsed:false,secretsOrAddressesLogged:false})); -} finally { - if(gateway) await new Promise(resolveClose=>gateway.close(()=>resolveClose())); - await stopWallet(); await rm(temp,{recursive:true,force:true}); if(!passed) process.exitCode=1; -} diff --git a/scripts/monero-observer-worker.ts b/scripts/monero-observer-worker.ts deleted file mode 100755 index cf67c43..0000000 --- a/scripts/monero-observer-worker.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Pool } from 'pg'; -import { createMoneroWalletObserver, loadMoneroObserverConfig, moneroWalletFingerprint } 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 { runMoneroObserverWorker } from '../server/market/monero-observer-worker.ts'; - -if (process.platform === 'win32') throw new Error('Monero observer worker requires a POSIX deployment with verifiable protected-file permissions'); -if (process.env.WIKIDECK_MONERO_OBSERVER_WORKER !== '1') throw new Error('Set WIKIDECK_MONERO_OBSERVER_WORKER=1 to start the private observer worker'); - -const observerConfig = await loadMoneroObserverConfig(); -const transportConfig = await loadMoneroMtlsTransportConfig(); -if (new URL(observerConfig.url).href !== new URL(transportConfig.url).href) { - throw new Error('Observer and mTLS transport endpoints do not match'); -} -const transport = await loadMoneroMtlsTransport(transportConfig); -const scanner = createMoneroWalletObserver(observerConfig, transport); -const primaryAddress = await scanner.verifyMainnetWallet(); -const walletFingerprint = await moneroWalletFingerprint(primaryAddress); -const pool = new Pool(await postgresConfig()); -pool.on('error', () => console.error(JSON.stringify({ component: 'monero-observer', event: 'database-unavailable' }))); -const abort = new AbortController(); -const stop = () => abort.abort(); -process.once('SIGINT', stop); -process.once('SIGTERM', stop); - -try { - await runMoneroObserverWorker(pool, walletFingerprint, scanner, { - pollIntervalMs: parsePollInterval(process.env.WIKIDECK_MONERO_OBSERVER_POLL_MS), - signal: abort.signal, - onBatch(result) { - console.log(JSON.stringify({ component: 'monero-observer', event: 'scan-complete', - scanHeight: result.scanHeight, walletHeight: result.walletHeight, caughtUp: result.caughtUp, - pages: result.pages.length, observed: result.pages.reduce((sum, page) => sum + page.observed, 0), - changed: result.pages.reduce((sum, page) => sum + page.changed, 0), - reappeared: result.pages.reduce((sum, page) => sum + page.reappeared, 0), - notSeen: result.pages.reduce((sum, page) => sum + page.notSeen, 0) })); - }, - onFailure() { console.error(JSON.stringify({ component: 'monero-observer', event: 'scan-failed', action: 'retrying' })); }, - }); -} finally { - process.off('SIGINT', stop); - process.off('SIGTERM', stop); - await pool.end(); -} - -function parsePollInterval(value: string | undefined): number { - if (value === undefined) return 30_000; - if (!/^[1-9]\d{0,5}$/.test(value)) throw new Error('Invalid Monero observer polling interval'); - const interval = Number(value); - if (!Number.isSafeInteger(interval) || interval < 1_000 || interval > 15 * 60_000) { - throw new Error('Invalid Monero observer polling interval'); - } - return interval; -} diff --git a/scripts/monero-stagenet-rpc-rehearsal.mjs b/scripts/monero-stagenet-rpc-rehearsal.mjs deleted file mode 100755 index 6c23e29..0000000 --- a/scripts/monero-stagenet-rpc-rehearsal.mjs +++ /dev/null @@ -1,170 +0,0 @@ -import assert from 'node:assert/strict'; -import { randomBytes, randomUUID } from 'node:crypto'; -import { spawn } from 'node:child_process'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; - -const walletRpcBinary = process.argv[2]; -if (!walletRpcBinary || !/monero-wallet-rpc(?:\.exe)?$/i.test(walletRpcBinary)) { - throw new Error('Pass the path to the verified monero-wallet-rpc binary'); -} -const rpcBinary = resolve(walletRpcBinary); -const port = 18083; -const endpoint = `http://127.0.0.1:${port}/json_rpc`; -const walletPassword = randomBytes(32).toString('hex'); -const temp = await mkdtemp(join(tmpdir(), 'wikideck-stagenet-rpc-')); -const walletDirectory = join(temp, 'wallets'); -const watchOnlyName = 'wikideck-stagenet-watch-only'; -let child; - -function startWalletRpc(walletFile) { - const args = [ - '--stagenet', '--offline', '--rpc-bind-ip=127.0.0.1', `--rpc-bind-port=${port}`, - '--disable-rpc-login', `--wallet-dir=${walletDirectory}`, - '--non-interactive', '--no-initial-sync', '--max-concurrency=1', '--log-level=0', - '--log-file=' + join(temp, 'wallet-rpc.log'), - ]; - if (walletFile) { - args.splice(args.indexOf(`--wallet-dir=${walletDirectory}`), 1); - args.push(`--wallet-file=${walletFile}`, `--password=${walletPassword}`); - } - child = spawn(rpcBinary, args, { shell: false, windowsHide: true, stdio: 'ignore', cwd: temp }); - child.on('error', () => {}); -} - -async function rpc(method, params = {}) { - const id = randomUUID(); - const response = await fetch(endpoint, { - method: 'POST', - redirect: 'error', - signal: AbortSignal.timeout(5000), - headers: { - accept: 'application/json', - 'content-type': 'application/json', - }, - body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), - }); - assert.equal(response.status, 200, `RPC ${method} status`); - const body = await response.json(); - assert.equal(body.jsonrpc, '2.0'); - assert.equal(body.id, id); - if (body.error) { - const error = new Error(`RPC ${method} returned an error`); - error.rpcError = body.error; - throw error; - } - return body.result; -} - -async function waitUntilReady() { - const deadline = Date.now() + 15_000; - while (Date.now() < deadline) { - if (child.exitCode !== null) throw new Error(`wallet-RPC exited during startup (${child.exitCode})`); - try { - return await rpc('get_version'); - } catch { - await new Promise(resolveWait => setTimeout(resolveWait, 100)); - } - } - throw new Error('wallet-RPC did not become ready on loopback'); -} - -async function stopWalletRpc() { - if (!child || child.exitCode !== null) return; - await rpc('store').catch(() => {}); - await rpc('stop_wallet').catch(() => {}); - await Promise.race([ - new Promise(resolveExit => child.once('exit', resolveExit)), - new Promise(resolveTimeout => setTimeout(resolveTimeout, 5000)), - ]); - if (child.exitCode === null) child.kill('SIGKILL'); -} - -let created = false; -try { - console.log('stage: start offline stagenet wallet RPC'); - startWalletRpc(); - const version = await waitUntilReady(); - console.log('stage: RPC ready'); - assert.equal(version.release, true); - - await rpc('create_wallet', { filename: 'temporary-source', password: walletPassword, language: 'English' }); - console.log('stage: disposable source wallet created'); - const originalAddress = (await rpc('get_address', { account_index: 0 })).address; - const originalValidation = await rpc('validate_address', { address: originalAddress }); - assert.equal(originalValidation.valid, true); - assert.equal(originalValidation.nettype, 'stagenet'); - const viewKey = (await rpc('query_key', { key_type: 'view_key' })).key; - assert.match(viewKey, /^[a-f0-9]{64}$/); - - const generated = await rpc('generate_from_keys', { - filename: watchOnlyName, - address: originalAddress, - viewkey: viewKey, - password: walletPassword, - restore_height: 0, - autosave_current: true, - }); - console.log('stage: watch-only wallet generated'); - assert.match(generated.address, /^.{95}$/); - assert.equal((await rpc('get_address', { account_index: 0 })).address, originalAddress); - const watchValidation = await rpc('validate_address', { address: originalAddress }); - assert.equal(watchValidation.valid, true); - assert.equal(watchValidation.nettype, 'stagenet'); - await assert.rejects(rpc('query_key', { key_type: 'spend_key' })); - - const before = await rpc('get_address', { account_index: 0 }); - const reservedIndex = Math.max(...before.addresses.map(address => address.address_index)) + 1; - let absent; - try { - absent = await rpc('get_address', { account_index: 0, address_index: [reservedIndex] }); - } catch (error) { - assert.equal(error.rpcError?.code, -15); - assert.match(error.rpcError?.message ?? '', /address index is out of bound/i); - absent = { addresses: [] }; - } - assert.equal(absent.addresses.length, 0, 'reserved index should be absent before create_address'); - - const label = `wikideck-deposit-v1:${randomUUID()}`; - const lostResponse = await rpc('create_address', { account_index: 0, label, count: 1 }); - console.log('stage: one subaddress created; simulating lost application response'); - assert.equal(lostResponse.address_index, reservedIndex); - assert.equal(lostResponse.addresses.length, 1); - // Simulate the application losing the response: discard its address, then recover by reserved index. - const recovered = await rpc('get_address', { account_index: 0, address_index: [reservedIndex] }); - assert.equal(recovered.addresses.length, 1); - assert.equal(recovered.addresses[0].address, lostResponse.address); - assert.equal(recovered.addresses[0].label, label); - - await stopWalletRpc(); - console.log('stage: wallet RPC stopped after store'); - startWalletRpc(join(walletDirectory, watchOnlyName)); - await waitUntilReady(); - console.log('stage: wallet RPC restarted with watch-only wallet'); - assert.equal((await rpc('get_address', { account_index: 0 })).address, originalAddress); - assert.equal((await rpc('validate_address', { address: originalAddress })).nettype, 'stagenet'); - await assert.rejects(rpc('query_key', { key_type: 'spend_key' })); - const afterRestart = await rpc('get_address', { account_index: 0, address_index: [reservedIndex] }); - assert.equal(afterRestart.addresses.length, 1); - assert.equal(afterRestart.addresses[0].address, recovered.addresses[0].address); - assert.equal(afterRestart.addresses[0].label, label); - created = true; - console.log(JSON.stringify({ - result: 'passed', - moneroWalletRpc: '0.18.5.4', - network: 'Stagenet', - offline: true, - watchOnly: true, - rpcBind: '127.0.0.1', - subaddressIndex: reservedIndex, - lostResponseRecovery: true, - restartPersistence: true, - realFundsUsed: false, - addressOrKeysLogged: false, - })); -} finally { - await stopWalletRpc(); - await rm(temp, { recursive: true, force: true }); - if (!created) process.exitCode = 1; -} diff --git a/scripts/pilot-monero-deposit-address.ts b/scripts/pilot-monero-deposit-address.ts deleted file mode 100755 index eb88de8..0000000 --- a/scripts/pilot-monero-deposit-address.ts +++ /dev/null @@ -1,64 +0,0 @@ -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/scripts/pilot-monero-observe-once.ts b/scripts/pilot-monero-observe-once.ts deleted file mode 100755 index 3efcf76..0000000 --- a/scripts/pilot-monero-observe-once.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { Pool } from 'pg'; -import { IS_PILOT } from '../shared/network.ts'; -import { createMoneroWalletObserver, loadMoneroObserverConfig, moneroWalletFingerprint } 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 { parsePilotCustodianId, verifyPilotCustodyIdentity } from '../server/market/pilot-custody-identity.ts'; -import { readPilotMoneroObservationCursor, persistPilotMoneroObservationScan } from '../server/market/pilot-monero-observation-store.ts'; - -if (process.platform === 'win32') throw new Error('Pilot observer requires POSIX deployment with protected-file permission checks'); -if (!IS_PILOT || process.env.WIKIDECK_PILOT_MODE !== 'private-mock-only') { - throw new Error('Pilot observer requires the private mock-only pilot profile'); -} -if (process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS !== 'pilot-observation-only-no-credit') { - throw new Error('Set WIKIDECK_PILOT_CUSTODY_OPERATIONS=pilot-observation-only-no-credit for this explicit observer action'); -} - -const custodianId = parsePilotCustodianId(process.env.WIKIDECK_PILOT_CUSTODIAN_ID ?? ''); -const walletGenerationId = process.env.WIKIDECK_MONERO_WALLET_GENERATION_ID ?? ''; -const expectedFingerprint = process.env.WIKIDECK_MONERO_WALLET_FINGERPRINT ?? ''; -if (!/^[a-f0-9-]{36}$/i.test(walletGenerationId) || !/^[a-f0-9]{64}$/.test(expectedFingerprint)) { - throw new Error('Invalid protected pilot watch-only wallet generation or fingerprint'); -} -function positiveBounded(name: string, fallback: number, maximum: number): number { - const value = process.env[name]; - if (value === undefined) return fallback; - if (!/^[1-9]\d{0,5}$/.test(value)) throw new Error(`Invalid ${name}`); - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed > maximum) throw new Error(`Invalid ${name}`); - return parsed; -} -const maxBlocks = positiveBounded('WIKIDECK_PILOT_OBSERVER_MAX_BLOCKS', 5000, 100_000); -const overlap = positiveBounded('WIKIDECK_PILOT_OBSERVER_OVERLAP', 100, 10_000); - -const observerConfig = await loadMoneroObserverConfig({ ...process.env, WIKIDECK_MONERO_OBSERVER: '1' }); -const transportConfig = await loadMoneroMtlsTransportConfig(); -if (observerConfig.network !== 'mainnet' || new URL(observerConfig.url).href !== new URL(transportConfig.url).href) { - throw new Error('Pilot observer requires matching Mainnet Wallet-RPC and mTLS gateway endpoints'); -} -const scanner = createMoneroWalletObserver(observerConfig, await loadMoneroMtlsTransport(transportConfig)); -const primaryAddress = await scanner.verifyMainnetWallet(); -const walletFingerprint = await moneroWalletFingerprint(primaryAddress); -if (walletFingerprint !== expectedFingerprint) throw new Error('Configured pilot wallet fingerprint does not match protected Wallet-RPC'); - -const dbUrlFile = process.env.WIKIDECK_PILOT_OBSERVER_PG_URL_FILE; -const dbCaFile = process.env.WIKIDECK_PILOT_OBSERVER_PG_CA_FILE; -if (!dbUrlFile || !dbCaFile) throw new Error('Pilot observer PostgreSQL secret URL and CA files are required'); -const pool = new Pool(await postgresConfig({ ...process.env, - WIKIDECK_CUSTODY_PG_URL_FILE: dbUrlFile, - WIKIDECK_CUSTODY_PG_CA_FILE: dbCaFile, - WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls' })); -pool.on('error', () => console.error(JSON.stringify({ component: 'pilot-monero-observer', event: 'database-unavailable' }))); -try { - const identity = await verifyPilotCustodyIdentity(pool, custodianId); - const scanHeight = await readPilotMoneroObservationCursor(pool, walletGenerationId, walletFingerprint); - const fromHeight = Math.max(0, scanHeight - overlap); - const page = await scanner.scanIncoming(fromHeight, maxBlocks); - const result = await persistPilotMoneroObservationScan(pool, { scanId: randomUUID(), walletGenerationId, - walletFingerprint, expectedScanHeight: scanHeight, scannedAt: Date.now(), page }); - console.log(JSON.stringify({ component: 'pilot-monero-observer', network: identity.moneroNetwork, - marketNetwork: identity.marketNetwork, walletHeight: page.walletHeight, scanHeight: result.scanHeight, - caughtUp: result.scanHeight >= page.walletHeight, observed: result.observed, assigned: result.assigned, - unallocated: result.unallocated, changed: result.changed, reappeared: result.reappeared, - notSeen: result.notSeen, replayed: result.replayed, credits: 'disabled' })); -} catch { - console.error('Pilot observation failed or requires review; no balance credit was performed.'); - process.exitCode = 1; -} finally { await pool.end(); } diff --git a/scripts/rpc-load.ts b/scripts/rpc-load.ts index 083aab6..1c8111b 100755 --- a/scripts/rpc-load.ts +++ b/scripts/rpc-load.ts @@ -1,3 +1,5 @@ +export {}; + // Opt-in loopback-only, read-only smoke load. Not a production capacity certification. const args=process.argv.slice(2); const argument=(key:string,fallback:string)=>{const i=args.indexOf(key);return i<0?fallback:args[i+1];}; @@ -12,7 +14,8 @@ const started=performance.now(); await Promise.all(Array.from({length:concurrency},async()=>{ while(next2*1024*1024)throw new Error('Response too large');}}finally{await reader.cancel();reader.releaseLock();} totalBytes+=bytes;statuses[response.status]=(statuses[response.status]??0)+1; diff --git a/scripts/run-monero-mtls-current-source.mjs b/scripts/run-monero-mtls-current-source.mjs deleted file mode 100755 index bb949fe..0000000 --- a/scripts/run-monero-mtls-current-source.mjs +++ /dev/null @@ -1,23 +0,0 @@ -import { execFileSync } from 'node:child_process'; - -const containerArgs = ['create', '--network', 'none', '--entrypoint', 'node', 'wikideck-monero-mtls-test', - '--experimental-strip-types', 'scripts/monero-mtls-stagenet-rehearsal.mjs', '/opt/monero/monero-wallet-rpc']; -const containerId = execFileSync('docker', containerArgs, { encoding: 'utf8' }).trim(); -if (!/^[a-f0-9]{64}$/.test(containerId)) throw new Error('Docker returned an invalid rehearsal container identity'); - -let exitCode = 1; -try { - execFileSync('docker', ['cp', 'Z:\\Repositories\\wikideck\\server', `${containerId}:/app/`], { stdio: 'inherit' }); - execFileSync('docker', ['cp', 'Z:\\Repositories\\wikideck\\shared', `${containerId}:/app/`], { stdio: 'inherit' }); - execFileSync('docker', ['cp', 'Z:\\Repositories\\wikideck\\scripts\\monero-mtls-stagenet-rehearsal.mjs', - `${containerId}:/app/scripts/monero-mtls-stagenet-rehearsal.mjs`], { stdio: 'inherit' }); - try { - execFileSync('docker', ['start', '--attach', containerId], { stdio: 'inherit' }); - exitCode = 0; - } catch (error) { - exitCode = typeof error.status === 'number' ? error.status : 1; - } -} finally { - execFileSync('docker', ['rm', '--force', containerId], { stdio: 'ignore' }); -} -process.exitCode = exitCode; diff --git a/scripts/run-monero-mtls-current-source.ps1 b/scripts/run-monero-mtls-current-source.ps1 deleted file mode 100755 index 6f17476..0000000 --- a/scripts/run-monero-mtls-current-source.ps1 +++ /dev/null @@ -1,20 +0,0 @@ -$ErrorActionPreference = 'Stop' -$containerId = & docker create --network none --entrypoint node wikideck-monero-mtls-test ` - --experimental-strip-types scripts/monero-mtls-stagenet-rehearsal.mjs /opt/monero/monero-wallet-rpc -if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - -$exitCode = 1 -try { - & docker cp 'Z:\Repositories\wikideck\server' "${containerId}:/app/" - if ($LASTEXITCODE -ne 0) { throw 'Copying current server source into the isolated rehearsal container failed.' } - & docker cp 'Z:\Repositories\wikideck\shared' "${containerId}:/app/" - if ($LASTEXITCODE -ne 0) { throw 'Copying current shared source into the isolated rehearsal container failed.' } - & docker cp 'Z:\Repositories\wikideck\scripts\monero-mtls-stagenet-rehearsal.mjs' ` - "${containerId}:/app/scripts/monero-mtls-stagenet-rehearsal.mjs" - if ($LASTEXITCODE -ne 0) { throw 'Copying the current rehearsal script failed.' } - & docker start --attach $containerId - $exitCode = $LASTEXITCODE -} finally { - & docker rm --force $containerId | Out-Null -} -exit $exitCode \ No newline at end of file diff --git a/server/analysis/calibration.ts b/server/analysis/calibration.ts index cec7209..6ad13cd 100755 --- a/server/analysis/calibration.ts +++ b/server/analysis/calibration.ts @@ -53,7 +53,7 @@ export function calibrationReport() { .map(name=>[name,simulate(name)])); const a=scenarios.steady.at(-1)!,b=scenarios.rapid.at(-1)!; return { schema:'wikideck-consensus-analysis-v1',chainId:CHAIN_ID,consensusChanged:false, - scope:'Deterministic header-math scenarios only; no valid blocks, mining, payments or launch.', + scope:'Deterministic header-math scenarios only; no valid blocks, mining or launch.', initialExpectedHashes:expectedHashes(MAX_TARGET),targetSeconds:CONSENSUS.targetSeconds, assumptions:'Spacing is externally imposed, not stochastic mining at constant hashrate. Work comparisons do not imply an attacker can produce that work.', scenarios, diff --git a/server/coordinated-backup.ts b/server/coordinated-backup.ts deleted file mode 100755 index 9da7069..0000000 --- a/server/coordinated-backup.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { createHash } from 'node:crypto'; -import { spawn, type ChildProcess } from 'node:child_process'; -import { createReadStream } from 'node:fs'; -import { chmod, mkdir, lstat, readFile, readdir, writeFile, rm, open, realpath } from 'node:fs/promises'; -import { dirname, join, resolve, relative, isAbsolute } from 'node:path'; -import { tmpdir } from 'node:os'; -import { Pool } from 'pg'; -import { DatabaseSync } from 'node:sqlite'; -import { z } from 'zod'; -import { IS_MAINNET, NETWORK, PROFILE } from '../shared/network.ts'; -import { CHAIN_ID } from './testnet/consensus.ts'; -import { CUSTODY_WRITE_LOCK } from './market/postgres-schema.ts'; -import { postgresConfig } from './market/postgres-config.ts'; -import { withStoppedNodeLock, createNodeSnapshotUnlocked, verifyBackup } from './recovery.ts'; -import { auditPostgres } from './market/postgres-custody.ts'; - -const pairManifestSchema = z.object({ format: z.literal('wikideck-coordinated-backup'), version: z.literal(1), - network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID), profile: z.literal(PROFILE), - createdAt: z.string().datetime(), custodianId: z.string().uuid(), database: z.string().regex(/^[a-zA-Z0-9_-]+$/), - postgresServerVersion: z.string().min(1).max(64), postgresServerVersionNum: z.string().regex(/^\d{5,6}$/), - pgDumpVersion: z.string().min(1).max(128), pgRestoreVersion: z.string().min(1).max(128), - sqliteCutoverSourceDigest: z.string().regex(/^[a-f0-9]{64}$/).nullable(), - nodeManifestSha256: z.string().regex(/^[a-f0-9]{64}$/), - postgresDump: z.object({ name: z.literal('custody.dump'), bytes: z.number().int().positive().max(256 * 1024 ** 3), - sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict() }).strict(); -export type CoordinatedBackupManifest = z.infer; -const bindingSchema = z.object({ version: z.literal(1), custodianId: z.string().uuid() }).strict(); -function digest(data: Buffer | string): string { return createHash('sha256').update(data).digest('hex'); } -async function fileDigest(path: string): Promise { - const hash = createHash('sha256'); - for await (const chunk of createReadStream(path)) hash.update(chunk); - return hash.digest('hex'); -} -async function flush(path: string): Promise { - const handle = await open(path, 'r+'); try { await handle.sync(); } finally { await handle.close(); } -} -async function flushDirectory(path: string): Promise { - if (process.platform === 'win32') return; - const handle = await open(path, 'r'); try { await handle.sync(); } finally { await handle.close(); } -} -async function regularFile(path: string): Promise<{ size: number }> { - const info = await lstat(path); - if (!info.isFile() || info.isSymbolicLink()) throw new Error('Coordinated backup file is not a regular non-symlink file'); - return info; -} -function childEnvironment(config: Awaited>, caFile: string | undefined, - serviceFile: string, passFile: string): NodeJS.ProcessEnv { - if (typeof config.host !== 'string' || !config.host || typeof config.database !== 'string' || !config.database || - typeof config.user !== 'string' || !config.user) throw new Error('Incomplete PostgreSQL backup connection settings'); - const ssl = config.ssl; - const tls = ssl === false ? 'disable' : 'verify-full'; - const ca = typeof ssl === 'object' && ssl && 'ca' in ssl ? ssl.ca : undefined; - if (ca && !caFile) throw new Error('Private PostgreSQL CA must be staged for the backup client'); - // Whitelist child environment. Inherited PGHOSTADDR/SSL* variables could redirect libpq - // to a different endpoint than the node-pg advisory-lock connection we just audited. - const env: NodeJS.ProcessEnv = { PATH: process.env.PATH ?? '', - ...(process.platform === 'win32' ? { SYSTEMROOT: process.env.SYSTEMROOT ?? '', WINDIR: process.env.WINDIR ?? '' } : {}), - PGHOST: config.host, PGPORT: String(config.port), PGDATABASE: config.database, - PGUSER: config.user, PGPASSWORD: String(config.password ?? ''), PGPASSFILE: passFile, PGCONNECT_TIMEOUT: '10', - PGSERVICEFILE: serviceFile, PGSSLMODE: tls, ...(caFile ? { PGSSLROOTCERT: caFile } : {}) }; - delete env.PGSERVICE; delete env.PGSSLCERT; delete env.PGSSLKEY; delete env.PGOPTIONS; - return env; -} -async function dumpDatabase(config: Awaited>, output: string, binary: string): Promise { - const temp = await import('node:fs/promises').then(m => m.mkdtemp(join(tmpdir(), 'wikideck-pg-backup-'))); - let caFile: string | undefined; - try { - const ssl = config.ssl; - const ca = typeof ssl === 'object' && ssl && 'ca' in ssl ? ssl.ca : undefined; - if (typeof ca === 'string') { caFile = join(temp, 'root-ca.pem'); await writeFile(caFile, ca, { flag: 'wx', mode: 0o600 }); } - const serviceFile = join(temp, 'pg_service.conf'); - await writeFile(serviceFile, '', { flag: 'wx', mode: 0o600 }); - const passFile = join(temp, 'pgpass'); - await writeFile(passFile, '', { flag: 'wx', mode: 0o600 }); - const env = childEnvironment(config, caFile, serviceFile, passFile); - await new Promise((resolveDump, reject) => { - let diagnostic = ''; - const child: ChildProcess = spawn(binary, ['--no-password', '--format=custom', '--compress=6', - '--file', output, '--dbname', config.database!], { shell: false, windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'], env }); - child.stderr?.on('data', chunk => { diagnostic = (diagnostic + chunk.toString('utf8')).slice(-1024); }); - let timedOut = false; - const timeout = setTimeout(() => { - timedOut = true; child.kill(); - const forceKill = setTimeout(() => child.kill('SIGKILL'), 5000); forceKill.unref(); - child.once('exit', () => clearTimeout(forceKill)); - }, 30 * 60_000); - timeout.unref(); - child.once('error', () => { clearTimeout(timeout); reject(new Error('PostgreSQL dump utility failed to start')); }); - child.once('exit', code => { - clearTimeout(timeout); - if (timedOut) reject(new Error('PostgreSQL dump exceeded its time budget')); - else code === 0 ? resolveDump() : reject(new Error(`PostgreSQL dump failed (${code ?? 'signal'}): ${diagnostic.replace(/[\r\n\x00-\x1f]/g, ' ').slice(0, 500)}`)); - }); - }); - } finally { await rm(temp, { recursive: true, force: true }); } -} -async function toolVersion(binary: string): Promise { - return new Promise((resolveVersion, reject) => { - let stdout = ''; - const child: ChildProcess = spawn(binary, ['--version'], { shell: false, windowsHide: true, - stdio: ['ignore', 'pipe', 'ignore'], env: { PATH: process.env.PATH ?? '', ...(process.platform === 'win32' - ? { SYSTEMROOT: process.env.SYSTEMROOT ?? '', WINDIR: process.env.WINDIR ?? '' } : {}) } }); - child.stdout?.on('data', chunk => { - if (stdout.length + chunk.length > 1024) { child.kill(); return; } - stdout += chunk.toString('utf8'); - }); - const timeout = setTimeout(() => child.kill(), 10_000); timeout.unref(); - child.once('error', () => { clearTimeout(timeout); reject(new Error('PostgreSQL client utility failed to start')); }); - child.once('exit', code => { - clearTimeout(timeout); - const version = stdout.trim(); - if (code !== 0 || !/^pg_(?:dump|restore) \(PostgreSQL\) \d+(?:\.\d+){1,2}$/.test(version)) { - reject(new Error('PostgreSQL client utility returned an invalid version response')); - } else resolveVersion(version); - }); - }); -} -function pgMajor(version: string): number { - const match = /\(PostgreSQL\) (\d+)/.exec(version); - if (!match) throw new Error('Invalid PostgreSQL client version'); - return Number(match[1]); -} - -/** Stop-node + PostgreSQL writer-fenced snapshot. This sandbox tool never restores or resets a database. */ -export async function createCoordinatedCustodyBackup(dataDirectory: string, destination: string, - env: NodeJS.ProcessEnv = process.env): Promise { - if (IS_MAINNET) throw new Error('Coordinated custody backup utility is not approved for mainnet'); - const backupUrlFile = env.WIKIDECK_CUSTODY_PG_BACKUP_URL_FILE; - if (!backupUrlFile) throw new Error('A separate PostgreSQL backup-role URL file is required'); - const runtimeConfig = await postgresConfig(env); - const backupConfig = await postgresConfig({ ...env, WIKIDECK_CUSTODY_PG_URL_FILE: backupUrlFile }); - const dumpBinary = env.WIKIDECK_PG_DUMP_BINARY ?? 'pg_dump'; - const restoreBinary = env.WIKIDECK_PG_RESTORE_BINARY ?? 'pg_restore'; - if (dumpBinary !== 'pg_dump' && !isAbsolute(dumpBinary)) throw new Error('Custom PostgreSQL dump binary must use an absolute path'); - if (restoreBinary !== 'pg_restore' && !isAbsolute(restoreBinary)) throw new Error('Custom PostgreSQL restore binary must use an absolute path'); - const [pgDumpVersion, pgRestoreVersion] = await Promise.all([toolVersion(dumpBinary), toolVersion(restoreBinary)]); - if (pgMajor(pgRestoreVersion) < pgMajor(pgDumpVersion)) throw new Error('PostgreSQL restore utility is older than the dump utility'); - if (backupConfig.host !== runtimeConfig.host || backupConfig.port !== runtimeConfig.port || - backupConfig.database !== runtimeConfig.database || backupConfig.user === runtimeConfig.user) { - throw new Error('Backup database target must match custody, using a separate backup role'); - } - const source = await realpath(dataDirectory); - const target = resolve(destination), rel = relative(source, target); - if (!rel || (!rel.startsWith('..') && !isAbsolute(rel))) throw new Error('Backup destination must be outside node data directory'); - const parentPath = dirname(target), parent = await realpath(parentPath); - if (process.platform === 'win32' ? parent.toLowerCase() !== parentPath.toLowerCase() : parent !== parentPath) { - throw new Error('Backup destination parent must not be symlinked'); - } - try { await lstat(target); throw new Error('Backup destination already exists'); } - catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } - - return withStoppedNodeLock(source, async lockedSource => { - // Reserve the final name exclusively. The pair manifest is written last; partial folders never verify. - await mkdir(target, { mode: 0o700 }); - const bindingPath = join(lockedSource, PROFILE, 'custody-postgres.json'); - const bindingInfo = await regularFile(bindingPath); - if (bindingInfo.size > 4096) throw new Error('PostgreSQL data-directory binding exceeds limit'); - const binding = bindingSchema.parse(JSON.parse(await readFile(bindingPath, 'utf8'))); - const pool = new Pool({ ...backupConfig, max: 1, application_name: 'wikideck-coordinated-backup' }); - let client; - try { - client = await pool.connect(); - const actualDb = await client.query('SELECT current_database() AS name'); - if (actualDb.rows[0].name !== backupConfig.database) throw new Error('Backup connected to an unexpected database'); - const serverVersion = await client.query(`SELECT current_setting('server_version') AS version, - current_setting('server_version_num')::text AS version_num`); - await client.query('SELECT pg_advisory_lock($1::bigint)', [CUSTODY_WRITE_LOCK]); - const identity = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'"); - if (!identity.rowCount) throw new Error('Custody PostgreSQL identity missing'); - const record = JSON.parse(identity.rows[0].value); - if (record.id !== binding.custodianId || record.version !== 1 || record.network !== NETWORK || - record.chainId !== CHAIN_ID || record.mode !== 'sandbox') throw new Error('PostgreSQL and node custody identities differ'); - const imported = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='import_source'"); - const frozenSource = join(lockedSource, PROFILE, 'custody-sandbox.sqlite'); - try { - const sourceInfo = await lstat(frozenSource); - if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink() || !imported.rowCount) throw new Error('Frozen SQLite cutover source is not bound to this PostgreSQL database'); - const sourceDb = new DatabaseSync(frozenSource, { readOnly: true }); - try { - const marker = sourceDb.prepare("SELECT value FROM custody_meta WHERE key='postgres_cutover'").get() as { value: string } | undefined; - if (marker?.value !== imported.rows[0].value) throw new Error('Frozen SQLite source digest differs from PostgreSQL import binding'); - } finally { sourceDb.close(); } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - if (imported.rowCount) throw new Error('Imported PostgreSQL custody is missing its frozen SQLite source; operator review required'); - } - await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'); - try { await auditPostgres(client); await client.query('COMMIT'); } - catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } - - const nodeManifest = await createNodeSnapshotUnlocked(lockedSource, join(target, 'node'), true); - const dumpPath = join(target, 'custody.dump'); - await dumpDatabase(backupConfig, dumpPath, dumpBinary); - const dumpInfo = await regularFile(dumpPath); - if (dumpInfo.size < 1 || dumpInfo.size > 256 * 1024 ** 3) throw new Error('PostgreSQL dump size outside supported bounds'); - if (process.platform !== 'win32') await chmod(dumpPath, 0o600); - await flush(dumpPath); - const manifest = pairManifestSchema.parse({ format: 'wikideck-coordinated-backup', version: 1, network: NETWORK, - chainId: CHAIN_ID, profile: PROFILE, createdAt: new Date().toISOString(), custodianId: binding.custodianId, - database: backupConfig.database, postgresServerVersion: serverVersion.rows[0].version, - postgresServerVersionNum: serverVersion.rows[0].version_num, pgDumpVersion, pgRestoreVersion, - sqliteCutoverSourceDigest: imported.rows[0]?.value ?? null, - nodeManifestSha256: digest(JSON.stringify(nodeManifest)), - postgresDump: { name: 'custody.dump', bytes: dumpInfo.size, sha256: await fileDigest(dumpPath) } }); - await writeFile(join(target, 'manifest.json'), JSON.stringify(manifest, null, 2), { flag: 'wx', mode: 0o600 }); - if (process.platform !== 'win32') await chmod(join(target, 'manifest.json'), 0o600); - await flush(join(target, 'manifest.json')); await flushDirectory(target); await flushDirectory(parent); - return manifest; - } finally { - if (client) { - await client.query('SELECT pg_advisory_unlock($1::bigint)', [CUSTODY_WRITE_LOCK]).catch(() => {}); - client.release(); - } - await pool.end(); - } - }); -} - -async function verifyDumpArchive(path: string, binary: string): Promise { - if (binary !== 'pg_restore' && !isAbsolute(binary)) throw new Error('Custom PostgreSQL restore binary must use an absolute path'); - await new Promise((resolveArchive, reject) => { - let diagnostic = '', listing = '', listingTooLarge = false; - const child: ChildProcess = spawn(binary, ['--list', path], { - shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], - env: { PATH: process.env.PATH ?? '', SYSTEMROOT: process.env.SYSTEMROOT ?? '', WINDIR: process.env.WINDIR ?? '' }, - }); - child.stdout?.on('data', chunk => { - if (listing.length + chunk.length > 2 * 1024 * 1024) { - listingTooLarge = true; child.kill(); return; - } - listing += chunk.toString('utf8'); - }); - child.stderr?.on('data', chunk => { diagnostic = (diagnostic + chunk.toString('utf8')).slice(-1024); }); - let timedOut = false; - const timeout = setTimeout(() => { - timedOut = true; child.kill(); - const forceKill = setTimeout(() => child.kill('SIGKILL'), 5000); forceKill.unref(); - child.once('exit', () => clearTimeout(forceKill)); - }, 60_000); - timeout.unref(); - child.once('error', () => { clearTimeout(timeout); reject(new Error('PostgreSQL restore utility failed to start')); }); - child.once('exit', code => { - clearTimeout(timeout); - if (timedOut) reject(new Error('PostgreSQL archive verification exceeded its time budget')); - else if (listingTooLarge) reject(new Error('PostgreSQL dump archive listing exceeds verification budget')); - else if (code !== 0) reject(new Error(`PostgreSQL dump archive is invalid (${code ?? 'signal'}): ${diagnostic.replace(/[\r\n\x00-\x1f]/g, ' ').slice(0, 500)}`)); - else if (!['schema_versions', 'custody_meta', 'accounts', 'journals', 'entries'].every(name => - new RegExp(`\\bTABLE\\s+wk_custody\\s+${name}(?:\\s|$)`).test(listing))) { - reject(new Error('PostgreSQL dump archive is missing required custody accounting tables')); - } - else resolveArchive(); - }); - }); -} - -export async function verifyCoordinatedCustodyBackup(directory: string, - restoreBinary = process.env.WIKIDECK_PG_RESTORE_BINARY ?? 'pg_restore'): Promise { - if (restoreBinary !== 'pg_restore' && !isAbsolute(restoreBinary)) throw new Error('Custom PostgreSQL restore binary must use an absolute path'); - const root = await realpath(directory); - const manifestPath = join(root, 'manifest.json'), info = await regularFile(manifestPath); - if (info.size > 16 * 1024) throw new Error('Coordinated backup manifest exceeds limit'); - const manifest = pairManifestSchema.parse(JSON.parse(await readFile(manifestPath, 'utf8'))); - const restoreVersion = await toolVersion(restoreBinary); - if (pgMajor(restoreVersion) < pgMajor(manifest.pgDumpVersion)) throw new Error('Installed pg_restore is older than archive pg_dump; use a compatible maintenance image'); - const nodePath = join(root, 'node'), nodeInfo = await lstat(nodePath); - if (!nodeInfo.isDirectory() || nodeInfo.isSymbolicLink()) throw new Error('Node backup package must be a regular directory'); - const nodeManifest = await verifyBackup(nodePath); - if (digest(JSON.stringify(nodeManifest)) !== manifest.nodeManifestSha256 || nodeManifest.network !== manifest.network || - nodeManifest.chainId !== manifest.chainId || nodeManifest.profile !== manifest.profile) throw new Error('Node snapshot does not match pair manifest'); - const expectedNodeEntries = [...nodeManifest.files.map(file => file.name), 'manifest.json'].sort(); - if ((await readdir(nodePath)).sort().join('|') !== expectedNodeEntries.join('|')) { - throw new Error('Unexpected coordinated node backup contents'); - } - const dumpPath = join(root, manifest.postgresDump.name), dumpInfo = await regularFile(dumpPath); - if (dumpInfo.size !== manifest.postgresDump.bytes || await fileDigest(dumpPath) !== manifest.postgresDump.sha256) { - throw new Error('PostgreSQL dump hash/size mismatch'); - } - await verifyDumpArchive(dumpPath, restoreBinary); - const entries = (await readdir(root)).sort(); - if (entries.join('|') !== ['custody.dump', 'manifest.json', 'node'].sort().join('|')) throw new Error('Unexpected coordinated backup contents'); - return manifest; -} diff --git a/server/launch.ts b/server/launch.ts index 51a1a7e..390f731 100755 --- a/server/launch.ts +++ b/server/launch.ts @@ -1,22 +1,12 @@ -import { IS_MAINNET, IS_PILOT, NETWORK, PROFILE } from '../shared/network.ts'; +import { IS_MAINNET, NETWORK, PROFILE } from '../shared/network.ts'; import { CHAIN_ID, GENESIS_HASH, GENESIS_TIME } from './testnet/consensus.ts'; export function launchManifest() { return { profile: PROFILE, network: NETWORK, chainId: CHAIN_ID, genesis: GENESIS_HASH, - genesisTime: GENESIS_TIME, launchStatus: IS_MAINNET ? 'candidate-gated' : IS_PILOT ? 'private-pilot-mock-only' : 'testnet', - paymentMode: 'mock', audited: false }; + genesisTime: GENESIS_TIME, launchStatus: IS_MAINNET ? 'candidate-gated' : 'testnet', audited: false }; } /** Operator attestation is an accident-prevention gate, NOT proof of a security audit. */ export function assertLaunchAllowed(env: NodeJS.ProcessEnv = process.env, now = Date.now()) { - if (IS_PILOT) { - if (env.WIKIDECK_PILOT_MODE !== 'private-mock-only') { - throw new Error('Private pilot requires WIKIDECK_PILOT_MODE=private-mock-only'); - } - if ((env.WIKIDECK_PAYMENT_MODE ?? 'mock') !== 'mock') { - throw new Error('Private pilot supports mock payments only; live XMR remains disabled'); - } - return; - } if (!IS_MAINNET) return; if (env.WIKIDECK_MAINNET_LAUNCH !== CHAIN_ID || env.WIKIDECK_MAINNET_REVIEW_ACK !== 'independent-review-complete') { throw new Error(`Mainnet candidate is launch-gated. Review the launch checklist; explicit chain approval required: ${CHAIN_ID}`); @@ -31,5 +21,4 @@ export function assertLaunchAllowed(env: NodeJS.ProcessEnv = process.env, now = if (peer.protocol !== 'https:' || peer.origin === origin.origin || peer.username || peer.password || peer.pathname !== '/' || peer.search || peer.hash) throw new Error('Invalid mainnet peer origin'); } if (new Set(peers.map(p => new URL(p).origin)).size < 2 || env.WIKIDECK_ALLOW_PRIVATE_PEERS === '1') throw new Error('Mainnet requires distinct public peer origins; private peer bypass is disabled'); - if ((env.WIKIDECK_PAYMENT_MODE ?? 'mock') !== 'mock') throw new Error('Live XMR payments remain disabled pending a separate settlement review'); } diff --git a/server/market/canonical-inbox.ts b/server/market/canonical-inbox.ts deleted file mode 100755 index 89e965a..0000000 --- a/server/market/canonical-inbox.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { PoolClient } from 'pg'; -import { z } from 'zod'; -import { GENESIS_HASH, sha256 } from '../testnet/consensus.ts'; - -const hex = z.string().regex(/^[a-f0-9]{64}$/); -const MAX_REHEARSAL_EVENTS = 4096; -const eventSchema = z.object({ sequence: z.number().int().positive().safe(), type: z.enum(['connect', 'disconnect']), - height: z.number().int().positive().safe(), hash: hex, previousHash: hex, - transactionIds: z.array(hex).max(64), eventHash: hex }).strict(); -const pageSchema = z.object({ streamId: z.string().uuid(), previousAnchor: hex, anchor: hex, - events: z.array(eventSchema).max(64), cursor: z.number().int().nonnegative().safe(), - head: z.number().int().nonnegative().safe(), caughtUp: z.boolean() }).strict(); -export type CanonicalInboxPage = z.infer; -export type CanonicalInboxResult = { streamId: string; cursor: number; head: number; height: number; tip: string; - replayed: boolean; appendedEvents: z.infer[]; reviewRequired?: boolean }; - -export class CanonicalInboxError extends Error { - readonly code: string; - constructor(code: string) { super(`Canonical event inbox rejected input: ${code}`); this.code = code; } -} -const streamKey = 'canonical-inbox:v1:stream'; -const eventKey = (sequence: number) => `canonical-inbox:v1:event:${sequence.toString().padStart(16, '0')}`; -function storedEvent(text: string): z.infer { - let decoded: unknown; - try { decoded = JSON.parse(text); } catch { throw new CanonicalInboxError('stored-event-json-invalid'); } - const parsed = eventSchema.safeParse(decoded); - if (!parsed.success) throw new CanonicalInboxError('stored-event-invalid'); - return parsed.data; -} -async function anchorAt(client: PoolClient, sequence: number): Promise { - if (sequence === 0) return GENESIS_HASH; - const row = await client.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1', [eventKey(sequence)]); - return row.rows[0] ? storedEvent(row.rows[0].value).eventHash : undefined; -} -function eventDigest(streamId: string, e: z.infer, previousAnchor: string): string { - return sha256(JSON.stringify(['wikideck-canonical-event-v1', streamId, e.sequence, e.type, - e.height, e.hash, e.previousHash, e.transactionIds, previousAnchor])); -} -function applyTip(stack: string[], e: z.infer): void { - if (e.type === 'connect') { - if (e.height !== stack.length + 1 || e.previousHash !== (stack.at(-1) ?? GENESIS_HASH)) throw new CanonicalInboxError('connect-parent-mismatch'); - stack.push(e.hash); return; - } - if (e.height !== stack.length || e.hash !== stack.at(-1) || e.height < 1 || - e.previousHash !== (stack.at(-2) ?? GENESIS_HASH)) throw new CanonicalInboxError('disconnect-ancestry-mismatch'); - stack.pop(); -} -async function readCanonicalHistory(client: PoolClient) { - const rows = await client.query(`SELECT key,value FROM wk_custody.custody_meta - WHERE key=$1 OR key LIKE 'canonical-inbox:v1:event:%' ORDER BY key LIMIT $2`, [streamKey, MAX_REHEARSAL_EVENTS + 2]); - if (rows.rows.length > MAX_REHEARSAL_EVENTS + 1) throw new CanonicalInboxError('rehearsal-event-budget-exceeded'); - const stream = rows.rows.find(r => r.key === streamKey)?.value as string | undefined; - if (rows.rows.length && (!stream || !z.string().uuid().safeParse(stream).success)) throw new CanonicalInboxError('stream-record-invalid'); - const stack: string[] = []; - let cursor = 0, anchor = GENESIS_HASH; - for (const row of rows.rows) { - if (row.key === streamKey) continue; - const match = /^canonical-inbox:v1:event:(\d{16})$/.exec(row.key); - const e = storedEvent(row.value); - if (!match || Number(match[1]) !== cursor + 1 || e.sequence !== cursor + 1) throw new CanonicalInboxError('history-gap-or-invalid-record'); - if (e.eventHash !== eventDigest(stream!, e, anchor)) throw new CanonicalInboxError('event-anchor-invalid'); - applyTip(stack, e); cursor++; anchor = e.eventHash; - } - return { streamId: stream, cursor, anchor, stack }; -} -function assertPageShape(page: CanonicalInboxPage): void { - const finalCursor = page.events.at(-1)?.sequence ?? page.cursor; - if ((page.events.length && page.events[0].sequence < 1) || page.cursor !== finalCursor || - page.head < page.cursor || page.caughtUp !== (page.cursor === page.head) || - (!page.events.length && !page.caughtUp) || - page.anchor !== (page.events.at(-1)?.eventHash ?? page.previousAnchor) || page.events.some((e, i) => - i > 0 && e.sequence !== page.events[i - 1].sequence + 1)) throw new CanonicalInboxError('page-shape-invalid'); -} - -/** Persist only events returned by the local ledger's validated canonical-event API. This verifies - * continuity/integrity, not PoW or provenance; callers must not pass peer-supplied, unvalidated data. - */ -export async function ingestCanonicalPage(client: PoolClient, raw: unknown): Promise { - const parsed = pageSchema.safeParse(raw); - if (!parsed.success) throw new CanonicalInboxError('page-schema-invalid'); - const page = parsed.data; - assertPageShape(page); - if (page.head > MAX_REHEARSAL_EVENTS || page.cursor > MAX_REHEARSAL_EVENTS) throw new CanonicalInboxError('rehearsal-event-budget-exceeded'); - const history = await readCanonicalHistory(client); - const storedStream = history.streamId; - if (storedStream && storedStream !== page.streamId) throw new CanonicalInboxError('stream-changed'); - let { cursor, anchor } = history; - const stack = history.stack; - if (!storedStream) { - if (cursor !== 0 || page.previousAnchor !== GENESIS_HASH || - (page.events.length ? page.events[0].sequence !== 1 : page.cursor !== 0)) { - throw new CanonicalInboxError('initial-history-required'); - } - await client.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)', [streamKey, page.streamId]); - } - if (!page.events.length) { - const savedAnchor = await anchorAt(client, page.cursor); - if (page.cursor > cursor || savedAnchor !== page.anchor || page.previousAnchor !== page.anchor) throw new CanonicalInboxError('empty-cursor-mismatch'); - return { streamId: page.streamId, cursor, head: Math.max(page.head, cursor), height: stack.length, - tip: stack.at(-1) ?? GENESIS_HASH, replayed: true, appendedEvents: [] }; - } - const firstSequence = page.events[0].sequence; - if (firstSequence > cursor + 1) throw new CanonicalInboxError('cursor-gap'); - const predecessor = await anchorAt(client, firstSequence - 1); - if (!predecessor || page.previousAnchor !== predecessor) throw new CanonicalInboxError('cursor-anchor-mismatch'); - let priorAnchor = predecessor; - let appended = false; - const appendedEvents: z.infer[] = []; - for (const e of page.events) { - if (e.eventHash !== eventDigest(page.streamId, e, priorAnchor)) throw new CanonicalInboxError('event-hash-invalid'); - if (e.sequence <= cursor) { - const saved = await client.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1', [eventKey(e.sequence)]); - if (!saved.rowCount || JSON.stringify(storedEvent(saved.rows[0].value)) !== JSON.stringify(e)) throw new CanonicalInboxError('replayed-event-mismatch'); - } else { - if (e.sequence !== cursor + 1 || priorAnchor !== anchor) throw new CanonicalInboxError('cursor-anchor-mismatch'); - applyTip(stack, e); - await client.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)', [eventKey(e.sequence), JSON.stringify(e)]); - cursor = e.sequence; anchor = e.eventHash; appended = true; appendedEvents.push(e); - } - priorAnchor = e.eventHash; - } - if (page.cursor !== page.events.at(-1)!.sequence || page.anchor !== priorAnchor) throw new CanonicalInboxError('cursor-anchor-mismatch'); - return { streamId: page.streamId, cursor, head: Math.max(page.head, cursor), height: stack.length, - tip: stack.at(-1) ?? GENESIS_HASH, replayed: !appended, appendedEvents }; -} - -/** Full append-only inbox verification. Expected to run under the caller's coherent audit snapshot. */ -export async function auditCanonicalInbox(client: PoolClient): Promise { - await readCanonicalHistory(client); - const reviews = await client.query(`SELECT key,value FROM wk_custody.custody_meta - WHERE key LIKE 'sale-reorg-review:%' ORDER BY key`); - for (const row of reviews.rows) { - let record: unknown; - try { record = JSON.parse(row.value); } catch { throw new CanonicalInboxError('sale-review-record-invalid'); } - const schema = z.object({ version: z.literal(1), streamId: z.string().uuid(), orderId: z.string().uuid(), - terminalTransactionId: hex, stateRoot: hex, eventSequence: z.number().int().positive().safe(), eventHash: hex }).strict(); - const parsed = schema.safeParse(record), keyMatch = /^sale-reorg-review:([a-f0-9-]{36}):(\d+)$/.exec(row.key); - if (!parsed.success || !keyMatch || keyMatch[1] !== parsed.data.orderId || Number(keyMatch[2]) !== parsed.data.eventSequence) { - throw new CanonicalInboxError('sale-review-record-invalid'); - } - const event = await client.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1', - [eventKey(parsed.data.eventSequence)]); - if (!event.rowCount) throw new CanonicalInboxError('sale-review-event-missing'); - const disconnected = storedEvent(event.rows[0].value); - if (disconnected.type !== 'disconnect' || disconnected.eventHash !== parsed.data.eventHash || - !disconnected.transactionIds.includes(parsed.data.terminalTransactionId)) { - throw new CanonicalInboxError('sale-review-event-mismatch'); - } - } - if (reviews.rowCount) throw new CanonicalInboxError('posted-sale-terminal-disconnected'); -} diff --git a/server/market/canonical-observer.ts b/server/market/canonical-observer.ts deleted file mode 100755 index 8f3b87b..0000000 --- a/server/market/canonical-observer.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Ledger } from '../testnet/ledger.ts'; -import type { PostgresCustody } from './postgres-custody.ts'; - -export type CanonicalObserverState = 'stopped' | 'running' | 'failed' | 'review-required'; -export type CanonicalObserverStatus = { state: CanonicalObserverState; cursor: number; head: number; height: number; - tip: string; caughtUp: boolean; failureCode: string | null }; - -/** Bounded polling loop for the explicitly disposable PostgreSQL rehearsal only. - * It reads local Ledger events; it does not verify candidate consensus or trigger custody payouts. - */ -export class CanonicalObserver { - private readonly custody: PostgresCustody; - private readonly ledger: Ledger; - private readonly intervalMs: number; - private readonly pagesPerCycle: number; - private active = false; - private timer: ReturnType | undefined; - private cycle: Promise | undefined; - private current: CanonicalObserverStatus = { state: 'stopped', cursor: 0, head: 0, height: 0, - tip: '', caughtUp: false, failureCode: null }; - - constructor(custody: PostgresCustody, ledger: Ledger, options: { intervalMs?: number; pagesPerCycle?: number } = {}) { - if (!custody.rehearsalObserverEnabled) throw new Error('Canonical observer requires disposable rehearsal custody'); - if (process.env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL !== '1' || process.env.WIKIDECK_PG_DISPOSABLE_TEST !== '1' || - process.env.NODE_ENV === 'production') throw new Error('Canonical observer requires explicit non-production disposable runtime opt-in'); - if (!(ledger instanceof Ledger)) throw new Error('Canonical observer requires a local Ledger instance'); - this.custody = custody; this.ledger = ledger; - this.intervalMs = options.intervalMs ?? 1000; this.pagesPerCycle = options.pagesPerCycle ?? 4; - if (!Number.isSafeInteger(this.intervalMs) || this.intervalMs < 25 || this.intervalMs > 60000 || - !Number.isSafeInteger(this.pagesPerCycle) || this.pagesPerCycle < 1 || this.pagesPerCycle > 16) { - throw new Error('Invalid canonical observer bounds'); - } - } - - status(): CanonicalObserverStatus { return { ...this.current }; } - - async start(): Promise { - if (this.active) { await this.cycle; return this.status(); } - if (this.current.state === 'failed' || this.current.state === 'review-required') return this.status(); - this.active = true; this.current = { ...this.current, state: 'running', failureCode: null }; - await this.runCycle(); - return this.status(); - } - - /** Deterministic wake-up hook; the scheduled poll and manual wake are serialized. */ - async pollNow(): Promise { - if (!this.active) throw new Error('Canonical observer is not running'); - if (this.timer) { clearTimeout(this.timer); this.timer = undefined; } - await this.runCycle(); - return this.status(); - } - - async close(): Promise { - this.active = false; - if (this.timer) { clearTimeout(this.timer); this.timer = undefined; } - await this.cycle; - if (this.current.state === 'running') this.current = { ...this.current, state: 'stopped' }; - } - - private runCycle(): Promise { - if (this.cycle) return this.cycle; - const work = (async () => { - for (let page = 0; page < this.pagesPerCycle && this.active; page++) { - try { - const result = await this.custody.observeLedgerCanonicalEvents(this.ledger); - this.current = { state: result.reviewRequired ? 'review-required' : 'running', cursor: result.cursor, - head: result.head, height: result.height, tip: result.tip, caughtUp: result.cursor >= result.head, - failureCode: result.reviewRequired ? 'posted-sale-terminal-disconnected' : null }; - if (result.reviewRequired) { this.active = false; break; } - if (result.cursor >= result.head) break; - } catch { - this.current = { ...this.current, state: 'failed', failureCode: 'canonical-observer-failed' }; - this.active = false; - break; - } - } - })(); - this.cycle = work.finally(() => { - this.cycle = undefined; - if (this.active && !this.timer) { - this.timer = setTimeout(() => { this.timer = undefined; void this.runCycle(); }, this.intervalMs); - this.timer.unref(); - } - }); - return this.cycle; - } -} diff --git a/server/market/custody-db-args.ts b/server/market/custody-db-args.ts deleted file mode 100755 index f164cc1..0000000 --- a/server/market/custody-db-args.ts +++ /dev/null @@ -1,122 +0,0 @@ -export const CUSTODY_DB_ACTION_ARGUMENT_COUNTS = { - migrate: 0, - 'migrate-mainnet-schema': 0, - 'initialize-mainnet-custody-identity': 1, - 'migrate-pilot-schema': 0, - 'initialize-pilot-custody-identity': 1, - 'grant-pilot-deposit-reservation-runtime': 1, - 'grant-pilot-deposit-address-runtime': 1, - 'grant-pilot-monero-observer-runtime': 1, - 'grant-pilot-monero-review-runtime': 1, - 'pilot-deposit-prepare-review': 2, - 'grant-runtime': 1, - 'grant-monero-observer-runtime': 1, - 'grant-monero-deposit-allocator-runtime': 1, - 'grant-monero-withdrawal-runtime': 1, - 'grant-monero-credit-runtime': 1, - 'import-sqlite': 1, - audit: 0, - 'resolve-integrity': 1, - 'deposit-prepare-credit': 2, - 'deposit-stage-credit': 1, - 'deposit-prepare-release': 1, - 'deposit-release': 1, - 'deposit-reconcile-holds': 1, - 'deposit-prepare-resolve-hold': 2, - 'deposit-resolve-hold': 1, -} as const; - -export type CustodyDbAction = keyof typeof CUSTODY_DB_ACTION_ARGUMENT_COUNTS; -export type CustodyDbArguments = { action: CustodyDbAction; argument?: string; extraArgument?: string }; - -export function assertCustodyDbActionAllowedOnNetwork(action: CustodyDbAction, isMainnet: boolean): void { - if ((action === 'migrate-mainnet-schema' || action === 'initialize-mainnet-custody-identity') && !isMainnet) { - throw new Error('Mainnet custody operation requires the mainnet profile'); - } - const mainnetAllowed = ['grant-monero-observer-runtime', 'grant-monero-deposit-allocator-runtime', - 'grant-monero-withdrawal-runtime', - 'grant-monero-credit-runtime', 'migrate-mainnet-schema', 'initialize-mainnet-custody-identity', - 'deposit-prepare-credit', 'deposit-stage-credit', 'deposit-prepare-release', 'deposit-release', - 'deposit-reconcile-holds', 'deposit-prepare-resolve-hold', 'deposit-resolve-hold']; - if (isMainnet && !mainnetAllowed.includes(action)) { - throw new Error('Only scoped Monero infrastructure grants, identity initialization, and reviewed credit/withdrawal-intent operations are allowed on mainnet'); - } -} - -export function assertMainnetSchemaMigrationAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void { - if (action === 'migrate-mainnet-schema' && acknowledgement !== 'explicit-schema-migration-only') { - throw new Error('Mainnet schema migration requires explicit schema-migration acknowledgement'); - } -} - -export function assertMainnetCustodyOperationAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void { - const requiresReviewAck = action === 'initialize-mainnet-custody-identity' || action === 'grant-monero-credit-runtime' || - action.startsWith('deposit-'); - if (requiresReviewAck && acknowledgement !== 'reviewed-credit-only') { - throw new Error('Mainnet deposit operations require WIKIDECK_MAINNET_CUSTODY_OPERATIONS=reviewed-credit-only'); - } -} - -export function assertPilotCustodyActionAllowed(action: CustodyDbAction, isPilot: boolean): void { - const pilotActions = ['migrate-pilot-schema', 'initialize-pilot-custody-identity', - 'grant-pilot-deposit-reservation-runtime', 'grant-pilot-deposit-address-runtime', 'grant-pilot-monero-observer-runtime', - 'grant-pilot-monero-review-runtime', 'pilot-deposit-prepare-review']; - if ((isPilot && !pilotActions.includes(action)) || (!isPilot && pilotActions.includes(action))) { - throw new Error(isPilot - ? 'Pilot profile permits only pilot-specific schema, identity, scoped-role, and review-preparation operations through this CLI' - : 'Pilot custody operation requires the pilot profile'); - } -} - -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 assertPilotAddressGrantAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void { - if (action === 'grant-pilot-deposit-address-runtime' && acknowledgement !== 'operator-triggered-addresses-only') { - throw new Error('Pilot address role grant requires explicit operator-triggered/address-only acknowledgement'); - } -} - -export function assertPilotObserverGrantAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void { - if (action === 'grant-pilot-monero-observer-runtime' && acknowledgement !== 'pilot-observation-only-no-credit') { - throw new Error('Pilot observer role grant requires explicit observation-only/no-credit acknowledgement'); - } -} - -export function assertPilotDepositReviewAcknowledged(action: CustodyDbAction, acknowledgement: string | undefined): void { - if (action === 'grant-pilot-monero-review-runtime' && acknowledgement !== 'read-only-pilot-review-role-only') { - throw new Error('Pilot review role grant requires the exact read-only-role acknowledgement'); - } - if (action === 'pilot-deposit-prepare-review' && acknowledgement !== 'pilot-deposit-review-preparation-only') { - throw new Error('Pilot deposit review preparation requires the exact preparation-only 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'); - } -} - -export function assertPilotCustodyIdentityAcknowledged(action: CustodyDbAction, identityAcknowledgement: string | undefined, - operationAcknowledgement: string | undefined): void { - if (action === 'initialize-pilot-custody-identity' && - (identityAcknowledgement !== 'initialize-pilot-custody-v1' || operationAcknowledgement !== 'identity-only-no-funds')) { - throw new Error('Pilot identity initialization requires exact identity-only/no-funds acknowledgements'); - } -} - -export function parseCustodyDbArguments(args: readonly string[]): CustodyDbArguments { - const [action, ...values] = args; - if (!action || !Object.hasOwn(CUSTODY_DB_ACTION_ARGUMENT_COUNTS, action)) { - throw new Error(`Expected one of: ${Object.keys(CUSTODY_DB_ACTION_ARGUMENT_COUNTS).join(', ')}`); - } - const count = CUSTODY_DB_ACTION_ARGUMENT_COUNTS[action as CustodyDbAction]; - if (values.length !== count || values.some(value => value.length === 0)) { - throw new Error(`Invalid argument count for custody operation: ${action}`); - } - return { action: action as CustodyDbAction, argument: values[0], extraArgument: values[1] }; -} diff --git a/server/market/custody-import.ts b/server/market/custody-import.ts deleted file mode 100755 index fb0a4a5..0000000 --- a/server/market/custody-import.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { DatabaseSync } from 'node:sqlite'; -import { createHash } from 'node:crypto'; -import { lstat } from 'node:fs/promises'; -import type { Pool } from 'pg'; -import { CUSTODY_POLICY } from '../../shared/custody.ts'; -import { NETWORK } from '../../shared/network.ts'; -import { CHAIN_ID } from '../testnet/consensus.ts'; -import { auditPostgres } from './postgres-custody.ts'; -import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts'; - -/** Explicit stopped-node SANDBOX import. Source freezes BEFORE target commit; failure requires resume, not dual writes. */ -export async function importSqliteCustody(file: string, pool: Pool): Promise<{ imported: true; sourceDigest: string; custodianId: string }> { - const stat = await lstat(file); - if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128 * 1024 * 1024) throw new Error('Invalid sandbox source database'); - const db = new DatabaseSync(file); - db.exec('PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000'); - const names = ['accounts', 'journals', 'entries', 'listings', 'orders', 'withdrawals', 'receipts', 'outbox', 'credits'] as const; - type Row = Record; - let snapshot: Record; - let identityText: string; - let sourceDigest: string; - try { - db.exec('BEGIN IMMEDIATE'); - try { - if (Object.values(db.prepare('PRAGMA quick_check').get()!)[0] !== 'ok') throw new Error('Invalid SQLite integrity'); - identityText = (db.prepare("SELECT value FROM custody_meta WHERE key='identity'").get() as { value: string }).value; - const identity = JSON.parse(identityText); - if (identity.version !== 1 || identity.network !== NETWORK || identity.chainId !== CHAIN_ID || identity.mode !== 'sandbox' || identity.policy !== CUSTODY_POLICY) { - throw new Error('Source custody identity mismatch'); - } - snapshot = Object.fromEntries(names.map(name => [name, db.prepare(`SELECT rowid AS import_sequence,* FROM ${name} ORDER BY rowid`).all()])) as typeof snapshot; - sourceDigest = createHash('sha256').update(JSON.stringify([identityText, snapshot])).digest('hex'); - const existing = db.prepare("SELECT value FROM custody_meta WHERE key='postgres_cutover'").get() as { value: string } | undefined; - if (existing && existing.value !== sourceDigest) throw new Error('Frozen SQLite source changed; operator review required'); - db.prepare('INSERT OR IGNORE INTO custody_meta VALUES(?,?)').run('postgres_cutover', sourceDigest); - db.exec('COMMIT'); // Source is now permanently ineligible for SQLite startup. Preserve for audit, NEVER delete/reset. - } catch (e) { db.exec('ROLLBACK'); throw e; } - } finally { db.close(); } - const c = await pool.connect(); - try { - await c.query('BEGIN'); - await c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]); - await verifyCustodySchema(c); - const old = await c.query("SELECT value FROM wk_custody.custody_meta WHERE key='import_source'"); - if (old.rowCount) { - if (old.rows[0].value !== sourceDigest) throw new Error('Target already imported a different source'); - await auditPostgres(c); await c.query('COMMIT'); - return { imported: true, sourceDigest, custodianId: JSON.parse(identityText).id }; - } - for (const table of ['custody_meta', ...names]) { - if ((await c.query(`SELECT 1 FROM wk_custody.${table} LIMIT 1`)).rowCount) throw new Error('Import requires a migrated EMPTY PostgreSQL custody schema'); - } - for (const a of snapshot.accounts) { - if (!/^(0|[1-9]\d{0,29})$/.test(String(a.balance))) throw new Error('Invalid source atomic balance'); - await c.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,$2)', [a.id, a.normal]); - } - // Rebuild balances from journal entries, never trust mutable projections as opening balances. - const byJournal = new Map(); - for (const e of snapshot.entries) { - const rows = byJournal.get(String(e.journal)) ?? []; rows.push(e); byJournal.set(String(e.journal), rows); - } - for (const j of snapshot.journals) { - await c.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)', [j.id, j.kind, j.created]); - const lines = (byJournal.get(String(j.id)) ?? []).sort((a, b) => Number(a.position) - Number(b.position)); - for (const e of lines) { - if (!/^(0|[1-9]\d{0,29})$/.test(String(e.debit)) || !/^(0|[1-9]\d{0,29})$/.test(String(e.credit))) throw new Error('Invalid source entry encoding'); - await c.query('INSERT INTO wk_custody.entries VALUES($1,$2,$3,$4,$5)', [e.journal, e.position, e.account, e.debit, e.credit]); - } - } - const journalIds = new Set(snapshot.journals.map(j => j.id)); - if (snapshot.entries.some(e => !journalIds.has(e.journal))) throw new Error('Orphan source entry'); - for (const a of snapshot.accounts) { - const rebuilt = await c.query('SELECT balance::text AS balance FROM wk_custody.accounts WHERE id=$1', [a.id]); - if (rebuilt.rows[0].balance !== a.balance) throw new Error('Source balance projection mismatch'); - } - for (const l of snapshot.listings) await c.query('INSERT INTO wk_custody.listings(sequence,id,card,status,data) VALUES($1,$2,$3,$4,$5)', [l.import_sequence, l.id, l.card, l.status, l.data]); - for (const o of snapshot.orders) await c.query('INSERT INTO wk_custody.orders(sequence,id,buyer,seller,status,data) VALUES($1,$2,$3,$4,$5,$6)', [o.import_sequence, o.id, o.buyer, o.seller, o.status, o.data]); - for (const w of snapshot.withdrawals) await c.query('INSERT INTO wk_custody.withdrawals(sequence,id,actor,status,data) VALUES($1,$2,$3,$4,$5)', [w.import_sequence, w.id, w.actor, w.status, w.data]); - for (const r of snapshot.receipts) await c.query('INSERT INTO wk_custody.receipts VALUES($1,$2,$3)', [r.id, r.digest, r.data]); - for (const o of snapshot.outbox) await c.query('INSERT INTO wk_custody.outbox VALUES($1,$2,$3,$4)', [o.id, o.kind, o.status, o.data]); - for (const a of snapshot.credits) await c.query('INSERT INTO wk_custody.credits VALUES($1)', [a.actor]); - for (const table of ['listings', 'orders', 'withdrawals']) await c.query(`SELECT setval(pg_get_serial_sequence('wk_custody.${table}','sequence'),COALESCE((SELECT max(sequence) FROM wk_custody.${table}),0)+1,false)`); - await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2),($3,$4)', ['identity', identityText, 'import_source', sourceDigest]); - await auditPostgres(c); - await c.query('COMMIT'); - return { imported: true, sourceDigest, custodianId: JSON.parse(identityText).id }; - } catch (e) { await c.query('ROLLBACK').catch(() => {}); throw e; } - finally { c.release(); } -} diff --git a/server/market/custody-incident.ts b/server/market/custody-incident.ts deleted file mode 100755 index fc5dbe3..0000000 --- a/server/market/custody-incident.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { randomUUID, createHash } from 'node:crypto'; -import type { Pool } from 'pg'; -import { z } from 'zod'; -import { CUSTODY_WRITE_LOCK } from './postgres-schema.ts'; -import { auditPostgres } from './postgres-custody.ts'; - -/** Operator-only acknowledgement AFTER externally reviewed repair/restore; never repairs or resets a journal. */ -export async function resolveCustodyIncident(pool: Pool, evidenceId: string): Promise<{ resolutionId: string }> { - z.string().uuid().parse(evidenceId); - const c = await pool.connect(); - try { - await c.query('BEGIN'); - await c.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 c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]); - const allowed = await c.query("SELECT pg_has_role(current_user,nspowner,'MEMBER') AS owns FROM pg_namespace WHERE nspname='wk_custody'"); - if (allowed.rows[0]?.owns !== true) throw new Error('Schema-owner approval required for integrity resolution'); - const incident = await c.query("SELECT value FROM wk_custody.custody_meta WHERE key='integrity_incident_v1' FOR UPDATE"); - if (!incident.rowCount) throw new Error('No unresolved custody integrity incident'); - // Delete is UNCOMMITTED and exclusively locked. Any audit failure rolls it back, leaving the hold intact. - await c.query("DELETE FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'"); - await auditPostgres(c); - const resolutionId = randomUUID(); - await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2)', [`integrity_resolution:${resolutionId}`, - JSON.stringify({ version: 1, evidenceId, resolvedAt: Date.now(), - incidentDigest: createHash('sha256').update(incident.rows[0].value).digest('hex') })]); - await c.query('COMMIT'); return { resolutionId }; - } catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; } - finally { c.release(); } -} diff --git a/server/market/custody-integrity.ts b/server/market/custody-integrity.ts deleted file mode 100755 index 7424b26..0000000 --- a/server/market/custody-integrity.ts +++ /dev/null @@ -1,480 +0,0 @@ -import { z } from 'zod'; -import { createHash } from 'node:crypto'; -import type { PoolClient, QueryResult } from 'pg'; -import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts'; -import { parseAtomicXmr } from '../../shared/xmr-amount.ts'; -import { allocateXmrSale } from '../../shared/xmr-pricing.ts'; -import { validateXmrAddress } from './xmr.ts'; -import { replayReservedSale } from './sale-rehearsal.ts'; -import { address, verifySignature } from '../testnet/consensus.ts'; -import { depositCreditApprovalMessage, depositCreditReleaseApprovalMessage, - depositReorgResolutionMessage, type DepositCreditReleaseSubject, type DepositCreditReviewSubject, - type DepositReorgResolutionSubject } from './monero-deposit-credit.ts'; -import { mainnetWithdrawalCancellationMessage, mainnetWithdrawalMessage } from '../../shared/monero-withdrawal.ts'; - -/** Codes only: never attach customer IDs, payloads, SQL errors or addresses to operational diagnostics. */ -export class CustodyIntegrityError extends Error { - readonly code: string; - constructor(code: string) { super(`Custody integrity check failed: ${code}`); this.code = code; } -} -function fail(code: string): never { throw new CustodyIntegrityError(code); } -const uuid = z.string().uuid(), hex = z.string().regex(/^[a-f0-9]{64}$/); -const integer = z.number().int().nonnegative().safe(); -const atomic = z.string().max(22).refine(s => { try { parseAtomicXmr(s); return true; } catch { return false; } }); -const positive = atomic.refine(s => { try { return parseAtomicXmr(s) > 0n; } catch { return false; } }); -const wallet = z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{95}$/); -const listingSchema = z.object({ id: uuid, seller: hex, cardHash: hex, sequence: integer, - title: z.string().min(1).max(200), priceAtomicXmr: positive, feeBps: z.number().int().min(0).max(5000), - hostFeeAddress: wallet.optional(), status: z.enum(['open', 'reserved', 'cancelled', 'sold']), createdAt: integer }).strict(); -const orderSchema = z.object({ id: uuid, listingId: uuid, seller: hex, buyer: hex, cardHash: hex, sequence: integer, - grossAtomicXmr: positive, sellerAtomicXmr: atomic, authorAtomicXmr: atomic, hostAtomicXmr: atomic, - authorAddress: wallet, hostFeeAddress: wallet.optional(), status: z.enum(['awaiting-lock', 'cancelled', 'settled']), createdAt: integer }).strict(); -const withdrawalSchema = z.object({ id: uuid, actor: hex, destination: z.literal('mock-external'), amountAtomicXmr: positive, - maxFeeAtomicXmr: atomic, status: z.enum(['reserved', 'prepared', 'complete', 'cancelled']), - attemptId: uuid.optional(), transactionId: z.string().regex(/^fixture:[a-f0-9]{64}$/).optional(), - feeAtomicXmr: atomic.optional(), createdAt: integer }).strict(); -type Entry = { account: string; debit: string; credit: string }; -type Journal = { id: string; kind: string; lines: Entry[] }; -function parse(schema: z.ZodType, data: unknown, code: string): T { - const result = schema.safeParse(data); return result.success ? result.data : fail(code); -} -function same(a: unknown, b: unknown): boolean { - // Compare parsed records without depending on JSONB object key ordering. - if (a === b) return true; - if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false; - const x = a as Record, y = b as Record; - const keys = Object.keys(x).filter(k => x[k] !== undefined).sort(); - return keys.join('\0') === Object.keys(y).filter(k => y[k] !== undefined).sort().join('\0') && keys.every(k => same(x[k], y[k])); -} -function json(text: string | null): unknown { try { return JSON.parse(text ?? ''); } catch { return fail('outbox-encoding'); } } -const d = (account: string, amount: string): Entry => ({ account, debit: amount, credit: '0' }); -const c = (account: string, amount: string): Entry => ({ account, debit: '0', credit: amount }); -async function journals(client: PoolClient, ids: string[]): Promise> { - const result = await client.query(`SELECT j.id,j.kind,jsonb_agg(jsonb_build_object('account',e.account, - 'debit',e.debit::text,'credit',e.credit::text) ORDER BY e.position) FILTER(WHERE e.journal IS NOT NULL) AS lines - FROM wk_custody.journals j LEFT JOIN wk_custody.entries e ON e.journal=j.id - WHERE j.id=ANY($1::text[]) GROUP BY j.id`, [ids]); - return new Map(result.rows.map(r => [r.id, r as Journal])); -} -function expectJournal(all: Map, id: string, kind: string, expected: Entry[] | null) { - const got = all.get(id); - if (expected === null) { if (got) fail('unexpected-terminal-posting'); return; } - if (!got || got.kind !== kind || !Array.isArray(got.lines)) fail('missing-lifecycle-posting'); - const normalized = (lines: Entry[]) => lines.filter(l => BigInt(l.debit) !== 0n || BigInt(l.credit) !== 0n) - .map(l => JSON.stringify([l.account, l.debit, l.credit])).sort(); - if (!same(normalized(got.lines), normalized(expected))) fail('lifecycle-posting-mismatch'); -} -async function validateWallets(values: (string | undefined)[]) { - // Bounded per-batch cache, not a growing process-wide map of customer data. - for (const address of new Set(values.filter((v): v is string => !!v))) { - try { await validateXmrAddress(address); } catch { fail('fee-recipient-invalid'); } - } -} - -type OrderRow = { id: string; buyer: string; seller: string; status: string; data: unknown; - listing_data: unknown; listing_status: string; listing_card: string; balance: string | null; - normal: string | null; box_kind: string | null; box_status: string | null; box_data: string | null }; -async function checkOrders(client: PoolClient, rows: OrderRow[]) { - const posting = await journals(client, rows.flatMap(r => [`reserve:${r.id}`, `cancel:${r.id}`, `settle:${r.id}`])); - const wallets: string[] = []; - for (const row of rows) { - const o = parse(orderSchema, row.data, 'order-record-invalid'), l = parse(listingSchema, row.listing_data, 'order-listing-invalid'); - if (o.id !== row.id || o.buyer !== row.buyer || o.seller !== row.seller || o.status !== row.status || - l.status !== row.listing_status || l.cardHash !== row.listing_card || o.listingId !== l.id || o.seller !== l.seller || - o.cardHash !== l.cardHash || o.sequence !== l.sequence || o.grossAtomicXmr !== l.priceAtomicXmr || o.seller === o.buyer || - (o.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) !== (l.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address)) fail('order-terms-mismatch'); - const split = allocateXmrSale(BigInt(l.priceAtomicXmr), l.feeBps); - if (o.sellerAtomicXmr !== split.sellerBeforeNetworkCosts.toString() || o.hostAtomicXmr !== split.hosting.toString() || - o.authorAtomicXmr !== split.author.toString() || o.authorAddress !== ADDITIONAL_MARKET_FEE.address) fail('order-allocation-mismatch'); - wallets.push(o.authorAddress, o.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address); - const account = `customer:${o.buyer}:purchase:${o.id}`, available = `customer:${o.buyer}:available`; - if (row.normal !== 'credit' || row.balance !== (o.status === 'awaiting-lock' ? o.grossAtomicXmr : '0')) fail('order-reservation-mismatch'); - const expectedStatus = o.status === 'awaiting-lock' ? 'reserved' : o.status === 'settled' ? 'sold' : 'cancelled'; - if (l.status !== expectedStatus || row.box_kind !== 'nft-lock') fail('order-state-mismatch'); - if (o.status === 'settled') { - if (row.box_status !== 'fixture-complete' || !/^fixture:[a-f0-9]{64}$/.test(row.box_data ?? '')) fail('settlement-evidence-mismatch'); - } else { - const original = parse(orderSchema, json(row.box_data), 'order-outbox-invalid'); - if (row.box_status !== (o.status === 'cancelled' ? 'cancelled' : 'blocked') || - !same(original, { ...o, status: 'awaiting-lock' })) fail('order-outbox-mismatch'); - } - expectJournal(posting, `reserve:${o.id}`, 'purchase-reservation', [d(available, o.grossAtomicXmr), c(account, o.grossAtomicXmr)]); - expectJournal(posting, `cancel:${o.id}`, 'purchase-cancellation', o.status === 'cancelled' ? [d(account, o.grossAtomicXmr), c(available, o.grossAtomicXmr)] : null); - expectJournal(posting, `settle:${o.id}`, 'mock-sale-settlement', o.status === 'settled' ? [d(account, o.grossAtomicXmr), - c(`customer:${o.seller}:available`, o.sellerAtomicXmr), c(`author:${o.authorAddress}`, o.authorAtomicXmr), - c(o.hostFeeAddress ? `revenue:host:${o.hostFeeAddress}` : 'revenue:host', o.hostAtomicXmr)] : null); - const evidenceRows = await client.query('SELECT key,value FROM wk_custody.custody_meta WHERE key=ANY($1::text[])', - [[`sale-rehearsal-binding:${o.id}`, `sale-rehearsal-observation:${o.id}`, `sale-rehearsal-terminal:${o.id}`, - `sale-rehearsal-terminal-index:${o.id}`]]); - if (evidenceRows.rowCount) { - try { - const records = new Map(evidenceRows.rows.map(r => [r.key, r.value])); - const binding = records.get(`sale-rehearsal-binding:${o.id}`); - const identity = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'"); - const verify = (key: string) => { - const evidence = JSON.parse(records.get(key) ?? ''); - const replay = replayReservedSale({ ...o, status: 'awaiting-lock' }, JSON.parse(identity.rows[0].value).id, evidence.input, l.feeBps); - if (evidence.version !== 1 || evidence.binding !== binding || replay.binding !== binding || - replay.stateRoot !== evidence.stateRoot) fail('sale-rehearsal-evidence-mismatch'); - return replay; - }; - verify(`sale-rehearsal-observation:${o.id}`); - if (o.status === 'awaiting-lock') { - if (records.has(`sale-rehearsal-terminal:${o.id}`) || records.has(`sale-rehearsal-terminal-index:${o.id}`)) { - fail('sale-rehearsal-evidence-mismatch'); - } - } else { - const replay = verify(`sale-rehearsal-terminal:${o.id}`); - if (replay.decision.action !== (o.status === 'settled' ? 'settle' : 'refund') || - (o.status === 'settled' && row.box_data !== `fixture:${replay.stateRoot}`)) fail('sale-rehearsal-evidence-mismatch'); - const index = JSON.parse(records.get(`sale-rehearsal-terminal-index:${o.id}`) ?? ''); - if (index.version !== 1 || index.stateRoot !== replay.stateRoot || - index.terminalTransactionId !== (replay.terminalTransactionId ?? null)) fail('sale-rehearsal-evidence-mismatch'); - if (replay.terminalTransactionId) { - const link = await client.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1', - [`sale-rehearsal-tx:${replay.terminalTransactionId}`]); - const parsedLink = JSON.parse(link.rows[0]?.value ?? ''); - if (parsedLink.version !== 1 || parsedLink.orderId !== o.id || parsedLink.stateRoot !== replay.stateRoot) { - fail('sale-rehearsal-terminal-index-mismatch'); - } - } - } - } catch { fail('sale-rehearsal-evidence-mismatch'); } - } - } - await validateWallets(wallets); -} -const orderSelect = `SELECT o.id::text,o.buyer,o.seller,o.status,o.data,l.data AS listing_data,l.status AS listing_status, - l.card AS listing_card,a.balance::text,a.normal,b.kind AS box_kind,b.status AS box_status,b.data AS box_data - FROM wk_custody.orders o LEFT JOIN wk_custody.listings l ON l.id::text=o.data->>'listingId' - LEFT JOIN wk_custody.accounts a ON a.id='customer:'||o.buyer||':purchase:'||o.id::text - LEFT JOIN wk_custody.outbox b ON b.id=o.id`; - -type WithdrawalRow = { id: string; actor: string; status: string; data: unknown; balance: string | null; - normal: string | null; box_kind: string | null; box_status: string | null; box_data: string | null }; -async function checkWithdrawals(client: PoolClient, rows: WithdrawalRow[]) { - const posting = await journals(client, rows.flatMap(r => [`withdraw:${r.id}`, `withdraw-cancel:${r.id}`, `withdraw-confirm:${r.id}`])); - for (const row of rows) { - const w = parse(withdrawalSchema, row.data, 'withdrawal-record-invalid'); - if (w.id !== row.id || w.actor !== row.actor || w.status !== row.status) fail('withdrawal-identity-mismatch'); - const total = (BigInt(w.amountAtomicXmr) + BigInt(w.maxFeeAtomicXmr)).toString(); - const active = w.status === 'reserved' || w.status === 'prepared'; - if (row.normal !== 'credit' || row.balance !== (active ? total : '0')) fail('withdrawal-reservation-mismatch'); - const prepared = w.status === 'prepared' || w.status === 'complete'; - if (prepared ? !w.attemptId || !w.transactionId || w.feeAtomicXmr === undefined || BigInt(w.feeAtomicXmr) > BigInt(w.maxFeeAtomicXmr) - : w.attemptId !== undefined || w.transactionId !== undefined || w.feeAtomicXmr !== undefined) fail('withdrawal-attempt-invalid'); - const status = w.status === 'reserved' ? 'blocked' : w.status === 'cancelled' ? 'cancelled' - : w.status === 'prepared' ? 'fixture-prepared' : 'fixture-complete'; - const box = parse(withdrawalSchema, json(row.box_data), 'withdrawal-outbox-invalid'); - if (row.box_kind !== 'withdrawal' || row.box_status !== status || !same(box, { ...w, status: prepared ? 'prepared' : 'reserved' })) fail('withdrawal-outbox-mismatch'); - const account = `customer:${w.actor}:withdrawal:${w.id}`, available = `customer:${w.actor}:available`; - expectJournal(posting, `withdraw:${w.id}`, 'withdrawal-reservation', [d(available, total), c(account, total)]); - expectJournal(posting, `withdraw-cancel:${w.id}`, 'withdrawal-cancellation', w.status === 'cancelled' ? [d(account, total), c(available, total)] : null); - expectJournal(posting, `withdraw-confirm:${w.id}`, 'mock-withdrawal', w.status === 'complete' ? [d(account, total), - c('treasury:mock', (BigInt(w.amountAtomicXmr) + BigInt(w.feeAtomicXmr!)).toString()), - c(available, (BigInt(w.maxFeeAtomicXmr) - BigInt(w.feeAtomicXmr!)).toString())] : null); - } -} - -type DepositCreditRow = { credit_id: string; wallet_fingerprint: string; observation_id: string; allocation_id: string; - beneficiary: string; amount_atomic_xmr: string; journal_id: string; last_observation_event_sequence: string; - observation_digest: string; reviewer_owner: string; reviewer_public_key: string; review_nonce: string; - review_expires_at: string; review_signature: string; review_subject: DepositCreditReviewSubject; credited_at: string; - review_digest: string; minimum_confirmations: number; balance: string | null; normal: string | null; - release_id: string | null; release_journal_id: string | null; release_reviewer_owner: string | null; - release_reviewer_public_key: string | null; release_review_nonce: string | null; release_review_expires_at: string | null; - release_review_signature: string | null; release_review_subject: DepositCreditReleaseSubject | null; - release_review_digest: string | null; released_at: string | null }; -async function checkDepositCredits(client: PoolClient, budget: () => void) { - let after: string | null = null; - for (;;) { - budget(); - const result: QueryResult = await client.query(`SELECT c.credit_id::text,c.wallet_fingerprint,c.observation_id, - c.allocation_id::text,c.beneficiary,c.amount_atomic_xmr::text,c.journal_id,c.last_observation_event_sequence::text, - c.observation_digest,c.reviewer_owner,c.reviewer_public_key,c.review_nonce,c.review_expires_at::text, - c.review_signature,c.review_subject,c.review_digest,c.credited_at::text,c.minimum_confirmations, - a.balance::text,a.normal,r.release_id::text,r.journal_id AS release_journal_id,r.reviewer_owner AS release_reviewer_owner, - r.reviewer_public_key AS release_reviewer_public_key,r.review_nonce::text AS release_review_nonce, - r.review_expires_at::text AS release_review_expires_at,r.review_signature AS release_review_signature, - r.review_subject AS release_review_subject,r.review_digest AS release_review_digest,r.released_at::text - FROM wk_custody.monero_deposit_credit_events c - LEFT JOIN wk_custody.accounts a ON a.id='customer:'||c.beneficiary||':deposit-pending:'||c.credit_id::text - LEFT JOIN wk_custody.monero_deposit_release_events r ON r.credit_id=c.credit_id - WHERE ($1::uuid IS NULL OR c.credit_id>$1::uuid) ORDER BY c.credit_id LIMIT 128`, [after]); - const postings = await journals(client, result.rows.flatMap(row => [row.journal_id, ...(row.release_journal_id ? [row.release_journal_id] : [])])); - for (const row of result.rows) { - let approvalIsValid = false; - let subjectIsValid = false; - try { - const message = depositCreditApprovalMessage(row.review_subject, row.reviewer_owner, - row.review_nonce, Number(row.review_expires_at)); - approvalIsValid = address(row.reviewer_public_key) === row.reviewer_owner && - createHash('sha256').update(message).digest('hex') === row.review_digest && - Number(row.review_expires_at) >= Number(row.credited_at) && - verifySignature(row.reviewer_public_key, row.reviewer_owner, message, row.review_signature); - subjectIsValid = row.observation_id === `${row.review_subject.observationId.split(':')[0]}:${row.review_subject.outputPublicKey}` && - row.review_subject.walletFingerprint === row.wallet_fingerprint && - row.review_subject.observationDigest === row.observation_digest && - row.review_subject.observationId === row.observation_id && - row.review_subject.amountAtomicXmr === row.amount_atomic_xmr && row.review_subject.beneficiary === row.beneficiary && - row.review_subject.allocationId === row.allocation_id && - row.review_subject.observationEventSequence === Number(row.last_observation_event_sequence) && - row.review_subject.minimumConfirmations === row.minimum_confirmations; - } catch { approvalIsValid = false; } - if (!subjectIsValid || !approvalIsValid) fail('deposit-credit-observation-mismatch'); - const released = row.release_id !== null; - if (row.normal !== 'credit' || row.balance !== (released ? '0' : row.amount_atomic_xmr)) fail('deposit-pending-liability-mismatch'); - expectJournal(postings, row.journal_id, 'monero-deposit-pending-credit', [ - d('treasury:monero:pending', row.amount_atomic_xmr), - c(`customer:${row.beneficiary}:deposit-pending:${row.credit_id}`, row.amount_atomic_xmr), - ]); - if (released) { - let releaseValid = false; - try { - const releaseMessage = depositCreditReleaseApprovalMessage(row.release_review_subject!, row.release_reviewer_owner!, - row.release_review_nonce!, Number(row.release_review_expires_at)); - const releaseSubject = row.release_review_subject!; - releaseValid = address(row.release_reviewer_public_key!) === row.release_reviewer_owner && - createHash('sha256').update(releaseMessage).digest('hex') === row.release_review_digest && - Number(row.release_review_expires_at) >= Number(row.released_at) && - verifySignature(row.release_reviewer_public_key!, row.release_reviewer_owner!, releaseMessage, row.release_review_signature!) && - releaseSubject.creditId === row.credit_id && releaseSubject.walletFingerprint === row.wallet_fingerprint && - releaseSubject.observationId === row.observation_id && releaseSubject.beneficiary === row.beneficiary && - releaseSubject.amountAtomicXmr === row.amount_atomic_xmr && releaseSubject.allocationId === row.allocation_id; - } catch { releaseValid = false; } - if (!releaseValid) fail('deposit-release-review-invalid'); - expectJournal(postings, row.release_journal_id!, 'monero-deposit-release', [ - d(`customer:${row.beneficiary}:deposit-pending:${row.credit_id}`, row.amount_atomic_xmr), - c(`customer:${row.beneficiary}:available`, row.amount_atomic_xmr), - ]); - const resolutions = await client.query(`SELECT r.resolution_id::text,r.hold_id::text,r.release_id::text,r.resolved_by,r.resolved_at::text, - r.disposition,r.resolution_kind,r.reviewer_public_key,r.review_nonce::text,r.review_expires_at::text, - r.review_signature,r.review_subject,r.review_digest,h.observation_event_sequence::text AS hold_event_sequence, - h.event_type AS hold_event_type - FROM wk_custody.monero_deposit_reorg_resolutions r - JOIN wk_custody.monero_deposit_reorg_holds h ON h.hold_id=r.hold_id WHERE r.credit_id=$1`, [row.credit_id]); - const approvedHoldIds = [...(row.release_review_subject?.unresolvedHolds ?? [])].map(hold => hold.holdId).sort(); - const releaseResolutions = resolutions.rows.filter(item => item.resolution_kind === 'release-review'); - const releaseHolds = await client.query(`SELECT h.hold_id::text,h.observation_event_sequence::text,h.event_type - FROM wk_custody.monero_deposit_reorg_holds h JOIN wk_custody.monero_deposit_reorg_resolutions r - ON r.hold_id=h.hold_id AND r.resolution_kind='release-review' - WHERE h.credit_id=$1 AND r.release_id=$2 ORDER BY h.observation_event_sequence`, [row.credit_id, row.release_id]); - const signedHoldFacts = [...(row.release_review_subject?.unresolvedHolds ?? [])] - .map(hold => JSON.stringify([hold.holdId, hold.eventSequence, hold.eventType])).sort(); - const persistedHoldFacts = releaseHolds.rows.map(hold => JSON.stringify([ - hold.hold_id, Number(hold.observation_event_sequence), hold.event_type])).sort(); - if (releaseResolutions.some(item => item.release_id !== row.release_id || item.resolved_by !== row.release_reviewer_owner || - item.reviewer_public_key !== row.release_reviewer_public_key || item.review_nonce !== row.release_review_nonce || - item.review_expires_at !== row.release_review_expires_at || item.review_signature !== row.release_review_signature || - item.review_digest !== row.release_review_digest || item.disposition !== 'revalidated-for-release') || - releaseResolutions.map(item => item.hold_id).sort().join(',') !== approvedHoldIds.join(',') || - releaseHolds.rows.length !== signedHoldFacts.length || signedHoldFacts.join(',') !== persistedHoldFacts.join(',')) { - fail('deposit-reorg-resolution-invalid'); - } - for (const resolution of resolutions.rows.filter(item => item.resolution_kind === 'post-release-review')) { - let resolutionValid = false; - try { - const subject = resolution.review_subject as DepositReorgResolutionSubject; - const resolutionMessage = depositReorgResolutionMessage(subject, resolution.resolved_by, - resolution.review_nonce, Number(resolution.review_expires_at)); - resolutionValid = resolution.release_id === row.release_id && resolution.disposition === 'revalidated-for-release' && - subject.creditId === row.credit_id && subject.releaseId === row.release_id && subject.holdId === resolution.hold_id && - subject.eventSequence === Number(resolution.hold_event_sequence) && subject.eventType === resolution.hold_event_type && - subject.walletFingerprint === row.wallet_fingerprint && subject.observationId === row.observation_id && - subject.currentObservation.beneficiary === row.beneficiary && - subject.currentObservation.amountAtomicXmr === row.amount_atomic_xmr && - address(resolution.reviewer_public_key) === resolution.resolved_by && - createHash('sha256').update(resolutionMessage).digest('hex') === resolution.review_digest && - Number(resolution.review_expires_at) >= Number(resolution.resolved_at) && - verifySignature(resolution.reviewer_public_key, resolution.resolved_by, resolutionMessage, resolution.review_signature); - } catch { resolutionValid = false; } - if (!resolutionValid) fail('deposit-reorg-resolution-invalid'); - } - if (resolutions.rows.some(item => !['release-review','post-release-review'].includes(item.resolution_kind))) { - fail('deposit-reorg-resolution-invalid'); - } - } - } - if (result.rows.length < 128) break; - after = result.rows.at(-1)!.credit_id; - } - const orphanAccount = await client.query(`SELECT 1 FROM wk_custody.accounts a - WHERE a.id LIKE 'customer:%:deposit-pending:%' AND NOT EXISTS ( - SELECT 1 FROM wk_custody.monero_deposit_credit_events c - WHERE a.id='customer:'||c.beneficiary||':deposit-pending:'||c.credit_id::text) LIMIT 1`); - if (orphanAccount.rowCount) fail('orphan-deposit-pending-liability'); - const orphanJournal = await client.query(`SELECT 1 FROM wk_custody.journals j WHERE j.kind='monero-deposit-pending-credit' - AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_deposit_credit_events c WHERE c.journal_id=j.id) LIMIT 1`); - if (orphanJournal.rowCount) fail('orphan-deposit-credit-journal'); - const orphanRelease = await client.query(`SELECT 1 FROM wk_custody.journals j WHERE j.kind='monero-deposit-release' - AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_deposit_release_events r WHERE r.journal_id=j.id) LIMIT 1`); - if (orphanRelease.rowCount) fail('orphan-deposit-release-journal'); - const orphanResolution = await client.query(`SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions r - LEFT JOIN wk_custody.monero_deposit_reorg_holds h ON h.hold_id=r.hold_id - LEFT JOIN wk_custody.monero_deposit_release_events e ON e.release_id=r.release_id - WHERE h.hold_id IS NULL OR e.release_id IS NULL OR h.credit_id<>r.credit_id OR e.credit_id<>r.credit_id LIMIT 1`); - if (orphanResolution.rowCount) fail('orphan-deposit-reorg-resolution'); - const invalidHold = await client.query(`SELECT 1 FROM wk_custody.monero_deposit_reorg_holds h - JOIN wk_custody.monero_deposit_credit_events c ON c.credit_id=h.credit_id - WHERE h.wallet_fingerprint<>c.wallet_fingerprint OR h.observation_id<>c.observation_id OR - h.observation_event_sequence<=c.last_observation_event_sequence LIMIT 1`); - if (invalidHold.rowCount) fail('deposit-reorg-hold-mismatch'); -} - -async function checkMainnetWithdrawalIntents(client: PoolClient, budget: () => void): Promise { - type WithdrawalIntentAuditRow = { request_id: string; custodian_id: string; market_network: string; market_chain_id: string; - beneficiary: string; destination: string; amount_atomic_xmr: string; max_fee_atomic_xmr: string; public_key: string; - expires_at: string; request_signature: string; request_digest: string; status: string; created_at: string; - cancel_request_id: string | null; cancel_public_key: string | null; cancel_expires_at: string | null; - cancel_signature: string | null; cancelled_at: string | null }; - let after: string | null = null; - const identityResult = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'"); - if (!identityResult.rowCount) { - if ((await client.query('SELECT 1 FROM wk_custody.monero_withdrawal_intents LIMIT 1')).rowCount) fail('mainnet-withdrawal-identity-missing'); - return; - } - const identity = JSON.parse(identityResult.rows[0].value) as Record; - if (identity.mode !== 'mainnet-custody' || identity.network !== 'wikideck-randomx-mainnet-1' || identity.policy !== 'custody-mainnet-v1') { - if ((await client.query('SELECT 1 FROM wk_custody.monero_withdrawal_intents LIMIT 1')).rowCount) fail('mainnet-withdrawal-identity-mismatch'); - return; - } - for (;;) { - budget(); - const result: QueryResult = await client.query(`SELECT request_id::text,custodian_id::text,market_network,market_chain_id,beneficiary, - destination,amount_atomic_xmr::text,max_fee_atomic_xmr::text,public_key,expires_at::text,request_signature, - request_digest,status,created_at::text,cancel_request_id::text,cancel_public_key,cancel_expires_at::text, - cancel_signature,cancelled_at::text FROM wk_custody.monero_withdrawal_intents - WHERE ($1::uuid IS NULL OR request_id>$1::uuid) ORDER BY request_id LIMIT 128`, [after]); - const postings = await journals(client, result.rows.flatMap(row => [ - `monero-withdrawal-reserve:${row.request_id}`, `monero-withdrawal-cancel:${row.request_id}`])); - for (const row of result.rows) { - let destination: string; - try { destination = await validateXmrAddress(row.destination); } - catch { fail('mainnet-withdrawal-destination-invalid'); } - const total = (BigInt(row.amount_atomic_xmr) + BigInt(row.max_fee_atomic_xmr)).toString(); - const aggregate = await client.query(`SELECT - COALESCE((SELECT sum(amount_atomic_xmr+max_fee_atomic_xmr) FROM wk_custody.monero_withdrawal_intents - WHERE beneficiary=$1 AND status='reserved'),0)::text AS reserved, - COALESCE((SELECT sum(c.amount_atomic_xmr) FROM wk_custody.monero_deposit_credit_events c - JOIN wk_custody.monero_deposit_release_events r ON r.credit_id=c.credit_id WHERE c.beneficiary=$1),0)::text AS credited`, - [row.beneficiary]); - if (BigInt(aggregate.rows[0].reserved) > BigInt(aggregate.rows[0].credited)) { - fail('mainnet-withdrawal-exceeds-reviewed-credit'); - } - const holdAccount = `customer:${row.beneficiary}:monero-withdrawal:${row.request_id}`; - const availableAccount = `customer:${row.beneficiary}:available`; - const hold = await client.query('SELECT balance::text,normal FROM wk_custody.accounts WHERE id=$1', [holdAccount]); - const active = row.status === 'reserved'; - if (!['reserved','cancelled'].includes(row.status) || (active && (hold.rowCount !== 1 || hold.rows[0].normal !== 'credit' || hold.rows[0].balance !== total)) || - (!active && hold.rowCount === 1 && (hold.rows[0].normal !== 'credit' || hold.rows[0].balance !== '0'))) fail('mainnet-withdrawal-reservation-mismatch'); - try { - const request = { version: 1 as const, network: 'wikideck-randomx-mainnet-1' as const, - chainId: row.market_chain_id, custodianId: row.custodian_id, beneficiary: row.beneficiary, - publicKey: row.public_key, requestId: row.request_id, destination, amountAtomicXmr: row.amount_atomic_xmr, - maxFeeAtomicXmr: row.max_fee_atomic_xmr, expiresAt: Number(row.expires_at), signature: row.request_signature }; - const { signature: _signature, ...unsigned } = request; - const message = mainnetWithdrawalMessage(unsigned); - if (row.market_network !== 'wikideck-randomx-mainnet-1' || row.custodian_id !== identity.id || - address(row.public_key) !== row.beneficiary || createHash('sha256').update(message).digest('hex') !== row.request_digest || - !verifySignature(row.public_key, row.beneficiary, message, row.request_signature)) fail('mainnet-withdrawal-authorization-invalid'); - } catch { fail('mainnet-withdrawal-authorization-invalid'); } - expectJournal(postings, `monero-withdrawal-reserve:${row.request_id}`, 'monero-withdrawal-reservation', [ - d(availableAccount, total), c(holdAccount, total)]); - if (active) { - if (row.cancel_request_id || row.cancel_signature || row.cancelled_at) fail('mainnet-withdrawal-cancellation-invalid'); - expectJournal(postings, `monero-withdrawal-cancel:${row.request_id}`, 'monero-withdrawal-cancellation', null); - } else { - try { - const cancellation = { version: 1 as const, network: 'wikideck-randomx-mainnet-1' as const, - chainId: row.market_chain_id, custodianId: row.custodian_id, beneficiary: row.beneficiary, - publicKey: row.cancel_public_key!, cancellationId: row.cancel_request_id!, - withdrawalRequestId: row.request_id, expiresAt: Number(row.cancel_expires_at), signature: row.cancel_signature! }; - const { signature: cancelSignature, ...unsigned } = cancellation; - const message = mainnetWithdrawalCancellationMessage(unsigned); - if (address(cancellation.publicKey) !== row.beneficiary || - !verifySignature(cancellation.publicKey, row.beneficiary, message, cancelSignature)) fail('mainnet-withdrawal-cancellation-invalid'); - } catch { fail('mainnet-withdrawal-cancellation-invalid'); } - expectJournal(postings, `monero-withdrawal-cancel:${row.request_id}`, 'monero-withdrawal-cancellation', [ - d(holdAccount, total), c(availableAccount, total)]); - } - } - if (result.rows.length < 128) break; - after = result.rows.at(-1)!.request_id; - } -} - -const withdrawalSelect = `SELECT w.id::text,w.actor,w.status,w.data,a.balance::text,a.normal, - b.kind AS box_kind,b.status AS box_status,b.data AS box_data FROM wk_custody.withdrawals w - LEFT JOIN wk_custody.accounts a ON a.id='customer:'||w.actor||':withdrawal:'||w.id::text - LEFT JOIN wk_custody.outbox b ON b.id=w.id`; - -/** Targeted mutation checks share the same transaction as postings/receipts. No external side effects. */ -export async function auditOrder(client: PoolClient, id: string) { - const result = await client.query(`${orderSelect} WHERE o.id=$1`, [id]); - if (result.rowCount !== 1) fail('order-record-missing'); await checkOrders(client, result.rows); -} -export async function auditWithdrawal(client: PoolClient, id: string) { - const result = await client.query(`${withdrawalSelect} WHERE w.id=$1`, [id]); - if (result.rowCount !== 1) fail('withdrawal-record-missing'); await checkWithdrawals(client, result.rows); -} -/** Caller supplies a coherent transaction. Keyset pages bound application memory, not total audit duration. */ -export async function auditBusinessLiabilities(client: PoolClient): Promise { - const deadline = performance.now() + 15000; - const budget = () => { if (performance.now() > deadline) throw new Error('Custody business audit budget exceeded'); }; - let after: string | null = null; - for (;;) { - budget(); - const result: QueryResult = await client.query(`${orderSelect} WHERE ($1::uuid IS NULL OR o.id>$1::uuid) ORDER BY o.id LIMIT 128`, [after]); - await checkOrders(client, result.rows); if (result.rows.length < 128) break; after = result.rows.at(-1)!.id; - } - after = null; - for (;;) { - budget(); - const result: QueryResult = await client.query(`${withdrawalSelect} WHERE ($1::uuid IS NULL OR w.id>$1::uuid) ORDER BY w.id LIMIT 128`, [after]); - await checkWithdrawals(client, result.rows); if (result.rows.length < 128) break; after = result.rows.at(-1)!.id; - } - after = null; - for (;;) { - budget(); - const result: QueryResult<{ id: string; card: string; status: string; data: unknown }> = await client.query('SELECT id::text,card,status,data FROM wk_custody.listings WHERE ($1::uuid IS NULL OR id>$1::uuid) ORDER BY id LIMIT 128', [after]); - const wallets: string[] = []; - for (const row of result.rows) { - const l = parse(listingSchema, row.data, 'listing-record-invalid'); - if (row.id !== l.id || row.card !== l.cardHash || row.status !== l.status) fail('listing-identity-mismatch'); - wallets.push(l.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address); - } - await validateWallets(wallets); - if (result.rows.length < 128) break; after = result.rows.at(-1)!.id; - } - await checkDepositCredits(client, budget); - await checkMainnetWithdrawalIntents(client, budget); - // Check both directions: a correct order cannot excuse orphan reserves, tasks or terminal postings. - const checks: [string, string][] = [ - ['orphan-purchase-reserve', `SELECT 1 FROM wk_custody.accounts a WHERE a.id LIKE 'customer:%:purchase:%' - AND NOT EXISTS(SELECT 1 FROM wk_custody.orders o WHERE a.id='customer:'||o.buyer||':purchase:'||o.id::text) LIMIT 1`], - ['orphan-withdrawal-reserve', `SELECT 1 FROM wk_custody.accounts a WHERE a.id LIKE 'customer:%:withdrawal:%' - AND NOT EXISTS(SELECT 1 FROM wk_custody.withdrawals w WHERE a.id='customer:'||w.actor||':withdrawal:'||w.id::text) LIMIT 1`], - ['orphan-monero-withdrawal-reserve', `SELECT 1 FROM wk_custody.accounts a WHERE a.id LIKE 'customer:%:monero-withdrawal:%' - AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_withdrawal_intents w - WHERE a.id='customer:'||w.beneficiary||':monero-withdrawal:'||w.request_id::text) LIMIT 1`], - ['orphan-outbox', `SELECT 1 FROM wk_custody.outbox b WHERE NOT EXISTS(SELECT 1 FROM wk_custody.orders o WHERE o.id=b.id AND b.kind='nft-lock') - AND NOT EXISTS(SELECT 1 FROM wk_custody.withdrawals w WHERE w.id=b.id AND b.kind='withdrawal') LIMIT 1`], - ['listing-order-mismatch', `SELECT 1 FROM wk_custody.listings l LEFT JOIN wk_custody.orders o ON o.data->>'listingId'=l.id::text - WHERE (l.status IN ('reserved','sold') AND o.id IS NULL) OR l.id::text IS DISTINCT FROM l.data->>'id' - OR l.status IS DISTINCT FROM l.data->>'status' OR l.card IS DISTINCT FROM l.data->>'cardHash' LIMIT 1`], - ['duplicate-listing-orders', `SELECT 1 FROM wk_custody.orders GROUP BY data->>'listingId' HAVING count(*)<>1 LIMIT 1`], - ['orphan-lifecycle-posting', `SELECT 1 FROM wk_custody.journals j WHERE - (j.kind IN ('purchase-reservation','purchase-cancellation','mock-sale-settlement') AND NOT EXISTS - (SELECT 1 FROM wk_custody.orders o WHERE j.id IN ('reserve:'||o.id::text,'cancel:'||o.id::text,'settle:'||o.id::text))) OR - (j.kind IN ('withdrawal-reservation','withdrawal-cancellation','mock-withdrawal') AND NOT EXISTS - (SELECT 1 FROM wk_custody.withdrawals w WHERE j.id IN ('withdraw:'||w.id::text,'withdraw-cancel:'||w.id::text,'withdraw-confirm:'||w.id::text))) OR - (j.kind IN ('monero-withdrawal-reservation','monero-withdrawal-cancellation') AND NOT EXISTS - (SELECT 1 FROM wk_custody.monero_withdrawal_intents w WHERE j.id IN - ('monero-withdrawal-reserve:'||w.request_id::text,'monero-withdrawal-cancel:'||w.request_id::text))) LIMIT 1`], - ['duplicate-prepared-attempt', `SELECT 1 FROM wk_custody.withdrawals WHERE status IN ('prepared','complete') - GROUP BY data->>'attemptId' HAVING count(*)>1 LIMIT 1`], - ['duplicate-prepared-transaction', `SELECT 1 FROM wk_custody.withdrawals WHERE status IN ('prepared','complete') - GROUP BY data->>'transactionId' HAVING count(*)>1 LIMIT 1`], - ]; - for (const [code, sql] of checks) { budget(); if ((await client.query(sql)).rowCount) fail(code); } -} diff --git a/server/market/custody-storage.ts b/server/market/custody-storage.ts deleted file mode 100755 index afe4ee6..0000000 --- a/server/market/custody-storage.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { DatabaseSync } from 'node:sqlite'; -import { readFile, open, lstat, mkdir } from 'node:fs/promises'; -import { join } from 'node:path'; -import { CustodyMarket } from './custody.ts'; -import { PostgresCustody, type CustodyOptions } from './postgres-custody.ts'; -import { custodyStorage, postgresConfig } from './postgres-config.ts'; -import { PROFILE } from '../../shared/network.ts'; - -/** No automatic SQLite reinterpretation or fallback after a database connection/configuration failure. */ -export async function openCustodyStorage(directory: string, options: CustodyOptions, env = process.env): Promise { - const profileDirectory = join(directory, PROFILE); - const storage = custodyStorage(env), marker = join(profileDirectory, 'custody-postgres.json'); - const observerMode = env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL; - if (observerMode !== undefined && observerMode !== '0' && observerMode !== '1') { - throw new Error('WIKIDECK_CUSTODY_CANONICAL_REHEARSAL must be exactly 0 or 1'); - } - if (observerMode === '1' && (env.NODE_ENV === 'production' || storage !== 'postgres' || env.WIKIDECK_PG_DISPOSABLE_TEST !== '1')) { - throw new Error('Canonical observer rehearsal requires PostgreSQL and the disposable database test guard'); - } - const custodyOptions = observerMode === '1' ? { ...options, disposableSaleRehearsal: true } : options; - await mkdir(profileDirectory, { recursive: true, mode: 0o700 }); - let existing: { version: number; custodianId: string } | undefined; - try { existing = JSON.parse(await readFile(marker, 'utf8')); } - catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; } - if (storage === 'sqlite') { - if (existing) throw new Error('Custody data directory is bound to PostgreSQL; SQLite fallback prohibited'); - return CustodyMarket.open(directory, custodyOptions); - } - const source = join(profileDirectory, 'custody-sandbox.sqlite'); - let frozen: string | undefined; - try { - const info = await lstat(source); - if (!info.isFile() || info.isSymbolicLink()) throw new Error('Invalid custody source file'); - const db = new DatabaseSync(source, { readOnly: true }); - try { - frozen = (db.prepare("SELECT value FROM custody_meta WHERE key='postgres_cutover'").get() as { value: string } | undefined)?.value; - if (!frozen) throw new Error('Existing SQLite custody requires explicit stopped-node import before PostgreSQL activation'); - } finally { db.close(); } - } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; } - const market = await PostgresCustody.open(await postgresConfig(env), custodyOptions); - try { - if (frozen && await market.importSource() !== frozen) throw new Error('PostgreSQL import does not match local frozen custody source'); - if (existing && (existing.version !== 1 || existing.custodianId !== market.id)) throw new Error('PostgreSQL custodian differs from data-directory binding'); - if (!existing) { - const handle = await open(marker, 'wx', 0o600); - try { await handle.writeFile(JSON.stringify({ version: 1, custodianId: market.id })); await handle.sync(); } - finally { await handle.close(); } - if (process.platform !== 'win32') { - const parent = await open(profileDirectory, 'r'); - try { await parent.sync(); } finally { await parent.close(); } - } - } - return market; - } catch (e) { await market.close(); throw e; } -} diff --git a/server/market/custody.ts b/server/market/custody.ts deleted file mode 100755 index 0917cd8..0000000 --- a/server/market/custody.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { DatabaseSync } from 'node:sqlite'; -import { createHash, randomUUID } from 'node:crypto'; -import { mkdir, chmod } from 'node:fs/promises'; -import { join } from 'node:path'; -import { IS_MAINNET, PROFILE } from '../../shared/network.ts'; -import { NETWORK } from '../../shared/testnet.ts'; -import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts'; -import { allocateXmrSale } from '../../shared/xmr-pricing.ts'; -import { parseAtomicXmr } from '../../shared/xmr-amount.ts'; -import { CUSTODY_POLICY, MOCK_CREDIT_ATOMIC, custodyCommandSchema, custodyMessage, - type CustodyCommand, type CustodyActivity, type CustodyInfo, type CustodyListing, - type CustodyOrder, type CustodyWithdrawal, type CustodyPage } from '../../shared/custody.ts'; -import { CHAIN_ID, verifySignature } from '../testnet/consensus.ts'; -import { resolvedMarketPolicy, validateMarketPolicy, permitsMarketAction, type MarketPolicy } from './policy.ts'; - -export function custodyMode(env: NodeJS.ProcessEnv = process.env): 'off' | 'sandbox' { - const mode = env.WIKIDECK_CUSTODY_MODE ?? 'off'; - if (mode !== 'off' && mode !== 'sandbox') throw new Error('Custody supports off or sandbox only; live custody is disabled'); - if (IS_MAINNET && mode !== 'off') throw new Error('Custody sandbox cannot run on mainnet'); - return mode; -} -export class CustodyError extends Error {} -type Card = { title: string }; -type Options = { feeBps: number; card(cardHash: string, owner: string, sequence: number): Card | undefined; now?: () => number } & MarketPolicy; -type Account = { id: string; normal: 'debit' | 'credit'; balance: string }; -type Line = { account: string; normal: 'debit' | 'credit'; debit?: bigint; credit?: bigint }; -type Result = CustodyActivity | CustodyListing | CustodyOrder | CustodyWithdrawal; -const AGGREGATE_MAX = 10n ** 30n - 1n; -const LIMIT = 10000; // Explicit disposable-sandbox resource budget; never archive financial history silently. -const available = (actor: string) => `customer:${actor}:available`; -const orderAccount = (order: CustodyOrder) => `customer:${order.buyer}:purchase:${order.id}`; -const withdrawalAccount = (w: CustodyWithdrawal) => `customer:${w.actor}:withdrawal:${w.id}`; -const debit = (account: string, amount: bigint, normal: 'debit' | 'credit' = 'credit'): Line => ({ account, normal, debit: amount }); -const credit = (account: string, amount: bigint, normal: 'debit' | 'credit' = 'credit'): Line => ({ account, normal, credit: amount }); -const digest = (value: string) => createHash('sha256').update(value).digest('hex'); -function requireState(condition: unknown, message: string): asserts condition { - if (!condition) throw new CustodyError(message); -} - -/** Isolated, durable OFFLINE/SANDBOX financial model. No wallet RPC, keys, addresses or NFT signer. */ -export class CustodyMarket { - private readonly db: DatabaseSync; - private readonly options: Options; - readonly id: string; - private readonly now: () => number; - private constructor(db: DatabaseSync, options: Options, id: string) { - this.db = db; this.options = options; this.id = id; this.now = options.now ?? Date.now; - } - static async open(directory: string, options: Options): Promise { - if (IS_MAINNET) throw new Error('Custody sandbox cannot run on mainnet'); - await validateMarketPolicy(options); - if (!Number.isSafeInteger(options.feeBps) || options.feeBps < 0 || options.feeBps > 5000) throw new Error('Invalid custody fee'); - const folder = join(directory, PROFILE); - await mkdir(folder, { recursive: true, mode: 0o700 }); - const file = join(folder, 'custody-sandbox.sqlite'); - const db = new DatabaseSync(file); - try { - db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON; - CREATE TABLE IF NOT EXISTS custody_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS accounts(id TEXT PRIMARY KEY,normal TEXT NOT NULL CHECK(normal IN ('debit','credit')),balance TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS journals(id TEXT PRIMARY KEY,kind TEXT NOT NULL,created INTEGER NOT NULL); - CREATE TABLE IF NOT EXISTS entries(journal TEXT NOT NULL REFERENCES journals(id),position INTEGER NOT NULL, - account TEXT NOT NULL REFERENCES accounts(id),debit TEXT NOT NULL,credit TEXT NOT NULL,PRIMARY KEY(journal,position)); - CREATE TABLE IF NOT EXISTS listings(id TEXT PRIMARY KEY,card TEXT NOT NULL,status TEXT NOT NULL,data TEXT NOT NULL); - CREATE UNIQUE INDEX IF NOT EXISTS custody_one_card ON listings(card) WHERE status IN ('open','reserved'); - CREATE TABLE IF NOT EXISTS orders(id TEXT PRIMARY KEY,buyer TEXT NOT NULL,seller TEXT NOT NULL,status TEXT NOT NULL,data TEXT NOT NULL); - CREATE INDEX IF NOT EXISTS custody_orders_buyer ON orders(buyer); - CREATE INDEX IF NOT EXISTS custody_orders_seller ON orders(seller); - CREATE TABLE IF NOT EXISTS withdrawals(id TEXT PRIMARY KEY,actor TEXT NOT NULL,status TEXT NOT NULL,data TEXT NOT NULL); - CREATE INDEX IF NOT EXISTS custody_withdrawals_actor ON withdrawals(actor); - CREATE TABLE IF NOT EXISTS receipts(id TEXT PRIMARY KEY,digest TEXT NOT NULL,data TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS outbox(id TEXT PRIMARY KEY,kind TEXT NOT NULL,status TEXT NOT NULL,data TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS credits(actor TEXT PRIMARY KEY); - CREATE TRIGGER IF NOT EXISTS entries_no_update BEFORE UPDATE ON entries BEGIN SELECT RAISE(ABORT,'Immutable financial entry'); END; - CREATE TRIGGER IF NOT EXISTS entries_no_delete BEFORE DELETE ON entries BEGIN SELECT RAISE(ABORT,'Immutable financial entry'); END; - CREATE TRIGGER IF NOT EXISTS journals_no_update BEFORE UPDATE ON journals BEGIN SELECT RAISE(ABORT,'Immutable financial journal'); END; - CREATE TRIGGER IF NOT EXISTS journals_no_delete BEFORE DELETE ON journals BEGIN SELECT RAISE(ABORT,'Immutable financial journal'); END;`); - db.exec('BEGIN IMMEDIATE'); - let id: string; - try { - if (db.prepare("SELECT 1 FROM custody_meta WHERE key='postgres_cutover'").get()) { - throw new Error('SQLite custody was frozen for PostgreSQL cutover; resume the import, never restart SQLite'); - } - const row = db.prepare('SELECT value FROM custody_meta WHERE key=?').get('identity') as { value: string } | undefined; - if (row) { - const identity = JSON.parse(row.value); - if (identity.version !== 1 || identity.network !== NETWORK || identity.chainId !== CHAIN_ID || - identity.mode !== 'sandbox' || identity.policy !== CUSTODY_POLICY || - !/^[a-f0-9-]{36}$/.test(identity.id)) throw new Error('Custody identity/schema mismatch'); - id = identity.id; - } else { - id = randomUUID(); - db.prepare('INSERT INTO custody_meta VALUES(?,?)').run('identity', JSON.stringify({ version: 1, - mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK, chainId: CHAIN_ID, id })); - } - db.exec('COMMIT'); - } catch (error) { db.exec('ROLLBACK'); throw error; } - if (process.platform !== 'win32') await chmod(file, 0o600); - const market = new CustodyMarket(db, options, id); - market.audit(); // Refuse to start if projections no longer match the immutable journal. - return market; - } catch (error) { db.close(); throw error; } - } - close() { this.db.close(); } - info(): CustodyInfo { - return { enabled: true, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK, chainId: CHAIN_ID, - custodianId: this.id, feeBps: this.options.feeBps, ...resolvedMarketPolicy(this.options), liveDeposits: false, liveWithdrawals: false, nftSettlement: false }; - } - private atomic(fn: () => T): T { - this.db.exec('BEGIN IMMEDIATE'); - try { const result = fn(); this.db.exec('COMMIT'); return result; } - catch (error) { this.db.exec('ROLLBACK'); throw error; } - } - private balance(id: string): bigint { - const row = this.db.prepare('SELECT balance FROM accounts WHERE id=?').get(id) as { balance: string } | undefined; - return row ? BigInt(row.balance) : 0n; - } - private post(id: string, kind: string, lines: Line[]) { - if (['mock-deposit', 'purchase-reservation', 'withdrawal-reservation'].includes(kind)) { - requireState((this.db.prepare('SELECT COUNT(*) AS n FROM journals').get() as { n: number }).n < LIMIT, - 'Custody sandbox journal budget reached; existing reservations can still be cancelled'); - } - const totals = lines.reduce((s, line) => s + (line.debit ?? 0n) - (line.credit ?? 0n), 0n); - if (totals !== 0n || !lines.length) throw new Error('Unbalanced custody journal'); - this.db.prepare('INSERT INTO journals VALUES(?,?,?)').run(id, kind, this.now()); - for (const [position, line] of lines.entries()) { - const d = line.debit ?? 0n, c = line.credit ?? 0n; - if (d < 0n || c < 0n || (d > 0n && c > 0n)) throw new Error('Invalid journal line'); - if (d === 0n && c === 0n) continue; - this.db.prepare('INSERT OR IGNORE INTO accounts VALUES(?,?,?)').run(line.account, line.normal, '0'); - const account = this.db.prepare('SELECT * FROM accounts WHERE id=?').get(line.account) as Account; - if (account.normal !== line.normal) throw new Error('Account normal changed'); - const balance = BigInt(account.balance) + (line.normal === 'debit' ? d - c : c - d); - requireState(balance >= 0n, 'Insufficient available sandbox balance'); - if (balance > AGGREGATE_MAX) throw new CustodyError('Custody aggregate amount limit reached'); - this.db.prepare('INSERT INTO entries VALUES(?,?,?,?,?)').run(id, position, line.account, d.toString(), c.toString()); - this.db.prepare('UPDATE accounts SET balance=? WHERE id=?').run(balance.toString(), line.account); - } - } - private get(table: 'listings' | 'orders' | 'withdrawals', id: string): T { - const row = this.db.prepare(`SELECT data FROM ${table} WHERE id=?`).get(id) as { data: string } | undefined; - requireState(row, 'Custody record not found'); - return JSON.parse(row.data) as T; - } - private saveOrder(order: CustodyOrder) { - this.db.prepare('INSERT INTO orders VALUES(?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data') - .run(order.id, order.buyer, order.seller, order.status, JSON.stringify(order)); - } - private saveWithdrawal(w: CustodyWithdrawal) { - this.db.prepare('INSERT INTO withdrawals VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data') - .run(w.id, w.actor, w.status, JSON.stringify(w)); - } - private saveListing(l: CustodyListing) { - this.db.prepare('INSERT INTO listings VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data') - .run(l.id, l.cardHash, l.status, JSON.stringify(l)); - } - browse(offset = 0): CustodyPage { - requireState(Number.isSafeInteger(offset) && offset >= 0 && offset <= LIMIT, 'Invalid custody page'); - if (!resolvedMarketPolicy(this.options).marketEnabled) return { info: this.info(), listings: [], next: null }; - const rows = this.db.prepare("SELECT data FROM listings WHERE status IN ('open','reserved') ORDER BY rowid DESC LIMIT 25 OFFSET ?") - .all(offset) as { data: string }[]; - return { info: this.info(), listings: rows.slice(0, 24).map(r => JSON.parse(r.data)), next: rows.length > 24 ? offset + 24 : null }; - } - private activity(actor: string, offset: number): CustodyActivity { - const orders = this.db.prepare('SELECT data FROM orders WHERE buyer=? OR seller=? ORDER BY rowid DESC LIMIT 25 OFFSET ?') - .all(actor, actor, offset) as { data: string }[]; - const withdrawals = this.db.prepare('SELECT data FROM withdrawals WHERE actor=? ORDER BY rowid DESC LIMIT 25 OFFSET ?') - .all(actor, offset) as { data: string }[]; - const sum = (prefix: string) => (this.db.prepare('SELECT balance FROM accounts WHERE id GLOB ?').all(prefix) as { balance: string }[]) - .reduce((s, r) => s + BigInt(r.balance), 0n).toString(); - return { availableAtomicXmr: this.balance(available(actor)).toString(), - purchaseReservedAtomicXmr: sum(`customer:${actor}:purchase:*`), withdrawalReservedAtomicXmr: sum(`customer:${actor}:withdrawal:*`), - mockCreditClaimed: !!this.db.prepare('SELECT 1 FROM credits WHERE actor=?').get(actor), - orders: orders.slice(0, 24).map(r => JSON.parse(r.data)), withdrawals: withdrawals.slice(0, 24).map(r => JSON.parse(r.data)), - next: orders.length > 24 || withdrawals.length > 24 ? offset + 24 : null }; - } - execute(raw: unknown): Result { - const c = custodyCommandSchema.parse(raw), message = custodyMessage(c), now = this.now(); - requireState(c.network === NETWORK && c.chainId === CHAIN_ID && c.custodianId === this.id && - c.expiresAt >= now && c.expiresAt <= now + 5 * 60_000 && - verifySignature(c.publicKey, c.actor, message, c.signature), 'Invalid or expired custody signature'); - if (c.action.type === 'activity') return this.activity(c.actor, c.action.offset); - return this.atomic(() => { - const key = `${c.actor}:${c.nonce}`, hash = digest(message); - const prior = this.db.prepare('SELECT digest,data FROM receipts WHERE id=?').get(key) as { digest: string; data: string } | undefined; - if (prior) { requireState(prior.digest === hash, 'Nonce already used for different custody terms'); return JSON.parse(prior.data) as Result; } - requireState(permitsMarketAction(this.options, c.action.type), 'New market trading is disabled by this host; existing balances and resolution remain available'); - if (!['cancel-listing', 'cancel-reservation', 'cancel-withdrawal'].includes(c.action.type)) { - requireState((this.db.prepare('SELECT COUNT(*) AS n FROM receipts').get() as { n: number }).n < LIMIT, - 'Custody sandbox command budget reached; existing reservations can still be cancelled'); - } - const result = this.mutate(c); - this.db.prepare('INSERT INTO receipts VALUES(?,?,?)').run(key, hash, JSON.stringify(result)); - return result; - }); - } - private mutate(c: CustodyCommand): Result { - const a = c.action; - if (a.type === 'mock-credit') { - requireState(!this.db.prepare('SELECT 1 FROM credits WHERE actor=?').get(c.actor), 'Sandbox credit already claimed'); - this.post(`credit:${c.actor}`, 'mock-deposit', [debit('treasury:mock', BigInt(MOCK_CREDIT_ATOMIC), 'debit'), credit(available(c.actor), BigInt(MOCK_CREDIT_ATOMIC))]); - this.db.prepare('INSERT INTO credits VALUES(?)').run(c.actor); - return this.activity(c.actor, 0); - } - if (a.type === 'list') { - requireState(a.feeBps === this.options.feeBps, 'Custody hosting fee changed; review again'); - const hostFeeAddress = resolvedMarketPolicy(this.options).hostFeeAddress; - requireState((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) === hostFeeAddress, 'Host fee recipient changed; review and sign again'); - const card = this.options.card(a.cardHash, c.actor, a.sequence); - requireState(card, 'Card is missing, stale, immature, or pending'); - requireState(!this.db.prepare("SELECT 1 FROM listings WHERE card=? AND status IN ('open','reserved')").get(a.cardHash), 'Card already has a custody listing'); - const l: CustodyListing = { id: randomUUID(), seller: c.actor, cardHash: a.cardHash, sequence: a.sequence, - title: card.title, priceAtomicXmr: a.priceAtomicXmr, feeBps: a.feeBps, hostFeeAddress, status: 'open', createdAt: this.now() }; - this.saveListing(l); return l; - } - if (a.type === 'cancel-listing' || a.type === 'reserve') { - const l = this.get('listings', a.listingId); - requireState(l.status === 'open', 'Custody listing is no longer available'); - if (a.type === 'cancel-listing') { - requireState(l.seller === c.actor, 'Only the seller can cancel this listing'); - l.status = 'cancelled'; this.saveListing(l); return l; - } - requireState(c.actor !== l.seller, 'Cannot buy your own card'); - requireState(l.priceAtomicXmr === a.priceAtomicXmr && l.feeBps === a.feeBps, 'Custody sale terms changed'); - const hostFeeAddress = l.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address; - requireState((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) === hostFeeAddress, 'Host fee recipient does not match listing terms'); - requireState(this.options.card(l.cardHash, l.seller, l.sequence), 'Seller card is no longer available'); - const allocation = allocateXmrSale(parseAtomicXmr(a.priceAtomicXmr), a.feeBps); - const order: CustodyOrder = { id: randomUUID(), listingId: l.id, seller: l.seller, buyer: c.actor, - cardHash: l.cardHash, sequence: l.sequence, grossAtomicXmr: a.priceAtomicXmr, - sellerAtomicXmr: allocation.sellerBeforeNetworkCosts.toString(), authorAtomicXmr: allocation.author.toString(), - hostAtomicXmr: allocation.hosting.toString(), authorAddress: ADDITIONAL_MARKET_FEE.address, hostFeeAddress, - status: 'awaiting-lock', createdAt: this.now() }; - this.post(`reserve:${order.id}`, 'purchase-reservation', [debit(available(c.actor), allocation.gross), credit(orderAccount(order), allocation.gross)]); - l.status = 'reserved'; this.saveListing(l); this.saveOrder(order); - // Durable intent only. No NFT signing/submission worker is enabled until reviewed consensus rules exist. - this.db.prepare('INSERT INTO outbox VALUES(?,?,?,?)').run(order.id, 'nft-lock', 'blocked', JSON.stringify(order)); - return order; - } - if (a.type === 'cancel-reservation') { - const o = this.get('orders', a.orderId); - requireState(o.buyer === c.actor && o.status === 'awaiting-lock', 'Only the buyer can cancel an unstarted sandbox reservation'); - const intent = this.db.prepare('SELECT status FROM outbox WHERE id=?').get(o.id) as { status: string } | undefined; - requireState(intent?.status === 'blocked', 'NFT intent may have executed; reservation requires review'); - // Safe only because this sandbox never emits a custody delivery witness. Do not reuse for live refunds. - this.post(`cancel:${o.id}`, 'purchase-cancellation', [debit(orderAccount(o), BigInt(o.grossAtomicXmr)), credit(available(o.buyer), BigInt(o.grossAtomicXmr))]); - o.status = 'cancelled'; this.saveOrder(o); - const l = this.get('listings', o.listingId); l.status = 'cancelled'; this.saveListing(l); - this.db.prepare("UPDATE outbox SET status='cancelled' WHERE id=?").run(o.id); - return o; - } - if (a.type === 'withdraw') { - const w: CustodyWithdrawal = { id: randomUUID(), actor: c.actor, destination: a.destination, - amountAtomicXmr: a.amountAtomicXmr, maxFeeAtomicXmr: a.maxFeeAtomicXmr, status: 'reserved', createdAt: this.now() }; - const total = parseAtomicXmr(w.amountAtomicXmr) + parseAtomicXmr(w.maxFeeAtomicXmr); - this.post(`withdraw:${w.id}`, 'withdrawal-reservation', [debit(available(c.actor), total), credit(withdrawalAccount(w), total)]); - this.saveWithdrawal(w); - this.db.prepare('INSERT INTO outbox VALUES(?,?,?,?)').run(w.id, 'withdrawal', 'blocked', JSON.stringify(w)); - return w; - } - if (a.type === 'cancel-withdrawal') { - const w = this.get('withdrawals', a.withdrawalId); - requireState(w.actor === c.actor && w.status === 'reserved', 'Only an unprepared withdrawal can be cancelled by its owner'); - const total = BigInt(w.amountAtomicXmr) + BigInt(w.maxFeeAtomicXmr); - this.post(`withdraw-cancel:${w.id}`, 'withdrawal-cancellation', [debit(withdrawalAccount(w), total), credit(available(c.actor), total)]); - w.status = 'cancelled'; this.saveWithdrawal(w); - this.db.prepare("UPDATE outbox SET status='cancelled' WHERE id=?").run(w.id); - return w; - } - throw new CustodyError('Unsupported custody action'); - } - - /** Offline fixture seam ONLY: records a simulated canonical event, never reachable from HTTP. */ - settleFixture(orderId: string, evidenceId: string): CustodyOrder { - requireState(/^fixture:[a-f0-9]{64}$/.test(evidenceId), 'Expected offline fixture evidence'); - return this.atomic(() => { - const o = this.get('orders', orderId); - if (o.status === 'settled') { - const event = this.db.prepare('SELECT data FROM outbox WHERE id=?').get(o.id) as { data: string }; - requireState(event.data === evidenceId, 'Conflicting fixture settlement evidence'); return o; - } - requireState(o.status === 'awaiting-lock', 'Order is not eligible for fixture settlement'); - this.post(`settle:${o.id}`, 'mock-sale-settlement', [debit(orderAccount(o), BigInt(o.grossAtomicXmr)), - credit(available(o.seller), BigInt(o.sellerAtomicXmr)), credit(`author:${o.authorAddress}`, BigInt(o.authorAtomicXmr)), - credit(o.hostFeeAddress ? `revenue:host:${o.hostFeeAddress}` : 'revenue:host', BigInt(o.hostAtomicXmr))]); - o.status = 'settled'; this.saveOrder(o); - const l = this.get('listings', o.listingId); l.status = 'sold'; this.saveListing(l); - this.db.prepare("UPDATE outbox SET status='fixture-complete',data=? WHERE id=?").run(evidenceId, o.id); - return o; - }); - } - /** Offline mock treasury: persist one transaction identity; a retry must never prepare another spend. */ - prepareWithdrawalFixture(id: string, attemptId: string, transactionId: string, feeAtomicXmr: string): CustodyWithdrawal { - requireState(/^fixture:[a-f0-9]{64}$/.test(transactionId) && /^[a-f0-9-]{36}$/.test(attemptId), 'Expected offline withdrawal identity'); - const fee = parseAtomicXmr(feeAtomicXmr); - return this.atomic(() => { - const w = this.get('withdrawals', id); - if (w.status === 'prepared' || w.status === 'complete') { - requireState(w.attemptId === attemptId && w.transactionId === transactionId && w.feeAtomicXmr === feeAtomicXmr, - 'Withdrawal already prepared with different terms'); return w; - } - requireState(w.status === 'reserved' && fee <= BigInt(w.maxFeeAtomicXmr), 'Withdrawal state or fee cap invalid'); - w.status = 'prepared'; w.attemptId = attemptId; w.transactionId = transactionId; w.feeAtomicXmr = feeAtomicXmr; - this.saveWithdrawal(w); - this.db.prepare("UPDATE outbox SET status='fixture-prepared',data=? WHERE id=?").run(JSON.stringify(w), w.id); - return w; - }); - } - confirmWithdrawalFixture(id: string, transactionId: string): CustodyWithdrawal { - return this.atomic(() => { - const w = this.get('withdrawals', id); - requireState(w.transactionId === transactionId && ['prepared', 'complete'].includes(w.status), 'Withdrawal identity/state mismatch'); - if (w.status === 'complete') return w; - const amount = BigInt(w.amountAtomicXmr), fee = BigInt(w.feeAtomicXmr!), maxFee = BigInt(w.maxFeeAtomicXmr); - this.post(`withdraw-confirm:${id}`, 'mock-withdrawal', [debit(withdrawalAccount(w), amount + maxFee), - credit('treasury:mock', amount + fee, 'debit'), credit(available(w.actor), maxFee - fee)]); - w.status = 'complete'; this.saveWithdrawal(w); - this.db.prepare("UPDATE outbox SET status='fixture-complete' WHERE id=?").run(w.id); - return w; - }); - } - /** No floating SQL SUM: replay canonical strings using bigint, independently of balance projections. */ - audit(): { journals: number; assetsAtomicXmr: string; claimsAndRevenueAtomicXmr: string } { - return this.atomic(() => { - const accounts = this.db.prepare('SELECT * FROM accounts').all() as Account[]; - const balances = new Map(accounts.map(a => [a.id, 0n])); - const normals = new Map(accounts.map(a => [a.id, a.normal])); - const journals = this.db.prepare('SELECT id FROM journals').all() as { id: string }[]; - for (const journal of journals) { - const lines = this.db.prepare('SELECT account,debit,credit FROM entries WHERE journal=? ORDER BY position').all(journal.id) as { account: string; debit: string; credit: string }[]; - let total = 0n; - if (!lines.length) throw new Error('Empty custody journal'); - for (const l of lines) { - if (!/^(0|[1-9]\d{0,29})$/.test(l.debit) || !/^(0|[1-9]\d{0,29})$/.test(l.credit)) throw new Error('Invalid custody entry encoding'); - const d = BigInt(l.debit), c = BigInt(l.credit); - if (d > 0n && c > 0n || !normals.has(l.account)) throw new Error('Invalid custody entry'); - total += d - c; - balances.set(l.account, balances.get(l.account)! + (normals.get(l.account) === 'debit' ? d - c : c - d)); - } - if (total !== 0n) throw new Error('Unbalanced custody history'); - } - let assets = 0n, claims = 0n; - for (const a of accounts) { - if (!/^(0|[1-9]\d{0,29})$/.test(a.balance) || balances.get(a.id) !== BigInt(a.balance)) throw new Error('Custody balance projection mismatch'); - if (a.normal === 'debit') assets += BigInt(a.balance); else claims += BigInt(a.balance); - } - if (assets !== claims) throw new Error('Custody backing mismatch'); - return { journals: journals.length, assetsAtomicXmr: assets.toString(), claimsAndRevenueAtomicXmr: claims.toString() }; - }); - } -} diff --git a/server/market/mainnet-custody-identity.ts b/server/market/mainnet-custody-identity.ts deleted file mode 100755 index 095a4d2..0000000 --- a/server/market/mainnet-custody-identity.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { Pool } from 'pg'; -import { NETWORK } from '../../shared/network.ts'; -import { CHAIN_ID } from '../testnet/consensus.ts'; -import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts'; - -export type MainnetCustodyIdentity = { - version: 1; - mode: 'mainnet-custody'; - policy: 'custody-mainnet-v1'; - network: 'wikideck-randomx-mainnet-1'; - chainId: string; - id: string; -}; - -function uuid(value: string): boolean { - return /^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value); -} - -/** One-time, schema-owner-only identity binding for the separately operated mainnet custody ledger. */ -export async function initializeMainnetCustodyIdentity(pool: Pool, custodianId: string): Promise { - if (NETWORK !== 'wikideck-randomx-mainnet-1' || !uuid(custodianId)) { - throw new Error('Mainnet custody identity requires the mainnet profile and a valid custodian UUID'); - } - const identity: MainnetCustodyIdentity = { version: 1, mode: 'mainnet-custody', policy: 'custody-mainnet-v1', - network: NETWORK, chainId: CHAIN_ID, id: custodianId }; - const client = await pool.connect(); - try { - await client.query('BEGIN'); - await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on"); - await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]); - await verifyCustodySchema(client); - const current = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity' FOR UPDATE"); - if (current.rowCount) { - const parsed = JSON.parse(current.rows[0].value) as Partial; - if (parsed.version !== identity.version || parsed.mode !== identity.mode || parsed.policy !== identity.policy || - parsed.network !== identity.network || parsed.chainId !== identity.chainId || parsed.id !== identity.id) { - throw new Error('Custody database is already bound to a different network or custodian identity'); - } - } else { - const populated = await client.query(`SELECT - EXISTS(SELECT 1 FROM wk_custody.journals) AS has_journals, - EXISTS(SELECT 1 FROM wk_custody.monero_deposit_allocations) AS has_allocations`); - if (populated.rows[0].has_journals || populated.rows[0].has_allocations) { - throw new Error('Cannot initialize mainnet custody identity after financial or deposit records exist'); - } - await client.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)', ['identity', JSON.stringify(identity)]); - } - await client.query('COMMIT'); - return identity; - } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } - finally { client.release(); } -} - -export async function verifyMainnetCustodyIdentity(pool: Pool, custodianId: string): Promise { - if (NETWORK !== 'wikideck-randomx-mainnet-1' || !uuid(custodianId)) { - throw new Error('Mainnet custody identity requires the mainnet profile and a valid custodian UUID'); - } - const result = await pool.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'"); - if (result.rowCount !== 1) throw new Error('Mainnet custody identity is not initialized'); - const identity = JSON.parse(result.rows[0].value) as Partial; - if (identity.version !== 1 || identity.mode !== 'mainnet-custody' || identity.policy !== 'custody-mainnet-v1' || - identity.network !== NETWORK || identity.chainId !== CHAIN_ID || identity.id !== custodianId) { - throw new Error('Mainnet custody identity does not match configured custodian'); - } - return identity as MainnetCustodyIdentity; -} - -export function parseMainnetCustodianId(value: string): string { - if (!uuid(value)) throw new Error('Invalid mainnet custodian UUID'); - return value.toLowerCase(); -} diff --git a/server/market/mainnet-withdrawal.ts b/server/market/mainnet-withdrawal.ts deleted file mode 100755 index 29c266a..0000000 --- a/server/market/mainnet-withdrawal.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { createHash } from 'node:crypto'; -import type { Pool, PoolClient } from 'pg'; -import { address, verifySignature } from '../testnet/consensus.ts'; -import { validateXmrAddress } from './xmr.ts'; -import { assertXmrRpcAmount } from '../../shared/xmr-amount.ts'; -import { CUSTODY_WRITE_LOCK } from './postgres-schema.ts'; -import { mainnetWithdrawalCancellationMessage, mainnetWithdrawalCancellationSchema, - mainnetCustodyBalanceMessage, mainnetCustodyBalanceRequestSchema, mainnetWithdrawalMessage, - mainnetWithdrawalRequestSchema } from '../../shared/monero-withdrawal.ts'; - -const MAX_REQUEST_LIFETIME_MS = 5 * 60_000; -const digest = (text: string) => createHash('sha256').update(text).digest('hex'); -const availableAccount = (beneficiary: string) => `customer:${beneficiary}:available`; -const withdrawalAccount = (beneficiary: string, requestId: string) => `customer:${beneficiary}:monero-withdrawal:${requestId}`; - -export type MainnetWithdrawalIntent = { - requestId: string; - custodianId: string; - beneficiary: string; - destination: string; - amountAtomicXmr: string; - maxFeeAtomicXmr: string; - status: 'reserved' | 'cancelled'; - createdAt: number; - cancelledAt: number | null; -}; -export type MainnetCustodyBalance = { - availableAtomicXmr: string; - depositPendingAtomicXmr: string; - withdrawalReservedAtomicXmr: string; -}; - -async function insertJournal(client: PoolClient, journalId: string, kind: string, createdAt: number, - debitAccount: string, creditAccount: string, amount: string): Promise { - for (const [accountId, normal] of [[debitAccount, 'credit'], [creditAccount, 'credit']] as const) { - await client.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,$2) ON CONFLICT(id) DO NOTHING', [accountId, normal]); - const row = await client.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [accountId]); - if (row.rowCount !== 1 || row.rows[0].normal !== normal) throw new Error('Mainnet withdrawal ledger account mismatch'); - } - await client.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)', [journalId, kind, createdAt]); - await client.query('INSERT INTO wk_custody.entries VALUES($1,0,$2,$3,0),($1,1,$4,0,$3)', - [journalId, debitAccount, amount, creditAccount]); -} - -async function mainnetIdentity(client: PoolClient, custodianId: string, chainId: string): Promise { - const result = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'"); - if (result.rowCount !== 1) throw new Error('Mainnet custody identity is not initialized'); - const identity = JSON.parse(result.rows[0].value) as Record; - if (identity.version !== 1 || identity.mode !== 'mainnet-custody' || identity.policy !== 'custody-mainnet-v1' || - identity.network !== 'wikideck-randomx-mainnet-1' || identity.id !== custodianId || identity.chainId !== chainId) { - throw new Error('Withdrawal request does not match the initialized mainnet custody identity'); - } -} - -function expiryIsValid(expiresAt: number, now: number): boolean { - return Number.isSafeInteger(now) && now >= 0 && expiresAt >= now && expiresAt <= now + MAX_REQUEST_LIFETIME_MS; -} - -/** Customer-signed request: atomically reserve available liability, but do not prepare or send XMR. */ -export async function reserveMainnetWithdrawal(pool: Pool, rawRequest: unknown, now = Date.now()): Promise { - const request = mainnetWithdrawalRequestSchema.parse(rawRequest); - const destination = await validateXmrAddress(request.destination); - if (address(request.publicKey) !== request.beneficiary || - !verifySignature(request.publicKey, request.beneficiary, mainnetWithdrawalMessage(request), request.signature)) { - throw new Error('Mainnet withdrawal signature is invalid'); - } - const requestDigest = digest(mainnetWithdrawalMessage(request)); - const amount = BigInt(request.amountAtomicXmr), fee = BigInt(request.maxFeeAtomicXmr), total = amount + fee; - assertXmrRpcAmount(amount); assertXmrRpcAmount(fee); - const client = await pool.connect(); - try { - await client.query('BEGIN'); - await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on"); - await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]); - await mainnetIdentity(client, request.custodianId, request.chainId); - const prior = await client.query('SELECT * FROM wk_custody.monero_withdrawal_intents WHERE request_id=$1 FOR UPDATE', [request.requestId]); - if (prior.rowCount) { - const row = prior.rows[0]; - if (row.request_digest !== requestDigest || row.request_signature !== request.signature) throw new Error('Withdrawal request ID was reused with different terms'); - await client.query('COMMIT'); - return { requestId: row.request_id, custodianId: row.custodian_id, beneficiary: row.beneficiary, - destination: row.destination, amountAtomicXmr: row.amount_atomic_xmr, maxFeeAtomicXmr: row.max_fee_atomic_xmr, - status: row.status, createdAt: Number(row.created_at), cancelledAt: row.cancelled_at === null ? null : Number(row.cancelled_at) }; - } - if (!expiryIsValid(request.expiresAt, now)) throw new Error('Mainnet withdrawal request is expired or outside the allowed window'); - const hold = await client.query(`SELECT 1 FROM wk_custody.monero_deposit_credit_events d - JOIN wk_custody.monero_deposit_reorg_holds h ON h.credit_id=d.credit_id - LEFT JOIN wk_custody.monero_deposit_reorg_resolutions r ON r.hold_id=h.hold_id - WHERE d.beneficiary=$1 AND r.hold_id IS NULL LIMIT 1`, [request.beneficiary]); - if (hold.rowCount) throw new Error('Customer deposit is under reorg review; new withdrawals are held'); - const creditTotal = await client.query(`SELECT COALESCE(sum(amount_atomic_xmr),0)::text AS credited - FROM wk_custody.monero_deposit_release_events r - JOIN wk_custody.monero_deposit_credit_events c ON c.credit_id=r.credit_id - WHERE c.beneficiary=$1`, [request.beneficiary]); - const activeTotals = await client.query(`SELECT - COALESCE((SELECT sum(amount_atomic_xmr+max_fee_atomic_xmr) FROM wk_custody.monero_withdrawal_intents - WHERE beneficiary=$1 AND status='reserved'),0)::text AS reserved`, [request.beneficiary]); - const available = await client.query('SELECT balance::text,normal FROM wk_custody.accounts WHERE id=$1', - [availableAccount(request.beneficiary)]); - if (available.rowCount !== 1 || available.rows[0].normal !== 'credit' || BigInt(available.rows[0].balance) < total) { - throw new Error('Insufficient available mainnet custody balance'); - } - const alreadyReserved = BigInt(activeTotals.rows[0].reserved); - const backed = BigInt(creditTotal.rows[0].credited); - if (alreadyReserved + total > backed) throw new Error('Mainnet customer withdrawals exceed reviewed released deposit backing'); - const createdAt = now, reserveJournal = `monero-withdrawal-reserve:${request.requestId}`; - await insertJournal(client, reserveJournal, 'monero-withdrawal-reservation', now, - availableAccount(request.beneficiary), withdrawalAccount(request.beneficiary, request.requestId), total.toString()); - await client.query(`INSERT INTO wk_custody.monero_withdrawal_intents( - request_id,custodian_id,market_network,market_chain_id,beneficiary,destination,amount_atomic_xmr,max_fee_atomic_xmr, - public_key,expires_at,request_signature,request_digest,status,created_at) - VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'reserved',$13)`, - [request.requestId, request.custodianId, request.network, request.chainId, request.beneficiary, destination, - request.amountAtomicXmr, request.maxFeeAtomicXmr, request.publicKey, request.expiresAt, request.signature, - requestDigest, createdAt]); - await client.query('COMMIT'); - return { requestId: request.requestId, custodianId: request.custodianId, beneficiary: request.beneficiary, - destination, amountAtomicXmr: request.amountAtomicXmr, maxFeeAtomicXmr: request.maxFeeAtomicXmr, - status: 'reserved', createdAt, cancelledAt: null }; - } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } - finally { client.release(); } -} - -/** Customer may release a reservation only before any separate treasury preparation has occurred. */ -export async function cancelMainnetWithdrawal(pool: Pool, rawRequest: unknown, now = Date.now()): Promise { - const cancellation = mainnetWithdrawalCancellationSchema.parse(rawRequest); - if (address(cancellation.publicKey) !== cancellation.beneficiary || - !verifySignature(cancellation.publicKey, cancellation.beneficiary, - mainnetWithdrawalCancellationMessage(cancellation), cancellation.signature)) { - throw new Error('Mainnet withdrawal cancellation signature is invalid'); - } - const client = await pool.connect(); - try { - await client.query('BEGIN'); - await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on"); - await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]); - await mainnetIdentity(client, cancellation.custodianId, cancellation.chainId); - const result = await client.query('SELECT * FROM wk_custody.monero_withdrawal_intents WHERE request_id=$1 FOR UPDATE', - [cancellation.withdrawalRequestId]); - if (result.rowCount !== 1 || result.rows[0].beneficiary !== cancellation.beneficiary) { - throw new Error('Mainnet withdrawal request not found for this beneficiary'); - } - const row = result.rows[0]; - if (row.status === 'cancelled') { - if (row.cancel_request_id !== cancellation.cancellationId || row.cancel_signature !== cancellation.signature) { - throw new Error('Withdrawal was cancelled under different signed terms'); - } - await client.query('COMMIT'); - return { requestId: row.request_id, custodianId: row.custodian_id, beneficiary: row.beneficiary, - destination: row.destination, amountAtomicXmr: row.amount_atomic_xmr, maxFeeAtomicXmr: row.max_fee_atomic_xmr, - status: 'cancelled', createdAt: Number(row.created_at), cancelledAt: Number(row.cancelled_at) }; - } - if (!expiryIsValid(cancellation.expiresAt, now)) throw new Error('Mainnet withdrawal cancellation is expired or outside the allowed window'); - const amount = BigInt(row.amount_atomic_xmr), maxFee = BigInt(row.max_fee_atomic_xmr), total = amount + maxFee; - const cancelJournal = `monero-withdrawal-cancel:${row.request_id}`; - await insertJournal(client, cancelJournal, 'monero-withdrawal-cancellation', now, - withdrawalAccount(row.beneficiary, row.request_id), availableAccount(row.beneficiary), total.toString()); - await client.query(`UPDATE wk_custody.monero_withdrawal_intents SET status='cancelled',cancel_request_id=$2, - cancel_public_key=$3,cancel_expires_at=$4,cancel_signature=$5,cancelled_at=$6 WHERE request_id=$1`, - [row.request_id, cancellation.cancellationId, cancellation.publicKey, cancellation.expiresAt, cancellation.signature, now]); - await client.query('COMMIT'); - return { requestId: row.request_id, custodianId: row.custodian_id, beneficiary: row.beneficiary, - destination: row.destination, amountAtomicXmr: row.amount_atomic_xmr, maxFeeAtomicXmr: row.max_fee_atomic_xmr, - status: 'cancelled', createdAt: Number(row.created_at), cancelledAt: now }; - } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } - finally { client.release(); } -} - -/** Read the caller's own balances using a signed, expiring request; never accepts an actor from query parameters. */ -export async function readMainnetCustodyBalance(pool: Pool, rawRequest: unknown, now = Date.now()): Promise { - const request = mainnetCustodyBalanceRequestSchema.parse(rawRequest); - if (address(request.publicKey) !== request.beneficiary || - !verifySignature(request.publicKey, request.beneficiary, mainnetCustodyBalanceMessage(request), request.signature)) { - throw new Error('Mainnet custody balance signature is invalid'); - } - if (!expiryIsValid(request.expiresAt, now)) throw new Error('Mainnet custody balance request is expired or outside the allowed window'); - const client = await pool.connect(); - try { - await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'); - await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'"); - await mainnetIdentity(client, request.custodianId, request.chainId); - const available = await client.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1', - [availableAccount(request.beneficiary)]); - const pending = await client.query(`SELECT COALESCE(sum(balance),0)::text AS balance FROM wk_custody.accounts - WHERE id LIKE $1`, [`customer:${request.beneficiary}:deposit-pending:%`]); - const withdrawals = await client.query(`SELECT COALESCE(sum(amount_atomic_xmr+max_fee_atomic_xmr),0)::text AS balance - FROM wk_custody.monero_withdrawal_intents WHERE beneficiary=$1 AND status='reserved'`, [request.beneficiary]); - await client.query('COMMIT'); - return { availableAtomicXmr: available.rows[0]?.balance ?? '0', - depositPendingAtomicXmr: pending.rows[0].balance, - withdrawalReservedAtomicXmr: withdrawals.rows[0].balance }; - } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } - finally { client.release(); } -} diff --git a/server/market/monero-daemon-probe.ts b/server/market/monero-daemon-probe.ts deleted file mode 100755 index f87ffc1..0000000 --- a/server/market/monero-daemon-probe.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { lstat, readFile } from 'node:fs/promises'; -import { isIP } from 'node:net'; -import { randomUUID } from 'node:crypto'; -import { z } from 'zod'; - -const MAX_CONFIG_BYTES = 4096; -const MAX_RESPONSE_BYTES = 64 * 1024; -const RPC_TIMEOUT_MS = 5000; -const configSchema = z.object({ - network: z.enum(['mainnet', 'stagenet']), - url: z.string().min(1).max(2048), - allowPrivateHttp: z.boolean(), -}).strict(); -const infoSchema = z.object({ - status: z.literal('OK'), - nettype: z.enum(['mainnet', 'stagenet', 'testnet']), - mainnet: z.boolean(), stagenet: z.boolean(), testnet: z.boolean(), - synchronized: z.boolean(), busy_syncing: z.boolean(), offline: z.boolean(), untrusted: z.boolean(), - height: z.number().int().nonnegative().safe(), - height_without_bootstrap: z.number().int().nonnegative().safe(), - target_height: z.number().int().nonnegative().safe(), - version: z.string().min(1).max(80), - bootstrap_daemon_address: z.string().max(512), -}).passthrough(); -const envelopeSchema = z.object({ jsonrpc: z.literal('2.0'), id: z.string().uuid(), - result: z.record(z.string(), z.unknown()), error: z.never().optional() }).strict(); - -export type MoneroDaemonProbeConfig = z.infer; -export type MoneroDaemonProbeTransport = (url: string, init: RequestInit) => Promise; -export type MoneroDaemonProbeResult = { - network: 'mainnet' | 'stagenet'; - height: number; - version: string; - synchronized: true; - bootstrapProxyUsed: false; - checkedAt: string; -}; - -function isPrivateIp(host: string): boolean { - const version = isIP(host); - if (version === 4) { - const [a, b] = host.split('.').map(Number); - return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || - (a === 192 && b === 168) || (a === 169 && b === 254); - } - if (version === 6) { - const normalized = host.toLowerCase(); - return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe8') || - normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb'); - } - return false; -} - -function validateEndpoint(config: MoneroDaemonProbeConfig): URL { - let url: URL; - try { url = new URL(config.url); } catch { throw new Error('Invalid Monero daemon RPC endpoint'); } - if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || - url.pathname !== '/json_rpc' || !url.port) { - throw new Error('Monero daemon endpoint must be a bare /json_rpc URL with an explicit port'); - } - const host = url.hostname.replace(/^\[|\]$/g, ''); - if (!isPrivateIp(host)) throw new Error('Monero daemon endpoint must use a private literal IP address'); - if (url.protocol === 'http:' && !config.allowPrivateHttp) { - throw new Error('Plain HTTP daemon RPC requires explicit private-network acknowledgement'); - } - return url; -} - -export async function loadMoneroDaemonProbeConfig(env: NodeJS.ProcessEnv = process.env): Promise { - const path = env.WIKIDECK_MONERO_DAEMON_CONFIG_FILE; - if (!path) throw new Error('Protected Monero daemon probe config file is required'); - try { - const info = await lstat(path); - if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > MAX_CONFIG_BYTES) throw new Error(); - if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error(); - const config = configSchema.parse(JSON.parse(await readFile(path, 'utf8'))); - validateEndpoint(config); - return config; - } catch { - throw new Error('Invalid or inaccessible protected Monero daemon probe configuration'); - } -} - -async function readBounded(response: Response): Promise { - if (!response.body) throw new Error('Monero daemon RPC response body is missing'); - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let size = 0; - try { - for (;;) { - const part = await reader.read(); - if (part.done) break; - size += part.value.byteLength; - if (size > MAX_RESPONSE_BYTES) throw new Error('Monero daemon RPC response exceeds size limit'); - chunks.push(part.value); - } - } finally { - await reader.cancel().catch(() => {}); - reader.releaseLock(); - } - return Buffer.concat(chunks).toString('utf8'); -} - -/** Calls only monerod's read-only get_info method; no Wallet-RPC or transaction methods exist here. */ -export async function probeMoneroDaemon(configInput: unknown, transport: MoneroDaemonProbeTransport = fetch, - now = Date.now()): Promise { - const config = configSchema.parse(configInput); - const endpoint = validateEndpoint(config); - if (!Number.isSafeInteger(now) || now < 0) throw new Error('Invalid daemon probe time'); - const id = randomUUID(); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS); - timeout.unref(); - try { - let response: Response; - try { - response = await transport(endpoint.href, { method: 'POST', redirect: 'error', signal: controller.signal, - headers: { accept: 'application/json', 'content-type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id, method: 'get_info' }) }); - } catch { - throw new Error('Monero daemon RPC probe failed or timed out'); - } - if (response.status !== 200 || response.redirected || - !/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '')) { - await response.body?.cancel(); - throw new Error('Monero daemon RPC response rejected'); - } - let envelope: z.infer; - try { envelope = envelopeSchema.parse(JSON.parse(await readBounded(response))); } - catch { throw new Error('Invalid Monero daemon RPC response'); } - if (envelope.id !== id) throw new Error('Monero daemon RPC response ID mismatch'); - let info: z.infer; - try { info = infoSchema.parse(envelope.result); } - catch { throw new Error('Invalid Monero daemon RPC response'); } - if (info.nettype !== config.network || info.mainnet !== (config.network === 'mainnet') || - info.stagenet !== (config.network === 'stagenet') || info.testnet || - info.height < 1 || info.height_without_bootstrap !== info.height || info.untrusted || - info.offline || !info.synchronized || info.busy_syncing || info.bootstrap_daemon_address !== '') { - throw new Error('Monero daemon network or synchronization state is not acceptable'); - } - return { network: config.network, height: info.height, version: info.version, - synchronized: true, bootstrapProxyUsed: false, checkedAt: new Date(now).toISOString() }; - } finally { - clearTimeout(timeout); - } -} diff --git a/server/market/monero-deposit-allocation-store.ts b/server/market/monero-deposit-allocation-store.ts deleted file mode 100755 index 91e06cf..0000000 --- a/server/market/monero-deposit-allocation-store.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto'; -import type { Pool, PoolClient } from 'pg'; -import { z } from 'zod'; -import type { MoneroDepositAddressRpc } from './monero-observer.ts'; -import { validateXmrSubaddress } from './xmr.ts'; - -const uuid = z.string().uuid(); -const hex64 = z.string().regex(/^[a-f0-9]{64}$/); -const index = z.number().int().min(0).max(0xffff_ffff).safe(); -const reserveInputSchema = z.object({ - requestId: uuid, - custodianId: uuid, - marketNetwork: z.enum(['wikideck-randomx-mainnet-1', 'wikideck-randomx-testnet-6']), - marketChainId: hex64, - beneficiary: hex64, - walletGenerationId: uuid, - walletFingerprint: hex64, - accountIndex: index, - reservedAt: z.number().int().nonnegative().safe(), -}).strict(); -const registerInputSchema = z.object({ - allocationId: uuid, - walletGenerationId: uuid, - moneroAddress: z.string().length(95), - registeredAt: z.number().int().nonnegative().safe(), -}).strict(); - -export type MoneroDepositAllocation = { - allocationId: string; - requestId: string; - custodianId: string; - marketNetwork: 'wikideck-randomx-mainnet-1' | 'wikideck-randomx-testnet-6'; - marketChainId: string; - beneficiary: string; - walletGenerationId: string; - walletFingerprint: string; - moneroNetwork: 'mainnet'; - accountIndex: number; - addressIndex: number; - status: 'reserved' | 'registered'; - moneroAddress: string | null; - reservedAt: number; - registeredAt: number | null; -}; - -type AllocationRow = { - allocation_id: string; - request_id: string; - custodian_id: string; - market_network: 'wikideck-randomx-mainnet-1' | 'wikideck-randomx-testnet-6'; - market_chain_id: string; - beneficiary: string; - wallet_generation_id: string; - wallet_fingerprint: string; - monero_network: 'mainnet'; - account_index: string; - address_index: string; - status: 'reserved' | 'registered'; - monero_address: string | null; - reserved_at: string; - registered_at: string | null; - request_digest: string; -}; - -function toAllocation(row: AllocationRow): MoneroDepositAllocation { - return { - allocationId: row.allocation_id, - requestId: row.request_id, - custodianId: row.custodian_id, - marketNetwork: row.market_network, - marketChainId: row.market_chain_id, - beneficiary: row.beneficiary, - walletGenerationId: row.wallet_generation_id, - walletFingerprint: row.wallet_fingerprint, - moneroNetwork: row.monero_network, - accountIndex: Number(row.account_index), - addressIndex: Number(row.address_index), - status: row.status, - moneroAddress: row.monero_address, - reservedAt: Number(row.reserved_at), - registeredAt: row.registered_at === null ? null : Number(row.registered_at), - }; -} - -function requestDigest(input: z.infer): string { - return createHash('sha256').update(JSON.stringify([ - 'wikideck-monero-deposit-allocation-v1', input.custodianId, input.marketNetwork, - input.marketChainId, input.beneficiary, input.walletGenerationId, - input.walletFingerprint, input.accountIndex, - ])).digest('hex'); -} - -async function finishTransaction(client: PoolClient, commit: boolean): Promise { - if (commit) await client.query('COMMIT'); - else await client.query('ROLLBACK').catch(() => {}); -} - -/** - * Durably reserve a never-reused wallet subaddress index and bind it to a beneficiary before - * any wallet RPC address-generation side effect. This module is deliberately not wired to - * startup, HTTP, a wallet RPC, or financial posting. A reserved row is never reclaimed. - */ -export async function reserveMoneroDepositAllocation( - pool: Pool, - input: unknown, -): Promise<{ allocation: MoneroDepositAllocation; replayed: boolean }> { - const parsed = reserveInputSchema.parse(input); - const digest = requestDigest(parsed); - const client = await pool.connect(); - try { - await client.query('BEGIN'); - await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'"); - // Per-wallet/account serialization makes MAX(index)+1 safe across independent processes. - await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))', - [`wikideck-monero-deposit-allocation-v1:${parsed.walletGenerationId}:${parsed.accountIndex}`]); - - const byRequest = await client.query(`SELECT * FROM wk_custody.monero_deposit_allocations - WHERE request_id=$1 FOR UPDATE`, [parsed.requestId]); - if (byRequest.rowCount) { - if (byRequest.rows[0].request_digest !== digest) throw new Error('Deposit allocation request ID was reused with different terms'); - await finishTransaction(client, true); - return { allocation: toAllocation(byRequest.rows[0]), replayed: true }; - } - - const byBeneficiary = await client.query(`SELECT * FROM wk_custody.monero_deposit_allocations - WHERE custodian_id=$1 AND market_network=$2 AND market_chain_id=$3 AND beneficiary=$4 - AND wallet_generation_id=$5 FOR UPDATE`, [parsed.custodianId, parsed.marketNetwork, parsed.marketChainId, - parsed.beneficiary, parsed.walletGenerationId]); - if (byBeneficiary.rowCount) { - if (byBeneficiary.rows[0].request_digest !== digest) throw new Error('Beneficiary already has a different deposit allocation for this wallet generation'); - await finishTransaction(client, true); - return { allocation: toAllocation(byBeneficiary.rows[0]), replayed: true }; - } - - const next = await client.query<{ next_index: string }>(`SELECT (COALESCE(MAX(address_index),-1)+1)::text AS next_index - FROM wk_custody.monero_deposit_allocations WHERE wallet_generation_id=$1 AND account_index=$2`, - [parsed.walletGenerationId, parsed.accountIndex]); - const addressIndex = BigInt(next.rows[0].next_index); - if (addressIndex > 0xffff_ffffn) throw new Error('Monero subaddress index space is exhausted for this account'); - - const inserted = await client.query(`INSERT INTO wk_custody.monero_deposit_allocations( - allocation_id,request_id,custodian_id,market_network,market_chain_id,beneficiary,wallet_generation_id, - wallet_fingerprint,monero_network,account_index,address_index,status,request_digest,reserved_at) - VALUES($1,$2,$3,$4,$5,$6,$7,$8,'mainnet',$9,$10,'reserved',$11,$12) RETURNING *`, - [randomUUID(), parsed.requestId, parsed.custodianId, parsed.marketNetwork, parsed.marketChainId, - parsed.beneficiary, parsed.walletGenerationId, parsed.walletFingerprint, parsed.accountIndex, - addressIndex.toString(), digest, parsed.reservedAt]); - await finishTransaction(client, true); - return { allocation: toAllocation(inserted.rows[0]), replayed: false }; - } catch (error) { - await finishTransaction(client, false); - if (typeof error === 'object' && error !== null && 'code' in error && error.code === '23505') { - throw new Error('Deposit allocation conflicts with an existing beneficiary, wallet index, or address'); - } - throw error; - } finally { - client.release(); - } -} - -/** Register the address returned by a separately managed wallet operation exactly once. */ -export async function registerMoneroDepositSubaddress( - pool: Pool, - input: unknown, -): Promise<{ allocation: MoneroDepositAllocation; replayed: boolean }> { - const parsed = registerInputSchema.parse(input); - const address = await validateXmrSubaddress(parsed.moneroAddress); - const client = await pool.connect(); - try { - await client.query('BEGIN'); - await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'"); - const found = await client.query(`SELECT * FROM wk_custody.monero_deposit_allocations - WHERE allocation_id=$1 AND wallet_generation_id=$2 FOR UPDATE`, [parsed.allocationId, parsed.walletGenerationId]); - if (!found.rowCount) throw new Error('Deposit allocation reservation was not found for this wallet generation'); - const row = found.rows[0]; - if (row.status === 'registered') { - if (row.monero_address !== address) throw new Error('Registered deposit address cannot be replaced'); - await finishTransaction(client, true); - return { allocation: toAllocation(row), replayed: true }; - } - if (parsed.registeredAt < Number(row.reserved_at)) throw new Error('Deposit address registration predates its durable reservation'); - const registered = await client.query(`UPDATE wk_custody.monero_deposit_allocations - SET status='registered',monero_address=$3,registered_at=$4 - WHERE allocation_id=$1 AND wallet_generation_id=$2 RETURNING *`, - [parsed.allocationId, parsed.walletGenerationId, address, parsed.registeredAt]); - await finishTransaction(client, true); - return { allocation: toAllocation(registered.rows[0]), replayed: false }; - } catch (error) { - await finishTransaction(client, false); - if (typeof error === 'object' && error !== null && 'code' in error && error.code === '23505') { - throw new Error('Monero deposit address is already registered to an allocation'); - } - throw error; - } finally { - client.release(); - } -} - -/** - * Create or recover the wallet subaddress for a previously committed reservation. A stable - * wallet label makes a lost create_address response recoverable via get_address at the exact - * reserved index. The wallet RPC must be dedicated to this allocator: external address - * creation can consume indexes and is treated as a hard mismatch, never silently reassigned. - */ -export async function createOrRecoverMoneroDepositSubaddress( - pool: Pool, - rpc: MoneroDepositAddressRpc, - allocationId: string, - walletGenerationId: string, - registeredAt: number, -): Promise<{ allocation: MoneroDepositAllocation; replayed: boolean }> { - const parsedAllocationId = uuid.parse(allocationId); - const parsedGenerationId = uuid.parse(walletGenerationId); - if (!Number.isSafeInteger(registeredAt) || registeredAt < 0) throw new Error('Invalid deposit address registration time'); - - const client = await pool.connect(); - let address: string | undefined; - let label: string | undefined; - let lockKey: string | undefined; - let locked = false; - try { - const initial = await client.query(`SELECT * FROM wk_custody.monero_deposit_allocations - WHERE allocation_id=$1 AND wallet_generation_id=$2`, [parsedAllocationId, parsedGenerationId]); - if (!initial.rowCount) throw new Error('Deposit allocation reservation was not found for this wallet generation'); - if (initial.rows[0].status === 'registered') { - return { allocation: toAllocation(initial.rows[0]), replayed: true }; - } - const row = initial.rows[0]; - lockKey = `wikideck-monero-deposit-address-v1:${row.wallet_generation_id}:${row.account_index}`; - await client.query('SELECT pg_advisory_lock(hashtextextended($1,0))', [lockKey]); - locked = true; - - // Re-read after obtaining the per-wallet lock in case another process finished first. - const current = await client.query(`SELECT * FROM wk_custody.monero_deposit_allocations - WHERE allocation_id=$1 AND wallet_generation_id=$2`, [parsedAllocationId, parsedGenerationId]); - if (!current.rowCount) throw new Error('Deposit allocation reservation disappeared'); - if (current.rows[0].status === 'registered') { - return { allocation: toAllocation(current.rows[0]), replayed: true }; - } - const allocation = current.rows[0]; - const liveWalletFingerprint = await rpc.verifyWallet(); - if (liveWalletFingerprint !== allocation.wallet_fingerprint) { - throw new Error('Configured Monero wallet identity differs from the durable allocation generation'); - } - const expectedLabel = `wikideck-deposit-v1:${allocation.allocation_id}`; - const expectedIndex = Number(allocation.address_index); - const existing = await rpc.getSubaddress(Number(allocation.account_index), expectedIndex); - if (existing) { - if (existing.label !== expectedLabel) throw new Error('Reserved Monero subaddress index is occupied by a different wallet label'); - address = await validateXmrSubaddress(existing.address); - label = existing.label; - } else { - const created = await rpc.createSubaddress(Number(allocation.account_index), expectedLabel); - if (created.addressIndex !== expectedIndex) { - throw new Error('Monero wallet created an unexpected subaddress index; allocation requires operator reconciliation'); - } - const verified = await rpc.getSubaddress(Number(allocation.account_index), expectedIndex); - if (!verified || verified.label !== expectedLabel || verified.address !== created.address) { - throw new Error('Monero wallet subaddress could not be verified at its reserved index'); - } - address = await validateXmrSubaddress(verified.address); - label = verified.label; - } - } finally { - if (locked && lockKey) await client.query('SELECT pg_advisory_unlock(hashtextextended($1,0))', [lockKey]).catch(() => {}); - client.release(); - } - if (!address || !label) throw new Error('Monero deposit address allocation was not verified'); - return registerMoneroDepositSubaddress(pool, { allocationId: parsedAllocationId, - walletGenerationId: parsedGenerationId, moneroAddress: address, registeredAt }); -} diff --git a/server/market/monero-deposit-credit.ts b/server/market/monero-deposit-credit.ts deleted file mode 100755 index fc728bf..0000000 --- a/server/market/monero-deposit-credit.ts +++ /dev/null @@ -1,518 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto'; -import type { Pool, PoolClient } from 'pg'; -import { z } from 'zod'; -import { assertXmrRpcAmount, parseAtomicXmr } from '../../shared/xmr-amount.ts'; -import { address, verifySignature } from '../testnet/consensus.ts'; -import { CUSTODY_WRITE_LOCK } from './postgres-schema.ts'; - -const hex = z.string().regex(/^[a-f0-9]{64}$/); -const fingerprint = hex; -const publicKey = z.string().min(1).max(256); -const signature = z.string().min(1).max(256); -const uuid = z.string().uuid(); -const POLICY_VERSION = 'monero-pending-credit-v1'; -const MAX_EVENT_HISTORY = 256; - -export type DepositCreditPolicy = { - /** Reviewed key-owner hashes; the signing key remains operator-held and is never stored here. */ - approvedReviewerOwners: readonly string[]; - /** Provisional floor; a production value still requires explicit approval. */ - minimumConfirmations: number; - /** Refuse credit review when observation scans have gone stale. */ - maximumObservationAgeMs: number; -}; -export type DepositCreditReviewSubject = { - policyVersion: typeof POLICY_VERSION; - walletFingerprint: string; - observationId: string; - outputPublicKey: string; - amountAtomicXmr: string; - blockHeight: number; - confirmations: number; - minimumConfirmations: number; - locked: boolean; - doubleSpendSeen: boolean; - allocationId: string; - beneficiary: string; - observationDigest: string; - observationEventSequence: number; - observationEventHistoryDigest: string; - observerUpdatedAt: number; -}; -export type DepositCreditApproval = { - subject: DepositCreditReviewSubject; - reviewerOwner: string; - reviewerPublicKey: string; - nonce: string; - expiresAt: number; - signature: string; -}; -export type DepositCreditResult = { creditId: string; journalId: string; beneficiary: string; amountAtomicXmr: string; replayed: boolean }; -export type DepositCreditReleaseSubject = DepositCreditReviewSubject & { - creditId: string; - pendingJournalId: string; - unresolvedHolds: { holdId: string; eventSequence: number; eventType: 'changed' | 'not-seen' | 'reappeared' }[]; -}; -export type DepositCreditReleaseApproval = { - subject: DepositCreditReleaseSubject; - reviewerOwner: string; - reviewerPublicKey: string; - nonce: string; - expiresAt: number; - signature: string; -}; -export type DepositCreditReleaseResult = { creditId: string; releaseId: string; journalId: string; beneficiary: string; amountAtomicXmr: string; replayed: boolean }; -export type DepositReorgResolutionSubject = { - policyVersion: 'monero-reorg-resolution-v1'; - creditId: string; - releaseId: string; - holdId: string; - walletFingerprint: string; - observationId: string; - eventSequence: number; - eventType: 'changed' | 'not-seen' | 'reappeared'; - currentObservation: DepositCreditReviewSubject; -}; -export type DepositReorgResolutionApproval = { - subject: DepositReorgResolutionSubject; - reviewerOwner: string; - reviewerPublicKey: string; - nonce: string; - expiresAt: number; - signature: string; -}; -export type DepositReorgResolutionResult = { resolutionId: string; holdId: string; replayed: boolean }; - -const subjectSchema = z.object({ - policyVersion: z.literal(POLICY_VERSION), walletFingerprint: fingerprint, observationId: z.string().min(129).max(160), - outputPublicKey: hex, amountAtomicXmr: z.string().regex(/^[1-9]\d{0,19}$/), - blockHeight: z.number().int().positive().safe(), confirmations: z.number().int().positive().safe(), - minimumConfirmations: z.number().int().min(10).max(100_000), locked: z.boolean(), doubleSpendSeen: z.boolean(), - allocationId: uuid, beneficiary: hex, observationDigest: hex, - observationEventSequence: z.number().int().nonnegative().safe(), observationEventHistoryDigest: hex, - observerUpdatedAt: z.number().int().nonnegative().safe(), -}).strict(); -const approvalSchema = z.object({ subject: subjectSchema, reviewerOwner: hex, reviewerPublicKey: publicKey, - nonce: uuid, expiresAt: z.number().int().nonnegative().safe(), signature }).strict(); -const releaseSubjectSchema = subjectSchema.extend({ creditId: uuid, pendingJournalId: z.string().min(1).max(256), - unresolvedHolds: z.array(z.object({ holdId: uuid, eventSequence: z.number().int().positive().safe(), - eventType: z.enum(['changed', 'not-seen', 'reappeared']) }).strict()).max(1024) }).strict(); -const releaseApprovalSchema = z.object({ subject: releaseSubjectSchema, reviewerOwner: hex, reviewerPublicKey: publicKey, - nonce: uuid, expiresAt: z.number().int().nonnegative().safe(), signature }).strict(); -const resolutionSubjectSchema = z.object({ policyVersion: z.literal('monero-reorg-resolution-v1'), creditId: uuid, - releaseId: uuid, holdId: uuid, walletFingerprint: fingerprint, observationId: z.string().min(129).max(160), - eventSequence: z.number().int().positive().safe(), eventType: z.enum(['changed','not-seen','reappeared']), - currentObservation: subjectSchema }).strict(); -const resolutionApprovalSchema = z.object({ subject: resolutionSubjectSchema, reviewerOwner: hex, - reviewerPublicKey: publicKey, nonce: uuid, expiresAt: z.number().int().nonnegative().safe(), signature }).strict(); - -function digest(value: string): string { return createHash('sha256').update(value).digest('hex'); } -function canonicalReviewMessage(subject: DepositCreditReviewSubject, reviewerOwner: string, nonce: string, expiresAt: number): string { - return JSON.stringify(['wikideck-monero-pending-credit-review-v1', reviewerOwner, nonce, expiresAt, - subject.policyVersion, subject.walletFingerprint, subject.observationId, subject.outputPublicKey, - subject.amountAtomicXmr, subject.blockHeight, subject.confirmations, subject.minimumConfirmations, - subject.locked, subject.doubleSpendSeen, subject.allocationId, subject.beneficiary, - subject.observationDigest, subject.observationEventSequence, subject.observationEventHistoryDigest, - subject.observerUpdatedAt]); -} -function canonicalReleaseMessage(subject: DepositCreditReleaseSubject, reviewerOwner: string, nonce: string, expiresAt: number): string { - return JSON.stringify(['wikideck-monero-pending-release-v1', reviewerOwner, nonce, expiresAt, - subject.policyVersion, subject.creditId, subject.pendingJournalId, subject.walletFingerprint, subject.observationId, - subject.outputPublicKey, subject.amountAtomicXmr, subject.blockHeight, subject.confirmations, - subject.minimumConfirmations, subject.locked, subject.doubleSpendSeen, subject.allocationId, subject.beneficiary, - subject.observationDigest, subject.observationEventSequence, subject.observationEventHistoryDigest, - subject.observerUpdatedAt, subject.unresolvedHolds.map(hold => [hold.holdId, hold.eventSequence, hold.eventType])]); -} -function canonicalResolutionMessage(subject: DepositReorgResolutionSubject, reviewerOwner: string, nonce: string, expiresAt: number): string { - const observation = subject.currentObservation; - return JSON.stringify(['wikideck-monero-reorg-resolution-v1', reviewerOwner, nonce, expiresAt, - subject.policyVersion, subject.creditId, subject.releaseId, subject.holdId, subject.walletFingerprint, - subject.observationId, subject.eventSequence, subject.eventType, observation.outputPublicKey, - observation.amountAtomicXmr, observation.blockHeight, observation.confirmations, observation.minimumConfirmations, - observation.locked, observation.doubleSpendSeen, observation.allocationId, observation.beneficiary, - observation.observationDigest, observation.observationEventSequence, observation.observationEventHistoryDigest, - observation.observerUpdatedAt]); -} - -function validatePolicy(policy: DepositCreditPolicy): void { - if (!Array.isArray(policy.approvedReviewerOwners) || !policy.approvedReviewerOwners.length || - policy.approvedReviewerOwners.length > 8 || policy.approvedReviewerOwners.some(owner => !hex.safeParse(owner).success) || - !Number.isSafeInteger(policy.minimumConfirmations) || policy.minimumConfirmations < 10 || policy.minimumConfirmations > 100_000 || - !Number.isSafeInteger(policy.maximumObservationAgeMs) || policy.maximumObservationAgeMs < 10_000 || policy.maximumObservationAgeMs > 15 * 60_000) { - throw new Error('Invalid private Monero deposit-credit policy'); - } -} - -async function readReviewSubject(client: PoolClient, walletFingerprint: string, observationId: string, - policy: DepositCreditPolicy, now: number, allowReorgHistory = false): Promise { - const observation = await client.query(`SELECT o.wallet_fingerprint,o.observation_id,o.txid,o.monero_address,o.account_index, - o.address_index,o.output_public_key,o.amount_atomic_xmr::text,o.block_height,o.confirmations,o.locked, - o.double_spend_seen,o.payload_digest,o.currently_present,s.updated_at AS observer_updated_at - FROM wk_custody.monero_transfer_observations o - JOIN wk_custody.monero_observer_state s USING(wallet_fingerprint) - WHERE o.wallet_fingerprint=$1 AND o.observation_id=$2`, [walletFingerprint, observationId]); - if (observation.rowCount !== 1) throw new Error('Deposit observation not found'); - const row = observation.rows[0]; - if (!row.output_public_key || row.observation_id !== `${row.txid}:${row.output_public_key}` || !row.currently_present || - row.double_spend_seen || row.locked || Number(row.confirmations) < policy.minimumConfirmations) { - throw new Error('Deposit observation is not eligible for reviewed pending credit'); - } - if (now - Number(row.observer_updated_at) < 0 || now - Number(row.observer_updated_at) > policy.maximumObservationAgeMs) { - throw new Error('Monero observer state is stale'); - } - const amount = BigInt(row.amount_atomic_xmr); - assertXmrRpcAmount(amount); - parseAtomicXmr(amount.toString()); - - const allocation = await client.query(`SELECT allocation_id,beneficiary,custodian_id,market_network,market_chain_id - FROM wk_custody.monero_deposit_allocations - WHERE wallet_fingerprint=$1 AND account_index=$2 AND address_index=$3 AND monero_address=$4 AND status='registered'`, - [walletFingerprint, row.account_index, row.address_index, row.monero_address]); - if (allocation.rowCount !== 1) throw new Error('Deposit output has no unique registered beneficiary allocation'); - const identity = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'"); - if (identity.rowCount !== 1) throw new Error('Custodian identity is unavailable'); - const custodian = JSON.parse(identity.rows[0].value) as { id?: string; network?: string; chainId?: string; mode?: string; policy?: string }; - const mapped = allocation.rows[0]; - const validIdentity = (custodian.mode === 'sandbox' && custodian.network === 'wikideck-randomx-testnet-6' && - custodian.policy === 'custody-sandbox-v1' && mapped.market_network === 'wikideck-randomx-testnet-6') || - (custodian.mode === 'mainnet-custody' && custodian.network === 'wikideck-randomx-mainnet-1' && - custodian.policy === 'custody-mainnet-v1' && mapped.market_network === 'wikideck-randomx-mainnet-1'); - if (!validIdentity || mapped.custodian_id !== custodian.id || mapped.market_network !== custodian.network || - mapped.market_chain_id !== custodian.chainId) throw new Error('Deposit allocation is outside this custody identity'); - - const events = await client.query(`SELECT sequence,event_type,payload_digest,wallet_height,observed_at - FROM wk_custody.monero_observation_events WHERE wallet_fingerprint=$1 AND observation_id=$2 - ORDER BY sequence LIMIT $3`, [walletFingerprint, observationId, MAX_EVENT_HISTORY + 1]); - if ((events.rowCount ?? events.rows.length) > MAX_EVENT_HISTORY) throw new Error('Deposit observation event history exceeds review bound'); - if (!allowReorgHistory && events.rows.some(event => event.event_type === 'not-seen' || event.event_type === 'reappeared')) { - throw new Error('Deposit observation has prior disappearance/reappearance and requires separate review'); - } - const sequence = events.rows.length ? Number(events.rows.at(-1)!.sequence) : 0; - const eventDigest = digest(JSON.stringify(events.rows.map(event => [Number(event.sequence), event.event_type, - event.payload_digest, Number(event.wallet_height), Number(event.observed_at)]))); - return { - policyVersion: POLICY_VERSION, walletFingerprint, observationId, outputPublicKey: row.output_public_key, - amountAtomicXmr: amount.toString(), blockHeight: Number(row.block_height), confirmations: Number(row.confirmations), - minimumConfirmations: policy.minimumConfirmations, locked: row.locked, doubleSpendSeen: row.double_spend_seen, - allocationId: mapped.allocation_id, beneficiary: mapped.beneficiary, observationDigest: row.payload_digest, - observationEventSequence: sequence, observationEventHistoryDigest: eventDigest, - observerUpdatedAt: Number(row.observer_updated_at), - }; -} - -/** Return the exact current snapshot an authorized operator must review and sign. */ -export async function prepareDepositCreditReview(pool: Pool, walletFingerprint: string, observationId: string, - policy: DepositCreditPolicy, now = Date.now()): Promise { - validatePolicy(policy); fingerprint.parse(walletFingerprint); - const c = await pool.connect(); - try { - await c.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'); - await c.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'"); - const subject = await readReviewSubject(c, walletFingerprint, observationId, policy, now); - await c.query('COMMIT'); return subject; - } catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; } - finally { c.release(); } -} - -/** Canonical message an allow-listed reviewer signs after examining the exact output/allocation snapshot. */ -export function depositCreditApprovalMessage(subject: DepositCreditReviewSubject, reviewerOwner: string, nonce: string, - expiresAt: number): string { - return canonicalReviewMessage(subjectSchema.parse(subject), hex.parse(reviewerOwner), uuid.parse(nonce), expiresAt); -} - -/** - * Posts an operator-reviewed credit into a non-spendable pending-liability account. This service is not - * wired to HTTP/startup; release to available balance is a separate, not-yet-implemented approval step. - */ -export async function postReviewedDepositPendingCredit(pool: Pool, rawApproval: DepositCreditApproval, - policy: DepositCreditPolicy, now = Date.now()): Promise { - validatePolicy(policy); - const approval = approvalSchema.parse(rawApproval); - if (!policy.approvedReviewerOwners.includes(approval.reviewerOwner) || - address(approval.reviewerPublicKey) !== approval.reviewerOwner || - !verifySignature(approval.reviewerPublicKey, approval.reviewerOwner, - canonicalReviewMessage(approval.subject, approval.reviewerOwner, approval.nonce, approval.expiresAt), approval.signature)) { - throw new Error('Deposit credit review signature is invalid'); - } - const reviewDigest = digest(canonicalReviewMessage(approval.subject, approval.reviewerOwner, approval.nonce, approval.expiresAt)); - const client = await pool.connect(); - try { - await client.query('BEGIN'); - await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'; SET LOCAL synchronous_commit=on"); - await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]); - const existing = await client.query(`SELECT credit_id,journal_id,beneficiary,amount_atomic_xmr::text,reviewer_owner, - review_nonce,observation_digest,allocation_id::text,reviewer_public_key,review_expires_at,review_signature, - minimum_confirmations,review_digest FROM wk_custody.monero_deposit_credit_events - WHERE wallet_fingerprint=$1 AND observation_id=$2`, - [approval.subject.walletFingerprint, approval.subject.observationId]); - if (existing.rowCount) { - const saved = existing.rows[0]; - if (saved.reviewer_owner !== approval.reviewerOwner || saved.review_nonce !== approval.nonce || - saved.observation_digest !== approval.subject.observationDigest || saved.amount_atomic_xmr !== approval.subject.amountAtomicXmr || - saved.allocation_id !== approval.subject.allocationId || saved.minimum_confirmations !== approval.subject.minimumConfirmations || - saved.reviewer_public_key !== approval.reviewerPublicKey || Number(saved.review_expires_at) !== approval.expiresAt || - saved.review_signature !== approval.signature || saved.review_digest !== reviewDigest) { - throw new Error('Deposit output was already staged under different reviewed terms'); - } - await client.query('COMMIT'); - return { creditId: saved.credit_id, journalId: saved.journal_id, beneficiary: saved.beneficiary, - amountAtomicXmr: saved.amount_atomic_xmr, replayed: true }; - } - if (approval.expiresAt < now || approval.expiresAt > now + 5 * 60_000) { - throw new Error('Deposit credit review signature is expired or outside the allowed window'); - } - const current = await readReviewSubject(client, approval.subject.walletFingerprint, approval.subject.observationId, policy, now); - if (JSON.stringify(current) !== JSON.stringify(approval.subject)) throw new Error('Deposit observation changed after reviewer approval'); - const creditId = randomUUID(); - const journalId = `monero-deposit-pending:${digest(`${current.walletFingerprint}:${current.observationId}`)}`; - const assetAccount = 'treasury:monero:pending'; - const liabilityAccount = `customer:${current.beneficiary}:deposit-pending:${creditId}`; - await client.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)', - [journalId, 'monero-deposit-pending-credit', now]); - const lines = [[0, assetAccount, 'debit'], [1, liabilityAccount, 'credit']] as const; - for (const [position, accountId, normal] of lines) { - await client.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,$2) ON CONFLICT(id) DO NOTHING', [accountId, normal]); - const account = await client.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [accountId]); - if (account.rows[0]?.normal !== normal) throw new Error('Deposit pending account normal mismatch'); - await client.query('INSERT INTO wk_custody.entries VALUES($1,$2,$3,$4,$5)', - [journalId, position, accountId, normal === 'debit' ? current.amountAtomicXmr : '0', - normal === 'credit' ? current.amountAtomicXmr : '0']); - } - await client.query(`INSERT INTO wk_custody.monero_deposit_credit_events( - credit_id,wallet_fingerprint,observation_id,allocation_id,beneficiary,amount_atomic_xmr,journal_id, - observation_digest,last_observation_event_sequence,reviewer_owner,reviewer_public_key,review_nonce, - review_expires_at,review_signature,review_subject,review_digest,credited_at,minimum_confirmations) - VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`, - [creditId, current.walletFingerprint, current.observationId, current.allocationId, current.beneficiary, - current.amountAtomicXmr, journalId, current.observationDigest, current.observationEventSequence, - approval.reviewerOwner, approval.reviewerPublicKey, approval.nonce, approval.expiresAt, approval.signature, - JSON.stringify(current), reviewDigest, now, current.minimumConfirmations]); - await client.query('COMMIT'); - return { creditId, journalId, beneficiary: current.beneficiary, amountAtomicXmr: current.amountAtomicXmr, replayed: false }; - } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } - finally { client.release(); } -} - -async function readReleaseSubject(client: PoolClient, creditId: string, policy: DepositCreditPolicy, - now: number): Promise { - const credit = await client.query(`SELECT credit_id::text,wallet_fingerprint,observation_id,beneficiary, - amount_atomic_xmr::text,journal_id FROM wk_custody.monero_deposit_credit_events WHERE credit_id=$1`, [creditId]); - if (credit.rowCount !== 1) throw new Error('Pending deposit credit not found'); - const row = credit.rows[0]; - if (await client.query('SELECT 1 FROM wk_custody.monero_deposit_release_events WHERE credit_id=$1', [creditId]).then(r => r.rowCount)) { - throw new Error('Pending deposit credit was already released'); - } - const observation = await readReviewSubject(client, row.wallet_fingerprint, row.observation_id, policy, now, true); - if (observation.beneficiary !== row.beneficiary || observation.amountAtomicXmr !== row.amount_atomic_xmr) { - throw new Error('Pending credit no longer matches the current observed output'); - } - const holds = await client.query(`SELECT h.hold_id::text,h.observation_event_sequence::text,h.event_type - FROM wk_custody.monero_deposit_reorg_holds h - WHERE h.credit_id=$1 AND NOT EXISTS ( - SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions r WHERE r.hold_id=h.hold_id) - ORDER BY h.observation_event_sequence LIMIT 1025`, [creditId]); - if (holds.rows.length > 1024) throw new Error('Deposit reorg hold set exceeds review bound'); - return { ...observation, creditId, pendingJournalId: row.journal_id, - unresolvedHolds: holds.rows.map(hold => ({ holdId: hold.hold_id, - eventSequence: Number(hold.observation_event_sequence), eventType: hold.event_type })) }; -} - -/** Return the current pending liability and unresolved reorg evidence for explicit reviewer approval. */ -export async function prepareDepositCreditReleaseReview(pool: Pool, creditId: string, - policy: DepositCreditPolicy, now = Date.now()): Promise { - validatePolicy(policy); uuid.parse(creditId); - const client = await pool.connect(); - try { - await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'); - await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'"); - const subject = await readReleaseSubject(client, creditId, policy, now); - await client.query('COMMIT'); return subject; - } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } - finally { client.release(); } -} - -/** Canonical reviewer message for releasing a pending liability and resolving its included scoped holds. */ -export function depositCreditReleaseApprovalMessage(subject: DepositCreditReleaseSubject, reviewerOwner: string, - nonce: string, expiresAt: number): string { - return canonicalReleaseMessage(releaseSubjectSchema.parse(subject), hex.parse(reviewerOwner), uuid.parse(nonce), expiresAt); -} - -/** Explicit reviewer-authorized, idempotent move from pending to spendable available balance. Not HTTP-wired. */ -export async function releaseReviewedDepositCredit(pool: Pool, rawApproval: DepositCreditReleaseApproval, - policy: DepositCreditPolicy, now = Date.now()): Promise { - validatePolicy(policy); - const approval = releaseApprovalSchema.parse(rawApproval); - const message = canonicalReleaseMessage(approval.subject, approval.reviewerOwner, approval.nonce, approval.expiresAt); - if (!policy.approvedReviewerOwners.includes(approval.reviewerOwner) || - address(approval.reviewerPublicKey) !== approval.reviewerOwner || - !verifySignature(approval.reviewerPublicKey, approval.reviewerOwner, message, approval.signature)) { - throw new Error('Deposit release review signature is invalid'); - } - const reviewDigest = digest(message); - const client = await pool.connect(); - try { - await client.query('BEGIN'); - await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'; SET LOCAL synchronous_commit=on"); - await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]); - const existing = await client.query(`SELECT release_id,credit_id::text,journal_id,reviewer_owner,review_nonce, - review_signature,review_digest,review_subject,released_at FROM wk_custody.monero_deposit_release_events - WHERE credit_id=$1`, [approval.subject.creditId]); - if (existing.rowCount) { - const saved = existing.rows[0]; - if (saved.reviewer_owner !== approval.reviewerOwner || saved.review_nonce !== approval.nonce || - saved.review_signature !== approval.signature || saved.review_digest !== reviewDigest) { - throw new Error('Deposit release was already committed under different reviewed terms'); - } - const credit = await client.query(`SELECT beneficiary,amount_atomic_xmr::text FROM wk_custody.monero_deposit_credit_events - WHERE credit_id=$1`, [saved.credit_id]); - await client.query('COMMIT'); - return { creditId: saved.credit_id, releaseId: saved.release_id, journalId: saved.journal_id, - beneficiary: credit.rows[0].beneficiary, amountAtomicXmr: credit.rows[0].amount_atomic_xmr, replayed: true }; - } - if (approval.expiresAt < now || approval.expiresAt > now + 5 * 60_000) { - throw new Error('Deposit release review signature is expired or outside the allowed window'); - } - const current = await readReleaseSubject(client, approval.subject.creditId, policy, now); - if (JSON.stringify(current) !== JSON.stringify(approval.subject)) throw new Error('Deposit release evidence changed after reviewer approval'); - const pendingAccount = `customer:${current.beneficiary}:deposit-pending:${current.creditId}`; - const availableAccount = `customer:${current.beneficiary}:available`; - const pending = await client.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [pendingAccount]); - if (pending.rowCount !== 1 || pending.rows[0].normal !== 'credit') { - throw new Error('Pending deposit liability does not match reviewed release amount'); - } - await client.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,\'credit\') ON CONFLICT(id) DO NOTHING', [availableAccount]); - const available = await client.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [availableAccount]); - if (available.rows[0]?.normal !== 'credit') throw new Error('Available customer account normal mismatch'); - - const releaseId = randomUUID(), journalId = `monero-deposit-release:${current.creditId}`; - await client.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)', - [journalId, 'monero-deposit-release', now]); - await client.query('INSERT INTO wk_custody.entries VALUES($1,0,$2,$3,0),($1,1,$4,0,$3)', - [journalId, pendingAccount, current.amountAtomicXmr, availableAccount]); - await client.query(`INSERT INTO wk_custody.monero_deposit_release_events( - release_id,credit_id,journal_id,reviewer_owner,reviewer_public_key,review_nonce,review_expires_at, - review_signature,review_subject,review_digest,released_at) - VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, - [releaseId, current.creditId, journalId, approval.reviewerOwner, approval.reviewerPublicKey, approval.nonce, - approval.expiresAt, approval.signature, JSON.stringify(current), reviewDigest, now]); - for (const hold of current.unresolvedHolds) { - await client.query(`INSERT INTO wk_custody.monero_deposit_reorg_resolutions( - resolution_id,hold_id,credit_id,release_id,resolved_by,resolved_at,disposition,resolution_kind, - reviewer_public_key,review_nonce,review_expires_at,review_signature,review_subject,review_digest) - VALUES($1,$2,$3,$4,$5,$6,'revalidated-for-release','release-review',$7,$8,$9,$10,$11,$12)`, - [randomUUID(), hold.holdId, current.creditId, releaseId, approval.reviewerOwner, now, - approval.reviewerPublicKey, approval.nonce, approval.expiresAt, approval.signature, - JSON.stringify({ subject: current, holdId: hold.holdId }), reviewDigest]); - } - await client.query('COMMIT'); - return { creditId: current.creditId, releaseId, journalId, beneficiary: current.beneficiary, - amountAtomicXmr: current.amountAtomicXmr, replayed: false }; - } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } - finally { client.release(); } -} - -async function readReorgResolutionSubject(client: PoolClient, creditId: string, holdId: string, - policy: DepositCreditPolicy, now: number): Promise { - const row = await client.query(`SELECT h.hold_id::text,h.credit_id::text,h.wallet_fingerprint,h.observation_id, - h.observation_event_sequence::text,h.event_type,c.release_id::text - FROM wk_custody.monero_deposit_reorg_holds h - JOIN wk_custody.monero_deposit_release_events c ON c.credit_id=h.credit_id - WHERE h.hold_id=$1 AND h.credit_id=$2 AND NOT EXISTS( - SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions r WHERE r.hold_id=h.hold_id)`, [holdId, creditId]); - if (row.rowCount !== 1) throw new Error('Unresolved released-deposit reorg hold not found'); - const currentObservation = await readReviewSubject(client, row.rows[0].wallet_fingerprint, - row.rows[0].observation_id, policy, now, true); - return resolutionSubjectSchema.parse({ policyVersion: 'monero-reorg-resolution-v1', creditId, - releaseId: row.rows[0].release_id, holdId, walletFingerprint: row.rows[0].wallet_fingerprint, - observationId: row.rows[0].observation_id, eventSequence: Number(row.rows[0].observation_event_sequence), - eventType: row.rows[0].event_type, currentObservation }); -} - -/** Prepare an exact current snapshot to resolve one scoped reorg hold after the output reappears. */ -export async function prepareDepositReorgResolution(pool: Pool, creditId: string, holdId: string, - policy: DepositCreditPolicy, now = Date.now()): Promise { - validatePolicy(policy); uuid.parse(creditId); uuid.parse(holdId); - const client = await pool.connect(); - try { - await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'); - await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'"); - const subject = await readReorgResolutionSubject(client, creditId, holdId, policy, now); - await client.query('COMMIT'); return subject; - } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } - finally { client.release(); } -} - -export function depositReorgResolutionMessage(subject: DepositReorgResolutionSubject, reviewerOwner: string, - nonce: string, expiresAt: number): string { - return canonicalResolutionMessage(resolutionSubjectSchema.parse(subject), hex.parse(reviewerOwner), uuid.parse(nonce), expiresAt); -} - -/** Resolve a reorg hold only after the output is again present, unlocked, sufficiently confirmed, and signed off. */ -export async function resolveReleasedDepositReorgHold(pool: Pool, rawApproval: DepositReorgResolutionApproval, - policy: DepositCreditPolicy, now = Date.now()): Promise { - validatePolicy(policy); - const approval = resolutionApprovalSchema.parse(rawApproval); - const message = canonicalResolutionMessage(approval.subject, approval.reviewerOwner, approval.nonce, approval.expiresAt); - if (!policy.approvedReviewerOwners.includes(approval.reviewerOwner) || - address(approval.reviewerPublicKey) !== approval.reviewerOwner || - !verifySignature(approval.reviewerPublicKey, approval.reviewerOwner, message, approval.signature)) { - throw new Error('Deposit reorg resolution signature is invalid'); - } - const reviewDigest = digest(message), client = await pool.connect(); - try { - await client.query('BEGIN'); - await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on"); - await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]); - const existing = await client.query(`SELECT resolution_id::text,review_nonce,review_signature,review_digest - FROM wk_custody.monero_deposit_reorg_resolutions WHERE hold_id=$1`, [approval.subject.holdId]); - if (existing.rowCount) { - const saved = existing.rows[0]; - if (saved.review_nonce !== approval.nonce || saved.review_signature !== approval.signature || saved.review_digest !== reviewDigest) { - throw new Error('Deposit reorg hold was already resolved under different reviewed terms'); - } - await client.query('COMMIT'); return { resolutionId: saved.resolution_id, holdId: approval.subject.holdId, replayed: true }; - } - if (approval.expiresAt < now || approval.expiresAt > now + 5 * 60_000) { - throw new Error('Deposit reorg resolution signature is expired or outside the allowed window'); - } - const current = await readReorgResolutionSubject(client, approval.subject.creditId, approval.subject.holdId, policy, now); - if (JSON.stringify(current) !== JSON.stringify(approval.subject)) throw new Error('Deposit reorg evidence changed after review'); - const resolutionId = randomUUID(); - await client.query(`INSERT INTO wk_custody.monero_deposit_reorg_resolutions( - resolution_id,hold_id,credit_id,release_id,resolved_by,resolved_at,disposition,resolution_kind, - reviewer_public_key,review_nonce,review_expires_at,review_signature,review_subject,review_digest) - VALUES($1,$2,$3,$4,$5,$6,'revalidated-for-release','post-release-review',$7,$8,$9,$10,$11,$12)`, - [resolutionId, approval.subject.holdId, approval.subject.creditId, approval.subject.releaseId, - approval.reviewerOwner, now, approval.reviewerPublicKey, approval.nonce, approval.expiresAt, - approval.signature, JSON.stringify(approval.subject), reviewDigest]); - await client.query('COMMIT'); return { resolutionId, holdId: approval.subject.holdId, replayed: false }; - } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } - finally { client.release(); } -} - -/** Append review holds for post-credit Monero reorg signals; this never reverses a journal or releases pending funds. */ -export async function reconcileDepositCreditReorgHolds(pool: Pool, walletFingerprint: string, now = Date.now()): Promise { - fingerprint.parse(walletFingerprint); - const client = await pool.connect(); - try { - await client.query('BEGIN'); - await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on"); - await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]); - const reorgs = await client.query(`SELECT c.credit_id,c.observation_id,e.sequence,e.event_type - FROM wk_custody.monero_deposit_credit_events c - JOIN wk_custody.monero_observation_events e ON e.wallet_fingerprint=c.wallet_fingerprint - AND e.observation_id=c.observation_id AND e.sequence>c.last_observation_event_sequence - WHERE c.wallet_fingerprint=$1 AND e.event_type IN ('changed','not-seen','reappeared') - AND NOT EXISTS (SELECT 1 FROM wk_custody.monero_deposit_reorg_holds h - WHERE h.credit_id=c.credit_id AND h.observation_event_sequence=e.sequence) - ORDER BY e.sequence LIMIT 1024`, [walletFingerprint]); - for (const row of reorgs.rows) { - await client.query(`INSERT INTO wk_custody.monero_deposit_reorg_holds( - hold_id,credit_id,wallet_fingerprint,observation_id,observation_event_sequence,event_type,created_at) - VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(credit_id,observation_event_sequence) DO NOTHING`, - [randomUUID(), row.credit_id, walletFingerprint, row.observation_id, row.sequence, row.event_type, now]); - } - await client.query('COMMIT'); return reorgs.rowCount ?? 0; - } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } - finally { client.release(); } -} diff --git a/server/market/monero-deposit-enrollment.ts b/server/market/monero-deposit-enrollment.ts deleted file mode 100755 index a7e4e58..0000000 --- a/server/market/monero-deposit-enrollment.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { Pool } from 'pg'; -import { address, verifySignature } from '../testnet/consensus.ts'; -import { moneroDepositAddressRequestSchema, moneroDepositAddressMessage } from '../../shared/monero-deposit.ts'; -import { createOrRecoverMoneroDepositSubaddress, reserveMoneroDepositAllocation } from './monero-deposit-allocation-store.ts'; -import type { MoneroDepositAddressRpc } from './monero-observer.ts'; - -const MAX_REQUEST_LIFETIME_MS = 5 * 60_000; - -export type MainnetEnrollmentContext = { - custodianId: string; - chainId: string; - walletGenerationId: string; - walletFingerprint: string; - accountIndex: number; - now?: () => number; -}; - -export type MoneroDepositAddressResult = { - network: 'mainnet'; - allocationId: string; - walletGenerationId: string; - addressIndex: number; - address: string; - replayed: boolean; -}; - -/** - * Verify an expiring customer-wallet signature and durably allocate that customer's one mainnet - * subaddress. This only enrolls an address; it never credits a balance, starts a sale, or moves XMR. - * Do not expose a public route until the matching credit, withdrawal, and recovery paths are ready. - */ -export async function enrollMoneroDepositAddress(pool: Pool, rpc: MoneroDepositAddressRpc, - context: MainnetEnrollmentContext, rawRequest: unknown): Promise { - const request = moneroDepositAddressRequestSchema.parse(rawRequest); - if (request.network !== 'wikideck-randomx-mainnet-1' || request.custodianId !== context.custodianId || - request.chainId !== context.chainId || request.walletGenerationId !== context.walletGenerationId || - request.walletFingerprint !== context.walletFingerprint || request.accountIndex !== context.accountIndex) { - throw new Error('Deposit address request does not match this mainnet custodian and wallet generation'); - } - if (address(request.publicKey) !== request.actor) throw new Error('Deposit address signer does not own the requested beneficiary'); - const { signature, ...unsigned } = request; - const message = moneroDepositAddressMessage(unsigned); - if (!verifySignature(request.publicKey, request.actor, message, signature)) { - throw new Error('Deposit address request signature is invalid'); - } - const now = context.now?.() ?? Date.now(); - if (!Number.isSafeInteger(now) || now < 0 || request.expiresAt < now || - request.expiresAt > now + MAX_REQUEST_LIFETIME_MS) { - throw new Error('Deposit address request is expired or outside the allowed window'); - } - - const { allocation } = await reserveMoneroDepositAllocation(pool, { - requestId: request.requestId, - custodianId: context.custodianId, - marketNetwork: 'wikideck-randomx-mainnet-1', - marketChainId: context.chainId, - beneficiary: request.actor, - walletGenerationId: context.walletGenerationId, - walletFingerprint: context.walletFingerprint, - accountIndex: context.accountIndex, - reservedAt: now, - }); - const registered = await createOrRecoverMoneroDepositSubaddress(pool, rpc, allocation.allocationId, - context.walletGenerationId, now); - if (registered.allocation.beneficiary !== request.actor || registered.allocation.custodianId !== context.custodianId || - registered.allocation.marketChainId !== context.chainId || registered.allocation.moneroNetwork !== 'mainnet' || - registered.allocation.status !== 'registered' || !registered.allocation.moneroAddress) { - throw new Error('Registered Monero deposit address does not match the signed beneficiary allocation'); - } - return { network: 'mainnet', allocationId: registered.allocation.allocationId, - walletGenerationId: registered.allocation.walletGenerationId, - addressIndex: registered.allocation.addressIndex, address: registered.allocation.moneroAddress, - replayed: registered.replayed }; -} diff --git a/server/market/monero-deposit-review-config.ts b/server/market/monero-deposit-review-config.ts deleted file mode 100755 index 5072446..0000000 --- a/server/market/monero-deposit-review-config.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { constants } from 'node:fs'; -import { open } from 'node:fs/promises'; -import { isAbsolute } from 'node:path'; -import { z } from 'zod'; -import type { DepositCreditPolicy } from './monero-deposit-credit.ts'; - -const MAX_REVIEW_CONFIG_BYTES = 4096; -const MAX_APPROVAL_BYTES = 64 * 1024; -const reviewerPolicySchema = z.object({ - approvedReviewerOwners: z.array(z.string().regex(/^[a-f0-9]{64}$/)).min(1).max(8), - minimumConfirmations: z.number().int().min(10).max(100_000), - maximumObservationAgeMs: z.number().int().min(10_000).max(15 * 60_000), -}).strict().superRefine((value, context) => { - if (new Set(value.approvedReviewerOwners).size !== value.approvedReviewerOwners.length) { - context.addIssue({ code: 'custom', message: 'Reviewer owner allowlist must not contain duplicates' }); - } -}); - -async function readPrivateJson(path: string, maximumBytes: number): Promise { - if (process.platform === 'win32') throw new Error('Owner-only ACL verification is unavailable on Windows'); - if (!isAbsolute(path)) throw new Error('Review files require an absolute path'); - const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); - try { - const info = await file.stat(); - if (!info.isFile() || info.size < 1 || info.size > maximumBytes) { - throw new Error('Review file must be a bounded regular file'); - } - if ((info.mode & 0o077) !== 0) { - throw new Error('Review file permissions must be owner-only'); - } - const chunks: Buffer[] = []; - let total = 0, position = 0; - while (total <= maximumBytes) { - const chunk = Buffer.allocUnsafe(Math.min(4096, maximumBytes + 1 - total)); - const { bytesRead } = await file.read(chunk, 0, chunk.length, position); - if (bytesRead === 0) break; - total += bytesRead; position += bytesRead; - if (total > maximumBytes) throw new Error('Review file exceeds byte limit'); - chunks.push(chunk.subarray(0, bytesRead)); - } - const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)); - return JSON.parse(text) as unknown; - } finally { await file.close(); } -} - -/** Public keys/policy only; no reviewer private key is loaded by the application. */ -export async function loadMoneroDepositReviewPolicy(env: NodeJS.ProcessEnv = process.env): Promise { - const path = env.WIKIDECK_MONERO_DEPOSIT_REVIEW_POLICY_FILE; - if (!path) throw new Error('Monero deposit review policy file is required'); - try { return reviewerPolicySchema.parse(await readPrivateJson(path, MAX_REVIEW_CONFIG_BYTES)); } - catch { throw new Error('Invalid or inaccessible protected Monero deposit review policy'); } -} - -/** Signed approvals are bearer authorizations, so read only owner-protected regular files. */ -export async function loadMoneroDepositReviewApproval(path: string): Promise { - try { return await readPrivateJson(path, MAX_APPROVAL_BYTES); } - catch { throw new Error('Invalid or inaccessible protected Monero deposit review approval'); } -} diff --git a/server/market/monero-mtls-transport.ts b/server/market/monero-mtls-transport.ts deleted file mode 100755 index 2d2986c..0000000 --- a/server/market/monero-mtls-transport.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { lstat, readFile } from 'node:fs/promises'; -import { request as httpsRequest } from 'node:https'; -import { isIP } from 'node:net'; -import { z } from 'zod'; -import type { MoneroFixtureTransport } from './monero-observer.ts'; - -const MAX_TLS_FILE_BYTES = 64 * 1024; -const MAX_RPC_REQUEST_BYTES = 64 * 1024; -const MAX_RPC_RESPONSE_BYTES = 1024 * 1024; -const transportConfigSchema = z.object({ - url: z.string().url().max(2048), - clientCertificateFile: z.string().min(1).max(4096), - clientPrivateKeyFile: z.string().min(1).max(4096), - serverCaFile: z.string().min(1).max(4096), -}).strict(); - -export async function loadMoneroMtlsTransportConfig(env: NodeJS.ProcessEnv = process.env) { - const configPath = env.WIKIDECK_MONERO_MTLS_CONFIG_FILE; - if (!configPath) throw new Error('Monero mTLS transport config file is required'); - try { - const info = await lstat(configPath); - if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > MAX_TLS_FILE_BYTES) throw new Error(); - if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error(); - const parsed = transportConfigSchema.parse(JSON.parse(await readFile(configPath, 'utf8'))); - const url = new URL(parsed.url); - if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/json_rpc') throw new Error(); - for (const path of [parsed.clientCertificateFile, parsed.clientPrivateKeyFile, parsed.serverCaFile]) { - if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) throw new Error(); - } - return parsed; - } catch { - throw new Error('Invalid or inaccessible Monero mTLS configuration'); - } -} - -async function readProtectedPem(path: string): Promise { - const info = await lstat(path); - if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > MAX_TLS_FILE_BYTES) { - throw new Error('Monero RPC TLS material must be a bounded regular file'); - } - if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) { - throw new Error('Monero RPC TLS material permissions are too broad'); - } - return readFile(path); -} - -/** - * Create an HTTPS mutual-TLS transport for a private Wallet-RPC gateway. This replaces - * Monero v0.18.5.4's legacy HTTP Digest authentication without implementing MD5 in the app. - * The gateway is responsible for certificate authorization and a strict Wallet-RPC method - * allowlist; this transport never adds Basic/Digest Authorization headers or follows redirects. - */ -export async function loadMoneroMtlsTransport(configInput: unknown): Promise { - const config = transportConfigSchema.parse(configInput); - const url = new URL(config.url); - if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/json_rpc') { - throw new Error('Monero mTLS gateway must be a bare HTTPS /json_rpc endpoint'); - } - const [cert, key, ca] = await Promise.all([ - readProtectedPem(config.clientCertificateFile), - readProtectedPem(config.clientPrivateKeyFile), - readProtectedPem(config.serverCaFile), - ]); - - return async (endpoint, init) => { - const target = new URL(endpoint); - if (target.href !== url.href || init.method !== 'POST' || init.redirect !== 'error' || typeof init.body !== 'string') { - throw new Error('Monero mTLS request does not match the pinned RPC endpoint contract'); - } - const body = Buffer.from(init.body, 'utf8'); - if (body.byteLength > MAX_RPC_REQUEST_BYTES) throw new Error('Monero RPC request exceeds size limit'); - if (new Headers(init.headers).has('authorization')) throw new Error('HTTP Authorization headers are not supported; use mTLS'); - - return new Promise((resolve, reject) => { - const request = httpsRequest({ - protocol: 'https:', hostname: url.hostname, port: url.port || 443, path: url.pathname, - method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', - 'content-length': body.byteLength }, cert, key, ca, rejectUnauthorized: true, - ...(isIP(url.hostname) ? {} : { servername: url.hostname }), minVersion: 'TLSv1.3', - }, response => { - if ((response.statusCode ?? 0) < 200 || (response.statusCode ?? 0) >= 300 || - (response.headers.location !== undefined) || - !/^application\/json(?:\s*;|$)/i.test(response.headers['content-type'] ?? '')) { - response.destroy(); reject(new Error('Monero mTLS gateway response rejected')); return; - } - const chunks: Buffer[] = []; - let size = 0; - response.on('data', (chunk: Buffer) => { - size += chunk.byteLength; - if (size > MAX_RPC_RESPONSE_BYTES) { - response.destroy(new Error('Monero mTLS gateway response exceeds size limit')); - return; - } - chunks.push(chunk); - }); - response.on('error', () => reject(new Error('Monero mTLS gateway response failed'))); - response.on('end', () => resolve(new Response(Buffer.concat(chunks), { - status: response.statusCode, - headers: { 'content-type': response.headers['content-type'] ?? 'application/json' }, - }))); - }); - request.setTimeout(5000, () => request.destroy(new Error('Monero mTLS request deadline exceeded'))); - const signal = init.signal; - if (signal?.aborted) { request.destroy(new Error('Monero mTLS request aborted')); return; } - signal?.addEventListener('abort', () => request.destroy(new Error('Monero mTLS request aborted')), { once: true }); - request.on('error', () => reject(new Error('Monero mTLS request failed'))); - request.end(body); - }); - }; -} diff --git a/server/market/monero-observation-store.ts b/server/market/monero-observation-store.ts deleted file mode 100755 index 7b9edab..0000000 --- a/server/market/monero-observation-store.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { createHash } from 'node:crypto'; -import type { Pool, PoolClient } from 'pg'; -import { z } from 'zod'; -import { XMR_RPC_UINT64_MAX } from '../../shared/xmr-amount.ts'; -import { CUSTODY_WRITE_LOCK } from './postgres-schema.ts'; -import { validateXmrAddress } from './xmr.ts'; -import type { MoneroIncomingObservation, MoneroObservationPage } from './monero-observer.ts'; - -const walletFingerprintSchema = z.string().regex(/^[a-f0-9]{64}$/); -const scanInputSchema = z.object({ - scanId: z.string().uuid(), - walletFingerprint: walletFingerprintSchema, - expectedScanHeight: z.number().int().nonnegative().safe(), - scannedAt: z.number().int().nonnegative().safe(), - page: z.object({ - network: z.literal('mainnet'), walletHeight: z.number().int().nonnegative().safe(), - fromHeight: z.number().int().nonnegative().safe(), throughHeight: z.number().int().nonnegative().safe(), - primaryAddress: z.string().length(95), incoming: z.array(z.unknown()).max(4096), - }).strict(), -}).strict(); -const observationSchema = z.object({ - observationId: z.string().min(1).max(160), transactionId: z.string().regex(/^[a-f0-9]{64}$/), - address: z.string().length(95), - amountAtomicXmr: z.string().regex(/^[1-9]\d{0,19}$/), - height: z.number().int().positive().safe(), confirmations: z.number().int().positive().safe(), - timestamp: z.number().int().nonnegative().safe(), unlockTime: z.string().regex(/^(0|[1-9]\d{0,19})$/), - accountIndex: z.number().int().nonnegative().safe(), addressIndex: z.number().int().nonnegative().safe(), - outputPublicKey: z.string().regex(/^[a-f0-9]{64}$/), globalOutputIndex: z.number().int().nonnegative().safe(), - locked: z.boolean(), doubleSpendSeen: z.boolean(), -}).strict(); -const MAX_SCAN_RANGE = 100_000; -const MAX_OVERLAP = 10_000; - -export type PersistObservationScanInput = { - scanId: string; - walletFingerprint: string; - expectedScanHeight: number; - scannedAt: number; - page: MoneroObservationPage; -}; -export type PersistObservationScanResult = { - scanId: string; - scanHeight: number; - observed: number; - changed: number; - reappeared: number; - notSeen: number; - replayed: boolean; -}; - -function digest(value: unknown): string { - return createHash('sha256').update(JSON.stringify(value)).digest('hex'); -} -function rowDigest(observation: MoneroIncomingObservation): string { - return digest([observation.observationId, observation.transactionId, observation.amountAtomicXmr, - observation.height, observation.timestamp, observation.unlockTime, - observation.accountIndex, observation.addressIndex, observation.outputPublicKey, observation.globalOutputIndex, - observation.locked, observation.doubleSpendSeen]); -} - -/** - * Persist one complete wallet-RPC scan page as observation-only state. This function does not - * create customer credits or financial journals. A missing transfer produces `not-seen`, not - * an automatic debit/reversal; operators must reconcile Monero reorgs before any later credit design. - */ -export async function persistMoneroObservationScan(pool: Pool, input: PersistObservationScanInput): Promise { - const parsed = scanInputSchema.parse(input); - const page = parsed.page; - if (page.fromHeight > page.throughHeight || page.throughHeight > page.walletHeight || - page.throughHeight - page.fromHeight > MAX_SCAN_RANGE || parsed.expectedScanHeight - page.fromHeight > MAX_OVERLAP || - page.fromHeight > parsed.expectedScanHeight || page.throughHeight < parsed.expectedScanHeight) { - throw new Error('Monero observation scan range is not contiguous with its durable cursor'); - } - await validateXmrAddress(page.primaryAddress); - const observations = page.incoming.map(value => observationSchema.parse(value)); - const ids = new Set(); - for (const observation of observations) { - await validateXmrAddress(observation.address); - if (observation.observationId !== `${observation.transactionId}:${observation.outputPublicKey}` || - BigInt(observation.amountAtomicXmr) > XMR_RPC_UINT64_MAX || observation.height < page.fromHeight || - observation.height > page.throughHeight || observation.confirmations !== page.walletHeight - observation.height + 1 || - (observation.locked && observation.confirmations <= 0)) throw new Error('Monero observation is inconsistent with its scan page'); - if (ids.has(observation.observationId)) throw new Error('Duplicate Monero observation identity'); - ids.add(observation.observationId); - } - const pageDigest = digest([parsed.walletFingerprint, page.fromHeight, page.throughHeight, - observations.map(row => [row.observationId, rowDigest(row)]).sort((a, b) => String(a[0]).localeCompare(String(b[0])))]); - - 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\'' ); - await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]); - const priorScan = await client.query(`SELECT payload_digest,through_height FROM wk_custody.monero_observation_scans - WHERE scan_id=$1`, [parsed.scanId]); - if (priorScan.rowCount) { - if (priorScan.rows[0].payload_digest !== pageDigest) throw new Error('Monero scan ID was reused with different observations'); - await client.query('COMMIT'); - return { scanId: parsed.scanId, scanHeight: Number(priorScan.rows[0].through_height), observed: observations.length, - changed: 0, reappeared: 0, notSeen: 0, replayed: true }; - } - let state = await client.query(`SELECT network,scan_height FROM wk_custody.monero_observer_state - WHERE wallet_fingerprint=$1 FOR UPDATE`, [parsed.walletFingerprint]); - if (!state.rowCount) { - if (parsed.expectedScanHeight !== 0) throw new Error('First Monero observation scan must start from wallet history height zero'); - await client.query(`INSERT INTO wk_custody.monero_observer_state(wallet_fingerprint,network,scan_height,updated_at) - VALUES($1,'mainnet',0,$2)`, [parsed.walletFingerprint, parsed.scannedAt]); - state = await client.query(`SELECT network,scan_height FROM wk_custody.monero_observer_state - WHERE wallet_fingerprint=$1 FOR UPDATE`, [parsed.walletFingerprint]); - } - if (state.rows[0].network !== 'mainnet' || Number(state.rows[0].scan_height) !== parsed.expectedScanHeight) { - throw new Error('Monero observer cursor changed; reread durable state before retry'); - } - - let changed = 0, reappeared = 0; - for (const observation of observations) { - const current = await client.query(`SELECT payload_digest,currently_present,first_observed_at - FROM wk_custody.monero_transfer_observations - WHERE wallet_fingerprint=$1 AND observation_id=$2 FOR UPDATE`, [parsed.walletFingerprint, observation.observationId]); - const payloadDigest = rowDigest(observation); - const eventType = !current.rowCount ? 'seen' : !current.rows[0].currently_present ? 'reappeared' - : current.rows[0].payload_digest !== payloadDigest ? 'changed' : null; - await client.query(`INSERT INTO wk_custody.monero_transfer_observations( - wallet_fingerprint,observation_id,txid,monero_address,account_index,address_index,output_public_key,global_output_index, - amount_atomic_xmr,block_height, - wallet_height,confirmations,timestamp,unlock_time,locked,double_spend_seen,payload_digest, - currently_present,last_seen_scan_id,first_observed_at,last_observed_at) - VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,true,$18,$19,$19) - ON CONFLICT(wallet_fingerprint,observation_id) DO UPDATE SET - monero_address=excluded.monero_address, - output_public_key=excluded.output_public_key,global_output_index=excluded.global_output_index, - amount_atomic_xmr=excluded.amount_atomic_xmr,block_height=excluded.block_height,wallet_height=excluded.wallet_height, - confirmations=excluded.confirmations,timestamp=excluded.timestamp,unlock_time=excluded.unlock_time, - locked=excluded.locked,double_spend_seen=excluded.double_spend_seen,payload_digest=excluded.payload_digest, - currently_present=true,last_seen_scan_id=excluded.last_seen_scan_id,last_observed_at=excluded.last_observed_at`, - [parsed.walletFingerprint, observation.observationId, observation.transactionId, observation.address, - observation.accountIndex, observation.addressIndex, observation.outputPublicKey, observation.globalOutputIndex, - observation.amountAtomicXmr, observation.height, page.walletHeight, observation.confirmations, - observation.timestamp, observation.unlockTime, observation.locked, observation.doubleSpendSeen, payloadDigest, parsed.scanId, - current.rowCount ? Number(current.rows[0].first_observed_at) : parsed.scannedAt]); - if (eventType) { - await client.query(`INSERT INTO wk_custody.monero_observation_events - (wallet_fingerprint,observation_id,scan_id,event_type,payload_digest,wallet_height,observed_at) - VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT DO NOTHING`, - [parsed.walletFingerprint, observation.observationId, parsed.scanId, eventType, payloadDigest, page.walletHeight, parsed.scannedAt]); - if (eventType === 'changed') changed++; - if (eventType === 'reappeared') reappeared++; - } - } - - const missing = await client.query(`SELECT observation_id,payload_digest FROM wk_custody.monero_transfer_observations - WHERE wallet_fingerprint=$1 AND currently_present=true AND block_height BETWEEN $2 AND $3 - AND last_seen_scan_id<>$4 FOR UPDATE`, [parsed.walletFingerprint, page.fromHeight, page.throughHeight, parsed.scanId]); - for (const row of missing.rows) { - await client.query(`UPDATE wk_custody.monero_transfer_observations SET currently_present=false,last_seen_scan_id=$3 - WHERE wallet_fingerprint=$1 AND observation_id=$2`, [parsed.walletFingerprint, row.observation_id, parsed.scanId]); - await client.query(`INSERT INTO wk_custody.monero_observation_events - (wallet_fingerprint,observation_id,scan_id,event_type,payload_digest,wallet_height,observed_at) - VALUES($1,$2,$3,'not-seen',$4,$5,$6) ON CONFLICT DO NOTHING`, - [parsed.walletFingerprint, row.observation_id, parsed.scanId, row.payload_digest, page.walletHeight, parsed.scannedAt]); - } - await client.query(`INSERT INTO wk_custody.monero_observation_scans - (scan_id,wallet_fingerprint,from_height,through_height,payload_digest,completed_at) VALUES($1,$2,$3,$4,$5,$6)`, - [parsed.scanId, parsed.walletFingerprint, page.fromHeight, page.throughHeight, pageDigest, parsed.scannedAt]); - await client.query(`UPDATE wk_custody.monero_observer_state SET scan_height=GREATEST(scan_height,$2),updated_at=$3 - WHERE wallet_fingerprint=$1`, [parsed.walletFingerprint, page.throughHeight, parsed.scannedAt]); - await client.query('COMMIT'); - return { scanId: parsed.scanId, scanHeight: Math.max(parsed.expectedScanHeight, page.throughHeight), - observed: observations.length, changed, reappeared, notSeen: missing.rowCount ?? 0, replayed: false }; - } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } - finally { client.release(); } -} diff --git a/server/market/monero-observer-worker.ts b/server/market/monero-observer-worker.ts deleted file mode 100755 index 97f5bf2..0000000 --- a/server/market/monero-observer-worker.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { Pool } from 'pg'; -import { randomUUID } from 'node:crypto'; -import { z } from 'zod'; -import { MONERO_OBSERVER_WORKER_LOCK } from './postgres-schema.ts'; -import { persistMoneroObservationScan, type PersistObservationScanResult } from './monero-observation-store.ts'; -import type { MoneroObservationScanner } from './monero-observer.ts'; - -const fingerprintSchema = z.string().regex(/^[a-f0-9]{64}$/); -export const MONERO_OBSERVER_MAX_BLOCKS_PER_PAGE = 100_000; -export const MONERO_OBSERVER_OVERLAP_BLOCKS = 1_000; -export const MONERO_OBSERVER_MAX_PAGES_PER_BATCH = 4; - -export type MoneroObserverBatchResult = { - walletFingerprint: string; - pages: PersistObservationScanResult[]; - scanHeight: number; - walletHeight: number; - caughtUp: boolean; -}; - -/** - * Perform a small bounded catch-up batch. The database observation role supplies the durable cursor; - * the scanner never receives a spend or address-allocation capability. - */ -export async function runMoneroObserverBatch(pool: Pool, walletFingerprint: string, scanner: MoneroObservationScanner, - options: { now?: () => number; maxPages?: number } = {}): Promise { - fingerprintSchema.parse(walletFingerprint); - const maxPages = options.maxPages ?? MONERO_OBSERVER_MAX_PAGES_PER_BATCH; - if (!Number.isSafeInteger(maxPages) || maxPages < 1 || maxPages > MONERO_OBSERVER_MAX_PAGES_PER_BATCH) { - throw new Error('Invalid Monero observer page budget'); - } - const pages: PersistObservationScanResult[] = []; - let walletHeight = 0, scanHeight = 0; - for (let pageNumber = 0; pageNumber < maxPages; pageNumber++) { - const state = await pool.query<{ scan_height: string }>( - 'SELECT scan_height::text FROM wk_custody.monero_observer_state WHERE wallet_fingerprint=$1', [walletFingerprint]); - const expectedScanHeight = state.rowCount ? Number(state.rows[0].scan_height) : 0; - if (!Number.isSafeInteger(expectedScanHeight) || expectedScanHeight < 0) throw new Error('Invalid durable Monero observer cursor'); - const fromHeight = Math.max(0, expectedScanHeight - MONERO_OBSERVER_OVERLAP_BLOCKS); - const page = await scanner.scanIncoming(fromHeight, MONERO_OBSERVER_MAX_BLOCKS_PER_PAGE); - if (page.network !== 'mainnet' || page.fromHeight !== fromHeight || page.throughHeight < expectedScanHeight || - page.throughHeight - page.fromHeight >= MONERO_OBSERVER_MAX_BLOCKS_PER_PAGE) { - throw new Error('Monero observer returned a page outside the worker’s bounded cursor window'); - } - const result = await persistMoneroObservationScan(pool, { scanId: randomUUID(), walletFingerprint, - expectedScanHeight, scannedAt: (options.now ?? Date.now)(), page }); - pages.push(result); scanHeight = result.scanHeight; walletHeight = page.walletHeight; - if (page.throughHeight >= page.walletHeight) break; - } - return { walletFingerprint, pages, scanHeight, walletHeight, caughtUp: scanHeight >= walletHeight }; -} - -export type ObserverWorkerOptions = { - pollIntervalMs?: number; - onBatch?: (result: MoneroObserverBatchResult) => void; - onFailure?: () => void; - signal?: AbortSignal; -}; - -function sleep(ms: number, signal?: AbortSignal): Promise { - if (signal?.aborted) return Promise.resolve(); - return new Promise(resolve => { - const timer = setTimeout(done, ms); timer.unref(); - function done() { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); } - signal?.addEventListener('abort', done, { once: true }); - }); -} - -/** - * Explicit opt-in worker loop. A PostgreSQL session advisory lock prevents duplicate scanner processes; - * scan failures are redacted and retried with bounded backoff. No journal/credit/release APIs are called. - */ -export async function runMoneroObserverWorker(pool: Pool, walletFingerprint: string, scanner: MoneroObservationScanner, - options: ObserverWorkerOptions = {}): Promise { - fingerprintSchema.parse(walletFingerprint); - const pollIntervalMs = options.pollIntervalMs ?? 30_000; - if (!Number.isSafeInteger(pollIntervalMs) || pollIntervalMs < 1_000 || pollIntervalMs > 15 * 60_000) { - throw new Error('Invalid Monero observer polling interval'); - } - const leader = await pool.connect(); - let acquired = false; - let retryDelay = pollIntervalMs; - try { - const lock = await leader.query<{ locked: boolean }>('SELECT pg_try_advisory_lock($1::bigint) AS locked', [MONERO_OBSERVER_WORKER_LOCK]); - acquired = lock.rows[0]?.locked === true; - if (!acquired) throw new Error('A Monero observer worker already holds the database lock'); - while (!options.signal?.aborted) { - try { - const result = await runMoneroObserverBatch(pool, walletFingerprint, scanner); - options.onBatch?.(result); - retryDelay = pollIntervalMs; - if (!result.caughtUp) continue; - } catch { - options.onFailure?.(); - retryDelay = Math.min(Math.max(pollIntervalMs, retryDelay * 2), 5 * 60_000); - } - await sleep(retryDelay, options.signal); - } - } finally { - if (acquired) await leader.query('SELECT pg_advisory_unlock($1::bigint)', [MONERO_OBSERVER_WORKER_LOCK]).catch(() => {}); - leader.release(); - } -} diff --git a/server/market/monero-observer.ts b/server/market/monero-observer.ts deleted file mode 100755 index f0784bd..0000000 --- a/server/market/monero-observer.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { lstat, readFile } from 'node:fs/promises'; -import { z } from 'zod'; -import { parseAtomicXmr, XMR_RPC_UINT64_MAX } from '../../shared/xmr-amount.ts'; -import { validateXmrAddress, validateXmrSubaddress } from './xmr.ts'; - -const MAX_CONFIG_BYTES = 4096; -const MAX_RESPONSE_BYTES = 1024 * 1024; -const RPC_TIMEOUT_MS = 5000; -const MAX_TRANSFERS = 4096; -const MAX_SCAN_RANGE = 100_000; -const configSchema = z.object({ - network: z.literal('mainnet'), - url: z.string().min(1).max(2048), -}).strict(); -const rpcEnvelopeSchema = z.object({ - jsonrpc: z.literal('2.0'), id: z.string().uuid(), result: z.record(z.string(), z.unknown()).optional(), - error: z.object({ code: z.string().regex(/^-?(0|[1-9]\d*)$/).transform(Number) - .refine(Number.isSafeInteger), message: z.string().max(1024) }).passthrough().optional(), -}).strict().refine(value => (value.result === undefined) !== (value.error === undefined)); -const integerString = z.string().regex(/^(0|[1-9]\d{0,19})$/); -const heightResultSchema = z.object({ height: integerString, status: z.literal('OK') }).passthrough(); -const addressResultSchema = z.object({ address: z.string().length(95), addresses: z.array(z.unknown()).optional(), status: z.literal('OK') }).passthrough(); -const validateAddressResultSchema = z.object({ valid: z.literal(true), integrated: z.literal(false), - nettype: z.literal('mainnet'), subaddress: z.boolean(), openalias_address: z.literal('') }).passthrough(); -const subaddressEntrySchema = z.object({ address: z.string().length(95), label: z.string().max(128), - address_index: integerString, used: z.boolean() }).passthrough(); -const subaddressReadResultSchema = z.object({ address: z.string().length(95), - addresses: z.array(subaddressEntrySchema).max(1), status: z.literal('OK') }).passthrough(); -const subaddressCreateResultSchema = z.object({ address: z.string().length(95), address_index: integerString, - address_indices: z.array(integerString).max(1).optional(), addresses: z.array(z.string().length(95)).max(1).optional(), - status: z.literal('OK') }).passthrough(); -const incomingTransferSchema = z.object({ - txid: z.string().regex(/^[a-f0-9]{64}$/), - address: z.string().length(95), - amount: integerString, - height: integerString, - timestamp: integerString, - unlock_time: integerString, - locked: z.boolean(), - double_spend_seen: z.boolean(), - subaddr_index: z.object({ major: integerString, minor: integerString }).strict(), -}).passthrough(); -const transfersResultSchema = z.object({ - in: z.array(incomingTransferSchema).max(MAX_TRANSFERS).default([]), - status: z.literal('OK'), -}).passthrough(); -const incomingOutputSchema = z.object({ - amount: integerString, - global_index: integerString, - tx_hash: z.string().regex(/^[a-f0-9]{64}$/), - subaddr_index: z.object({ major: integerString, minor: integerString }).strict(), - unlocked: z.boolean(), - block_height: integerString, - pubkey: z.string().regex(/^[a-f0-9]{64}$/), -}).passthrough(); -const incomingOutputsResultSchema = z.object({ - // v0.18.5.4 omits default-valued empty arrays/status from JSON-RPC serialization. - transfers: z.array(incomingOutputSchema).max(MAX_TRANSFERS).default([]), - status: z.literal('OK').optional(), -}).passthrough(); - -export type MoneroIncomingObservation = { - observationId: string; - transactionId: string; - address: string; - amountAtomicXmr: string; - height: number; - confirmations: number; - timestamp: number; - unlockTime: string; - accountIndex: number; - addressIndex: number; - outputPublicKey: string; - globalOutputIndex: number; - locked: boolean; - doubleSpendSeen: boolean; -}; -export type MoneroObservationPage = { - network: 'mainnet'; - walletHeight: number; - fromHeight: number; - throughHeight: number; - primaryAddress: string; - incoming: MoneroIncomingObservation[]; -}; -export type MoneroObservationScanner = { - scanIncoming(fromHeight: number, maxBlocks?: number): Promise; -}; -export type MoneroFixtureTransport = (url: string, init: RequestInit) => Promise; - -class MoneroWalletRpcError extends Error { - readonly rpcCode: number; - readonly rpcMessage: string; - constructor(rpcCode: number, rpcMessage: string) { - super(`Monero wallet-RPC method failed (${rpcCode})`); - this.name = 'MoneroWalletRpcError'; - this.rpcCode = rpcCode; - this.rpcMessage = rpcMessage; - } -} - -function integer(value: string, maximum: bigint, name: string): number { - const parsed = BigInt(value); - if (parsed > maximum || parsed > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`Invalid Monero ${name}`); - return Number(parsed); -} - -/** JSON.parse rounds uint64 amounts. Convert JSON numeric tokens to strings before parsing, - * preserving exact wallet-RPC integer text so all monetary values can be checked as bigint. */ -function parseLosslessJson(text: string): unknown { - let output = '', inString = false, escaped = false; - for (let i = 0; i < text.length;) { - const char = text[i]; - if (inString) { - output += char; - if (escaped) escaped = false; - else if (char === '\\') escaped = true; - else if (char === '"') inString = false; - i++; - continue; - } - if (char === '"') { inString = true; output += char; i++; continue; } - if (char === '-' || (char >= '0' && char <= '9')) { - const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i)); - if (!match) throw new Error('Invalid JSON numeric token'); - output += JSON.stringify(match[0]); i += match[0].length; continue; - } - output += char; i++; - } - return JSON.parse(output) as unknown; -} - -function validateEndpoint(value: string): URL { - let url: URL; - try { url = new URL(value); } catch { throw new Error('Invalid Monero wallet-RPC endpoint'); } - if (url.username || url.password || url.search || url.hash || url.pathname !== '/json_rpc') { - throw new Error('Monero wallet-RPC endpoint must be a bare /json_rpc URL'); - } - const loopback = url.hostname === '127.0.0.1' || url.hostname === '[::1]'; - if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) { - throw new Error('Wallet RPC requires HTTPS, except loopback-only HTTP'); - } - if (!url.port && !['http:', 'https:'].includes(url.protocol)) throw new Error('Invalid Monero wallet-RPC port'); - return url; -} - -export async function loadMoneroObserverConfig(env: NodeJS.ProcessEnv = process.env) { - if (env.WIKIDECK_MONERO_OBSERVER !== '1') throw new Error('Monero observer is disabled'); - const path = env.WIKIDECK_MONERO_WALLET_RPC_CONFIG_FILE; - if (!path) throw new Error('Monero wallet-RPC config file is required'); - try { - const info = await lstat(path); - if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > MAX_CONFIG_BYTES) throw new Error(); - if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error(); - const parsed = configSchema.parse(JSON.parse(await readFile(path, 'utf8'))); - validateEndpoint(parsed.url); - return parsed; - } catch { - throw new Error('Invalid or inaccessible protected Monero wallet-RPC configuration'); - } -} - -/** Restricted read-only surface. No generic RPC method, subaddress creation, transfer or sweep API exists. */ -/** Restricted RPC surface. Authentication is delegated to an explicitly injected secure transport. */ -function createWalletRpc(config: z.infer, transport: MoneroFixtureTransport, - allowedMethods: readonly ('get_height' | 'get_address' | 'get_transfers' | 'incoming_transfers' | 'validate_address' | 'create_address')[]) { - const endpoint = validateEndpoint(config.url); - // Removed Basic auth header - async function rpc(method: 'get_height' | 'get_address' | 'get_transfers' | 'incoming_transfers' | 'validate_address' | 'create_address', params: Record = {}) { - if (!allowedMethods.includes(method)) throw new Error('Monero wallet-RPC method is not allowed for this client'); - const id = randomUUID(), controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS); timeout.unref(); - const abort = new Promise((_, reject) => controller.signal.addEventListener('abort', - () => reject(new Error('Monero wallet-RPC deadline exceeded')), { once: true })); - const operation = (async () => { - let response: Response; - try { - response = await transport(endpoint.href, { method: 'POST', redirect: 'error', signal: controller.signal, - headers: { accept: 'application/json', 'content-type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id, method, params }) }); - } catch { throw new Error('Monero wallet-RPC request failed'); } - if (!response.ok || response.redirected || !response.body || - !/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '')) { - await response.body?.cancel(); throw new Error('Monero wallet-RPC response rejected'); - } - const contentLength = response.headers.get('content-length'); - if (contentLength !== null && (!/^\d+$/.test(contentLength) || Number(contentLength) > MAX_RESPONSE_BYTES)) { - await response.body.cancel(); throw new Error('Monero wallet-RPC response exceeds size limit'); - } - const reader = response.body.getReader(), chunks: Uint8Array[] = []; let size = 0; - try { - for (;;) { - const part = await reader.read(); if (part.done) break; - size += part.value.byteLength; - if (size > MAX_RESPONSE_BYTES) throw new Error('Monero wallet-RPC response exceeds size limit'); - chunks.push(part.value); - } - } finally { await reader.cancel(); reader.releaseLock(); } - let raw: unknown; - try { raw = parseLosslessJson(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))); } - catch { throw new Error('Monero wallet-RPC returned malformed JSON'); } - const envelope = rpcEnvelopeSchema.parse(raw); - if (envelope.id !== id) throw new Error('Monero wallet-RPC response ID mismatch'); - if (envelope.error) throw new MoneroWalletRpcError(envelope.error.code, envelope.error.message); - return envelope.result!; - })(); - try { return await Promise.race([operation, abort]); } - finally { clearTimeout(timeout); } - } - return rpc; -} - -/** Read-only observer capability. It deliberately cannot create addresses or move funds. */ -export function createMoneroWalletObserver(config: z.infer, transport: MoneroFixtureTransport) { - const rpc = createWalletRpc(config, transport, ['get_height', 'get_address', 'get_transfers', 'incoming_transfers']); - return { - async verifyMainnetWallet(): Promise { - const result = addressResultSchema.parse(await rpc('get_address', { account_index: 0 })); - return validateXmrAddress(result.address); - }, - async scanIncoming(fromHeight: number, maxBlocks = MAX_SCAN_RANGE): Promise { - if (!Number.isSafeInteger(fromHeight) || fromHeight < 0 || !Number.isSafeInteger(maxBlocks) || - maxBlocks < 1 || maxBlocks > MAX_SCAN_RANGE) throw new Error('Invalid Monero scan range'); - const [heightRaw, primaryAddress] = await Promise.all([ - rpc('get_height'), this.verifyMainnetWallet(), - ]); - const walletHeight = integer(heightResultSchema.parse(heightRaw).height, BigInt(Number.MAX_SAFE_INTEGER), 'height'); - if (fromHeight > walletHeight) throw new Error('Monero observer cursor is ahead of wallet height'); - const throughHeight = Math.min(walletHeight, fromHeight + maxBlocks - 1); - const transferRaw = transfersResultSchema.parse(await rpc('get_transfers', { - in: true, filter_by_height: true, min_height: fromHeight, max_height: throughHeight, - })); - if (transferRaw.in.length >= MAX_TRANSFERS) throw new Error('Monero transfer page reached its safety limit; refusing to advance scan cursor'); - const transferMetadata = new Map>(); - const keyForIndex = (txid: string, major: number, minor: number) => `${txid}:${major}:${minor}`; - for (const item of transferRaw.in) { - const key = keyForIndex(item.txid, integer(item.subaddr_index.major, 0xffffffffn, 'account index'), - integer(item.subaddr_index.minor, 0xffffffffn, 'address index')); - if (transferMetadata.has(key)) throw new Error('Duplicate incoming Monero transaction metadata'); - transferMetadata.set(key, item); - } - - // get_transfers groups amounts by transaction/subaddress. incoming_transfers exposes each - // owned output and its public key, which is the stable per-output identity needed for credit idempotency. - const requestedIndices = new Map>(); - for (const item of transferRaw.in) { - const major = integer(item.subaddr_index.major, 0xffffffffn, 'account index'); - const minor = integer(item.subaddr_index.minor, 0xffffffffn, 'address index'); - const indices = requestedIndices.get(major) ?? new Set(); - indices.add(minor); requestedIndices.set(major, indices); - } - const incoming: MoneroIncomingObservation[] = []; - const seenOutputKeys = new Set(); - for (const [major, indices] of requestedIndices) { - const minors = [...indices]; - for (let offset = 0; offset < minors.length; offset += 64) { - const selected = minors.slice(offset, offset + 64); - const outputRaw = incomingOutputsResultSchema.parse(await rpc('incoming_transfers', { - transfer_type: 'all', account_index: major, subaddr_indices: selected, - })); - if (outputRaw.transfers.length >= MAX_TRANSFERS) { - throw new Error('Monero output page reached its safety limit; refusing to advance scan cursor'); - } - for (const output of outputRaw.transfers) { - const outputMajor = integer(output.subaddr_index.major, 0xffffffffn, 'account index'); - const outputMinor = integer(output.subaddr_index.minor, 0xffffffffn, 'address index'); - if (outputMajor !== major || !indices.has(outputMinor)) throw new Error('Wallet returned an output outside requested subaddresses'); - const metadata = transferMetadata.get(keyForIndex(output.tx_hash, outputMajor, outputMinor)); - if (!metadata) continue; - const height = integer(output.block_height, BigInt(Number.MAX_SAFE_INTEGER), 'output height'); - const expectedHeight = integer(metadata.height, BigInt(Number.MAX_SAFE_INTEGER), 'transfer height'); - if (height !== expectedHeight || height < fromHeight || height > throughHeight) continue; - const amount = BigInt(output.amount); - if (amount <= 0n || amount > XMR_RPC_UINT64_MAX) throw new Error('Invalid incoming Monero output amount'); - const globalOutputIndex = integer(output.global_index, BigInt(Number.MAX_SAFE_INTEGER), 'global output index'); - const outputPublicKey = output.pubkey; - if (seenOutputKeys.has(outputPublicKey)) throw new Error('Duplicate Monero wallet output public key'); - if (incoming.length >= MAX_TRANSFERS) throw new Error('Monero output page reached its safety limit; refusing to advance scan cursor'); - seenOutputKeys.add(outputPublicKey); - incoming.push({ observationId: `${output.tx_hash}:${outputPublicKey}`, transactionId: output.tx_hash, - address: metadata.address, amountAtomicXmr: amount.toString(), height, - confirmations: walletHeight - height + 1, - timestamp: integer(metadata.timestamp, BigInt(Number.MAX_SAFE_INTEGER), 'timestamp'), - unlockTime: metadata.unlock_time, accountIndex: outputMajor, addressIndex: outputMinor, - outputPublicKey, globalOutputIndex, locked: !output.unlocked, doubleSpendSeen: metadata.double_spend_seen }); - } - } - } - const totals = new Map(); - for (const output of incoming) { - const key = keyForIndex(output.transactionId, output.accountIndex, output.addressIndex); - totals.set(key, (totals.get(key) ?? 0n) + BigInt(output.amountAtomicXmr)); - } - for (const [key, metadata] of transferMetadata) { - if (totals.get(key) !== BigInt(metadata.amount)) throw new Error('Wallet transaction amount does not match its owned output set'); - await validateXmrAddress(metadata.address); - } - return { network: 'mainnet', walletHeight, fromHeight, throughHeight, primaryAddress, incoming }; - }, - }; -} - -export type MoneroDepositAddressRpc = { - verifyWallet(): Promise; - getSubaddress(accountIndex: number, addressIndex: number): Promise<{ address: string; label: string } | null>; - createSubaddress(accountIndex: number, label: string): Promise<{ address: string; addressIndex: number }>; -}; - -/** Stable, non-secret identity of the wallet generation, bound to its verified mainnet primary address. */ -export async function moneroWalletFingerprint(primaryAddress: string): Promise { - await validateXmrAddress(primaryAddress); - return createHash('sha256').update(JSON.stringify(['wikideck-monero-wallet-generation-v1', primaryAddress])).digest('hex'); -} - -/** - * Narrow address-allocation capability; it verifies the mainnet primary address and can only - * inspect or create one subaddress at a time. Watch-only status must be enforced by wallet - * provisioning because the pinned Wallet RPC does not expose a supported wallet-type query. - * Keep this credential/service separate - * from API and withdrawal capabilities. This - * client is intentionally not attached to startup, routes, UI, or automatic crediting. - */ -export function createMoneroDepositAddressRpc(config: z.infer, transport: MoneroFixtureTransport) { - const rpc = createWalletRpc(config, transport, ['get_address', 'validate_address', 'create_address']); - return { - async verifyWallet(): Promise { - const address = addressResultSchema.parse(await rpc('get_address', { account_index: 0, address_index: [0] })); - await validateXmrAddress(address.address); - validateAddressResultSchema.parse(await rpc('validate_address', { address: address.address })); - return moneroWalletFingerprint(address.address); - }, - async getSubaddress(accountIndex: number, addressIndex: number): Promise<{ address: string; label: string } | null> { - if (![accountIndex, addressIndex].every(value => Number.isSafeInteger(value) && value >= 0 && value <= 0xffff_ffff)) { - throw new Error('Invalid Monero subaddress index'); - } - let raw: Record; - try { - raw = await rpc('get_address', { account_index: accountIndex, address_index: [addressIndex] }); - } catch (error) { - // Monero v0.18.5.4 reports an unused, not-yet-created index as RPC error -15. - // Do not treat unrelated account, wallet, or transport failures as an empty address. - if (error instanceof MoneroWalletRpcError && error.rpcCode === -15 && - /address index is out of bound/i.test(error.rpcMessage)) return null; - throw error; - } - const result = subaddressReadResultSchema.parse(raw); - const entry = result.addresses[0]; - if (!entry) return null; - if (integer(entry.address_index, 0xffff_ffffn, 'address index') !== addressIndex) { - throw new Error('Monero wallet returned a different subaddress index'); - } - await validateXmrAddress(entry.address); - return { address: entry.address, label: entry.label }; - }, - async createSubaddress(accountIndex: number, label: string): Promise<{ address: string; addressIndex: number }> { - if (!Number.isSafeInteger(accountIndex) || accountIndex < 0 || accountIndex > 0xffff_ffff || - typeof label !== 'string' || label.length < 1 || label.length > 128 || /[\r\n\x00]/.test(label)) { - throw new Error('Invalid Monero subaddress creation parameters'); - } - const result = subaddressCreateResultSchema.parse(await rpc('create_address', { - account_index: accountIndex, label, count: 1, - })); - const addressIndex = integer(result.address_index, 0xffff_ffffn, 'address index'); - if ((result.address_indices && (result.address_indices.length !== 1 || integer(result.address_indices[0], 0xffff_ffffn, 'address index') !== addressIndex)) || - (result.addresses && (result.addresses.length !== 1 || result.addresses[0] !== result.address))) { - throw new Error('Monero wallet returned inconsistent subaddress creation data'); - } - await validateXmrSubaddress(result.address); - return { address: result.address, addressIndex }; - }, - }; -} diff --git a/server/market/monero-rpc-gateway.ts b/server/market/monero-rpc-gateway.ts deleted file mode 100755 index 9f7c1c8..0000000 --- a/server/market/monero-rpc-gateway.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { createHash } from 'node:crypto'; -import { lstat, readFile } from 'node:fs/promises'; -import { createServer, type Server } from 'node:https'; -import type { AddressInfo } from 'node:net'; -import { isIP } from 'node:net'; -import type { TLSSocket } from 'node:tls'; -import { z } from 'zod'; - -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, - 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) { - const octets = value.split('.').map(Number); - return octets[0] === 127 || octets[0] === 10 || - (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || - (octets[0] === 192 && octets[1] === 168); - } - if (isIP(value) === 6) { - const normalized = value.toLowerCase(); - return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd'); - } - return false; -} - -export async function loadMoneroRpcGatewayConfig(path: string): Promise { - try { - const info = await lstat(path); - if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > 16 * 1024) throw new Error(); - if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error(); - return gatewayConfigSchema.parse(JSON.parse(await readFile(path, 'utf8'))); - } catch { - throw new Error('Invalid or inaccessible protected Monero RPC gateway configuration'); - } -} - -async function readPrivateFile(path: string): Promise { - const info = await lstat(path); - if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > 64 * 1024) { - throw new Error('Monero gateway TLS material must be a bounded regular file'); - } - if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) { - throw new Error('Monero gateway TLS material permissions are too broad'); - } - return readFile(path); -} - -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': - z.object({}).strict().parse(params); - break; - case 'get_address': { - z.object({ account_index: uint32, address_index: z.array(uint32).length(1) }).strict().parse(params); - break; - } - case 'validate_address': - z.object({ address: z.string().length(95) }).strict().parse(params); - break; - case 'create_address': { - const parsed = z.object({ account_index: uint32, label: z.string().min(1).max(128), count: z.literal(1) }).strict().parse(params); - if (/[\r\n\x00]/.test(parsed.label)) throw new Error('Invalid subaddress label'); - break; - } - case 'get_transfers': { - const parsed = z.object({ in: z.literal(true), filter_by_height: z.literal(true), - min_height: height, max_height: height }).strict().parse(params); - if (parsed.max_height < parsed.min_height || parsed.max_height - parsed.min_height > 100_000) { - throw new Error('Wallet transfer scan range is invalid or too large'); - } - break; - } - case 'incoming_transfers': - z.object({ transfer_type: z.literal('all'), account_index: uint32, - subaddr_indices: z.array(uint32).min(1).max(64) }).strict().parse(params); - break; - default: - throw new Error('Wallet RPC method is not allow-listed'); - } - const body = JSON.stringify(request); - if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) throw new Error('Wallet RPC request exceeds size limit'); - return body; -} - -async function forwardToLocalWallet(url: URL, body: string): Promise<{ status: number; contentType: string | undefined; body: Buffer }> { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); timeout.unref(); - try { - const response = await fetch(url, { method: 'POST', redirect: 'error', signal: controller.signal, - headers: { accept: 'application/json', 'content-type': 'application/json' }, body }); - if (response.status !== 200 || response.redirected || - !/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '') || !response.body) { - await response.body?.cancel(); throw new Error('Local Wallet RPC response rejected'); - } - const reader = response.body.getReader(), chunks: Uint8Array[] = []; - let size = 0; - try { - for (;;) { - const part = await reader.read(); if (part.done) break; - size += part.value.byteLength; - if (size > MAX_RESPONSE_BYTES) throw new Error('Local Wallet RPC response exceeds size limit'); - chunks.push(part.value); - } - } finally { await reader.cancel(); reader.releaseLock(); } - return { status: response.status, contentType: response.headers.get('content-type') ?? undefined, - body: Buffer.concat(chunks) }; - } finally { clearTimeout(timeout); } -} - -/** - * Private mTLS authorization gateway for Monero v0.18.x Wallet RPC. Bind it to loopback; - * run wallet-rpc with --disable-rpc-login only behind this process and a host firewall. - * Only allow-listed, schema-bounded methods are forwarded to a literal loopback upstream. - */ -export async function createMoneroRpcGateway(configInput: unknown): Promise { - const config = gatewayConfigSchema.parse(configInput); - if (!isPrivateBindAddress(config.listenHost)) throw new Error('Monero RPC gateway must bind to an explicit private interface IP'); - let upstream: URL; - try { upstream = new URL(config.walletRpcUrl); } catch { throw new Error('Invalid local Wallet RPC URL'); } - if (upstream.protocol !== 'http:' || !['127.0.0.1', '[::1]'].includes(upstream.hostname) || - upstream.pathname !== '/json_rpc' || upstream.username || upstream.password || upstream.search || upstream.hash) { - throw new Error('Wallet RPC gateway upstream must be fixed loopback-only HTTP'); - } - const clientPolicies = new Map(config.clientPolicies.map(policy => [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) => { - void (async () => { - if (request.method !== 'POST' || request.url !== '/json_rpc' || - !/^application\/json(?:\s*;|$)/i.test(request.headers['content-type'] ?? '')) { - response.writeHead(404).end(); return; - } - const peer = (request.socket as TLSSocket).getPeerCertificate(); - const roles = peer.raw ? clientPolicies.get(createHash('sha256').update(peer.raw).digest('hex')) : undefined; - if (!roles) { - response.writeHead(403).end(); return; - } - const chunks: Buffer[] = []; let size = 0; - for await (const chunk of request) { - size += chunk.length; - if (size > MAX_REQUEST_BYTES) { response.writeHead(413).end(); return; } - chunks.push(Buffer.from(chunk)); - } - let body: string; - try { body = validateMoneroRpcGatewayRequest(JSON.parse(Buffer.concat(chunks).toString('utf8')), roles); } - catch { response.writeHead(400).end(); return; } - let result: Awaited>; - try { result = await forwardToLocalWallet(upstream, body); } - catch { response.writeHead(502).end(); return; } - response.writeHead(result.status, { 'content-type': result.contentType ?? 'application/json', - 'cache-control': 'no-store', 'content-length': result.body.byteLength }).end(result.body); - })().catch(() => { if (!response.headersSent) response.writeHead(500).end(); else response.destroy(); }); - }); - server.maxConnections = 32; - server.headersTimeout = 5000; - server.requestTimeout = 10_000; - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(config.listenPort, config.listenHost, () => { server.off('error', reject); resolve(); }); - }); - const address = server.address() as AddressInfo | null; - if (!address || address.address !== config.listenHost || address.port !== config.listenPort) { - await new Promise(resolve => server.close(() => resolve())); - throw new Error('Monero RPC gateway did not bind to the configured loopback address'); - } - return server; -} diff --git a/server/market/payments.ts b/server/market/payments.ts deleted file mode 100755 index cf11d8a..0000000 --- a/server/market/payments.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts'; -import { formatXmr, parseAtomicXmr, XMR_ATOMIC_UNITS } from '../../shared/xmr-amount.ts'; -import { allocateXmrSale } from '../../shared/xmr-pricing.ts'; - -/** No live payment adapter is reachable in this release. Never return a payable mock address. */ -export function paymentMode(env: NodeJS.ProcessEnv = process.env): 'mock' { - const mode = env.WIKIDECK_PAYMENT_MODE ?? 'mock'; - if (mode !== 'mock') throw new Error('Real payments are launch-gated. Only WIKIDECK_PAYMENT_MODE=mock is supported until production settlement review.'); - return mode; -} -export function xmrString(atomic: bigint): string { - return formatXmr(atomic); -} -/** Deterministic simulation ONLY. Round gross up and both independent seller fees down. */ -export function mockQuote(priceCents: number, feeBps: number, usdCentsPerXmr: number) { - const rate = BigInt(usdCentsPerXmr); - const gross = (BigInt(priceCents) * XMR_ATOMIC_UNITS + rate - 1n) / rate; - const { hosting: fee, author: additionalFee, sellerBeforeNetworkCosts } = allocateXmrSale(gross, feeBps); - return { invoice: { provider: 'mock' as const, amountXmr: xmrString(gross), address: null, - label: 'SIMULATION ONLY — no Monero payment address. Never send real XMR.' }, - feeXmr: xmrString(fee), sellerNetXmr: xmrString(sellerBeforeNetworkCosts), - additionalFee: { ...ADDITIONAL_MARKET_FEE, amountXmr: xmrString(additionalFee), payout: 'not-started' as const } }; -} - -/** Native-XMR sandbox quote: exact listed gross, with no exchange-rate conversion. */ -export function mockXmrQuote(priceAtomicXmr: string, feeBps: number) { - const gross = parseAtomicXmr(priceAtomicXmr); - if (gross === 0n) throw new Error('Sale price must be positive'); - const { hosting: fee, author: additionalFee, sellerBeforeNetworkCosts } = allocateXmrSale(gross, feeBps); - return { invoice: { provider: 'mock' as const, amountXmr: formatXmr(gross), address: null, - label: 'SIMULATION ONLY — no Monero payment address. Never send real XMR.' }, - feeXmr: formatXmr(fee), sellerNetXmr: formatXmr(sellerBeforeNetworkCosts), - additionalFee: { ...ADDITIONAL_MARKET_FEE, amountXmr: formatXmr(additionalFee), payout: 'not-started' as const } }; -} diff --git a/server/market/pilot-custody-identity.ts b/server/market/pilot-custody-identity.ts deleted file mode 100755 index 5eb93a8..0000000 --- a/server/market/pilot-custody-identity.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { Pool, PoolClient } from 'pg'; -import { IS_PILOT, NETWORK } from '../../shared/network.ts'; -import { CHAIN_ID } from '../testnet/consensus.ts'; -import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts'; - -export type PilotCustodyIdentity = { - version: 1; - mode: 'pilot-custody'; - policy: 'pilot-custody-v1'; - marketNetwork: 'wikideck-randomx-pilot-1'; - marketChainId: string; - moneroNetwork: 'mainnet'; - custodianId: string; - activationStatus: 'identity-only'; - initializedAt: number; -}; - -function validUuid(value: string): boolean { - return /^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value); -} - -function requirePilot(custodianId: string, initializedAt: number): void { - if (!IS_PILOT || NETWORK !== 'wikideck-randomx-pilot-1' || !validUuid(custodianId)) { - throw new Error('Pilot custody identity requires the pilot profile and a valid custodian UUID'); - } - if (!Number.isSafeInteger(initializedAt) || initializedAt < 0) throw new Error('Invalid pilot identity initialization time'); -} - -export function pilotCustodyIdentityFromRow(row: Record): PilotCustodyIdentity { - const identity = { - version: Number(row.version), mode: row.mode, policy: row.policy, - marketNetwork: row.market_network, marketChainId: row.market_chain_id, - moneroNetwork: row.monero_network, custodianId: row.custodian_id, - activationStatus: row.activation_status, initializedAt: Number(row.initialized_at), - } as PilotCustodyIdentity; - if (identity.version !== 1 || identity.mode !== 'pilot-custody' || identity.policy !== 'pilot-custody-v1' || - identity.marketNetwork !== 'wikideck-randomx-pilot-1' || identity.marketChainId !== CHAIN_ID || - identity.moneroNetwork !== 'mainnet' || !validUuid(identity.custodianId) || - identity.activationStatus !== 'identity-only' || !Number.isSafeInteger(identity.initializedAt) || identity.initializedAt < 0) { - throw new Error('Stored pilot custody identity is invalid or inconsistent'); - } - return identity; -} - -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. */ -export async function initializePilotCustodyIdentity(pool: Pool, custodianId: string, - initializedAt = Date.now()): Promise { - requirePilot(custodianId, initializedAt); - const expected: PilotCustodyIdentity = { version: 1, mode: 'pilot-custody', policy: 'pilot-custody-v1', - marketNetwork: 'wikideck-randomx-pilot-1', marketChainId: CHAIN_ID, moneroNetwork: 'mainnet', - custodianId: custodianId.toLowerCase(), activationStatus: 'identity-only', initializedAt }; - const client = await pool.connect(); - try { - await client.query('BEGIN'); - await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on"); - await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]); - await verifyCustodySchema(client); - const current = await client.query('SELECT * FROM wk_custody.pilot_custody_identity WHERE singleton=1 FOR UPDATE'); - if (current.rowCount) { - const identity = pilotCustodyIdentityFromRow(current.rows[0]); - if (identity.version !== expected.version || identity.mode !== expected.mode || identity.policy !== expected.policy || - identity.marketNetwork !== expected.marketNetwork || identity.marketChainId !== expected.marketChainId || - identity.moneroNetwork !== expected.moneroNetwork || identity.custodianId !== expected.custodianId) { - throw new Error('Pilot custody database is already bound to a different market, Monero network, or custodian'); - } - await client.query('COMMIT'); - return identity; - } - await requireEmptyFinancialDatabase(client); - await client.query(`INSERT INTO wk_custody.pilot_custody_identity - (singleton,version,mode,policy,market_network,market_chain_id,monero_network,custodian_id,activation_status,initialized_at) - VALUES(1,$1,$2,$3,$4,$5,$6,$7,$8,$9)`, [expected.version, expected.mode, expected.policy, - expected.marketNetwork, expected.marketChainId, expected.moneroNetwork, expected.custodianId, - expected.activationStatus, expected.initializedAt]); - await client.query('COMMIT'); - return expected; - } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } - finally { client.release(); } -} - -export async function verifyPilotCustodyIdentity(pool: Pool, custodianId: string): Promise { - requirePilot(custodianId, 0); - const result = await pool.query('SELECT * FROM wk_custody.pilot_custody_identity WHERE singleton=1'); - if (result.rowCount !== 1) throw new Error('Pilot custody identity is not initialized'); - const identity = pilotCustodyIdentityFromRow(result.rows[0]); - if (identity.custodianId !== custodianId.toLowerCase()) throw new Error('Pilot custody identity does not match configured custodian'); - return identity; -} - -export function parsePilotCustodianId(value: string): string { - if (!validUuid(value)) throw new Error('Invalid pilot custodian UUID'); - return value.toLowerCase(); -} diff --git a/server/market/pilot-monero-deposit-allocation.ts b/server/market/pilot-monero-deposit-allocation.ts deleted file mode 100755 index ece9e91..0000000 --- a/server/market/pilot-monero-deposit-allocation.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto'; -import type { Pool, PoolClient } from 'pg'; -import { z } from 'zod'; -import type { MoneroDepositAddressRpc } from './monero-observer.ts'; -import { validateXmrSubaddress } from './xmr.ts'; -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; -}; - -type ReservationAddressRow = ReservationRow & { - status: 'reserved' | 'creating' | 'registered'; - create_started_at: string | null; - monero_address: string | null; - registered_at: string | null; -}; - -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); -} - -async function registerPilotAddress(pool: Pool, reservation: PilotDepositReservation, moneroAddress: string, - registeredAt: number): Promise<{ reservation: PilotDepositReservation; address: string; replayed: boolean }> { - const checkedAddress = await validateXmrSubaddress(moneroAddress); - if (!Number.isSafeInteger(registeredAt) || registeredAt < reservation.reservedAt) { - throw new Error('Pilot address registration predates its durable index reservation'); - } - const client = await pool.connect(); - try { - await client.query('BEGIN'); - await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on"); - const result = await client.query(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations - WHERE allocation_id=$1 AND wallet_generation_id=$2`, [reservation.allocationId, reservation.walletGenerationId]); - if (!result.rowCount) throw new Error('Pilot deposit reservation was not found for this wallet generation'); - const row = result.rows[0]; - if (row.status === 'registered') { - if (row.monero_address !== checkedAddress) throw new Error('Registered pilot deposit address cannot be replaced'); - await client.query('COMMIT'); - return { reservation: toReservation(row), address: checkedAddress, replayed: true }; - } - if (row.status !== 'creating' || row.create_started_at === null || row.monero_address !== null || - row.registered_at !== null || registeredAt < Number(row.create_started_at)) { - throw new Error('Pilot deposit reservation registration state is inconsistent'); - } - const updated = await client.query(`UPDATE wk_custody.pilot_monero_deposit_reservations - SET status='registered',monero_address=$2,registered_at=$3 - WHERE allocation_id=$1 AND status='creating' AND monero_address IS NULL RETURNING *`, [reservation.allocationId, checkedAddress, registeredAt]); - if (!updated.rowCount) { - const already = await client.query(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations - WHERE allocation_id=$1 AND status='registered'`, [reservation.allocationId]); - if (!already.rowCount || already.rows[0].monero_address !== checkedAddress) { - throw new Error('Pilot deposit reservation changed during address registration'); - } - await client.query('COMMIT'); - return { reservation: toReservation(already.rows[0]), address: checkedAddress, replayed: true }; - } - await client.query('COMMIT'); - return { reservation: toReservation(updated.rows[0]), address: checkedAddress, replayed: false }; - } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } - finally { client.release(); } -} - -/** - * Operator/library-only address creation for a durable reservation. A lost create_address - * response is recovered by the stable label at the exact reserved index on the next call. - * No HTTP route invokes this function and the result is not a credit or payment authorization. - */ -export async function createOrRecoverPilotMoneroDepositAddress(pool: Pool, rpc: MoneroDepositAddressRpc, - context: Omit, rawRequest: unknown, - now = Date.now()): Promise<{ reservation: PilotDepositReservation; address: string; replayed: boolean }> { - const reserved = await reservePilotMoneroDepositRequest(pool, context, rawRequest, now); - const initial = await pool.query(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations - WHERE allocation_id=$1 AND wallet_generation_id=$2`, [reserved.reservation.allocationId, reserved.reservation.walletGenerationId]); - if (!initial.rowCount) throw new Error('Pilot deposit reservation was not found for its wallet generation'); - if (initial.rows[0].status === 'registered') { - const address = await validateXmrSubaddress(initial.rows[0].monero_address ?? ''); - return { reservation: toReservation(initial.rows[0]), address, replayed: true }; - } - - const row = initial.rows[0]; - const lockKey = `wikideck-pilot-deposit-address-v1:${row.wallet_generation_id}:${row.account_index}`; - const lockClient = await pool.connect(); - let locked = false; - try { - await lockClient.query('SELECT pg_advisory_lock(hashtextextended($1,0))', [lockKey]); - locked = true; - // Another worker may have completed while this call waited for the per-wallet fence. - const current = await pool.query(`SELECT * FROM wk_custody.pilot_monero_deposit_reservations - WHERE allocation_id=$1 AND wallet_generation_id=$2`, [row.allocation_id, row.wallet_generation_id]); - if (!current.rowCount) throw new Error('Pilot deposit reservation disappeared'); - if (current.rows[0].status === 'registered') { - const address = await validateXmrSubaddress(current.rows[0].monero_address ?? ''); - return { reservation: toReservation(current.rows[0]), address, replayed: true }; - } - const allocation = current.rows[0]; - if (await rpc.verifyWallet() !== allocation.wallet_fingerprint) { - throw new Error('Configured Monero wallet identity differs from the durable pilot reservation'); - } - const expectedIndex = Number(allocation.address_index); - const expectedLabel = `wikideck-pilot-deposit-v1:${allocation.allocation_id}`; - const createWasAlreadyStarted = allocation.status === 'creating'; - let existing = await rpc.getSubaddress(Number(allocation.account_index), expectedIndex); - if (!createWasAlreadyStarted) { - // Persist the side-effect intent first. If the process or transport fails afterwards, - // retry is reconciliation-only: it may recover an existing labeled index but never create twice. - const createStartedAt = Math.max(now, Number(allocation.reserved_at)); - const started = await pool.query(`UPDATE wk_custody.pilot_monero_deposit_reservations - SET status='creating',create_started_at=$2 WHERE allocation_id=$1 AND status='reserved' - RETURNING allocation_id`, [allocation.allocation_id, createStartedAt]); - if (started.rowCount !== 1) throw new Error('Pilot address create intent could not be committed before Wallet-RPC'); - } - if (!existing) { - if (createWasAlreadyStarted) { - throw new Error('Pilot Wallet-RPC create outcome is uncertain; reconcile the reserved index and wallet before retrying'); - } - const created = await rpc.createSubaddress(Number(allocation.account_index), expectedLabel); - if (created.addressIndex !== expectedIndex) { - throw new Error('Monero wallet created an unexpected pilot subaddress index; reservation requires operator reconciliation'); - } - // Never trust the create response alone; query the exact reserved index and stable label. - existing = await rpc.getSubaddress(Number(allocation.account_index), expectedIndex); - if (!existing || existing.address !== created.address) { - throw new Error('Monero wallet subaddress could not be recovered at the reserved pilot index'); - } - } - if (existing.label !== expectedLabel) { - throw new Error('Reserved pilot Monero subaddress index is occupied by a different wallet label'); - } - const address = await validateXmrSubaddress(existing.address); - const reservation = toReservation(allocation); - return await registerPilotAddress(pool, reservation, address, now); - } finally { - if (locked) await lockClient.query('SELECT pg_advisory_unlock(hashtextextended($1,0))', [lockKey]).catch(() => {}); - lockClient.release(); - } -} diff --git a/server/market/pilot-monero-deposit-request-file.ts b/server/market/pilot-monero-deposit-request-file.ts deleted file mode 100755 index d9d3684..0000000 --- a/server/market/pilot-monero-deposit-request-file.ts +++ /dev/null @@ -1,33 +0,0 @@ -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/server/market/pilot-monero-deposit-request.ts b/server/market/pilot-monero-deposit-request.ts deleted file mode 100755 index b444e3e..0000000 --- a/server/market/pilot-monero-deposit-request.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { createHash } from 'node:crypto'; -import { IS_PILOT, NETWORK } from '../../shared/network.ts'; -import { pilotMoneroDepositRequestMessage, pilotMoneroDepositRequestSchema } from '../../shared/pilot-monero-deposit.ts'; -import type { PilotCustodyIdentity } from './pilot-custody-identity.ts'; -import { address, CHAIN_ID, verifySignature } from '../testnet/consensus.ts'; - -const MAX_REQUEST_LIFETIME_MS = 5 * 60_000; - -export type PilotDepositRequestContext = { - identity: PilotCustodyIdentity; - walletGenerationId: string; - walletFingerprint: string; - accountIndex: number; - now?: () => number; -}; - -/** - * Validate a signed pilot deposit intent without allocating an address or touching PostgreSQL/Wallet-RPC. - * This boundary is intentionally identity-only until credit, withdrawal, and recovery are connected. - */ -export function validatePilotMoneroDepositRequest(context: PilotDepositRequestContext, rawRequest: unknown) { - if (!IS_PILOT || NETWORK !== 'wikideck-randomx-pilot-1') { - throw new Error('Pilot Monero deposit requests require the pilot market profile'); - } - const request = pilotMoneroDepositRequestSchema.parse(rawRequest); - const { identity } = context; - if (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' || - request.marketNetwork !== identity.marketNetwork || request.marketChainId !== identity.marketChainId || - request.moneroNetwork !== identity.moneroNetwork || request.custodianId !== identity.custodianId || - request.walletGenerationId !== context.walletGenerationId || request.walletFingerprint !== context.walletFingerprint || - request.accountIndex !== context.accountIndex) { - throw new Error('Pilot deposit request does not match the identity, Monero network, or wallet generation'); - } - if (!/^[a-f0-9]{64}$/.test(context.walletFingerprint) || - !Number.isSafeInteger(context.accountIndex) || context.accountIndex < 0 || context.accountIndex > 0xffff_ffff) { - throw new Error('Invalid configured pilot watch-only wallet identity'); - } - if (address(request.publicKey) !== request.beneficiary) { - throw new Error('Pilot deposit request signer does not own the requested beneficiary'); - } - const { signature, ...unsigned } = request; - const message = pilotMoneroDepositRequestMessage(unsigned); - if (!verifySignature(request.publicKey, request.beneficiary, message, signature)) { - throw new Error('Pilot deposit request signature is invalid'); - } - const now = context.now?.() ?? Date.now(); - if (!Number.isSafeInteger(now) || now < 0 || request.expiresAt < now || request.expiresAt > now + MAX_REQUEST_LIFETIME_MS) { - throw new Error('Pilot deposit request is expired or outside the allowed window'); - } - const requestDigest = createHash('sha256').update(message).digest('hex'); - return { - requestId: request.requestId, - custodianId: identity.custodianId, - marketNetwork: identity.marketNetwork, - marketChainId: identity.marketChainId, - moneroNetwork: identity.moneroNetwork, - beneficiary: request.beneficiary, - walletGenerationId: request.walletGenerationId, - walletFingerprint: request.walletFingerprint, - accountIndex: request.accountIndex, - expiresAt: request.expiresAt, - requestDigest, - } as const; -} diff --git a/server/market/pilot-monero-deposit-review.ts b/server/market/pilot-monero-deposit-review.ts deleted file mode 100755 index f339782..0000000 --- a/server/market/pilot-monero-deposit-review.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { createHash } from 'node:crypto'; -import type { Pool, PoolClient } from 'pg'; -import { z } from 'zod'; -import { XMR_RPC_UINT64_MAX } from '../../shared/xmr-amount.ts'; -import { CHAIN_ID } from '../testnet/consensus.ts'; -import { IS_PILOT, NETWORK } from '../../shared/network.ts'; -import { verifyCustodySchema } from './postgres-schema.ts'; -import { pilotCustodyIdentityFromRow } from './pilot-custody-identity.ts'; -import { validateXmrSubaddress } from './xmr.ts'; - -const HEX64 = z.string().regex(/^[a-f0-9]{64}$/); -const UUID = z.string().uuid(); -const OBSERVATION_ID = /^[a-f0-9]{64}:[a-f0-9]{64}$/; -const UINT64 = /^(0|[1-9]\d{0,19})$/; -const MAX_EVENT_HISTORY = 256; -const MAX_BLOCK_UNLOCK = 500_000_000n; - -export type PilotDepositReviewPolicy = { - minimumConfirmations: number; - maximumObservationAgeMs: number; -}; - -const subjectWithoutDigestSchema = z.object({ - policyVersion: z.literal('pilot-monero-deposit-review-v1'), - preparationOnly: z.literal(true), - crediting: z.literal('disabled'), - marketNetwork: z.literal('wikideck-randomx-pilot-1'), - marketChainId: HEX64, - moneroNetwork: z.literal('mainnet'), - custodianId: UUID, - activationStatus: z.literal('identity-only'), - walletGenerationId: UUID, - walletFingerprint: HEX64, - allocationId: UUID, - beneficiary: HEX64, - accountIndex: z.number().int().nonnegative().max(0xffff_ffff).safe(), - addressIndex: z.number().int().nonnegative().max(0xffff_ffff).safe(), - moneroAddress: z.string().length(95), - observationId: z.string().regex(OBSERVATION_ID), - transactionId: HEX64, - outputPublicKey: HEX64, - amountAtomicXmr: z.string().regex(/^[1-9]\d{0,19}$/), - blockHeight: z.number().int().positive().safe(), - walletHeight: z.number().int().positive().safe(), - confirmations: z.number().int().positive().safe(), - minimumConfirmations: z.number().int().min(10).max(100_000).safe(), - unlockTime: z.string().regex(UINT64), - locked: z.literal(false), - doubleSpendSeen: z.literal(false), - currentlyPresent: z.literal(true), - globalOutputIndex: z.number().int().nonnegative().safe(), - observationDigest: HEX64, - scanId: UUID, - scanFromHeight: z.number().int().nonnegative().safe(), - scanThroughHeight: z.number().int().nonnegative().safe(), - observerScanHeight: z.number().int().nonnegative().safe(), - scanCompletedAt: z.number().int().nonnegative().safe(), - observerUpdatedAt: z.number().int().nonnegative().safe(), - lastObservedAt: z.number().int().nonnegative().safe(), - scanFreshnessMs: z.number().int().nonnegative().safe(), - maximumObservationAgeMs: z.number().int().min(10_000).max(15 * 60_000).safe(), - eventHistorySequence: z.number().int().nonnegative().safe(), - eventHistoryCount: z.number().int().positive().max(MAX_EVENT_HISTORY).safe(), - eventHistoryDigest: HEX64, - preparedAt: z.number().int().nonnegative().safe(), -}).strict(); - -export type PilotDepositReviewSubject = z.infer & { subjectDigest: string }; - -const reviewSubjectSchema = subjectWithoutDigestSchema.extend({ subjectDigest: HEX64 }).strict(); -const inputSchema = z.object({ - walletGenerationId: UUID, - observationId: z.string().regex(OBSERVATION_ID), -}).strict(); - -function digest(value: string): string { - return createHash('sha256').update(value).digest('hex'); -} - -function safeInteger(value: unknown, name: string, minimum = 0): number { - if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'bigint') { - throw new Error(`Invalid pilot review ${name}`); - } - const number = Number(value); - if (!Number.isSafeInteger(number) || number < minimum) throw new Error(`Invalid pilot review ${name}`); - return number; -} - -function validatePolicy(policy: PilotDepositReviewPolicy): void { - if (!Number.isSafeInteger(policy.minimumConfirmations) || policy.minimumConfirmations < 10 || - policy.minimumConfirmations > 100_000 || !Number.isSafeInteger(policy.maximumObservationAgeMs) || - policy.maximumObservationAgeMs < 10_000 || policy.maximumObservationAgeMs > 15 * 60_000) { - throw new Error('Invalid pilot deposit review policy'); - } -} - -function assertUnlockMature(unlockTime: string, walletHeight: number, scanCompletedAt: number): void { - if (!UINT64.test(unlockTime)) throw new Error('Pilot deposit unlock time is malformed'); - const unlock = BigInt(unlockTime); - if (unlock === 0n) return; - const mature = unlock < MAX_BLOCK_UNLOCK - ? BigInt(walletHeight) >= unlock - : BigInt(Math.floor(scanCompletedAt / 1000)) >= unlock; - if (!mature) throw new Error('Pilot deposit output has a future unlock time'); -} - -function validateEvents(rows: Record[], observationDigest: string) { - if (!rows.length || rows.length > MAX_EVENT_HISTORY) { - throw new Error('Pilot observation event history is missing or exceeds the review bound'); - } - const events = rows.map(row => { - const sequence = safeInteger(row.sequence, 'event sequence'); - const observedAt = safeInteger(row.observed_at, 'event timestamp'); - const walletHeight = safeInteger(row.wallet_height, 'event wallet height'); - const payloadDigest = HEX64.parse(row.payload_digest); - const eventType = z.enum(['seen', 'changed', 'reappeared', 'not-seen']).parse(row.event_type); - const scanId = UUID.parse(row.scan_id); - return { sequence, observedAt, walletHeight, payloadDigest, eventType, scanId }; - }); - if (events.some(event => event.eventType !== 'seen' || event.payloadDigest !== observationDigest)) { - throw new Error('Pilot observation history contains change, disappearance, or reappearance evidence and needs separate review'); - } - const last = events.at(-1)!; - const historyDigest = digest(JSON.stringify(events.map(event => [event.sequence, event.eventType, - event.payloadDigest, event.walletHeight, event.observedAt, event.scanId]))); - return { sequence: last.sequence, count: events.length, digest: historyDigest }; -} - -/** - * Build an operator-facing pilot deposit review snapshot from persisted observations only. - * This function is read-only and does not approve, stage, post, release, or credit funds. - */ -export async function preparePilotMoneroDepositReview(pool: Pool, inputValue: unknown, - policy: PilotDepositReviewPolicy, now = Date.now()): Promise { - if (!IS_PILOT || NETWORK !== 'wikideck-randomx-pilot-1') { - throw new Error('Pilot deposit review preparation requires the pilot profile'); - } - const input = inputSchema.parse(inputValue); - validatePolicy(policy); - if (!Number.isSafeInteger(now) || now < 0) throw new Error('Invalid pilot review time'); - - const client: PoolClient = await pool.connect(); - try { - await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'); - await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'"); - await verifyCustodySchema(client); - - const identityResult = await client.query('SELECT * FROM wk_custody.pilot_custody_identity WHERE singleton=1'); - if (identityResult.rowCount !== 1) throw new Error('Pilot custody identity is not initialized'); - const identity = pilotCustodyIdentityFromRow(identityResult.rows[0]); - if (identity.marketChainId !== CHAIN_ID || identity.marketNetwork !== NETWORK || - identity.moneroNetwork !== 'mainnet' || identity.activationStatus !== 'identity-only') { - throw new Error('Pilot custody identity does not permit review preparation'); - } - - const observations = await client.query(`SELECT - o.wallet_generation_id::text,o.observation_id,o.allocation_id::text,o.classification,o.txid,o.monero_address, - o.amount_atomic_xmr::text AS amount_atomic_xmr,o.block_height::text AS block_height, - o.wallet_height::text AS wallet_height,o.confirmations::text AS confirmations,o.unlock_time, - o.account_index::text AS account_index,o.address_index::text AS address_index,o.output_public_key, - o.global_output_index::text AS global_output_index,o.locked,o.double_spend_seen,o.payload_digest, - o.currently_present,o.last_seen_scan_id::text AS scan_id,o.last_observed_at::text AS last_observed_at, - s.wallet_fingerprint,s.network,s.scan_height::text AS observer_scan_height,s.updated_at::text AS observer_updated_at, - sc.from_height::text AS scan_from_height,sc.through_height::text AS scan_through_height, - sc.completed_at::text AS scan_completed_at - FROM wk_custody.pilot_monero_observations o - JOIN wk_custody.pilot_monero_observer_state s USING(wallet_generation_id) - JOIN wk_custody.pilot_monero_observation_scans sc - ON sc.scan_id=o.last_seen_scan_id AND sc.wallet_generation_id=o.wallet_generation_id - WHERE o.wallet_generation_id=$1 AND o.observation_id=$2`, [input.walletGenerationId, input.observationId]); - if (observations.rowCount !== 1) throw new Error('Pilot deposit observation was not found for this wallet generation'); - const row = observations.rows[0]; - if (row.wallet_generation_id !== input.walletGenerationId || row.observation_id !== input.observationId || - row.network !== identity.moneroNetwork || row.classification !== 'assigned' || row.allocation_id === null || - row.currently_present !== true || row.double_spend_seen !== false || row.locked !== false) { - throw new Error('Pilot deposit observation is not an assigned, present, unlocked, non-double-spent output'); - } - - const txid = HEX64.parse(row.txid); - const outputPublicKey = HEX64.parse(row.output_public_key); - if (row.observation_id !== `${txid}:${outputPublicKey}`) throw new Error('Pilot observation ID does not match its output identity'); - const amountAtomicXmr = z.string().regex(/^[1-9]\d{0,19}$/).parse(row.amount_atomic_xmr); - const amount = BigInt(amountAtomicXmr); - if (amount > XMR_RPC_UINT64_MAX) throw new Error('Pilot deposit amount exceeds the Monero RPC uint64 range'); - - const blockHeight = safeInteger(row.block_height, 'block height', 1); - const walletHeight = safeInteger(row.wallet_height, 'wallet height', 1); - const confirmations = safeInteger(row.confirmations, 'confirmation count', 1); - const observerScanHeight = safeInteger(row.observer_scan_height, 'observer scan height'); - const scanFromHeight = safeInteger(row.scan_from_height, 'scan start height'); - const scanThroughHeight = safeInteger(row.scan_through_height, 'scan end height'); - const scanCompletedAt = safeInteger(row.scan_completed_at, 'scan completion time'); - const observerUpdatedAt = safeInteger(row.observer_updated_at, 'observer update time'); - const lastObservedAt = safeInteger(row.last_observed_at, 'last observation time'); - const accountIndex = safeInteger(row.account_index, 'account index'); - const addressIndex = safeInteger(row.address_index, 'address index'); - const globalOutputIndex = safeInteger(row.global_output_index, 'global output index'); - if (confirmations !== walletHeight - blockHeight + 1 || confirmations < policy.minimumConfirmations) { - throw new Error('Pilot deposit output is below the configured confirmation floor'); - } - if (observerScanHeight < walletHeight || scanThroughHeight < walletHeight || - scanFromHeight > blockHeight || scanThroughHeight < blockHeight || lastObservedAt !== scanCompletedAt || - observerUpdatedAt < scanCompletedAt) { - throw new Error('Pilot observation scan is not caught up or its persisted scan context is inconsistent'); - } - const scanFreshnessMs = now - observerUpdatedAt; - if (scanFreshnessMs < 0 || now - scanCompletedAt < 0 || now - lastObservedAt < 0 || - scanFreshnessMs > policy.maximumObservationAgeMs || now - scanCompletedAt > policy.maximumObservationAgeMs || - now - lastObservedAt > policy.maximumObservationAgeMs) { - throw new Error('Pilot Monero observation scan is stale or has a future timestamp'); - } - assertUnlockMature(row.unlock_time, walletHeight, scanCompletedAt); - - const allocations = await client.query(`SELECT allocation_id::text,custodian_id::text,market_network,market_chain_id, - monero_network,beneficiary,wallet_generation_id::text,wallet_fingerprint,account_index::text AS account_index, - address_index::text AS address_index,status,monero_address - FROM wk_custody.pilot_monero_deposit_reservations WHERE allocation_id=$1`, [row.allocation_id]); - if (allocations.rowCount !== 1) throw new Error('Pilot assigned output has no unique durable reservation'); - const allocation = allocations.rows[0]; - const beneficiary = HEX64.parse(allocation.beneficiary); - if (allocation.custodian_id !== identity.custodianId || allocation.market_network !== identity.marketNetwork || - allocation.market_chain_id !== identity.marketChainId || allocation.monero_network !== identity.moneroNetwork || - allocation.wallet_generation_id !== input.walletGenerationId || allocation.wallet_fingerprint !== row.wallet_fingerprint || - safeInteger(allocation.account_index, 'reserved account index') !== accountIndex || - safeInteger(allocation.address_index, 'reserved address index') !== addressIndex || - allocation.status !== 'registered' || allocation.monero_address !== row.monero_address) { - throw new Error('Pilot output does not exactly match its immutable registered reservation and identity'); - } - const moneroAddress = await validateXmrSubaddress(z.string().length(95).parse(row.monero_address)); - const walletFingerprint = HEX64.parse(row.wallet_fingerprint); - const observationDigest = HEX64.parse(row.payload_digest); - const scanId = UUID.parse(row.scan_id); - - const history = await client.query(`SELECT sequence::text,event_type,payload_digest,wallet_height::text AS wallet_height, - observed_at::text AS observed_at,scan_id::text - FROM wk_custody.pilot_monero_observation_events - WHERE wallet_generation_id=$1 AND observation_id=$2 ORDER BY sequence LIMIT $3`, - [input.walletGenerationId, input.observationId, MAX_EVENT_HISTORY + 1]); - if (history.rows.length > MAX_EVENT_HISTORY) throw new Error('Pilot observation event history exceeds the review bound'); - const eventHistory = validateEvents(history.rows, observationDigest); - - const unsigned = subjectWithoutDigestSchema.parse({ - policyVersion: 'pilot-monero-deposit-review-v1', preparationOnly: true, crediting: 'disabled', - marketNetwork: identity.marketNetwork, marketChainId: identity.marketChainId, moneroNetwork: identity.moneroNetwork, - custodianId: identity.custodianId, activationStatus: identity.activationStatus, - walletGenerationId: input.walletGenerationId, walletFingerprint, allocationId: allocation.allocation_id, - beneficiary, accountIndex, addressIndex, moneroAddress, observationId: input.observationId, - transactionId: txid, outputPublicKey, amountAtomicXmr, blockHeight, walletHeight, confirmations, - minimumConfirmations: policy.minimumConfirmations, unlockTime: z.string().regex(UINT64).parse(row.unlock_time), - locked: false, doubleSpendSeen: false, currentlyPresent: true, globalOutputIndex, observationDigest, - scanId, scanFromHeight, scanThroughHeight, observerScanHeight, scanCompletedAt, observerUpdatedAt, - lastObservedAt, scanFreshnessMs, maximumObservationAgeMs: policy.maximumObservationAgeMs, - eventHistorySequence: eventHistory.sequence, eventHistoryCount: eventHistory.count, - eventHistoryDigest: eventHistory.digest, preparedAt: now, - }); - const subject = reviewSubjectSchema.parse({ ...unsigned, subjectDigest: digest(JSON.stringify(unsigned)) }); - await client.query('COMMIT'); - return subject; - } catch (error) { - await client.query('ROLLBACK').catch(() => {}); - throw error; - } finally { - client.release(); - } -} diff --git a/server/market/pilot-monero-observation-store.ts b/server/market/pilot-monero-observation-store.ts deleted file mode 100755 index 712fdde..0000000 --- a/server/market/pilot-monero-observation-store.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { createHash } from 'node:crypto'; -import type { Pool, PoolClient } from 'pg'; -import { z } from 'zod'; -import { XMR_RPC_UINT64_MAX } from '../../shared/xmr-amount.ts'; -import type { MoneroIncomingObservation, MoneroObservationPage } from './monero-observer.ts'; -import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts'; -import { verifyPilotCustodyIdentity } from './pilot-custody-identity.ts'; -import { validateXmrAddress } from './xmr.ts'; - -const MAX_SCAN_RANGE = 100_000; -const MAX_OVERLAP = 10_000; -const fingerprintSchema = z.string().regex(/^[a-f0-9]{64}$/); -const scanInputSchema = z.object({ - scanId: z.string().uuid(), - walletGenerationId: z.string().uuid(), - walletFingerprint: fingerprintSchema, - expectedScanHeight: z.number().int().nonnegative().safe(), - scannedAt: z.number().int().nonnegative().safe(), - page: z.object({ - network: z.literal('mainnet'), walletHeight: z.number().int().nonnegative().safe(), - fromHeight: z.number().int().nonnegative().safe(), throughHeight: z.number().int().nonnegative().safe(), - primaryAddress: z.string().length(95), incoming: z.array(z.unknown()).max(4096), - }).strict(), -}).strict(); -const observationSchema = z.object({ - observationId: z.string().min(1).max(160), transactionId: fingerprintSchema, - address: z.string().length(95), amountAtomicXmr: z.string().regex(/^[1-9]\d{0,19}$/), - height: z.number().int().positive().safe(), confirmations: z.number().int().positive().safe(), - timestamp: z.number().int().nonnegative().safe(), unlockTime: z.string().regex(/^(0|[1-9]\d{0,19})$/), - accountIndex: z.number().int().nonnegative().max(0xffff_ffff).safe(), - addressIndex: z.number().int().nonnegative().max(0xffff_ffff).safe(), - outputPublicKey: fingerprintSchema, globalOutputIndex: z.number().int().nonnegative().safe(), - locked: z.boolean(), doubleSpendSeen: z.boolean(), -}).strict(); - -type PersistInput = { - scanId: string; - walletGenerationId: string; - walletFingerprint: string; - expectedScanHeight: number; - scannedAt: number; - page: MoneroObservationPage; -}; -export type PilotObservationClassification = 'assigned' | 'unallocated'; -export type PilotObservationScanResult = { - scanId: string; scanHeight: number; observed: number; assigned: number; unallocated: number; - changed: number; reappeared: number; notSeen: number; replayed: boolean; -}; - -export async function readPilotMoneroObservationCursor(pool: Pool, walletGenerationId: string, - walletFingerprint: string): Promise { - const generation = z.string().uuid().parse(walletGenerationId); - const fingerprint = fingerprintSchema.parse(walletFingerprint); - const identity = await verifyPilotCustodyIdentity(pool, (await pool.query( - 'SELECT custodian_id FROM wk_custody.pilot_custody_identity WHERE singleton=1')).rows[0]?.custodian_id ?? ''); - const result = await pool.query(`SELECT wallet_fingerprint,network,scan_height - FROM wk_custody.pilot_monero_observer_state WHERE wallet_generation_id=$1`, [generation]); - if (!result.rowCount) return 0; - if (result.rows[0].wallet_fingerprint !== fingerprint || result.rows[0].network !== identity.moneroNetwork) { - throw new Error('Pilot observer cursor is bound to a different wallet or Monero network'); - } - return Number(result.rows[0].scan_height); -} - -function digest(value: unknown): string { - return createHash('sha256').update(JSON.stringify(value)).digest('hex'); -} -function observationDigest(observation: MoneroIncomingObservation, classification: PilotObservationClassification): string { - return digest([classification, observation.observationId, observation.transactionId, observation.amountAtomicXmr, - observation.height, observation.timestamp, observation.unlockTime, observation.accountIndex, - observation.addressIndex, observation.outputPublicKey, observation.globalOutputIndex, - observation.locked, observation.doubleSpendSeen]); -} - -/** Persist pilot-wallet observations separately from mainnet custody observations. Never posts money or credits. */ -export async function persistPilotMoneroObservationScan(pool: Pool, input: PersistInput): Promise { - const parsed = scanInputSchema.parse(input), page = parsed.page; - if (page.fromHeight > page.throughHeight || page.throughHeight > page.walletHeight || - page.throughHeight - page.fromHeight > MAX_SCAN_RANGE || parsed.expectedScanHeight - page.fromHeight > MAX_OVERLAP || - page.fromHeight > parsed.expectedScanHeight || page.throughHeight < parsed.expectedScanHeight) { - throw new Error('Pilot Monero observation range is not contiguous with its durable cursor'); - } - await validateXmrAddress(page.primaryAddress); - const observations = page.incoming.map(row => observationSchema.parse(row)); - const seen = new Set(); - for (const observation of observations) { - await validateXmrAddress(observation.address); - if (observation.observationId !== `${observation.transactionId}:${observation.outputPublicKey}` || - BigInt(observation.amountAtomicXmr) > XMR_RPC_UINT64_MAX || observation.height < page.fromHeight || - observation.height > page.throughHeight || observation.confirmations !== page.walletHeight - observation.height + 1 || - seen.has(observation.observationId)) throw new Error('Pilot Monero observation is inconsistent with its scan page'); - seen.add(observation.observationId); - } - const pageDigest = digest([parsed.walletGenerationId, parsed.walletFingerprint, page.fromHeight, page.throughHeight, - observations.map(row => [row.observationId, row]).sort((a, b) => String(a[0]).localeCompare(String(b[0])))]); - - 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); - const identity = await verifyPilotCustodyIdentity(pool, (await client.query( - 'SELECT custodian_id FROM wk_custody.pilot_custody_identity WHERE singleton=1')).rows[0]?.custodian_id ?? ''); - const state = await client.query('SELECT wallet_fingerprint,network,scan_height FROM wk_custody.pilot_monero_observer_state WHERE wallet_generation_id=$1 FOR UPDATE', - [parsed.walletGenerationId]); - const prior = await client.query('SELECT payload_digest,through_height FROM wk_custody.pilot_monero_observation_scans WHERE scan_id=$1', [parsed.scanId]); - if (prior.rowCount) { - if (prior.rows[0].payload_digest !== pageDigest) throw new Error('Pilot observation scan ID was reused with different data'); - await client.query('COMMIT'); - return { scanId: parsed.scanId, scanHeight: Number(prior.rows[0].through_height), observed: observations.length, - assigned: 0, unallocated: 0, changed: 0, reappeared: 0, notSeen: 0, replayed: true }; - } - if (!state.rowCount) { - if (parsed.expectedScanHeight !== 0) throw new Error('First pilot observation scan must start at wallet history height zero'); - await client.query(`INSERT INTO wk_custody.pilot_monero_observer_state(wallet_generation_id,wallet_fingerprint,network,scan_height,updated_at) - VALUES($1,$2,'mainnet',0,$3)`, [parsed.walletGenerationId, parsed.walletFingerprint, parsed.scannedAt]); - } else if (state.rows[0].wallet_fingerprint !== parsed.walletFingerprint || state.rows[0].network !== 'mainnet' || - Number(state.rows[0].scan_height) !== parsed.expectedScanHeight) { - throw new Error('Pilot observer wallet identity or cursor changed; reread durable state before retry'); - } - const scanState = await client.query('SELECT wallet_fingerprint FROM wk_custody.pilot_monero_observer_state WHERE wallet_generation_id=$1', - [parsed.walletGenerationId]); - if (scanState.rows[0].wallet_fingerprint !== parsed.walletFingerprint) throw new Error('Pilot observer wallet fingerprint mismatch'); - await client.query(`INSERT INTO wk_custody.pilot_monero_observation_scans - (scan_id,wallet_generation_id,from_height,through_height,payload_digest,completed_at) VALUES($1,$2,$3,$4,$5,$6)`, - [parsed.scanId, parsed.walletGenerationId, page.fromHeight, page.throughHeight, pageDigest, parsed.scannedAt]); - - let assigned = 0, unallocated = 0, changed = 0, reappeared = 0; - for (const observation of observations) { - const allocation = await client.query<{ allocation_id: string }>(`SELECT allocation_id - FROM wk_custody.pilot_monero_deposit_reservations - WHERE wallet_generation_id=$1 AND wallet_fingerprint=$2 AND account_index=$3 AND address_index=$4 - AND monero_network='mainnet' AND status='registered' AND monero_address=$5`, - [parsed.walletGenerationId, parsed.walletFingerprint, observation.accountIndex, observation.addressIndex, observation.address]); - const classification: PilotObservationClassification = allocation.rowCount ? 'assigned' : 'unallocated'; - const allocationId: string | null = allocation.rowCount ? allocation.rows[0].allocation_id : null; - const payloadDigest = observationDigest(observation, classification); - const existing = await client.query(`SELECT payload_digest,currently_present,first_observed_at FROM wk_custody.pilot_monero_observations - WHERE wallet_generation_id=$1 AND observation_id=$2 FOR UPDATE`, [parsed.walletGenerationId, observation.observationId]); - const eventType = !existing.rowCount ? 'seen' : !existing.rows[0].currently_present ? 'reappeared' - : existing.rows[0].payload_digest !== payloadDigest ? 'changed' : null; - await client.query(`INSERT INTO wk_custody.pilot_monero_observations( - wallet_generation_id,observation_id,allocation_id,classification,txid,monero_address,amount_atomic_xmr, - block_height,wallet_height,confirmations,timestamp,unlock_time,account_index,address_index, - output_public_key,global_output_index,locked,double_spend_seen,payload_digest,currently_present, - last_seen_scan_id,first_observed_at,last_observed_at) - VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,true,$20,$21,$21) - ON CONFLICT(wallet_generation_id,observation_id) DO UPDATE SET - amount_atomic_xmr=excluded.amount_atomic_xmr,block_height=excluded.block_height,wallet_height=excluded.wallet_height, - confirmations=excluded.confirmations,timestamp=excluded.timestamp,unlock_time=excluded.unlock_time, - locked=excluded.locked,double_spend_seen=excluded.double_spend_seen,payload_digest=excluded.payload_digest, - currently_present=true,last_seen_scan_id=excluded.last_seen_scan_id,last_observed_at=excluded.last_observed_at`, - [parsed.walletGenerationId, observation.observationId, allocationId, classification, observation.transactionId, - observation.address, observation.amountAtomicXmr, observation.height, page.walletHeight, observation.confirmations, - observation.timestamp, observation.unlockTime, observation.accountIndex, observation.addressIndex, - observation.outputPublicKey, observation.globalOutputIndex, observation.locked, observation.doubleSpendSeen, - payloadDigest, parsed.scanId, existing.rowCount ? Number(existing.rows[0].first_observed_at) : parsed.scannedAt]); - if (classification === 'assigned') assigned++; else unallocated++; - if (eventType) { - await client.query(`INSERT INTO wk_custody.pilot_monero_observation_events - (wallet_generation_id,observation_id,scan_id,event_type,payload_digest,wallet_height,observed_at) - VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT DO NOTHING`, - [parsed.walletGenerationId, observation.observationId, parsed.scanId, eventType, payloadDigest, page.walletHeight, parsed.scannedAt]); - if (eventType === 'changed') changed++; - if (eventType === 'reappeared') reappeared++; - } - } - - const missing = await client.query(`SELECT observation_id,payload_digest FROM wk_custody.pilot_monero_observations - WHERE wallet_generation_id=$1 AND currently_present=true AND block_height BETWEEN $2 AND $3 AND last_seen_scan_id<>$4 FOR UPDATE`, - [parsed.walletGenerationId, page.fromHeight, page.throughHeight, parsed.scanId]); - for (const row of missing.rows) { - await client.query(`UPDATE wk_custody.pilot_monero_observations SET currently_present=false,last_seen_scan_id=$3 - WHERE wallet_generation_id=$1 AND observation_id=$2`, [parsed.walletGenerationId, row.observation_id, parsed.scanId]); - await client.query(`INSERT INTO wk_custody.pilot_monero_observation_events - (wallet_generation_id,observation_id,scan_id,event_type,payload_digest,wallet_height,observed_at) - VALUES($1,$2,$3,'not-seen',$4,$5,$6) ON CONFLICT DO NOTHING`, - [parsed.walletGenerationId, row.observation_id, parsed.scanId, row.payload_digest, page.walletHeight, parsed.scannedAt]); - } - await client.query(`UPDATE wk_custody.pilot_monero_observer_state SET scan_height=GREATEST(scan_height,$2),updated_at=$3 - WHERE wallet_generation_id=$1`, [parsed.walletGenerationId, page.throughHeight, parsed.scannedAt]); - await client.query('COMMIT'); - return { scanId: parsed.scanId, scanHeight: Math.max(parsed.expectedScanHeight, page.throughHeight), observed: observations.length, - assigned, unallocated, changed, reappeared, notSeen: missing.rowCount ?? 0, replayed: false }; - } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } - finally { client.release(); } -} diff --git a/server/market/policy.ts b/server/market/policy.ts deleted file mode 100755 index 0c6515e..0000000 --- a/server/market/policy.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts'; -import { validateXmrAddress } from './xmr.ts'; - -export type MarketPolicy = { marketEnabled?: boolean; tradingEnabled?: boolean; hostFeeAddress?: string }; -export function operatorMarketPolicy(env: NodeJS.ProcessEnv = process.env): Required { - const flag = (name: string, fallback: boolean) => { - const value = env[name] ?? (fallback ? '1' : '0'); - if (value !== '0' && value !== '1') throw new Error(`${name} must be 0 or 1`); - return value === '1'; - }; - const hostFeeAddress = env.WIKIDECK_MARKET_HOST_FEE_ADDRESS ?? ADDITIONAL_MARKET_FEE.address; - if (!/^[1-9A-HJ-NP-Za-km-z]{95}$/.test(hostFeeAddress)) throw new Error('Invalid WIKIDECK_MARKET_HOST_FEE_ADDRESS'); - return { marketEnabled: flag('WIKIDECK_MARKET_ENABLED', true), - tradingEnabled: flag('WIKIDECK_MARKET_TRADING_ENABLED', true), hostFeeAddress }; -} -export function resolvedMarketPolicy(policy: MarketPolicy): Required { - return { marketEnabled: policy.marketEnabled ?? true, tradingEnabled: policy.tradingEnabled ?? true, - hostFeeAddress: policy.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address }; -} -export async function validateMarketPolicy(policy: MarketPolicy): Promise { - await validateXmrAddress(resolvedMarketPolicy(policy).hostFeeAddress); -} -/** Pausing new business never hides liabilities or blocks safe resolution/withdrawal requests. */ -export function permitsMarketAction(policy: MarketPolicy, action: string): boolean { - return !['list', 'bid', 'buy', 'reserve', 'mock-credit'].includes(action) || - (resolvedMarketPolicy(policy).marketEnabled && resolvedMarketPolicy(policy).tradingEnabled); -} diff --git a/server/market/postgres-config.ts b/server/market/postgres-config.ts deleted file mode 100755 index 5be2378..0000000 --- a/server/market/postgres-config.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { lstat, readFile } from 'node:fs/promises'; -import { constants } from 'node:fs'; -import { open } from 'node:fs/promises'; -import { X509Certificate } from 'node:crypto'; -import type { PoolConfig } from 'pg'; - -export function custodyStorage(env: NodeJS.ProcessEnv = process.env): 'sqlite' | 'postgres' { - const value = env.WIKIDECK_CUSTODY_STORAGE ?? 'sqlite'; - if (value !== 'sqlite' && value !== 'postgres') throw new Error('Invalid custody storage backend'); - return value; -} -async function secretFile(path: string | undefined): Promise { - if (!path) throw new Error('Custody PostgreSQL secret file is required'); - const info = await lstat(path); - if (!info.isFile() || info.isSymbolicLink() || info.size > 16384 || info.size === 0) throw new Error('Invalid PostgreSQL secret file'); - if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error('PostgreSQL secret file permissions are too broad'); - return (await readFile(path, 'utf8')).trim(); -} -function derLength(bytes: Buffer, offset: number): { length: number; next: number } { - if (offset >= bytes.length) throw new Error('Invalid certificate DER length'); - const first = bytes[offset]; - if (first < 0x80) return { length: first, next: offset + 1 }; - const count = first & 0x7f; - if (count === 0 || count > 4 || offset + count >= bytes.length) throw new Error('Invalid certificate DER length'); - let length = 0; - for (let i = 1; i <= count; i++) length = length * 256 + bytes[offset + i]; - return { length, next: offset + count + 1 }; -} -function derElement(bytes: Buffer, offset: number): { tag: number; start: number; end: number; next: number } { - if (offset >= bytes.length) throw new Error('Invalid certificate DER'); - const tag = bytes[offset], size = derLength(bytes, offset + 1), end = size.next + size.length; - if (end > bytes.length) throw new Error('Invalid certificate DER'); - return { tag, start: size.next, end, next: end }; -} -function validateCaCertificate(pem: Buffer): string { - const certificate = new X509Certificate(pem); - const now = Date.now(); - if (!Number.isFinite(Date.parse(certificate.validFrom)) || !Number.isFinite(Date.parse(certificate.validTo)) || - now < Date.parse(certificate.validFrom) || now > Date.parse(certificate.validTo)) { - throw new Error('PostgreSQL CA certificate is not currently valid'); - } - if (certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) { - throw new Error('PostgreSQL CA certificate must be a valid self-signed trust anchor'); - } - const key = certificate.publicKey; - if (key.asymmetricKeyType !== 'rsa' || (key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) { - throw new Error('PostgreSQL CA certificate key must be RSA-2048 or stronger'); - } - const der = certificate.raw; - const outer = derElement(der, 0); - if (outer.tag !== 0x30) throw new Error('Invalid PostgreSQL CA certificate DER'); - const tbs = derElement(der, outer.start); - const signatureAlgorithm = derElement(der, tbs.next); - if (signatureAlgorithm.tag !== 0x30) throw new Error('Invalid PostgreSQL CA signature algorithm'); - const oid = derElement(der, signatureAlgorithm.start); - if (oid.tag !== 0x06) throw new Error('Invalid PostgreSQL CA signature algorithm'); - const signatureOid = der.subarray(oid.start, oid.end).toString('hex'); - const weakSignatureOids = new Set(['2a864886f70d010104', '2b0e03021a', '2b0e03021b']); - if (weakSignatureOids.has(signatureOid)) throw new Error('PostgreSQL CA certificate uses a weak signature algorithm'); - return pem.toString('utf8'); -} -async function publicCaFile(path: string | undefined): Promise { - if (!path) return undefined; - const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); - try { - const info = await file.stat(); - if (!info.isFile() || info.size < 1 || info.size > 64 * 1024) throw new Error('Invalid PostgreSQL CA file'); - // A CA certificate is public trust material; mode 0644 is expected. It is not a database credential. - const bytes = await file.readFile(); - return validateCaCertificate(bytes); - } finally { await file.close(); } -} -/** No URL logging, no TLS options in a connection string overriding verified transport. */ -export async function postgresConfig(env: NodeJS.ProcessEnv = process.env): Promise { - try { - const url = new URL(await secretFile(env.WIKIDECK_CUSTODY_PG_URL_FILE)); - if (!['postgres:', 'postgresql:'].includes(url.protocol) || !url.hostname || url.search || url.hash || - !/^\/[a-zA-Z0-9_-]+$/.test(url.pathname)) throw new Error('Invalid PostgreSQL URL'); - const insecure = env.WIKIDECK_CUSTODY_PG_LOCAL_TEST === '1'; - const transport = env.WIKIDECK_CUSTODY_PG_TRANSPORT ?? 'tls'; - if (transport !== 'tls' && transport !== 'infrastructure') throw new Error('Invalid PostgreSQL transport'); - const infrastructure = transport === 'infrastructure'; - const acknowledgement = env.WIKIDECK_CUSTODY_PG_INFRASTRUCTURE_ACK; - if (infrastructure) { - if (acknowledgement !== 'encrypted-authenticated-path-enforced' || insecure || env.WIKIDECK_CUSTODY_PG_CA_FILE) { - throw new Error('Infrastructure transport requires explicit acknowledgement without conflicting TLS/test settings'); - } - } else if (acknowledgement !== undefined) throw new Error('Infrastructure acknowledgement without infrastructure transport'); - if (env.WIKIDECK_CUSTODY_PG_LOCAL_TEST !== undefined && !insecure) throw new Error('Invalid local-test switch'); - if (insecure && !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) throw new Error('Non-TLS test connections must be loopback'); - if (!insecure && (!url.username || !url.password)) throw new Error('PostgreSQL credentials required'); - if (url.port && (!/^\d+$/.test(url.port) || Number(url.port) < 1 || Number(url.port) > 65535)) throw new Error('Invalid PostgreSQL port'); - const ca = await publicCaFile(env.WIKIDECK_CUSTODY_PG_CA_FILE); - return { host: url.hostname.replace(/^\[|\]$/g, ''), port: url.port ? Number(url.port) : 5432, - database: url.pathname.slice(1), user: decodeURIComponent(url.username), password: decodeURIComponent(url.password), - // Infrastructure mode is explicit plaintext on the driver's hop. It does NOT configure or - // verify a service mesh, peer identity, network policy, or encryption outside this process. - ssl: insecure || infrastructure ? false : { rejectUnauthorized: true, ...(ca ? { ca } : {}) }, - max: 4, connectionTimeoutMillis: 5000, idleTimeoutMillis: 10000, - statement_timeout: 10000, lock_timeout: 3000, idle_in_transaction_session_timeout: 10000, - application_name: 'wikideck-custody', options: '-c search_path=wk_custody,pg_catalog' }; - } catch { throw new Error('Invalid or inaccessible custody PostgreSQL configuration; check secret files and explicit transport policy'); } -} diff --git a/server/market/postgres-custody.ts b/server/market/postgres-custody.ts deleted file mode 100755 index 7dcc9cd..0000000 --- a/server/market/postgres-custody.ts +++ /dev/null @@ -1,464 +0,0 @@ -import { Pool, type PoolClient, type PoolConfig } from 'pg'; -import { createHash, randomUUID } from 'node:crypto'; -import { IS_MAINNET, NETWORK } from '../../shared/network.ts'; -import { CHAIN_ID, verifySignature } from '../testnet/consensus.ts'; -import { Ledger } from '../testnet/ledger.ts'; -import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts'; -import { allocateXmrSale } from '../../shared/xmr-pricing.ts'; -import { parseAtomicXmr } from '../../shared/xmr-amount.ts'; -import { CUSTODY_POLICY, MOCK_CREDIT_ATOMIC, custodyCommandSchema, custodyMessage, - type CustodyActivity, type CustodyCommand, type CustodyInfo, type CustodyListing, - type CustodyOrder, type CustodyPage, type CustodyWithdrawal } from '../../shared/custody.ts'; -import { CustodyError } from './custody.ts'; -import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts'; -import { resolvedMarketPolicy, validateMarketPolicy, permitsMarketAction, type MarketPolicy } from './policy.ts'; -import { CustodyIntegrityError, auditBusinessLiabilities, auditOrder, auditWithdrawal } from './custody-integrity.ts'; -import { replayReservedSale } from './sale-rehearsal.ts'; -import { CanonicalInboxError, auditCanonicalInbox, ingestCanonicalPage } from './canonical-inbox.ts'; - -export type CustodyOptions = { feeBps: number; card(hash: string, owner: string, sequence: number): { title: string } | undefined; - now?: () => number; disposableSaleRehearsal?: boolean } & MarketPolicy; -type Result = CustodyActivity | CustodyListing | CustodyOrder | CustodyWithdrawal; -export type CustodyAudit = { journals: number; assetsAtomicXmr: string; claimsAndRevenueAtomicXmr: string }; -type Line = { account: string; normal?: 'debit' | 'credit'; debit?: bigint; credit?: bigint }; -const available = (actor: string) => `customer:${actor}:available`; -const reserved = (o: CustodyOrder) => `customer:${o.buyer}:purchase:${o.id}`; -const held = (w: CustodyWithdrawal) => `customer:${w.actor}:withdrawal:${w.id}`; -function check(ok: unknown, message: string): asserts ok { if (!ok) throw new CustodyError(message); } - -/** PostgreSQL-backed SAME sandbox protocol. No real XMR, NFT witness, or automatic schema migration. */ -export class PostgresCustody { - readonly id: string; - private readonly pool: Pool; - private readonly options: CustodyOptions; - private active = 0; - private readonly auditTimer: ReturnType; - private auditInFlight: Promise | undefined; - private integrityHold = false; - private auditState: { status: 'ok' | 'failed'; lastAttemptAt: number; lastSuccessAt: number; failureCode: string | null }; - private constructor(pool: Pool, options: CustodyOptions, id: string) { - this.pool = pool; this.options = options; this.id = id; - this.auditState = { status: 'ok', lastAttemptAt: Date.now(), lastSuccessAt: Date.now(), failureCode: null }; - this.auditTimer = setInterval(() => { void this.audit().catch(() => { - console.error('Custody financial audit unavailable or inconsistent; mutations held pending operator review.'); - }); }, 60000); - this.auditTimer.unref(); - } - static async open(config: PoolConfig, options: CustodyOptions): Promise { - if (IS_MAINNET) throw new Error('Custody sandbox cannot run on mainnet'); - if (options.disposableSaleRehearsal && (config.database !== 'custody_test' || process.env.WIKIDECK_PG_DISPOSABLE_TEST !== '1')) { - throw new Error('Sale rehearsal requires the explicitly isolated disposable database'); - } - await validateMarketPolicy(options); - if (!Number.isSafeInteger(options.feeBps) || options.feeBps < 0 || options.feeBps > 5000) throw new Error('Invalid custody fee'); - const pool = new Pool(config); - // Never log connection details or let idle-client socket errors become uncaught process exceptions. - pool.on('error', () => { console.error('Custody PostgreSQL connection unavailable; requests will fail closed.'); }); - try { - const c = await pool.connect(); - let id: string; - try { - if (options.disposableSaleRehearsal && (await c.query('SELECT current_database() AS name')).rows[0].name !== 'custody_test') { - throw new Error('Sale rehearsal requires the explicitly isolated disposable database'); - } - await c.query('BEGIN'); - await c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]); - await verifyCustodySchema(c); - const privilege = await c.query(`SELECT r.rolsuper,r.rolcreatedb,r.rolcreaterole,r.rolreplication,r.rolbypassrls, - pg_has_role(current_user,n.nspowner,'MEMBER') AS owns_schema - FROM pg_roles r CROSS JOIN pg_namespace n WHERE r.rolname=current_user AND n.nspname='wk_custody'`); - if (!privilege.rowCount || Object.values(privilege.rows[0]).some(Boolean)) { - throw new Error('Custody runtime requires a non-administrative role separate from the schema owner'); - } - const existing = await c.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'"); - if (existing.rowCount) { - const v = JSON.parse(existing.rows[0].value); - if (v.version !== 1 || v.network !== NETWORK || v.chainId !== CHAIN_ID || v.mode !== 'sandbox' || - v.policy !== CUSTODY_POLICY || !/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(v.id)) throw new Error('Custody PostgreSQL identity mismatch'); - id = v.id; - } else { - id = randomUUID(); - await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2)', ['identity', JSON.stringify({ version: 1, - network: NETWORK, chainId: CHAIN_ID, mode: 'sandbox', policy: CUSTODY_POLICY, id })]); - } - await auditPostgres(c); - await c.query('COMMIT'); - } catch (e) { - await c.query('ROLLBACK').catch(() => {}); - if (e instanceof CustodyIntegrityError) await recordIntegrityIncident(c, e.code).catch(() => { - console.error('Custody integrity incident could not be persisted; fence all custody writers and inspect database availability.'); - }); - throw e; - } - finally { c.release(); } - return new PostgresCustody(pool, options, id); - } catch (e) { await pool.end(); throw e; } - } - async close(): Promise { clearInterval(this.auditTimer); await this.auditInFlight?.catch(() => {}); await this.pool.end(); } - diagnostics() { - const stale = Date.now() - this.auditState.lastSuccessAt > 120000; - return { backend: 'postgres', mutationHold: this.integrityHold || this.auditState.status !== 'ok' || stale, - integrityHold: this.integrityHold, audit: { ...this.auditState, stale } }; - } - info(): CustodyInfo { - return { enabled: true, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK, chainId: CHAIN_ID, - custodianId: this.id, feeBps: this.options.feeBps, ...resolvedMarketPolicy(this.options), liveDeposits: false, liveWithdrawals: false, nftSettlement: false }; - } - get rehearsalObserverEnabled(): boolean { return this.options.disposableSaleRehearsal === true; } - private async transaction(write: boolean, fn: (c: PoolClient) => Promise): Promise { - check(this.active < 16, 'Custody database busy; retry the original command shortly'); - this.active++; - let c: PoolClient | undefined; - let mutationSavepoint = false; - try { - c = await this.pool.connect(); - await c.query(write ? 'BEGIN' : 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'); - await c.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'; SET LOCAL synchronous_commit=on"); - // One writer per financial domain initially; other readers remain nonblocking. Lock is database-scoped, - // shared by every node/process, migrations and import, not an in-process promise queue. - if (write) { - await c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]); - await c.query('SAVEPOINT custody_mutation'); mutationSavepoint = true; - } - const result = await fn(c); - await c.query('COMMIT'); - return result; - } catch (e) { - if (e instanceof CustodyIntegrityError) { - this.integrityHold = true; - this.auditState = { ...this.auditState, status: 'failed', lastAttemptAt: Date.now(), failureCode: e.code }; - // Roll back business changes but retain the cross-process writer lock until the incident is committed. - // Read-only/background audit failures require a separate marker transaction; DB/process loss can still - // prevent persistence, so external writer fencing remains an incident-response requirement. - if (c) { - try { - if (mutationSavepoint) await c.query('ROLLBACK TO SAVEPOINT custody_mutation'); - else await c.query('ROLLBACK'); - await recordIntegrityIncident(c, e.code); - if (mutationSavepoint) await c.query('COMMIT'); - } catch { - await c.query('ROLLBACK').catch(() => {}); - console.error('Custody integrity incident could not be persisted; fence all custody writers and inspect database availability.'); - } - } - throw e; - } - await c?.query('ROLLBACK').catch(() => {}); - // Never retry after an ambiguous COMMIT. Signed receipts resolve the SAME nonce on a client retry. - if (e instanceof CustodyError) throw e; - if ((e as { code?: string }).code === '23514') throw new CustodyError('Insufficient balance or custody amount constraint'); - throw new Error('Custody PostgreSQL operation failed; refresh or retry the original signed command'); - } finally { c?.release(); this.active--; } - } - async browse(offset = 0): Promise { - check(Number.isSafeInteger(offset) && offset >= 0 && offset <= 10000, 'Invalid custody page'); - if (!resolvedMarketPolicy(this.options).marketEnabled) return { info: this.info(), listings: [], next: null }; - return this.transaction(false, async c => { - const { rows } = await c.query("SELECT data FROM wk_custody.listings WHERE status IN ('open','reserved') ORDER BY sequence DESC LIMIT 25 OFFSET $1", [offset]); - return { info: this.info(), listings: rows.slice(0, 24).map(r => r.data), next: rows.length > 24 ? offset + 24 : null }; - }); - } - private async activity(c: PoolClient, actor: string, offset: number): Promise { - const orders = await c.query('SELECT data FROM wk_custody.orders WHERE buyer=$1 OR seller=$1 ORDER BY sequence DESC LIMIT 25 OFFSET $2', [actor, offset]); - const withdrawals = await c.query('SELECT data FROM wk_custody.withdrawals WHERE actor=$1 ORDER BY sequence DESC LIMIT 25 OFFSET $2', [actor, offset]); - const sums = await c.query(`SELECT COALESCE(sum(balance) FILTER(WHERE id=$1),0)::text AS available, - COALESCE(sum(balance) FILTER(WHERE id LIKE $2),0)::text AS purchase, - COALESCE(sum(balance) FILTER(WHERE id LIKE $3),0)::text AS withdrawal FROM wk_custody.accounts WHERE id LIKE $4`, - [available(actor), `customer:${actor}:purchase:%`, `customer:${actor}:withdrawal:%`, `customer:${actor}:%`]); - const claimed = await c.query('SELECT 1 FROM wk_custody.credits WHERE actor=$1', [actor]); - return { availableAtomicXmr: sums.rows[0].available, purchaseReservedAtomicXmr: sums.rows[0].purchase, - withdrawalReservedAtomicXmr: sums.rows[0].withdrawal, mockCreditClaimed: !!claimed.rowCount, - orders: orders.rows.slice(0, 24).map(r => r.data), withdrawals: withdrawals.rows.slice(0, 24).map(r => r.data), - next: orders.rows.length > 24 || withdrawals.rows.length > 24 ? offset + 24 : null }; - } - private async post(c: PoolClient, id: string, kind: string, lines: Line[]) { - const nonzero = lines.filter(l => (l.debit ?? 0n) !== 0n || (l.credit ?? 0n) !== 0n); - if (!nonzero.length || nonzero.reduce((s, l) => s + (l.debit ?? 0n) - (l.credit ?? 0n), 0n) !== 0n) throw new Error('Unbalanced custody posting'); - await c.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)', [id, kind, (this.options.now ?? Date.now)()]); - for (const [position, line] of nonzero.entries()) { - const normal = line.normal ?? 'credit'; - await c.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,$2) ON CONFLICT(id) DO NOTHING', [line.account, normal]); - // Runtime intentionally has no UPDATE privilege on balances. The SECURITY DEFINER projection - // trigger takes the account row lock; global writer admission protects the preceding normal check. - const account = await c.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [line.account]); - if (account.rows[0].normal !== normal) throw new Error('Custody account normal mismatch'); - // DB trigger updates projection; deferred trigger rejects an empty/unbalanced journal at COMMIT. - await c.query('INSERT INTO wk_custody.entries VALUES($1,$2,$3,$4,$5)', - [id, position, line.account, (line.debit ?? 0n).toString(), (line.credit ?? 0n).toString()]); - } - } - private async record(c: PoolClient, table: 'listings' | 'orders' | 'withdrawals', id: string): Promise { - const r = await c.query(`SELECT data FROM wk_custody.${table} WHERE id=$1 FOR UPDATE`, [id]); - check(r.rowCount, 'Custody record not found'); return r.rows[0].data as T; - } - private async update(c: PoolClient, table: 'listings' | 'orders' | 'withdrawals', record: { id: string; status: string }) { - await c.query(`UPDATE wk_custody.${table} SET status=$1,data=$2 WHERE id=$3`, [record.status, JSON.stringify(record), record.id]); - } - async execute(raw: unknown): Promise { - const cmd = custodyCommandSchema.parse(raw), message = custodyMessage(cmd), now = (this.options.now ?? Date.now)(); - check(cmd.custodianId === this.id && cmd.chainId === CHAIN_ID && cmd.expiresAt >= now && cmd.expiresAt <= now + 300000 && - verifySignature(cmd.publicKey, cmd.actor, message, cmd.signature), 'Invalid or expired custody signature'); - const a = cmd.action; - if (a.type === 'activity') return this.transaction(false, c => this.activity(c, cmd.actor, a.offset)); - return this.transaction(true, async c => { - const key = `${cmd.actor}:${cmd.nonce}`, hash = createHash('sha256').update(message).digest('hex'); - const old = await c.query('SELECT digest,data FROM wk_custody.receipts WHERE id=$1', [key]); - if (old.rowCount) { check(old.rows[0].digest === hash, 'Nonce already used for different custody terms'); return old.rows[0].data as Result; } - const incident = await c.query("SELECT 1 FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'"); - check(!incident.rowCount, 'Custody integrity incident is unresolved; operator review required'); - check(!this.diagnostics().mutationHold, 'Custody financial reconciliation requires operator review; balances remain readable and original receipts can be replayed'); - check(permitsMarketAction(this.options, a.type), 'New market trading is disabled by this host; existing balances and resolution remain available'); - if (a.type === 'reserve' || a.type === 'withdraw') { - const depositHold = await c.query(`SELECT 1 FROM wk_custody.monero_deposit_credit_events d - JOIN wk_custody.monero_deposit_reorg_holds h ON h.credit_id=d.credit_id - LEFT JOIN wk_custody.monero_deposit_reorg_resolutions r ON r.hold_id=h.hold_id - WHERE d.beneficiary=$1 AND r.hold_id IS NULL LIMIT 1`, [cmd.actor]); - check(!depositHold.rowCount, 'Customer deposit is under reorg review; new spending is held'); - } - // Sandbox admission budget matches SQLite. Receipt/history are NEVER pruned; cancellation stays available. - if (!['cancel-listing', 'cancel-reservation', 'cancel-withdrawal'].includes(a.type)) { - const count = await c.query('SELECT count(*)::integer AS n FROM (SELECT 1 FROM wk_custody.receipts LIMIT 10000) b'); - check(count.rows[0].n < 10000, 'Custody sandbox command budget reached'); - } - // Verify existing liabilities BEFORE resolving them, and validate the new business state before COMMIT. - if (a.type === 'cancel-reservation') await auditOrder(c, a.orderId); - if (a.type === 'cancel-withdrawal') await auditWithdrawal(c, a.withdrawalId); - const result = await this.mutate(c, cmd); - if (a.type === 'reserve' || a.type === 'cancel-reservation') await auditOrder(c, (result as CustodyOrder).id); - if (a.type === 'withdraw' || a.type === 'cancel-withdrawal') await auditWithdrawal(c, (result as CustodyWithdrawal).id); - await c.query('INSERT INTO wk_custody.receipts VALUES($1,$2,$3)', [key, hash, JSON.stringify(result)]); - return result; - }); - } - private async mutate(c: PoolClient, cmd: CustodyCommand): Promise { - const a = cmd.action, actor = cmd.actor, createdAt = (this.options.now ?? Date.now)(); - if (a.type === 'mock-credit') { - check(!(await c.query('SELECT 1 FROM wk_custody.credits WHERE actor=$1', [actor])).rowCount, 'Sandbox credit already claimed'); - await this.post(c, `credit:${actor}`, 'mock-deposit', [ - { account: 'treasury:mock', normal: 'debit', debit: BigInt(MOCK_CREDIT_ATOMIC) }, - { account: available(actor), credit: BigInt(MOCK_CREDIT_ATOMIC) }]); - await c.query('INSERT INTO wk_custody.credits VALUES($1)', [actor]); return this.activity(c, actor, 0); - } - if (a.type === 'list') { - check(a.feeBps === this.options.feeBps, 'Custody hosting fee changed; review again'); - const hostFeeAddress = resolvedMarketPolicy(this.options).hostFeeAddress; - check((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) === hostFeeAddress, 'Host fee recipient changed; review and sign again'); - const card = this.options.card(a.cardHash, actor, a.sequence); - check(card, 'Card is missing, stale, immature, or pending'); - check(!(await c.query("SELECT 1 FROM wk_custody.listings WHERE card=$1 AND status IN ('open','reserved')", [a.cardHash])).rowCount, 'Card already has a custody listing'); - const l: CustodyListing = { id: randomUUID(), seller: actor, cardHash: a.cardHash, sequence: a.sequence, - title: card.title, priceAtomicXmr: a.priceAtomicXmr, feeBps: a.feeBps, hostFeeAddress, status: 'open', createdAt }; - await c.query('INSERT INTO wk_custody.listings(id,card,status,data) VALUES($1,$2,$3,$4)', [l.id, l.cardHash, l.status, JSON.stringify(l)]); return l; - } - if (a.type === 'reserve' || a.type === 'cancel-listing') { - const l = await this.record(c, 'listings', a.listingId); - check(l.status === 'open', 'Custody listing is no longer available'); - if (a.type === 'cancel-listing') { - check(l.seller === actor, 'Only the seller can cancel this listing'); l.status = 'cancelled'; await this.update(c, 'listings', l); return l; - } - check(actor !== l.seller, 'Cannot buy your own card'); - check(a.priceAtomicXmr === l.priceAtomicXmr && a.feeBps === l.feeBps, 'Custody sale terms changed'); - const hostFeeAddress = l.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address; - check((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) === hostFeeAddress, 'Host fee recipient does not match listing terms'); - check(this.options.card(l.cardHash, l.seller, l.sequence), 'Seller card is no longer available'); - const allocation = allocateXmrSale(parseAtomicXmr(a.priceAtomicXmr), a.feeBps); - const o: CustodyOrder = { id: randomUUID(), listingId: l.id, seller: l.seller, buyer: actor, cardHash: l.cardHash, - sequence: l.sequence, grossAtomicXmr: a.priceAtomicXmr, sellerAtomicXmr: allocation.sellerBeforeNetworkCosts.toString(), - authorAtomicXmr: allocation.author.toString(), hostAtomicXmr: allocation.hosting.toString(), authorAddress: ADDITIONAL_MARKET_FEE.address, hostFeeAddress, - status: 'awaiting-lock', createdAt }; - await this.post(c, `reserve:${o.id}`, 'purchase-reservation', [{ account: available(actor), debit: allocation.gross }, { account: reserved(o), credit: allocation.gross }]); - l.status = 'reserved'; await this.update(c, 'listings', l); - await c.query('INSERT INTO wk_custody.orders(id,buyer,seller,status,data) VALUES($1,$2,$3,$4,$5)', [o.id, actor, o.seller, o.status, JSON.stringify(o)]); - await c.query('INSERT INTO wk_custody.outbox VALUES($1,$2,$3,$4)', [o.id, 'nft-lock', 'blocked', JSON.stringify(o)]); return o; - } - if (a.type === 'cancel-reservation') { - const o = await this.record(c, 'orders', a.orderId); - check(!(await c.query('SELECT 1 FROM wk_custody.custody_meta WHERE key=$1', [`sale-rehearsal-binding:${o.id}`])).rowCount, - 'Rehearsal sale authorization exists; cancellation requires replayed terminal evidence'); - const intent = await c.query('SELECT status FROM wk_custody.outbox WHERE id=$1 FOR UPDATE', [o.id]); - check(o.buyer === actor && o.status === 'awaiting-lock' && intent.rows[0]?.status === 'blocked', 'Only an unstarted reservation can be cancelled by its buyer'); - await this.post(c, `cancel:${o.id}`, 'purchase-cancellation', [{ account: reserved(o), debit: BigInt(o.grossAtomicXmr) }, { account: available(actor), credit: BigInt(o.grossAtomicXmr) }]); - o.status = 'cancelled'; await this.update(c, 'orders', o); - const l = await this.record(c, 'listings', o.listingId); l.status = 'cancelled'; await this.update(c, 'listings', l); - await c.query("UPDATE wk_custody.outbox SET status='cancelled' WHERE id=$1", [o.id]); return o; - } - if (a.type === 'withdraw') { - const w: CustodyWithdrawal = { id: randomUUID(), actor, destination: a.destination, amountAtomicXmr: a.amountAtomicXmr, - maxFeeAtomicXmr: a.maxFeeAtomicXmr, status: 'reserved', createdAt }; - const total = BigInt(a.amountAtomicXmr) + BigInt(a.maxFeeAtomicXmr); - await this.post(c, `withdraw:${w.id}`, 'withdrawal-reservation', [{ account: available(actor), debit: total }, { account: held(w), credit: total }]); - await c.query('INSERT INTO wk_custody.withdrawals(id,actor,status,data) VALUES($1,$2,$3,$4)', [w.id, actor, w.status, JSON.stringify(w)]); - await c.query('INSERT INTO wk_custody.outbox VALUES($1,$2,$3,$4)', [w.id, 'withdrawal', 'blocked', JSON.stringify(w)]); return w; - } - if (a.type === 'cancel-withdrawal') { - const w = await this.record(c, 'withdrawals', a.withdrawalId); - check(w.actor === actor && w.status === 'reserved', 'Only an unprepared withdrawal can be cancelled by its owner'); - const total = BigInt(w.amountAtomicXmr) + BigInt(w.maxFeeAtomicXmr); - await this.post(c, `withdraw-cancel:${w.id}`, 'withdrawal-cancellation', [{ account: held(w), debit: total }, { account: available(actor), credit: total }]); - w.status = 'cancelled'; await this.update(c, 'withdrawals', w); - await c.query("UPDATE wk_custody.outbox SET status='cancelled' WHERE id=$1", [w.id]); return w; - } - throw new CustodyError('Unsupported custody action'); - } - /** Disposable database ONLY. Not wired to HTTP, configuration flags, live chain or a signer. */ - observeSaleRehearsal(orderId: string, transcript: unknown) { - check(this.options.disposableSaleRehearsal === true, 'Sale rehearsal is unavailable on this custody instance'); - return this.transaction(true, async c => { - check(!this.diagnostics().mutationHold, 'Custody reconciliation hold'); - check(!(await c.query("SELECT 1 FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'")).rowCount, 'Custody integrity incident unresolved'); - const o = await this.record(c, 'orders', orderId); - await auditOrder(c, o.id); - const l = await this.record(c, 'listings', o.listingId); - const replay = replayReservedSale(o, this.id, transcript, l.feeBps); - const key = `sale-rehearsal-binding:${o.id}`; - const old = await c.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1', [key]); - if (old.rowCount) check(old.rows[0].value === replay.binding, 'Rehearsal authorization changed'); - else { - check(o.status === 'awaiting-lock', 'Cannot bind an already resolved order to a rehearsal'); - await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2)', [key, replay.binding]); - } - if (replay.decision.action === 'review') throw new CustodyIntegrityError('sale-rehearsal-outcome-reorg'); - // Retain the first signed observation plus terminal evidence, not a canonical event inbox. - // Runtime has INSERT but intentionally no UPDATE privilege on these metadata records. - const evidenceKey = `sale-rehearsal-observation:${o.id}`; - await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2) ON CONFLICT(key) DO NOTHING', [evidenceKey, - JSON.stringify({ version: 1, binding: replay.binding, stateRoot: replay.stateRoot, input: replay.input })]); - if (replay.decision.action === 'settle' || replay.decision.action === 'refund') { - check(o.status === 'awaiting-lock', 'Rehearsal order already resolved'); - if (replay.decision.action === 'settle') { - await this.post(c, `settle:${o.id}`, 'mock-sale-settlement', [ - { account: reserved(o), debit: BigInt(o.grossAtomicXmr) }, - { account: available(o.seller), credit: BigInt(o.sellerAtomicXmr) }, - { account: `author:${o.authorAddress}`, credit: BigInt(o.authorAtomicXmr) }, - { account: o.hostFeeAddress ? `revenue:host:${o.hostFeeAddress}` : 'revenue:host', credit: BigInt(o.hostAtomicXmr) }]); - o.status = 'settled'; l.status = 'sold'; - await c.query("UPDATE wk_custody.outbox SET status='fixture-complete',data=$1 WHERE id=$2", [`fixture:${replay.stateRoot}`, o.id]); - } else { - await this.post(c, `cancel:${o.id}`, 'purchase-cancellation', [ - { account: reserved(o), debit: BigInt(o.grossAtomicXmr) }, { account: available(o.buyer), credit: BigInt(o.grossAtomicXmr) }]); - o.status = 'cancelled'; l.status = 'cancelled'; - await c.query("UPDATE wk_custody.outbox SET status='cancelled' WHERE id=$1", [o.id]); - } - await this.update(c, 'orders', o); await this.update(c, 'listings', l); - await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2)', [`sale-rehearsal-terminal:${o.id}`, - JSON.stringify({ version: 1, binding: replay.binding, stateRoot: replay.stateRoot, input: replay.input })]); - await c.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)', - [`sale-rehearsal-terminal-index:${o.id}`, JSON.stringify({ version: 1, - terminalTransactionId: replay.terminalTransactionId ?? null, stateRoot: replay.stateRoot })]); - if (replay.terminalTransactionId) { - await c.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)', - [`sale-rehearsal-tx:${replay.terminalTransactionId}`, - JSON.stringify({ version: 1, orderId: o.id, stateRoot: replay.stateRoot })]); - } - await auditOrder(c, o.id); - } - return { order: o, action: replay.decision.action, reservationDigest: replay.reservationDigest, stateRoot: replay.stateRoot }; - }); - } - /** Disposable observer rehearsal only; input must come from Ledger.canonicalEvents(), never peers/HTTP. */ - async observeCanonicalEvents(page: unknown) { - check(this.options.disposableSaleRehearsal === true, 'Canonical event inbox rehearsal is unavailable on this custody instance'); - const result = await this.transaction(true, async c => { - check(!this.diagnostics().mutationHold, 'Custody reconciliation hold'); - check(!(await c.query("SELECT 1 FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'")).rowCount, 'Custody integrity incident unresolved'); - try { - const result = await ingestCanonicalPage(c, page); - const reviews: { orderId: string; terminalTransactionId: string; stateRoot: string; eventSequence: number; eventHash: string }[] = []; - for (const event of result.appendedEvents) { - if (event.type !== 'disconnect') continue; - for (const txid of event.transactionIds) { - const terminal = await c.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1', [`sale-rehearsal-tx:${txid}`]); - if (terminal.rowCount) { - const link = JSON.parse(terminal.rows[0].value); - if (link.version !== 1 || typeof link.orderId !== 'string' || typeof link.stateRoot !== 'string') { - throw new CustodyIntegrityError('sale-terminal-index-invalid'); - } - reviews.push({ orderId: link.orderId, terminalTransactionId: txid, stateRoot: link.stateRoot, - eventSequence: event.sequence, eventHash: event.eventHash }); - } - } - } - for (const review of reviews) { - await c.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2) ON CONFLICT(key) DO NOTHING', - [`sale-reorg-review:${review.orderId}:${review.eventSequence}`, JSON.stringify({ version: 1, - streamId: result.streamId, ...review })]); - } - if (reviews.length) await recordIntegrityIncident(c, 'posted-sale-terminal-disconnected'); - return { ...result, appendedEvents: [], reviewRequired: reviews.length > 0 }; - } - catch (error) { - if (error instanceof CanonicalInboxError) throw new CustodyIntegrityError(`canonical-inbox-${error.code}`); - throw error; - } - }); - if (result.reviewRequired) { - this.integrityHold = true; - this.auditState = { ...this.auditState, status: 'failed', lastAttemptAt: Date.now(), failureCode: 'posted-sale-terminal-disconnected' }; - } - return result; - } - /** Consume one bounded page directly from this process's locally validated ledger; no HTTP/peer feed. */ - async observeLedgerCanonicalEvents(ledger: Ledger) { - check(this.options.disposableSaleRehearsal === true, 'Canonical event inbox rehearsal is unavailable on this custody instance'); - check(ledger instanceof Ledger, 'Canonical inbox requires a local Ledger instance'); - const summary = ledger.summary(); - check(summary.network === NETWORK && summary.chainId === CHAIN_ID, 'Canonical ledger identity mismatch'); - const cursor = await this.transaction(false, async c => { - const row = await c.query(`SELECT key FROM wk_custody.custody_meta - WHERE key LIKE 'canonical-inbox:v1:event:%' ORDER BY key DESC LIMIT 1`); - if (!row.rowCount) return 0; - const match = /^canonical-inbox:v1:event:(\d{16})$/.exec(row.rows[0].key); - if (!match) throw new CustodyIntegrityError('canonical-inbox-cursor-invalid'); - return Number(match[1]); - }); - let page: unknown; - try { page = ledger.canonicalEvents(cursor, 64); } - catch { throw new CustodyIntegrityError('canonical-ledger-cursor-mismatch'); } - return this.observeCanonicalEvents(page); - } - audit(): Promise { - if (this.auditInFlight) return this.auditInFlight; - this.auditState.lastAttemptAt = Date.now(); - const pending = this.transaction(false, auditPostgres).then(result => { - this.auditState = { ...this.auditState, lastSuccessAt: Date.now(), - status: this.integrityHold ? 'failed' : 'ok', failureCode: this.integrityHold ? this.auditState.failureCode : null }; - return result; - }).catch(error => { - this.auditState = { ...this.auditState, status: 'failed', failureCode: error instanceof CustodyIntegrityError ? error.code : 'audit-unavailable' }; - throw error; - }).finally(() => { this.auditInFlight = undefined; }); - this.auditInFlight = pending; return pending; - } - importSource(): Promise { - return this.transaction(false, async c => (await c.query("SELECT value FROM wk_custody.custody_meta WHERE key='import_source'")).rows[0]?.value); - } -} - -/** Exact NUMERIC/text aggregation in a coherent transaction; no Number conversion of money. */ -export async function auditPostgres(c: PoolClient): Promise { - const incident = await c.query("SELECT 1 FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'"); - if (incident.rowCount) throw new CustodyIntegrityError('unresolved-integrity-incident'); - try { await auditCanonicalInbox(c); } - catch (error) { - if (error instanceof CanonicalInboxError) throw new CustodyIntegrityError(`canonical-inbox-${error.code}`); - throw error; - } - const bad = await c.query(`SELECT 1 FROM wk_custody.journals j LEFT JOIN wk_custody.entries e ON e.journal=j.id - GROUP BY j.id HAVING count(e.position)=0 OR sum(e.debit-e.credit)<>0 LIMIT 1`); - if (bad.rowCount) throw new CustodyIntegrityError('unbalanced-journal'); - const mismatch = await c.query(`SELECT 1 FROM wk_custody.accounts a LEFT JOIN wk_custody.entries e ON e.account=a.id - GROUP BY a.id HAVING a.balance<>COALESCE(sum(CASE WHEN a.normal='debit' THEN e.debit-e.credit ELSE e.credit-e.debit END),0) LIMIT 1`); - if (mismatch.rowCount) throw new CustodyIntegrityError('balance-projection-mismatch'); - const totals = await c.query(`SELECT COALESCE(sum(balance) FILTER(WHERE normal='debit'),0)::text AS assets, - COALESCE(sum(balance) FILTER(WHERE normal='credit'),0)::text AS claims FROM wk_custody.accounts`); - if (totals.rows[0].assets !== totals.rows[0].claims) throw new CustodyIntegrityError('accounting-backing-mismatch'); - await auditBusinessLiabilities(c); - const n = await c.query('SELECT count(*)::text AS n FROM wk_custody.journals'); - if (BigInt(n.rows[0].n) > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('Journal count exceeds supported range'); - return { journals: Number(n.rows[0].n), assetsAtomicXmr: totals.rows[0].assets, claimsAndRevenueAtomicXmr: totals.rows[0].claims }; -} - -async function recordIntegrityIncident(c: PoolClient, code: string): Promise { - await c.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2) ON CONFLICT(key) DO NOTHING', - ['integrity_incident_v1', JSON.stringify({ version: 1, code, detectedAt: Date.now() })]); -} diff --git a/server/market/postgres-schema.ts b/server/market/postgres-schema.ts deleted file mode 100755 index 846af35..0000000 --- a/server/market/postgres-schema.ts +++ /dev/null @@ -1,1036 +0,0 @@ -import { createHash } from 'node:crypto'; -import type { Pool, PoolClient } from 'pg'; - -export const CUSTODY_PG_VERSION = 14; -export const CUSTODY_WRITE_LOCK = '7140824361'; -export const MONERO_OBSERVER_WORKER_LOCK = '7140824362'; -export const CUSTODY_SCHEMA_V1 = ` -CREATE SCHEMA IF NOT EXISTS wk_custody; -REVOKE ALL ON SCHEMA wk_custody FROM PUBLIC; -SET LOCAL search_path = wk_custody, pg_catalog; -CREATE TABLE schema_versions(version integer PRIMARY KEY, digest text NOT NULL); -CREATE TABLE custody_meta(key text PRIMARY KEY, value text NOT NULL); -CREATE TABLE accounts(id text PRIMARY KEY, normal text NOT NULL CHECK(normal IN ('debit','credit')), - balance numeric NOT NULL DEFAULT 0 CHECK(balance>=0 AND balance<1e30 AND balance=trunc(balance))); -CREATE TABLE journals(sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE, id text PRIMARY KEY, - kind text NOT NULL, created bigint NOT NULL CHECK(created>=0), posting_tx bigint NOT NULL DEFAULT txid_current()); -CREATE TABLE entries(journal text NOT NULL REFERENCES journals(id), position integer NOT NULL CHECK(position>=0), - account text NOT NULL REFERENCES accounts(id), debit numeric NOT NULL, credit numeric NOT NULL, - PRIMARY KEY(journal,position), CHECK(debit>=0 AND credit>=0 AND debit<1e30 AND credit<1e30), - CHECK(debit=trunc(debit) AND credit=trunc(credit) AND ((debit>0 AND credit=0) OR (credit>0 AND debit=0)))); -CREATE INDEX entries_account ON entries(account,journal); -CREATE TABLE listings(sequence bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE, id uuid PRIMARY KEY, card text NOT NULL, - status text NOT NULL CHECK(status IN ('open','reserved','cancelled','sold')), data jsonb NOT NULL); -CREATE UNIQUE INDEX one_active_card ON listings(card) WHERE status IN ('open','reserved'); -CREATE TABLE orders(sequence bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE, id uuid PRIMARY KEY, - buyer text NOT NULL, seller text NOT NULL, status text NOT NULL CHECK(status IN ('awaiting-lock','cancelled','settled')), data jsonb NOT NULL); -CREATE INDEX orders_buyer ON orders(buyer,sequence DESC); -CREATE INDEX orders_seller ON orders(seller,sequence DESC); -CREATE TABLE withdrawals(sequence bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE, id uuid PRIMARY KEY, actor text NOT NULL, - status text NOT NULL CHECK(status IN ('reserved','prepared','complete','cancelled')), data jsonb NOT NULL); -CREATE INDEX withdrawals_actor ON withdrawals(actor,sequence DESC); -CREATE TABLE receipts(id text PRIMARY KEY, digest text NOT NULL, data jsonb NOT NULL); -CREATE TABLE outbox(id uuid PRIMARY KEY, kind text NOT NULL CHECK(kind IN ('nft-lock','withdrawal')), status text NOT NULL, data text NOT NULL); -CREATE TABLE credits(actor text PRIMARY KEY); -CREATE FUNCTION immutable_history() RETURNS trigger LANGUAGE plpgsql SET search_path=wk_custody,pg_catalog AS $$ -BEGIN RAISE EXCEPTION 'Immutable financial history'; END $$; -CREATE TRIGGER entries_immutable BEFORE UPDATE OR DELETE ON entries FOR EACH ROW EXECUTE FUNCTION immutable_history(); -CREATE TRIGGER journals_immutable BEFORE UPDATE OR DELETE ON journals FOR EACH ROW EXECUTE FUNCTION immutable_history(); -CREATE TRIGGER receipts_immutable BEFORE UPDATE OR DELETE ON receipts FOR EACH ROW EXECUTE FUNCTION immutable_history(); -CREATE FUNCTION apply_entry() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path=wk_custody,pg_catalog AS $$ -DECLARE n text; amount numeric; -BEGIN - IF NOT EXISTS(SELECT 1 FROM journals WHERE id=NEW.journal AND posting_tx=txid_current()) THEN - RAISE EXCEPTION 'Cannot append to a committed journal'; - END IF; - SELECT normal INTO STRICT n FROM accounts WHERE id=NEW.account FOR UPDATE; - amount := CASE WHEN n='debit' THEN NEW.debit-NEW.credit ELSE NEW.credit-NEW.debit END; - UPDATE accounts SET balance=balance+amount WHERE id=NEW.account; - RETURN NEW; -END $$; -CREATE TRIGGER entry_projection AFTER INSERT ON entries FOR EACH ROW EXECUTE FUNCTION apply_entry(); -CREATE FUNCTION balanced_journal() RETURNS trigger LANGUAGE plpgsql SET search_path=wk_custody,pg_catalog AS $$ -DECLARE total numeric; n bigint; journal_id text; -BEGIN - IF TG_TABLE_NAME='entries' THEN journal_id := NEW.journal; ELSE journal_id := NEW.id; END IF; - SELECT count(*),sum(debit-credit) INTO n,total FROM entries WHERE journal=journal_id; - IF n=0 OR total<>0 THEN RAISE EXCEPTION 'Unbalanced financial journal'; END IF; - RETURN NULL; -END $$; -CREATE CONSTRAINT TRIGGER journal_balance AFTER INSERT ON journals DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION balanced_journal(); -CREATE CONSTRAINT TRIGGER entry_balance AFTER INSERT ON entries DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION balanced_journal(); -REVOKE ALL ON ALL TABLES IN SCHEMA wk_custody FROM PUBLIC; -REVOKE ALL ON ALL SEQUENCES IN SCHEMA wk_custody FROM PUBLIC; -REVOKE ALL ON ALL FUNCTIONS IN SCHEMA wk_custody FROM PUBLIC; -`; -export const CUSTODY_SCHEMA_V1_DIGEST = createHash('sha256').update(CUSTODY_SCHEMA_V1).digest('hex'); -export const CUSTODY_MIGRATION_V2 = ` -CREATE TABLE wk_custody.monero_observer_state( - wallet_fingerprint text PRIMARY KEY CHECK(wallet_fingerprint ~ '^[a-f0-9]{64}$'), - network text NOT NULL CHECK(network='mainnet'), - scan_height bigint NOT NULL CHECK(scan_height>=0), - updated_at bigint NOT NULL CHECK(updated_at>=0) -); -CREATE TABLE wk_custody.monero_transfer_observations( - wallet_fingerprint text NOT NULL REFERENCES wk_custody.monero_observer_state(wallet_fingerprint), - observation_id text NOT NULL, - txid text NOT NULL CHECK(txid ~ '^[a-f0-9]{64}$'), - monero_address text NOT NULL CHECK(length(monero_address)=95), - account_index bigint NOT NULL CHECK(account_index>=0), - address_index bigint NOT NULL CHECK(address_index>=0), - amount_atomic_xmr numeric(20,0) NOT NULL CHECK(amount_atomic_xmr>0 AND amount_atomic_xmr<=18446744073709551615), - block_height bigint NOT NULL CHECK(block_height>0), - wallet_height bigint NOT NULL CHECK(wallet_height>=block_height), - confirmations bigint NOT NULL CHECK(confirmations=wallet_height-block_height+1), - timestamp bigint NOT NULL CHECK(timestamp>=0), - unlock_time text NOT NULL CHECK(unlock_time ~ '^(0|[1-9][0-9]{0,19})$'), - locked boolean NOT NULL, - double_spend_seen boolean NOT NULL, - payload_digest text NOT NULL CHECK(payload_digest ~ '^[a-f0-9]{64}$'), - currently_present boolean NOT NULL DEFAULT true, - last_seen_scan_id uuid NOT NULL, - first_observed_at bigint NOT NULL CHECK(first_observed_at>=0), - last_observed_at bigint NOT NULL CHECK(last_observed_at>=first_observed_at), - PRIMARY KEY(wallet_fingerprint,observation_id), - UNIQUE(wallet_fingerprint,txid,account_index,address_index) -); -CREATE INDEX monero_observations_height ON wk_custody.monero_transfer_observations(wallet_fingerprint,block_height,observation_id); -CREATE TABLE wk_custody.monero_observation_events( - sequence bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - wallet_fingerprint text NOT NULL, - observation_id text NOT NULL, - scan_id uuid NOT NULL, - event_type text NOT NULL CHECK(event_type IN ('seen','changed','reappeared','not-seen')), - payload_digest text NOT NULL CHECK(payload_digest ~ '^[a-f0-9]{64}$'), - wallet_height bigint NOT NULL CHECK(wallet_height>=0), - observed_at bigint NOT NULL CHECK(observed_at>=0), - UNIQUE(wallet_fingerprint,observation_id,scan_id,event_type), - FOREIGN KEY(wallet_fingerprint,observation_id) - REFERENCES wk_custody.monero_transfer_observations(wallet_fingerprint,observation_id) -); -CREATE TABLE wk_custody.monero_observation_scans( - scan_id uuid PRIMARY KEY, - wallet_fingerprint text NOT NULL REFERENCES wk_custody.monero_observer_state(wallet_fingerprint), - from_height bigint NOT NULL CHECK(from_height>=0), - through_height bigint NOT NULL CHECK(through_height>=from_height), - payload_digest text NOT NULL CHECK(payload_digest ~ '^[a-f0-9]{64}$'), - completed_at bigint NOT NULL CHECK(completed_at>=0) -); -CREATE INDEX monero_scans_wallet_height ON wk_custody.monero_observation_scans(wallet_fingerprint,through_height DESC); -CREATE FUNCTION wk_custody.monero_observation_events_immutable() RETURNS trigger LANGUAGE plpgsql SET search_path=wk_custody,pg_catalog AS $$ -BEGIN RAISE EXCEPTION 'Monero observation event history is append-only'; END $$; -CREATE TRIGGER monero_observation_events_no_update BEFORE UPDATE OR DELETE ON wk_custody.monero_observation_events - FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_observation_events_immutable(); -CREATE TRIGGER monero_observation_scans_no_update BEFORE UPDATE OR DELETE ON wk_custody.monero_observation_scans - FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_observation_events_immutable(); -`; -export const CUSTODY_SCHEMA_V2_DIGEST = createHash('sha256') - .update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}`).digest('hex'); -export const CUSTODY_MIGRATION_V3 = ` -CREATE TABLE wk_custody.monero_deposit_allocations( - allocation_id uuid PRIMARY KEY, - request_id uuid NOT NULL UNIQUE, - custodian_id uuid NOT NULL, - market_network text NOT NULL CHECK(market_network IN ('wikideck-randomx-mainnet-1','wikideck-randomx-testnet-6')), - market_chain_id text NOT NULL CHECK(market_chain_id ~ '^[a-f0-9]{64}$'), - beneficiary text NOT NULL CHECK(beneficiary ~ '^[a-f0-9]{64}$'), - wallet_generation_id uuid NOT NULL, - wallet_fingerprint text NOT NULL CHECK(wallet_fingerprint ~ '^[a-f0-9]{64}$'), - monero_network text NOT NULL CHECK(monero_network='mainnet'), - account_index bigint NOT NULL CHECK(account_index BETWEEN 0 AND 4294967295), - address_index bigint NOT NULL CHECK(address_index BETWEEN 0 AND 4294967295), - status text NOT NULL CHECK(status IN ('reserved','registered')), - monero_address text UNIQUE CHECK(monero_address IS NULL OR length(monero_address)=95), - request_digest text NOT NULL CHECK(request_digest ~ '^[a-f0-9]{64}$'), - reserved_at bigint NOT NULL CHECK(reserved_at>=0), - registered_at bigint CHECK(registered_at IS NULL OR registered_at>=reserved_at), - CHECK((status='reserved' AND monero_address IS NULL AND registered_at IS NULL) OR - (status='registered' AND monero_address IS NOT NULL AND registered_at IS NOT NULL)), - UNIQUE(wallet_generation_id,account_index,address_index), - UNIQUE(custodian_id,market_network,market_chain_id,beneficiary,wallet_generation_id) -); -CREATE INDEX monero_deposit_allocations_beneficiary - ON wk_custody.monero_deposit_allocations(custodian_id,market_network,market_chain_id,beneficiary); -CREATE FUNCTION wk_custody.monero_deposit_allocations_immutable() RETURNS trigger LANGUAGE plpgsql - SET search_path=wk_custody,pg_catalog AS $$ -BEGIN - IF TG_OP='DELETE' THEN RAISE EXCEPTION 'Monero deposit allocation history cannot be deleted'; END IF; - IF OLD.status<>'reserved' OR NEW.status<>'registered' OR OLD.monero_address IS NOT NULL OR NEW.monero_address IS NULL OR - NEW.allocation_id IS DISTINCT FROM OLD.allocation_id OR NEW.request_id IS DISTINCT FROM OLD.request_id OR - NEW.custodian_id IS DISTINCT FROM OLD.custodian_id OR NEW.market_network IS DISTINCT FROM OLD.market_network OR - NEW.market_chain_id IS DISTINCT FROM OLD.market_chain_id OR NEW.beneficiary IS DISTINCT FROM OLD.beneficiary OR - NEW.wallet_generation_id IS DISTINCT FROM OLD.wallet_generation_id OR NEW.wallet_fingerprint IS DISTINCT FROM OLD.wallet_fingerprint OR - NEW.monero_network IS DISTINCT FROM OLD.monero_network OR NEW.account_index IS DISTINCT FROM OLD.account_index OR - NEW.address_index IS DISTINCT FROM OLD.address_index OR NEW.request_digest IS DISTINCT FROM OLD.request_digest OR - NEW.reserved_at IS DISTINCT FROM OLD.reserved_at OR NEW.registered_at IS NULL OR NEW.registered_at=0), - ADD CONSTRAINT monero_observation_output_identity CHECK( - (output_public_key IS NULL AND global_output_index IS NULL) OR - (output_public_key IS NOT NULL AND global_output_index IS NOT NULL AND observation_id=(txid || ':' || output_public_key))); -CREATE UNIQUE INDEX monero_observations_output_key - ON wk_custody.monero_transfer_observations(wallet_fingerprint,output_public_key) - WHERE output_public_key IS NOT NULL; -`; -export const CUSTODY_SCHEMA_V4_DIGEST = createHash('sha256') - .update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}`).digest('hex'); -export const CUSTODY_MIGRATION_V5 = ` --- Deferred balance enforcement runs with the schema owner's read access; the --- fixed search_path and static table reference keep the trigger safe for the --- narrowly privileged pending-credit coordinator. -ALTER FUNCTION wk_custody.balanced_journal() SECURITY DEFINER; -CREATE TABLE wk_custody.monero_deposit_credit_events( - credit_id uuid PRIMARY KEY, - wallet_fingerprint text NOT NULL, - observation_id text NOT NULL, - allocation_id uuid NOT NULL REFERENCES wk_custody.monero_deposit_allocations(allocation_id), - beneficiary text NOT NULL CHECK(beneficiary ~ '^[a-f0-9]{64}$'), - amount_atomic_xmr numeric(20,0) NOT NULL CHECK(amount_atomic_xmr>0 AND amount_atomic_xmr<=18446744073709551615), - journal_id text NOT NULL UNIQUE REFERENCES wk_custody.journals(id), - observation_digest text NOT NULL CHECK(observation_digest ~ '^[a-f0-9]{64}$'), - last_observation_event_sequence bigint NOT NULL CHECK(last_observation_event_sequence>=0), - reviewer_owner text NOT NULL CHECK(reviewer_owner ~ '^[a-f0-9]{64}$'), - reviewer_public_key text NOT NULL CHECK(length(reviewer_public_key) BETWEEN 1 AND 256), - review_nonce uuid NOT NULL, - review_expires_at bigint NOT NULL CHECK(review_expires_at>=0), - review_signature text NOT NULL CHECK(length(review_signature) BETWEEN 1 AND 256), - review_subject jsonb NOT NULL, - review_digest text NOT NULL CHECK(review_digest ~ '^[a-f0-9]{64}$'), - credited_at bigint NOT NULL CHECK(credited_at>=0), - minimum_confirmations integer NOT NULL CHECK(minimum_confirmations>=10), - UNIQUE(wallet_fingerprint,observation_id), - UNIQUE(reviewer_owner,review_nonce), - FOREIGN KEY(wallet_fingerprint,observation_id) - REFERENCES wk_custody.monero_transfer_observations(wallet_fingerprint,observation_id) -); -ALTER TABLE wk_custody.monero_observation_events - ADD CONSTRAINT monero_observation_event_identity UNIQUE(sequence,wallet_fingerprint,observation_id,event_type); -CREATE TABLE wk_custody.monero_deposit_reorg_holds( - hold_id uuid PRIMARY KEY, - credit_id uuid NOT NULL REFERENCES wk_custody.monero_deposit_credit_events(credit_id), - wallet_fingerprint text NOT NULL, - observation_id text NOT NULL, - observation_event_sequence bigint NOT NULL REFERENCES wk_custody.monero_observation_events(sequence), - event_type text NOT NULL CHECK(event_type IN ('changed','not-seen','reappeared')), - created_at bigint NOT NULL CHECK(created_at>=0), - UNIQUE(credit_id,observation_event_sequence), - FOREIGN KEY(observation_event_sequence,wallet_fingerprint,observation_id,event_type) - REFERENCES wk_custody.monero_observation_events(sequence,wallet_fingerprint,observation_id,event_type) -); -CREATE INDEX monero_deposit_holds_credit ON wk_custody.monero_deposit_reorg_holds(credit_id,observation_event_sequence); -CREATE FUNCTION wk_custody.monero_deposit_credit_history_immutable() RETURNS trigger LANGUAGE plpgsql - SET search_path=wk_custody,pg_catalog AS $$ -BEGIN RAISE EXCEPTION 'Monero deposit credit/reorg history is append-only'; END $$; -CREATE TRIGGER monero_deposit_credit_events_no_update BEFORE UPDATE OR DELETE ON wk_custody.monero_deposit_credit_events - FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_deposit_credit_history_immutable(); -CREATE TRIGGER monero_deposit_reorg_holds_no_update BEFORE UPDATE OR DELETE ON wk_custody.monero_deposit_reorg_holds - FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_deposit_credit_history_immutable(); -REVOKE ALL ON wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds FROM PUBLIC; -REVOKE ALL ON FUNCTION wk_custody.monero_deposit_credit_history_immutable() FROM PUBLIC; -`; -export const CUSTODY_SCHEMA_V5_DIGEST = createHash('sha256') - .update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}\n-- custody migration v5 --\n${CUSTODY_MIGRATION_V5}`).digest('hex'); -export const CUSTODY_MIGRATION_V6 = ` -CREATE TABLE wk_custody.monero_deposit_release_events( - release_id uuid PRIMARY KEY, - credit_id uuid NOT NULL UNIQUE REFERENCES wk_custody.monero_deposit_credit_events(credit_id), - journal_id text NOT NULL UNIQUE REFERENCES wk_custody.journals(id), - reviewer_owner text NOT NULL CHECK(reviewer_owner ~ '^[a-f0-9]{64}$'), - reviewer_public_key text NOT NULL CHECK(length(reviewer_public_key) BETWEEN 1 AND 256), - review_nonce uuid NOT NULL, - review_expires_at bigint NOT NULL CHECK(review_expires_at>=0), - review_signature text NOT NULL CHECK(length(review_signature) BETWEEN 1 AND 256), - review_subject jsonb NOT NULL, - review_digest text NOT NULL CHECK(review_digest ~ '^[a-f0-9]{64}$'), - released_at bigint NOT NULL CHECK(released_at>=0), - UNIQUE(reviewer_owner,review_nonce) -); -CREATE TABLE wk_custody.monero_deposit_reorg_resolutions( - resolution_id uuid PRIMARY KEY, - hold_id uuid NOT NULL UNIQUE REFERENCES wk_custody.monero_deposit_reorg_holds(hold_id), - credit_id uuid NOT NULL REFERENCES wk_custody.monero_deposit_credit_events(credit_id), - release_id uuid NOT NULL REFERENCES wk_custody.monero_deposit_release_events(release_id), - resolved_by text NOT NULL CHECK(resolved_by ~ '^[a-f0-9]{64}$'), - resolved_at bigint NOT NULL CHECK(resolved_at>=0), - disposition text NOT NULL CHECK(disposition='revalidated-for-release'), - resolution_kind text NOT NULL CHECK(resolution_kind IN ('release-review','post-release-review')), - reviewer_public_key text NOT NULL CHECK(length(reviewer_public_key) BETWEEN 1 AND 256), - review_nonce uuid NOT NULL, - review_expires_at bigint NOT NULL CHECK(review_expires_at>=0), - review_signature text NOT NULL CHECK(length(review_signature) BETWEEN 1 AND 256), - review_subject jsonb NOT NULL, - review_digest text NOT NULL CHECK(review_digest ~ '^[a-f0-9]{64}$'), - UNIQUE(hold_id,release_id), - UNIQUE(resolved_by,review_nonce) -); -CREATE INDEX monero_deposit_resolutions_credit ON wk_custody.monero_deposit_reorg_resolutions(credit_id,hold_id); -CREATE FUNCTION wk_custody.monero_deposit_release_history_immutable() RETURNS trigger LANGUAGE plpgsql - SET search_path=wk_custody,pg_catalog AS $$ -BEGIN RAISE EXCEPTION 'Monero deposit release/resolution history is append-only'; END $$; -CREATE TRIGGER monero_deposit_release_events_no_update BEFORE UPDATE OR DELETE ON wk_custody.monero_deposit_release_events - FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_deposit_release_history_immutable(); -CREATE TRIGGER monero_deposit_reorg_resolutions_no_update BEFORE UPDATE OR DELETE ON wk_custody.monero_deposit_reorg_resolutions - FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_deposit_release_history_immutable(); -REVOKE ALL ON wk_custody.monero_deposit_release_events,wk_custody.monero_deposit_reorg_resolutions FROM PUBLIC; -REVOKE ALL ON FUNCTION wk_custody.monero_deposit_release_history_immutable() FROM PUBLIC; -`; -export const CUSTODY_SCHEMA_V6_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}`).digest('hex'); -export const CUSTODY_MIGRATION_V7 = ` -ALTER TABLE wk_custody.monero_deposit_reorg_resolutions - DROP CONSTRAINT monero_deposit_reorg_resolutions_resolved_by_review_nonce_key; -`; -export const CUSTODY_SCHEMA_V7_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}`).digest('hex'); -export const CUSTODY_MIGRATION_V8 = ` -CREATE FUNCTION wk_custody.monero_deposit_allocation_insert_guard() RETURNS trigger LANGUAGE plpgsql - SET search_path=wk_custody,pg_catalog AS $$ -BEGIN - IF NEW.status<>'reserved' OR NEW.monero_address IS NOT NULL OR NEW.registered_at IS NOT NULL THEN - RAISE EXCEPTION 'Monero deposit allocations must be inserted as address-free reservations'; - END IF; - RETURN NEW; -END $$; -CREATE TRIGGER monero_deposit_allocations_insert_reserved - BEFORE INSERT ON wk_custody.monero_deposit_allocations - FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_deposit_allocation_insert_guard(); -REVOKE ALL ON FUNCTION wk_custody.monero_deposit_allocation_insert_guard() FROM PUBLIC; -`; -export const CUSTODY_SCHEMA_V8_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}`).digest('hex'); -export const CUSTODY_MIGRATION_V9 = ` -CREATE TABLE wk_custody.monero_withdrawal_intents( - request_id uuid PRIMARY KEY, - custodian_id uuid NOT NULL, - market_network text NOT NULL CHECK(market_network='wikideck-randomx-mainnet-1'), - market_chain_id text NOT NULL CHECK(market_chain_id ~ '^[a-f0-9]{64}$'), - beneficiary text NOT NULL CHECK(beneficiary ~ '^[a-f0-9]{64}$'), - destination text NOT NULL CHECK(length(destination)=95), - amount_atomic_xmr numeric(20,0) NOT NULL CHECK(amount_atomic_xmr>0 AND amount_atomic_xmr<=18446744073709551615), - max_fee_atomic_xmr numeric(20,0) NOT NULL CHECK(max_fee_atomic_xmr>=0 AND max_fee_atomic_xmr<=18446744073709551615), - public_key text NOT NULL CHECK(length(public_key) BETWEEN 1 AND 256), - expires_at bigint NOT NULL CHECK(expires_at>=0), - request_signature text NOT NULL CHECK(length(request_signature) BETWEEN 1 AND 256), - request_digest text NOT NULL CHECK(request_digest ~ '^[a-f0-9]{64}$'), - status text NOT NULL CHECK(status IN ('reserved','cancelled')), - created_at bigint NOT NULL CHECK(created_at>=0), - cancel_request_id uuid UNIQUE, - cancel_public_key text CHECK(cancel_public_key IS NULL OR length(cancel_public_key) BETWEEN 1 AND 256), - cancel_expires_at bigint CHECK(cancel_expires_at IS NULL OR cancel_expires_at>=0), - cancel_signature text CHECK(cancel_signature IS NULL OR length(cancel_signature) BETWEEN 1 AND 256), - cancelled_at bigint CHECK(cancelled_at IS NULL OR cancelled_at>=created_at), - CHECK((status='reserved' AND cancel_request_id IS NULL AND cancel_signature IS NULL AND cancelled_at IS NULL) OR - (status='cancelled' AND cancel_request_id IS NOT NULL AND cancel_public_key IS NOT NULL AND - cancel_expires_at IS NOT NULL AND cancel_signature IS NOT NULL AND cancelled_at IS NOT NULL)) -); -CREATE FUNCTION wk_custody.monero_withdrawal_intents_immutable() RETURNS trigger LANGUAGE plpgsql - SET search_path=wk_custody,pg_catalog AS $$ -BEGIN - IF TG_OP='DELETE' THEN RAISE EXCEPTION 'Monero withdrawal intent history cannot be deleted'; END IF; - IF OLD.status<>'reserved' OR NEW.status<>'cancelled' OR NEW.request_id IS DISTINCT FROM OLD.request_id OR - NEW.custodian_id IS DISTINCT FROM OLD.custodian_id OR NEW.market_network IS DISTINCT FROM OLD.market_network OR - NEW.market_chain_id IS DISTINCT FROM OLD.market_chain_id OR NEW.beneficiary IS DISTINCT FROM OLD.beneficiary OR - NEW.destination IS DISTINCT FROM OLD.destination OR NEW.amount_atomic_xmr IS DISTINCT FROM OLD.amount_atomic_xmr OR - NEW.max_fee_atomic_xmr IS DISTINCT FROM OLD.max_fee_atomic_xmr OR NEW.public_key IS DISTINCT FROM OLD.public_key OR - NEW.expires_at IS DISTINCT FROM OLD.expires_at OR NEW.request_signature IS DISTINCT FROM OLD.request_signature OR - NEW.request_digest IS DISTINCT FROM OLD.request_digest OR NEW.created_at IS DISTINCT FROM OLD.created_at OR - NEW.cancel_request_id IS NULL OR NEW.cancel_public_key IS NULL OR NEW.cancel_expires_at IS NULL OR - NEW.cancel_signature IS NULL OR NEW.cancelled_at IS NULL THEN - RAISE EXCEPTION 'Monero withdrawal intent identity is immutable'; - END IF; - RETURN NEW; -END $$; -CREATE TRIGGER monero_withdrawal_intents_immutable BEFORE UPDATE OR DELETE ON wk_custody.monero_withdrawal_intents - FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_withdrawal_intents_immutable(); -REVOKE ALL ON wk_custody.monero_withdrawal_intents FROM PUBLIC; -REVOKE ALL ON FUNCTION wk_custody.monero_withdrawal_intents_immutable() FROM PUBLIC; -`; -export const CUSTODY_SCHEMA_V9_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}`).digest('hex'); -export const CUSTODY_MIGRATION_V10 = ` -CREATE TABLE wk_custody.pilot_custody_identity( - singleton smallint PRIMARY KEY DEFAULT 1 CHECK(singleton=1), - version integer NOT NULL CHECK(version=1), - mode text NOT NULL CHECK(mode='pilot-custody'), - policy text NOT NULL CHECK(policy='pilot-custody-v1'), - 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'), - 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), - 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 $$ -BEGIN RAISE EXCEPTION 'Pilot custody identity is immutable'; END $$; -CREATE TRIGGER pilot_custody_identity_immutable BEFORE UPDATE OR DELETE ON wk_custody.pilot_custody_identity - FOR EACH ROW EXECUTE FUNCTION wk_custody.pilot_custody_identity_immutable(); -REVOKE ALL ON wk_custody.pilot_custody_identity FROM PUBLIC; -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'); -export const CUSTODY_MIGRATION_V12 = ` -DROP TRIGGER pilot_monero_deposit_reservations_immutable ON wk_custody.pilot_monero_deposit_reservations; -DROP FUNCTION wk_custody.pilot_monero_deposit_reservations_immutable(); -ALTER TABLE wk_custody.pilot_monero_deposit_reservations - ADD COLUMN status text NOT NULL DEFAULT 'reserved' CHECK(status IN ('reserved','registered')), - ADD COLUMN monero_address text CHECK(monero_address IS NULL OR length(monero_address)=95), - ADD COLUMN registered_at bigint CHECK(registered_at IS NULL OR registered_at>=reserved_at), - ADD CONSTRAINT pilot_deposit_reservation_registration_state CHECK( - (status='reserved' AND monero_address IS NULL AND registered_at IS NULL) OR - (status='registered' AND monero_address IS NOT NULL AND registered_at IS NOT NULL)); -CREATE UNIQUE INDEX pilot_monero_deposit_address_unique - ON wk_custody.pilot_monero_deposit_reservations(monero_address) WHERE monero_address IS NOT NULL; -CREATE FUNCTION wk_custody.pilot_monero_deposit_registration_guard() RETURNS trigger LANGUAGE plpgsql - SET search_path=wk_custody,pg_catalog AS $$ -BEGIN - IF TG_OP='DELETE' THEN RAISE EXCEPTION 'Pilot Monero deposit reservations cannot be deleted'; END IF; - IF OLD.status<>'reserved' OR NEW.status<>'registered' OR NEW.monero_address IS NULL OR NEW.registered_at IS NULL OR - NEW.registered_at=reserved_at); -UPDATE wk_custody.pilot_monero_deposit_reservations - SET create_started_at=registered_at WHERE status='registered'; -ALTER TABLE wk_custody.pilot_monero_deposit_reservations - DROP CONSTRAINT pilot_monero_deposit_reservations_status_check, - ADD CONSTRAINT pilot_monero_deposit_reservations_status_check CHECK(status IN ('reserved','creating','registered')), - DROP CONSTRAINT pilot_deposit_reservation_registration_state, - ADD CONSTRAINT pilot_deposit_reservation_registration_state CHECK( - (status='reserved' AND create_started_at IS NULL AND monero_address IS NULL AND registered_at IS NULL) OR - (status='creating' AND create_started_at IS NOT NULL AND monero_address IS NULL AND registered_at IS NULL) OR - (status='registered' AND create_started_at IS NOT NULL AND monero_address IS NOT NULL AND - registered_at IS NOT NULL AND registered_at>=create_started_at)); -CREATE FUNCTION wk_custody.pilot_monero_deposit_registration_guard() RETURNS trigger LANGUAGE plpgsql - SET search_path=wk_custody,pg_catalog AS $$ -BEGIN - IF TG_OP='DELETE' THEN RAISE EXCEPTION 'Pilot Monero deposit reservations cannot be deleted'; END IF; - IF NEW.allocation_id IS DISTINCT FROM OLD.allocation_id OR NEW.request_id IS DISTINCT FROM OLD.request_id OR - NEW.custodian_id IS DISTINCT FROM OLD.custodian_id OR NEW.market_network IS DISTINCT FROM OLD.market_network OR - NEW.market_chain_id IS DISTINCT FROM OLD.market_chain_id OR NEW.monero_network IS DISTINCT FROM OLD.monero_network OR - NEW.beneficiary IS DISTINCT FROM OLD.beneficiary OR NEW.wallet_generation_id IS DISTINCT FROM OLD.wallet_generation_id OR - NEW.wallet_fingerprint IS DISTINCT FROM OLD.wallet_fingerprint OR NEW.account_index IS DISTINCT FROM OLD.account_index OR - NEW.address_index IS DISTINCT FROM OLD.address_index OR NEW.request_digest IS DISTINCT FROM OLD.request_digest OR - NEW.allocation_digest IS DISTINCT FROM OLD.allocation_digest OR NEW.reserved_at IS DISTINCT FROM OLD.reserved_at THEN - RAISE EXCEPTION 'Pilot Monero reservation terms are immutable'; - END IF; - IF OLD.status='reserved' AND NEW.status='creating' AND OLD.create_started_at IS NULL AND - NEW.create_started_at IS NOT NULL AND NEW.monero_address IS NULL AND NEW.registered_at IS NULL THEN - RETURN NEW; - END IF; - IF OLD.status='creating' AND NEW.status='registered' AND OLD.create_started_at IS NOT NULL AND - NEW.create_started_at IS NOT DISTINCT FROM OLD.create_started_at AND NEW.monero_address IS NOT NULL AND - OLD.monero_address IS NULL AND NEW.registered_at IS NOT NULL AND NEW.registered_at>=OLD.create_started_at THEN - RETURN NEW; - END IF; - RAISE EXCEPTION 'Pilot Monero reservation permits only one-time create intent and address registration'; -END $$; -REVOKE ALL ON FUNCTION wk_custody.pilot_monero_deposit_registration_guard() FROM PUBLIC; -`; -export const CUSTODY_SCHEMA_V13_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}\n-- custody migration v12 --\n${CUSTODY_MIGRATION_V12}\n-- custody migration v13 --\n${CUSTODY_MIGRATION_V13}`).digest('hex'); -export const CUSTODY_MIGRATION_V14 = ` -CREATE TABLE wk_custody.pilot_monero_observer_state( - wallet_generation_id uuid PRIMARY KEY, - wallet_fingerprint text NOT NULL CHECK(wallet_fingerprint ~ '^[a-f0-9]{64}$'), - network text NOT NULL CHECK(network='mainnet'), - scan_height bigint NOT NULL CHECK(scan_height>=0), - updated_at bigint NOT NULL CHECK(updated_at>=0) -); -CREATE TABLE wk_custody.pilot_monero_observation_scans( - scan_id uuid PRIMARY KEY, - wallet_generation_id uuid NOT NULL REFERENCES wk_custody.pilot_monero_observer_state(wallet_generation_id), - from_height bigint NOT NULL CHECK(from_height>=0), - through_height bigint NOT NULL CHECK(through_height>=from_height), - payload_digest text NOT NULL CHECK(payload_digest ~ '^[a-f0-9]{64}$'), - completed_at bigint NOT NULL CHECK(completed_at>=0) -); -CREATE INDEX pilot_monero_scans_wallet_height - ON wk_custody.pilot_monero_observation_scans(wallet_generation_id,through_height DESC); -CREATE TABLE wk_custody.pilot_monero_observations( - wallet_generation_id uuid NOT NULL REFERENCES wk_custody.pilot_monero_observer_state(wallet_generation_id), - observation_id text NOT NULL, - allocation_id uuid REFERENCES wk_custody.pilot_monero_deposit_reservations(allocation_id), - classification text NOT NULL CHECK(classification IN ('assigned','unallocated')), - txid text NOT NULL CHECK(txid ~ '^[a-f0-9]{64}$'), - monero_address text NOT NULL CHECK(length(monero_address)=95), - amount_atomic_xmr numeric(20,0) NOT NULL CHECK(amount_atomic_xmr>0 AND amount_atomic_xmr<=18446744073709551615), - block_height bigint NOT NULL CHECK(block_height>0), - wallet_height bigint NOT NULL CHECK(wallet_height>=block_height), - confirmations bigint NOT NULL CHECK(confirmations=wallet_height-block_height+1), - timestamp bigint NOT NULL CHECK(timestamp>=0), - unlock_time text NOT NULL CHECK(unlock_time ~ '^(0|[1-9][0-9]{0,19})$'), - account_index bigint NOT NULL CHECK(account_index BETWEEN 0 AND 4294967295), - address_index bigint NOT NULL CHECK(address_index BETWEEN 0 AND 4294967295), - output_public_key text NOT NULL CHECK(output_public_key ~ '^[a-f0-9]{64}$'), - global_output_index bigint NOT NULL CHECK(global_output_index>=0), - locked boolean NOT NULL, - double_spend_seen boolean NOT NULL, - payload_digest text NOT NULL CHECK(payload_digest ~ '^[a-f0-9]{64}$'), - currently_present boolean NOT NULL DEFAULT true, - last_seen_scan_id uuid NOT NULL REFERENCES wk_custody.pilot_monero_observation_scans(scan_id), - first_observed_at bigint NOT NULL CHECK(first_observed_at>=0), - last_observed_at bigint NOT NULL CHECK(last_observed_at>=first_observed_at), - PRIMARY KEY(wallet_generation_id,observation_id), - UNIQUE(wallet_generation_id,output_public_key), - UNIQUE(wallet_generation_id,txid,account_index,address_index,output_public_key), - CHECK((classification='assigned' AND allocation_id IS NOT NULL) OR - (classification='unallocated' AND allocation_id IS NULL)), - CHECK(observation_id=(txid || ':' || output_public_key)) -); -CREATE FUNCTION wk_custody.pilot_monero_observation_guard() RETURNS trigger LANGUAGE plpgsql - SET search_path=wk_custody,pg_catalog AS $$ -DECLARE primary_fingerprint text; -BEGIN - SELECT wallet_fingerprint INTO STRICT primary_fingerprint FROM wk_custody.pilot_monero_observer_state - WHERE wallet_generation_id=NEW.wallet_generation_id; - IF NEW.classification='assigned' THEN - IF NEW.allocation_id IS NULL OR NOT EXISTS( - SELECT 1 FROM wk_custody.pilot_monero_deposit_reservations r - WHERE r.allocation_id=NEW.allocation_id AND r.wallet_generation_id=NEW.wallet_generation_id AND - r.wallet_fingerprint=primary_fingerprint AND r.status='registered' AND r.monero_address=NEW.monero_address AND - r.account_index=NEW.account_index AND r.address_index=NEW.address_index AND r.monero_network='mainnet') THEN - RAISE EXCEPTION 'Pilot observation does not match a registered wallet reservation'; - END IF; - ELSIF NEW.allocation_id IS NOT NULL THEN - RAISE EXCEPTION 'Unallocated pilot observation cannot name a reservation'; - END IF; - IF TG_OP='UPDATE' AND ( - NEW.wallet_generation_id IS DISTINCT FROM OLD.wallet_generation_id OR - NEW.observation_id IS DISTINCT FROM OLD.observation_id OR NEW.allocation_id IS DISTINCT FROM OLD.allocation_id OR - NEW.classification IS DISTINCT FROM OLD.classification OR NEW.txid IS DISTINCT FROM OLD.txid OR - NEW.monero_address IS DISTINCT FROM OLD.monero_address OR NEW.account_index IS DISTINCT FROM OLD.account_index OR - NEW.address_index IS DISTINCT FROM OLD.address_index OR NEW.output_public_key IS DISTINCT FROM OLD.output_public_key OR - NEW.global_output_index IS DISTINCT FROM OLD.global_output_index OR NEW.first_observed_at IS DISTINCT FROM OLD.first_observed_at) THEN - RAISE EXCEPTION 'Pilot observation identity is immutable'; - END IF; - RETURN NEW; -END $$; -CREATE TRIGGER pilot_monero_observations_guard BEFORE INSERT OR UPDATE ON wk_custody.pilot_monero_observations - FOR EACH ROW EXECUTE FUNCTION wk_custody.pilot_monero_observation_guard(); -CREATE INDEX pilot_monero_observations_height - ON wk_custody.pilot_monero_observations(wallet_generation_id,block_height,observation_id); -CREATE TABLE wk_custody.pilot_monero_observation_events( - sequence bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - wallet_generation_id uuid NOT NULL, - observation_id text NOT NULL, - scan_id uuid NOT NULL REFERENCES wk_custody.pilot_monero_observation_scans(scan_id), - event_type text NOT NULL CHECK(event_type IN ('seen','changed','reappeared','not-seen')), - payload_digest text NOT NULL CHECK(payload_digest ~ '^[a-f0-9]{64}$'), - wallet_height bigint NOT NULL CHECK(wallet_height>=0), - observed_at bigint NOT NULL CHECK(observed_at>=0), - UNIQUE(wallet_generation_id,observation_id,scan_id,event_type), - FOREIGN KEY(wallet_generation_id,observation_id) - REFERENCES wk_custody.pilot_monero_observations(wallet_generation_id,observation_id) -); -CREATE FUNCTION wk_custody.pilot_monero_observation_history_immutable() RETURNS trigger LANGUAGE plpgsql - SET search_path=wk_custody,pg_catalog AS $$ -BEGIN RAISE EXCEPTION 'Pilot Monero observation history is append-only'; END $$; -CREATE TRIGGER pilot_monero_observation_scans_no_update BEFORE UPDATE OR DELETE ON wk_custody.pilot_monero_observation_scans - FOR EACH ROW EXECUTE FUNCTION wk_custody.pilot_monero_observation_history_immutable(); -CREATE TRIGGER pilot_monero_observation_events_no_update BEFORE UPDATE OR DELETE ON wk_custody.pilot_monero_observation_events - FOR EACH ROW EXECUTE FUNCTION wk_custody.pilot_monero_observation_history_immutable(); -REVOKE ALL ON FUNCTION wk_custody.pilot_monero_observation_guard() FROM PUBLIC; -REVOKE ALL ON wk_custody.pilot_monero_observer_state,wk_custody.pilot_monero_observation_scans, - wk_custody.pilot_monero_observations,wk_custody.pilot_monero_observation_events FROM PUBLIC; -REVOKE ALL ON FUNCTION wk_custody.pilot_monero_observation_history_immutable() FROM PUBLIC; -`; -export const CUSTODY_SCHEMA_V14_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}\n-- custody migration v12 --\n${CUSTODY_MIGRATION_V12}\n-- custody migration v13 --\n${CUSTODY_MIGRATION_V13}\n-- custody migration v14 --\n${CUSTODY_MIGRATION_V14}`).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; - -/** Explicit operator migration; the HTTP runtime never creates or upgrades schema. */ -export async function migrateCustody(pool: Pool): Promise { - const c = await pool.connect(); - try { - await c.query('BEGIN'); - await c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]); - const exists = await c.query("SELECT to_regclass('wk_custody.schema_versions') AS name"); - let current = 0; - if (!exists.rows[0].name) { - await c.query(CUSTODY_SCHEMA_V1); - await c.query('INSERT INTO wk_custody.schema_versions VALUES(1,$1)', [CUSTODY_SCHEMA_V1_DIGEST]); - current = 1; - } else current = await verifyCustodySchemaHistory(c); - if (current === 1) { - await c.query(CUSTODY_MIGRATION_V2); - await c.query('INSERT INTO wk_custody.schema_versions VALUES(2,$1)', [CUSTODY_SCHEMA_V2_DIGEST]); - current = 2; - } - if (current === 2) { - await c.query(CUSTODY_MIGRATION_V3); - await c.query('INSERT INTO wk_custody.schema_versions VALUES(3,$1)', [CUSTODY_SCHEMA_V3_DIGEST]); - current = 3; - } - if (current === 3) { - await c.query(CUSTODY_MIGRATION_V4); - await c.query('INSERT INTO wk_custody.schema_versions VALUES(4,$1)', [CUSTODY_SCHEMA_V4_DIGEST]); - current = 4; - } - if (current === 4) { - await c.query(CUSTODY_MIGRATION_V5); - await c.query('INSERT INTO wk_custody.schema_versions VALUES(5,$1)', [CUSTODY_SCHEMA_V5_DIGEST]); - current = 5; - } - if (current === 5) { - await c.query(CUSTODY_MIGRATION_V6); - await c.query('INSERT INTO wk_custody.schema_versions VALUES(6,$1)', [CUSTODY_SCHEMA_V6_DIGEST]); - current = 6; - } - if (current === 6) { - await c.query(CUSTODY_MIGRATION_V7); - await c.query('INSERT INTO wk_custody.schema_versions VALUES(7,$1)', [CUSTODY_SCHEMA_V7_DIGEST]); - current = 7; - } - if (current === 7) { - await c.query(CUSTODY_MIGRATION_V8); - await c.query('INSERT INTO wk_custody.schema_versions VALUES(8,$1)', [CUSTODY_SCHEMA_V8_DIGEST]); - current = 8; - } - if (current === 8) { - await c.query(CUSTODY_MIGRATION_V9); - await c.query('INSERT INTO wk_custody.schema_versions VALUES(9,$1)', [CUSTODY_SCHEMA_V9_DIGEST]); - current = 9; - } - 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]); - current = 11; - } - if (current === 11) { - await c.query(CUSTODY_MIGRATION_V12); - await c.query('INSERT INTO wk_custody.schema_versions VALUES(12,$1)', [CUSTODY_SCHEMA_V12_DIGEST]); - current = 12; - } - if (current === 12) { - await c.query(CUSTODY_MIGRATION_V13); - await c.query('INSERT INTO wk_custody.schema_versions VALUES(13,$1)', [CUSTODY_SCHEMA_V13_DIGEST]); - current = 13; - } - if (current === 13) { - await c.query(CUSTODY_MIGRATION_V14); - await c.query('INSERT INTO wk_custody.schema_versions VALUES(14,$1)', [CUSTODY_SCHEMA_V14_DIGEST]); - } - if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application'); - await verifyCustodySchema(c); - await c.query('COMMIT'); - } catch (e) { await c.query('ROLLBACK').catch(() => {}); throw e; } - finally { c.release(); } -} -export async function verifyCustodySchema(c: PoolClient): Promise { - const version = await verifyCustodySchemaHistory(c); - if (version !== CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema migration required'); -} -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_V11_DIGEST, - CUSTODY_SCHEMA_V12_DIGEST, CUSTODY_SCHEMA_V13_DIGEST, CUSTODY_SCHEMA_V14_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'); - } - return Number(rows.rows.at(-1).version); -} - -/** Role must already exist (operator creates credentials out-of-band); never accept SQL identifiers from HTTP. */ -export async function grantCustodyRuntime(pool: Pool, role: string): Promise { - if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid runtime role identifier'); - 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('Runtime role must exist without administrative privileges'); - 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('Runtime role must not own the custody schema'); - await c.query(`GRANT USAGE ON SCHEMA wk_custody TO ${name}; - GRANT SELECT ON ALL TABLES IN SCHEMA wk_custody TO ${name}; - GRANT USAGE,SELECT ON ALL SEQUENCES IN SCHEMA wk_custody TO ${name}; - GRANT INSERT(id,normal) ON wk_custody.accounts TO ${name}; - GRANT INSERT(id,kind,created) ON wk_custody.journals TO ${name}; - GRANT INSERT ON wk_custody.entries,wk_custody.receipts,wk_custody.credits,wk_custody.custody_meta TO ${name}; - GRANT INSERT,UPDATE ON wk_custody.listings,wk_custody.orders,wk_custody.withdrawals,wk_custody.outbox TO ${name};`); - await c.query(`REVOKE ALL PRIVILEGES ON wk_custody.monero_observer_state,wk_custody.monero_transfer_observations, - wk_custody.monero_observation_events,wk_custody.monero_observation_scans FROM ${name}; - REVOKE ALL PRIVILEGES ON wk_custody.monero_deposit_allocations FROM ${name}; - REVOKE ALL PRIVILEGES ON wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds, - wk_custody.monero_deposit_release_events,wk_custody.monero_deposit_reorg_resolutions FROM ${name}; - GRANT SELECT ON wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds, - wk_custody.monero_deposit_release_events,wk_custody.monero_deposit_reorg_resolutions TO ${name}; - REVOKE ALL PRIVILEGES ON SEQUENCE wk_custody.monero_observation_events_sequence_seq FROM ${name};`); - await c.query('COMMIT'); - } catch (e) { await c.query('ROLLBACK').catch(() => {}); throw e; } - finally { c.release(); } -} - -/** Separate least-privilege role for private wallet observations; it cannot read balances/journals or post credits. */ -export async function grantMoneroObserverRuntime(pool: Pool, role: string, custodyRuntimeRole = 'custody_runtime'): Promise { - if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid observer role identifier'); - if (role === custodyRuntimeRole) throw new Error('Monero observer requires a separate role from custody runtime'); - 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('Observer 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('Observer 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('Observer 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,INSERT,UPDATE ON wk_custody.monero_observer_state,wk_custody.monero_transfer_observations TO ${name}; - GRANT SELECT,INSERT ON wk_custody.monero_observation_events,wk_custody.monero_observation_scans TO ${name}; - GRANT USAGE,SELECT ON SEQUENCE wk_custody.monero_observation_events_sequence_seq TO ${name};`); - await c.query('COMMIT'); - } catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; } - finally { c.release(); } -} - -/** Separate least-privilege role for reviewed pending-credit posting and deposit reorg evidence. */ -export async function grantMoneroDepositCreditRuntime(pool: Pool, role: string, - custodyRuntimeRole = 'custody_runtime', observerRole = 'monero_observer', - allocatorRole = 'monero_deposit_allocator'): Promise { - if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid deposit-credit role identifier'); - if ([custodyRuntimeRole, observerRole, allocatorRole].includes(role)) { - throw new Error('Deposit-credit coordinator requires a separate role from custody runtime, observer, and allocator'); - } - const name = `"${role}"`; - const c = await pool.connect(); - try { - await c.query('BEGIN'); await verifyCustodySchema(c); - const r = await c.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolreplication,rolbypassrls FROM pg_roles WHERE rolname=$1', [role]); - if (!r.rowCount || Object.values(r.rows[0]).some(Boolean)) throw new Error('Deposit-credit role must exist without administrative privileges'); - const memberships = await c.query('SELECT 1 FROM pg_auth_members WHERE member=(SELECT oid FROM pg_roles WHERE rolname=$1) LIMIT 1', [role]); - if (memberships.rowCount) throw new Error('Deposit-credit role must not inherit privileges through role membership'); - const owner = await c.query("SELECT pg_get_userbyid(nspowner) AS owner FROM pg_namespace WHERE nspname='wk_custody'"); - if (owner.rows[0].owner === role) throw new Error('Deposit-credit role must not own the custody schema'); - await c.query(`GRANT USAGE ON SCHEMA wk_custody TO ${name}; - REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA wk_custody FROM ${name}; - REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA wk_custody FROM ${name}; - GRANT SELECT ON wk_custody.custody_meta,wk_custody.monero_observer_state,wk_custody.monero_transfer_observations, - wk_custody.monero_observation_events,wk_custody.monero_deposit_allocations, - wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds, - wk_custody.monero_deposit_release_events,wk_custody.monero_deposit_reorg_resolutions TO ${name}; - GRANT SELECT(id,normal) ON wk_custody.accounts TO ${name}; - GRANT INSERT(id,normal) ON wk_custody.accounts TO ${name}; - GRANT INSERT(id,kind,created) ON wk_custody.journals TO ${name}; - GRANT INSERT ON wk_custody.entries,wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds, - wk_custody.monero_deposit_release_events,wk_custody.monero_deposit_reorg_resolutions TO ${name};`); - await c.query('COMMIT'); - } catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; } - finally { c.release(); } -} - -/** Separate API role for immutable customer-bound subaddress allocation; it cannot read balances or post journals. */ -export async function grantMoneroDepositAllocatorRuntime(pool: Pool, role: string, - custodyRuntimeRole = 'custody_runtime', observerRole = 'monero_observer', - creditRole = 'monero_deposit_credit'): Promise { - if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid deposit-allocator role identifier'); - if ([custodyRuntimeRole, observerRole, creditRole].includes(role)) { - throw new Error('Deposit allocator requires a separate role from custody, observer, and credit 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('Deposit allocator role must exist without administrative privileges'); - const memberships = await c.query('SELECT 1 FROM pg_auth_members WHERE member=(SELECT oid FROM pg_roles WHERE rolname=$1) LIMIT 1', [role]); - if (memberships.rowCount) throw new Error('Deposit allocator role must not inherit privileges through role membership'); - const owner = await c.query("SELECT pg_get_userbyid(nspowner) AS owner FROM pg_namespace WHERE nspname='wk_custody'"); - if (owner.rows[0].owner === role) throw new Error('Deposit allocator 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,INSERT ON wk_custody.monero_deposit_allocations TO ${name}; - GRANT UPDATE(status,monero_address,registered_at) ON wk_custody.monero_deposit_allocations TO ${name};`); - await c.query('COMMIT'); - } catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; } - 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(); } -} - -/** Separate pilot address allocator: only the reserved-to-registered column transition is writable. */ -export async function grantPilotDepositAddressRuntime(pool: Pool, role: string, - reservationRole = 'pilot_deposit_reservation', custodyRuntimeRole = 'custody_runtime', - observerRole = 'monero_observer', creditRole = 'monero_deposit_credit'): Promise { - if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid pilot deposit address role identifier'); - if ([reservationRole, custodyRuntimeRole, observerRole, creditRole].includes(role)) { - throw new Error('Pilot address role must be separate from reservation and 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 address 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 address 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 address 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,wk_custody.pilot_custody_identity TO ${name}; - GRANT SELECT,INSERT ON wk_custody.pilot_monero_deposit_reservations TO ${name}; - GRANT UPDATE(status,create_started_at,monero_address,registered_at) 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(); } -} - -/** Pilot observation-only role. It cannot allocate addresses, access mainnet custody, or post financial entries. */ -export async function grantPilotMoneroObserverRuntime(pool: Pool, role: string, - addressRole = 'pilot_deposit_address', reservationRole = 'pilot_deposit_reservation', - custodyRuntimeRole = 'custody_runtime', creditRole = 'monero_deposit_credit'): Promise { - if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid pilot Monero observer role identifier'); - if ([addressRole, reservationRole, custodyRuntimeRole, creditRole].includes(role)) { - throw new Error('Pilot observer role must be separate from address, reservation, custody, and credit 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 observer 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 observer 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 observer 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,wk_custody.pilot_custody_identity, - wk_custody.pilot_monero_deposit_reservations TO ${name}; - GRANT SELECT,INSERT ON wk_custody.pilot_monero_observer_state TO ${name}; - GRANT UPDATE(scan_height,updated_at) ON wk_custody.pilot_monero_observer_state TO ${name}; - GRANT SELECT,INSERT ON wk_custody.pilot_monero_observation_scans TO ${name}; - GRANT SELECT,INSERT ON wk_custody.pilot_monero_observation_events TO ${name}; - GRANT USAGE,SELECT ON SEQUENCE wk_custody.pilot_monero_observation_events_sequence_seq TO ${name}; - GRANT SELECT,INSERT ON wk_custody.pilot_monero_observations TO ${name}; - GRANT UPDATE(amount_atomic_xmr,block_height,wallet_height,confirmations,timestamp,unlock_time,locked, - double_spend_seen,payload_digest,currently_present,last_seen_scan_id,last_observed_at) - ON wk_custody.pilot_monero_observations TO ${name};`); - await c.query('COMMIT'); - } catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; } - finally { c.release(); } -} - -/** Read-only pilot review role. It can inspect persisted evidence, but not observer or financial state. */ -export async function grantPilotMoneroDepositReviewRuntime(pool: Pool, role: string, - addressRole = 'pilot_deposit_address', reservationRole = 'pilot_deposit_reservation', - observerRole = 'pilot_monero_observer', custodyRuntimeRole = 'custody_runtime', - creditRole = 'monero_deposit_credit'): Promise { - if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid pilot deposit review role identifier'); - if ([addressRole, reservationRole, observerRole, custodyRuntimeRole, creditRole].includes(role)) { - throw new Error('Pilot deposit review role must be separate from observer, allocator, custody, and credit 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 deposit review 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 deposit review 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 deposit review role must not own the custody schema'); - const owned = await c.query(`SELECT 1 FROM pg_class rel JOIN pg_namespace n ON n.oid=rel.relnamespace - WHERE n.nspname='wk_custody' AND rel.relowner=(SELECT oid FROM pg_roles WHERE rolname=$1) LIMIT 1`, [role]); - if (owned.rowCount) throw new Error('Pilot deposit review role must not own custody tables or sequences'); - const databaseCreate = await c.query('SELECT has_database_privilege($1,current_database(),\'CREATE\') AS allowed', [role]); - if (databaseCreate.rows[0].allowed) throw new Error('Pilot deposit review role must not create database objects'); - 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,wk_custody.pilot_custody_identity, - wk_custody.pilot_monero_deposit_reservations,wk_custody.pilot_monero_observer_state, - wk_custody.pilot_monero_observation_scans,wk_custody.pilot_monero_observations, - wk_custody.pilot_monero_observation_events 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', - creditRole = 'monero_deposit_credit', allocatorRole = 'monero_deposit_allocator'): Promise { - if (!/^[a-z][a-z0-9_]{0,62}$/.test(role)) throw new Error('Invalid Monero withdrawal role identifier'); - if ([custodyRuntimeRole, observerRole, creditRole, allocatorRole].includes(role)) { - throw new Error('Monero withdrawal role must be separate from custody, observer, credit, and allocator 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('Monero withdrawal 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('Monero withdrawal 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('Monero withdrawal role must not own the custody schema'); - await c.query(`GRANT USAGE ON SCHEMA wk_custody TO ${name}; - REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA wk_custody FROM ${name}; - REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA wk_custody FROM ${name}; - GRANT SELECT ON wk_custody.custody_meta,wk_custody.accounts,wk_custody.monero_deposit_credit_events, - wk_custody.monero_deposit_release_events,wk_custody.monero_deposit_reorg_holds, - wk_custody.monero_deposit_reorg_resolutions TO ${name}; - GRANT SELECT,INSERT ON wk_custody.monero_withdrawal_intents TO ${name}; - GRANT UPDATE(status,cancel_request_id,cancel_public_key,cancel_expires_at,cancel_signature,cancelled_at) - ON wk_custody.monero_withdrawal_intents TO ${name}; - GRANT INSERT(id,normal) ON wk_custody.accounts TO ${name}; - GRANT INSERT(id,kind,created) ON wk_custody.journals TO ${name}; - GRANT INSERT ON wk_custody.entries TO ${name};`); - await c.query('COMMIT'); - } catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; } - finally { c.release(); } -} diff --git a/server/market/reconciliation-commit-worker.js b/server/market/reconciliation-commit-worker.js deleted file mode 100755 index bff1583..0000000 --- a/server/market/reconciliation-commit-worker.js +++ /dev/null @@ -1,184 +0,0 @@ -import { DatabaseSync } from 'node:sqlite'; -import { parentPort, workerData } from 'node:worker_threads'; -import { isDeepStrictEqual } from 'node:util'; -import { createHash } from 'node:crypto'; - -const db = new DatabaseSync(workerData.marketFile); -db.exec('PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL;'); -const ledger = workerData.ledgerFile ? new DatabaseSync(workerData.ledgerFile, { readOnly: true }) : null; -ledger?.exec('PRAGMA busy_timeout=5000; PRAGMA query_only=ON;'); -const port = parentPort; -if (!port) throw new Error('Reconciliation commit worker requires a parent port'); - -function atomic(fn) { - db.exec('BEGIN IMMEDIATE'); - try { const result = fn(); db.exec('COMMIT'); return result; } - catch (error) { db.exec('ROLLBACK'); throw error; } -} -function assertCurrentJob(expected) { - const row = db.prepare("SELECT value FROM market_meta WHERE key='canonical_reconciliation_job'").get(); - if (!row || !isDeepStrictEqual(JSON.parse(row.value), expected)) { - throw new Error('Reconciliation job changed before worker commit'); - } -} -function rows(stage, afterId, limit) { - const table = stage === 'orders' ? 'orders' : 'listings'; - return db.prepare(`SELECT id,data FROM ${table} WHERE (? IS NULL OR id>?) ORDER BY id LIMIT ?`) - .all(afterId, afterId, limit); -} -function readLedgerFacts(request, selected) { - if (!ledger) return request.fallbackFacts; - ledger.exec('BEGIN'); - try { - const tip = ledger.prepare('SELECT hash FROM canonical_blocks ORDER BY height DESC LIMIT 1').get()?.hash ?? request.genesisHash; - const height = ledger.prepare('SELECT COALESCE(MAX(height),0) AS n FROM canonical_blocks').get().n; - const eventHead = ledger.prepare('SELECT COALESCE(MAX(sequence),0) AS n FROM canonical_events').get().n; - const pendingTransactions = ledger.prepare('SELECT id,transaction_json FROM pending_transfers ORDER BY position').all() - .map(row => { - const tx = JSON.parse(row.transaction_json); - const cardIds = tx.type === 'trade' ? [...tx.offered, ...tx.requested].map(leg => leg.cardHash) : [tx.cardHash]; - return { id: row.id, cardIds }; - }); - const pendingIds = pendingTransactions.map(tx => tx.id); - const deliveryIds = Object.create(null), transactionIds = new Set(), cardIds = new Set(); - for (const row of selected) { - const record = JSON.parse(row.data); - if (request.stage === 'orders') { - const id = createHash('sha256').update(JSON.stringify([request.network, 'nft-transfer', request.chainId, - record.cardHash, record.seller, record.buyer, record.sequence])).digest('hex'); - deliveryIds[record.id] = id; transactionIds.add(id); cardIds.add(record.cardHash); - } else cardIds.add(record.cardHash); - } - const deliveryTransactions = Object.create(null); - const readTx = ledger.prepare('SELECT height,block_hash FROM canonical_transactions WHERE id=?'); - for (const id of transactionIds) deliveryTransactions[id] = readTx.get(id) ?? null; - const nfts = Object.create(null); - const readNft = ledger.prepare(`SELECT n.owner,n.sequence,n.minted_height,b.block_json FROM canonical_nfts n - JOIN blocks b ON b.hash=n.block_hash WHERE n.token=?`); - for (const card of cardIds) { - const nft = readNft.get(card); - nfts[card] = nft ? { owner: nft.owner, sequence: nft.sequence, confirmations: height - nft.minted_height + 1 } : null; - } - ledger.exec('COMMIT'); - return { height, - pendingTransactions, pendingIds, pendingCardIds: [...new Set(pendingTransactions.flatMap(tx => tx.cardIds))], - deliveryIds, deliveryTransactions, nfts, - snapshot: { tip, eventHead, pendingDigest: JSON.stringify([...pendingIds].sort()) } }; - } catch (error) { ledger.exec('ROLLBACK'); throw error; } -} -function validCard(facts, cardHash, owner, sequence, maturity) { - const nft = facts.nfts[cardHash]; - return !!nft && nft.owner === owner && nft.sequence === sequence && nft.confirmations >= maturity; -} -function deliveryId(request, order) { return request.deliveryIds[order.id]; } -function processOrders(request, selected) { - const disconnected = new Set(request.disconnectedIds); - const pendingIds = new Set(request.pendingIds); - const orders = [], listings = []; - for (const row of selected) { - const order = JSON.parse(row.data); - const listingRow = db.prepare('SELECT data FROM listings WHERE id=?').get(order.listingId); - if (!listingRow) throw new Error('Order listing missing during reconciliation'); - const listing = JSON.parse(listingRow.data); - const id = deliveryId(request, order), delivery = request.deliveryTransactions[id]; - if (order.status === 'complete' && disconnected.has(id)) { - order.status = 'review'; order.payout = 'review'; - if (order.additionalFee) order.additionalFee.payout = 'review'; - } - if (order.status === 'refunded' || order.status === 'expired') continue; - const confirmations = delivery ? request.height - delivery.height + 1 : 0; - order.deliveryConfirmations = confirmations; - if (order.status === 'complete' || order.status === 'review') { - if (!delivery || confirmations < order.requiredConfirmations) { - order.status = 'review'; order.payout = 'review'; - if (order.additionalFee) order.additionalFee.payout = 'review'; - } - orders.push(order); - continue; - } - if (delivery && order.status === 'awaiting-payment') { - order.status = 'review'; order.payout = 'review'; - if (order.additionalFee) order.additionalFee.payout = 'review'; - } else if (delivery) { - order.transferId = id; order.status = 'delivery-pending'; - if (confirmations >= order.requiredConfirmations) { - order.status = 'complete'; order.payout = 'mock-paid'; listing.status = 'sold'; - if (order.additionalFee) order.additionalFee.payout = 'mock-paid'; - } - } else if (order.status === 'awaiting-payment' && request.expectedJob.observedAt >= order.payBy) { - order.status = 'expired'; listing.status = listing.kind === 'fixed' ? 'open' : 'expired'; - } else if (order.status !== 'awaiting-payment') { - const pending = pendingIds.has(id); - const conflict = request.pendingTransactions.some(tx => tx.cardIds.includes(order.cardHash) && tx.id !== id); - if (!validCard(request, order.cardHash, order.seller, order.sequence, request.maturity) || - conflict || (request.expectedJob.observedAt >= order.deliverBy && !pending)) order.status = 'refund-required'; - else if (order.status === 'delivery-pending' && !pending) order.status = 'paid'; - } - orders.push(order); - listings.push(listing); - } - return { orders, listings }; -} -function processListings(request, selected) { - const pendingCards = new Set(request.pendingCardIds), listings = []; - for (const row of selected) { - const listing = JSON.parse(row.data); - if (listing.status !== 'open') continue; - if (listing.additionalFee?.bps !== request.additionalFee.bps || listing.additionalFee?.address !== request.additionalFee.address || - !validCard(request, listing.cardHash, listing.seller, listing.sequence, request.maturity) || pendingCards.has(listing.cardHash)) { - listing.status = 'invalid'; - } else if (listing.kind === 'auction' && request.expectedJob.observedAt >= listing.endsAt + request.paymentWindow) { - listing.status = 'expired'; - } else if (listing.kind === 'auction' && request.expectedJob.observedAt >= listing.endsAt && !listing.highestBid) { - listing.status = 'expired'; - } - listings.push(listing); - } - return { orders: [], listings }; -} -function saveResults(result) { - const order = db.prepare(`INSERT INTO orders(id,data) VALUES(?,?) - ON CONFLICT(id) DO UPDATE SET data=excluded.data WHERE orders.data<>excluded.data`); - for (const item of result.orders) order.run(item.id, JSON.stringify(item)); - const listing = db.prepare(`INSERT INTO listings(id,card,status,data) VALUES(?,?,?,?) - ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data WHERE listings.data<>excluded.data`); - for (const item of result.listings) listing.run(item.id, item.cardHash, item.status, JSON.stringify(item)); -} - -port.on('message', request => { - try { - let snapshot, nextJob; - if (request.operation === 'reconcile-batch') { - atomic(() => { - assertCurrentJob(request.expectedJob); - const selected = rows(request.stage, request.afterId, request.batchSize); - const facts = readLedgerFacts(request, selected); - snapshot = facts.snapshot; - const prepared = { ...request, ...facts }; - const result = request.stage === 'orders' ? processOrders(prepared, selected) : processListings(prepared, selected); - saveResults(result); - nextJob = request.stage === 'orders' - ? selected.length < request.batchSize ? { ...request.expectedJob, stage: 'listings', afterId: null } - : { ...request.expectedJob, afterId: selected.at(-1).id } - : selected.length < request.batchSize ? { ...request.expectedJob, stage: 'finalize', afterId: null } - : { ...request.expectedJob, afterId: selected.at(-1).id }; - db.prepare("UPDATE market_meta SET value=? WHERE key='canonical_reconciliation_job'") - .run(JSON.stringify(nextJob)); - }); - } else if (request.operation === 'finalize') { - atomic(() => { - assertCurrentJob(request.expectedJob); - db.prepare('DELETE FROM receipts WHERE expires < ?').run(request.observedAt - 86400_000); - if (request.cursor) { - db.prepare(`INSERT INTO market_meta(key,value) VALUES('canonical_event_cursor',?) - ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(request.cursor); - } - db.prepare("DELETE FROM market_meta WHERE key='canonical_reconciliation_job'").run(); - }); - } else throw new Error('Unknown reconciliation worker operation'); - port.postMessage({ id: request.id, ok: true, snapshot, nextJob }); - } catch (error) { - port.postMessage({ id: request.id, ok: false, error: error instanceof Error ? error.message : 'Worker batch failed' }); - } -}); -port.on('close', () => { ledger?.close(); db.close(); }); diff --git a/server/market/reconciliation-commit-worker.ts b/server/market/reconciliation-commit-worker.ts deleted file mode 100755 index c55854e..0000000 --- a/server/market/reconciliation-commit-worker.ts +++ /dev/null @@ -1 +0,0 @@ -// The runtime worker is reconciliation-commit-worker.js so it is independent of TS loaders. diff --git a/server/market/sale-rehearsal.ts b/server/market/sale-rehearsal.ts deleted file mode 100755 index ff3e421..0000000 --- a/server/market/sale-rehearsal.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { z } from 'zod'; -import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts'; -import type { CustodyOrder } from '../../shared/custody.ts'; -import { saleTransactionSchema, saleTermsMessage } from '../../shared/sale-candidate.ts'; -import { applySaleBlock, initialSaleState, saleContext, saleDecision, saleId, saleStateRoot, - saleCandidateTransactionId, termsForCustodyOrder, type SaleContext } from '../testnet/sale-candidate.ts'; -import { sha256, verifySignature } from '../testnet/consensus.ts'; - -const height = z.number().int().min(0).max(1000000); -export const rehearsalSchema = z.object({ - policy: z.object({ coinbaseMaturity: height.positive(), lockDepth: height.positive(), outcomeDepth: height.positive(), - maxLockBlocks: height.positive(), maxTransactions: z.number().int().min(1).max(64) }).strict(), - seed: z.object({ height, mintedHeight: height, pageId: height.positive() }).strict(), - lock: saleTransactionSchema.options[0], - blocks: z.array(z.array(saleTransactionSchema).max(64)).min(1).max(128), -}).strict(); -export type SaleRehearsal = z.infer; - -// This is an internal JSON-only seam, but still bound traversal before Zod or serialization. -function checkEvidenceBudget(raw: unknown): void { - let nodes = 0, bytes = 0; - const seen = new WeakSet(); - const visit = (value: unknown, depth: number): void => { - if (++nodes > 32768 || depth > 12) throw new Error('Rehearsal evidence exceeds complexity budget'); - if (typeof value === 'string') { - if (value.length > 256 * 1024) throw new Error('Rehearsal evidence exceeds budget'); - bytes += Buffer.byteLength(value) + 2; - } else if (value !== null && typeof value === 'object') { - if (seen.has(value)) throw new Error('Rehearsal evidence must be acyclic JSON'); - seen.add(value); - if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new Error('Rehearsal evidence must be JSON'); - const keys = Object.keys(value); - if (keys.length > (Array.isArray(value) ? 128 : 64)) throw new Error('Rehearsal evidence exceeds collection budget'); - for (const key of keys) { - bytes += Buffer.byteLength(key) + 4; - const property = Object.getOwnPropertyDescriptor(value, key)!; - if (!('value' in property)) throw new Error('Rehearsal evidence must not contain accessors'); - visit(property.value, depth + 1); - } - seen.delete(value); - } else if (value !== undefined && value !== null && typeof value !== 'boolean' && - !(typeof value === 'number' && Number.isFinite(value))) throw new Error('Rehearsal evidence must be JSON'); - if (bytes > 256 * 1024) throw new Error('Rehearsal evidence exceeds budget'); - }; - visit(raw, 0); -} - -/** Replays signed synthetic blocks from a caller-supplied seed, NOT a canonical-chain verifier. - * No RPC/PoW, Monero, clock, signing key or HTTP route. Limited to one actual sandbox reservation. - */ -export function replayReservedSale(order: CustodyOrder, custodianId: string, raw: unknown, listingFeeBps: number) { - checkEvidenceBudget(raw); - const input = rehearsalSchema.parse(raw), context: SaleContext = saleContext(input.policy); - if (Buffer.byteLength(JSON.stringify(input)) > 256 * 1024) throw new Error('Rehearsal evidence exceeds budget'); - if (input.lock.type !== 'sale-lock') throw new Error('Rehearsal requires signed lock authorization'); - const terms = input.lock.terms; - if (terms.feeBps !== listingFeeBps) throw new Error('Rehearsal fee rate does not match reserved listing'); - const expected = termsForCustodyOrder({ ...order, status: 'awaiting-lock' }, context, { custodianId, - host: terms.host, feeBps: terms.feeBps, lockUntilHeight: terms.lockUntilHeight, expiresHeight: terms.expiresHeight }); - if (saleTermsMessage(expected) !== saleTermsMessage(terms)) throw new Error('Rehearsal does not match reserved order terms'); - if (input.seed.mintedHeight > input.seed.height) throw new Error('Invalid rehearsal issuance height'); - const message = saleTermsMessage(terms); - if (!verifySignature(input.lock.sellerAuthorization.publicKey, order.seller, message, input.lock.sellerAuthorization.signature) || - !verifySignature(input.lock.buyerAuthorization.publicKey, order.buyer, message, input.lock.buyerAuthorization.signature)) { - throw new Error('Rehearsal requires trader consent'); - } - const binding = sha256(JSON.stringify(['custody-sale-rehearsal-v1', custodianId, message, input.seed, input.policy])); - const reservationDigest = sha256(JSON.stringify(['custody-reservation-rehearsal-v1', custodianId, order.id, - order.buyer, order.seller, order.cardHash, order.sequence, order.grossAtomicXmr, - order.sellerAtomicXmr, order.authorAtomicXmr, order.hostAtomicXmr, order.authorAddress, - order.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address, binding])); - let state = initialSaleState({ [order.cardHash]: { owner: order.seller, sequence: order.sequence, - mintedHeight: input.seed.mintedHeight, pageId: input.seed.pageId } }, input.seed.height); - let lockTransactionId: string | undefined, terminalTransactionId: string | undefined; - for (const block of input.blocks) { - for (const tx of block) { - if (tx.type === 'sale-lock' ? saleId(tx.terms) !== saleId(terms) : tx.saleId !== saleId(terms)) throw new Error('Unrelated sale in rehearsal'); - if (tx.type === 'sale-complete' && tx.reservationDigest !== reservationDigest) throw new Error('Completion reservation commitment mismatch'); - if (tx.type === 'sale-lock') lockTransactionId = saleCandidateTransactionId(tx); - else terminalTransactionId = saleCandidateTransactionId(tx); - } - state = applySaleBlock(state, block, context); - } - const previous = order.status === 'settled' ? 'settled' : order.status === 'cancelled' ? 'refunded' : 'reserved'; - return { input, binding, reservationDigest, lockTransactionId, terminalTransactionId, stateRoot: saleStateRoot(state, context), - decision: saleDecision(state, terms, context, previous), context }; -} diff --git a/server/market/store.ts b/server/market/store.ts deleted file mode 100755 index e1ff901..0000000 --- a/server/market/store.ts +++ /dev/null @@ -1,664 +0,0 @@ -import { DatabaseSync } from 'node:sqlite'; -import { randomUUID } from 'node:crypto'; -import { mkdir, chmod } from 'node:fs/promises'; -import { join } from 'node:path'; -import { Worker } from 'node:worker_threads'; -import { NETWORK, CONSENSUS, nftTransferMessage, testnetOwnerOf, transactionCardIds, - type TestnetSnapshot, type NftTransfer } from '../../shared/testnet.ts'; -import { ADDITIONAL_MARKET_FEE, marketCommandSchema, marketMessage, type MarketCommand, type MarketListing, - type MarketOrder, type MarketInfo } from '../../shared/market.ts'; -import { isXmrListing, isXmrOrder, marketCommandV3Schema, marketMessageV3, - type MarketActivityV3, type MarketCommandV3, type MarketListingRecord, type MarketListingV3, - type MarketOrderRecord, type MarketOrderV3 } from '../../shared/market-v3.ts'; -import { CHAIN_ID, GENESIS_HASH, transactionId, transferSchema, verifySignature } from '../testnet/consensus.ts'; -import { mockQuote, mockXmrQuote, paymentMode } from './payments.ts'; -import { RpcError } from '../resources.ts'; -import type { CanonicalEvents, IndexedNft } from '../testnet/canonical-index.ts'; -import { z } from 'zod'; -import { operatorMarketPolicy, resolvedMarketPolicy, permitsMarketAction, validateMarketPolicy, type MarketPolicy } from './policy.ts'; -import { PROFILE } from '../../shared/network.ts'; - -type Chain = { snapshot(): TestnetSnapshot; summary?(): { tip: string; revision: number; height?: number }; - submitTransfer(raw: unknown): Promise; nft?(token: string): IndexedNft | undefined; - canonicalTransaction?(id: string): { height: number; blockHash: string } | undefined; - canonicalHead?(): number; canonicalEvents?(after: number, limit?: number): CanonicalEvents; - pendingTransactions?(): TestnetSnapshot['pendingTransfers']; reconciliationDatabasePath?(): string }; -type ChainView = { height: number; pendingTransfers: TestnetSnapshot['pendingTransfers']; - nft(token: string): IndexedNft | undefined; transaction(id: string): { height: number; blockHash: string } | undefined }; -type Config = { feeBps: number; confirmations: number } & MarketPolicy; -export function marketConfig(env: NodeJS.ProcessEnv = process.env): Config { - paymentMode(env); - const integer = (name: string, fallback: number, min: number, max: number) => { - const text = env[name] ?? String(fallback); - if (!/^\d+$/.test(text)) throw new Error(`Invalid ${name}`); - const n = Number(text); - if (!Number.isSafeInteger(n) || n < min || n > max) throw new Error(`Invalid ${name}`); - return n; - }; - return { ...operatorMarketPolicy(env), feeBps: integer('WIKIDECK_MARKET_FEE_BPS', 250, 0, 5000), - confirmations: integer('WIKIDECK_MARKET_CONFIRMATIONS', 10, 2, 1000) }; -} -const PAYMENT_WINDOW = 30 * 60_000; -const DELIVERY_WINDOW = 48 * 60 * 60_000; -const RECONCILIATION_FRESH_MS = 15_000; -const cursorSchema = z.object({ version: z.literal(1), streamId: z.string().uuid(), - cursor: z.number().int().nonnegative().safe(), anchor: z.string().regex(/^[a-f0-9]{64}$/) }).strict(); -const canonicalEventSchema = z.object({ sequence: z.number().int().positive().safe(), type: z.enum(['connect', 'disconnect']), - height: z.number().int().positive().safe(), hash: z.string().regex(/^[a-f0-9]{64}$/), - previousHash: z.string().regex(/^[a-f0-9]{64}$/), transactionIds: z.array(z.string().regex(/^[a-f0-9]{64}$/)).max(64), - eventHash: z.string().regex(/^[a-f0-9]{64}$/) }).strict(); -const canonicalEventPageSchema = z.object({ streamId: z.string().uuid(), previousAnchor: z.string().regex(/^[a-f0-9]{64}$/), - anchor: z.string().regex(/^[a-f0-9]{64}$/), events: z.array(canonicalEventSchema).max(64), - cursor: z.number().int().nonnegative().safe(), head: z.number().int().nonnegative().safe(), caughtUp: z.boolean() }).strict(); -const reconciliationJobSchema = z.object({ version: z.literal(1), stage: z.enum(['orders', 'listings', 'finalize']), - afterId: z.string().nullable(), observedAt: z.number().int().nonnegative().safe(), - tip: z.string().regex(/^[a-f0-9]{64}$/).nullable(), revision: z.number().int().nonnegative().safe().nullable(), - eventPage: canonicalEventPageSchema.nullable() }).strict(); -// Keep each synchronous SQLite slice small enough to limit request-loop stalls. -export const MARKET_RECONCILIATION_BATCH_SIZE = 32; -const RECONCILIATION_JOB_KEY = 'canonical_reconciliation_job'; - -/** Node-local marketplace, NOT consensus escrow. Its SQLite journal is backed up with the ledger. */ -export class Market { - private queue: Promise = Promise.resolve(); - private queued = 0; - private reconciled: { tip: string; revision: number; at: number } | undefined; - private eventCursor = 0; - private eventCheckpoint: z.infer | undefined; - private eventBacklog = false; - private reconciliationJob: z.infer | undefined; - private readonly reconciliationWorker?: Worker; - private readonly workerReadsLedger: boolean; - private workerRequestId = 0; - private readonly workerRequests = new Map): void; reject(error: Error): void }>(); - private reconciliationWorkerBusy = false; - private reconciliationWorkerError: string | undefined; - private readonly db: DatabaseSync; - private readonly chain: Chain; - readonly config: Config; - readonly id: string; - private readonly now: () => number; - private constructor(db: DatabaseSync, chain: Chain, config: Config, id: string, now: () => number, worker?: Worker) { - this.db = db; this.chain = chain; this.config = config; this.id = id; this.now = now; - this.reconciliationWorker = worker; - this.workerReadsLedger = !!chain.reconciliationDatabasePath; - worker?.on('message', (message: { id: number; ok: boolean; error?: string }) => { - const pending = this.workerRequests.get(message.id); - if (!pending) return; - this.workerRequests.delete(message.id); - if (message.ok) pending.resolve(message); else pending.reject(new Error(message.error ?? 'Reconciliation worker failed')); - }); - worker?.on('error', error => { - this.reconciliationWorkerError = 'Reconciliation worker failed'; - for (const pending of this.workerRequests.values()) pending.reject(error); - this.workerRequests.clear(); - }); - worker?.on('exit', code => { - if (!this.workerRequests.size) return; - const error = new Error(`Reconciliation worker exited with code ${code}`); - this.reconciliationWorkerError = 'Reconciliation worker stopped unexpectedly'; - for (const pending of this.workerRequests.values()) pending.reject(error); - this.workerRequests.clear(); - }); - const cursor = db.prepare("SELECT value FROM market_meta WHERE key='canonical_event_cursor'").get() as { value: string } | undefined; - if (cursor) { - this.eventCheckpoint = cursorSchema.parse(JSON.parse(cursor.value)); - this.eventCursor = this.eventCheckpoint.cursor; - } - const job = db.prepare('SELECT value FROM market_meta WHERE key=?').get(RECONCILIATION_JOB_KEY) as { value: string } | undefined; - if (job) { this.reconciliationJob = reconciliationJobSchema.parse(JSON.parse(job.value)); this.eventBacklog = true; } - else this.eventBacklog = !!chain.canonicalHead && this.eventCursor < chain.canonicalHead(); - } - - static async open(directory: string, chain: Chain, config = marketConfig(), now = Date.now, - reconciliation: { worker?: boolean } = {}): Promise { - await validateMarketPolicy(config); - const folder = join(directory, PROFILE); - await mkdir(folder, { recursive: true, mode: 0o700 }); - const file = join(folder, 'market.sqlite'); - const db = new DatabaseSync(file); - let worker: Worker | undefined; - try { - db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000; - CREATE TABLE IF NOT EXISTS market_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS listings(id TEXT PRIMARY KEY, card TEXT NOT NULL, status TEXT NOT NULL, data TEXT NOT NULL); - CREATE UNIQUE INDEX IF NOT EXISTS one_active_listing ON listings(card) WHERE status IN ('open','reserved'); - CREATE TABLE IF NOT EXISTS orders(id TEXT PRIMARY KEY, data TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS receipts(id TEXT PRIMARY KEY, digest TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);`); - db.exec(`CREATE INDEX IF NOT EXISTS listings_by_status ON listings(status); - CREATE INDEX IF NOT EXISTS listings_by_seller ON listings(json_extract(data,'$.seller')); - CREATE INDEX IF NOT EXISTS orders_by_seller ON orders(json_extract(data,'$.seller')); - CREATE INDEX IF NOT EXISTS orders_by_buyer ON orders(json_extract(data,'$.buyer'));`); - const meta = db.prepare('SELECT value FROM market_meta WHERE key=?').get('identity') as { value: string } | undefined; - let id: string; - if (meta) { - const identity = JSON.parse(meta.value); - if (identity.network !== NETWORK || identity.chainId !== CHAIN_ID || identity.version !== 1 || - typeof identity.id !== 'string') throw new Error('Marketplace network/schema mismatch'); - id = identity.id; - } else { - id = randomUUID(); - db.prepare('INSERT INTO market_meta VALUES(?,?)').run('identity', JSON.stringify({ version: 1, network: NETWORK, chainId: CHAIN_ID, id })); - } - if (process.platform !== 'win32') await chmod(file, 0o600); - worker = reconciliation.worker ? new Worker(new URL('./reconciliation-commit-worker.js', import.meta.url), - { workerData: { marketFile: file, ledgerFile: chain.reconciliationDatabasePath?.() ?? null }, execArgv: [] }) : undefined; - worker?.unref(); - return new Market(db, chain, config, id, now, worker); - } catch (error) { if (worker) await worker.terminate(); db.close(); throw error; } - } - async close(): Promise { - if (this.reconciliationWorker) await this.reconciliationWorker.terminate(); - this.db.close(); - } - isReconciliationWorkerBusy(): boolean { return this.reconciliationWorkerBusy; } - isReconciliationWorkerFailed(): boolean { return !!this.reconciliationWorkerError; } - diagnostics() { - const states=this.db.prepare("SELECT json_extract(data,'$.status') AS status,COUNT(*) AS count FROM orders GROUP BY json_extract(data,'$.status')").all(); - return { queued:this.queued,lastReconciledAt:this.reconciled?.at??null,orderStates:states, - canonicalEventCursor:this.eventCursor,canonicalEventHead:this.chain.canonicalHead?.()??null,eventBacklog:this.eventBacklog, - reconciliationWorkerBusy:this.reconciliationWorkerBusy,reconciliationWorkerFailed:!!this.reconciliationWorkerError }; - } - hasReconciliationBacklog(): boolean { return this.eventBacklog || !!this.reconciliationJob; } - info(): MarketInfo { - return { network: NETWORK, chainId: CHAIN_ID, marketId: this.id, paymentMode: 'mock', marketVersion: 3, feeBps: this.config.feeBps, - ...resolvedMarketPolicy(this.config), - additionalFee: { ...ADDITIONAL_MARKET_FEE }, - deliveryConfirmations: this.config.confirmations, serverTime: this.now() }; - } - private nextReconciliationRows(table: 'listings' | 'orders', afterId: string | null): { id: string; data: string }[] { - return this.db.prepare(`SELECT id,data FROM ${table} WHERE (? IS NULL OR id>?) ORDER BY id LIMIT ?`) - .all(afterId, afterId, MARKET_RECONCILIATION_BATCH_SIZE) as { id: string; data: string }[]; - } - private listing(id: string): MarketListingRecord { - const row = this.db.prepare('SELECT data FROM listings WHERE id=?').get(id) as { data: string } | undefined; - if (!row) throw new Error('Listing not found'); - return JSON.parse(row.data) as MarketListingRecord; - } - private order(id: string): MarketOrderRecord { - const row = this.db.prepare('SELECT data FROM orders WHERE id=?').get(id) as { data: string } | undefined; - if (!row) throw new Error('Order not found'); - return JSON.parse(row.data) as MarketOrderRecord; - } - private saveListing(l: MarketListingRecord) { - this.db.prepare('INSERT INTO listings VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data WHERE listings.data<>excluded.data') - .run(l.id, l.cardHash, l.status, JSON.stringify(l)); - } - private saveOrder(o: MarketOrderRecord) { - this.db.prepare('INSERT INTO orders VALUES(?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data WHERE orders.data<>excluded.data').run(o.id, JSON.stringify(o)); - } - private atomic(fn: () => T): T { - this.db.exec('BEGIN IMMEDIATE'); - try { const result = fn(); this.db.exec('COMMIT'); return result; } - catch (error) { this.db.exec('ROLLBACK'); throw error; } - } - private view(): ChainView { - if (this.chain.nft && this.chain.canonicalTransaction && this.chain.pendingTransactions && this.chain.summary) { - const height = this.chain.summary().height; - if (height === undefined) throw new Error('Indexed chain height missing'); - return { height, pendingTransfers: this.chain.pendingTransactions(), nft: token => this.chain.nft!(token), - transaction: id => this.chain.canonicalTransaction!(id) }; - } - // Compatibility boundary for unit-test/legacy adapters only. The actual node always supplies indexed queries. - const s = this.chain.snapshot(); - const txs = new Map(s.blocks.flatMap(b => b.transactions.map(tx => [transactionId(tx), { height: b.height, blockHash: b.hash }] as const))); - return { height: s.blocks.length, pendingTransfers: s.pendingTransfers, transaction: id => txs.get(id), nft: token => { - const block = s.blocks.find(b => b.tokenId === token); - return block ? { block, owner: testnetOwnerOf(s, token)!, sequence: s.transfers.filter(tx => transactionCardIds(tx).includes(token)).length, - confirmations: s.blocks.length + 1 - block.height } : undefined; - } }; - } - private card(view: ChainView, hash: string, owner: string, sequence: number): boolean { - const nft = view.nft(hash); - return !!nft && nft.owner === owner && nft.sequence === sequence && nft.confirmations >= CONSENSUS.coinbaseMaturity; - } - private deliveryId(order: MarketOrderRecord): string { - // Identity excludes signature bytes. Recognize exact sale delivery through ANY submission route, - // including peer gossip or a direct /api/transfer, even before the market records delivery intent. - return transactionId({ network: NETWORK, chainId: CHAIN_ID, cardHash: order.cardHash, - from: order.seller, to: order.buyer, sequence: order.sequence, publicKey: '', signature: '' }); - } - private workerRequest(request: Record): Promise> { - const worker = this.reconciliationWorker; - if (!worker) return Promise.reject(new Error('Reconciliation worker is unavailable')); - const id = ++this.workerRequestId; - return new Promise((resolve, reject) => { - this.workerRequests.set(id, { resolve, reject }); - try { worker.postMessage({ ...request, id }); } - catch (error) { this.workerRequests.delete(id); reject(error instanceof Error ? error : new Error('Worker request failed')); } - }); - } - private scheduleWorkerContinuation(): void { - setImmediate(() => { if (!this.reconciliationWorkerBusy && this.hasReconciliationBacklog()) this.reconcile(); }).unref(); - } - private finalizeWorkerJob(job: z.infer): void { - const summary = this.chain.summary?.(), eventPage = job.eventPage as CanonicalEvents | null; - const currentHead = this.chain.canonicalHead?.() ?? eventPage?.head ?? 0; - if ((summary && (job.tip !== summary.tip || job.revision !== summary.revision)) || - (eventPage && currentHead > eventPage.head)) { - const events = this.chain.canonicalEvents?.(this.eventCursor, 64); - if (events && this.eventCheckpoint && (events.streamId !== this.eventCheckpoint.streamId || - events.previousAnchor !== this.eventCheckpoint.anchor)) throw new RpcError(409, 'Canonical event history changed; restore matching ledger and market backups.'); - const restarted = reconciliationJobSchema.parse({ version: 1, stage: 'orders', afterId: null, observedAt: this.now(), - tip: summary?.tip ?? null, revision: summary?.revision ?? null, eventPage: events ?? null }); - this.atomic(() => this.db.prepare('UPDATE market_meta SET value=? WHERE key=?') - .run(JSON.stringify(restarted), RECONCILIATION_JOB_KEY)); - this.reconciliationJob = restarted; this.eventBacklog = true; this.reconciliationWorkerError = undefined; - this.scheduleWorkerContinuation(); - return; - } - if (this.reconciliationWorkerBusy) return; - this.reconciliationWorkerBusy = true; - const cursor = eventPage ? JSON.stringify({ version: 1, streamId: eventPage.streamId, - cursor: eventPage.cursor, anchor: eventPage.anchor }) : null; - void this.workerRequest({ operation: 'finalize', expectedJob: job, cursor, observedAt: job.observedAt }) - .then(() => { - this.reconciliationJob = undefined; - if (eventPage) { - this.eventCursor = eventPage.cursor; - this.eventCheckpoint = { version: 1, streamId: eventPage.streamId, cursor: eventPage.cursor, anchor: eventPage.anchor }; - } - const current = this.chain.summary?.(); - const head = this.chain.canonicalHead?.() ?? eventPage?.head ?? 0; - this.eventBacklog = (!!eventPage && (!eventPage.caughtUp || head > eventPage.cursor)) || - (!!current && (current.tip !== job.tip || current.revision !== job.revision)); - if (current && !this.eventBacklog) this.reconciled = { ...current, at: this.now() }; - this.reconciliationWorkerError = undefined; - }) - .catch(error => { this.reconciliationWorkerError = error instanceof Error ? error.message : 'Worker finalize failed'; this.eventBacklog = true; }) - .finally(() => { - this.reconciliationWorkerBusy = false; - if (!this.reconciliationWorkerError && this.hasReconciliationBacklog()) this.scheduleWorkerContinuation(); - }); - } - private dispatchWorkerBatch(job: z.infer): void { - if (!this.reconciliationWorker || this.reconciliationWorkerBusy || job.stage === 'finalize') return; - this.reconciliationWorkerBusy = true; - try { - let fallbackFacts: Record | null = null; - if (!this.workerReadsLedger) { - const rows = this.nextReconciliationRows(job.stage, job.afterId), s = this.view(); - const pendingTransactions = s.pendingTransfers.map(tx => ({ id: transactionId(tx), cardIds: transactionCardIds(tx) })); - const pendingIds = pendingTransactions.map(tx => tx.id); - const pendingCardIds = [...new Set(pendingTransactions.flatMap(tx => tx.cardIds))]; - const deliveryIds: Record = Object.create(null), targetCards = new Set(); - if (job.stage === 'orders') for (const row of rows) { - const order = JSON.parse(row.data) as MarketOrderRecord; - deliveryIds[order.id] = this.deliveryId(order); targetCards.add(order.cardHash); - } else for (const row of rows) targetCards.add((JSON.parse(row.data) as MarketListingRecord).cardHash); - const deliveryTransactions: Record = Object.create(null); - const nfts: Record = Object.create(null); - if (job.stage === 'orders') for (const id of Object.values(deliveryIds)) deliveryTransactions[id] = s.transaction(id) ?? null; - for (const card of targetCards) { - const nft = s.nft(card); nfts[card] = nft ? { owner: nft.owner, sequence: nft.sequence, confirmations: nft.confirmations } : null; - } - fallbackFacts = { height: s.height, pendingTransactions, pendingIds, pendingCardIds, deliveryIds, deliveryTransactions, nfts }; - } - const disconnectedIds = job.eventPage?.events.filter(event => event.type === 'disconnect').flatMap(event => event.transactionIds) ?? []; - void this.workerRequest({ operation: 'reconcile-batch', expectedJob: job, stage: job.stage, - afterId: job.afterId, batchSize: MARKET_RECONCILIATION_BATCH_SIZE, fallbackFacts, - genesisHash: GENESIS_HASH, network: NETWORK, chainId: CHAIN_ID, disconnectedIds, - maturity: CONSENSUS.coinbaseMaturity, additionalFee: ADDITIONAL_MARKET_FEE, paymentWindow: PAYMENT_WINDOW }) - .then(message => { - const nextJob = reconciliationJobSchema.parse(message.nextJob); - this.reconciliationJob = nextJob; this.reconciliationWorkerError = undefined; this.eventBacklog = true; - if (this.workerReadsLedger) this.refenceWorkerSnapshot(job, message.snapshot as { tip: string; eventHead: number; pendingDigest: string }); - }) - .catch(error => { this.reconciliationWorkerError = error instanceof Error ? error.message : 'Worker reconciliation failed'; this.eventBacklog = true; }) - .finally(() => { - this.reconciliationWorkerBusy = false; - if (!this.reconciliationWorkerError) this.scheduleWorkerContinuation(); - }); - } catch (error) { - this.reconciliationWorkerBusy = false; - this.reconciliationWorkerError = error instanceof Error ? error.message : 'Worker reconciliation preparation failed'; - this.eventBacklog = true; - } - } - private refenceWorkerSnapshot(job: z.infer, snapshot: { tip: string; eventHead: number; pendingDigest: string }): void { - const summary = this.chain.summary?.(); - if (!summary) return; - const pendingDigest = this.chain.pendingTransactions - ? JSON.stringify(this.chain.pendingTransactions().map(transactionId).sort()) : ''; - const pageHead = job.eventPage?.head ?? snapshot.eventHead; - if (summary.tip === snapshot.tip && (job.revision === null || summary.revision === job.revision) && snapshot.eventHead <= pageHead && - pendingDigest === snapshot.pendingDigest) return; - const events = this.chain.canonicalEvents?.(this.eventCursor, 64); - if (events && this.eventCheckpoint && (events.streamId !== this.eventCheckpoint.streamId || - events.previousAnchor !== this.eventCheckpoint.anchor)) throw new RpcError(409, 'Canonical event history changed; restore matching ledger and market backups.'); - const restarted = reconciliationJobSchema.parse({ version: 1, stage: 'orders', afterId: null, observedAt: this.now(), - tip: summary.tip, revision: summary.revision, eventPage: events ?? null }); - this.atomic(() => this.db.prepare('UPDATE market_meta SET value=? WHERE key=?') - .run(JSON.stringify(restarted), RECONCILIATION_JOB_KEY)); - this.reconciliationJob = restarted; this.eventBacklog = true; - } - /** Reconcile one durable keyset batch from canonical transaction identity, never mempool acknowledgement. */ - private reconcile() { - if (this.reconciliationWorkerBusy || this.reconciliationWorkerError) return; - const now = this.now(); - const summary = this.chain.summary?.(); - if (!this.reconciliationJob) { - const events = this.chain.canonicalEvents?.(this.eventCursor, 64); - if (events && this.eventCheckpoint && (events.streamId !== this.eventCheckpoint.streamId || - events.previousAnchor !== this.eventCheckpoint.anchor)) throw new RpcError(409, 'Canonical event history changed; restore matching ledger and market backups.'); - const job = reconciliationJobSchema.parse({ version: 1, stage: 'orders', afterId: null, observedAt: now, - tip: summary?.tip ?? null, revision: summary?.revision ?? null, eventPage: events ?? null }); - this.atomic(() => this.db.prepare('INSERT INTO market_meta(key,value) VALUES(?,?)') - .run(RECONCILIATION_JOB_KEY, JSON.stringify(job))); - this.reconciliationJob = job; this.eventBacklog = true; - } - - let job = this.reconciliationJob; - if (summary && (job.tip !== summary.tip || job.revision !== summary.revision)) { - // Canonical state changed while a multi-batch scan was running. Rewind the - // keyset and bind a fresh event page so every row is checked against one - // observed chain revision before the canonical cursor can advance. - const events = this.chain.canonicalEvents?.(this.eventCursor, 64); - if (events && this.eventCheckpoint && (events.streamId !== this.eventCheckpoint.streamId || - events.previousAnchor !== this.eventCheckpoint.anchor)) throw new RpcError(409, 'Canonical event history changed; restore matching ledger and market backups.'); - job = reconciliationJobSchema.parse({ version: 1, stage: 'orders', afterId: null, observedAt: now, - tip: summary.tip, revision: summary.revision, eventPage: events ?? null }); - this.atomic(() => this.db.prepare('UPDATE market_meta SET value=? WHERE key=?') - .run(JSON.stringify(job), RECONCILIATION_JOB_KEY)); - this.reconciliationJob = job; - this.eventBacklog = true; - } - if (job.stage === 'finalize') { - if (this.reconciliationWorker) { this.finalizeWorkerJob(job); return; } - const eventPage = job.eventPage as CanonicalEvents | null; - this.atomic(() => { - this.db.prepare('DELETE FROM receipts WHERE expires < ?').run(job.observedAt - 86400_000); - if (eventPage) this.db.prepare('INSERT INTO market_meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value') - .run('canonical_event_cursor', JSON.stringify({ version: 1, streamId: eventPage.streamId, - cursor: eventPage.cursor, anchor: eventPage.anchor })); - this.db.prepare('DELETE FROM market_meta WHERE key=?').run(RECONCILIATION_JOB_KEY); - }); - this.reconciliationJob = undefined; - if (eventPage) { - this.eventCursor = eventPage.cursor; - this.eventCheckpoint = { version: 1, streamId: eventPage.streamId, cursor: eventPage.cursor, anchor: eventPage.anchor }; - } - const head = this.chain.canonicalHead?.() ?? eventPage?.head ?? 0; - this.eventBacklog = !!eventPage && (!eventPage.caughtUp || head > eventPage.cursor); - const current = this.chain.summary?.(); - if (current && !this.eventBacklog) this.reconciled = { ...current, at: now }; - return; - } - if (this.reconciliationWorker) { this.dispatchWorkerBatch(job); return; } - const s = this.view(), pendingIds = new Set(s.pendingTransfers.map(transactionId)); - const eventPage = job.eventPage as CanonicalEvents | null; - const disconnected = new Set(eventPage?.events.filter(e => e.type === 'disconnect').flatMap(e => e.transactionIds) ?? []); - const rows = this.nextReconciliationRows(job.stage, job.afterId); - let nextJob: z.infer | undefined; - let completed = false; - this.atomic(() => { - if (job.stage === 'orders' && rows.length) { - for (const row of rows) { - const o = JSON.parse(row.data) as MarketOrderRecord; - const l = this.listing(o.listingId); - // A disconnect of completed delivery is sticky even if the same transaction has reappeared. - if (o.status === 'complete' && disconnected.has(this.deliveryId(o))) { - o.status = 'review'; o.payout = 'review'; - if (o.additionalFee) o.additionalFee.payout = 'review'; - this.saveOrder(o); - } - if (o.status === 'refunded' || o.status === 'expired') continue; - const deliveryId = this.deliveryId(o); - const delivery = s.transaction(deliveryId); - const confirmations = delivery ? s.height - delivery.height + 1 : 0; - o.deliveryConfirmations = confirmations; - if (o.status === 'complete' || o.status === 'review') { - if (!delivery || confirmations < o.requiredConfirmations) { - o.status = 'review'; o.payout = 'review'; - if (o.additionalFee) o.additionalFee.payout = 'review'; - } - this.saveOrder(o); continue; // Never automatically pay a second time after a reorganization. - } - if (delivery && o.status === 'awaiting-payment') { - o.status = 'review'; o.payout = 'review'; - if (o.additionalFee) o.additionalFee.payout = 'review'; - } else if (delivery) { - o.transferId = deliveryId; o.status = 'delivery-pending'; - if (confirmations >= o.requiredConfirmations) { - o.status = 'complete'; o.payout = 'mock-paid'; l.status = 'sold'; - if (o.additionalFee) o.additionalFee.payout = 'mock-paid'; - } - } else if (o.status === 'awaiting-payment' && job.observedAt >= o.payBy) { - o.status = 'expired'; l.status = l.kind === 'fixed' ? 'open' : 'expired'; - } else if (o.status !== 'awaiting-payment') { - const pending = pendingIds.has(deliveryId); - const conflict = s.pendingTransfers.some(tx => transactionCardIds(tx).includes(o.cardHash) && transactionId(tx) !== deliveryId); - if (!this.card(s, o.cardHash, o.seller, o.sequence) || conflict || (job.observedAt >= o.deliverBy && !pending)) o.status = 'refund-required'; - else if (o.status === 'delivery-pending' && !pending) o.status = 'paid'; - } - this.saveOrder(o); this.saveListing(l); - } - } else if (job.stage === 'listings' && rows.length) { - for (const row of rows) { - const l = JSON.parse(row.data) as MarketListingRecord; - if (l.status !== 'open') continue; - if (l.additionalFee?.bps !== ADDITIONAL_MARKET_FEE.bps || l.additionalFee?.address !== ADDITIONAL_MARKET_FEE.address || - !this.card(s, l.cardHash, l.seller, l.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(l.cardHash))) l.status = 'invalid'; - else if (l.kind === 'auction' && job.observedAt >= l.endsAt! + PAYMENT_WINDOW) l.status = 'expired'; - else if (l.kind === 'auction' && job.observedAt >= l.endsAt! && !l.highestBid) l.status = 'expired'; - this.saveListing(l); - } - } - - if (job.stage === 'orders') { - if (rows.length < MARKET_RECONCILIATION_BATCH_SIZE) nextJob = { ...job, stage: 'listings', afterId: null }; - else nextJob = { ...job, afterId: rows.at(-1)!.id }; - } else { - if (rows.length < MARKET_RECONCILIATION_BATCH_SIZE) { - this.db.prepare('DELETE FROM receipts WHERE expires < ?').run(job.observedAt - 86400_000); - if (eventPage) { - this.db.prepare('INSERT INTO market_meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value') - .run('canonical_event_cursor', JSON.stringify({ version: 1, streamId: eventPage.streamId, - cursor: eventPage.cursor, anchor: eventPage.anchor })); - } - this.db.prepare('DELETE FROM market_meta WHERE key=?').run(RECONCILIATION_JOB_KEY); - completed = true; - } else nextJob = { ...job, afterId: rows.at(-1)!.id }; - } - if (nextJob) this.db.prepare('UPDATE market_meta SET value=? WHERE key=?').run(JSON.stringify(nextJob), RECONCILIATION_JOB_KEY); - }); - - if (completed) { - this.reconciliationJob = undefined; - if (eventPage) { - this.eventCursor = eventPage.cursor; - this.eventCheckpoint = { version: 1, streamId: eventPage.streamId, cursor: eventPage.cursor, anchor: eventPage.anchor }; - // The ledger can append events while large journals are processed over many bounded calls. - // Do not mark the market reconciled to a newer tip until those post-page events are consumed. - const currentHead = this.chain.canonicalHead?.() ?? eventPage.head; - this.eventBacklog = !eventPage.caughtUp || currentHead > eventPage.cursor; - } else this.eventBacklog = false; - const current = this.chain.summary?.(); - if (current && !this.eventBacklog) this.reconciled = { ...current, at: now }; - } else { - this.reconciliationJob = nextJob; - this.eventBacklog = true; - } - // Empty/short order pages can advance to the listing stage in the same bounded call. This - // avoids rejecting the first command on an empty market while still capping work at 2×128 rows. - if (!completed && job.stage === 'orders' && rows.length < MARKET_RECONCILIATION_BATCH_SIZE) this.reconcile(); - } - browse(offset = 0): { listings: MarketListingRecord[]; next: number | null; info: MarketInfo } { - if (!Number.isSafeInteger(offset) || offset < 0 || offset > 10000) throw new Error('Invalid marketplace page'); - // Reconciliation must not interleave with delivery submission while its chain await is outstanding. - const summary = this.chain.summary?.(); - const last = this.reconciled; - if (!this.queued && (this.hasReconciliationBacklog() || !summary || !last || summary.tip !== last.tip || - summary.revision !== last.revision || this.now() - last.at >= RECONCILIATION_FRESH_MS)) this.reconcile(); - if (!resolvedMarketPolicy(this.config).marketEnabled) return { listings: [], next: null, info: this.info() }; - const rows = this.db.prepare("SELECT data FROM listings WHERE status IN ('open','reserved') ORDER BY rowid DESC LIMIT 25 OFFSET ?") - .all(offset) as { data: string }[]; - return { listings: rows.slice(0,24).map(r=>JSON.parse(r.data)), next: rows.length>24?offset+24:null, info: this.info() }; - } - execute(raw: unknown): Promise { - const current = marketCommandV3Schema.safeParse(raw); - const cmd = current.success ? current.data : marketCommandSchema.parse(raw); - if (this.queued >= 64) return Promise.reject(new Error('Marketplace busy; retry shortly')); - this.queued++; - const result = this.queue.then(async () => { - try { return await this.run(cmd); } - finally { this.queued--; } - }); - this.queue = result.catch(() => undefined); - return result; - } - private async run(c: MarketCommand | MarketCommandV3): Promise { - const now = this.now(); - const isV3 = 'version' in c; - const message = isV3 ? marketMessageV3(c) : marketMessage(c); - if (c.chainId !== CHAIN_ID || c.marketId !== this.id || c.expiresAt < now || c.expiresAt > now + 5 * 60_000 || - !verifySignature(c.publicKey, c.actor, message, c.signature)) throw new Error('Invalid or expired marketplace signature'); - const key = `${c.actor}:${c.nonce}`, digest = message; - const old = this.db.prepare('SELECT digest,data FROM receipts WHERE id=?').get(key) as { digest: string; data: string } | undefined; - if (old) { - if (old.digest !== digest) throw new Error('Nonce already used for different terms'); - return JSON.parse(old.data); - } - if (!permitsMarketAction(this.config, c.action.type)) throw new Error('New market trading is disabled by this host; existing activity and resolution remain available'); - const a = c.action; - const summary = this.chain.summary?.(), last = this.reconciled; - const stale = !summary || !last || summary.tip !== last.tip || summary.revision !== last.revision || - this.now() - last.at >= RECONCILIATION_FRESH_MS; - if (this.hasReconciliationBacklog() || stale || (!this.reconciliationWorker && a.type !== 'activity')) this.reconcile(); - if (this.eventBacklog && a.type !== 'activity') throw new RpcError(503, 'Market canonical reconciliation is catching up; retry shortly.'); - if (a.type === 'activity') return { - orders: (this.db.prepare("SELECT data FROM orders WHERE json_extract(data,'$.seller')=? OR json_extract(data,'$.buyer')=? ORDER BY rowid DESC") - .all(c.actor,c.actor) as { data: string }[]).map(r=>JSON.parse(r.data) as MarketOrderRecord), - listings: (this.db.prepare("SELECT data FROM listings WHERE json_extract(data,'$.seller')=? ORDER BY rowid DESC") - .all(c.actor) as { data: string }[]).map(r=>JSON.parse(r.data) as MarketListingRecord) }; - if (a.type === 'deliver') { - const o = this.order(a.orderId), tx = transferSchema.parse(a.transfer); - if (isV3 && !isXmrOrder(o) || !isV3 && isXmrOrder(o) || o.seller !== c.actor || !['paid', 'delivery-pending'].includes(o.status)) throw new Error('Only the paid order seller can deliver under the order terms'); - if (tx.cardHash !== o.cardHash || tx.sequence !== o.sequence || tx.from !== o.seller || tx.to !== o.buyer || - !verifySignature(tx.publicKey, tx.from, nftTransferMessage(tx), tx.signature)) throw new Error('Delivery does not match this sale'); - // Persist intent before the ledger await. A crash/restart reconciles by deterministic tx ID. - o.transfer = tx; o.transferId = transactionId(tx); o.status = 'delivery-pending'; this.saveOrder(o); - await this.chain.submitTransfer(tx); - return this.receipt(key, digest, c.expiresAt, o); - } - return this.atomic(() => { - const result = isV3 ? this.mutateV3(c) : this.mutateV2(c); - return this.receipt(key, digest, c.expiresAt, result); - }); - } - private receipt(key: string, digest: string, expires: number, value: T): T { - this.db.prepare('INSERT INTO receipts VALUES(?,?,?,?)').run(key, digest, expires, JSON.stringify(value)); - return value; - } - private mutateV2(c: MarketCommand): MarketActivityV3 | MarketListingRecord | MarketOrderRecord { - const a = c.action, now = this.now(), s = this.view(); - if (a.type === 'list') { - throw new Error('USD listing terms are retired; create a new native-XMR listing'); - } - if (a.type === 'cancel') { - const l = this.listing(a.listingId); - if (isXmrListing(l) || l.status !== 'open') throw new Error('USD legacy listing cannot be changed under this command'); - if (l.seller !== c.actor || l.highestBid) throw new Error('Only the seller can cancel, and auctions with bids cannot be cancelled'); - l.status = 'cancelled'; this.saveListing(l); return l; - } - if (a.type === 'mock-pay' || a.type === 'refund') { - const o = this.order(a.orderId); - if (isXmrOrder(o)) throw new Error('Use native-XMR signed terms for this order'); - if (o.buyer !== c.actor) throw new Error('Only the order buyer can perform this action'); - if (a.type === 'mock-pay') { - if (o.status !== 'awaiting-payment' || now >= o.payBy) throw new Error('Invoice is not payable'); - if (!this.card(s, o.cardHash, o.seller, o.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(o.cardHash))) throw new Error('Seller card changed; do not pay'); - o.status = 'paid'; o.deliverBy = now + DELIVERY_WINDOW; - } else { - if (o.status !== 'refund-required' || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(o.cardHash)) || - s.transaction(this.deliveryId(o))) throw new Error('Refund requires failed delivery and no pending/canonical sale transfer'); - o.status = 'refunded'; const l = this.listing(o.listingId); l.status = 'cancelled'; this.saveListing(l); - } - this.saveOrder(o); return o; - } - if (a.type === 'activity') return { - orders: (this.db.prepare("SELECT data FROM orders WHERE json_extract(data,'$.seller')=? OR json_extract(data,'$.buyer')=? ORDER BY rowid DESC") - .all(c.actor,c.actor) as { data: string }[]).map(r=>JSON.parse(r.data) as MarketOrderRecord), - listings: (this.db.prepare("SELECT data FROM listings WHERE json_extract(data,'$.seller')=? ORDER BY rowid DESC") - .all(c.actor) as { data: string }[]).map(r=>JSON.parse(r.data) as MarketListingRecord) }; - if (a.type === 'deliver') throw new Error('Legacy delivery is handled by the common signed-order delivery path'); - throw new Error('Legacy USD bids and checkout are retired; only already-created orders may proceed'); - } - - private mutateV3(c: MarketCommandV3): MarketActivityV3 | MarketListingRecord | MarketOrderRecord { - const a = c.action, now = this.now(), s = this.view(); - if (a.type === 'list') { - if (a.feeBps !== this.config.feeBps) throw new Error('Market fee changed; review and sign again'); - const hostFeeAddress = resolvedMarketPolicy(this.config).hostFeeAddress; - if ((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) !== hostFeeAddress) throw new Error('Host fee recipient changed; review and sign again'); - if (!this.card(s, a.cardHash, c.actor, a.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(a.cardHash))) throw new Error('Card is missing, stale, immature, or already pending'); - if (a.kind === 'fixed' ? a.endsAt !== null : a.endsAt === null || a.endsAt < now + 60_000 || a.endsAt > now + 30 * 86400_000) throw new Error('Auction duration must be 1 minute to 30 days; fixed listings have no end'); - const activeListings = this.db.prepare(`SELECT COUNT(*) AS n FROM listings - WHERE json_extract(data,'$.seller')=? AND status IN ('open','reserved')`).get(c.actor) as { n: number }; - if (activeListings.n >= 100) throw new Error('Limit: 100 active listings per seller'); - if ((this.db.prepare('SELECT COUNT(*) AS n FROM listings').get() as { n: number }).n >= 10000) throw new Error('Marketplace journal limit reached; operator archival required'); - if (this.db.prepare("SELECT 1 FROM listings WHERE card=? AND status IN ('open','reserved')").get(a.cardHash)) throw new Error('Card already listed'); - const mint = s.nft(a.cardHash)!.block; - const l: MarketListingV3 = { id: randomUUID(), termsVersion: 3, cardHash: a.cardHash, sequence: a.sequence, - seller: c.actor, title: mint.article.title, image: mint.article.image!, kind: a.kind, - priceAtomicXmr: a.priceAtomicXmr, feeBps: a.feeBps, hostFeeAddress, additionalFee: { ...ADDITIONAL_MARKET_FEE }, - createdAt: now, endsAt: a.endsAt, status: 'open', highestBid: null }; - this.saveListing(l); return l; - } - if (a.type === 'retire-legacy-listing') { - const listing = this.listing(a.listingId); - if (isXmrListing(listing)) throw new Error('Native-XMR listings use the signed cancel action'); - if (listing.seller !== c.actor || !['open','invalid'].includes(listing.status)) throw new Error('Only the seller can retire an unreserved legacy listing'); - if (listing.highestBid) throw new Error('Legacy auction has bids; resolve or wait for its original terms before retirement'); - if (this.db.prepare("SELECT 1 FROM orders WHERE json_extract(data,'$.listingId')=? AND json_extract(data,'$.status') IN ('awaiting-payment','paid','delivery-pending','refund-required','review') LIMIT 1").get(listing.id)) { - throw new Error('Legacy listing has an in-flight order; preserve that order under its original terms'); - } - listing.status = 'cancelled'; this.saveListing(listing); return listing; - } - if (a.type === 'bid' || a.type === 'buy' || a.type === 'cancel') { - const l = this.listing(a.listingId); - if (!isXmrListing(l)) throw new Error('Legacy USD listings are read-only; seller must retire and relist with fresh XMR authorization'); - if (l.status !== 'open') throw new Error('Listing is no longer available'); - if (a.type === 'cancel') { - if (l.seller !== c.actor || l.highestBid) throw new Error('Only the seller can cancel, and auctions with bids cannot be cancelled'); - l.status = 'cancelled'; this.saveListing(l); return l; - } - if (l.seller === c.actor) throw new Error('Cannot buy or bid on your own card'); - if (a.type === 'bid') { - if (l.kind !== 'auction' || now >= l.endsAt!) throw new Error('Auction is not accepting bids'); - const minimum = l.highestBid ? BigInt(l.highestBid.priceAtomicXmr) + 1n : BigInt(l.priceAtomicXmr); - if (BigInt(a.priceAtomicXmr) < minimum) throw new Error('Bid must meet the start price and exceed the current bid by one atomic unit'); - l.highestBid = { bidder: c.actor, priceAtomicXmr: a.priceAtomicXmr }; this.saveListing(l); return l; - } - const price = l.kind === 'fixed' ? l.priceAtomicXmr : l.highestBid?.priceAtomicXmr; - const hostFeeAddress = l.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address; - if ((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) !== hostFeeAddress) throw new Error('Host fee recipient does not match signed listing terms'); - if (l.kind === 'auction' && (now < l.endsAt! || now >= l.endsAt! + PAYMENT_WINDOW || l.highestBid?.bidder !== c.actor)) throw new Error('Only the auction winner can check out within 30 minutes of closing'); - if (price !== a.priceAtomicXmr || l.feeBps !== a.feeBps) throw new Error('Sale terms changed; review and sign again'); - if (!this.card(s, l.cardHash, l.seller, l.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(l.cardHash))) throw new Error('Card is no longer available'); - if ((this.db.prepare('SELECT COUNT(*) AS n FROM orders').get() as { n: number }).n >= 10000) throw new Error('Marketplace order limit reached; operator archival required'); - const payBy = l.kind === 'auction' ? l.endsAt! + PAYMENT_WINDOW : now + PAYMENT_WINDOW; - const order: MarketOrderV3 = { id: randomUUID(), termsVersion: 3, listingId: l.id, seller: l.seller, buyer: c.actor, - cardHash: l.cardHash, sequence: l.sequence, priceAtomicXmr: price!, feeBps: l.feeBps, hostFeeAddress, createdAt: now, payBy, - deliverBy: payBy + DELIVERY_WINDOW, status: 'awaiting-payment', ...mockXmrQuote(price!, l.feeBps), - payout: 'not-started', deliveryConfirmations: 0, requiredConfirmations: this.config.confirmations }; - l.status = 'reserved'; this.saveListing(l); this.saveOrder(order); return order; - } - if (a.type === 'mock-pay' || a.type === 'refund') { - const order = this.order(a.orderId); - if (!isXmrOrder(order)) throw new Error('Legacy USD orders keep their original terms and cannot be changed through native-XMR actions'); - if (order.buyer !== c.actor) throw new Error('Only the order buyer can perform this action'); - if (a.type === 'mock-pay') { - if (order.status !== 'awaiting-payment' || now >= order.payBy) throw new Error('Invoice is not payable'); - if (!this.card(s, order.cardHash, order.seller, order.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(order.cardHash))) throw new Error('Seller card changed; do not pay'); - order.status = 'paid'; order.deliverBy = now + DELIVERY_WINDOW; - } else { - if (order.status !== 'refund-required' || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(order.cardHash)) || - s.transaction(this.deliveryId(order))) throw new Error('Refund requires failed delivery and no pending/canonical sale transfer'); - order.status = 'refunded'; const listing = this.listing(order.listingId); listing.status = 'cancelled'; this.saveListing(listing); - } - this.saveOrder(order); return order; - } - throw new Error('Unsupported native-XMR market action'); - } -} diff --git a/server/market/xmr.ts b/server/market/xmr.ts deleted file mode 100755 index 3a7d01b..0000000 --- a/server/market/xmr.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { keccak } from 'hash-wasm'; -import { parseXmr } from '../../shared/xmr-amount.ts'; - -const alphabet='123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; -const decodedSizes:Record={2:1,3:2,5:3,6:4,7:5,9:6,10:7,11:8}; -/** Support only mainnet standard/subaddresses. Integrated/payment-ID addresses need a separate policy. */ -async function validateMainnetAddressPrefix(address:string):Promise{ - if(typeof address!=='string'||address.length!==95)throw new Error('Expected a mainnet standard XMR address or subaddress'); - const bytes:number[]=[]; - for(let start=0;start(size).fill(0); - for(let i=size-1;i>=0;i--){output[i]=Number(value&255n);value>>=8n;} - if(value)throw new Error('XMR Base58 block overflow');bytes.push(...output); - } - if(bytes.length!==69||![18,42].includes(bytes[0]))throw new Error('XMR address network/type is not supported'); - const decoded=Uint8Array.from(bytes),expected=(await keccak(decoded.slice(0,-4),256)).slice(0,8); - if(Buffer.from(decoded.slice(-4)).toString('hex')!==expected)throw new Error('XMR checksum mismatch'); - return bytes[0]; -} -export async function validateXmrAddress(address:string):Promise{ - await validateMainnetAddressPrefix(address); - return address; -} -export async function validateXmrSubaddress(address:string):Promise{ - if(await validateMainnetAddressPrefix(address)!==42)throw new Error('Expected a Monero mainnet subaddress'); - return address; -} -export function atomicXmr(value:string):bigint{ - return parseXmr(value); -} -export function fiatCents(value:string):number{ - if(typeof value!=='string'||value.length>10||!/^(0|[1-9]\d{0,6})(\.\d{1,2})?(?![\s\S])/.test(value))throw new Error('Invalid USD decimal'); - const [whole,fraction='']=value.split('.');return Number(whole)*100+Number(fraction.padEnd(2,'0')); -} diff --git a/server/operations-check.ts b/server/operations-check.ts index 3df9993..9600783 100755 --- a/server/operations-check.ts +++ b/server/operations-check.ts @@ -4,28 +4,16 @@ const schema=z.object({schema:z.literal('wikideck-operations-v1'),observedAt:z.s native:z.object({available:z.boolean(),queued:nonnegative}), ledger:z.object({height:nonnegative,mutationQueue:nonnegative,tipAgeSeconds:nonnegative.nullable()}), peers:z.object({configured:nonnegative,connected:nonnegative}), - eventLoop:z.object({meanMs:nonnegative}),disk:z.unknown(),diskSampledAt:nonnegative.nullable(), - market:z.object({queued:nonnegative,lastReconciledAt:nonnegative.nullable(), - orderStates:z.array(z.object({status:z.string(),count:nonnegative})).max(20)}), - custody:z.object({backend:z.string(),mutationHold:z.boolean().optional(),integrityHold:z.boolean().optional(), - audit:z.object({status:z.enum(['ok','failed']),lastAttemptAt:nonnegative,lastSuccessAt:nonnegative, - failureCode:z.string().nullable(),stale:z.boolean()}).optional()}).optional()}); + eventLoop:z.object({meanMs:nonnegative}),disk:z.unknown(),diskSampledAt:nonnegative.nullable()}); export function checkOperations(raw:unknown,now=Date.now()){ const s=schema.parse(raw),warnings:string[]=[]; const observation=Date.parse(s.observedAt); if(Math.abs(now-observation)>60000)warnings.push('diagnostics-clock-or-freshness'); if(!s.native.available)warnings.push('native-engine-unavailable'); - if(s.native.queued>=48||s.ledger.mutationQueue>=192||s.market.queued>=48)warnings.push('queue-pressure'); + if(s.native.queued>=48||s.ledger.mutationQueue>=192)warnings.push('queue-pressure'); if(!s.peers.configured||!s.peers.connected)warnings.push('peer-isolation'); if(s.ledger.height===0||s.ledger.tipAgeSeconds===null||s.ledger.tipAgeSeconds>3600)warnings.push('empty-or-stale-chain'); if(s.eventLoop.meanMs>250)warnings.push('event-loop-delay'); - if(!s.market.lastReconciledAt||now-s.market.lastReconciledAt>60000)warnings.push('market-reconciliation-stale'); - if(s.market.orderStates.some(r=>(r.status==='review'||r.status==='refund-required')&&r.count>0))warnings.push('market-operator-review'); - if(s.custody?.backend==='postgres') { - const audit=s.custody.audit; - if(s.custody.mutationHold||s.custody.integrityHold||audit?.status==='failed')warnings.push('custody-financial-review'); - if(!audit||audit.stale||now-audit.lastSuccessAt>120000||audit.lastSuccessAt>now+60000)warnings.push('custody-audit-stale'); - } const disk=z.object({availableBytes:nonnegative}).safeParse(s.disk); if(!disk.success||!s.diskSampledAt||now-s.diskSampledAt>90000)warnings.push('disk-sample-unavailable-or-stale'); else if(disk.data.availableBytes<512*1024*1024)warnings.push('disk-low'); diff --git a/server/operations.ts b/server/operations.ts index 2562e01..c5884a5 100755 --- a/server/operations.ts +++ b/server/operations.ts @@ -35,7 +35,7 @@ export function startOperations(port: number | undefined, snapshot: ()=>unknown, if(!directory)return; try { const fs=await statfs(directory); - const sizes=await Promise.all(['ledger.sqlite','ledger.sqlite-wal','market.sqlite','market.sqlite-wal'].map(async name=>{ + const sizes=await Promise.all(['ledger.sqlite','ledger.sqlite-wal'].map(async name=>{ try{return {name,bytes:(await stat(join(directory,name))).size};} catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return {name,bytes:0};throw error;} })); diff --git a/server/preflight.ts b/server/preflight.ts index 3e17a82..5793f54 100755 --- a/server/preflight.ts +++ b/server/preflight.ts @@ -1,23 +1,13 @@ -import { IS_MAINNET, IS_PILOT } from '../shared/network.ts'; -import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts'; +import { IS_MAINNET } from '../shared/network.ts'; import { assertLaunchAllowed, launchManifest } from './launch.ts'; -import { marketConfig } from './market/store.ts'; export type LaunchCheck = { id: string; status: 'pass' | 'blocked' | 'unverified'; detail: string }; /** Read-only local assessment. Never opens a ledger, starts PoW, contacts peers, or sets approvals. * Configured approvals cannot resolve findings that require new code or independent evidence. */ export function launchPreflight(env: NodeJS.ProcessEnv = process.env, now = Date.now()) { - const checks: LaunchCheck[] = [{ id: 'network-selection', status: IS_MAINNET || IS_PILOT ? 'pass' : 'blocked', - detail: IS_MAINNET ? 'Separate mainnet candidate selected.' : IS_PILOT - ? 'Private pilot chain selected; this is not a public or production profile.' - : 'Testnet selected; this is not a production profile.' }]; - try { - marketConfig(env); - checks.push({ id: 'mock-payment-boundary', status: 'pass', detail: 'Only mock payments are supported; market configuration is valid.' }); - } catch { - checks.push({ id: 'mock-payment-boundary', status: 'blocked', detail: 'Invalid market configuration or unsupported live payment mode. No secret values are included in this report.' }); - } - if (IS_MAINNET || IS_PILOT) { + const checks: LaunchCheck[] = [{ id: 'network-selection', status: IS_MAINNET ? 'pass' : 'blocked', + detail: IS_MAINNET ? 'Separate mainnet candidate selected.' : 'Testnet selected; this is not a production profile.' }]; + if (IS_MAINNET) { try { assertLaunchAllowed(env, now); checks.push({ id: 'operator-startup-gates', status: 'pass', detail: 'Local startup gates satisfied; this does not establish production readiness or peer independence.' }); @@ -26,15 +16,12 @@ export function launchPreflight(env: NodeJS.ProcessEnv = process.env, now = Date } } checks.push( - { id: 'public-node-resource-controls', status: 'blocked', detail: 'Stage-1 global RPC budgets, bounded pages/client history and read caching exist. In-memory DAG growth, full-history UI/reconciliation and production-scale load/capacity evidence remain unresolved.' }, - { id: 'consensus-launch-calibration', status: 'blocked', detail: 'Stage-3 bounded offline native benchmarks and deterministic retarget/timestamp analysis exist. Representative network hashrate, stochastic/multi-host adversarial evidence, reviewed launch rules/genesis and independent economic-security review remain missing.' }, - { id: 'wallet-key-protection', status: 'blocked', detail: 'Stage-2 encrypted vault storage, explicit migration/unlock and lock-aware signing exist. Legacy plaintext copies, unlocked-page compromise, migration/lifecycle coverage and independent production wallet/recovery review remain unresolved.' }, - { id: 'live-market-settlement', status: 'blocked', detail: 'No end-to-end live Monero custody path is connected. Production deposit allocation/crediting, treasury withdrawal signing, market ledger settlement, recovery integration and reviewed custody/refund policy remain unavailable. Mock payment is not real payment.' }, + { id: 'public-node-resource-controls', status: 'blocked', detail: 'Global RPC budgets, bounded pages, and browser history controls exist. In-memory DAG growth and production-scale load/capacity evidence remain unresolved.' }, + { id: 'consensus-launch-calibration', status: 'blocked', detail: 'Bounded offline native benchmarks and deterministic retarget/timestamp analysis exist. Representative network hashrate, stochastic/multi-host adversarial evidence, reviewed launch rules/genesis, and independent economic-security review remain missing.' }, + { id: 'wallet-key-protection', status: 'blocked', detail: 'Encrypted vault storage, explicit migration/unlock, and lock-aware signing exist. Legacy plaintext copies, unlocked-page compromise, migration/lifecycle coverage, and independent production wallet/recovery review remain unresolved.' }, { id: 'release-security-review', status: 'unverified', detail: 'Independent consensus/application review and release-specific dependency/native/container scans require external evidence; an operator environment flag is not evidence.' }, - { id: 'deployment-rehearsal', status: 'unverified', detail: 'Stage-4 explicit proxy trust, private diagnostics/checker and stopped-node paired backup/restore with local native replay tests exist. Actual HTTPS/proxy topology, independent hosts/miners, alert delivery, off-host recovery and on-call procedures remain unverified.' }, - { id: 'fee-recipient-control', status: 'unverified', detail: 'Confirm the fixed public XMR fee address, checksum and recipient control out of band before any future live payout. No keys or proof are requested here.' }, + { id: 'deployment-rehearsal', status: 'unverified', detail: 'Explicit proxy trust, private diagnostics/checker, and stopped-node ledger backup/restore with local native replay tests exist. Actual HTTPS/proxy topology, independent hosts/miners, alert delivery, off-host recovery, and on-call procedures remain unverified.' }, ); return { ...launchManifest(), assessedAt: new Date(now).toISOString(), productionReady: false, - decision: 'NO-GO', scope: 'Local code/configuration preflight; not an independent audit or launch approval.', - additionalFee: { ...ADDITIONAL_MARKET_FEE }, checks }; + decision: 'NO-GO', scope: 'Local code/configuration preflight; not an independent audit or launch approval.', checks }; } diff --git a/server/recovery.ts b/server/recovery.ts index c69e147..9e41ecb 100755 --- a/server/recovery.ts +++ b/server/recovery.ts @@ -6,14 +6,19 @@ import { DatabaseSync } from 'node:sqlite'; import { z } from 'zod'; import { NETWORK, PROFILE } from '../shared/network.ts'; import { CHAIN_ID } from './testnet/consensus.ts'; -import { CUSTODY_POLICY } from '../shared/custody.ts'; -const files=['ledger.sqlite','market.sqlite','custody-sandbox.sqlite'] as const; -const manifestSchema=z.object({format:z.literal('wikideck-stopped-backup'),version:z.literal(1), +const ledgerFile='ledger.sqlite'; +const legacyFiles=['ledger.sqlite','market.sqlite','custody-sandbox.sqlite'] as const; +const legacyManifestSchema=z.object({format:z.literal('wikideck-stopped-backup'),version:z.literal(1), network:z.literal(NETWORK),chainId:z.literal(CHAIN_ID),profile:z.literal(PROFILE),createdAt:z.string().datetime(), - files:z.array(z.object({name:z.enum(files),bytes:z.number().int().positive().max(64*1024**3),sha256:z.string().regex(/^[a-f0-9]{64}$/)}).strict()).min(2).max(3)}).strict() + files:z.array(z.object({name:z.enum(legacyFiles),bytes:z.number().int().positive().max(64*1024**3),sha256:z.string().regex(/^[a-f0-9]{64}$/)}).strict()).min(2).max(3)}).strict() .refine(m => new Set(m.files.map(f=>f.name)).size === m.files.length && - m.files.some(f=>f.name==='ledger.sqlite') && m.files.some(f=>f.name==='market.sqlite'), 'Missing or duplicate backup database'); + m.files.some(f=>f.name==='ledger.sqlite') && m.files.some(f=>f.name==='market.sqlite'), 'Missing or duplicate legacy backup database'); +const manifestSchema=z.object({format:z.literal('wikideck-stopped-backup'),version:z.literal(2), + network:z.literal(NETWORK),chainId:z.literal(CHAIN_ID),profile:z.literal(PROFILE),createdAt:z.string().datetime(), + files:z.tuple([z.object({name:z.literal(ledgerFile),bytes:z.number().int().positive().max(64*1024**3), + sha256:z.string().regex(/^[a-f0-9]{64}$/)}).strict()])}).strict(); +const supportedManifestSchema=z.union([manifestSchema,legacyManifestSchema]); async function regular(path:string){const s=await lstat(path);if(!s.isFile()||s.isSymbolicLink())throw new Error('Expected regular non-symlink database file');return s;} async function digest(path:string){const hash=createHash('sha256');for await(const chunk of createReadStream(path))hash.update(chunk);return hash.digest('hex');} async function flush(path:string){const handle=await open(path,'r+');try{await handle.sync();}finally{await handle.close();}} @@ -21,7 +26,7 @@ async function flushDirectory(path:string){ if(process.platform==='win32')return; // Windows directory durability requires filesystem-specific operational validation. const handle=await open(path,'r');try{await handle.sync();}finally{await handle.close();} } -function inspect(file:string,name:typeof files[number]) { +function inspect(file:string,name:string) { const db=new DatabaseSync(file,{readOnly:true}); try { const result=db.prepare('PRAGMA quick_check').all(); @@ -29,18 +34,6 @@ function inspect(file:string,name:typeof files[number]) { if(name==='ledger.sqlite'){ const meta=(key:string)=>(db.prepare('SELECT value FROM metadata WHERE key=?').get(key) as {value:string}|undefined)?.value; if(meta('schema_version')!=='1'||meta('network')!==NETWORK||meta('chain_id')!==CHAIN_ID)throw new Error('Ledger network/schema mismatch'); - }else if(name==='custody-sandbox.sqlite'){ - if(db.prepare("SELECT 1 FROM custody_meta WHERE key='postgres_cutover'").get()) { - throw new Error('PostgreSQL cutover source cannot be backed up as current custody; coordinated database backup required'); - } - const row=db.prepare("SELECT value FROM custody_meta WHERE key='identity'").get() as {value:string}|undefined; - const identity=row?JSON.parse(row.value):undefined; - if(identity?.version!==1||identity.network!==NETWORK||identity.chainId!==CHAIN_ID||identity.mode!=='sandbox'|| - identity.policy!==CUSTODY_POLICY||!z.string().uuid().safeParse(identity.id).success)throw new Error('Custody identity mismatch'); - }else{ - const row=db.prepare("SELECT value FROM market_meta WHERE key='identity'").get() as {value:string}|undefined; - const identity=row?JSON.parse(row.value):undefined; - if(identity?.version!==1||identity.network!==NETWORK||identity.chainId!==CHAIN_ID||!z.string().uuid().safeParse(identity.id).success)throw new Error('Market identity mismatch'); } }finally{db.close();} } @@ -57,35 +50,27 @@ export async function withStoppedNodeLock(dataDir:string,operation:(source:st return offlineLock(source,()=>operation(source)); } /** Snapshot the local SQLite half while the caller holds withStoppedNodeLock(). */ -export async function createNodeSnapshotUnlocked(source:string,destination:string,allowPostgresBinding=false){ +export async function createNodeSnapshotUnlocked(source:string,destination:string){ const dest=resolve(destination); const rel=relative(source,dest); if(!rel||(!rel.startsWith('..')&&!isAbsolute(rel)))throw new Error('Backup destination must be outside the node data directory'); // File-only backups cannot represent an external financial database. Fail rather than omit liabilities. - try { - await lstat(join(source,PROFILE,'custody-postgres.json')); - if(!allowPostgresBinding)throw new Error('PostgreSQL custody requires coordinated database and node backup; SQLite-only backup refused'); - } catch(e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; } - if(await realpath(join(source,PROFILE))!==join(source,PROFILE))throw new Error('Network data directory must not be symlinked'); - await mkdir(dest,{mode:0o700}); // Exclusive: never overwrite or recursively create a surprising destination. - const entries=[]; - for(const name of files){ - const src=join(source,PROFILE,name),target=join(dest,name); - // PostgreSQL is authoritative after cutover. Do not reinterpret or copy the frozen SQLite source as current custody. - if(name==='custody-sandbox.sqlite'&&allowPostgresBinding)continue; - if(name==='custody-sandbox.sqlite'){ - try{await lstat(src);}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')continue;throw e;} - } - await regular(src);inspect(src,name); - const db=new DatabaseSync(src,{readOnly:true}); - try{db.prepare('VACUUM INTO ?').run(target);}finally{db.close();} - if(process.platform!=='win32')await chmod(target,0o600); - await flush(target); - inspect(target,name);const info=await regular(target); - entries.push({name,bytes:info.size,sha256:await digest(target)}); - } - const manifest=manifestSchema.parse({format:'wikideck-stopped-backup',version:1,network:NETWORK,chainId:CHAIN_ID,profile:PROFILE, - createdAt:new Date().toISOString(),files:entries}); + // Never create a misleading ledger-only backup when retired financial journals remain. + for(const name of ['market.sqlite','custody-sandbox.sqlite','custody-postgres.json']){ + try { await lstat(join(source,PROFILE,name)); throw new Error(`Legacy financial data (${name}) requires a separately retained pre-retirement backup`); } + catch(e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; } + } + if(await realpath(join(source,PROFILE))!==join(source,PROFILE))throw new Error('Network data directory must not be symlinked'); + await mkdir(dest,{mode:0o700}); // Exclusive: never overwrite or recursively create a surprising destination. + const src=join(source,PROFILE,ledgerFile),target=join(dest,ledgerFile); + await regular(src);inspect(src,ledgerFile); + const db=new DatabaseSync(src,{readOnly:true}); + try{db.prepare('VACUUM INTO ?').run(target);}finally{db.close();} + if(process.platform!=='win32')await chmod(target,0o600); + await flush(target); + inspect(target,ledgerFile);const info=await regular(target); + const manifest=manifestSchema.parse({format:'wikideck-stopped-backup',version:2,network:NETWORK,chainId:CHAIN_ID,profile:PROFILE, + createdAt:new Date().toISOString(),files:[{name:ledgerFile,bytes:info.size,sha256:await digest(target)}]}); // Completion marker written last. Partial output is preserved for operator inspection, never treated as usable. await writeFile(join(dest,'manifest.json'),JSON.stringify(manifest,null,2),{flag:'wx',mode:0o600}); await flush(join(dest,'manifest.json')); @@ -98,7 +83,7 @@ export async function createBackup(dataDir:string,destination:string){ export async function verifyBackup(directory:string){ const root=await realpath(directory),manifestFile=join(root,'manifest.json'); if((await regular(manifestFile)).size>16384)throw new Error('Backup manifest too large'); - const manifest=manifestSchema.parse(JSON.parse(await readFile(manifestFile,'utf8'))); + const manifest=supportedManifestSchema.parse(JSON.parse(await readFile(manifestFile,'utf8'))); if(new Set(manifest.files.map(f=>f.name)).size!==manifest.files.length)throw new Error('Duplicate backup database'); for(const file of manifest.files){ const path=join(root,file.name),info=await regular(path); diff --git a/server/testnet/http.ts b/server/testnet/http.ts index 6a6cee0..d0f2807 100644 --- a/server/testnet/http.ts +++ b/server/testnet/http.ts @@ -1,13 +1,11 @@ import express from 'express'; -import { Pool } from 'pg'; import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { mkdir, open, unlink } from 'node:fs/promises'; import { z } from 'zod'; -import { NETWORK, CONSENSUS } from '../../shared/testnet.ts'; -import { transactionCardIds } from '../../shared/testnet.ts'; +import { NETWORK } from '../../shared/testnet.ts'; import { CHAIN_ID, blockSchema, tradeSchema, transferSchema, transactionSchema } from './consensus.ts'; import { RandomX } from './randomx.ts'; import { Ledger } from './ledger.ts'; @@ -15,66 +13,13 @@ import { PeerNetwork } from './peers.ts'; import { wikipediaEnrichment } from '../enrichment.ts'; import { snapshotStats } from '../../shared/enrichment.ts'; import { wikipediaArticles } from '../wiki.ts'; -import { Market, marketConfig } from '../market/store.ts'; -import { paymentMode } from '../market/payments.ts'; -import { marketCommandSchema } from '../../shared/market.ts'; -import { marketCommandV3Schema } from '../../shared/market-v3.ts'; -import { custodyCommandSchema } from '../../shared/custody.ts'; -import { moneroDepositAddressRequestSchema } from '../../shared/monero-deposit.ts'; -import { mainnetCustodyBalanceRequestSchema, mainnetWithdrawalRequestSchema, - mainnetWithdrawalCancellationSchema } from '../../shared/monero-withdrawal.ts'; -import { CustodyMarket, CustodyError, custodyMode } from '../market/custody.ts'; -import { openCustodyStorage } from '../market/custody-storage.ts'; -import { PostgresCustody } from '../market/postgres-custody.ts'; -import { CanonicalObserver } from '../market/canonical-observer.ts'; -import { enrollMoneroDepositAddress, type MainnetEnrollmentContext } from '../market/monero-deposit-enrollment.ts'; -import { cancelMainnetWithdrawal, readMainnetCustodyBalance, reserveMainnetWithdrawal } from '../market/mainnet-withdrawal.ts'; -import { verifyMainnetCustodyIdentity } from '../market/mainnet-custody-identity.ts'; -import { createMoneroDepositAddressRpc, loadMoneroObserverConfig } from '../market/monero-observer.ts'; -import { loadMoneroMtlsTransport, loadMoneroMtlsTransportConfig } from '../market/monero-mtls-transport.ts'; -import { postgresConfig } from '../market/postgres-config.ts'; -import { validateMarketPolicy } from '../market/policy.ts'; -import { IS_MAINNET, IS_PILOT, PROFILE } from '../../shared/network.ts'; +import { IS_MAINNET, PROFILE } from '../../shared/network.ts'; import { assertLaunchAllowed, launchManifest } from '../launch.ts'; import { resourceConfig, RpcBudget, RpcError } from '../resources.ts'; import { trustedProxies, operationsPort, startOperations } from '../operations.ts'; const root = fileURLToPath(new URL('../../', import.meta.url)); assertLaunchAllowed(); -const marketRuntimeSetting = process.env.WIKIDECK_MARKET_RUNTIME_ENABLED ?? '0'; -if (marketRuntimeSetting !== '0' && marketRuntimeSetting !== '1') { - throw new Error('WIKIDECK_MARKET_RUNTIME_ENABLED must be exactly 0 or 1'); -} -const marketRuntimeEnabled = marketRuntimeSetting === '1'; -const custodyConfig = custodyMode(); // Explicit opt-in sandbox only. Unknown/live modes fail before opening resources. -paymentMode(); // Keep real-payment modes fail-closed even when the optional market runtime is off. -let paymentConfig: ReturnType | undefined; -if (marketRuntimeEnabled || custodyConfig === 'sandbox') { - paymentConfig = marketConfig(); - await validateMarketPolicy(paymentConfig); // Reject invalid recipient checksum/network before opening node resources. -} -const canonicalObserverMode = process.env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL; -if (canonicalObserverMode !== undefined && canonicalObserverMode !== '0' && canonicalObserverMode !== '1') { - throw new Error('WIKIDECK_CUSTODY_CANONICAL_REHEARSAL must be exactly 0 or 1'); -} -const depositEnrollmentMode = process.env.WIKIDECK_MONERO_DEPOSIT_ENROLLMENT ?? '0'; -if (depositEnrollmentMode !== '0' && depositEnrollmentMode !== '1') { - throw new Error('WIKIDECK_MONERO_DEPOSIT_ENROLLMENT must be exactly 0 or 1'); -} -if (depositEnrollmentMode === '1' && !IS_MAINNET) { - throw new Error('Monero deposit enrollment requires the mainnet profile'); -} -const withdrawalRequestMode = process.env.WIKIDECK_MONERO_WITHDRAWAL_REQUESTS ?? '0'; -if (withdrawalRequestMode !== '0' && withdrawalRequestMode !== '1') { - throw new Error('WIKIDECK_MONERO_WITHDRAWAL_REQUESTS must be exactly 0 or 1'); -} -if (withdrawalRequestMode === '1' && !IS_MAINNET) { - throw new Error('Monero withdrawal requests require the mainnet profile'); -} -if (canonicalObserverMode === '1' && (process.env.NODE_ENV === 'production' || custodyConfig !== 'sandbox' || - process.env.WIKIDECK_CUSTODY_STORAGE !== 'postgres' || process.env.WIKIDECK_PG_DISPOSABLE_TEST !== '1')) { - throw new Error('Canonical observer rehearsal requires sandbox custody, PostgreSQL, and disposable test isolation'); -} const limits = resourceConfig(); const proxyTrust=trustedProxies(); const opsPort=operationsPort(); @@ -96,68 +41,6 @@ let peers: PeerNetwork; try { peers = new PeerNetwork({ ledger, urls:(process.env.WIKIDECK_PEERS ?? '').split(',').map(s=>s.trim()).filter(Boolean), allowPrivate:process.env.WIKIDECK_ALLOW_PRIVATE_PEERS === '1' }); } catch(e) {ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;} -let market: Market | undefined; -try { if (marketRuntimeEnabled) market = await Market.open(dataDir, ledger, paymentConfig!, Date.now, { worker: true }); } -catch(e) {peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;} -let custody: CustodyMarket | PostgresCustody | undefined; -let custodyObserver: CanonicalObserver | undefined; -let moneroDepositPool: Pool | undefined; -let moneroDepositRpc: ReturnType | undefined; -let moneroDepositContext: MainnetEnrollmentContext | undefined; -let moneroWithdrawalPool: Pool | undefined; -let moneroWithdrawalCustodianId: string | undefined; -try { - if (custodyConfig === 'sandbox') custody = await openCustodyStorage(dataDir, { ...paymentConfig!, - card: (hash, owner, sequence) => { - const nft = ledger.nft(hash); - if (!nft || nft.owner !== owner || nft.confirmations < CONSENSUS.coinbaseMaturity || nft.sequence !== sequence || - ledger.pendingTransactions().some(tx => transactionCardIds(tx).includes(hash))) return undefined; - return { title: nft.block.article.title }; - } }); - if (canonicalObserverMode === '1') { - if (!(custody instanceof PostgresCustody)) throw new Error('Canonical observer rehearsal requires PostgreSQL custody'); - custodyObserver = new CanonicalObserver(custody, ledger); - if ((await custodyObserver.start()).state !== 'running') throw new Error('Canonical observer rehearsal failed to start; inspect custody diagnostics'); - } - if (depositEnrollmentMode === '1') { - const custodianId = z.string().uuid().parse(process.env.WIKIDECK_MONERO_CUSTODIAN_ID); - const walletGenerationId = z.string().uuid().parse(process.env.WIKIDECK_MONERO_WALLET_GENERATION_ID); - const expectedFingerprint = z.string().regex(/^[a-f0-9]{64}$/).parse(process.env.WIKIDECK_MONERO_WALLET_FINGERPRINT); - const accountText = process.env.WIKIDECK_MONERO_DEPOSIT_ACCOUNT_INDEX; - if (!accountText || !/^(0|[1-9]\d{0,9})$/.test(accountText) || Number(accountText) > 0xffff_ffff) { - throw new Error('Invalid configured Monero deposit account index'); - } - const observerConfig = await loadMoneroObserverConfig({ ...process.env, WIKIDECK_MONERO_OBSERVER: '1' }); - const mtlsConfig = await loadMoneroMtlsTransportConfig(); - if (new URL(observerConfig.url).href !== new URL(mtlsConfig.url).href) { - throw new Error('Monero observer and mTLS gateway endpoints do not match'); - } - const transport = await loadMoneroMtlsTransport(mtlsConfig); - moneroDepositRpc = createMoneroDepositAddressRpc(observerConfig, transport); - const actualFingerprint = await moneroDepositRpc.verifyWallet(); - if (actualFingerprint !== expectedFingerprint) throw new Error('Configured Monero wallet fingerprint does not match the protected wallet'); - const allocatorUrlFile = process.env.WIKIDECK_MONERO_ALLOCATOR_PG_URL_FILE; - const allocatorCaFile = process.env.WIKIDECK_MONERO_ALLOCATOR_PG_CA_FILE; - if (!allocatorUrlFile || !allocatorCaFile) throw new Error('Monero deposit allocator PostgreSQL URL and CA files are required'); - moneroDepositPool = new Pool(await postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: allocatorUrlFile, - WIKIDECK_CUSTODY_PG_CA_FILE: allocatorCaFile, WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls' })); - moneroDepositPool.on('error', () => console.error('Monero deposit allocator database unavailable.')); - await moneroDepositPool.query('SELECT 1 FROM wk_custody.monero_deposit_allocations LIMIT 0'); - moneroDepositContext = { custodianId, chainId: CHAIN_ID, walletGenerationId, walletFingerprint: actualFingerprint, - accountIndex: Number(accountText) }; - } - if (withdrawalRequestMode === '1') { - moneroWithdrawalCustodianId = z.string().uuid().parse(process.env.WIKIDECK_MONERO_WITHDRAWAL_CUSTODIAN_ID); - const urlFile = process.env.WIKIDECK_MONERO_WITHDRAWAL_PG_URL_FILE; - const caFile = process.env.WIKIDECK_MONERO_WITHDRAWAL_PG_CA_FILE; - if (!urlFile || !caFile) throw new Error('Monero withdrawal request PostgreSQL URL and CA files are required'); - moneroWithdrawalPool = new Pool(await postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: urlFile, - WIKIDECK_CUSTODY_PG_CA_FILE: caFile, WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls' })); - moneroWithdrawalPool.on('error', () => console.error('Monero withdrawal request database unavailable.')); - await verifyMainnetCustodyIdentity(moneroWithdrawalPool, moneroWithdrawalCustodianId); - await moneroWithdrawalPool.query('SELECT 1 FROM wk_custody.monero_withdrawal_intents LIMIT 0'); - } -} catch(e) {await moneroWithdrawalPool?.end();await moneroDepositPool?.end();await custodyObserver?.close();await custody?.close();await market?.close();peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;} const app = express(); app.disable('x-powered-by'); if(proxyTrust.length)app.set('trust proxy',proxyTrust); @@ -191,93 +74,6 @@ app.get('/api/canonical',(req,res)=>{ app.get('/api/network',(_req,res)=>res.json(launchManifest())); app.get('/api/chain',(_req,res)=>res.json(ledger.legacySnapshot(limits.legacyBlocks))); app.get('/api/peers',(_req,res)=>res.json({peers:peers.status()})); -if (market) { -app.get('/api/market', (req,res)=>res.json(market!.browse(Number(req.query.offset ?? 0)))); -app.post('/api/market/command',rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}),async(req,res)=>{ - const current = marketCommandV3Schema.safeParse(req.body); - const command = current.success ? current.data : marketCommandSchema.parse(req.body); - try { - const result = await market!.execute(command); - res.json(result); - if(command.action.type==='deliver')void peers.broadcastTransfer(command.action.transfer).catch(()=>{}); - } catch(error) { - if(error instanceof RpcError)throw error; - // Marketplace storage/payment internals must never leak through the generic node error handler. - if(error instanceof Error && !/SQLITE|constraint|database/i.test(error.message))res.status(400).json({error:error.message.slice(0,200)}); - else res.status(409).json({error:'Marketplace state conflict. Refresh and retry.'}); - } -}); -} -app.get('/api/custody', async (req,res)=> { - if (!custody) { res.json({ enabled: false }); return; } - try { res.json(await custody.browse(Number(req.query.offset ?? 0))); } - catch (error) { - if (error instanceof CustodyError) res.status(400).json({ error: error.message }); - else res.status(503).json({ error: 'Custody sandbox unavailable; operator review required.' }); - } -}); -app.post('/api/custody/command', rateLimit({windowMs:60000,limit:30,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res)=> { - if (!custody) { res.status(503).json({ error: 'Custody sandbox is disabled on this host.' }); return; } - const parsed = custodyCommandSchema.safeParse(req.body); - if (!parsed.success) { res.status(400).json({ error: 'Invalid custody command schema.' }); return; } - try { res.json(await custody.execute(parsed.data)); } - catch (error) { - if (error instanceof CustodyError) res.status(400).json({ error: error.message }); - else res.status(503).json({ error: 'Custody state conflict; refresh activity before retrying. Operator review may be required.' }); - } -}); -if (depositEnrollmentMode === '1' && moneroDepositContext && moneroDepositRpc && moneroDepositPool) { - app.get('/api/monero/deposit-config', (_req,res) => { - res.json({ network: 'wikideck-randomx-mainnet-1', chainId: moneroDepositContext!.chainId, - custodianId: moneroDepositContext!.custodianId, walletGenerationId: moneroDepositContext!.walletGenerationId, - walletFingerprint: moneroDepositContext!.walletFingerprint, accountIndex: moneroDepositContext!.accountIndex, - addressEnrollmentEnabled: true, depositCreditEnabled: false, withdrawalsEnabled: false }); - }); - app.post('/api/monero/deposit-address', rateLimit({windowMs:60000,limit:5,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res) => { - const parsed = moneroDepositAddressRequestSchema.safeParse(req.body); - if (!parsed.success) { res.status(400).json({ error: 'Invalid signed Monero deposit-address request.' }); return; } - try { - const result = await enrollMoneroDepositAddress(moneroDepositPool!, moneroDepositRpc!, moneroDepositContext!, parsed.data); - res.json({ ...result, status: 'address-enrolled-not-credited', depositCreditEnabled: false, withdrawalsEnabled: false }); - } catch { - res.status(409).json({ error: 'Deposit address enrollment failed; verify signed terms and retry with the same request ID.' }); - } - }); -} -if (withdrawalRequestMode === '1' && moneroWithdrawalPool && moneroWithdrawalCustodianId) { - app.get('/api/monero/withdrawal-config', (_req,res) => { - res.json({ network: 'wikideck-randomx-mainnet-1', chainId: CHAIN_ID, - custodianId: moneroWithdrawalCustodianId, withdrawalRequestsEnabled: true, - transactionSigningEnabled: false, transactionRelayEnabled: false }); - }); - app.post('/api/monero/custody-balance', rateLimit({windowMs:60000,limit:20,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res) => { - const parsed = mainnetCustodyBalanceRequestSchema.safeParse(req.body); - if (!parsed.success) { res.status(400).json({ error: 'Invalid signed mainnet custody balance request.' }); return; } - try { res.json(await readMainnetCustodyBalance(moneroWithdrawalPool!, parsed.data)); } - catch { res.status(403).json({ error: 'Mainnet custody balance is unavailable for this signed request.' }); } - }); - app.post('/api/monero/withdrawals', rateLimit({windowMs:60000,limit:5,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res) => { - const parsed = mainnetWithdrawalRequestSchema.safeParse(req.body); - if (!parsed.success) { res.status(400).json({ error: 'Invalid signed mainnet withdrawal request.' }); return; } - try { - const result = await reserveMainnetWithdrawal(moneroWithdrawalPool!, parsed.data); - res.status(202).json({ ...result, status: result.status === 'reserved' ? 'reserved-not-sent' : result.status, - transactionSigningEnabled: false, transactionRelayEnabled: false }); - } catch { - res.status(409).json({ error: 'Withdrawal request could not be reserved; verify signed terms and available balance.' }); - } - }); - app.post('/api/monero/withdrawals/cancel', rateLimit({windowMs:60000,limit:5,standardHeaders:'draft-8',legacyHeaders:false}), async (req,res) => { - const parsed = mainnetWithdrawalCancellationSchema.safeParse(req.body); - if (!parsed.success) { res.status(400).json({ error: 'Invalid signed withdrawal cancellation.' }); return; } - try { - const result = await cancelMainnetWithdrawal(moneroWithdrawalPool!, parsed.data); - res.json({ ...result, transactionSigningEnabled: false, transactionRelayEnabled: false }); - } catch { - res.status(409).json({ error: 'Withdrawal cancellation could not be applied; it may already be cancelled or under operator processing.' }); - } - }); -} app.get('/api/decks/:address', rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}), (req,res)=>{ if(typeof req.params.address!=='string'||!/^[a-f0-9]{64}$/.test(req.params.address)){ res.status(400).json({error:'Invalid wallet address'});return; @@ -365,55 +161,22 @@ app.use((err:Error & {status?:number},_req:express.Request,res:express.Response, }); const port=Number(process.env.PORT??3001),host=process.env.HOST??'127.0.0.1'; const server=app.listen(port,host,()=>{ - const a=server.address();console.log(`Wikideck RandomX ${IS_MAINNET?'mainnet candidate':IS_PILOT?'private pilot':'testnet'}: http://${host}:${typeof a==='object'&&a?a.port:port} · chain ${CHAIN_ID} · ${ledger.snapshot().blocks.length} blocks`); + const a=server.address();console.log(`Wikideck RandomX ${IS_MAINNET?'mainnet candidate':'testnet'}: http://${host}:${typeof a==='object'&&a?a.port:port} · chain ${CHAIN_ID} · ${ledger.snapshot().blocks.length} blocks`); peers.start(); }); -const operations=startOperations(opsPort,()=>({network:NETWORK,paymentMode:'mock', +const operations=startOperations(opsPort,()=>({network:NETWORK, ledger:ledger.diagnostics(),native:engine.diagnostics(), peers:{configured:peers.status().length,connected:peers.status().filter(p=>p.connected).length}, - rpc:{api:apiBudget.status(),p2p:peerBudget.status()},market:market?.diagnostics() ?? { enabled:false }, - canonicalObserver:custodyObserver?.status(), - custody: custody && 'diagnostics' in custody ? custody.diagnostics() : { backend: custody ? 'sqlite-sandbox' : 'off' }}),resolve(dataDir,PROFILE)); + rpc:{api:apiBudget.status(),p2p:peerBudget.status()}}),resolve(dataDir,PROFILE)); server.maxConnections=256; server.headersTimeout=10000; server.requestTimeout=15000; server.keepAliveTimeout=5000; server.setTimeout(30000,socket=>socket.destroy()); -// Advance simulated payout/expiry state even when no browser is open. No real funds are sent. let stopping=false; -let catchupPaused=false; -let catchupImmediate:NodeJS.Immediate|undefined; -let marketTimer:NodeJS.Timeout|undefined; -let marketCatchupTimer:NodeJS.Timeout|undefined; -// Drain durable catch-up without page-load dependence. Each pass is a smaller synchronous -// SQLite slice, then setImmediate yields so HTTP and peer work can run between passes. -const advanceMarketCatchup=()=>{ - catchupImmediate=undefined; - if(!market||stopping||catchupPaused||market.isReconciliationWorkerBusy()||!market.hasReconciliationBacklog())return; - if(market.isReconciliationWorkerFailed()){ - catchupPaused=true;console.error('Marketplace reconciliation worker failed; operator review required.');return; - } - try {market.browse();} - catch {catchupPaused=true;console.error('Marketplace canonical catch-up paused; operator review required.');return;} - if(market.hasReconciliationBacklog()&&!market.isReconciliationWorkerBusy()){ - catchupImmediate=setImmediate(advanceMarketCatchup);catchupImmediate.unref(); - } -}; -if (market) { - marketTimer=setInterval(()=>{try {market!.browse();}catch{console.error('Marketplace reconciliation failed; operator review required.');}},15000); - marketTimer.unref(); - marketCatchupTimer=setInterval(()=>{ - if(!stopping&&!catchupPaused&&!catchupImmediate&&market!.hasReconciliationBacklog()){ - catchupImmediate=setImmediate(advanceMarketCatchup);catchupImmediate.unref(); - } - },250); - marketCatchupTimer.unref(); -} for(const signal of ['SIGTERM','SIGINT']as const)process.once(signal,()=>{ - if(stopping)return;stopping=true;peers.close();operations.close();if(marketTimer)clearInterval(marketTimer);if(marketCatchupTimer)clearInterval(marketCatchupTimer); - if(catchupImmediate)clearImmediate(catchupImmediate); - const observerDrain=custodyObserver?.close()??Promise.resolve(); + if(stopping)return;stopping=true;peers.close();operations.close(); const timeout=setTimeout(()=>process.exit(1),25000);timeout.unref(); - server.close(async()=>{await observerDrain;await moneroWithdrawalPool?.end();await moneroDepositPool?.end();await custody?.close();await market?.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);}); + server.close(async()=>{ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);}); }); -server.on('error',async()=>{stopping=true;if(marketTimer)clearInterval(marketTimer);if(marketCatchupTimer)clearInterval(marketCatchupTimer);if(catchupImmediate)clearImmediate(catchupImmediate);operations.close();peers.close();await custodyObserver?.close();await moneroWithdrawalPool?.end();await moneroDepositPool?.end();await custody?.close();await market?.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);}); +server.on('error',async()=>{stopping=true;operations.close();peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);}); diff --git a/server/testnet/ledger.ts b/server/testnet/ledger.ts index f369bfb..25499b6 100644 --- a/server/testnet/ledger.ts +++ b/server/testnet/ledger.ts @@ -257,8 +257,6 @@ export class Ledger { canonicalHead(): number { return this.index.head(); } nft(token: string) { return this.index.nft(token, this.height()); } canonicalTransaction(id: string) { return this.index.transaction(id); } - /** Local read-only observer path for the market worker; never exposed through an HTTP response. */ - reconciliationDatabasePath(): string { return this.file; } legacySnapshot(maxBlocks: number): TestnetSnapshot { // Reject before any full-chain copy/serialization. Never truncate ownership history silently. if (this.height() > maxBlocks) throw new RpcError(413, 'Full snapshot disabled at this height; use /api/summary and /api/canonical pages.'); diff --git a/server/testnet/sale-candidate.ts b/server/testnet/sale-candidate.ts deleted file mode 100755 index deb003e..0000000 --- a/server/testnet/sale-candidate.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { z } from 'zod'; -import { NETWORK } from '../../shared/network.ts'; -import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts'; -import { allocateXmrSale } from '../../shared/xmr-pricing.ts'; -import { parseAtomicXmr } from '../../shared/xmr-amount.ts'; -import { nftTransferMessage, nftTradeMessage, type NftState, type NftTransfer, type NftTrade } from '../../shared/testnet.ts'; -import { SALE_CANDIDATE, saleTransactionSchema, saleTermsSchema, saleTermsMessage, - saleCompletionMessage, saleCancellationMessage, type SaleTerms, type SaleTransaction } from '../../shared/sale-candidate.ts'; -import { CHAIN_ID, sha256, verifySignature, transferSchema, tradeSchema } from './consensus.ts'; -import type { CustodyOrder } from '../../shared/custody.ts'; - -const integer = z.number().int().min(1).max(1000000); -const policySchema = z.object({ coinbaseMaturity: integer, lockDepth: integer, outcomeDepth: integer, - maxLockBlocks: integer, maxTransactions: z.number().int().min(1).max(64) }).strict(); -export type SalePolicy = z.infer; -export type SaleContext = { chainId: string; policyDigest: string; policy: SalePolicy }; -export type SaleRecord = { terms: SaleTerms; lockHeight: number; status: 'locked' | 'completed' | 'cancelled' | 'expired'; - outcomeHeight?: number; reservationDigest?: string }; -export type SaleState = { height: number; nfts: NftState; sales: Record; - locks: Record; orders: Record }; -export type CandidateTransaction = SaleTransaction | { type: 'native-transfer'; transfer: NftTransfer } | - { type: 'native-trade'; trade: NftTrade }; -const genericChain = z.string().regex(/^[a-f0-9]{64}$/); -const candidateNativeTransfer = transferSchema.extend({ chainId: genericChain }); -// Reuse the existing two-party signature/uniqueness checks, but NEVER the active chain ID. -const candidateNativeTrade = tradeSchema.safeExtend({ chainId: genericChain }); -const candidateTransactionSchema = z.union([saleTransactionSchema, - z.object({ type: z.literal('native-transfer'), transfer: candidateNativeTransfer }).strict(), - z.object({ type: z.literal('native-trade'), trade: candidateNativeTrade }).strict()]); -function check(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); } -const orderKey = (t: SaleTerms) => `${t.custodianId}:${t.orderId}`; - -/** Explicit policy, no invented production thresholds. Digest creates an OFFLINE domain, not a new genesis. */ -export function saleContext(raw: unknown): SaleContext { - const policy = policySchema.parse(raw); - check(policy.maxLockBlocks > policy.lockDepth, 'Lock window must exceed lock depth'); - const policyDigest = sha256(JSON.stringify([SALE_CANDIDATE, policy.coinbaseMaturity, policy.lockDepth, - policy.outcomeDepth, policy.maxLockBlocks, policy.maxTransactions, ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address])); - return { policy, policyDigest, chainId: sha256(JSON.stringify([SALE_CANDIDATE, NETWORK, CHAIN_ID, policyDigest])) }; -} -export function saleId(terms: SaleTerms): string { return sha256(saleTermsMessage(saleTermsSchema.parse(terms))); } - -/** Pure bridge from an actual reservation record to candidate terms; signs/submits/debits NOTHING. */ -export function termsForCustodyOrder(order: CustodyOrder, context: SaleContext, options: { - custodianId: string; host: string; feeBps: number; lockUntilHeight: number; expiresHeight: number; -}): SaleTerms { - check(order.status === 'awaiting-lock', 'Only awaiting-lock custody orders can draft candidate terms'); - const allocation = allocateXmrSale(parseAtomicXmr(order.grossAtomicXmr), options.feeBps); - check(order.sellerAtomicXmr === allocation.sellerBeforeNetworkCosts.toString() && order.hostAtomicXmr === allocation.hosting.toString() && - order.authorAtomicXmr === allocation.author.toString() && order.authorAddress === ADDITIONAL_MARKET_FEE.address, - 'Custody order allocations do not match candidate terms'); - return saleTermsSchema.parse({ protocol: SALE_CANDIDATE, network: NETWORK, chainId: context.chainId, - policyDigest: context.policyDigest, custodianId: options.custodianId, orderId: order.id, cardHash: order.cardHash, - sequence: order.sequence, seller: order.seller, buyer: order.buyer, host: options.host, - priceAtomicXmr: order.grossAtomicXmr, feeBps: options.feeBps, hostFeeAddress: order.hostFeeAddress, - lockUntilHeight: options.lockUntilHeight, expiresHeight: options.expiresHeight }); -} -export function initialSaleState(nfts: NftState, height = 0): SaleState { - check(Number.isSafeInteger(height) && height >= 0, 'Invalid candidate height'); - return { height, nfts: Object.assign(Object.create(null), structuredClone(nfts)), - sales: Object.create(null), locks: Object.create(null), orders: Object.create(null) }; -} -function advance(sequence: number): number { - check(Number.isSafeInteger(sequence) && sequence < Number.MAX_SAFE_INTEGER, 'NFT sequence exhausted'); return sequence + 1; -} - -/** Offline deterministic block transition; all changes roll back by discarding the clone on any error. - * No HTTP caller, Ledger import, wallet RPC, time, mutable configuration or custody DB access here. - */ -export function applySaleBlock(parent: SaleState, raw: unknown[], context: SaleContext): SaleState { - const canonicalContext = saleContext(context.policy); - check(context.chainId === canonicalContext.chainId && context.policyDigest === canonicalContext.policyDigest && context.chainId !== CHAIN_ID, - 'Invalid candidate context'); - check(Number.isSafeInteger(parent.height) && parent.height < Number.MAX_SAFE_INTEGER, 'Candidate height exhausted'); - check(Array.isArray(raw) && raw.length <= context.policy.maxTransactions, 'Candidate transaction budget exceeded'); - const txs = raw.map(tx => candidateTransactionSchema.parse(tx)); - const state = structuredClone(parent); state.height++; - const seen = new Set(); - for (const tx of txs) { - const identity = candidateIdentity(tx); - check(!seen.has(identity), 'Duplicate candidate transaction'); seen.add(identity); - if (tx.type === 'native-transfer' || tx.type === 'native-trade') { - applyNative(state, tx, context); continue; - } - if (tx.type === 'sale-lock') { - const t = tx.terms, id = saleId(t), nft = state.nfts[t.cardHash]; - check(t.chainId === context.chainId && t.policyDigest === context.policyDigest, 'Sale context mismatch'); - check(t.seller !== t.buyer && t.host !== t.seller && t.host !== t.buyer, 'Host and trading identities must be separate'); - check(nft && nft.owner === t.seller && nft.sequence === t.sequence && !state.locks[t.cardHash], 'Card missing, locked, or stale'); - check(nft.sequence <= Number.MAX_SAFE_INTEGER - 2, 'NFT sequence exhausted for lock and terminal outcome'); - check(state.height - nft.mintedHeight >= context.policy.coinbaseMaturity, 'Immature sale card'); - check(state.height <= t.lockUntilHeight && t.lockUntilHeight + context.policy.lockDepth < t.expiresHeight && - Number.isSafeInteger(t.lockUntilHeight + context.policy.lockDepth) && - t.expiresHeight - state.height <= context.policy.maxLockBlocks, 'Invalid lock height window'); - check(!state.sales[id] && !state.orders[orderKey(t)], 'Sale/order authorization already used'); - const message = saleTermsMessage(t); - check(verifySignature(tx.sellerAuthorization.publicKey, t.seller, message, tx.sellerAuthorization.signature) && - verifySignature(tx.buyerAuthorization.publicKey, t.buyer, message, tx.buyerAuthorization.signature), 'Sale lock requires both trader signatures'); - // Sequence changes at lock AND terminal outcome invalidate previously signed ordinary transfers. - state.nfts[t.cardHash] = { ...nft, sequence: advance(nft.sequence) }; - state.sales[id] = { terms: t, lockHeight: state.height, status: 'locked' }; - state.locks[t.cardHash] = id; state.orders[orderKey(t)] = id; - continue; - } - check(tx.chainId === context.chainId, 'Sale context mismatch'); - const sale = state.sales[tx.saleId]; - check(sale?.status === 'locked' && state.locks[sale.terms.cardHash] === tx.saleId, 'No active matching sale lock'); - const t = sale.terms, nft = state.nfts[t.cardHash]; - check(nft.owner === t.seller && nft.sequence === t.sequence + 1, 'Sale ownership/sequence mismatch'); - if (tx.type === 'sale-complete') { - check(state.height < t.expiresHeight && state.height - sale.lockHeight >= context.policy.lockDepth, - 'Sale completion outside mature lock window'); - check(verifySignature(tx.hostAuthorization.publicKey, t.host, saleCompletionMessage(tx), tx.hostAuthorization.signature), - 'Invalid funding host authorization'); - sale.status = 'completed'; sale.reservationDigest = tx.reservationDigest; - state.nfts[t.cardHash] = { ...nft, owner: t.buyer, sequence: advance(nft.sequence) }; - } else if (tx.type === 'sale-cancel') { - check(state.height < t.expiresHeight, 'Use permissionless expiry at or after expiry height'); - const message = saleCancellationMessage(tx); - check(verifySignature(tx.hostAuthorization.publicKey, t.host, message, tx.hostAuthorization.signature) && - verifySignature(tx.buyerAuthorization.publicKey, t.buyer, message, tx.buyerAuthorization.signature), 'Cancellation requires host and buyer authorization'); - sale.status = 'cancelled'; state.nfts[t.cardHash] = { ...nft, sequence: advance(nft.sequence) }; - } else { - check(state.height >= t.expiresHeight, 'Sale has not expired'); - sale.status = 'expired'; state.nfts[t.cardHash] = { ...nft, sequence: advance(nft.sequence) }; - } - sale.outcomeHeight = state.height; delete state.locks[t.cardHash]; - } - return state; -} -/** Candidate transaction IDs exclude malleable signature bytes, matching the active chain convention. - * These IDs are an unactivated candidate format and are NOT accepted by the existing Ledger schema. - */ -export function saleCandidateTransactionId(tx: SaleTransaction): string { - if (tx.type === 'sale-lock') return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'lock', saleTermsMessage(tx.terms)])); - if (tx.type === 'sale-complete') return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'complete', saleCompletionMessage(tx)])); - if (tx.type === 'sale-cancel') return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'cancel', saleCancellationMessage(tx)])); - return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'expire', tx.chainId, tx.saleId])); -} -function candidateIdentity(tx: CandidateTransaction): string { - if (tx.type === 'sale-lock' || tx.type === 'sale-complete' || tx.type === 'sale-cancel' || tx.type === 'sale-expire') { - return saleCandidateTransactionId(tx); - } - return sha256(tx.type === 'native-transfer' ? nftTransferMessage(tx.transfer) : nftTradeMessage(tx.trade)); -} -function applyNative(state: SaleState, tx: Extract, context: SaleContext) { - const native = tx.type === 'native-transfer' ? tx.transfer : tx.trade; - check(native.chainId === context.chainId, 'Native candidate context mismatch'); - const legs = tx.type === 'native-transfer' ? [{ cardHash: tx.transfer.cardHash, sequence: tx.transfer.sequence, - from: tx.transfer.from, to: tx.transfer.to }] : [ - ...tx.trade.offered.map(l => ({ ...l, from: tx.trade.proposer, to: tx.trade.counterparty })), - ...tx.trade.requested.map(l => ({ ...l, from: tx.trade.counterparty, to: tx.trade.proposer }))]; - for (const l of legs) { - const nft = state.nfts[l.cardHash]; - check(nft && !state.locks[l.cardHash] && nft.owner === l.from && nft.sequence === l.sequence && l.from !== l.to, - 'Native candidate card locked, missing or stale'); - check(state.height - nft.mintedHeight >= context.policy.coinbaseMaturity, 'Immature native card'); - } - if (tx.type === 'native-transfer') check(verifySignature(tx.transfer.publicKey, tx.transfer.from, - nftTransferMessage(tx.transfer), tx.transfer.signature), 'Invalid native transfer signature'); - // Trade signatures/unique cards have already been checked by candidateNativeTrade. - for (const l of legs) state.nfts[l.cardHash] = { ...state.nfts[l.cardHash], owner: l.to, sequence: advance(l.sequence) }; -} - -export type SaleDecision = { action: 'hold' | 'settle' | 'refund' | 'review' | 'none'; reason: string }; -/** Pure financial observation policy: recommendation ONLY, not authority to post money or a canonical-chain proof. */ -export function saleDecision(state: SaleState, terms: SaleTerms, context: SaleContext, - previous: 'reserved' | 'settled' | 'refunded' | 'review' = 'reserved'): SaleDecision { - check(terms.chainId === context.chainId && terms.policyDigest === context.policyDigest, 'Observation context mismatch'); - const sale = state.sales[saleId(terms)]; - const mature = sale?.outcomeHeight !== undefined && state.height - sale.outcomeHeight + 1 >= context.policy.outcomeDepth; - if (previous === 'review') return { action: 'review', reason: 'Existing incident requires evidence-backed resolution' }; - if (previous === 'settled' || previous === 'refunded') { - const expected = previous === 'settled' ? sale?.status === 'completed' : sale?.status === 'cancelled' || sale?.status === 'expired' || - (!sale && state.height - terms.lockUntilHeight >= context.policy.outcomeDepth); - return expected && (sale ? mature : true) ? { action: 'none', reason: 'Already posted; never duplicate' } - : { action: 'review', reason: 'Previously posted outcome lost canonical maturity' }; - } - if (!sale) return state.height - terms.lockUntilHeight >= context.policy.outcomeDepth - ? { action: 'refund', reason: 'Lock admission deadline is mature without canonical lock' } - : { action: 'hold', reason: 'A withheld lock authorization can still be included' }; - if (!mature) return { action: 'hold', reason: 'Lock or terminal outcome is not mature' }; - return sale.status === 'completed' ? { action: 'settle', reason: 'Canonical completion reached configured depth' } - : { action: 'refund', reason: 'Canonical cancellation/expiry reached configured depth' }; -} -/** Candidate commitment includes locks AND permanent order-use tombstones, not only current NFT ownership. */ -export function saleStateRoot(state: SaleState, context: SaleContext): string { - return sha256(JSON.stringify([SALE_CANDIDATE, 'state', context.chainId, state.height, - Object.keys(state.nfts).sort().map(id => { const n = state.nfts[id]; return [id, n.owner, n.sequence, n.mintedHeight, n.pageId]; }), - Object.keys(state.sales).sort().map(id => { const s = state.sales[id]; return [id, saleTermsMessage(s.terms), s.lockHeight, - s.status, s.outcomeHeight ?? null, s.reservationDigest ?? null]; }), - Object.keys(state.locks).sort().map(id => [id, state.locks[id]]), - Object.keys(state.orders).sort().map(id => [id, state.orders[id]])])); -} diff --git a/shared/custody.ts b/shared/custody.ts deleted file mode 100755 index e45c8b3..0000000 --- a/shared/custody.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { z } from 'zod'; -import { NETWORK } from './testnet.ts'; -import { ADDITIONAL_MARKET_FEE } from './market.ts'; -import { parseAtomicXmr } from './xmr-amount.ts'; - -const hex = z.string().regex(/^[a-f0-9]{64}$/); -const id = z.string().uuid(); -const amount = z.string().max(22).refine(value => { - try { return parseAtomicXmr(value) > 0n; } catch { return false; } -}, 'Expected a positive canonical atomic XMR amount'); -const nonnegativeAmount = z.string().max(22).refine(value => { - try { parseAtomicXmr(value); return true; } catch { return false; } -}, 'Expected canonical atomic XMR amount'); -export const CUSTODY_POLICY = 'custody-sandbox-v1'; -export const MOCK_CREDIT_ATOMIC = '1000000000000'; - -/** Separate consent domain: existing v2/v3 mock signatures never authorize custodial debits. */ -export const custodyActionSchema = z.discriminatedUnion('type', [ - z.object({ type: z.literal('activity'), offset: z.number().int().min(0).max(10000).default(0) }).strict(), - z.object({ type: z.literal('mock-credit') }).strict(), - z.object({ type: z.literal('list'), cardHash: hex, sequence: z.number().int().nonnegative().safe(), - priceAtomicXmr: amount, feeBps: z.number().int().min(0).max(5000), - hostFeeAddress: z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{95}$/).optional() }).strict(), - z.object({ type: z.literal('cancel-listing'), listingId: id }).strict(), - z.object({ type: z.literal('reserve'), listingId: id, priceAtomicXmr: amount, - feeBps: z.number().int().min(0).max(5000), hostFeeAddress: z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{95}$/).optional() }).strict(), - z.object({ type: z.literal('cancel-reservation'), orderId: id }).strict(), - // No real Monero address accepted or generated in this phase. - z.object({ type: z.literal('withdraw'), destination: z.literal('mock-external'), amountAtomicXmr: amount, - maxFeeAtomicXmr: nonnegativeAmount }).strict(), - z.object({ type: z.literal('cancel-withdrawal'), withdrawalId: id }).strict(), -]); -export type CustodyAction = z.infer; -export const custodyCommandSchema = z.object({ version: z.literal(1), mode: z.literal('sandbox'), - policy: z.literal(CUSTODY_POLICY), network: z.literal(NETWORK), chainId: hex, custodianId: id, - actor: hex, publicKey: z.string().max(256), nonce: id, expiresAt: z.number().int().nonnegative().safe(), - action: custodyActionSchema, signature: z.string().max(128) }).strict(); -export type CustodyCommand = z.infer; -export function custodyMessage(c: Omit): string { - const a = c.action; - const terms = a.type === 'list' ? [a.cardHash, a.sequence, 'XMR', a.priceAtomicXmr, a.feeBps] - : a.type === 'reserve' ? [a.listingId, 'XMR', a.priceAtomicXmr, a.feeBps] - : a.type === 'cancel-listing' ? [a.listingId] - : a.type === 'cancel-reservation' ? [a.orderId] - : a.type === 'withdraw' ? [a.destination, 'XMR', a.amountAtomicXmr, a.maxFeeAtomicXmr] - : a.type === 'cancel-withdrawal' ? [a.withdrawalId] - : a.type === 'activity' ? [a.offset] : [MOCK_CREDIT_ATOMIC]; - const fees: (string | number)[] = [ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address]; - if ((a.type === 'list' || a.type === 'reserve') && a.hostFeeAddress !== undefined) fees.push('host-fee-recipient-v1', a.hostFeeAddress); - return JSON.stringify(['wikideck-custody-command-v1', c.version, c.mode, c.policy, c.network, - c.chainId, c.custodianId, c.actor, c.publicKey, c.nonce, c.expiresAt, a.type, terms, - fees]); -} -export type CustodyInfo = { enabled: true; mode: 'sandbox'; policy: typeof CUSTODY_POLICY; - network: typeof NETWORK; chainId: string; custodianId: string; feeBps: number; - liveDeposits: false; liveWithdrawals: false; nftSettlement: false; - marketEnabled?: boolean; tradingEnabled?: boolean; hostFeeAddress?: string }; -export type CustodyListing = { id: string; seller: string; cardHash: string; sequence: number; - title: string; priceAtomicXmr: string; feeBps: number; status: 'open' | 'reserved' | 'cancelled' | 'sold'; createdAt: number; hostFeeAddress?: string }; -export type CustodyOrder = { id: string; listingId: string; seller: string; buyer: string; cardHash: string; - sequence: number; grossAtomicXmr: string; sellerAtomicXmr: string; authorAtomicXmr: string; - hostAtomicXmr: string; authorAddress: string; status: 'awaiting-lock' | 'cancelled' | 'settled'; createdAt: number; hostFeeAddress?: string }; -export type CustodyWithdrawal = { id: string; actor: string; destination: 'mock-external'; - amountAtomicXmr: string; maxFeeAtomicXmr: string; status: 'reserved' | 'prepared' | 'complete' | 'cancelled'; - attemptId?: string; transactionId?: string; feeAtomicXmr?: string; createdAt: number }; -export type CustodyActivity = { availableAtomicXmr: string; purchaseReservedAtomicXmr: string; - withdrawalReservedAtomicXmr: string; mockCreditClaimed: boolean; orders: CustodyOrder[]; - withdrawals: CustodyWithdrawal[]; next: number | null }; -export type CustodyPage = { info: CustodyInfo; listings: CustodyListing[]; next: number | null }; diff --git a/shared/market-v3.ts b/shared/market-v3.ts deleted file mode 100755 index 988873e..0000000 --- a/shared/market-v3.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { z } from 'zod'; -import { NETWORK, type NftTransfer } from './testnet.ts'; -import { ADDITIONAL_MARKET_FEE, type MarketInfo, type MarketListing, type MarketOrder } from './market.ts'; -import { MAX_XMR_AMOUNT } from './xmr-amount.ts'; - -const id = z.string().uuid(); -const hex = z.string().regex(/^[a-f0-9]{64}$/); -const atomicPrice = z.string().regex(/^(0|[1-9]\d{0,21})$/).refine(value => { - try { - const amount = BigInt(value); - return amount > 0n && amount <= MAX_XMR_AMOUNT; - } catch { return false; } -}, 'XMR price must be a positive bounded atomic amount'); -const feeBps = z.number().int().min(0).max(5000); - -export const marketActionV3Schema = z.discriminatedUnion('type', [ - z.object({ type: z.literal('list'), cardHash: hex, sequence: z.number().int().nonnegative().safe(), - kind: z.enum(['fixed', 'auction']), priceAtomicXmr: atomicPrice, endsAt: z.number().int().nonnegative().nullable(), feeBps, - hostFeeAddress: z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{95}$/).optional() }).strict(), - z.object({ type: z.literal('bid'), listingId: id, priceAtomicXmr: atomicPrice }).strict(), - z.object({ type: z.literal('buy'), listingId: id, priceAtomicXmr: atomicPrice, feeBps, - hostFeeAddress: z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{95}$/).optional() }).strict(), - z.object({ type: z.literal('cancel'), listingId: id }).strict(), - z.object({ type: z.literal('retire-legacy-listing'), listingId: id }).strict(), - z.object({ type: z.literal('mock-pay'), orderId: id }).strict(), - z.object({ type: z.literal('refund'), orderId: id }).strict(), - z.object({ type: z.literal('deliver'), orderId: id, transfer: z.object({ network: z.literal(NETWORK), - chainId: hex, cardHash: hex, from: hex, to: hex, sequence: z.number().int().nonnegative().safe(), - publicKey: z.string().max(256), signature: z.string().max(128) }).strict() }).strict(), - z.object({ type: z.literal('activity') }).strict(), -]); -export type MarketActionV3 = z.infer; -export const marketCommandV3Schema = z.object({ version: z.literal(3), network: z.literal(NETWORK), chainId: hex, - marketId: id, actor: hex, publicKey: z.string().max(256), nonce: id, expiresAt: z.number().int().nonnegative().safe(), - action: marketActionV3Schema, signature: z.string().max(128) }).strict(); -export type MarketCommandV3 = z.infer; - -/** New domain: price and currency are exact XMR atomic units, never fiat-converted. */ -export function marketMessageV3(command: Omit): string { - const a = command.action; - const terms = a.type === 'list' ? [a.cardHash, a.sequence, a.kind, 'XMR', a.priceAtomicXmr, a.endsAt, a.feeBps] - : a.type === 'bid' ? [a.listingId, 'XMR', a.priceAtomicXmr] - : a.type === 'buy' ? [a.listingId, 'XMR', a.priceAtomicXmr, a.feeBps] - : a.type === 'cancel' || a.type === 'retire-legacy-listing' ? [a.listingId] - : a.type === 'activity' ? [] - : a.type === 'deliver' ? [a.orderId, a.transfer.network, a.transfer.chainId, a.transfer.cardHash, - a.transfer.from, a.transfer.to, a.transfer.sequence, a.transfer.publicKey, a.transfer.signature] - : [a.orderId]; - const feeTerms: (string | number)[] = a.type === 'list' || a.type === 'buy' ? [ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address] : []; - // Preserve original v3 message bytes if omitted; omission authorizes ONLY the default author recipient. - if ((a.type === 'list' || a.type === 'buy') && a.hostFeeAddress !== undefined) feeTerms.push('host-fee-recipient-v1', a.hostFeeAddress); - return JSON.stringify([NETWORK, 'market-command-v3', command.version, command.chainId, command.marketId, - command.actor, command.publicKey, command.nonce, command.expiresAt, a.type, terms, feeTerms]); -} - -export type MarketListingV3 = Omit & { - termsVersion: 3; priceAtomicXmr: string; - highestBid: { bidder: string; priceAtomicXmr: string } | null; -}; -export type MarketOrderV3 = Omit & { termsVersion: 3; priceAtomicXmr: string }; -export type MarketListingRecord = MarketListing | MarketListingV3; -export type MarketOrderRecord = MarketOrder | MarketOrderV3; -export type MarketInfoV3 = MarketInfo & { marketVersion: 3 }; -export type MarketActivityV3 = { listings: MarketListingRecord[]; orders: MarketOrderRecord[] }; - -export function isXmrListing(listing: MarketListingRecord): listing is MarketListingV3 { - return 'priceAtomicXmr' in listing; -} -export function isXmrOrder(order: MarketOrderRecord): order is MarketOrderV3 { - return 'priceAtomicXmr' in order; -} diff --git a/shared/market.ts b/shared/market.ts deleted file mode 100755 index 3070e3e..0000000 --- a/shared/market.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { z } from 'zod'; -import { NETWORK, type NftTransfer } from './testnet.ts'; - -export const usdCents = z.number().int().min(1).max(100_000_000); -/** Public payout destination, not a secret or a buyer invoice address. Not environment-configurable. */ -export const ADDITIONAL_MARKET_FEE = Object.freeze({ bps: 100, - address: '44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv' }); -export type AdditionalFeePolicy = { bps: number; address: string }; -const id = z.string().uuid(); -const hex = z.string().regex(/^[a-f0-9]{64}$/); -export const marketActionSchema = z.discriminatedUnion('type', [ - z.object({ type: z.literal('list'), cardHash: hex, sequence: z.number().int().nonnegative(), - kind: z.enum(['fixed', 'auction']), priceCents: usdCents, endsAt: z.number().int().nonnegative().nullable(), - feeBps: z.number().int().min(0).max(5000) }).strict(), - z.object({ type: z.literal('bid'), listingId: id, priceCents: usdCents }).strict(), - z.object({ type: z.literal('buy'), listingId: id, priceCents: usdCents, feeBps: z.number().int().min(0).max(5000) }).strict(), - z.object({ type: z.literal('cancel'), listingId: id }).strict(), - z.object({ type: z.literal('mock-pay'), orderId: id }).strict(), - z.object({ type: z.literal('refund'), orderId: id }).strict(), - // Delivery is an ordinary chain-validated transfer, not a market signature substitute. - z.object({ type: z.literal('deliver'), orderId: id, transfer: z.object({ network: z.literal(NETWORK), - chainId: hex, cardHash: hex, from: hex, to: hex, sequence: z.number().int().nonnegative(), - publicKey: z.string().max(256), signature: z.string().max(128) }).strict() }).strict(), - z.object({ type: z.literal('activity') }).strict(), -]); -export type MarketAction = z.infer; -export const marketCommandSchema = z.object({ network: z.literal(NETWORK), chainId: hex, marketId: id, - actor: hex, publicKey: z.string().max(256), nonce: id, expiresAt: z.number().int().nonnegative(), - action: marketActionSchema, signature: z.string().max(128) }).strict(); -export type MarketCommand = z.infer; -export function marketMessage(command: Omit): string { - const a = command.action; - const terms = a.type === 'list' ? [a.cardHash, a.sequence, a.kind, a.priceCents, a.endsAt, a.feeBps] - : a.type === 'bid' ? [a.listingId, a.priceCents] - : a.type === 'buy' ? [a.listingId, a.priceCents, a.feeBps] - : a.type === 'cancel' ? [a.listingId] - : a.type === 'activity' ? [] - : a.type === 'deliver' ? [a.orderId, a.transfer.network, a.transfer.chainId, a.transfer.cardHash, - a.transfer.from, a.transfer.to, a.transfer.sequence, a.transfer.publicKey, a.transfer.signature] - : [a.orderId]; - // Bind the non-configurable fee and recipient to sale authorization, not just UI disclosure. - const feeTerms = a.type === 'list' || a.type === 'buy' ? [ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address] : []; - return JSON.stringify([NETWORK, 'market-command-v2', command.chainId, command.marketId, command.actor, - command.publicKey, command.nonce, command.expiresAt, a.type, terms, feeTerms]); -} -export type MarketListing = { - id: string; cardHash: string; sequence: number; seller: string; title: string; image: string; - kind: 'fixed' | 'auction'; priceCents: number; feeBps: number; createdAt: number; endsAt: number | null; - status: 'open' | 'reserved' | 'sold' | 'cancelled' | 'expired' | 'invalid'; - highestBid: { bidder: string; priceCents: number } | null; - additionalFee?: AdditionalFeePolicy; // Absent on legacy listings; relisting requires fresh fee authorization. - hostFeeAddress?: string; // Snapshotted signed recipient on new records; never rewrite historical terms. -}; -export type MarketOrder = { - id: string; listingId: string; seller: string; buyer: string; cardHash: string; sequence: number; - priceCents: number; feeBps: number; createdAt: number; payBy: number; deliverBy: number; - status: 'awaiting-payment' | 'paid' | 'delivery-pending' | 'complete' | 'expired' | 'refund-required' | 'refunded' | 'review'; - invoice: { provider: 'mock'; amountXmr: string; address: null; label: string }; - feeXmr: string; sellerNetXmr: string; payout: 'not-started' | 'mock-paid' | 'review'; - transfer?: NftTransfer; transferId?: string; deliveryConfirmations: number; requiredConfirmations: number; - additionalFee?: AdditionalFeePolicy & { amountXmr: string; payout: 'not-started' | 'mock-paid' | 'review' }; - hostFeeAddress?: string; -}; -export type MarketInfo = { network: string; chainId: string; marketId: string; paymentMode: 'mock'; - marketVersion: 3; feeBps: number; additionalFee: AdditionalFeePolicy; deliveryConfirmations: number; - serverTime: number; marketEnabled?: boolean; tradingEnabled?: boolean; hostFeeAddress?: string }; -export type MarketActivity = { orders: MarketOrder[]; listings: MarketListing[] }; -export function formatUsd(cents: number): string { - return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(cents / 100); -} -export function parseUsd(text: string): number { - if (!/^(0|[1-9]\d{0,6})(\.\d{1,2})?$/.test(text)) throw new Error('Enter a USD amount with at most two decimal places.'); - const [whole, fraction = ''] = text.split('.'); - return usdCents.parse(Number(whole) * 100 + Number(fraction.padEnd(2, '0'))); -} diff --git a/shared/monero-deposit.ts b/shared/monero-deposit.ts deleted file mode 100755 index 82b2fbe..0000000 --- a/shared/monero-deposit.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { z } from 'zod'; - -const hex64 = z.string().regex(/^[a-f0-9]{64}$/); -const uuid = z.string().uuid(); -const network = z.enum(['wikideck-randomx-mainnet-1', 'wikideck-randomx-testnet-6']); - -/** Wallet-signed request to allocate the caller's own immutable Monero subaddress. */ -export const moneroDepositAddressRequestSchema = z.object({ - version: z.literal(1), - network, - chainId: hex64, - custodianId: uuid, - actor: hex64, - publicKey: z.string().min(1).max(256), - requestId: uuid, - walletGenerationId: uuid, - walletFingerprint: hex64, - accountIndex: z.number().int().min(0).max(0xffff_ffff).safe(), - expiresAt: z.number().int().nonnegative().safe(), - signature: z.string().min(1).max(256), -}).strict(); - -export type MoneroDepositAddressRequest = z.infer; -export type MoneroDepositAddressRequestUnsigned = Omit; - -/** Domain-separated, canonical terms for requesting one beneficiary-bound deposit address. */ -export function moneroDepositAddressMessage(request: MoneroDepositAddressRequestUnsigned): string { - return JSON.stringify([ - 'wikideck-monero-deposit-address-v1', request.version, request.network, request.chainId, - request.custodianId, request.actor, request.publicKey, request.requestId, - request.walletGenerationId, request.walletFingerprint, request.accountIndex, request.expiresAt, - ]); -} diff --git a/shared/monero-withdrawal.ts b/shared/monero-withdrawal.ts deleted file mode 100755 index e50faeb..0000000 --- a/shared/monero-withdrawal.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { z } from 'zod'; -import { parseAtomicXmr } from './xmr-amount.ts'; - -const uuid = z.string().uuid(); -const hex = z.string().regex(/^[a-f0-9]{64}$/); -const atomic = z.string().max(22).refine(value => { try { parseAtomicXmr(value); return true; } catch { return false; } }); -const positiveAtomic = atomic.refine(value => BigInt(value) > 0n); - -export const mainnetWithdrawalRequestSchema = z.object({ - version: z.literal(1), - network: z.literal('wikideck-randomx-mainnet-1'), - chainId: hex, - custodianId: uuid, - beneficiary: hex, - publicKey: z.string().min(1).max(256), - requestId: uuid, - destination: z.string().length(95), - amountAtomicXmr: positiveAtomic, - maxFeeAtomicXmr: atomic, - expiresAt: z.number().int().nonnegative().safe(), - signature: z.string().min(1).max(256), -}).strict(); -export type MainnetWithdrawalRequest = z.infer; -export type MainnetWithdrawalRequestUnsigned = Omit; - -export const mainnetWithdrawalCancellationSchema = z.object({ - version: z.literal(1), - network: z.literal('wikideck-randomx-mainnet-1'), - chainId: hex, - custodianId: uuid, - beneficiary: hex, - publicKey: z.string().min(1).max(256), - cancellationId: uuid, - withdrawalRequestId: uuid, - expiresAt: z.number().int().nonnegative().safe(), - signature: z.string().min(1).max(256), -}).strict(); -export type MainnetWithdrawalCancellation = z.infer; -export type MainnetWithdrawalCancellationUnsigned = Omit; - -export function mainnetWithdrawalMessage(request: MainnetWithdrawalRequestUnsigned): string { - return JSON.stringify(['wikideck-mainnet-withdrawal-request-v1', request.version, request.network, - request.chainId, request.custodianId, request.beneficiary, request.publicKey, request.requestId, - request.destination, request.amountAtomicXmr, request.maxFeeAtomicXmr, request.expiresAt]); -} - -export function mainnetWithdrawalCancellationMessage(request: MainnetWithdrawalCancellationUnsigned): string { - return JSON.stringify(['wikideck-mainnet-withdrawal-cancel-v1', request.version, request.network, - request.chainId, request.custodianId, request.beneficiary, request.publicKey, request.cancellationId, - request.withdrawalRequestId, request.expiresAt]); -} - -export const mainnetCustodyBalanceRequestSchema = z.object({ - version: z.literal(1), - network: z.literal('wikideck-randomx-mainnet-1'), - chainId: hex, - custodianId: uuid, - beneficiary: hex, - publicKey: z.string().min(1).max(256), - nonce: uuid, - expiresAt: z.number().int().nonnegative().safe(), - signature: z.string().min(1).max(256), -}).strict(); -export type MainnetCustodyBalanceRequest = z.infer; -export type MainnetCustodyBalanceRequestUnsigned = Omit; - -export function mainnetCustodyBalanceMessage(request: MainnetCustodyBalanceRequestUnsigned): string { - return JSON.stringify(['wikideck-mainnet-custody-balance-v1', request.version, request.network, - request.chainId, request.custodianId, request.beneficiary, request.publicKey, request.nonce, request.expiresAt]); -} diff --git a/shared/network.ts b/shared/network.ts index 8cee714..9cb0925 100755 --- a/shared/network.ts +++ b/shared/network.ts @@ -1,18 +1,16 @@ /** Selection is fixed at server startup / browser build, never learned from an untrusted RPC. */ -export type NetworkProfile = 'testnet' | 'mainnet' | 'pilot'; +export type NetworkProfile = 'testnet' | 'mainnet'; export function networkProfile(value: string | undefined): NetworkProfile { const profile = value ?? 'testnet'; - if (profile !== 'testnet' && profile !== 'mainnet' && profile !== 'pilot') { - throw new Error('WIKIDECK_NETWORK must be testnet, mainnet, or pilot'); + if (profile !== 'testnet' && profile !== 'mainnet') { + throw new Error('WIKIDECK_NETWORK must be testnet or mainnet'); } return profile; } export const PROFILE = networkProfile(typeof process !== 'undefined' && process.versions?.node ? process.env.WIKIDECK_NETWORK : import.meta.env?.VITE_WIKIDECK_NETWORK); export const IS_MAINNET = PROFILE === 'mainnet'; -export const IS_PILOT = PROFILE === 'pilot'; -export const NETWORK = IS_MAINNET ? 'wikideck-randomx-mainnet-1' as const - : IS_PILOT ? 'wikideck-randomx-pilot-1' as const : 'wikideck-randomx-testnet-6' as const; +export const NETWORK = IS_MAINNET ? 'wikideck-randomx-mainnet-1' as const : 'wikideck-randomx-testnet-6' as const; // Candidate mainnet genesis: 2027-01-01 00:00 UTC. NOT a commitment to a launch date. -export const NETWORK_GENESIS_TIME = IS_MAINNET ? 1_798_761_600 : IS_PILOT ? 1_792_627_200 : 1_792_411_200; -export const NETWORK_LABEL = IS_MAINNET ? 'MAINNET CANDIDATE' : IS_PILOT ? 'PRIVATE PILOT' : 'TESTNET'; +export const NETWORK_GENESIS_TIME = IS_MAINNET ? 1_798_761_600 : 1_792_411_200; +export const NETWORK_LABEL = IS_MAINNET ? 'MAINNET CANDIDATE' : 'TESTNET'; diff --git a/shared/pilot-monero-deposit.ts b/shared/pilot-monero-deposit.ts deleted file mode 100755 index bc660df..0000000 --- a/shared/pilot-monero-deposit.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { z } from 'zod'; - -const hex64 = z.string().regex(/^[a-f0-9]{64}$/); -const uuid = z.string().uuid(); - -/** Pilot-specific wallet authorization. Market and Monero network identities are both explicit and signed. */ -export const pilotMoneroDepositRequestSchema = z.object({ - version: z.literal(1), - marketNetwork: z.literal('wikideck-randomx-pilot-1'), - marketChainId: hex64, - moneroNetwork: z.literal('mainnet'), - custodianId: uuid, - beneficiary: hex64, - publicKey: z.string().min(1).max(256), - requestId: uuid, - walletGenerationId: uuid, - walletFingerprint: hex64, - accountIndex: z.number().int().min(0).max(0xffff_ffff).safe(), - expiresAt: z.number().int().nonnegative().safe(), - signature: z.string().min(1).max(256), -}).strict(); - -export type PilotMoneroDepositRequest = z.infer; -export type PilotMoneroDepositRequestUnsigned = Omit; - -/** Canonical, domain-separated wallet authorization; both asset networks are signature-bound. */ -export function pilotMoneroDepositRequestMessage(request: PilotMoneroDepositRequestUnsigned): string { - return JSON.stringify([ - 'wikideck-pilot-monero-deposit-request-v1', request.version, - request.marketNetwork, request.marketChainId, request.moneroNetwork, request.custodianId, - request.beneficiary, request.publicKey, request.requestId, request.walletGenerationId, - request.walletFingerprint, request.accountIndex, request.expiresAt, - ]); -} diff --git a/shared/sale-candidate.ts b/shared/sale-candidate.ts deleted file mode 100755 index b2117c1..0000000 --- a/shared/sale-candidate.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { z } from 'zod'; -import { NETWORK } from './network.ts'; -import { ADDITIONAL_MARKET_FEE } from './market.ts'; -import { parseAtomicXmr } from './xmr-amount.ts'; - -/** REVIEW CANDIDATE ONLY. Never accepted by the active transactionSchema or custody HTTP API. */ -export const SALE_CANDIDATE = 'custodial-sale-lock-candidate-v1'; -const hex = z.string().regex(/^[a-f0-9]{64}$/); -const height = z.number().int().nonnegative().safe(); -const amount = z.string().max(22).refine(s => { try { return parseAtomicXmr(s) > 0n; } catch { return false; } }); -export const saleTermsSchema = z.object({ protocol: z.literal(SALE_CANDIDATE), network: z.literal(NETWORK), - chainId: hex, policyDigest: hex, custodianId: z.string().uuid(), orderId: z.string().uuid(), - cardHash: hex, sequence: height, seller: hex, buyer: hex, host: hex, - priceAtomicXmr: amount, feeBps: z.number().int().min(0).max(5000), - hostFeeAddress: z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{95}$/).optional(), - lockUntilHeight: height, expiresHeight: height }).strict(); -export type SaleTerms = z.infer; -export const saleSignatureSchema = z.object({ publicKey: z.string().min(1).max(256), - signature: z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).length(88) }).strict(); -const reference = { protocol: z.literal(SALE_CANDIDATE), chainId: hex, saleId: hex }; -export const saleTransactionSchema = z.discriminatedUnion('type', [ - z.object({ type: z.literal('sale-lock'), terms: saleTermsSchema, - sellerAuthorization: saleSignatureSchema, buyerAuthorization: saleSignatureSchema }).strict(), - z.object({ type: z.literal('sale-complete'), ...reference, reservationDigest: hex, - hostAuthorization: saleSignatureSchema }).strict(), - z.object({ type: z.literal('sale-cancel'), ...reference, - hostAuthorization: saleSignatureSchema, buyerAuthorization: saleSignatureSchema }).strict(), - z.object({ type: z.literal('sale-expire'), ...reference }).strict(), -]); -export type SaleTransaction = z.infer; -export function saleTermsMessage(t: SaleTerms): string { - const tuple: unknown[] = [SALE_CANDIDATE, 'terms', t.network, t.chainId, t.policyDigest, - t.custodianId, t.orderId, t.cardHash, t.sequence, t.seller, t.buyer, t.host, - 'XMR', t.priceAtomicXmr, t.feeBps, ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address, - t.lockUntilHeight, t.expiresHeight]; - if (t.hostFeeAddress !== undefined) tuple.push(['host-fee-recipient-v1', t.hostFeeAddress]); - return JSON.stringify(tuple); -} -export function saleCompletionMessage(t: Pick, 'chainId' | 'saleId' | 'reservationDigest'>): string { - return JSON.stringify([SALE_CANDIDATE, 'complete-funded-reservation', t.chainId, t.saleId, t.reservationDigest]); -} -export function saleCancellationMessage(t: { chainId: string; saleId: string }): string { - return JSON.stringify([SALE_CANDIDATE, 'cancel', t.chainId, t.saleId]); -} diff --git a/shared/xmr-amount.ts b/shared/xmr-amount.ts deleted file mode 100755 index 6e293e6..0000000 --- a/shared/xmr-amount.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** Exact, browser-safe amounts. No wallet RPC, exchange rate, custody or signing policy. */ -export const XMR_ATOMIC_UNITS = 1_000_000_000_000n; -// Preserve the existing processor decimal parser's ten-whole-digit encoding bound. -// This is NOT a supply limit, an exposure approval, or a wallet-RPC uint64 guarantee. -export const MAX_XMR_AMOUNT = 10_000_000_000n * XMR_ATOMIC_UNITS - 1n; -export const XMR_RPC_UINT64_MAX = (1n << 64n) - 1n; - -export function assertXmrAmount(amount: bigint): void { - if (typeof amount !== 'bigint' || amount < 0n || amount > MAX_XMR_AMOUNT) { - throw new Error('Invalid bounded atomic XMR amount'); - } -} - -/** Decimal user/processor input; never coerce, trim, round, or accept floating point. */ -export function parseXmr(value: string): bigint { - if (typeof value !== 'string' || value.length > 23 || !/^(0|[1-9]\d{0,9})(\.\d{1,12})?(?![\s\S])/.test(value)) { - throw new Error('Invalid bounded XMR decimal'); - } - const [whole, fraction = ''] = value.split('.'); - return BigInt(whole) * XMR_ATOMIC_UNITS + BigInt(fraction.padEnd(12, '0')); -} - -/** Canonical display/processor decimal: always twelve fractional places. */ -export function formatXmr(amount: bigint): string { - assertXmrAmount(amount); - return `${amount / XMR_ATOMIC_UNITS}.${(amount % XMR_ATOMIC_UNITS).toString().padStart(12, '0')}`; -} - -/** JSON/signature/storage representation: canonical base-ten integer string, not a JSON number. */ -export function parseAtomicXmr(value: string): bigint { - if (typeof value !== 'string' || value.length > 22 || !/^(0|[1-9]\d{0,21})(?![\s\S])/.test(value)) { - throw new Error('Invalid canonical atomic XMR string'); - } - const amount = BigInt(value); - assertXmrAmount(amount); - return amount; -} - -export function atomicXmrString(amount: bigint): string { - assertXmrAmount(amount); - return amount.toString(); -} - -/** RPC amounts need lossless JSON encoding too; Number(amount) is not safe. */ -export function assertXmrRpcAmount(amount: bigint): void { - assertXmrAmount(amount); - if (amount > XMR_RPC_UINT64_MAX) throw new Error('XMR amount exceeds wallet-RPC uint64'); -} - -/** Independently round commissions down; all sub-atomic remainders stay with the seller. */ -export function xmrCommission(gross: bigint, bps: number): bigint { - assertXmrAmount(gross); - if (!Number.isSafeInteger(bps) || bps < 0 || bps > 10_000) throw new Error('Invalid commission basis points'); - return gross * BigInt(bps) / 10_000n; -} diff --git a/shared/xmr-pricing.ts b/shared/xmr-pricing.ts deleted file mode 100755 index e7f5ce2..0000000 --- a/shared/xmr-pricing.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ADDITIONAL_MARKET_FEE } from './market.ts'; -import { assertXmrAmount, xmrCommission } from './xmr-amount.ts'; - -/** Allocation only, not proof of payment or an instruction to send/retain funds. */ -export function allocateXmrSale(gross: bigint, hostingBps: number) { - assertXmrAmount(gross); - if (gross === 0n) throw new Error('Sale price must be positive; use a free transfer for gifts'); - if (!Number.isSafeInteger(hostingBps) || hostingBps < 0 || hostingBps > 5000) { - throw new Error('Hosting commission must be between 0 and 5000 basis points'); - } - const hosting = xmrCommission(gross, hostingBps); - const author = xmrCommission(gross, ADDITIONAL_MARKET_FEE.bps); - return { gross, hosting, author, sellerBeforeNetworkCosts: gross - hosting - author }; -} - -/** - * Evaluate an explicitly supplied total seller-funded network-cost budget, including - * every applicable leg. Does not choose who funds an author leg or approve a quote. - * The budget is a maximum, not revenue; actual costs/unused budget need accounting. - */ -export function assessXmrSaleBudget(gross: bigint, hostingBps: number, sellerNetworkBudget: bigint) { - const allocation = allocateXmrSale(gross, hostingBps); - assertXmrAmount(sellerNetworkBudget); - const available = allocation.sellerBeforeNetworkCosts; - const viable = sellerNetworkBudget < available; - return { ...allocation, sellerNetworkBudget, viable, - sellerMinimumNet: viable ? available - sellerNetworkBudget : 0n, - // Additional gross-independent funding required to retain at least one atomic unit. - fundingShortfall: viable ? 0n : sellerNetworkBudget - available + 1n }; -} diff --git a/src/App.tsx b/src/App.tsx index 2f5566a..b26a9d1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,7 @@ import { WalletPanel } from './WalletPanel.tsx'; import { useModalFocus } from './useModalFocus.ts'; import { ThemePicker } from './ThemePicker.tsx'; import { TradeDesk } from './TradeDesk.tsx'; -import { IS_MAINNET, IS_PILOT, NETWORK_LABEL } from '../shared/network.ts'; +import { IS_MAINNET, NETWORK_LABEL } from '../shared/network.ts'; import { StateClient } from '../shared/rpc-client.ts'; import './App.css'; import './testnet.css'; @@ -172,7 +172,7 @@ export default function App() {
Explore {view}
RandomX {NETWORK_LABEL}{peerLabel}
-
RPC {rpcAvailable ? 'available' : 'unavailable'}{peerLabel}{IS_MAINNET?'Gated mainnet candidate · unaudited · mock payments only':IS_PILOT?'Private pilot · mock custody only · no real XMR':'Unbootstrapped testnet · no public mainnet'}{snapshot && !rpcAvailable && Showing last known state}
+
RPC {rpcAvailable ? 'available' : 'unavailable'}{peerLabel}{IS_MAINNET?'Gated mainnet candidate · unaudited':'Unbootstrapped testnet · no public mainnet'}{snapshot && !rpcAvailable && Showing last known state}
{error &&
{error}
} {view === 'Discover' && <>
@@ -220,7 +220,7 @@ export default function App() {
01

Mine with a CPU. Discover a card.

RandomX—the algorithm used by Wikideck and Monero—is designed to make specialized ASICs less advantageous and general-purpose CPUs more competitive. Mine a block to earn a one-of-a-kind card featuring a random illustrated Wikipedia article.

Mining, rewards & network facts

ASIC resistance is a design goal, not a guarantee of equal performance. Results depend on hardware and network difficulty; there is no promised reward rate or profit. Wikideck is an independent network and does not share Monero’s hashpower or security. Mining uses CPU, memory, electricity and time.

02

Keep your collection in your wallet.

Your encrypted local wallet controls your cards. Restore a backup to use the same identity in another browser profile. Confirmed ownership follows the canonical chain.

Wallet and ownership basics

Transfers remain pending until confirmed, and a chain reorganization can change confirmed ownership. Looking up a public wallet address reveals its confirmed holdings. Keep your encrypted backup and password separately.

-
03

Trade cards directly with another collector.

Both players sign the same card list; the complete swap is applied together when mined or rejected. Offers are shared between players and are not marketplace listings or sales.

What happens when I accept?

The app rechecks ownership, maturity, sequences, and pending activity before submitting. A trade stays pending until mined; re-check the recipient and cards before signing.

+
03

Trade cards directly with another collector.

Both players sign the same card list; the complete swap is applied together when mined or rejected. Offers are shared between players, not sold or submitted as listings.

What happens when I accept?

The app rechecks ownership, maturity, sequences, and pending activity before submitting. A trade stays pending until mined; re-check the recipient and cards before signing.

Collectible snapshots—not ownership of Wikipedia.

Cards do not grant rights to Wikipedia text or images. Check each card’s attribution and licenses. This is an experimental testnet; cards have no promised monetary value.

diff --git a/src/CustodyPanel.tsx b/src/CustodyPanel.tsx deleted file mode 100755 index 46d2464..0000000 --- a/src/CustodyPanel.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { NETWORK, CONSENSUS, testnetOwnerOf, transactionCardIds, type TestnetSnapshot } from '../shared/testnet.ts'; -import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts'; -import { CUSTODY_POLICY, custodyMessage, type CustodyAction, type CustodyActivity, - type CustodyCommand, type CustodyPage } from '../shared/custody.ts'; -import { formatXmr, parseXmr } from '../shared/xmr-amount.ts'; -import { sign, type Wallet } from './wallet.ts'; -import './custody.css'; - -type Props = { wallet?: Wallet; snapshot?: TestnetSnapshot; rpcAvailable: boolean }; -const short = (s: string) => `${s.slice(0, 8)}…${s.slice(-5)}`; -// Aggregate balances have a wider bound than individual sale amounts; format without Number coercion. -const display = (value: string) => { const n = BigInt(value); return `${n / 1_000_000_000_000n}.${(n % 1_000_000_000_000n).toString().padStart(12, '0')}`; }; -async function request(command?: CustodyCommand, offset = 0): Promise { - const response = await fetch(command ? '/api/custody/command' : `/api/custody?offset=${offset}`, { - cache: 'no-store', signal: AbortSignal.timeout(15000), ...(command ? { - method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(command), - } : {}), - }); - if (!response.headers.get('content-type')?.includes('application/json')) throw new Error('Custody API unavailable.'); - const data = await response.json(); - if (!response.ok) throw new Error(typeof data.error === 'string' ? data.error : 'Custody request failed.'); - return data; -} - -export function CustodyPanel({ wallet, snapshot, rpcAvailable }: Props) { - const [page, setPage] = useState(); - const [activity, setActivity] = useState(); - const [enabled, setEnabled] = useState(); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(''); - const [notice, setNotice] = useState(''); - const [review, setReview] = useState<{ action: CustodyAction; description: string }>(); - const [retry, setRetry] = useState(); - const [card, setCard] = useState(''); - const [price, setPrice] = useState('0.100000000000'); - const [withdraw, setWithdraw] = useState('0.100000000000'); - const [maxFee, setMaxFee] = useState('0.001000000000'); - const generation = useRef(0), pending = useRef(false); - const reviewElement = useRef(null); - const signed = useCallback(async (action: CustodyAction, current: CustodyPage): Promise => { - if (!wallet) throw new Error('Open your wallet first.'); - const c: Omit = { version: 1, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK, - chainId: current.info.chainId, custodianId: current.info.custodianId, actor: wallet.address, - publicKey: wallet.publicKey, nonce: crypto.randomUUID(), expiresAt: Date.now() + 4 * 60_000, action }; - return { ...c, signature: await sign(wallet, custodyMessage(c)) }; - }, [wallet]); - const refresh = useCallback(async (offset = 0) => { - const epoch = generation.current; - const data = await request(undefined, offset); - if (epoch !== generation.current) return; - if ('enabled' in data) { setEnabled(false); setPage(undefined); setActivity(undefined); return; } - if (data.info.mode !== 'sandbox' || data.info.policy !== CUSTODY_POLICY || data.info.network !== NETWORK || - data.info.liveDeposits !== false || data.info.liveWithdrawals !== false || data.info.nftSettlement !== false || - (snapshot && data.info.chainId !== snapshot.chainId)) throw new Error('Incompatible custody policy or network.'); - const command = wallet ? await signed({ type: 'activity', offset }, data) : undefined; - if (epoch !== generation.current) return; - const mine = command ? await request(command) : undefined; - if (epoch !== generation.current) return; - setEnabled(true); setPage(data); setActivity(mine); - }, [wallet, signed, snapshot?.chainId]); - useEffect(() => { - generation.current++; setPage(undefined); setActivity(undefined); setReview(undefined); setRetry(undefined); setError(''); setBusy(false); - if (rpcAvailable) void refresh().catch(e => setError(e instanceof Error ? e.message : 'Custody unavailable.')); - return () => { generation.current++; }; - }, [refresh, rpcAvailable]); - useEffect(() => { if (review) reviewElement.current?.focus(); }, [review]); - const prepare = (action: CustodyAction, description: string) => { - if (['list','reserve','mock-credit'].includes(action.type) && (page?.info.marketEnabled === false || page?.info.tradingEnabled === false)) { - setError('This host is not accepting new trading or funding. Existing balances and cancellation/withdrawal requests remain accessible.'); return; - } - if (action.type === 'list' || action.type === 'reserve') { - const listingId = action.type === 'reserve' ? action.listingId : undefined; - const recipient = action.type === 'reserve' - ? page?.listings.find(l => l.id === listingId)?.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address - : page?.info.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address; - action = { ...action, hostFeeAddress: recipient }; - description += ` Host fee recipient: ${recipient}. The separate fixed author fee is unchanged.`; - } - setError(''); setNotice(''); setReview({ action, description }); - }; - const submit = async (again?: CustodyCommand) => { - if (!wallet || !page || pending.current || (!review && !again)) return; - pending.current = true; setBusy(true); setError(''); - const epoch = generation.current; - let command: CustodyCommand | undefined; - try { - command = again ?? await signed(review!.action, page); - if (epoch !== generation.current) return; - setRetry(command); // Retain the EXACT envelope after ambiguous network outcomes; do not mint another debit nonce. - await request(command); - if (epoch !== generation.current) return; - setReview(undefined); setRetry(undefined); setNotice('Sandbox command recorded. No XMR or NFT was transferred.'); - await refresh(); - } catch (e) { - if (epoch === generation.current) setError(`${e instanceof Error ? e.message : 'Custody request failed.'}${command ? ' Refresh activity before making another request; retry uses the original signed command.' : ''}`); - } finally { pending.current = false; if (epoch === generation.current) setBusy(false); } - }; - const owned = snapshot?.blocks.filter(b => wallet && testnetOwnerOf(snapshot, b.tokenId) === wallet.address && - snapshot.blocks.length - b.height + 1 >= CONSENSUS.coinbaseMaturity && - !snapshot.pendingTransfers.some(t => transactionCardIds(t).includes(b.tokenId))) ?? []; - const disabled = busy || !wallet || !rpcAvailable || !page || !!retry; - return
-
Custodial ledger preview · fictitious balances only -

Test funding, fixed-price reservations and withdrawal holds. No deposit address, Monero access, NFT lock or card delivery is enabled. Existing mock checkout is separate. Funds shown here have no monetary value.

-

A balance for this market.

-

A future custodian would hold pooled XMR and track what each customer is owed. Purchases would move ledger balances, not XMR between subaddresses. You would trust the operator with your deposits.

- {enabled === false &&

This host has not enabled the custody sandbox. Real custody is unavailable.

} - {page&&(page.info.marketEnabled===false||page.info.tradingEnabled===false)&&

{page.info.marketEnabled===false?'Public market disabled on this host.':'New trading paused on this host.'} Existing balances, cancellations and withdrawal requests remain accessible.

} - {page?.info.hostFeeAddress&&

Host fee wallet (not a deposit address): {page.info.hostFeeAddress}

} - {!rpcAvailable &&

Reconnect to the node to use this preview.

} - {!wallet &&

Open your Wikideck wallet to view private balances and authorize sandbox commands.

} - {page &&

Sandbox custodian {page.info.custodianId} · {page.info.network} · host {page.info.feeBps / 100}% + author 1%. Fresh custody signatures are required; legacy mock authorizations do not debit balances.

} - {error &&

{error}

} - {notice &&

{notice}

} -
- {retry && <> - }
- {activity && <> -
{[ - ['Available', activity.availableAtomicXmr], ['Purchase reserved', activity.purchaseReservedAtomicXmr], - ['Withdrawal reserved', activity.withdrawalReservedAtomicXmr], - ].map(([label, value]) =>
{label}{display(value)} XMRSIMULATED
)}
- - } - {review &&
-

Review sandbox authorization

{review.description}

-
} - {page && <> -
-
{ e.preventDefault(); try { - if (!snapshot) throw new Error('Chain snapshot unavailable.'); - const priceAtomicXmr = parseXmr(price).toString(); - if (BigInt(priceAtomicXmr) === 0n) throw new Error('Use a positive price.'); - prepare({ type: 'list', cardHash: card, sequence: snapshot.transfers.filter(t => transactionCardIds(t).includes(card)).length, - priceAtomicXmr, feeBps: page.info.feeBps }, `List this card for ${formatXmr(BigInt(priceAtomicXmr))} fictitious XMR. Host ${page.info.feeBps / 100}% plus author 1% are deducted from gross if settlement is implemented. This preview does not lock or transfer the card.`); - } catch (e) { setError(e instanceof Error ? e.message : 'Invalid listing.'); } }}> -

Fixed-price custody listing

- setPrice(e.target.value)}/> - -
-
{ e.preventDefault(); try { - const amountAtomicXmr = parseXmr(withdraw).toString(), maxFeeAtomicXmr = parseXmr(maxFee).toString(); - if (BigInt(amountAtomicXmr) === 0n) throw new Error('Use a positive withdrawal amount.'); - prepare({ type: 'withdraw', destination: 'mock-external', amountAtomicXmr, maxFeeAtomicXmr }, `Reserve ${withdraw} fictitious XMR plus up to ${maxFee} XMR fee allowance for a MOCK destination. No address is accepted, no XMR is sent, and this hold remains cancellable until prepared by an offline test fixture.`); - } catch (e) { setError(e instanceof Error ? e.message : 'Invalid withdrawal.'); } }}> -

Withdrawal reservation preview

setWithdraw(e.target.value)}/> - setMaxFee(e.target.value)}/> - -
-
-

Custody listings · no auctions

-
{page.listings.map(l =>

{l.title}

{display(l.priceAtomicXmr)} XMR · {l.status} · seller {short(l.seller)}

- {l.status === 'open' && (l.seller === wallet?.address - ? - : )}
)}
- {page.next !== null && } - } - {activity &&

Your custody activity

- {activity.orders.map(o =>
Order {short(o.id)}

{display(o.grossAtomicXmr)} XMR · {o.status} · {o.buyer === wallet?.address ? 'Buyer' : 'Seller'}

- {o.status === 'awaiting-lock' &&

Blocked: NFT consensus lock and settlement are not implemented. No delivery signature was issued.

} - {o.buyer === wallet?.address && o.status === 'awaiting-lock' && }
)} - {activity.withdrawals.map(w =>
Mock withdrawal {short(w.id)}

{display(w.amountAtomicXmr)} XMR + max {display(w.maxFeeAtomicXmr)} fee · {w.status}

- {w.status === 'reserved' && }
)} - {activity.next !== null && } -
} -

Author policy recipient (not a deposit address): {ADDITIONAL_MARKET_FEE.address}. No real funds or commissions are collected.

-
; -} diff --git a/src/Marketplace.tsx b/src/Marketplace.tsx deleted file mode 100755 index 5c0da34..0000000 --- a/src/Marketplace.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { Check, Clock3, Gavel, LoaderCircle, RefreshCw, ShoppingBag, Tag } from 'lucide-react'; -import { NETWORK, CONSENSUS, nftTransferMessage, testnetOwnerOf, transactionCardIds, type TestnetSnapshot } from '../shared/testnet.ts'; -import { ADDITIONAL_MARKET_FEE, formatUsd, marketMessage, type MarketAction, type MarketInfo, - type MarketListing, type MarketOrder } from '../shared/market.ts'; -import { atomicXmrString, formatXmr, parseAtomicXmr, parseXmr } from '../shared/xmr-amount.ts'; -import { isXmrListing, isXmrOrder, marketMessageV3, type MarketActionV3, type MarketActivityV3, - type MarketCommandV3, type MarketListingRecord, type MarketOrderRecord } from '../shared/market-v3.ts'; -import { sign, type Wallet } from './wallet.ts'; -import { imageCredit } from './Card.tsx'; -import { CustodyPanel } from './CustodyPanel.tsx'; -import './market.css'; - -type Props = { wallet?: Wallet; snapshot?: TestnetSnapshot; rpcAvailable: boolean; onNotice(message: string): void }; -type Page = { listings: MarketListingRecord[]; next: number | null; info: MarketInfo }; -const short = (v: string) => `${v.slice(0, 8)}…${v.slice(-5)}`; -const feeDisclosure = (marketBps: number) => `Hosting peer fee: ${marketBps / 100}% plus a hardcoded 1% project author fee (${(marketBps + ADDITIONAL_MARKET_FEE.bps) / 100}% total), both deducted from seller proceeds. Author fee recipient: ${ADDITIONAL_MARKET_FEE.address}. Payouts are simulated only.`; -async function api(body?: unknown, offset = 0): Promise { - const response = await fetch(body ? '/api/market/command' : `/api/market?offset=${offset}`, { - cache: 'no-store', signal: AbortSignal.timeout(15000), - ...(body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}), - }); - if (!response.headers.get('content-type')?.includes('application/json')) throw new Error('Marketplace is unavailable on this node.'); - const data = await response.json(); - if (!response.ok) throw new Error(typeof data.error === 'string' ? data.error : 'Marketplace request failed.'); - return data; -} -async function commandV3(wallet: Wallet, info: MarketInfo, action: MarketActionV3): Promise { - const envelope = { network: NETWORK, chainId: info.chainId, marketId: info.marketId, actor: wallet.address, - version: 3 as const, publicKey: wallet.publicKey, nonce: crypto.randomUUID(), expiresAt: Date.now() + 4 * 60_000, action }; - return api({ ...envelope, signature: await sign(wallet, marketMessageV3(envelope as Omit)) }); -} -async function commandV2(wallet: Wallet, info: MarketInfo, action: MarketAction): Promise { - const envelope = { network: NETWORK, chainId: info.chainId, marketId: info.marketId, actor: wallet.address, - publicKey: wallet.publicKey, nonce: crypto.randomUUID(), expiresAt: Date.now() + 4 * 60_000, action }; - return api({ ...envelope, signature: await sign(wallet, marketMessage(envelope)) }); -} -const orderPrice = (order: MarketOrderRecord) => isXmrOrder(order) - ? `${formatXmr(parseAtomicXmr(order.priceAtomicXmr))} XMR` - : `${formatUsd(order.priceCents)} USD (legacy)`; -const listingPriceLegacy = (listing: Extract) => - `${formatUsd(listing.highestBid?.priceCents ?? listing.priceCents)} USD (legacy)`; -function deadline(time: number, now: number) { - if (time <= now) return 'Closed'; - const minutes = Math.ceil((time - now) / 60000); - return minutes >= 1440 ? `${Math.ceil(minutes / 1440)}d remaining` : minutes >= 60 ? `${Math.ceil(minutes / 60)}h remaining` : `${minutes}m remaining`; -} - -export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props) { - const [page, setPage] = useState(); - const [activity, setActivity] = useState({ listings: [], orders: [] }); - const [tab, setTab] = useState<'browse' | 'sell' | 'orders' | 'custody'>('browse'); - const [filter, setFilter] = useState<'all' | 'fixed' | 'auction'>('all'); - const [cardHash, setCardHash] = useState(''); - const [kind, setKind] = useState<'fixed' | 'auction'>('fixed'); - const [price, setPrice] = useState('0.100000000000'); - const [hours, setHours] = useState('24'); - const [bids, setBids] = useState>({}); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(''); - const [review, setReview] = useState<{ action: MarketAction | MarketActionV3; text: string; legacy?: boolean }>(); - const [now, setNow] = useState(Date.now()); - const version = useRef(0); - const inFlight = useRef(false); - const reviewPanel = useRef(null); - useEffect(() => { - if (!review) return; - reviewPanel.current?.focus({ preventScroll: true }); - reviewPanel.current?.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' }); - }, [review]); - const refresh = useCallback(async () => { - const current = version.current; - const data = await api(); - if (data.info.network !== NETWORK || data.info.marketVersion !== 3 || data.info.paymentMode !== 'mock' || - data.info.additionalFee?.bps !== ADDITIONAL_MARKET_FEE.bps || data.info.additionalFee?.address !== ADDITIONAL_MARKET_FEE.address || - (snapshot && data.info.chainId !== snapshot.chainId)) throw new Error('Incompatible marketplace network, payment mode, or fee policy.'); - const mine = wallet ? await commandV3(wallet, data.info, { type: 'activity' }) : { listings: [], orders: [] }; - if (current !== version.current) return; - setPage(data); setActivity(mine); setNow(Date.now()); - }, [wallet, snapshot?.chainId]); - useEffect(() => { - version.current++; setPage(undefined); setActivity({ listings: [], orders: [] }); setReview(undefined); - let active = true; - const load = () => { void refresh().catch(e => { if (active) setError(e instanceof Error ? e.message : 'Could not load market.'); }); }; - if (rpcAvailable) load(); - const timer = setInterval(() => { setNow(Date.now()); if (rpcAvailable && !inFlight.current) load(); }, 15000); - return () => { active = false; version.current++; clearInterval(timer); }; - }, [refresh, rpcAvailable]); - const prepare = (action: MarketAction | MarketActionV3, text: string, legacy = false) => { - if (['list','bid','buy'].includes(action.type) && (page?.info.marketEnabled === false || page?.info.tradingEnabled === false)) { - setError('This host is not accepting new trading. Existing orders can still be resolved.'); return; - } - if (!legacy && (action.type === 'list' || action.type === 'buy')) { - const listingId = action.type === 'buy' ? action.listingId : undefined; - const recipient = action.type === 'buy' - ? page?.listings.find(l => l.id === listingId)?.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address - : page?.info.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address; - action = { ...action, hostFeeAddress: recipient } as MarketActionV3; - text += ` Host fee recipient: ${recipient}. The separate fixed author fee is unchanged.`; - } - setError(''); setReview({ action, text, legacy }); - }; - const submit = async () => { - if (!wallet || !page || !review || inFlight.current || !rpcAvailable) return; - const currentVersion = version.current; - inFlight.current = true; setBusy(true); setError(''); - try { - const action = review.action.type === 'deliver' ? { ...review.action, transfer: { ...review.action.transfer, - signature: await sign(wallet, nftTransferMessage(review.action.transfer)) } } : review.action; - if (review.legacy) await commandV2(wallet, page.info, action as MarketAction); - else await commandV3(wallet, page.info, action as MarketActionV3); - if (currentVersion !== version.current) return; - const type = review.action.type; - setReview(undefined); - if (type === 'buy' || type === 'mock-pay' || type === 'deliver' || type === 'refund') setTab('orders'); - onNotice(type === 'deliver' ? 'Delivery submitted. Simulated seller payout waits for canonical confirmations.' : 'Marketplace updated. All XMR payments and payouts are simulations.'); - await refresh(); - } catch (e) { setError(e instanceof Error ? e.message : 'Marketplace action failed.'); } - finally { inFlight.current = false; setBusy(false); } - }; - const listed = new Set(activity.listings.filter(l => l.status === 'open' || l.status === 'reserved').map(l => l.cardHash)); - const owned = snapshot?.blocks.filter(b => wallet && testnetOwnerOf(snapshot, b.tokenId) === wallet.address && - snapshot.blocks.length + 1 - b.height >= CONSENSUS.coinbaseMaturity && !listed.has(b.tokenId) && - !snapshot.pendingTransfers.some(tx => transactionCardIds(tx).includes(b.tokenId))) ?? []; - const create = () => { - if (!snapshot || !page || !owned.some(b => b.tokenId === cardHash)) { setError('Select an eligible card first.'); return; } - try { - const priceAtomicXmr = atomicXmrString(parseXmr(price)), duration = Number(hours); - if (kind === 'auction' && (!Number.isFinite(duration) || duration < 1 / 60 || duration > 720)) throw new Error('Choose an auction duration from one minute to 30 days.'); - prepare({ type: 'list', cardHash, sequence: snapshot.transfers.filter(tx => transactionCardIds(tx).includes(cardHash)).length, - kind, priceAtomicXmr, endsAt: kind === 'auction' ? Date.now() + Math.round(duration * 3600_000) : null, feeBps: page.info.feeBps }, - `List ${owned.find(b => b.tokenId === cardHash)!.article.title} ${kind === 'fixed' ? 'for' : 'with a starting bid of'} ${formatXmr(parseAtomicXmr(priceAtomicXmr))} XMR. ${feeDisclosure(page.info.feeBps)} ${kind === 'auction' ? `Auction lasts ${duration} hours. You cannot cancel after the first bid.` : 'The listing stays open until sold or cancelled.'} Listing does not lock the card on-chain.`); - } catch (e) { setError(e instanceof Error ? e.message : 'Invalid price.'); } - }; - const deliver = async (o: MarketOrderRecord) => { - if (!wallet || !page) return; - try { - const tx = { network: NETWORK, chainId: page.info.chainId, cardHash: o.cardHash, - from: wallet.address, to: o.buyer, sequence: o.sequence }; - const transfer = { ...tx, publicKey: wallet.publicKey, signature: '' }; // Sign only after explicit review confirmation. - prepare({ type: 'deliver', orderId: o.id, transfer }, - `Transfer card ${short(o.cardHash)} permanently to ${short(o.buyer)}. Only mock payment has been received. This signature submits a real NFT transfer on the selected network. Agreed sale price: ${orderPrice(o)}. Simulated net proceeds: ${o.sellerNetXmr} XMR, after ${o.feeXmr} XMR market fee${o.additionalFee ? ` and ${o.additionalFee.amountXmr} XMR additional fee (${o.additionalFee.bps / 100}%) to ${o.additionalFee.address}` : ' (legacy order: no additional fee)'}.`, !isXmrOrder(o)); - } catch { setError('Could not prepare delivery signature.'); } - }; - const loadMore = async () => { - if (!page || page.next === null) return; - try { - const next = await api(undefined, page.next); - if (next.info.marketId !== page.info.marketId || next.info.chainId !== page.info.chainId) throw new Error('Market changed. Refresh first.'); - setPage(current => current ? { ...next, listings: [...new Map([...current.listings, ...next.listings].map(l => [l.id, l])).values()] } : next); - } catch (e) { setError(e instanceof Error ? e.message : 'Could not load listings.'); } - }; - const disabled = busy || !wallet || !rpcAvailable || !page; - return
- {page&&(page.info.marketEnabled===false||page.info.tradingEnabled===false)&&

{page.info.marketEnabled===false?'This peer does not offer a public marketplace.':'This peer has paused new trading.'} Existing orders, cancellations and resolution remain accessible through Your activity.

} - {page?.info.hostFeeAddress&&

Host fee wallet (public payout recipient, not a deposit address): {page.info.hostFeeAddress}

} -
A MARKET RUN BY THIS PEER

Find your next card.
Trade with your people.

Browse fixed-price cards or join a timed auction. Prices are shown in XMR. Each peer runs its own market, so listings and orders stay with this host.

PEER-HOSTED · COMMUNITY-DRIVEN
-

The host sets its market fee; a separate 1% author fee also applies. Both are taken from simulated seller proceeds and shown before signing. Community curation and custom trading-card games are planned, not available yet.

-
Mock payments only · never send real XMR

Quotes, payment confirmations, refunds, fees, and automatic seller payouts are simulated. Card delivery is a real on-chain transfer. There is no payable Monero address. Listings are local to this marketplace node, not shared by peers.

SANDBOX
-
{(['browse','sell','orders','custody'] as const).map(t=>)}
- {tab==='custody'&&} - {page&&<>
Host {page.info.feeBps/100}% + author 1% · {(page.info.feeBps+ADDITIONAL_MARKET_FEE.bps)/100}% total deducted from sellerDelivery {page.info.deliveryConfirmations} confirmations before mock payoutNew listings and orders: XMR-native

Hardcoded project author fee recipient · simulated payout only, not a checkout address: {ADDITIONAL_MARKET_FEE.address}

} - {!rpcAvailable&&

Reconnect to the node before using the marketplace.

} - {!wallet&&

Open your local wallet to list cards, bid, and check out.

} - {error&&

{error}

} - {review&&
REVIEW BEFORE SIGNING

{review.text}

} - {tab==='browse'&&<>
{(['all','fixed','auction'] as const).map(f=>)}
{page?.listings.filter(l=>filter==='all'||l.kind===filter).map(l=>{ - const ended=l.kind==='auction'&&now>=l.endsAt!, mine=l.seller===wallet?.address; - const legacy=!isXmrListing(l); - const saleAtomic=isXmrListing(l)?(l.highestBid?.priceAtomicXmr??l.priceAtomicXmr):undefined; - const salePrice=legacy?(l.highestBid?.priceCents??l.priceCents):undefined; - const article=snapshot?.blocks.find(b=>b.tokenId===l.cardHash)?.article; - return
{l.kind==='auction'?:} {l.kind==='auction'?'Timed auction':'Fixed price'}
BY {short(l.seller)} {mine?'· YOU':''} {legacy&&· USD LEGACY}

{l.title}

{legacy?formatUsd(salePrice!):`${formatXmr(parseAtomicXmr(saleAtomic!))} XMR`}{legacy?'Legacy USD terms':l.kind==='fixed'?'Native-XMR price':l.highestBid?'Highest bid':'Starting bid'}
{legacy&&l.status==='open'&&!mine&&

Legacy USD listing is read-only during the native-XMR market transition.

}{l.kind==='auction'&&

{deadline(l.endsAt!,now)}{ended&&l.highestBid?` · winner ${short(l.highestBid.bidder)}`:''}

} - {l.status==='reserved'?

Reserved · awaiting settlement

:legacy?mine&&['open','invalid'].includes(l.status)&&!l.highestBid?:

Read-only USD legacy listing · no new bids or checkout

:mine?:l.kind==='fixed'||ended?:
setBids(v=>({...v,[l.id]:e.target.value}))}/>
} -

{l.feeBps/100}% host + {(l.additionalFee?.bps ?? 0)/100}% author · deducted from seller · NFT {short(l.cardHash)}

{article&&

Wikipedia · Image & license · NFT metadata

}
; - })}
{!page?.listings.length&&

A discovery worth sharing?

The market is waiting for its first listing. Offer a card from your confirmed collection.

}{page?.next!==null&&page?.next!==undefined&&}} - {tab==='sell'&&
MAKE ROOM FOR THE NEXT DISCOVERY

Put a card on the market.

Choose a fixed XMR price or let collectors bid until the clock runs out. Enter up to 12 decimal places; one atomic unit is the smallest representable positive price. You retain custody of the card and must return to sign delivery after mock payment.

A listing does not lock a card in consensus. Transfers elsewhere can invalidate it. No cancellation after an auction’s first bid; the winner gets 30 minutes to pay. Seller delivery deadline is 48 hours after mock payment.

{e.preventDefault();create();}}>setPrice(e.target.value)} required/>

12 decimal places maximum. No USD conversion or minimum-price rule. Fees: {page?(page.info.feeBps/100)+'% host + 1% author':'—'} · rounded independently from gross proceeds.

{kind==='auction'&&<>setHours(e.target.value)} required/>}
} - {tab==='orders'&&
-

Orders & settlement

-

Private order details require your wallet signature. New prices use native XMR amounts; legacy orders retain their original USD terms. No private keys leave this browser. Payments and payouts remain simulated.

- {activity.orders.length===0&&

No orders yet. Explore the market to find your next discovery.

} - {activity.orders.map(o=>{ - const buyer=o.buyer===wallet?.address; - return
-
{snapshot?.blocks.find(b=>b.tokenId===o.cardHash)?.article.title??short(o.cardHash)}{buyer?'Buying from':'Selling to'} {short(buyer?o.seller:o.buyer)} · {short(o.id)}
{o.status.replaceAll('-',' ')}
-
-
{isXmrOrder(o)?'Sale price':'Sale price · USD legacy'}
{orderPrice(o)}
-
Mock checkout
{o.invoice.amountXmr} XMR
-
Hosting peer fee ({o.feeBps/100}%) · simulated
{o.feeXmr} XMR
- {o.additionalFee &&
Project author fee ({o.additionalFee.bps/100}%) · simulated
{o.additionalFee.amountXmr} XMR · {o.additionalFee.payout}Recipient: {o.additionalFee.address}
} -
Seller net · simulated
{o.sellerNetXmr} XMR
-
-

{o.status==='awaiting-payment'?`Pay by ${new Date(o.payBy).toLocaleString()}`:o.status==='paid'?`Seller signs delivery by ${new Date(o.deliverBy).toLocaleString()}`:`Delivery: ${o.deliveryConfirmations}/${o.requiredConfirmations} confirmations · payout: ${o.payout}`}

-
- {buyer&&o.status==='awaiting-payment'&&} - {!buyer&&(o.status==='paid'||o.status==='delivery-pending')&&} - {buyer&&o.status==='refund-required'&&} -
- {o.status==='review'&&

A chain reorganization affected settled delivery. Operator review required; no second payout is submitted.

} -
; - })} -

Your listing history

{activity.listings.map(l=>
{l.title}{isXmrListing(l)?`${formatXmr(parseAtomicXmr(l.highestBid?.priceAtomicXmr??l.priceAtomicXmr))} XMR`:listingPriceLegacy(l)}{l.status}
)} -
} -
; -} -function ShieldNote() { return ; } diff --git a/src/custody.css b/src/custody.css deleted file mode 100755 index 46e5827..0000000 --- a/src/custody.css +++ /dev/null @@ -1,16 +0,0 @@ -.custody-panel { display: grid; gap: 1.2rem; padding-block: 1rem; } -.custody-panel h2, .custody-panel h3, .custody-panel h4, .custody-panel p { margin: 0; } -.custody-panel p { line-height: 1.65; } -.custody-identity { overflow-wrap: anywhere; font-size: .85rem; } -.custody-balances { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; } -.custody-balances > div, .custody-forms > form, .custody-records > article { - padding: 1.25rem; border: 1px solid var(--ctp-surface1); border-radius: 1rem; background: var(--ctp-mantle); -} -.custody-balances > div { display: grid; gap: .6rem; } -.custody-balances strong { overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } -.custody-balances span { font-size: .65rem; letter-spacing: .13em; opacity: .65; } -.custody-forms { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } -.custody-forms > form, .custody-records { display: grid; gap: .8rem; } -.custody-forms input, .custody-forms select { width: 100%; box-sizing: border-box; padding: .75rem; border: 1px solid var(--ctp-surface1); border-radius: .5rem; background: var(--ctp-base); color: inherit; } -.custody-records > article { display: grid; gap: .75rem; } -@media (max-width: 750px) { .custody-balances, .custody-forms { grid-template-columns: 1fr; } } diff --git a/src/market.css b/src/market.css deleted file mode 100755 index 68f71f1..0000000 --- a/src/market.css +++ /dev/null @@ -1,3 +0,0 @@ -.market-fee-recipient{display:block;font-size:10px;line-height:1.8;color:var(--ctp-subtext0);overflow-wrap:anywhere}.market-fee-recipient span{font-family:monospace} -.marketplace{padding-top:22px}.market-hero{display:flex;align-items:center;justify-content:space-between;gap:30px;padding:38px;background:linear-gradient(125deg,var(--ctp-mantle),var(--theme-accent-soft));border:1px solid var(--theme-border);border-radius:16px}.market-hero h1{font-size:clamp(28px,3.4vw,46px);letter-spacing:-1.5px;line-height:1.15;margin:18px 0}.market-hero h1 span{color:var(--theme-accent)}.market-hero p{max-width:530px;font-size:13px;line-height:1.8;color:var(--ctp-subtext0)}.market-symbol{display:flex;align-items:center;flex-direction:column;gap:24px;min-width:180px;color:var(--theme-accent)}.market-symbol svg{padding:25px;box-sizing:content-box;border-radius:28px;border:1px solid var(--ctp-surface1);transform:rotate(-9deg);background:var(--ctp-base)}.market-symbol span{font-size:8px;letter-spacing:2px}.market-simulation{display:flex;align-items:flex-start;gap:14px;padding:19px 22px;margin:20px 0;background:var(--theme-accent-soft);border:1px solid var(--ctp-surface1);border-radius:10px;font-size:12px}.market-simulation strong{color:var(--ctp-yellow)}.market-simulation p{font-size:11px;line-height:1.8;color:var(--ctp-subtext0);margin:7px 0 0}.market-note-icon{color:var(--ctp-yellow)}.market-mode{margin-left:auto;white-space:nowrap;font-size:9px;letter-spacing:1px;border:1px solid var(--ctp-yellow);color:var(--ctp-yellow);border-radius:5px;padding:5px 8px}.market-toolbar,.market-tabs,.market-actions,.market-filter{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.market-toolbar{justify-content:space-between;margin:28px 0 20px}.market-tabs{gap:4px;background:var(--ctp-mantle);padding:5px;border-radius:9px;border:1px solid var(--theme-border)}.market-tabs button,.market-filter button{padding:10px 14px;border-radius:6px;font-size:11px;background:transparent;color:var(--ctp-subtext0)}.market-tabs button.selected,.market-filter button.selected{background:var(--theme-accent-soft);color:var(--theme-accent)}.market-meta{display:flex;gap:20px;flex-wrap:wrap;font-size:10px;color:var(--ctp-subtext0);padding-bottom:22px;border-bottom:1px solid var(--theme-border);line-height:1.8}.market-meta strong{color:var(--ctp-text)}.market-filter{margin:20px 0}.market-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:22px}.market-card{overflow:hidden;border:1px solid var(--theme-border);border-radius:12px;background:var(--ctp-mantle);display:flex;flex-direction:column}.market-art{position:relative;height:180px;background:var(--ctp-crust)}.market-art img{width:100%;height:100%;object-fit:cover}.market-art>span{position:absolute;top:12px;left:12px;display:flex;align-items:center;gap:5px;border:1px solid #cdd6f44d;background:#11111be6;color:#cdd6f4;border-radius:20px;padding:6px 9px;font-size:9px}.market-card-body{padding:20px;display:flex;flex-direction:column;flex:1}.market-card-body>small{font-size:8px;letter-spacing:1px;color:var(--ctp-subtext0)}.market-card h2{font-family:Georgia,serif;font-weight:500;font-size:23px;line-height:1.2;margin:12px 0 22px}.market-price{display:flex;align-items:baseline;gap:9px;flex-wrap:wrap;margin-top:auto}.market-price strong{font-size:24px;letter-spacing:-.7px}.market-price span{font-size:9px;color:var(--ctp-subtext0)}.market-countdown{display:flex;align-items:center;gap:6px;font-size:10px;color:var(--ctp-peach);line-height:1.7}.market-card .button{margin-top:15px;justify-content:center}.market-card-foot{color:var(--ctp-overlay2);font-size:9px;margin:13px 0 0}.market-bid{display:flex;gap:7px;align-items:center;margin-top:15px}.market-bid input{width:100%;min-width:0}.market-bid .button{margin:0;flex-shrink:0}.marketplace input,.marketplace select{background:var(--ctp-base);color:var(--ctp-text);border:1px solid var(--ctp-surface1);border-radius:6px;padding:12px;font:inherit;font-size:12px;max-width:100%}.marketplace input:focus-visible,.marketplace select:focus-visible{outline:2px solid var(--theme-accent);outline-offset:2px}.market-reserved{font-size:12px;color:var(--ctp-peach);padding:12px 0}.market-empty{text-align:center;padding:65px 25px;border:1px dashed var(--ctp-surface1);border-radius:12px;color:var(--ctp-subtext0)}.market-empty>svg{color:var(--theme-accent)}.market-empty h2{color:var(--ctp-text);font-size:23px;margin:20px 0 10px}.market-empty p{font-size:12px;line-height:1.8;margin:0 auto 22px;max-width:440px}.market-empty .button{margin:auto}.market-sell{display:grid;grid-template-columns:1fr 1fr;gap:45px;padding:32px;margin-top:28px;border:1px solid var(--theme-border);border-radius:12px;background:var(--ctp-mantle)}.market-sell h2{font-size:27px;line-height:1.3;margin:16px 0}.market-sell p{font-size:12px;line-height:1.9;color:var(--ctp-subtext0)}.market-sell form{display:flex;flex-direction:column;gap:10px}.market-sell label{font-size:9px;letter-spacing:1px;color:var(--ctp-subtext0);margin-top:8px}.market-review{padding:24px;background:var(--theme-accent-soft);border:1px solid var(--theme-accent);border-radius:10px;margin:20px 0}.market-review p{font-size:13px;line-height:1.8}.market-error{background:var(--theme-error-soft);color:var(--ctp-red);font-size:12px;line-height:1.7;padding:14px;border-radius:8px}.market-activity>h2{font-size:23px;margin:28px 0 12px}.market-order{border:1px solid var(--theme-border);border-radius:10px;background:var(--ctp-mantle);padding:24px;margin:18px 0}.market-order header{display:flex;justify-content:space-between;align-items:flex-start;gap:15px}.market-order header strong{font-size:17px}.market-order small{display:block;color:var(--ctp-subtext0);margin-top:8px;font-size:10px}.market-state{text-transform:capitalize;font-size:10px;white-space:nowrap;color:var(--theme-accent);border:1px solid var(--ctp-surface1);border-radius:20px;padding:6px 10px}.market-order dl{display:grid;grid-template-columns:repeat(2,1fr);gap:18px;margin:24px 0}.market-order dt{font-size:9px;color:var(--ctp-subtext0);margin-bottom:6px}.market-order dd{margin:0;font-size:12px;overflow-wrap:anywhere}.market-history{display:flex;gap:20px;padding:16px 0;border-bottom:1px solid var(--theme-border);font-size:12px}.market-history strong{flex:1}.market-history span{color:var(--ctp-subtext0)} -@media(max-width:850px){.market-hero{padding:26px}.market-symbol{display:none}.market-sell{grid-template-columns:1fr;gap:20px}.market-mode{display:none}}@media(max-width:560px){.market-hero{padding:24px}.market-simulation{padding:16px}.market-tabs button{padding:9px;font-size:10px}.market-sell{padding:20px}.market-order{padding:18px}.market-order header{flex-wrap:wrap}.market-order dl{grid-template-columns:1fr}.market-meta{gap:6px;flex-direction:column}.market-history{gap:10px;flex-wrap:wrap}.market-art{height:210px}} diff --git a/src/wallet.ts b/src/wallet.ts index 14cc040..a41c8b9 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -1,5 +1,5 @@ import { decryptWallet, encryptWallet, type WalletBackup } from '../shared/wallet-backup.ts'; -import { IS_MAINNET, PROFILE } from '../shared/network.ts'; +import { IS_MAINNET } from '../shared/network.ts'; export type Wallet = { publicKey: string; address: string }; type Keys = Awaited>; @@ -12,8 +12,7 @@ let session: { keys: Keys; handle: Wallet; revision: string; opened: number; tou let epoch = 0; const listeners = new Set<() => void>(); const isVault = (value: Stored): value is Vault => 'format' in value && value.format === 'wikideck-local-vault'; -const DATABASE = IS_MAINNET ? 'wikideck-mainnet-wallet-v1' - : PROFILE === 'testnet' ? 'wikideck-testnet-wallet-v2' : 'wikideck-pilot-wallet-v1'; +const DATABASE = IS_MAINNET ? 'wikideck-mainnet-wallet-v1' : 'wikideck-testnet-wallet-v2'; const LOCK = `${DATABASE}:identity`; const encode = (bytes: ArrayBuffer) => btoa(String.fromCharCode(...new Uint8Array(bytes))); diff --git a/tests/api.test.ts b/tests/api.test.ts index 030550d..e668ee2 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -6,7 +6,7 @@ import { constants, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { delimiter, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { generateKeyPairSync, randomUUID, sign } from 'node:crypto'; +import { generateKeyPairSync, sign } from 'node:crypto'; import { DatabaseSync } from 'node:sqlite'; import { setTimeout as delay } from 'node:timers/promises'; import { RandomX } from '../server/testnet/randomx.ts'; @@ -14,14 +14,9 @@ import { address, CHAIN_ID, GENESIS_HASH, MAX_TARGET, miningBlob, mintId, sha256 transferId, workFor } from '../server/testnet/consensus.ts'; import { NETWORK, CONSENSUS, blockClaimMessage, nftTransferMessage, type MiningTemplate, type NftTransfer, type TestnetBlock, type TestnetSnapshot } from '../shared/testnet.ts'; -import { type MarketAction, type MarketInfo } from '../shared/market.ts'; -import { marketMessageV3, type MarketActionV3, type MarketActivityV3, type MarketListingV3, type MarketOrderV3 } from '../shared/market-v3.ts'; import { StateClient } from '../shared/rpc-client.ts'; import { createBackup,restoreBackup } from '../server/recovery.ts'; import { Ledger } from '../server/testnet/ledger.ts'; -import { Market } from '../server/market/store.ts'; -import { CUSTODY_POLICY, custodyMessage, type CustodyCommand, type CustodyPage, type CustodyActivity } from '../shared/custody.ts'; -import { CustodyMarket } from '../server/market/custody.ts'; const root = fileURLToPath(new URL('../', import.meta.url)); const wikipediaMock = resolve(root, 'tests', 'mock-wikipedia.mjs'); @@ -82,17 +77,14 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du }; t.signal.addEventListener('abort', abort, { once: true }); - async function start(dataDir: string, peer?: string, marketRuntimeEnabled = true): Promise { + async function start(dataDir: string, peer?: string): Promise { t.signal.throwIfAborted(); const child = spawn(process.execPath, ['--import', wikipediaMock, '--experimental-strip-types', 'server/index.ts'], { cwd: root, windowsHide: true, shell: false, env: { ...process.env, HOST: '127.0.0.1', PORT: '0', NODE_ENV: 'production', WIKIDECK_DATA_DIR: dataDir, RANDOMX_BINARY: binary, WIKIDECK_PEERS: peer ?? '', WIKIDECK_ALLOW_PRIVATE_PEERS: peer ? '1' : '0', - PUBLIC_ORIGIN: 'http://127.0.0.1:3001', WIKIDECK_PAYMENT_MODE: 'mock', - WIKIDECK_MARKET_RUNTIME_ENABLED: marketRuntimeEnabled ? '1' : '0', - WIKIDECK_MARKET_FEE_BPS: '250', WIKIDECK_MARKET_CONFIRMATIONS: '2', WIKIDECK_CUSTODY_MODE: peer ? 'off' : 'sandbox', - // This integration burst now includes signed custody reads/replay probes; admission limits have dedicated tests. + PUBLIC_ORIGIN: 'http://127.0.0.1:3001', WIKIDECK_RPC_WRITES_PER_SECOND: '50' }, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -128,20 +120,12 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du async function request(base: string, path: string, expected = 200, body?: unknown, headers: Record = {}): Promise { - const until = Date.now() + 20_000; - let response: Response; - do { - response = await fetch(`${base}${path}`, { + const response = await fetch(`${base}${path}`, { method: body === undefined ? 'GET' : 'POST', redirect: 'error', signal: AbortSignal.any([t.signal, AbortSignal.timeout(30_000)]), headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...headers }, body: body === undefined ? undefined : JSON.stringify(body), }); - if (path !== '/api/market/command' || response.status !== 503 || Date.now() >= until || - !(await response.clone().text()).includes('reconciliation is catching up')) break; - const retryAfter = Number(response.headers.get('Retry-After')); - await delay(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 25, undefined, { signal: t.signal }); - } while (Date.now() < until); if (response.status !== expected) { assert.fail(`${path}: expected ${expected}, got ${response.status}: ${await response.text()}`); } @@ -151,19 +135,6 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du return (await request(base, path, expected, body)).json() as Promise; } const snapshot = (base: string) => json(base, '/api/chain'); - async function marketCommand(base: string, info: MarketInfo, who: ReturnType, action: MarketAction) { - const atomicFromCents = (cents: number) => ((BigInt(cents) * 1_000_000_000_000n + 14999n) / 15000n).toString(); - let current: MarketActionV3; - if (action.type === 'list') current = { type: 'list', cardHash: action.cardHash, sequence: action.sequence, - kind: action.kind, priceAtomicXmr: atomicFromCents(action.priceCents), endsAt: action.endsAt, feeBps: action.feeBps }; - else if (action.type === 'buy') current = { type: 'buy', listingId: action.listingId, - priceAtomicXmr: atomicFromCents(action.priceCents), feeBps: action.feeBps }; - else if (action.type === 'bid') current = { type: 'bid', listingId: action.listingId, priceAtomicXmr: atomicFromCents(action.priceCents) }; - else current = action; - const terms = { version: 3 as const, network: NETWORK, chainId: CHAIN_ID, marketId: info.marketId, actor: who.owner, - publicKey: who.publicKey, nonce: randomUUID(), expiresAt: Date.now() + 60000, action: current }; - return json(base, '/api/market/command', 200, { ...terms, signature: who.signed(marketMessageV3(terms)) }); - } async function waitFor(label: string, read: () => Promise, predicate: (value: T) => boolean): Promise { const until = Date.now() + 25_000; let value: T; @@ -219,25 +190,9 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du engine = new RandomX({ binary }); // Always the actual reference RandomX engine, never a test double. const alice = wallet(), bob = wallet(); assert.notEqual(alice.owner, bob.owner); - const marketDisabledNode = await start(firstDir, undefined, false); - await json(marketDisabledNode.base, '/api/summary'); - await request(marketDisabledNode.base, '/api/market', 404); - await stop(marketDisabledNode); let first = await start(firstDir); await assert.rejects(start(firstDir), /Data directory locked/, - 'a second server process must fail closed rather than run another marketplace worker on the same data directory'); - const custody = await json(first.base, '/api/custody'); - assert.equal(custody.info.mode, 'sandbox'); assert.equal(custody.info.liveDeposits, false); - const custodyTerms: Omit = { version: 1, mode: 'sandbox', policy: CUSTODY_POLICY, - network: NETWORK, chainId: CHAIN_ID, custodianId: custody.info.custodianId, actor: bob.owner, - publicKey: bob.publicKey, nonce: randomUUID(), expiresAt: Date.now() + 60000, action: { type: 'mock-credit' } }; - const custodyCredit = { ...custodyTerms, signature: bob.signed(custodyMessage(custodyTerms)) }; - const credited = await json(first.base, '/api/custody/command', 200, custodyCredit); - assert.equal(credited.availableAtomicXmr, '1000000000000'); - assert.deepEqual(await json(first.base, '/api/custody/command', 200, custodyCredit), credited); - await request(first.base, '/api/custody/command', 400, { ...custodyCredit, actor: alice.owner }); - await request(first.base, '/api/custody/command', 400, { ...custodyCredit, mode: 'live' }); - assert.match((await request(first.base, '/api/custody/command', 200, custodyCredit)).headers.get('cache-control') ?? '', /no-store/); + 'a second server process must fail closed rather than run a second writer on the same data directory'); const health = await request(first.base, '/healthz'); assert.deepEqual(await health.json(), { status: 'ok', network: NETWORK }); assert.equal(health.headers.get('cache-control'), 'no-store'); @@ -272,9 +227,13 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du signal: AbortSignal.any([t.signal, AbortSignal.timeout(5000)]) }); assert.equal(malformed.status, 400); assert.deepEqual(await malformed.json(), { error: 'Invalid request encoding' }); - for (const path of ['/api/missing', '/p2p/missing', `/api/nfts/${'0'.repeat(64)}`]) { + for (const path of ['/api/missing', '/api/market', '/api/custody', '/api/monero/deposit-config', + '/p2p/missing', `/api/nfts/${'0'.repeat(64)}`]) { await (await request(first.base, path, 404)).text(); } + for (const path of ['/api/market/command', '/api/custody/command', '/api/monero/deposit-address']) { + await (await request(first.base, path, 404, {})).text(); + } for (const offset of ['-1', '1.5', '9007199254740992', 'nope']) { await (await request(first.base, `/p2p/blocks?offset=${offset}`, 400)).text(); } @@ -307,25 +266,10 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du await request(first.base, '/api/nfts/not-a-token', 400); await request(first.base, `/api/decks/${alice.owner}?tip=${block1.hash}`, 409); assert.equal((await json<{ total: number }>(first.base, `/api/decks/${alice.owner}?tip=${block2.hash}&limit=1`)).total, 2); - const market = await json<{ info: MarketInfo; listings: MarketListingV3[] }>(first.base, '/api/market'); - assert.equal(market.info.paymentMode, 'mock'); - assert.deepEqual(market.listings, []); - await (await request(first.base, '/api/market/command', 400, {})).text(); - await (await request(first.base, '/api/market/command', 403, {}, { Origin: 'https://evil.example' })).text(); - const sale = await marketCommand(first.base, market.info, alice, { type: 'list', - cardHash: block1.tokenId, sequence: 0, kind: 'fixed', priceCents: 1500, endsAt: null, feeBps: 250 }); - assert.equal((await json<{ listings: MarketListingV3[] }>(first.base, '/api/market')).listings[0].id, sale.id); - const order = await marketCommand(first.base, market.info, bob, { type: 'buy', listingId: sale.id, - priceCents: 1500, feeBps: 250 }); - assert.equal(order.invoice.address, null); - assert.equal(order.invoice.amountXmr, '0.100000000000'); - await marketCommand(first.base, market.info, bob, { type: 'mock-pay', orderId: order.id }); await (await request(first.base, `/api/decks/${'z'.repeat(64)}`, 400)).text(); // A truly independent process reconstructs the canonical chain over HTTP and checks real work. const second = await start(secondDir, first.base); - assert.deepEqual(await json(second.base, '/api/custody'), { enabled: false }); - await request(second.base, '/api/custody/command', 503, custodyCredit); const synced = await waitFor('Second node canonical sync', () => snapshot(second.base), state => state.tip === block2.hash && state.peers === 1); assert.deepEqual(synced.blocks, [block1, block2]); @@ -336,8 +280,6 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du const badTransfer = await json<{ error: string }>(second.base, '/api/transfer', 400, { ...transfer, signature: tamperSignature(transfer.signature) }); assert.match(badTransfer.error, /signature/i); - await marketCommand(first.base, market.info, alice, { type: 'deliver', orderId: order.id, transfer }); - assert.equal((await marketCommand(first.base, market.info, bob, { type: 'activity' })).orders[0].status, 'delivery-pending'); const pending = { id: transferId(transfer), status: 'pending', message: 'Ownership changes only when this transaction is mined.' }; assert.deepEqual(await json(second.base, '/api/transfer', 202, transfer), pending); @@ -375,12 +317,6 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du assert.deepEqual(canonical.blocks, [block1, block2, block3]); assert.deepEqual(canonical.transfers, [transfer]); assert.deepEqual(canonical.pendingTransfers, []); - const settling = await waitFor('Worker market delivery reconciliation', - () => marketCommand(first.base, market.info, bob, { type: 'activity' }), - activity => activity.orders[0]?.deliveryConfirmations === 1); - assert.equal(settling.orders[0].deliveryConfirmations, 1); - assert.equal(settling.orders[0].payout, 'not-started'); - assert.equal((await json<{ listings: MarketListingV3[] }>(second.base, '/api/market')).listings.length, 0); assert.equal(canonical.cumulativeWork, (3n * workFor(MAX_TARGET)).toString()); for (const node of [first, second]) { const replay = await json<{ error: string }>(node.base, '/api/transfer', 400, transfer); @@ -413,37 +349,13 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du } const backupDir=join(directory,'stopped-pair-backup'),drillDir=join(directory,'isolated-restore-drill'); const backupManifest = await createBackup(firstDir,backupDir);await restoreBackup(backupDir,drillDir); - assert.ok(backupManifest.files.some(f=>f.name==='custody-sandbox.sqlite')); - const drillCustody = await CustodyMarket.open(drillDir, { feeBps: 250, card: () => undefined }); - try { - assert.equal(drillCustody.id, custody.info.custodianId); - assert.equal(drillCustody.audit().assetsAtomicXmr, '1000000000000'); - } finally { drillCustody.close(); } const drillLedger=await Ledger.open(drillDir,engine!); // Revalidate real RandomX proofs from the copied backup. try { assert.deepEqual(drillLedger.snapshot(),{...canonical,peers:0}); - const drillMarket=await Market.open(drillDir,drillLedger); - try {assert.equal(drillMarket.id,market.info.marketId);}finally{drillMarket.close();} - const db=new DatabaseSync(join(drillDir,'testnet','market.sqlite'),{readOnly:true}); - try { - const row=db.prepare('SELECT data FROM orders WHERE id=?').get(order.id) as {data:string}; - assert.equal(JSON.parse(row.data).buyer,bob.owner); - assert.ok((db.prepare('SELECT COUNT(*) AS n FROM receipts').get() as {n:number}).n>0); - }finally{db.close();} }finally{drillLedger.close();} first = await start(firstDir); // Ledger.open revalidates every persisted block using native RandomX. - const restartedCustody = await json(first.base, '/api/custody'); - assert.equal(restartedCustody.info.custodianId, custody.info.custodianId); - const balanceTerms: Omit = { ...custodyTerms, nonce: randomUUID(), - expiresAt: Date.now() + 60000, action: { type: 'activity', offset: 0 } }; - const restoredBalance = await json(first.base, '/api/custody/command', 200, - { ...balanceTerms, signature: bob.signed(custodyMessage(balanceTerms)) }); - assert.equal(restoredBalance.availableAtomicXmr, '1000000000000'); const restored = await snapshot(first.base); assert.deepEqual(await pagedClient.load(),restored); - const restoredMarket = await json<{ info: MarketInfo }>(first.base, '/api/market'); - assert.equal(restoredMarket.info.marketId, market.info.marketId); - assert.equal((await marketCommand(first.base, market.info, bob, { type: 'activity' })).orders[0].id, order.id); assert.deepEqual(restored, { ...canonical, peers: 0 }); assert.deepEqual(await json(first.base, `/api/nfts/${block1.tokenId}`), { network: NETWORK, chainId: CHAIN_ID, tokenId: block1.tokenId, owner: bob.owner, sequence: 1, diff --git a/tests/custody-db-args.test.ts b/tests/custody-db-args.test.ts deleted file mode 100755 index dd8cffe..0000000 --- a/tests/custody-db-args.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { assertCustodyDbActionAllowedOnNetwork, assertMainnetSchemaMigrationAcknowledged, - assertMainnetCustodyOperationAcknowledged, assertPilotCustodyActionAllowed, - assertPilotSchemaMigrationAcknowledged, assertPilotCustodyIdentityAcknowledged, - assertPilotReservationGrantAcknowledged, assertPilotAddressGrantAcknowledged, - assertPilotObserverGrantAcknowledged, - assertPilotDepositReviewAcknowledged, - parseCustodyDbArguments } from '../server/market/custody-db-args.ts'; - -test('custody database CLI parses two-argument deposit operations without shifting arguments', () => { - assert.deepEqual(parseCustodyDbArguments(['deposit-prepare-credit', 'a'.repeat(64), `${'b'.repeat(64)}:${'c'.repeat(64)}`]), { - action: 'deposit-prepare-credit', argument: 'a'.repeat(64), extraArgument: `${'b'.repeat(64)}:${'c'.repeat(64)}`, - }); - assert.deepEqual(parseCustodyDbArguments(['deposit-prepare-resolve-hold', 'credit-id', 'hold-id']), { - action: 'deposit-prepare-resolve-hold', argument: 'credit-id', extraArgument: 'hold-id', - }); - assert.deepEqual(parseCustodyDbArguments(['grant-monero-observer-runtime', 'monero_observer']), { - action: 'grant-monero-observer-runtime', argument: 'monero_observer', extraArgument: undefined, - }); - assert.deepEqual(parseCustodyDbArguments(['grant-monero-deposit-allocator-runtime', 'monero_allocator']), { - action: 'grant-monero-deposit-allocator-runtime', argument: 'monero_allocator', extraArgument: undefined, - }); - assert.deepEqual(parseCustodyDbArguments(['grant-monero-withdrawal-runtime', 'monero_withdrawal']), { - action: 'grant-monero-withdrawal-runtime', argument: 'monero_withdrawal', extraArgument: undefined, - }); - assert.deepEqual(parseCustodyDbArguments(['grant-pilot-monero-review-runtime', 'pilot_deposit_review']), { - action: 'grant-pilot-monero-review-runtime', argument: 'pilot_deposit_review', extraArgument: undefined, - }); - assert.deepEqual(parseCustodyDbArguments(['pilot-deposit-prepare-review', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', - `${'b'.repeat(64)}:${'c'.repeat(64)}`]), { - action: 'pilot-deposit-prepare-review', argument: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', - extraArgument: `${'b'.repeat(64)}:${'c'.repeat(64)}`, - }); - assert.deepEqual(parseCustodyDbArguments(['initialize-pilot-custody-identity', 'AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA']), { - action: 'initialize-pilot-custody-identity', argument: 'AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA', extraArgument: undefined, - }); -}); - -test('mainnet custody CLI permits only scoped Monero infrastructure grants and schema migration', () => { - assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('grant-monero-observer-runtime', true)); - assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('grant-monero-deposit-allocator-runtime', true)); - assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('grant-monero-withdrawal-runtime', true)); - assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('migrate-mainnet-schema', true)); - assert.throws(() => assertCustodyDbActionAllowedOnNetwork('migrate-mainnet-schema', false), /requires the mainnet profile/); - assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('initialize-mainnet-custody-identity', true)); - assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork('deposit-stage-credit', true)); - assert.throws(() => assertCustodyDbActionAllowedOnNetwork('initialize-mainnet-custody-identity', false), /requires the mainnet profile/); - assert.throws(() => assertMainnetSchemaMigrationAcknowledged('migrate-mainnet-schema', undefined), /acknowledgement/); - assert.throws(() => assertMainnetSchemaMigrationAcknowledged('migrate-mainnet-schema', 'yes'), /acknowledgement/); - assert.doesNotThrow(() => assertMainnetSchemaMigrationAcknowledged('migrate-mainnet-schema', 'explicit-schema-migration-only')); - assert.doesNotThrow(() => assertMainnetSchemaMigrationAcknowledged('grant-monero-observer-runtime', undefined)); - for (const action of ['migrate', 'grant-runtime', 'audit'] as const) { - assert.throws(() => assertCustodyDbActionAllowedOnNetwork(action, true), /only scoped/i); - assert.doesNotThrow(() => assertCustodyDbActionAllowedOnNetwork(action, false)); - } -}); - -test('mainnet reviewed deposit operations require exact credit-only acknowledgement', () => { - for (const action of ['initialize-mainnet-custody-identity', 'grant-monero-credit-runtime', - 'deposit-prepare-credit', 'deposit-stage-credit', 'deposit-prepare-release', 'deposit-release', - 'deposit-reconcile-holds', 'deposit-prepare-resolve-hold', 'deposit-resolve-hold'] as const) { - assert.throws(() => assertMainnetCustodyOperationAcknowledged(action, undefined), /reviewed-credit-only/); - assert.throws(() => assertMainnetCustodyOperationAcknowledged(action, 'yes'), /reviewed-credit-only/); - assert.doesNotThrow(() => assertMainnetCustodyOperationAcknowledged(action, 'reviewed-credit-only')); - } - assert.doesNotThrow(() => assertMainnetCustodyOperationAcknowledged('grant-monero-observer-runtime', undefined)); - assert.doesNotThrow(() => assertMainnetCustodyOperationAcknowledged('grant-monero-deposit-allocator-runtime', undefined)); - assert.doesNotThrow(() => assertMainnetCustodyOperationAcknowledged('grant-monero-withdrawal-runtime', undefined)); -}); - -test('pilot custody CLI permits only pilot-scoped schema, identity, role, and review-preparation actions', () => { - 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.doesNotThrow(() => assertPilotCustodyActionAllowed('grant-pilot-monero-review-runtime', true)); - assert.doesNotThrow(() => assertPilotCustodyActionAllowed('pilot-deposit-prepare-review', true)); - assert.throws(() => assertPilotCustodyActionAllowed('grant-monero-observer-runtime', true), /pilot-specific schema/); - assert.throws(() => assertPilotCustodyActionAllowed('deposit-stage-credit', true), /pilot-specific schema/); - assert.throws(() => assertPilotCustodyActionAllowed('initialize-pilot-custody-identity', false), /requires the pilot profile/); - assert.throws(() => assertPilotSchemaMigrationAcknowledged('migrate-pilot-schema', undefined), /acknowledgement/); - assert.throws(() => assertPilotSchemaMigrationAcknowledged('migrate-pilot-schema', 'yes'), /acknowledgement/); - assert.doesNotThrow(() => assertPilotSchemaMigrationAcknowledged('migrate-pilot-schema', 'explicit-pilot-schema-migration-only')); - assert.throws(() => assertPilotCustodyIdentityAcknowledged('initialize-pilot-custody-identity', undefined, undefined), /no-funds acknowledgements/); - assert.throws(() => assertPilotCustodyIdentityAcknowledged('initialize-pilot-custody-identity', - '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')); - assert.throws(() => assertPilotAddressGrantAcknowledged('grant-pilot-deposit-address-runtime', undefined), /acknowledgement/); - assert.throws(() => assertPilotAddressGrantAcknowledged('grant-pilot-deposit-address-runtime', 'reservation-only-no-addresses'), /acknowledgement/); - assert.doesNotThrow(() => assertPilotAddressGrantAcknowledged('grant-pilot-deposit-address-runtime', 'operator-triggered-addresses-only')); - assert.throws(() => assertPilotObserverGrantAcknowledged('grant-pilot-monero-observer-runtime', undefined), /no-credit acknowledgement/); - assert.throws(() => assertPilotObserverGrantAcknowledged('grant-pilot-monero-observer-runtime', 'reviewed-credit-only'), /no-credit acknowledgement/); - assert.doesNotThrow(() => assertPilotObserverGrantAcknowledged('grant-pilot-monero-observer-runtime', 'pilot-observation-only-no-credit')); - for (const action of ['grant-pilot-monero-review-runtime', 'pilot-deposit-prepare-review'] as const) { - assert.throws(() => assertPilotDepositReviewAcknowledged(action, undefined), /acknowledgement/); - assert.throws(() => assertPilotDepositReviewAcknowledged(action, 'yes'), /acknowledgement/); - } - assert.doesNotThrow(() => assertPilotDepositReviewAcknowledged('grant-pilot-monero-review-runtime', 'read-only-pilot-review-role-only')); - assert.doesNotThrow(() => assertPilotDepositReviewAcknowledged('pilot-deposit-prepare-review', 'pilot-deposit-review-preparation-only')); - assert.throws(() => assertPilotCustodyActionAllowed('pilot-deposit-prepare-review', false), /requires the pilot profile/); -}); - -test('custody database CLI rejects unknown, missing, empty, and excess arguments', () => { - for (const args of [[], ['unknown'], ['audit', 'extra'], ['deposit-prepare-credit', 'wallet'], - ['deposit-prepare-credit', 'wallet', 'observation', 'extra'], ['deposit-release', '']]) { - assert.throws(() => parseCustodyDbArguments(args)); - } -}); diff --git a/tests/custody.test.ts b/tests/custody.test.ts deleted file mode 100755 index 8488419..0000000 --- a/tests/custody.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { generateKeyPairSync, randomUUID, sign } from 'node:crypto'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { DatabaseSync } from 'node:sqlite'; -import { CustodyMarket, custodyMode } from '../server/market/custody.ts'; -import { CUSTODY_POLICY, MOCK_CREDIT_ATOMIC, custodyCommandSchema, custodyMessage, - type CustodyAction, type CustodyActivity, type CustodyListing, type CustodyOrder, - type CustodyWithdrawal, type CustodyCommand } from '../shared/custody.ts'; -import { NETWORK } from '../shared/testnet.ts'; -import { address, CHAIN_ID } from '../server/testnet/consensus.ts'; -import { operatorMarketPolicy, validateMarketPolicy, type MarketPolicy } from '../server/market/policy.ts'; -import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts'; -import { keccak } from 'hash-wasm'; - -function identity() { - const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); - const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); - return { publicKey, actor: address(publicKey), sign: (message: string) => sign('sha256', Buffer.from(message), - { key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') }; -} -const seller = identity(), buyer = identity(), stranger = identity(), cardHash = 'a'.repeat(64); -const evidence = `fixture:${'c'.repeat(64)}`, tx = `fixture:${'d'.repeat(64)}`; -async function fixture(t: { after(fn: () => Promise): void }) { - const dir = await mkdtemp(join(tmpdir(), 'wikideck-custody-')); - let now = 1_800_000_000_000, present = true; - const options = { feeBps: 250, now: () => now, card: (hash: string, owner: string, sequence: number) => - present && hash === cardHash && owner === seller.actor && sequence === 0 ? { title: 'Custody fixture' } : undefined }; - let market = await CustodyMarket.open(dir, options); - t.after(async () => { market.close(); await rm(dir, { recursive: true, force: true }); }); - const envelope = (who: typeof buyer, action: CustodyAction, nonce: string = randomUUID()) => { - const c: Omit = { version: 1, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK, - chainId: CHAIN_ID, custodianId: market.id, actor: who.actor, publicKey: who.publicKey, - nonce, expiresAt: now + 60000, action }; - return { ...c, signature: who.sign(custodyMessage(c)) }; - }; - const execute = (who: typeof buyer, action: CustodyAction) => market.execute(envelope(who, action)) as T; - const activity = (who = buyer) => execute(who, { type: 'activity', offset: 0 }); - const credit = (who = buyer) => execute(who, { type: 'mock-credit' }); - const listing = (price = '100000000000') => execute(seller, { type: 'list', cardHash, - sequence: 0, priceAtomicXmr: price, feeBps: 250 }); - const reserve = (l: CustodyListing, who = buyer) => execute(who, { type: 'reserve', listingId: l.id, - priceAtomicXmr: l.priceAtomicXmr, feeBps: l.feeBps }); - return { dir, envelope, execute, activity, credit, listing, reserve, get market() { return market; }, - changeCard: () => { present = false; }, advance: (ms: number) => { now += ms; }, - restart: async (policy: MarketPolicy = {}) => { market.close(); market = await CustodyMarket.open(dir, { ...options, ...policy }); } }; -} - -async function feeAddress() { - const prefix = Uint8Array.from([18, ...Array(64).fill(2)]); - const data = Buffer.concat([prefix, Buffer.from((await keccak(prefix, 256)).slice(0, 8), 'hex')]); - const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; - const sizes: Record = { 1: 2, 2: 3, 3: 5, 4: 6, 5: 7, 6: 9, 7: 10, 8: 11 }; - let encoded = ''; - for (let i = 0; i < data.length; i += 8) { - const chunk = data.subarray(i, i + 8); let n = BigInt(`0x${chunk.toString('hex')}`), text = ''; - while (n) { text = alphabet[Number(n % 58n)] + text; n /= 58n; } - encoded += text.padStart(sizes[chunk.length], '1'); - } - return encoded; -} -test('operator market switches are strict and host fee defaults to fixed author with checksum validation', async () => { - assert.deepEqual(operatorMarketPolicy({}), { marketEnabled: true, tradingEnabled: true, hostFeeAddress: ADDITIONAL_MARKET_FEE.address }); - for (const v of ['', 'true', 'false', 'yes', '2']) { - assert.throws(() => operatorMarketPolicy({ WIKIDECK_MARKET_ENABLED: v })); - assert.throws(() => operatorMarketPolicy({ WIKIDECK_MARKET_TRADING_ENABLED: v })); - } - await validateMarketPolicy(operatorMarketPolicy({})); - await assert.rejects(validateMarketPolicy({ hostFeeAddress: '4'.repeat(95) }), /checksum|network/); -}); -test('market disable hides public listings and rejects new business but retains private balances and safe release', async t => { - const f = await fixture(t); f.credit(); const l = f.listing(), o = f.reserve(l); - const w = f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '100', maxFeeAtomicXmr: '1' }); - await f.restart({ marketEnabled: false }); - assert.deepEqual(f.market.browse().listings, []); assert.equal(f.market.info().marketEnabled, false); - assert.throws(() => f.listing('2'), /disabled/); - assert.throws(() => f.credit(stranger), /disabled/); - f.execute(buyer, { type: 'cancel-reservation', orderId: o.id }); - f.execute(buyer, { type: 'cancel-withdrawal', withdrawalId: w.id }); - assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC); - const withdrawal = f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' }); - assert.equal(withdrawal.status, 'reserved'); -}); -test('paused trading keeps browse and cancellations available, and fee recipients are signed and snapshotted', async t => { - const f = await fixture(t), recipient = await feeAddress(); f.credit(); - await f.restart({ hostFeeAddress: recipient }); - assert.throws(() => f.listing(), /recipient/); - const action = { type: 'list' as const, cardHash, sequence: 0, priceAtomicXmr: '1000', feeBps: 250, hostFeeAddress: recipient }; - const c = f.envelope(seller, action); - assert.throws(() => f.market.execute({ ...c, action: { ...action, hostFeeAddress: ADDITIONAL_MARKET_FEE.address } }), /signature/); - const l = f.market.execute(c) as CustodyListing; - await f.restart({ hostFeeAddress: ADDITIONAL_MARKET_FEE.address, tradingEnabled: false }); - assert.equal(f.market.browse().listings[0].hostFeeAddress, recipient); - assert.throws(() => f.reserve(l), /disabled/); - await f.restart(); - assert.throws(() => f.reserve(l), /recipient/); - const o = f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250, hostFeeAddress: recipient }); - assert.equal(o.hostFeeAddress, recipient); assert.equal(o.authorAddress, ADDITIONAL_MARKET_FEE.address); - f.market.settleFixture(o.id, evidence); assert.equal(f.market.audit().assetsAtomicXmr, MOCK_CREDIT_ATOMIC); -}); - -test('custody mode is off by default and rejects all live configurations', () => { - assert.equal(custodyMode({}), 'off'); assert.equal(custodyMode({ WIKIDECK_CUSTODY_MODE: 'sandbox' }), 'sandbox'); - for (const mode of ['live', 'mainnet', '', 'processor']) assert.throws(() => custodyMode({ WIKIDECK_CUSTODY_MODE: mode })); -}); - -test('custody requires a separate consent domain and rejects malformed monetary encodings', async t => { - const f = await fixture(t), c = f.envelope(buyer, { type: 'mock-credit' }); - assert.match(custodyMessage(c), /wikideck-custody-command-v1/); - for (const patch of [{ actor: seller.actor }, { chainId: 'f'.repeat(64) }, { custodianId: randomUUID() }, - { expiresAt: c.expiresAt + 1 }, { action: { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' } }]) { - assert.throws(() => f.market.execute({ ...c, ...patch })); - } - for (const value of ['0', '-1', '01', '1.0', '1e2', ' 1', '1\n', '999999999999999999999999999999999']) { - assert.equal(custodyCommandSchema.safeParse({ ...c, action: { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: value, maxFeeAtomicXmr: '0' } }).success, false); - } - assert.throws(() => f.market.execute({ ...c, mode: 'live' })); - assert.throws(() => f.market.execute({ ...c, action: { type: 'withdraw', destination: 'real-address', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' } })); - assert.equal(f.activity().availableAtomicXmr, '0'); -}); - -test('mock funding and replay receipts persist across restart and never credit twice', async t => { - const f = await fixture(t), c = f.envelope(buyer, { type: 'mock-credit' }); - const first = f.market.execute(c); - assert.deepEqual(f.market.execute(c), first); - assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC); - assert.throws(() => f.credit(), /already claimed/); - const changed = f.envelope(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' }, c.nonce); - assert.throws(() => f.market.execute(changed), /Nonce already used/); - await f.restart(); assert.deepEqual(f.market.execute(c), first); - assert.equal(f.market.audit().journals, 1); - f.advance(60001); assert.throws(() => f.market.execute(c), /expired/); -}); - -test('funded fixed-price reservation and blocked intent commit together with private activity', async t => { - const f = await fixture(t), l = f.listing(); - assert.throws(() => f.reserve(l), /Insufficient/); - assert.equal(f.market.browse().listings[0].status, 'open'); - assert.equal(f.activity().orders.length, 0); - f.credit(); const order = f.reserve(l); - assert.equal(order.status, 'awaiting-lock'); assert.equal(order.sellerAtomicXmr, '96500000000'); - assert.equal(order.authorAtomicXmr, '1000000000'); assert.equal(order.hostAtomicXmr, '2500000000'); - assert.equal(f.activity().availableAtomicXmr, '900000000000'); - assert.equal(f.activity().purchaseReservedAtomicXmr, l.priceAtomicXmr); - assert.equal(f.activity(seller).availableAtomicXmr, '0'); - assert.equal(f.activity(seller).orders.length, 1); assert.equal(f.activity(stranger).orders.length, 0); - assert.throws(() => f.execute(stranger, { type: 'cancel-reservation', orderId: order.id }), /Only the buyer/); - await f.restart(); - f.execute(buyer, { type: 'cancel-reservation', orderId: order.id }); - assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC); assert.equal(f.activity().purchaseReservedAtomicXmr, '0'); - assert.throws(() => f.market.settleFixture(order.id, evidence), /not eligible/); - assert.equal(f.market.audit().assetsAtomicXmr, MOCK_CREDIT_ATOMIC); -}); - -test('card changes, self-purchases and changed amounts cannot reserve funds', async t => { - const f = await fixture(t), l = f.listing(); f.credit(); - assert.throws(() => f.reserve(l, seller), /own card/); - assert.throws(() => f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: '1', feeBps: 250 }), /terms changed/); - f.changeCard(); assert.throws(() => f.reserve(l), /no longer available/); - assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC); -}); - -test('offline canonical fixture settles exactly once with conserved fees and one-atomic-unit prices', async t => { - const f = await fixture(t); f.credit(); const l = f.listing('1'), o = f.reserve(l); - assert.equal(o.sellerAtomicXmr, '1'); assert.equal(o.hostAtomicXmr, '0'); assert.equal(o.authorAtomicXmr, '0'); - assert.throws(() => f.market.settleFixture(o.id, 'rpc-confirmed'), /offline fixture/); - f.market.settleFixture(o.id, evidence); - assert.equal(f.activity(seller).availableAtomicXmr, '1'); - assert.equal(f.activity().availableAtomicXmr, '999999999999'); - await f.restart(); f.market.settleFixture(o.id, evidence); - assert.equal(f.activity(seller).availableAtomicXmr, '1'); - assert.throws(() => f.market.settleFixture(o.id, `fixture:${'b'.repeat(64)}`), /Conflicting/); - assert.throws(() => f.execute(buyer, { type: 'cancel-reservation', orderId: o.id }), /Only the buyer/); - assert.deepEqual(f.market.audit(), { journals: 3, assetsAtomicXmr: MOCK_CREDIT_ATOMIC, claimsAndRevenueAtomicXmr: MOCK_CREDIT_ATOMIC }); -}); - -test('competing signed reservations serialize and failed attempts leave no partial writes', async t => { - const f = await fixture(t), l = f.listing('800000000000'); f.credit(); f.credit(stranger); - const commands = [f.envelope(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 }), - f.envelope(stranger, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 })]; - const results = await Promise.allSettled(commands.map(c => Promise.resolve().then(() => f.market.execute(c)))); - assert.equal(results.filter(r => r.status === 'fulfilled').length, 1); - assert.equal(f.activity().orders.length + f.activity(stranger).orders.length, 1); - assert.equal(f.market.audit().journals, 3); - assert.throws(() => f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '300000000000', maxFeeAtomicXmr: '0' }), /Insufficient/); - assert.equal(f.activity().withdrawals.length, 0); -}); - -test('withdrawal cancellation releases maximum fee and prepared uncertainty blocks cancellation/replacement', async t => { - const f = await fixture(t); f.credit(); - const make = () => f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '100000000000', maxFeeAtomicXmr: '1000' }); - const cancelled = make(); f.execute(buyer, { type: 'cancel-withdrawal', withdrawalId: cancelled.id }); - assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC); - const w = make(), attempt = randomUUID(); - assert.throws(() => f.market.prepareWithdrawalFixture(w.id, attempt, tx, '1001'), /fee cap/); - assert.equal(f.activity().withdrawals[0].status, 'reserved'); - f.market.prepareWithdrawalFixture(w.id, attempt, tx, '400'); - await f.restart(); - assert.throws(() => f.execute(buyer, { type: 'cancel-withdrawal', withdrawalId: w.id }), /unprepared/); - assert.throws(() => f.market.prepareWithdrawalFixture(w.id, randomUUID(), tx, '400'), /different terms/); - assert.equal(f.market.prepareWithdrawalFixture(w.id, attempt, tx, '400').transactionId, tx); - assert.throws(() => f.market.confirmWithdrawalFixture(w.id, `fixture:${'e'.repeat(64)}`), /mismatch/); - f.market.confirmWithdrawalFixture(w.id, tx); f.market.confirmWithdrawalFixture(w.id, tx); - assert.equal(f.activity().availableAtomicXmr, '899999999600'); assert.equal(f.activity().withdrawalReservedAtomicXmr, '0'); - assert.equal(f.market.audit().assetsAtomicXmr, '899999999600'); -}); - -test('multiple connections cannot overspend a balance and cancelled commands do not double-release', async t => { - const f = await fixture(t); f.credit(); - const second = await CustodyMarket.open(f.dir, { feeBps: 250, now: () => 1_800_000_000_000, card: () => undefined }); - try { - const c = f.envelope(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '900000000000', maxFeeAtomicXmr: '0' }); - const w = second.execute(c) as CustodyWithdrawal; - assert.throws(() => f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '900000000000', maxFeeAtomicXmr: '0' }), /Insufficient/); - const cancel = f.envelope(buyer, { type: 'cancel-withdrawal', withdrawalId: w.id }); - f.market.execute(cancel); second.execute(cancel); - assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC); second.audit(); - } finally { second.close(); } -}); - -test('sandbox cap stops new work but never blocks release of existing unstarted holds', async t => { - const f = await fixture(t); f.credit(); - const w = f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '100', maxFeeAtomicXmr: '1' }); - const db = new DatabaseSync(join(f.dir, 'testnet', 'custody-sandbox.sqlite')); - try { - const insert = db.prepare('INSERT INTO receipts VALUES(?,?,?)'); - db.exec('BEGIN'); for (let i = 0; i < 10000; i++) insert.run(`fixture:${i}`, 'fixture', '{}'); db.exec('COMMIT'); - assert.throws(() => f.listing(), /budget/); - f.execute(buyer, { type: 'cancel-withdrawal', withdrawalId: w.id }); - assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC); - assert.throws(() => db.prepare("UPDATE entries SET debit='1'").run(), /Immutable/); - assert.throws(() => db.prepare('DELETE FROM journals').run(), /Immutable/); - db.prepare("UPDATE accounts SET balance='12' WHERE id=?").run(`customer:${buyer.actor}:available`); - assert.throws(() => f.market.audit(), /projection mismatch/); - } finally { db.close(); } -}); - -test('property-style repeated settlements conserve gross and never round up author/host fees', async t => { - const f = await fixture(t); f.credit(); - let spent = 0n, received = 0n; - for (let i = 1n; i <= 40n; i++) { - const price = i * 137n, o = f.reserve(f.listing(price.toString())); - f.market.settleFixture(o.id, `fixture:${i.toString(16).padStart(64, '0')}`); - assert.equal(BigInt(o.authorAtomicXmr), price / 100n); - assert.equal(BigInt(o.hostAtomicXmr), price * 250n / 10000n); - spent += price; received += BigInt(o.sellerAtomicXmr); - assert.equal(BigInt(f.activity().availableAtomicXmr) + spent, BigInt(MOCK_CREDIT_ATOMIC)); - assert.equal(BigInt(f.activity(seller).availableAtomicXmr), received); - assert.equal(f.market.audit().assetsAtomicXmr, MOCK_CREDIT_ATOMIC); - } -}); diff --git a/tests/market.test.ts b/tests/market.test.ts deleted file mode 100755 index 41cc227..0000000 --- a/tests/market.test.ts +++ /dev/null @@ -1,771 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { generateKeyPairSync, randomUUID, sign } from 'node:crypto'; -import { mkdir, mkdtemp, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { DatabaseSync } from 'node:sqlite'; -import { Market, marketConfig } from '../server/market/store.ts'; -import { mockQuote, paymentMode } from '../server/market/payments.ts'; -import { ADDITIONAL_MARKET_FEE, marketMessage, parseUsd, type MarketAction } from '../shared/market.ts'; -import { marketActionV3Schema, marketMessageV3, type MarketActionV3, type MarketActivityV3, - type MarketListingV3, type MarketOrderV3 } from '../shared/market-v3.ts'; -import { NETWORK, nftTransferMessage, testnetOwnerOf, transactionCardIds, type NftTransfer, type TestnetSnapshot, type TestnetBlock } from '../shared/testnet.ts'; -import type { CanonicalEvent } from '../server/testnet/canonical-index.ts'; -import type { MarketPolicy } from '../server/market/policy.ts'; -import { address, CHAIN_ID, GENESIS_HASH, transferId, verifySignature } from '../server/testnet/consensus.ts'; - -function identity() { - const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); - const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); - return { publicKey, actor: address(publicKey), signed: (text: string) => sign('sha256', Buffer.from(text), - { key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') }; -} -const alice = identity(), bob = identity(), carol = identity(); -// Unit-test chain boundary. Production always uses Ledger + reference RandomX; no selectable fake PoW. -function chainFixture() { - const mint = { tokenId: 'a'.repeat(64), height: 1, owner: alice.actor, - article: { title: 'A collector’s discovery', image: 'https://upload.wikimedia.org/test.jpg' }, transactions: [] } as unknown as TestnetBlock; - const state: TestnetSnapshot = { network: NETWORK, chainId: CHAIN_ID, genesis: GENESIS_HASH, - tip: 'b'.repeat(64), blocks: [mint, { ...mint, tokenId: 'c'.repeat(64), height: 2 }], - transfers: [], pendingTransfers: [], peers: 0, algorithm: 'RandomX', maturity: 2, cumulativeWork: '2' }; - return { state, snapshot: () => structuredClone(state), submitTransfer: async (raw: unknown) => { - const tx = raw as NftTransfer; - assert.ok(verifySignature(tx.publicKey, tx.from, nftTransferMessage(tx), tx.signature)); - const id = transferId(tx); - if (!state.pendingTransfers.some(t => transferId(t as NftTransfer) === id)) state.pendingTransfers.push(tx); - return id; - } }; -} -async function fixture(t: { after(fn: () => Promise): void }, indexed = false, useWorker = false, workerLedger = false) { - const dir = await mkdtemp(join(tmpdir(), 'wikideck-market-')); - const chain = chainFixture(); - // Test-only indexed adapter: production indexes/events are exercised separately by Ledger tests. - const fEvents: CanonicalEvent[] = []; - const streamId = randomUUID(); - const ledgerFile = join(dir, 'testnet', 'ledger.sqlite'); - if (workerLedger) { - await mkdir(join(dir, 'testnet'), { recursive: true }); - const ledgerDb = new DatabaseSync(ledgerFile); - ledgerDb.exec(`PRAGMA journal_mode=WAL; CREATE TABLE blocks(hash TEXT PRIMARY KEY,block_json TEXT NOT NULL); - CREATE TABLE canonical_blocks(height INTEGER PRIMARY KEY,hash TEXT NOT NULL); - CREATE TABLE canonical_nfts(token TEXT PRIMARY KEY,owner TEXT NOT NULL,sequence INTEGER NOT NULL,minted_height INTEGER NOT NULL,block_hash TEXT NOT NULL); - CREATE TABLE canonical_transactions(id TEXT PRIMARY KEY,height INTEGER NOT NULL,block_hash TEXT NOT NULL); - CREATE TABLE canonical_events(sequence INTEGER PRIMARY KEY,type TEXT NOT NULL); - CREATE TABLE pending_transfers(position INTEGER PRIMARY KEY,id TEXT NOT NULL,transaction_json TEXT NOT NULL);`); - const insertBlock = ledgerDb.prepare('INSERT INTO blocks VALUES(?,?)'); - const insertCanonicalBlock = ledgerDb.prepare('INSERT INTO canonical_blocks VALUES(?,?)'); - const insertNft = ledgerDb.prepare('INSERT INTO canonical_nfts VALUES(?,?,?,?,?)'); - chain.state.blocks.forEach((block, i) => { - const hash = i === chain.state.blocks.length - 1 ? chain.state.tip : (i + 1).toString(16).padStart(64, '0'); - insertBlock.run(hash, JSON.stringify(block)); insertCanonicalBlock.run(block.height, hash); - insertNft.run(block.tokenId, alice.actor, 0, block.height, hash); - }); - ledgerDb.close(); - } - const syncWorkerEvents = () => { - if (!workerLedger) return; - const ledgerDb = new DatabaseSync(ledgerFile); - try { - ledgerDb.exec('BEGIN IMMEDIATE'); ledgerDb.exec('DELETE FROM canonical_events'); - const insert = ledgerDb.prepare('INSERT INTO canonical_events(sequence,type) VALUES(?,?)'); - fEvents.forEach(event => insert.run(event.sequence, event.type)); ledgerDb.exec('COMMIT'); - } finally { ledgerDb.close(); } - }; - const adapter = indexed ? { ...chain, - snapshot: (): TestnetSnapshot => { throw new Error('Indexed market must not request a full chain snapshot'); }, - summary: () => ({ tip: chain.state.tip, revision: fEvents.length, height: chain.state.blocks.length }), - pendingTransactions: () => structuredClone(chain.state.pendingTransfers), - nft: (token: string) => { - const b = chain.state.blocks.find(b => b.tokenId === token); - return b ? { block: b, owner: testnetOwnerOf(chain.state, token)!, - sequence: chain.state.transfers.filter(tx => transactionCardIds(tx).includes(token)).length, - confirmations: chain.state.blocks.length + 1 - b.height } : undefined; - }, - canonicalTransaction: (id: string) => { - const b = chain.state.blocks.find(b => b.transactions.some(tx => transferId(tx as NftTransfer) === id)); - return b ? { height: b.height, blockHash: b.hash ?? 'f'.repeat(64) } : undefined; - }, - canonicalHead: () => fEvents.length, - canonicalEvents: (after: number, limit = 64) => { - if (after > fEvents.length) throw new Error('Canonical cursor ahead of restored ledger'); - const page = fEvents.slice(after, after + limit), cursor = page.at(-1)?.sequence ?? after; - return { streamId, previousAnchor: after ? fEvents[after - 1].eventHash : GENESIS_HASH, - anchor: page.at(-1)?.eventHash ?? (after ? fEvents[after - 1].eventHash : GENESIS_HASH), - events: page, cursor, head: fEvents.length, caughtUp: cursor === fEvents.length }; - }, - ...(workerLedger ? { reconciliationDatabasePath: () => ledgerFile } : {}), - } : chain; - let time = 1_800_000_000_000; - let market = await Market.open(dir, adapter, { feeBps: 250, confirmations: 2 }, () => time, { worker: useWorker }); - t.after(async () => { await market.close(); await rm(dir, { recursive: true, force: true }); }); - const envelope = (who: typeof alice, action: MarketAction) => { - const terms = { network: NETWORK, chainId: CHAIN_ID, marketId: market.id, actor: who.actor, - publicKey: who.publicKey, nonce: randomUUID(), expiresAt: time + 60000, action }; - return { ...terms, signature: who.signed(marketMessage(terms)) }; - }; - const atomicFromCents = (cents: number) => ((BigInt(cents) * 1_000_000_000_000n + 14999n) / 15000n).toString(); - const toV3 = (action: MarketAction): MarketActionV3 => { - if (action.type === 'list') return { type: 'list', cardHash: action.cardHash, sequence: action.sequence, - kind: action.kind, priceAtomicXmr: atomicFromCents(action.priceCents), endsAt: action.endsAt, feeBps: action.feeBps }; - if (action.type === 'bid') return { type: 'bid', listingId: action.listingId, priceAtomicXmr: atomicFromCents(action.priceCents) }; - if (action.type === 'buy') return { type: 'buy', listingId: action.listingId, - priceAtomicXmr: atomicFromCents(action.priceCents), feeBps: action.feeBps }; - return action; - }; - const envelopeV3 = (who: typeof alice, action: MarketActionV3) => { - const terms = { version: 3 as const, network: NETWORK, chainId: CHAIN_ID, marketId: market.id, actor: who.actor, - publicKey: who.publicKey, nonce: randomUUID(), expiresAt: time + 60000, action }; - return { ...terms, signature: who.signed(marketMessageV3(terms)) }; - }; - const run = (who: typeof alice, action: MarketAction) => - market.execute(envelopeV3(who, toV3(action))) as Promise; - const list = (kind: 'fixed' | 'auction' = 'fixed') => run(alice, { type: 'list', cardHash: chain.state.blocks[0].tokenId, - sequence: 0, kind, priceCents: 1500, endsAt: kind === 'auction' ? time + 60000 : null, feeBps: 250 }); - const buy = (l: MarketListingV3, who = bob, priceCents = 1500) => run(who, - { type: 'buy', listingId: l.id, priceCents, feeBps: l.feeBps }); - const orders = async (who = bob) => (await run(who, { type: 'activity' })).orders; - const delivery = (o: Pick, to = bob) => { - const terms = { network: NETWORK, chainId: CHAIN_ID, cardHash: o.cardHash, from: alice.actor, to: to.actor, sequence: o.sequence }; - return { ...terms, publicKey: alice.publicKey, signature: alice.signed(nftTransferMessage(terms)) }; - }; - const confirm = (tx: NftTransfer) => { - const height = chain.state.blocks.length + 1; - chain.state.blocks.push({ ...chain.state.blocks[0], height, transactions: [tx] }); - chain.state.transfers.push(tx); chain.state.pendingTransfers = []; - }; - const events = fEvents; - return { dir, chain, events, syncWorkerEvents, ledgerFile, envelope, envelopeV3, run, list, buy, orders, delivery, confirm, - get market() { return market; }, get now() { return time; }, advance: (ms: number) => { time += ms; }, - restart: async (policy: MarketPolicy = {}) => { await market.close(); market = await Market.open(dir, adapter, - { feeBps: 500, confirmations: 5, ...policy }, () => time, { worker: useWorker }); } }; -} - -async function drainWorkerReconciliation(f: Awaited>, maxPasses = 2000): Promise { - for (let pass = 0; pass < maxPasses; pass++) { - f.market.browse(); - if (!f.market.hasReconciliationBacklog() && !f.market.isReconciliationWorkerBusy()) return; - await new Promise(resolve => setTimeout(resolve, 1)); - } - assert.fail(`Worker reconciliation did not drain within the bounded test wait: ${JSON.stringify(f.market.diagnostics())}`); -} -test('mock market switches block new listings/bids/checkout while allowing paid-order delivery and private activity', async t => { - const f = await fixture(t), l = await f.list(), o = await f.buy(l); - await f.run(bob, { type: 'mock-pay', orderId: o.id }); - await f.restart({ marketEnabled: false }); - assert.equal(f.market.info().marketEnabled, false); assert.deepEqual(f.market.browse().listings, []); - await assert.rejects(f.list(), /disabled/); - assert.equal((await f.orders())[0].id, o.id); - const tx = f.delivery(o); await f.run(alice, { type: 'deliver', orderId: o.id, transfer: tx }); - assert.equal(f.chain.state.pendingTransfers.length, 1); -}); - -test('indexed market uses bounded durable event catch-up and never requests full snapshots', async t => { - const f = await fixture(t, true); - for (let i = 1; i <= 130; i++) f.events.push({ sequence: i, type: 'connect', height: i, - hash: 'd'.repeat(64), previousHash: 'e'.repeat(64), transactionIds: [], eventHash: i.toString(16).padStart(64, '0') }); - await assert.rejects(f.list(), /catching up/); - assert.equal(f.market.diagnostics().canonicalEventCursor, 64); - f.market.browse(); assert.equal(f.market.diagnostics().canonicalEventCursor, 128); - const l = await f.list(); assert.ok(l.id); - assert.equal(f.market.diagnostics().canonicalEventCursor, 130); - assert.equal(f.market.diagnostics().eventBacklog, false); - await f.restart(); assert.equal(f.market.diagnostics().canonicalEventCursor, 130); - f.market.browse(); assert.equal(f.market.diagnostics().canonicalEventCursor, 130); - const anchor = f.events[129].eventHash; - f.events[129].eventHash = 'f'.repeat(64); f.advance(16_000); - assert.throws(() => f.market.browse(), /history changed/); - f.events[129].eventHash = anchor; - f.events.length = 0; - assert.throws(() => f.market.browse(), /cursor ahead/); -}); - -test('market reconciliation keyset progress is bounded, durable and resumes after restart', async t => { - const f = await fixture(t, true), db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); - const ids = Array.from({ length: 260 }, (_, i) => `00000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`); - try { - const insertListing = db.prepare('INSERT INTO listings VALUES(?,?,?,?)'); - const insertOrder = db.prepare('INSERT INTO orders VALUES(?,?)'); - for (const [i, id] of ids.entries()) { - const listingId = `10000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`; - const cardHash = (i + 1).toString(16).padStart(64, '0'); - const target = i === ids.length - 1; - insertListing.run(listingId, cardHash, target ? 'sold' : 'cancelled', JSON.stringify({ id: listingId, seller: alice.actor, - cardHash, sequence: 0, status: target ? 'sold' : 'cancelled', kind: 'fixed' })); - const order = target ? { id, termsVersion: 3, listingId, seller: alice.actor, buyer: bob.actor, cardHash, sequence: 0, - priceAtomicXmr: '100000000000', feeBps: 250, createdAt: f.now, payBy: f.now + 1000, deliverBy: f.now + 2000, - status: 'complete', payout: 'mock-paid', deliveryConfirmations: 2, requiredConfirmations: 2, transferId: '0'.repeat(64) } - : { id, listingId, seller: alice.actor, buyer: bob.actor, cardHash, sequence: 0, status: 'refunded', createdAt: f.now }; - insertOrder.run(id, JSON.stringify(order)); - } - } finally { db.close(); } - const disconnectedTx = transferId({ network: NETWORK, chainId: CHAIN_ID, - cardHash: (ids.length).toString(16).padStart(64, '0'), from: alice.actor, to: bob.actor, sequence: 0, publicKey: '', signature: '' }); - f.events.push({ sequence: 1, type: 'disconnect', height: 1, hash: 'd'.repeat(64), previousHash: GENESIS_HASH, - transactionIds: [disconnectedTx], eventHash: 'e'.repeat(64) }); - - // First browse starts reconciliation and visits only the first fixed-size keyset page. - f.market.browse(); - const progress = () => { - const conn = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); - try { return JSON.parse((conn.prepare('SELECT value FROM market_meta WHERE key=?').get('canonical_reconciliation_job') as { value: string }).value); } - finally { conn.close(); } - }; - assert.equal(progress().stage, 'orders'); assert.equal(progress().afterId, ids[31]); - assert.equal(f.market.hasReconciliationBacklog(), true); - await assert.rejects(f.list(), /catching up/); // Second 32-order batch; no new listing is admitted. - assert.equal(progress().stage, 'orders'); assert.equal(progress().afterId, ids[63]); - // The ledger advances while this durable multi-batch job is in flight. - f.events.push({ sequence: 2, type: 'connect', height: 2, hash: 'f'.repeat(64), previousHash: 'd'.repeat(64), - transactionIds: [], eventHash: '2'.repeat(64) }); - f.market.browse(); // Tip/revision changed: rewind and bind the updated event page. - assert.equal(progress().eventPage.cursor, 2); - assert.equal(progress().afterId, ids[31]); - const tailStatus = () => { - const conn = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); - try { return JSON.parse((conn.prepare('SELECT data FROM orders WHERE id=?').get(ids[259]) as { data: string }).data).status; } - finally { conn.close(); } - }; - for (let i = 0; i < 12 && tailStatus() !== 'review'; i++) f.market.browse(); - assert.equal(tailStatus(), 'review'); // Disconnect reached the tail order before checkpoint advancement. - assert.equal(f.market.diagnostics().canonicalEventCursor, 0); - assert.equal(f.market.hasReconciliationBacklog(), true, 'new events appended during job must stay behind the work fence'); - const reviewDb = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); - try { - const row = reviewDb.prepare('SELECT data FROM orders WHERE id=?').get(ids[259]) as { data: string }; - assert.equal(JSON.parse(row.data).status, 'review'); // Disconnect reached the tail batch before the cursor advances. - } finally { reviewDb.close(); } - assert.equal(f.market.diagnostics().canonicalEventCursor, 0); - - // Simulate process death after committed batch progress, then resume using the SQLite job cursor. - await f.restart(); assert.equal(f.market.diagnostics().eventBacklog, true); - for (let i = 0; i < 24 && f.market.hasReconciliationBacklog(); i++) f.market.browse(); - assert.equal(f.market.hasReconciliationBacklog(), false); - const conn = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); - try { assert.equal(conn.prepare('SELECT 1 FROM market_meta WHERE key=?').get('canonical_reconciliation_job'), undefined); } - finally { conn.close(); } - assert.equal(f.market.diagnostics().canonicalEventCursor, 2); -}); - -test('bounded disconnect review batch commits before checkpoint advance and resumes after restart', async t => { - const f = await fixture(t, true), listing = await f.list(), order = await f.buy(listing); - await f.run(bob, { type: 'mock-pay', orderId: order.id }); - const delivery = f.delivery(order); f.confirm(delivery); - f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: f.chain.state.blocks.length + 1 }); - f.events.push({ sequence: 1, type: 'connect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64), transactionIds: [transferId(delivery)], eventHash: '1'.repeat(64) }); - const initiallyCompleted = (await f.orders())[0]; - assert.equal(initiallyCompleted.status, 'complete', JSON.stringify({ order: initiallyCompleted, - job: new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')).prepare('SELECT value FROM market_meta WHERE key=?').get('canonical_reconciliation_job') })); - // The same delivery may already be canonical again; the intermediate disconnect must not be lost. - f.events.push({ sequence: 2, type: 'disconnect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64), transactionIds: [transferId(delivery)], eventHash: '2'.repeat(64) }); - const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); - try { - db.exec("CREATE TRIGGER fail_cursor BEFORE UPDATE ON market_meta WHEN NEW.key='canonical_event_cursor' BEGIN SELECT RAISE(ABORT,'injected cursor failure'); END"); - assert.throws(() => f.market.browse(), /injected cursor failure/); - const persisted = db.prepare('SELECT data FROM orders WHERE id=?').get(order.id) as { data: string }; - assert.equal(JSON.parse(persisted.data).status, 'review'); - const job = JSON.parse((db.prepare("SELECT value FROM market_meta WHERE key='canonical_reconciliation_job'").get() as { value: string }).value); - assert.equal(job.stage, 'listings'); // Order effects and work cursor committed atomically; event anchor still has not advanced. - assert.equal(f.market.diagnostics().canonicalEventCursor, 1); - db.exec('DROP TRIGGER fail_cursor'); - } finally { db.close(); } - await f.restart(); - const reviewed = (await f.orders())[0]; - assert.equal(reviewed.status, 'review'); assert.equal(reviewed.payout, 'review'); - assert.equal(f.market.diagnostics().canonicalEventCursor, 2); - await f.restart(); assert.equal((await f.orders())[0].status, 'review'); -}); - -test('market reconciliation batch failure rolls back order effects and resumes from durable keyset', async t => { - const f = await fixture(t, true), listing = await f.list(), order = await f.buy(listing); - await f.run(bob, { type: 'mock-pay', orderId: order.id }); - const delivery = f.delivery(order); f.confirm(delivery); - f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] }); - f.events.push({ sequence: 1, type: 'connect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64), - transactionIds: [transferId(delivery)], eventHash: '1'.repeat(64) }); - assert.equal((await f.orders())[0].status, 'complete'); - f.events.push({ sequence: 2, type: 'disconnect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64), - transactionIds: [transferId(delivery)], eventHash: '2'.repeat(64) }); - const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); - try { - db.exec("CREATE TRIGGER fail_review_batch BEFORE UPDATE ON orders WHEN json_extract(NEW.data,'$.status')='review' BEGIN SELECT RAISE(ABORT,'injected reconciliation batch failure'); END"); - assert.throws(() => f.market.browse(), /injected reconciliation batch failure/); - const row = db.prepare('SELECT data FROM orders WHERE id=?').get(order.id) as { data: string }; - assert.equal(JSON.parse(row.data).status, 'complete'); - const job = JSON.parse((db.prepare("SELECT value FROM market_meta WHERE key='canonical_reconciliation_job'").get() as { value: string }).value); - assert.equal(job.stage, 'orders'); assert.equal(job.afterId, null); - assert.equal(f.market.diagnostics().canonicalEventCursor, 1); - db.exec('DROP TRIGGER fail_review_batch'); - } finally { db.close(); } - await f.restart(); - assert.equal((await f.orders())[0].status, 'review'); - assert.equal(f.market.diagnostics().canonicalEventCursor, 2); - assert.equal(f.market.hasReconciliationBacklog(), false); -}); - -test('worker-backed reconciliation commits reorg holds atomically and resumes after restart', async t => { - const f = await fixture(t, true, true); - await drainWorkerReconciliation(f); - const listing = await f.list(), order = await f.buy(listing); - await f.run(bob, { type: 'mock-pay', orderId: order.id }); - const delivery = f.delivery(order); f.confirm(delivery); - f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] }); - f.events.push({ sequence: 1, type: 'connect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64), - transactionIds: [transferId(delivery)], eventHash: '1'.repeat(64) }); - await f.orders(); await drainWorkerReconciliation(f); - assert.equal((await f.orders())[0].status, 'complete'); - - f.events.push({ sequence: 2, type: 'disconnect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64), - transactionIds: [transferId(delivery)], eventHash: '2'.repeat(64) }); - const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); - try { - db.exec("CREATE TRIGGER fail_worker_review BEFORE UPDATE ON orders WHEN json_extract(NEW.data,'$.status')='review' BEGIN SELECT RAISE(ABORT,'injected worker batch failure'); END"); - } finally { db.close(); } - f.market.browse(); - assert.equal(f.market.isReconciliationWorkerBusy(), true); - assert.equal(f.market.diagnostics().eventBacklog, true); - for (let i = 0; i < 100 && (f.market.isReconciliationWorkerBusy() || !f.market.diagnostics().reconciliationWorkerFailed); i++) { - await new Promise(resolve => setTimeout(resolve, 1)); - } - assert.equal(f.market.diagnostics().reconciliationWorkerFailed, true); - assert.equal(f.market.isReconciliationWorkerBusy(), false); - await assert.rejects(f.list(), /catching up/); // Do not admit new business while the worker commit is uncertain. - assert.equal(f.market.isReconciliationWorkerBusy(), false); // Failure stays paused instead of hot-loop retrying. - const check = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); - try { - assert.equal(JSON.parse((check.prepare('SELECT data FROM orders WHERE id=?').get(order.id) as { data: string }).data).status, 'complete'); - assert.equal(f.market.diagnostics().canonicalEventCursor, 1); - check.exec('DROP TRIGGER fail_worker_review'); - } finally { check.close(); } - - await f.restart(); - await drainWorkerReconciliation(f); - const reviewed = (await f.orders())[0]; - assert.equal(reviewed.status, 'review'); assert.equal(reviewed.payout, 'review'); - assert.equal(f.market.diagnostics().canonicalEventCursor, 2); - assert.equal(f.market.hasReconciliationBacklog(), false); -}); - -test('worker reconciliation re-fences a canonical event appended during an in-flight batch', async t => { - const f = await fixture(t, true, true), db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); - const ids = Array.from({ length: 70 }, (_, i) => `20000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`); - try { - const insertListing = db.prepare('INSERT INTO listings VALUES(?,?,?,?)'); - const insertOrder = db.prepare('INSERT INTO orders VALUES(?,?)'); - for (const [i, id] of ids.entries()) { - const listingId = `30000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`; - const cardHash = (i + 1).toString(16).padStart(64, '0'); - insertListing.run(listingId, cardHash, 'cancelled', JSON.stringify({ id: listingId, cardHash, seller: alice.actor, - sequence: 0, kind: 'fixed', status: 'cancelled' })); - insertOrder.run(id, JSON.stringify({ id, listingId, seller: alice.actor, buyer: bob.actor, cardHash, - sequence: 0, status: 'refunded' })); - } - } finally { db.close(); } - f.events.push({ sequence: 1, type: 'connect', height: 1, hash: 'd'.repeat(64), previousHash: GENESIS_HASH, - transactionIds: [], eventHash: '1'.repeat(64) }); - f.market.browse(); - assert.equal(f.market.isReconciliationWorkerBusy(), true); - f.events.push({ sequence: 2, type: 'connect', height: 2, hash: 'e'.repeat(64), previousHash: 'd'.repeat(64), - transactionIds: [], eventHash: '2'.repeat(64) }); - await drainWorkerReconciliation(f, 100); - assert.equal(f.market.diagnostics().canonicalEventCursor, 2); - assert.equal(f.market.hasReconciliationBacklog(), false); -}); - -test('worker reads canonical facts from a separate read-only Ledger snapshot and re-fences event appends', async t => { - const f = await fixture(t, true, true, true), db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); - const soldCard = 'a'.repeat(64), deliveryId = transferId({ network: NETWORK, chainId: CHAIN_ID, cardHash: soldCard, - from: alice.actor, to: bob.actor, sequence: 0, publicKey: '', signature: '' }); - try { - const insertListing = db.prepare('INSERT INTO listings VALUES(?,?,?,?)'); - const insertOrder = db.prepare('INSERT INTO orders VALUES(?,?)'); - for (let i = 0; i < 70; i++) { - const id = `40000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`; - const listingId = `50000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`; - const active = i === 0, cardHash = active ? soldCard : (i + 1).toString(16).padStart(64, '0'); - const listingStatus = active ? 'reserved' : 'cancelled'; - insertListing.run(listingId, cardHash, listingStatus, JSON.stringify({ id: listingId, cardHash, seller: alice.actor, - sequence: 0, kind: 'fixed', status: listingStatus })); - insertOrder.run(id, JSON.stringify({ id, listingId, seller: alice.actor, buyer: bob.actor, cardHash, - sequence: 0, status: active ? 'paid' : 'refunded', payout: active ? 'not-started' : undefined, - createdAt: f.now, payBy: f.now + 1000, deliverBy: f.now + 2000, - requiredConfirmations: 2, deliveryConfirmations: 0 })); - } - } finally { db.close(); } - const ledgerDb = new DatabaseSync(f.ledgerFile); - try { - ledgerDb.prepare('UPDATE canonical_nfts SET owner=?,sequence=1 WHERE token=?').run(bob.actor, soldCard); - ledgerDb.prepare('INSERT INTO canonical_transactions VALUES(?,?,?)').run(deliveryId, 1, 'b'.repeat(64)); - } finally { ledgerDb.close(); } - f.events.push({ sequence: 1, type: 'connect', height: 1, hash: 'd'.repeat(64), previousHash: GENESIS_HASH, - transactionIds: [deliveryId], eventHash: '1'.repeat(64) }); - f.syncWorkerEvents(); - f.market.browse(); - assert.equal(f.market.isReconciliationWorkerBusy(), true); - f.events.push({ sequence: 2, type: 'connect', height: 2, hash: 'e'.repeat(64), previousHash: 'd'.repeat(64), - transactionIds: [], eventHash: '2'.repeat(64) }); - f.syncWorkerEvents(); - await drainWorkerReconciliation(f, 100); - assert.equal(f.market.diagnostics().canonicalEventCursor, 2); - assert.equal(f.market.hasReconciliationBacklog(), false); - const orders = await f.orders(); - const completed = orders.find(order => order.cardHash === soldCard); - assert.ok(completed); - assert.equal(completed.status, 'complete'); assert.equal(completed.payout, 'mock-paid'); - const verifyDb = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); - try { - assert.equal((verifyDb.prepare('SELECT status FROM listings WHERE id=?').get(completed.listingId) as { status: string }).status, 'sold'); - } finally { verifyDb.close(); } -}); - -test('worker reconciliation yields to event-loop timers during a large durable backlog', async t => { - const f = await fixture(t, true, true, true), db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); - try { - const insertListing = db.prepare('INSERT INTO listings VALUES(?,?,?,?)'); - const insertOrder = db.prepare('INSERT INTO orders VALUES(?,?)'); - db.exec('BEGIN IMMEDIATE'); - try { - for (let i = 0; i < 3000; i++) { - const id = `60000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`; - const listingId = `70000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`; - const cardHash = (i + 1).toString(16).padStart(64, '0'); - const listing = { id: listingId, cardHash, seller: alice.actor, sequence: 0, kind: 'fixed', status: 'cancelled' }; - insertListing.run(listingId, cardHash, 'cancelled', JSON.stringify(listing)); - insertOrder.run(id, JSON.stringify({ id, listingId, seller: alice.actor, buyer: bob.actor, - cardHash, sequence: 0, status: 'refunded' })); - } - db.exec('COMMIT'); - } catch (error) { db.exec('ROLLBACK'); throw error; } - } finally { db.close(); } - f.market.browse(); - assert.equal(f.market.isReconciliationWorkerBusy(), true); - let timerRan = false; - await new Promise(resolve => setTimeout(() => { timerRan = true; resolve(); }, 0)); - assert.equal(timerRan, true); - assert.equal(f.market.isReconciliationWorkerBusy(), true, - 'large reconciliation should still be in flight while the main-loop timer has run'); -}); - -test('USD and mock XMR accounting use bounded integer amounts and exact fee splitting', () => { - assert.equal(parseUsd('15.01'), 1501); assert.equal(parseUsd('0.01'), 1); - for (const invalid of ['0', '-1', '1e3', '1.001', ' 1', 'NaN', '1000001', '01.00']) assert.throws(() => parseUsd(invalid)); - const quote = mockQuote(1500, 250, 15000); - assert.equal(quote.invoice.amountXmr, '0.100000000000'); - assert.equal(quote.feeXmr, '0.002500000000'); - assert.equal(quote.sellerNetXmr, '0.096500000000'); - assert.deepEqual(quote.additionalFee, { bps: 100, - address: '44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv', - amountXmr: '0.001000000000', payout: 'not-started' }); - assert.equal(quote.invoice.address, null); - assert.equal(mockQuote(1, 0, 3).invoice.amountXmr, '0.333333333334'); - assert.equal(paymentMode({}), 'mock'); - for (const mode of ['live','processor','testnet','']) assert.throws(() => paymentMode({ WIKIDECK_PAYMENT_MODE: mode })); - for (const value of ['-1','5001','250.5','x','']) assert.throws(() => marketConfig({ WIKIDECK_MARKET_FEE_BPS: value })); - assert.equal(marketConfig({ WIKIDECK_MARKET_FEE_BPS: '0' }).feeBps, 0); - const noHostingFee = mockQuote(1500, 0, 15000); - assert.equal(noHostingFee.additionalFee.amountXmr, '0.001000000000'); - assert.equal(noHostingFee.sellerNetXmr, '0.099000000000'); - const atomic = (text: string) => BigInt(text.replace('.', '')); - for (const cents of [1, 19, 1500, 100_000_000]) for (const bps of [0, 250, 5000]) { - const q = mockQuote(cents, bps, 19999); - const gross = atomic(q.invoice.amountXmr); - assert.equal(atomic(q.additionalFee.amountXmr), gross * 100n / 10000n); - assert.equal(atomic(q.feeXmr) + atomic(q.additionalFee.amountXmr) + atomic(q.sellerNetXmr), gross); - assert.ok(atomic(q.sellerNetXmr) >= 0n); - } -}); - -test('versioned market commands bind actor, instance, network, expiry, currency, and every price term', async t => { - const f = await fixture(t); - const original = f.envelopeV3(alice, { type: 'list', cardHash: f.chain.state.blocks[0].tokenId, sequence: 0, - kind: 'fixed', priceAtomicXmr: '100000000000', endsAt: null, feeBps: 250 }); - for (const bad of [ - { ...original, actor: bob.actor }, { ...original, marketId: randomUUID() }, - { ...original, chainId: '0'.repeat(64) }, { ...original, expiresAt: f.now - 1 }, - { ...original, expiresAt: f.now + 600_000 }, - { ...original, action: { ...original.action, priceAtomicXmr: '1' } }, - ]) await assert.rejects(async () => f.market.execute(bad), /signature/); - await assert.rejects(async () => f.market.execute({ ...original, network: 'wrong-network' })); - const first = await f.market.execute(original); - const message = JSON.parse(marketMessageV3(original)); - assert.equal(message[1], 'market-command-v3'); - assert.deepEqual(message.at(-1), [100, ADDITIONAL_MARKET_FEE.address]); - message[message.length - 1] = [0, ADDITIONAL_MARKET_FEE.address]; - await assert.rejects(async () => f.market.execute({ ...original, signature: alice.signed(JSON.stringify(message)) }), /signature/); - assert.deepEqual(await f.market.execute(original), first); - const changed = { ...original, action: { ...original.action, priceAtomicXmr: '200000000000' } }; - await assert.rejects(async () => f.market.execute({ ...changed, signature: alice.signed(marketMessageV3(changed)) }), /Nonce/); - const legacy = f.envelope(alice, { type: 'list', cardHash: f.chain.state.blocks[1].tokenId, sequence: 0, - kind: 'fixed', priceCents: 1500, endsAt: null, feeBps: 250 }); - assert.equal(JSON.parse(marketMessage(legacy))[1], 'market-command-v2'); - await assert.rejects(f.market.execute(legacy), /USD listing terms are retired/); - assert.equal(f.market.browse().listings.length, 1); -}); - -test('legacy USD listings are read-only until seller retires them with v3 authorization', async t => { - const f = await fixture(t), cardHash = f.chain.state.blocks[0].tokenId, id = randomUUID(); - const legacy = { id, cardHash, sequence: 0, seller: alice.actor, title: 'Historical USD listing', - image: 'https://upload.wikimedia.org/test.jpg', kind: 'fixed' as const, priceCents: 1500, - feeBps: 250, createdAt: f.now, endsAt: null, status: 'open' as const, highestBid: null }; - const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); - try { db.prepare('INSERT INTO listings VALUES(?,?,?,?)').run(id, cardHash, 'open', JSON.stringify(legacy)); } - finally { db.close(); } - const activity = await f.run(alice, { type: 'activity' }); - assert.equal(activity.listings[0].id, id); - assert.equal((activity.listings[0] as typeof legacy).priceCents, 1500); - assert.equal(activity.listings[0].status, 'invalid'); - await assert.rejects(f.run(bob, { type: 'buy', listingId: id, priceCents: 1500, feeBps: 250 }), /Legacy USD listings are read-only/); - await assert.rejects(f.run(alice, { type: 'bid', listingId: id, priceCents: 1500 }), /Legacy USD listings are read-only/); - const retired = await f.market.execute(f.envelopeV3(alice, { type: 'retire-legacy-listing', listingId: id })) as MarketListingV3; - assert.equal(retired.status, 'cancelled'); - assert.equal((retired as unknown as typeof legacy).priceCents, 1500); -}); - -test('legacy reserved USD orders retain signed terms across current-XMR upgrade and restart', async t => { - const f = await fixture(t), listingId = randomUUID(), orderId = randomUUID(), cardHash = f.chain.state.blocks[0].tokenId; - const listing = { id: listingId, cardHash, sequence: 0, seller: alice.actor, title: 'Legacy in-flight order', - image: 'https://upload.wikimedia.org/test.jpg', kind: 'fixed' as const, priceCents: 1500, feeBps: 250, - createdAt: f.now, endsAt: null, status: 'reserved' as const, highestBid: null, - additionalFee: { bps: 100, address: ADDITIONAL_MARKET_FEE.address } }; - const quote = mockQuote(1500, 250, 15000); - const order = { id: orderId, listingId, seller: alice.actor, buyer: bob.actor, cardHash, sequence: 0, - priceCents: 1500, feeBps: 250, createdAt: f.now, payBy: f.now + 30 * 60_000, - deliverBy: f.now + 30 * 60_000 + 48 * 60 * 60_000, status: 'awaiting-payment' as const, - ...quote, payout: 'not-started' as const, deliveryConfirmations: 0, requiredConfirmations: 2 }; - const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); - try { - db.prepare('INSERT INTO listings VALUES(?,?,?,?)').run(listingId, cardHash, 'reserved', JSON.stringify(listing)); - db.prepare('INSERT INTO orders VALUES(?,?)').run(orderId, JSON.stringify(order)); - } finally { db.close(); } - await assert.rejects(f.run(bob, { type: 'mock-pay', orderId }), /Legacy USD orders keep their original terms/); - const historical = await f.market.execute(f.envelope(bob, { type: 'mock-pay', orderId })) as typeof order; - assert.equal(historical.status, 'paid'); - assert.equal(historical.priceCents, 1500); - assert.equal(historical.invoice.amountXmr, '0.100000000000'); - await f.restart(); - const restored = (await f.run(bob, { type: 'activity' })).orders[0] as typeof order; - assert.equal(restored.priceCents, 1500); - assert.equal(restored.invoice.amountXmr, '0.100000000000'); - assert.equal(restored.status, 'paid'); -}); - -test('native-XMR listing and checkout accept one atomic unit without conversion or fee minimums', async t => { - const f = await fixture(t); - const listing = await f.market.execute(f.envelopeV3(alice, { type: 'list', cardHash: f.chain.state.blocks[0].tokenId, - sequence: 0, kind: 'fixed', priceAtomicXmr: '1', endsAt: null, feeBps: 250 })) as MarketListingV3; - assert.equal(listing.priceAtomicXmr, '1'); - const order = await f.market.execute(f.envelopeV3(bob, { type: 'buy', listingId: listing.id, - priceAtomicXmr: '1', feeBps: 250 })) as MarketOrderV3; - assert.equal(order.invoice.amountXmr, '0.000000000001'); - assert.equal(order.feeXmr, '0.000000000000'); - assert.equal(order.additionalFee?.amountXmr, '0.000000000000'); - assert.equal(order.sellerNetXmr, '0.000000000001'); - const terms = { type: 'list', cardHash: f.chain.state.blocks[1].tokenId, sequence: 0, - kind: 'fixed', priceAtomicXmr: '0', endsAt: null, feeBps: 250 }; - assert.equal(marketActionV3Schema.safeParse(terms).success, false); - for (const invalid of ['01', '+1', '1.0', '1e0', '9'.repeat(23)]) { - assert.equal(marketActionV3Schema.safeParse({ ...terms, priceAtomicXmr: invalid }).success, false); - } -}); - -test('listings reject unknown, immature, stale, foreign, duplicate, and pending cards', async t => { - const f = await fixture(t); - const base = { type: 'list' as const, cardHash: f.chain.state.blocks[0].tokenId, sequence: 0, - kind: 'fixed' as const, priceCents: 1500, endsAt: null, feeBps: 250 }; - await assert.rejects(f.run(bob, base), /Card/); - await assert.rejects(f.run(alice, { ...base, cardHash: '0'.repeat(64) }), /Card/); - await assert.rejects(f.run(alice, { ...base, sequence: 1 }), /Card/); - await assert.rejects(f.run(alice, { ...base, feeBps: 0 }), /fee changed/); - f.chain.state.blocks.pop(); - await assert.rejects(f.run(alice, base), /Card/); - f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 2 }); - f.chain.state.pendingTransfers.push(f.delivery({ cardHash: base.cardHash, sequence: 0 })); - await assert.rejects(f.run(alice, base), /pending/); - f.chain.state.pendingTransfers = []; - const listing = await f.list(); - await assert.rejects(f.list(), /already listed/); - await f.restart(); - assert.equal(f.market.browse().listings[0].id, listing.id); - await f.run(alice, { type: 'cancel', listingId: listing.id }); - assert.equal(f.market.browse().listings.length, 0); -}); - -test('fixed-price reservations are exclusive, idempotent, private and expire without charging', async t => { - const f = await fixture(t), listing = await f.list(); - await assert.rejects(f.buy(listing, alice), /own card/); - const request = f.envelopeV3(bob, { type: 'buy', listingId: listing.id, priceAtomicXmr: '100000000000', feeBps: 250 }); - const results = await Promise.allSettled([f.market.execute(request), f.buy(listing, carol)]); - assert.equal(results[0].status, 'fulfilled'); assert.equal(results[1].status, 'rejected'); - const order = (results[0] as PromiseFulfilledResult).value; - assert.deepEqual(await f.market.execute(request), order); - assert.equal((await f.orders(carol)).length, 0); - assert.equal((await f.orders(alice)).length, 1); - await assert.rejects(f.run(carol, { type: 'mock-pay', orderId: order.id }), /Only the order buyer/); - await assert.rejects(f.run(alice, { type: 'cancel', listingId: listing.id }), /available/); - f.advance(30 * 60000); - assert.equal((await f.orders())[0].status, 'expired'); - assert.equal(f.market.browse().listings[0].status, 'open'); - await f.buy(listing, carol); -}); - -test('auction bids are signed, increasing, deadline-bound, uncancellable, and winner-only', async t => { - const f = await fixture(t), l = await f.list('auction'); - await assert.rejects(f.buy(l), /winner/); - await assert.rejects(f.run(alice, { type: 'bid', listingId: l.id, priceCents: 1500 }), /own card/); - await assert.rejects(f.run(bob, { type: 'bid', listingId: l.id, priceCents: 1499 }), /Bid/); - await f.run(bob, { type: 'bid', listingId: l.id, priceCents: 1500 }); - await assert.rejects(f.run(carol, { type: 'bid', listingId: l.id, priceCents: 1500 }), /Bid/); - await assert.rejects(f.run(alice, { type: 'cancel', listingId: l.id }), /cannot be cancelled/); - await f.run(carol, { type: 'bid', listingId: l.id, priceCents: 1600 }); - f.advance(60000); - await assert.rejects(f.run(bob, { type: 'bid', listingId: l.id, priceCents: 1700 }), /not accepting/); - await assert.rejects(f.buy(l, bob, 1600), /winner/); - await assert.rejects(f.buy(l, carol, 1500), /terms changed/); - const order = await f.buy(l, carol, 1600); - assert.equal(order.priceAtomicXmr, '106666666667'); assert.equal(order.payBy, f.now + 30 * 60000); - assert.equal(order.additionalFee?.bps, 100); - assert.equal(order.additionalFee?.address, ADDITIONAL_MARKET_FEE.address); -}); - -test('auctions without bids or unpaid winners expire without a silent runner-up sale', async t => { - const f = await fixture(t), l = await f.list('auction'); - f.advance(60000); assert.equal(f.market.browse().listings.length, 0); - const next = await f.list('auction'); - await f.run(bob, { type: 'bid', listingId: next.id, priceCents: 1500 }); - f.advance(31 * 60000); - assert.equal(f.market.browse().listings.length, 0); - await assert.rejects(f.buy(next), /available/); -}); - -test('mock payment cannot release a card: seller signature, exact buyer, and canonical depth required', async t => { - const f = await fixture(t), l = await f.list(), o = await f.buy(l); - await assert.rejects(f.run(alice, { type: 'deliver', orderId: o.id, transfer: f.delivery(o) }), /paid order seller/); - await f.run(bob, { type: 'mock-pay', orderId: o.id }); - await assert.rejects(f.run(bob, { type: 'deliver', orderId: o.id, transfer: f.delivery(o) }), /paid order seller/); - await assert.rejects(f.run(alice, { type: 'deliver', orderId: o.id, transfer: f.delivery(o, carol) }), /match this sale/); - const transfer = f.delivery(o); - await f.run(alice, { type: 'deliver', orderId: o.id, transfer }); - assert.equal((await f.orders())[0].payout, 'not-started'); - assert.equal((await f.orders())[0].additionalFee?.payout, 'not-started'); - f.confirm(transfer); - assert.equal((await f.orders())[0].status, 'delivery-pending'); - f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] }); - const completed = (await f.orders())[0]; - assert.equal(completed.status, 'complete'); assert.equal(completed.payout, 'mock-paid'); - assert.equal(completed.feeXmr, '0.002500000000'); assert.equal(completed.sellerNetXmr, '0.096500000000'); - assert.equal(completed.additionalFee?.amountXmr, '0.001000000000'); - assert.equal(completed.additionalFee?.address, ADDITIONAL_MARKET_FEE.address); - assert.equal(completed.additionalFee?.payout, 'mock-paid'); - assert.equal(f.market.browse().listings.length, 0); -}); - -test('settlement policy and quote survive restart/config changes; reorg never triggers duplicate payout', async t => { - const f = await fixture(t), l = await f.list(), o = await f.buy(l); - await f.run(bob, { type: 'mock-pay', orderId: o.id }); - const tx = f.delivery(o); - await f.run(alice, { type: 'deliver', orderId: o.id, transfer: tx }); - f.confirm(tx); - f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] }); - assert.equal((await f.orders())[0].status, 'complete'); - const id = f.market.id; - await f.restart(); assert.equal(f.market.id, id); - const restored = (await f.orders())[0]; - assert.equal(restored.status, 'complete'); assert.equal(restored.requiredConfirmations, 2); - assert.equal(restored.invoice.amountXmr, o.invoice.amountXmr); - assert.equal(restored.feeBps, 250); - assert.equal(restored.additionalFee?.address, ADDITIONAL_MARKET_FEE.address); - assert.equal(restored.additionalFee?.amountXmr, '0.001000000000'); - assert.equal(restored.additionalFee?.payout, 'mock-paid'); - f.chain.state.blocks.splice(2); f.chain.state.transfers = []; - assert.equal((await f.orders())[0].status, 'review'); - f.confirm(tx); f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] }); - assert.equal((await f.orders())[0].status, 'review'); - assert.equal((await f.orders())[0].payout, 'review'); - assert.equal((await f.orders())[0].additionalFee?.payout, 'review'); -}); - -test('failed seller delivery offers a full mock refund, never a seller payout', async t => { - const f = await fixture(t), l = await f.list(), o = await f.buy(l); - await f.run(bob, { type: 'mock-pay', orderId: o.id }); - await assert.rejects(f.run(bob, { type: 'refund', orderId: o.id }), /Refund requires/); - f.advance(48 * 3600_000); - assert.equal((await f.orders())[0].status, 'refund-required'); - await f.run(bob, { type: 'refund', orderId: o.id }); - assert.equal((await f.orders())[0].status, 'refunded'); - assert.equal((await f.orders())[0].payout, 'not-started'); - assert.equal((await f.orders())[0].additionalFee?.payout, 'not-started'); - assert.equal(f.market.browse().listings.length, 0); -}); - -test('stale listings are removed and changed cards cannot be paid for', async t => { - const f = await fixture(t), l = await f.list(), o = await f.buy(l); - f.chain.state.transfers.push(f.delivery(o, carol)); - await assert.rejects(f.run(bob, { type: 'mock-pay', orderId: o.id }), /Seller card changed/); - f.advance(30 * 60000); - await f.orders(); assert.equal(f.market.browse().listings.length, 0); -}); - -test('direct canonical delivery outside the marketplace cannot also produce a refund', async t => { - const f = await fixture(t), l = await f.list(), o = await f.buy(l); - await f.run(bob, { type: 'mock-pay', orderId: o.id }); - const tx = f.delivery(o); - f.confirm(tx); // No marketplace deliver command or saved transferId. - const settling = (await f.orders())[0]; - assert.equal(settling.status, 'delivery-pending'); - assert.equal(settling.transferId, transferId(tx)); - await assert.rejects(f.run(bob, { type: 'refund', orderId: o.id }), /Refund requires/); - f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] }); - assert.equal((await f.orders())[0].status, 'complete'); -}); - -test('direct pending delivery is not a conflict, while unpaid delivery never triggers payout', async t => { - const f = await fixture(t), l = await f.list(), o = await f.buy(l); - await f.run(bob, { type: 'mock-pay', orderId: o.id }); - f.chain.state.pendingTransfers.push(f.delivery(o)); - f.advance(48 * 3600_000); - assert.equal((await f.orders())[0].status, 'paid'); - await assert.rejects(f.run(bob, { type: 'refund', orderId: o.id }), /Refund requires/); -}); - -test('canonical delivery without mock payment is held for review, not paid out', async t => { - const f = await fixture(t), l = await f.list(), o = await f.buy(l); - f.confirm(f.delivery(o)); - f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] }); - assert.equal((await f.orders())[0].status, 'review'); - assert.equal((await f.orders())[0].payout, 'review'); - assert.equal((await f.orders())[0].additionalFee?.payout, 'review'); -}); - -test('old open listings require fresh seller fee authorization and cannot be bought', async t => { - const f = await fixture(t), l = await f.list(); - const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); - try { - delete l.additionalFee; - db.prepare('UPDATE listings SET data=? WHERE id=?').run(JSON.stringify(l), l.id); - } finally { db.close(); } - assert.equal(f.market.browse().listings.length, 0); - await assert.rejects(f.buy(l), /available/); - assert.equal((await f.list()).additionalFee?.bps, 100); -}); - -test('existing reserved orders retain their agreed net; the additional fee is never retroactive', async t => { - const f = await fixture(t), l = await f.list(), order = await f.buy(l); - const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')); - try { - delete l.additionalFee; - delete order.additionalFee; - order.sellerNetXmr = '0.097500000000'; - db.prepare('UPDATE listings SET data=? WHERE id=?').run(JSON.stringify({ ...l, status: 'reserved' }), l.id); - db.prepare('UPDATE orders SET data=? WHERE id=?').run(JSON.stringify(order), order.id); - } finally { db.close(); } - await f.restart(); - await f.run(bob, { type: 'mock-pay', orderId: order.id }); - const tx = f.delivery(order); - await f.run(alice, { type: 'deliver', orderId: order.id, transfer: tx }); - f.confirm(tx); - for (let i = 0; i < 4; i++) f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4 + i, transactions: [] }); - const restored = (await f.orders())[0]; - assert.equal(restored.status, 'complete'); - assert.equal(restored.additionalFee, undefined); - assert.equal(restored.sellerNetXmr, '0.097500000000'); -}); diff --git a/tests/monero-daemon-probe.test.ts b/tests/monero-daemon-probe.test.ts deleted file mode 100755 index d135e4c..0000000 --- a/tests/monero-daemon-probe.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { loadMoneroDaemonProbeConfig, probeMoneroDaemon, type MoneroDaemonProbeTransport } from '../server/market/monero-daemon-probe.ts'; - -const config = { network: 'mainnet' as const, url: 'http://10.1.2.3:18081/json_rpc', allowPrivateHttp: true }; -function info(overrides: Record = {}) { - return { status: 'OK', nettype: 'mainnet', mainnet: true, stagenet: false, testnet: false, - synchronized: true, busy_syncing: false, offline: false, untrusted: false, - height: 3_000_000, height_without_bootstrap: 3_000_000, target_height: 3_000_000, - version: '0.18.4.2-release', bootstrap_daemon_address: '', ...overrides }; -} -function transportFor(result: Record, options: { status?: number; type?: string; redirected?: boolean } = {}) { - let request: { method?: string; redirect?: RequestRedirect; headers?: HeadersInit; body?: string } | undefined; - const transport: MoneroDaemonProbeTransport = async (url, init) => { - assert.equal(url, config.url); - request = { method: init.method, redirect: init.redirect, headers: init.headers, body: init.body as string }; - const body = JSON.parse(init.body as string) as { id: string }; - return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, result }), { - status: options.status ?? 200, headers: { 'content-type': options.type ?? 'application/json' }, - ...((options.redirected === undefined) ? {} : { statusText: options.redirected ? 'Moved' : 'OK' }), - }); - }; - return { transport, getRequest: () => request }; -} - -test('daemon probe calls only read-only get_info and accepts a synchronized local Mainnet chain', async () => { - const fixture = transportFor(info()); - const result = await probeMoneroDaemon(config, fixture.transport, Date.UTC(2026, 9, 22)); - assert.deepEqual(result, { network: 'mainnet', height: 3_000_000, version: '0.18.4.2-release', - synchronized: true, bootstrapProxyUsed: false, checkedAt: '2026-10-22T00:00:00.000Z' }); - const request = fixture.getRequest()!; - assert.equal(request.method, 'POST'); - assert.equal(request.redirect, 'error'); - assert.equal(JSON.parse(request.body!).method, 'get_info'); - assert.equal(new Headers(request.headers).has('authorization'), false); -}); - -test('daemon probe rejects network mismatch, syncing, offline, bootstrap and untrusted replies', async () => { - for (const override of [ - { nettype: 'stagenet', mainnet: false, stagenet: true }, - { synchronized: false }, { busy_syncing: true }, { offline: true }, { untrusted: true }, - { height_without_bootstrap: 2_999_999 }, { bootstrap_daemon_address: 'node.example:18081' }, - { testnet: true }, - ]) { - const fixture = transportFor(info(override)); - await assert.rejects(probeMoneroDaemon(config, fixture.transport), /network or synchronization/); - } -}); - -test('daemon probe rejects malformed, oversized, redirected and non-JSON responses', async () => { - const badJson: MoneroDaemonProbeTransport = async (_url, init) => { - const body = JSON.parse(init.body as string) as { id: string }; - return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: { ...info(), height: 'bad' } }), - { headers: { 'content-type': 'application/json' } }); - }; - await assert.rejects(probeMoneroDaemon(config, badJson), /Invalid Monero daemon RPC response/); - const tooLarge: MoneroDaemonProbeTransport = async () => new Response('x'.repeat(70 * 1024), - { headers: { 'content-type': 'application/json' } }); - await assert.rejects(probeMoneroDaemon(config, tooLarge), /Invalid Monero daemon RPC response/); - const redirected = transportFor(info(), { status: 302 }); - await assert.rejects(probeMoneroDaemon(config, redirected.transport), /response rejected/); - const wrongType = transportFor(info(), { type: 'text/html' }); - await assert.rejects(probeMoneroDaemon(config, wrongType.transport), /response rejected/); - const mismatchedId: MoneroDaemonProbeTransport = async () => new Response(JSON.stringify({ - jsonrpc: '2.0', id: '00000000-0000-4000-8000-000000000000', result: info(), - }), { headers: { 'content-type': 'application/json' } }); - await assert.rejects(probeMoneroDaemon(config, mismatchedId), /response ID mismatch/); -}); - -test('daemon probe requires explicit private literal endpoint and HTTP boundary acknowledgement', async () => { - await assert.rejects(probeMoneroDaemon({ ...config, allowPrivateHttp: false }, async () => new Response()), /explicit private-network acknowledgement/); - await assert.rejects(probeMoneroDaemon({ ...config, url: 'http://daemon.example:18081/json_rpc' }, async () => new Response()), /private literal IP/); - await assert.rejects(probeMoneroDaemon({ ...config, url: 'http://8.8.8.8:18081/json_rpc' }, async () => new Response()), /private literal IP/); - await assert.rejects(probeMoneroDaemon({ ...config, url: 'https://user:password@10.1.2.3:18081/json_rpc' }, async () => new Response()), /bare \/json_rpc/); - await assert.rejects(probeMoneroDaemon({ ...config, url: 'http://10.1.2.3:18081/' }, async () => new Response()), /bare \/json_rpc/); -}); - -test('daemon probe config loader requires a protected file and rejects unsafe configuration', async () => { - await assert.rejects(loadMoneroDaemonProbeConfig({}), /config file is required/); - const directory = await mkdtemp(join(tmpdir(), 'wikideck-monero-daemon-')); - const path = join(directory, 'daemon.json'); - try { - await writeFile(path, JSON.stringify(config), { mode: 0o600 }); - if (process.platform !== 'win32') await chmod(path, 0o600); - const loaded = await loadMoneroDaemonProbeConfig({ WIKIDECK_MONERO_DAEMON_CONFIG_FILE: path }); - assert.deepEqual(loaded, config); - await writeFile(path, JSON.stringify({ ...config, url: 'http://daemon.example:18081/json_rpc' }), { mode: 0o600 }); - await assert.rejects(loadMoneroDaemonProbeConfig({ WIKIDECK_MONERO_DAEMON_CONFIG_FILE: path }), /Invalid or inaccessible/); - } finally { await rm(directory, { recursive: true, force: true }); } -}); diff --git a/tests/monero-deposit-review-config.test.ts b/tests/monero-deposit-review-config.test.ts deleted file mode 100755 index e38368f..0000000 --- a/tests/monero-deposit-review-config.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { chmod, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { loadMoneroDepositReviewApproval, loadMoneroDepositReviewPolicy } from '../server/market/monero-deposit-review-config.ts'; - -const owner = 'a'.repeat(64); - -async function privateFile(path: string, content: string) { - await writeFile(path, content, { mode: 0o600 }); - await chmod(path, 0o600); -} - -test('deposit review policy requires an owner-only protected file and strict bounds', async t => { - if (process.platform === 'win32') { - await assert.rejects(loadMoneroDepositReviewPolicy({ WIKIDECK_MONERO_DEPOSIT_REVIEW_POLICY_FILE: 'C:\\private\\policy.json' }), /Invalid or inaccessible/); - return; - } - const directory = await mkdtemp(join(tmpdir(), 'wikideck-deposit-review-')); - t.after(() => rm(directory, { recursive: true, force: true })); - const policyPath = join(directory, 'policy.json'); - const env = { WIKIDECK_MONERO_DEPOSIT_REVIEW_POLICY_FILE: policyPath }; - const valid = { approvedReviewerOwners: [owner], minimumConfirmations: 10, maximumObservationAgeMs: 120_000 }; - await assert.rejects(loadMoneroDepositReviewPolicy({}), /required/); - await privateFile(policyPath, JSON.stringify(valid)); - assert.deepEqual(await loadMoneroDepositReviewPolicy(env), valid); - - await privateFile(policyPath, JSON.stringify({ ...valid, unexpected: true })); - await assert.rejects(loadMoneroDepositReviewPolicy(env), /Invalid or inaccessible/); - await privateFile(policyPath, JSON.stringify({ ...valid, minimumConfirmations: 2 })); - await assert.rejects(loadMoneroDepositReviewPolicy(env), /Invalid or inaccessible/); - - await privateFile(policyPath, JSON.stringify(valid)); await chmod(policyPath, 0o644); - await assert.rejects(loadMoneroDepositReviewPolicy(env), /Invalid or inaccessible/); - await rm(policyPath); - await symlink(join(directory, 'missing-target.json'), policyPath); - await assert.rejects(loadMoneroDepositReviewPolicy(env), /Invalid or inaccessible/); -}); - -test('deposit approval file loader requires an absolute, bounded, private regular file', async t => { - if (process.platform === 'win32') { - await assert.rejects(loadMoneroDepositReviewApproval('C:\\private\\approval.json'), /Invalid or inaccessible/); - return; - } - const directory = await mkdtemp(join(tmpdir(), 'wikideck-deposit-approval-')); - t.after(() => rm(directory, { recursive: true, force: true })); - const approvalPath = join(directory, 'approval.json'); - const approval = { version: 1, signature: 'signed-review-fixture' }; - await assert.rejects(loadMoneroDepositReviewApproval('relative-approval.json'), /protected Monero deposit review approval/); - await privateFile(approvalPath, JSON.stringify(approval)); - assert.deepEqual(await loadMoneroDepositReviewApproval(approvalPath), approval); - - await privateFile(approvalPath, 'x'.repeat(64 * 1024 + 1)); - await assert.rejects(loadMoneroDepositReviewApproval(approvalPath), /protected Monero deposit review approval/); - await privateFile(approvalPath, '{malformed'); - await assert.rejects(loadMoneroDepositReviewApproval(approvalPath), /protected Monero deposit review approval/); -}); diff --git a/tests/monero-observer.test.ts b/tests/monero-observer.test.ts deleted file mode 100755 index ce68cfa..0000000 --- a/tests/monero-observer.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { keccak } from 'hash-wasm'; -import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts'; -import { createMoneroDepositAddressRpc, createMoneroWalletObserver, loadMoneroObserverConfig, - moneroWalletFingerprint, type MoneroFixtureTransport } from '../server/market/monero-observer.ts'; -import { loadMoneroMtlsTransportConfig } from '../server/market/monero-mtls-transport.ts'; -import { createMoneroRpcGateway, loadMoneroRpcGatewayConfig, - validateMoneroRpcGatewayRequest } from '../server/market/monero-rpc-gateway.ts'; - -const config = { network: 'mainnet' as const, url: 'http://127.0.0.1:18083/json_rpc' }; -function response(id: string, result: Record, status = 200, type = 'application/json') { - return new Response(JSON.stringify({ jsonrpc: '2.0', id, result }), { status, headers: { 'content-type': type } }); -} -function rpcErrorResponse(id: string, code: number, message: string) { - return new Response(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }), - { headers: { 'content-type': 'application/json' } }); -} -function fakeTransport(incoming: Record[] = [], outputs?: Record[]): { transport: MoneroFixtureTransport; calls: { method: string; params: unknown; auth: string | null }[] } { - const calls: { method: string; params: unknown; auth: string | null }[] = []; - const transport: MoneroFixtureTransport = async (url, init) => { - assert.equal(url, config.url); assert.equal(init.method, 'POST'); assert.equal(init.redirect, 'error'); - assert.ok(init.signal); - const body = JSON.parse(init.body as string) as { id: string; method: string; params: unknown }; - calls.push({ method: body.method, params: body.params, auth: new Headers(init.headers).get('authorization') }); - if (body.method === 'get_height') return response(body.id, { height: 100, status: 'OK' }); - if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' }); - if (body.method === 'get_transfers') return response(body.id, { in: incoming, status: 'OK' }); - if (body.method === 'incoming_transfers') return response(body.id, { transfers: outputs ?? incoming.map((item, index) => ({ - amount: item.amount, global_index: index, tx_hash: item.txid, subaddr_index: item.subaddr_index, - unlocked: !(item.locked as boolean), block_height: item.height, pubkey: item.outputPublicKey ?? item.txid, - })), status: 'OK' }); - throw new Error(`unexpected method ${body.method}`); - }; - return { transport, calls }; -} -async function syntheticSubaddress(fill = 3) { - const prefix = Uint8Array.from([42, ...Array(64).fill(fill)]); - const data = Buffer.concat([prefix, Buffer.from((await keccak(prefix, 256)).slice(0, 8), 'hex')]); - const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; - let encoded = ''; - for (let i = 0; i < data.length; i += 8) { - const chunk = data.subarray(i, i + 8); let n = BigInt(`0x${chunk.toString('hex')}`), text = ''; - while (n) { text = alphabet[Number(n % 58n)] + text; n /= 58n; } - encoded += text.padStart(chunk.length === 8 ? 11 : 7, '1'); - } - return encoded; -} - -test('read-only Monero observer preserves uint64 amount text and returns observations without crediting balances', async () => { - const { transport, calls } = fakeTransport([{ txid: 'a'.repeat(64), address: ADDITIONAL_MARKET_FEE.address, - amount: '9007199254740993', height: 91, - timestamp: 1_800_000_000, unlock_time: 0, locked: false, double_spend_seen: false, - subaddr_index: { major: 0, minor: 7 } }]); - const observer = createMoneroWalletObserver(config, transport); - assert.equal('createSubaddress' in observer, false); - const page = await observer.scanIncoming(90); - assert.equal(page.network, 'mainnet'); assert.equal(page.walletHeight, 100); - assert.equal(page.primaryAddress, ADDITIONAL_MARKET_FEE.address); - assert.equal(page.incoming.length, 1); - assert.deepEqual(page.incoming[0], { observationId: `${'a'.repeat(64)}:${'a'.repeat(64)}`, transactionId: 'a'.repeat(64), address: ADDITIONAL_MARKET_FEE.address, - amountAtomicXmr: '9007199254740993', height: 91, confirmations: 10, timestamp: 1_800_000_000, - unlockTime: '0', accountIndex: 0, addressIndex: 7, outputPublicKey: 'a'.repeat(64), globalOutputIndex: 0, - locked: false, doubleSpendSeen: false }); - assert.deepEqual(calls.map(call => call.method).sort(), ['get_address', 'get_height', 'get_transfers', 'incoming_transfers']); - assert.ok(calls.every(call => call.auth === null), 'Wallet RPC credentials must never be sent as Basic/Digest headers'); - assert.deepEqual(calls.find(call => call.method === 'get_transfers')?.params, - { in: true, filter_by_height: true, min_height: 90, max_height: 100 }); - assert.deepEqual(calls.find(call => call.method === 'incoming_transfers')?.params, - { transfer_type: 'all', account_index: 0, subaddr_indices: [7] }); - assert.equal(Object.keys(observer).some(key => /transfer|sweep|send|credit/i.test(key)), false); -}); - -test('observer distinguishes multiple outputs in one transaction by owned output public key', async () => { - const txid = 'a'.repeat(64), firstKey = 'b'.repeat(64), secondKey = 'c'.repeat(64); - const { transport } = fakeTransport([{ txid, address: ADDITIONAL_MARKET_FEE.address, amount: '30', height: 91, - timestamp: 1_800_000_000, unlock_time: 0, locked: false, double_spend_seen: false, - subaddr_index: { major: 0, minor: 7 } }], [ - { tx_hash: txid, pubkey: firstKey, amount: '10', global_index: 42, block_height: 91, unlocked: true, - subaddr_index: { major: 0, minor: 7 } }, - { tx_hash: txid, pubkey: secondKey, amount: '20', global_index: 43, block_height: 91, unlocked: true, - subaddr_index: { major: 0, minor: 7 } }, - ]); - const page = await createMoneroWalletObserver(config, transport).scanIncoming(90); - assert.deepEqual(page.incoming.map(row => [row.observationId, row.amountAtomicXmr, row.globalOutputIndex]), [ - [`${txid}:${firstKey}`, '10', 42], [`${txid}:${secondKey}`, '20', 43], - ]); -}); - -test('observer fails closed on missing, duplicated, or misattributed incoming outputs', async () => { - const txid = 'd'.repeat(64), firstKey = 'e'.repeat(64); - const transfer = { txid, address: ADDITIONAL_MARKET_FEE.address, amount: '2', height: 91, - timestamp: 1_800_000_000, unlock_time: 0, locked: false, double_spend_seen: false, - subaddr_index: { major: 0, minor: 7 } }; - const output = { tx_hash: txid, pubkey: firstKey, amount: '1', global_index: 42, block_height: 91, - unlocked: true, subaddr_index: { major: 0, minor: 7 } }; - - const incomplete = fakeTransport([transfer], [output]); - await assert.rejects(createMoneroWalletObserver(config, incomplete.transport).scanIncoming(90), /transaction amount/); - - const duplicate = fakeTransport([transfer], [output, { ...output, global_index: 43 }]); - await assert.rejects(createMoneroWalletObserver(config, duplicate.transport).scanIncoming(90), /Duplicate Monero wallet output public key/); - - const misattributed = fakeTransport([transfer], [{ ...output, subaddr_index: { major: 0, minor: 8 } }]); - await assert.rejects(createMoneroWalletObserver(config, misattributed.transport).scanIncoming(90), /outside requested subaddresses/); - - const malformed: MoneroFixtureTransport = async (_url, init) => { - const body = JSON.parse(init.body as string) as { id: string; method: string }; - if (body.method === 'get_height') return response(body.id, { height: 100, status: 'OK' }); - if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' }); - if (body.method === 'get_transfers') return response(body.id, { in: [transfer], status: 'OK' }); - return response(body.id, { transfers: [{ ...output, pubkey: 'not-a-public-key' }], status: 'OK' }); - }; - await assert.rejects(createMoneroWalletObserver(config, malformed).scanIncoming(90)); -}); - -test('observer tolerates v0.18.5.4 omission of empty incoming_transfers result fields', async () => { - const txid = 'd'.repeat(64); - const transport: MoneroFixtureTransport = async (_url, init) => { - const body = JSON.parse(init.body as string) as { id: string; method: string }; - if (body.method === 'get_height') return response(body.id, { height: 100, status: 'OK' }); - if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' }); - if (body.method === 'get_transfers') return response(body.id, { in: [{ txid, address: ADDITIONAL_MARKET_FEE.address, - amount: '1', height: 91, timestamp: 1, unlock_time: 0, locked: false, double_spend_seen: false, - subaddr_index: { major: 0, minor: 7 } }], status: 'OK' }); - return response(body.id, {}); // Actual empty result shape from the pinned Wallet RPC build. - }; - await assert.rejects(createMoneroWalletObserver(config, transport).scanIncoming(90), /transaction amount/); -}); - -test('deposit-address RPC client is narrowly allow-listed and creates only one verified subaddress', async () => { - const subaddress = await syntheticSubaddress(); - const calls: { method: string; params: unknown }[] = []; - const transport: MoneroFixtureTransport = async (_url, init) => { - const body = JSON.parse(init.body as string) as { id: string; method: string; params: unknown }; - calls.push({ method: body.method, params: body.params }); - if (body.method === 'validate_address') return response(body.id, { valid: true, integrated: false, - nettype: 'mainnet', subaddress: false, openalias_address: '' }); - if (body.method === 'get_address') { - const params = body.params as { address_index?: number[] }; - return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, - ...(params.address_index ? { addresses: [{ address: subaddress, label: 'wikideck-deposit-v1:fixture', address_index: 7, used: false }] } : {}), - status: 'OK' }); - } - if (body.method === 'create_address') return response(body.id, { address: subaddress, address_index: 7, - address_indices: [7], addresses: [subaddress], status: 'OK' }); - return response(body.id, { height: 100, status: 'OK' }); - }; - const rpc = createMoneroDepositAddressRpc(config, transport); - assert.equal(await rpc.verifyWallet(), await moneroWalletFingerprint(ADDITIONAL_MARKET_FEE.address)); - assert.deepEqual(await rpc.getSubaddress(0, 7), { address: subaddress, label: 'wikideck-deposit-v1:fixture' }); - assert.deepEqual(await rpc.createSubaddress(0, 'wikideck-deposit-v1:fixture'), { address: subaddress, addressIndex: 7 }); - assert.deepEqual(calls, [ - { method: 'get_address', params: { account_index: 0, address_index: [0] } }, - { method: 'validate_address', params: { address: ADDITIONAL_MARKET_FEE.address } }, - { method: 'get_address', params: { account_index: 0, address_index: [7] } }, - { method: 'create_address', params: { account_index: 0, label: 'wikideck-deposit-v1:fixture', count: 1 } }, - ]); - await assert.rejects(rpc.getSubaddress(0, 8), /different subaddress index/); - await assert.rejects(rpc.createSubaddress(0, 'bad\nlabel'), /parameters/); - - let unexpectedCalls = 0; - const onlyAddressTransport: MoneroFixtureTransport = async (_url, init) => { - unexpectedCalls++; - const body = JSON.parse(init.body as string) as { id: string; method: string }; - return response(body.id, { height: 100, status: 'OK' }); - }; - await assert.rejects(createMoneroDepositAddressRpc(config, onlyAddressTransport).getSubaddress(0, 0), /expected/); - assert.equal(unexpectedCalls, 1); - - const spendCapableWallet: MoneroFixtureTransport = async (_url, init) => { - const body = JSON.parse(init.body as string) as { id: string; method: string }; - if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' }); - if (body.method === 'validate_address') return response(body.id, { valid: true, integrated: false, - nettype: 'stagenet', subaddress: false, openalias_address: '' }); - throw new Error(`unexpected method ${body.method}`); - }; - await assert.rejects(createMoneroDepositAddressRpc(config, spendCapableWallet).verifyWallet()); -}); - -test('official wallet not-yet-created address-index error is recoverable but unrelated RPC errors fail closed', async () => { - const notFound: MoneroFixtureTransport = async (_url, init) => { - const body = JSON.parse(init.body as string) as { id: string }; - return rpcErrorResponse(body.id, -15, 'address index is out of bound'); - }; - assert.equal(await createMoneroDepositAddressRpc(config, notFound).getSubaddress(0, 1), null); - - const unrelated: MoneroFixtureTransport = async (_url, init) => { - const body = JSON.parse(init.body as string) as { id: string }; - return rpcErrorResponse(body.id, -15, 'account index is out of bound'); - }; - await assert.rejects(createMoneroDepositAddressRpc(config, unrelated).getSubaddress(0, 1), /method failed \(-15\)/); -}); - -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 }); - 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), ['observer', 'address-allocator']), /not allowed for this client certificate/); - } - 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, - }), observer), /range/); - assert.throws(() => validateMoneroRpcGatewayRequest(request('get_height'), [])); -}); - -test('production mTLS transport config is mandatory and requires a bare HTTPS RPC endpoint', async () => { - await assert.rejects(loadMoneroMtlsTransportConfig({}), /config file is required/); - 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' }, 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' }, clientPolicies: [{ fingerprint: 'a'.repeat(64), roles: ['observer'] }] }), /loopback-only/); -}); - -test('observer refuses non-loopback plaintext, URL credentials and malformed height ranges', async () => { - const { transport } = fakeTransport(); const observer = createMoneroWalletObserver(config, transport); - assert.throws(() => createMoneroWalletObserver({ ...config, url: 'http://wallet.example/json_rpc' }, transport), /HTTPS/); - assert.throws(() => createMoneroWalletObserver({ ...config, url: 'http://localhost/json_rpc' }, transport), /HTTPS/); - assert.throws(() => createMoneroWalletObserver({ ...config, url: 'https://user:pass@wallet.example/json_rpc' }, transport), /bare/); - await assert.rejects(observer.scanIncoming(-1), /scan range/); - await assert.rejects(observer.scanIncoming(101), /ahead/); -}); - -test('observer rejects unsafe amounts, duplicate observations, malformed transfers and wrong RPC identity', async () => { - const unsafeTransport: MoneroFixtureTransport = async (_url, init) => { - const body = JSON.parse(init.body as string) as { id: string; method: string }; - if (body.method === 'get_height') return response(body.id, { height: 100, status: 'OK' }); - if (body.method === 'get_address') return response(body.id, { address: ADDITIONAL_MARKET_FEE.address, status: 'OK' }); - if (body.method === 'get_transfers') return new Response(`{"jsonrpc":"2.0","id":"${body.id}","result":{"status":"OK","in":[{"txid":"${'b'.repeat(64)}","address":"${ADDITIONAL_MARKET_FEE.address}","amount":18446744073709551616,"height":91,"timestamp":1,"unlock_time":0,"locked":false,"double_spend_seen":false,"subaddr_index":{"major":0,"minor":1}}]}}`, - { headers: { 'content-type': 'application/json' } }); - return response(body.id, { status: 'OK', transfers: [{ tx_hash: 'b'.repeat(64), pubkey: 'c'.repeat(64), - amount: 18446744073709551616n.toString(), global_index: 1, block_height: 91, unlocked: true, - subaddr_index: { major: 0, minor: 1 } }] }); - }; - await assert.rejects(createMoneroWalletObserver(config, unsafeTransport).scanIncoming(90), /amount/); - - const duplicate = { txid: 'c'.repeat(64), address: ADDITIONAL_MARKET_FEE.address, amount: '1', height: 91, timestamp: 1, unlock_time: 0, - locked: true, double_spend_seen: false, subaddr_index: { major: 0, minor: 1 } }; - const duplicateFixture = fakeTransport([duplicate, duplicate]); - await assert.rejects(createMoneroWalletObserver(config, duplicateFixture.transport).scanIncoming(90), /Duplicate/); - - const wrongId: MoneroFixtureTransport = async (_url, init) => { - JSON.parse(init.body as string) as { id: string }; - return response('00000000-0000-4000-8000-000000000001', { height: 100, status: 'OK' }); - }; - await assert.rejects(createMoneroWalletObserver(config, wrongId).scanIncoming(90), /ID mismatch/); -}); - -test('observer refuses redirects, wrong content type and oversized RPC response without exposing credentials', async () => { - const secretSentinel = 'fixture-secret-never-log'; - const badResponses = [new Response('{}', { status: 302, headers: { 'content-type': 'application/json' } }), - new Response('', { headers: { 'content-type': 'text/html' } }), - new Response('x'.repeat(1024 * 1024 + 1), { headers: { 'content-type': 'application/json' } })]; - for (const bad of badResponses) { - let call = 0; - const transport: MoneroFixtureTransport = async () => { - call++; - if (call === 1) return response('unused', { height: 100, status: 'OK' }); - return bad; - }; - await assert.rejects(createMoneroWalletObserver(config, transport).scanIncoming(90), error => - error instanceof Error && !error.message.includes(secretSentinel)); - } -}); - -test('Monero RPC configuration is opt-in and inaccessible without a protected external config file', async () => { - await assert.rejects(loadMoneroObserverConfig({}), /disabled/); - await assert.rejects(loadMoneroObserverConfig({ WIKIDECK_MONERO_OBSERVER: '1' }), /config file is required/); -}); diff --git a/tests/network.test.ts b/tests/network.test.ts index e9aca71..05d0949 100755 --- a/tests/network.test.ts +++ b/tests/network.test.ts @@ -8,7 +8,6 @@ import { join } from 'node:path'; import { NETWORK, CONSENSUS } from '../shared/testnet.ts'; import { networkProfile } from '../shared/network.ts'; import { CHAIN_ID, GENESIS_TIME, GENESIS_HASH, MAX_TARGET } from '../server/testnet/consensus.ts'; -import { paymentMode } from '../server/market/payments.ts'; function script(code: string, env: Record = {}) { return execFileSync(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], { @@ -21,36 +20,8 @@ test('default testnet v6 manifest is unchanged and unknown network profiles fail assert.equal(GENESIS_TIME, 1_792_411_200); assert.equal(CHAIN_ID, hash(JSON.stringify(['wikideck-randomx-testnet-6', CONSENSUS, 1_792_411_200, MAX_TARGET]))); assert.equal(networkProfile(undefined), 'testnet'); - assert.equal(networkProfile('pilot'), 'pilot'); assert.throws(() => networkProfile('production')); assert.throws(() => networkProfile('')); }); -test('private pilot is a separate deterministic chain with an explicit mock-only startup gate', () => { - const output = JSON.parse(script(` - import { NETWORK, NETWORK_GENESIS_TIME, NETWORK_LABEL } from './shared/network.ts'; - import { CHAIN_ID, GENESIS_HASH, GENESIS_TIME } from './server/testnet/consensus.ts'; - import { launchManifest, assertLaunchAllowed } from './server/launch.ts'; - const allowed = (env) => { try { assertLaunchAllowed(env); return true; } catch { return false; } }; - console.log(JSON.stringify({ NETWORK, NETWORK_GENESIS_TIME, NETWORK_LABEL, CHAIN_ID, GENESIS_HASH, GENESIS_TIME, - manifest:launchManifest(), missing:allowed({}), approved:allowed({WIKIDECK_PILOT_MODE:'private-mock-only'}), - livePayment:allowed({WIKIDECK_PILOT_MODE:'private-mock-only',WIKIDECK_PAYMENT_MODE:'live'}) })); - `, { WIKIDECK_NETWORK: 'pilot' })); - assert.equal(output.NETWORK, 'wikideck-randomx-pilot-1'); - assert.equal(output.GENESIS_TIME, 1_792_627_200); - assert.equal(output.NETWORK_LABEL, 'PRIVATE PILOT'); - assert.equal(output.manifest.launchStatus, 'private-pilot-mock-only'); - assert.equal(output.manifest.paymentMode, 'mock'); - assert.equal(output.missing, false); - assert.equal(output.approved, true); - assert.equal(output.livePayment, false); - assert.notEqual(output.CHAIN_ID, CHAIN_ID); - assert.notEqual(output.GENESIS_HASH, GENESIS_HASH); - const missingMode = spawnSync(process.execPath, ['--experimental-strip-types', 'server/index.ts'], { - env: { ...process.env, WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: '' }, - encoding: 'utf8', timeout: 30000, - }); - assert.notEqual(missingMode.status, 0); - assert.match(missingMode.stderr, /Private pilot requires WIKIDECK_PILOT_MODE/); -}); test('mainnet candidate has distinct deterministic genesis, signatures and strict transaction schemas', () => { const output = JSON.parse(script(` import { NETWORK, nftTransferMessage } from './shared/testnet.ts'; @@ -70,67 +41,46 @@ test('mainnet startup is blocked before resources open, while manifest inspectio const result = spawnSync(process.execPath, args, { env, encoding: 'utf8', timeout: 30000 }); assert.notEqual(result.status, 0); assert.match(result.stderr, /launch-gated/); const manifest = JSON.parse(execFileSync(process.execPath, [...args, '--network-manifest'], { env, encoding: 'utf8' })); - assert.equal(manifest.profile, 'mainnet'); assert.equal(manifest.paymentMode, 'mock'); assert.equal(manifest.audited, false); + assert.equal(manifest.profile, 'mainnet'); assert.equal(manifest.audited, false); assert.equal(manifest.launchStatus, 'candidate-gated'); }); -test('mainnet gates require genesis time, chain-specific approval, HTTPS, independent peers, and mock payment', () => { +test('mainnet gates require genesis time, chain-specific approval, HTTPS, and independent peers', () => { const result = JSON.parse(script(` import { assertLaunchAllowed } from './server/launch.ts'; import { CHAIN_ID, GENESIS_TIME } from './server/testnet/consensus.ts'; const env={WIKIDECK_MAINNET_LAUNCH:CHAIN_ID,WIKIDECK_MAINNET_REVIEW_ACK:'independent-review-complete', - PUBLIC_ORIGIN:'https://market.example',WIKIDECK_PEERS:'https://peer-a.example,https://peer-b.example'}; + PUBLIC_ORIGIN:'https://wikideck.example',WIKIDECK_PEERS:'https://peer-a.example,https://peer-b.example'}; const results=[]; for(const [changes,now] of [[{},0],[{WIKIDECK_MAINNET_LAUNCH:'wrong'},Infinity], - [{PUBLIC_ORIGIN:'http://market.example'},Infinity],[{WIKIDECK_PEERS:''},Infinity], + [{PUBLIC_ORIGIN:'http://wikideck.example'},Infinity],[{WIKIDECK_PEERS:''},Infinity], [{WIKIDECK_PEERS:'https://peer-a.example,https://peer-a.example/'},Infinity], - [{WIKIDECK_ALLOW_PRIVATE_PEERS:'1'},Infinity],[{WIKIDECK_PAYMENT_MODE:'processor'},Infinity],[{},Infinity]]) { + [{WIKIDECK_ALLOW_PRIVATE_PEERS:'1'},Infinity],[{},Infinity]]) { try { assertLaunchAllowed({...env,...changes},now); results.push(true); } catch { results.push(false); } } console.log(JSON.stringify(results)); `)); - assert.deepEqual(result, [false,false,false,false,false,false,false,true]); - assert.throws(() => paymentMode({ WIKIDECK_NETWORK: 'mainnet', WIKIDECK_PAYMENT_MODE: 'processor' })); + assert.deepEqual(result, [false,false,false,false,false,false,true]); }); -test('mainnet ledger and market files use isolated folders; testnet backup headers are rejected', async () => { +test('mainnet ledger uses its isolated folder; testnet backup headers are rejected', async () => { const dir = await mkdtemp(join(tmpdir(), 'wikideck-network-')); try { const result = JSON.parse(script(` import { Ledger } from './server/testnet/ledger.ts'; - import { Market } from './server/market/store.ts'; import { decryptWallet } from './shared/wallet-backup.ts'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; const ledger=await Ledger.open(process.env.PROFILE_TEST_DIR,{hash:async()=>{throw Error('unused')},close(){}}); - const market=await Market.open(process.env.PROFILE_TEST_DIR,ledger); let rejected=false; try {await decryptWallet({format:'wikideck-wallet-backup',version:1,network:'wikideck-randomx-testnet-6', kdf:'PBKDF2-SHA256',iterations:600000,cipher:'AES-256-GCM',salt:'',iv:'',publicKey:'',ciphertext:''},'password');} catch(e){rejected=/network/.test(e.message);} console.log(JSON.stringify({ledger:existsSync(join(process.env.PROFILE_TEST_DIR,'mainnet','ledger.sqlite')), - market:existsSync(join(process.env.PROFILE_TEST_DIR,'mainnet','market.sqlite')), testnet:existsSync(join(process.env.PROFILE_TEST_DIR,'testnet')),rejected})); - market.close();ledger.close(); + ledger.close(); `, { PROFILE_TEST_DIR: dir })); - assert.deepEqual(result, { ledger: true, market: true, testnet: false, rejected: true }); + assert.deepEqual(result, { ledger: true, testnet: false, rejected: true }); } finally { await rm(dir, { recursive: true, force: true }); } }); -test('pilot ledger and market files use their own profile directory', async () => { - const dir = await mkdtemp(join(tmpdir(), 'wikideck-pilot-network-')); - try { - const result = JSON.parse(script(` - import { Ledger } from './server/testnet/ledger.ts'; - import { Market } from './server/market/store.ts'; - import { existsSync } from 'node:fs'; import { join } from 'node:path'; - const ledger=await Ledger.open(process.env.PROFILE_TEST_DIR,{hash:async()=>{throw Error('unused')},close(){}}); - const market=await Market.open(process.env.PROFILE_TEST_DIR,ledger,undefined,Date.now,{worker:false}); - const result={ledger:existsSync(join(process.env.PROFILE_TEST_DIR,'pilot','ledger.sqlite')), - market:existsSync(join(process.env.PROFILE_TEST_DIR,'pilot','market.sqlite')), - testnet:existsSync(join(process.env.PROFILE_TEST_DIR,'testnet'))}; - market.close();ledger.close();console.log(JSON.stringify(result)); - `, { WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: 'private-mock-only', PROFILE_TEST_DIR: dir })); - assert.deepEqual(result, { ledger: true, market: true, testnet: false }); - } finally { await rm(dir, { recursive: true, force: true }); } -}); test('operator preflight is read-only, fails closed, and does not disclose credentials', async () => { const dir = await mkdtemp(join(tmpdir(), 'wikideck-preflight-')); @@ -147,22 +97,9 @@ test('operator preflight is read-only, fails closed, and does not disclose crede const report = JSON.parse(result.stdout); assert.equal(report.productionReady, false); assert.equal(report.decision, 'NO-GO'); - assert.equal(report.paymentMode, 'mock'); assert.ok(report.checks.some((c: { id: string; status: string }) => c.id === 'consensus-launch-calibration' && c.status === 'blocked')); assert.equal(result.stdout.includes(secret), false); assert.equal(existsSync(dataDir), false); } finally { await rm(dir, { recursive: true, force: true }); } }); -test('Monero deposit and withdrawal request APIs cannot be enabled on testnet', () => { - for (const [name, value] of [['WIKIDECK_MONERO_DEPOSIT_ENROLLMENT', '1'], - ['WIKIDECK_MONERO_WITHDRAWAL_REQUESTS', '1']]) { - const result = spawnSync(process.execPath, ['--experimental-strip-types', 'server/index.ts'], { - env: { ...process.env, WIKIDECK_NETWORK: 'testnet', [name]: value }, - encoding: 'utf8', timeout: 30000, - }); - assert.equal(result.status, 1); - assert.match(result.stderr, new RegExp(name.includes('DEPOSIT') - ? 'Monero deposit enrollment requires the mainnet profile' : 'Monero withdrawal requests require the mainnet profile')); - } -}); diff --git a/tests/operations.test.ts b/tests/operations.test.ts index fbc144b..f4f3f9d 100755 --- a/tests/operations.test.ts +++ b/tests/operations.test.ts @@ -34,22 +34,15 @@ test('private operations listener exposes bounded aggregates only on separate lo assert.equal((await fetch(`http://127.0.0.1:${a.port}/metrics`)).status,404); assert.equal(ops.address?.()?.address,'127.0.0.1'); }); -test('operations checks identify isolation, stale reconciliation, disk pressure and market reviews',()=>{ +test('operations checks identify node, peer, native-engine, and disk pressure',()=>{ const now=Date.now(); const status={schema:'wikideck-operations-v1',observedAt:new Date(now).toISOString(), native:{available:true,queued:0},ledger:{height:10,mutationQueue:0,tipAgeSeconds:60}, - peers:{configured:2,connected:2},eventLoop:{meanMs:20},disk:{availableBytes:1024**3},diskSampledAt:now, - market:{queued:0,lastReconciledAt:now,orderStates:[] as {status:string;count:number}[]}}; + peers:{configured:2,connected:2},eventLoop:{meanMs:20},disk:{availableBytes:1024**3},diskSampledAt:now}; assert.equal(checkOperations(status,now).healthy,true); const bad={...status,native:{available:false,queued:50},peers:{configured:2,connected:0},disk:null, - market:{queued:0,lastReconciledAt:now-120000,orderStates:[{status:'review',count:1}]}}; + ledger:{...status.ledger,mutationQueue:192}}; const report=checkOperations(bad,now); - for(const warning of ['native-engine-unavailable','queue-pressure','peer-isolation','market-reconciliation-stale','market-operator-review','disk-sample-unavailable-or-stale'])assert.ok(report.warnings.includes(warning)); + for(const warning of ['native-engine-unavailable','queue-pressure','peer-isolation','disk-sample-unavailable-or-stale'])assert.ok(report.warnings.includes(warning)); assert.throws(()=>checkOperations({})); - const custody = {backend:'postgres',mutationHold:false,integrityHold:false, - audit:{status:'ok',lastAttemptAt:now,lastSuccessAt:now,failureCode:null,stale:false}}; - assert.equal(checkOperations({...status,custody},now).healthy,true); - const held=checkOperations({...status,custody:{...custody,mutationHold:true,integrityHold:true, - audit:{...custody.audit,status:'failed',failureCode:'order-reservation-mismatch',lastSuccessAt:now-180000,stale:true}}},now); - assert.ok(held.warnings.includes('custody-financial-review'));assert.ok(held.warnings.includes('custody-audit-stale')); }); diff --git a/tests/pilot-deployment.test.ts b/tests/pilot-deployment.test.ts deleted file mode 100755 index f379532..0000000 --- a/tests/pilot-deployment.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; - -test('private pilot Compose stack is loopback-bound, isolated, and mock-only', async () => { - const compose = (await readFile(new URL('../compose.custody-pilot.yaml', import.meta.url), 'utf8')).replaceAll('\r\n', '\n'); - assert.match(compose, /WIKIDECK_NETWORK: pilot/); - assert.match(compose, /WIKIDECK_PILOT_MODE: private-mock-only/); - assert.match(compose, /WIKIDECK_PAYMENT_MODE: mock/); - assert.match(compose, /WIKIDECK_CUSTODY_STORAGE: postgres/); - assert.match(compose, /WIKIDECK_CUSTODY_MODE: sandbox/); - assert.match(compose, /WIKIDECK_LOCAL_PG_BOOTSTRAP: sandbox-only/); - assert.match(compose, /127\.0\.0\.1:\$\{WIKIDECK_PILOT_PORT:-3423\}:3001/); - 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):/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]; - assert.doesNotMatch(app, /pg-admin:|pg-owner:|pg-tls:/); -}); diff --git a/tests/pilot-monero-deposit.test.ts b/tests/pilot-monero-deposit.test.ts deleted file mode 100755 index afdfa85..0000000 --- a/tests/pilot-monero-deposit.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -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], { - env: { ...process.env, WIKIDECK_NETWORK: 'pilot' }, encoding: 'utf8', timeout: 30_000, - }).trim()); -} - -const requestFixture = ` - import { generateKeyPairSync, sign } from 'node:crypto'; - import { address } from './server/testnet/consensus.ts'; - import { CHAIN_ID } from './server/testnet/consensus.ts'; - import { pilotMoneroDepositRequestMessage } from './shared/pilot-monero-deposit.ts'; - import { validatePilotMoneroDepositRequest } from './server/market/pilot-monero-deposit-request.ts'; - const pair=generateKeyPairSync('ec',{namedCurve:'prime256v1'}); - const publicKey=pair.publicKey.export({type:'spki',format:'der'}).toString('base64'); - const beneficiary=address(publicKey), now=1_800_000_000_000; - const identity={version:1,mode:'pilot-custody',policy:'pilot-custody-v1',marketNetwork:'wikideck-randomx-pilot-1', - marketChainId:CHAIN_ID,moneroNetwork:'mainnet',custodianId:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', - activationStatus:'identity-only',initializedAt:now-1}; - const context={identity,walletGenerationId:'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', - walletFingerprint:'c'.repeat(64),accountIndex:2,now:()=>now}; - const unsigned={version:1,marketNetwork:'wikideck-randomx-pilot-1',marketChainId:CHAIN_ID,moneroNetwork:'mainnet', - custodianId:identity.custodianId,beneficiary,publicKey,requestId:'dddddddd-dddd-4ddd-8ddd-dddddddddddd', - walletGenerationId:context.walletGenerationId,walletFingerprint:context.walletFingerprint,accountIndex:2,expiresAt:now+60_000}; - const signature=sign('sha256',Buffer.from(pilotMoneroDepositRequestMessage(unsigned)), - {key:pair.privateKey,dsaEncoding:'ieee-p1363'}).toString('base64'); - const request={...unsigned,signature}; -`; - -test('pilot deposit authorization binds both networks and validates without allocating an address', () => { - const result = run(`${requestFixture} - const intent=validatePilotMoneroDepositRequest(context,request); - console.log(JSON.stringify({intent,hasAddress:Object.hasOwn(intent,'address'),hasAddressIndex:Object.hasOwn(intent,'addressIndex')}));`); - assert.equal(result.intent.marketNetwork, 'wikideck-randomx-pilot-1'); - assert.match(result.intent.marketChainId, /^[a-f0-9]{64}$/); - assert.equal(result.intent.moneroNetwork, 'mainnet'); - assert.equal(result.intent.beneficiary.length, 64); - assert.match(result.intent.requestDigest, /^[a-f0-9]{64}$/); - assert.equal(result.hasAddress, false); - assert.equal(result.hasAddressIndex, false); -}); - -test('pilot deposit authorization rejects domain, identity, wallet, signer, signature, and expiry mismatches', () => { - const result = run(`${requestFixture} - const rejected=[]; - const check=(label,ctx,raw)=>{try{validatePilotMoneroDepositRequest(ctx,raw);rejected.push([label,false]);} - catch{rejected.push([label,true]);}}; - check('market-network',context,{...request,marketNetwork:'wikideck-randomx-mainnet-1'}); - check('monero-network',context,{...request,moneroNetwork:'stagenet'}); - check('chain-id',context,{...request,marketChainId:'e'.repeat(64)}); - check('custodian',context,{...request,custodianId:'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'}); - check('wallet-generation',{...context,walletGenerationId:'ffffffff-ffff-4fff-8fff-ffffffffffff'},request); - check('wallet-fingerprint',{...context,walletFingerprint:'d'.repeat(64)},request); - check('account-index',{...context,accountIndex:3},request); - check('signer',context,{...request,beneficiary:'1'.repeat(64)}); - check('signature',context,{...request,signature:'A'.repeat(88)}); - check('expired',{...context,now:()=>request.expiresAt+1},request); - check('too-far-future',{...context,now:()=>request.expiresAt+1},{...request,expiresAt:request.expiresAt+10*60_000}); - check('not-identity-only',{...context,identity:{...context.identity,activationStatus:'active'}},request); - console.log(JSON.stringify(rejected));`); - assert.deepEqual(result, [['market-network',true],['monero-network',true],['chain-id',true],['custodian',true], - ['wallet-generation',true],['wallet-fingerprint',true],['account-index',true],['signer',true],['signature',true], - ['expired',true],['too-far-future',true],['not-identity-only',true]]); -}); - -test('pilot deposit authorization rejects malformed and unknown fields', () => { - const result = run(`${requestFixture} - let malformed=false,unknown=false; - try{validatePilotMoneroDepositRequest(context,{...request,accountIndex:-1});}catch{malformed=true;} - try{validatePilotMoneroDepositRequest(context,{...request,address:'4'.repeat(95)});}catch{unknown=true;} - 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/); -}); diff --git a/tests/pilot-monero-observation.test.ts b/tests/pilot-monero-observation.test.ts deleted file mode 100755 index 5c0557b..0000000 --- a/tests/pilot-monero-observation.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; - -function run(code: string) { - return JSON.parse(execFileSync(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], { - env: { ...process.env, WIKIDECK_NETWORK: 'pilot' }, encoding: 'utf8', timeout: 30_000, - }).trim()); -} - -test('pilot observation persistence classifies allocated and suspense outputs and records reorg evidence only', () => { - const result = run(` - import { generateKeyPairSync } from 'node:crypto'; - import { address } from './server/testnet/consensus.ts'; - import { persistPilotMoneroObservationScan } from './server/market/pilot-monero-observation-store.ts'; - const pair=generateKeyPairSync('ec',{namedCurve:'prime256v1'}); - const publicKey=pair.publicKey.export({type:'spki',format:'der'}).toString('base64'); - const fingerprint='a'.repeat(64), generation='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; - const subaddress='4'.repeat(95), otherAddress='5'.repeat(95); - const pool={}; - console.log(JSON.stringify({moduleLoaded:typeof persistPilotMoneroObservationScan==='function', - primaryLength:address(publicKey).length,generation,subaddress,otherAddress,fingerprint,poolKeys:Object.keys(pool)})); - `); - assert.equal(result.moduleLoaded, true); - assert.equal(result.primaryLength, 64); -}); diff --git a/tests/postgres-deployment.test.ts b/tests/postgres-deployment.test.ts deleted file mode 100755 index d257d01..0000000 --- a/tests/postgres-deployment.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; - -const read = (path: string) => readFile(new URL(`../${path}`, import.meta.url), 'utf8'); -test('persistent custody deployment remains separate, sandbox-only and password authenticated without a database host port', async () => { - const compose = (await read('compose.custody.yaml')).replaceAll('\r\n', '\n'); - assert.match(compose, /WIKIDECK_CUSTODY_MODE: sandbox/); - assert.match(compose, /WIKIDECK_CUSTODY_STORAGE: postgres/); - assert.match(compose, /WIKIDECK_PAYMENT_MODE: mock/); - assert.match(compose, /financial: \{internal: true\}/); - assert.match(compose, /POSTGRES_PASSWORD_FILE:/); - assert.doesNotMatch(compose, /POSTGRES_HOST_AUTH_METHOD|LOCAL_TEST|5432:5432|tmpfs:.*postgresql/); - const database = compose.split(' database:')[1].split(' migrate:')[0]; - assert.doesNotMatch(database, /ports:/); - assert.match(database, /pg-data:\/var\/lib\/postgresql\/data/); - const application = compose.split(' wikideck:')[1].split('networks:\n financial:')[0]; - assert.match(application, /pg-runtime:\/secrets\/runtime:ro/); - assert.doesNotMatch(application, /pg-admin:|pg-owner:|pg-tls:/); -}); -test('database HBA requires TLS+SCRAM over TCP and provisioning refuses partial credential replacement', async () => { - const hba = await read('deploy/postgres/pg_hba.conf'), setup = await read('deploy/postgres/setup.mjs'); - assert.match(hba, /hostnossl all all all reject/); - assert.match(hba, /hostssl wikideck_custody custody_runtime all scram-sha-256/); - assert.doesNotMatch(hba, /^host.*trust/m); - assert.match(setup, /randomBytes\(32\)/); - assert.match(setup, /Partial secret initialization/); - assert.match(setup, /subjectAltName=DNS:database/); - assert.match(setup, /-checkend/); -}); - -test('standalone Monero observer service is non-root, filesystem-hardened and has no public listener', async () => { - const unit = await read('deploy/systemd/wikideck-monero-observer.service'); - const guide = await read('deploy/systemd/README.md'); - const observer = JSON.parse(await read('deploy/systemd/wallet-rpc.example.json')) as { network: string; url: string }; - const mtls = JSON.parse(await read('deploy/systemd/mtls.example.json')) as { url: string; clientPrivateKeyFile: string }; - assert.match(unit, /User=wikideck-observer/); - assert.match(unit, /ProtectSystem=strict/); - assert.match(unit, /ProtectHome=true/); - assert.match(unit, /NoNewPrivileges=true/); - assert.match(unit, /CapabilityBoundingSet=/); - assert.match(unit, /RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX/); - assert.match(unit, /WIKIDECK_MONERO_OBSERVER_WORKER=1/); - assert.doesNotMatch(unit, /^\s*(?:ListenStream|ListenDatagram|SocketUser)=/m); - assert.equal(observer.network, 'mainnet'); - assert.equal(observer.url, mtls.url); - assert.equal(mtls.clientPrivateKeyFile, '/etc/wikideck/monero-observer/client.key'); - assert.match(guide, /migrate-mainnet-schema/); - assert.match(guide, /does \*\*not\*\* issue customer deposit addresses/); - assert.doesNotMatch(`${unit}\n${guide}`, /(?:password|api[_-]?key)\s*[:=]\s*[^\s<]+/i); -}); diff --git a/tests/postgres-transport.test.ts b/tests/postgres-transport.test.ts deleted file mode 100755 index 06abb59..0000000 --- a/tests/postgres-transport.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtemp, writeFile, chmod, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { execFileSync } from 'node:child_process'; -import { postgresConfig } from '../server/market/postgres-config.ts'; - -test('PostgreSQL verified TLS remains the default and cannot be downgraded through URL options', async t => { - const dir = await mkdtemp(join(tmpdir(), 'wikideck-transport-')); - t.after(() => rm(dir, { recursive: true, force: true })); - const file = join(dir, 'connection'); - // Synthetic configuration fixture, never sent to a database. - await writeFile(file, 'postgresql://fixture:fixture-only@database.cluster.example/custody', { mode: 0o600 }); - const env = { WIKIDECK_CUSTODY_PG_URL_FILE: file }; - const config = await postgresConfig(env); - assert.equal((config.ssl as { rejectUnauthorized: boolean }).rejectUnauthorized, true); - assert.deepEqual(config.ssl, (await postgresConfig({ ...env, WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls' })).ssl); - for (const mode of ['', 'off', 'disable', 'false', 'mesh', 'TLS']) { - await assert.rejects(postgresConfig({ ...env, WIKIDECK_CUSTODY_PG_TRANSPORT: mode }), /configuration/); - } - if (process.platform !== 'win32') { - await chmod(file, 0o644); - await assert.rejects(postgresConfig(env), /configuration/); - await chmod(file, 0o600); - } - await writeFile(file, 'postgresql://fixture:fixture-only@database.cluster.example/custody?sslmode=disable'); - await assert.rejects(postgresConfig(env), /configuration/); -}); - -test('PostgreSQL public CA is validated separately from owner-only credential URL files', async t => { - if (process.platform === 'win32') return; - const dir = await mkdtemp(join(tmpdir(), 'wikideck-ca-')); - t.after(() => rm(dir, { recursive: true, force: true })); - const urlFile = join(dir, 'connection'), caFile = join(dir, 'ca.crt'), keyFile = join(dir, 'ca.key'); - await writeFile(urlFile, 'postgresql://fixture:fixture-only@database/custody', { mode: 0o600 }); - execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-sha256', '-nodes', '-days', '2', - '-subj', '/CN=fixture CA', '-keyout', keyFile, '-out', caFile], { stdio: 'ignore' }); - await chmod(urlFile, 0o600); await chmod(caFile, 0o644); await chmod(keyFile, 0o600); - const config = await postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: urlFile, WIKIDECK_CUSTODY_PG_CA_FILE: caFile }); - assert.equal((config.ssl as { rejectUnauthorized: boolean }).rejectUnauthorized, true); - assert.ok((config.ssl as { ca: string }).ca.includes('BEGIN CERTIFICATE')); - await chmod(urlFile, 0o644); - await assert.rejects(postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: urlFile, WIKIDECK_CUSTODY_PG_CA_FILE: caFile }), /configuration/); - await chmod(urlFile, 0o600); - await writeFile(caFile, 'not a certificate', { mode: 0o644 }); await chmod(caFile, 0o644); - await assert.rejects(postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: urlFile, WIKIDECK_CUSTODY_PG_CA_FILE: caFile }), /configuration/); -}); - -test('infrastructure-managed transport requires exact opt-in and rejects mixed TLS/test configuration', async t => { - const dir = await mkdtemp(join(tmpdir(), 'wikideck-infrastructure-')); - t.after(() => rm(dir, { recursive: true, force: true })); - const file = join(dir, 'connection'); - await writeFile(file, 'postgresql://fixture:fixture-only@database.cluster.example/custody', { mode: 0o600 }); - const env = { WIKIDECK_CUSTODY_PG_URL_FILE: file, WIKIDECK_CUSTODY_PG_TRANSPORT: 'infrastructure' }; - await assert.rejects(postgresConfig(env), /configuration/); - for (const ack of ['', 'true', 'firewall', 'encrypted-authenticated-path-enforced ']) { - await assert.rejects(postgresConfig({ ...env, WIKIDECK_CUSTODY_PG_INFRASTRUCTURE_ACK: ack }), /configuration/); - } - const approved = { ...env, WIKIDECK_CUSTODY_PG_INFRASTRUCTURE_ACK: 'encrypted-authenticated-path-enforced' }; - assert.equal((await postgresConfig(approved)).ssl, false); - for (const conflict of [{ WIKIDECK_CUSTODY_PG_CA_FILE: file }, { WIKIDECK_CUSTODY_PG_LOCAL_TEST: '1' }]) { - await assert.rejects(postgresConfig({ ...approved, ...conflict }), /configuration/); - } - await assert.rejects(postgresConfig({ ...approved, WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls' }), /configuration/); - await writeFile(file, 'postgresql://fixture@database.cluster.example/custody'); - await assert.rejects(postgresConfig(approved), /configuration/); // Password auth remains mandatory. - await writeFile(file, 'postgresql://fixture:fixture-only@database.cluster.example/custody?sslmode=require'); - await assert.rejects(postgresConfig(approved), /configuration/); -}); diff --git a/tests/postgres/custody.test.ts b/tests/postgres/custody.test.ts deleted file mode 100755 index 661e7f9..0000000 --- a/tests/postgres/custody.test.ts +++ /dev/null @@ -1,2191 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { Pool, type PoolConfig } from 'pg'; -import { createHash, generateKeyPairSync, randomUUID, sign } from 'node:crypto'; -import { access, chmod, lstat, mkdtemp, rm, writeFile, mkdir, readFile } from 'node:fs/promises'; -import { constants } from 'node:fs'; -import { join, resolve } from 'node:path'; -import { tmpdir } from 'node:os'; -import { spawn } from 'node:child_process'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import { DatabaseSync } from 'node:sqlite'; -import { PostgresCustody, auditPostgres } from '../../server/market/postgres-custody.ts'; -import { CustodyIntegrityError, auditBusinessLiabilities } from '../../server/market/custody-integrity.ts'; -import { resolveCustodyIncident } from '../../server/market/custody-incident.ts'; -import { CustodyMarket } from '../../server/market/custody.ts'; -import { migrateCustody, grantCustodyRuntime, grantMoneroObserverRuntime, CUSTODY_SCHEMA_V1, CUSTODY_SCHEMA_V1_DIGEST, - grantMoneroDepositCreditRuntime, grantMoneroDepositAllocatorRuntime, grantMoneroWithdrawalRuntime, - grantPilotDepositReservationRuntime, grantPilotDepositAddressRuntime, grantPilotMoneroObserverRuntime, - grantPilotMoneroDepositReviewRuntime, - 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 { createOrRecoverPilotMoneroDepositAddress, reservePilotMoneroDepositRequest, - reservePilotMoneroDepositIndex } from '../../server/market/pilot-monero-deposit-allocation.ts'; -import { persistPilotMoneroObservationScan } from '../../server/market/pilot-monero-observation-store.ts'; -import { readMainnetCustodyBalance, reserveMainnetWithdrawal, cancelMainnetWithdrawal } from '../../server/market/mainnet-withdrawal.ts'; -import { mainnetCustodyBalanceMessage, mainnetWithdrawalCancellationMessage, mainnetWithdrawalMessage, - type MainnetCustodyBalanceRequestUnsigned, type MainnetWithdrawalRequestUnsigned, - type MainnetWithdrawalCancellationUnsigned } from '../../shared/monero-withdrawal.ts'; -import { persistMoneroObservationScan } from '../../server/market/monero-observation-store.ts'; -import { moneroWalletFingerprint } from '../../server/market/monero-observer.ts'; -import { runMoneroObserverBatch, runMoneroObserverWorker } from '../../server/market/monero-observer-worker.ts'; -import { depositCreditApprovalMessage, depositCreditReleaseApprovalMessage, depositReorgResolutionMessage, - postReviewedDepositPendingCredit, prepareDepositCreditReleaseReview, prepareDepositCreditReview, prepareDepositReorgResolution, - reconcileDepositCreditReorgHolds, releaseReviewedDepositCredit, resolveReleasedDepositReorgHold, - type DepositCreditPolicy } from '../../server/market/monero-deposit-credit.ts'; -import { createOrRecoverMoneroDepositSubaddress, registerMoneroDepositSubaddress, - reserveMoneroDepositAllocation } from '../../server/market/monero-deposit-allocation-store.ts'; -import { enrollMoneroDepositAddress } from '../../server/market/monero-deposit-enrollment.ts'; -import { importSqliteCustody } from '../../server/market/custody-import.ts'; -import { postgresConfig, custodyStorage } from '../../server/market/postgres-config.ts'; -import { openCustodyStorage } from '../../server/market/custody-storage.ts'; -import { createBackup, restoreBackup } from '../../server/recovery.ts'; -import { createCoordinatedCustodyBackup, verifyCoordinatedCustodyBackup } from '../../server/coordinated-backup.ts'; -import { CUSTODY_POLICY, custodyMessage, type CustodyAction, type CustodyCommand, type CustodyActivity, - type CustodyListing, type CustodyOrder, type CustodyWithdrawal } from '../../shared/custody.ts'; -import { moneroDepositAddressMessage, type MoneroDepositAddressRequestUnsigned } from '../../shared/monero-deposit.ts'; -import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts'; -import { NETWORK } from '../../shared/network.ts'; -import { CHAIN_ID, GENESIS_HASH, address, sha256 } from '../../server/testnet/consensus.ts'; -import { saleContext, termsForCustodyOrder, saleCandidateTransactionId, saleId } from '../../server/testnet/sale-candidate.ts'; -import { SALE_CANDIDATE, saleTermsMessage, saleCompletionMessage, saleCancellationMessage, - type SaleTransaction } from '../../shared/sale-candidate.ts'; -import { replayReservedSale, type SaleRehearsal } from '../../server/market/sale-rehearsal.ts'; -import { keccak } from 'hash-wasm'; -import type { CanonicalInboxPage } from '../../server/market/canonical-inbox.ts'; -import { CanonicalObserver } from '../../server/market/canonical-observer.ts'; -import { Ledger } from '../../server/testnet/ledger.ts'; -import { RandomX } from '../../server/testnet/randomx.ts'; -import { GENESIS_TIME, makeTemplate, miningBlob, mintId, transition } from '../../server/testnet/consensus.ts'; -import { deriveStats } from '../../shared/enrichment.ts'; -import { blockClaimMessage, type NftStateView, type TestnetBlock } from '../../shared/testnet.ts'; - -if (process.env.WIKIDECK_PG_DISPOSABLE_TEST !== '1') throw new Error('PostgreSQL tests require the isolated disposable compose fixture'); -const config: PoolConfig = { host: '127.0.0.1', database: 'custody_test', user: 'postgres', ssl: false, max: 4, - connectionTimeoutMillis: 5000, statement_timeout: 10000, lock_timeout: 3000 }; -function identity() { - const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); - const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); - return { publicKey, actor: address(publicKey), sign: (message: string) => sign('sha256', Buffer.from(message), - { key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') }; -} -const buyer = identity(), seller = identity(), other = identity(), hash = 'a'.repeat(64); -const host = identity(); -async function syntheticFeeAddress() { - const prefix = Uint8Array.from([18, ...Array(64).fill(2)]); - const data = Buffer.concat([prefix, Buffer.from((await keccak(prefix, 256)).slice(0, 8), 'hex')]); - const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; - let encoded = ''; - for (let i = 0; i < data.length; i += 8) { - const chunk = data.subarray(i, i + 8); let n = BigInt(`0x${chunk.toString('hex')}`), text = ''; - while (n) { text = alphabet[Number(n % 58n)] + text; n /= 58n; } - encoded += text.padStart(chunk.length === 8 ? 11 : 7, '1'); - } - return encoded; -} -async function syntheticSubaddress(fill = 3) { - const prefix = Uint8Array.from([42, ...Array(64).fill(fill)]); - const data = Buffer.concat([prefix, Buffer.from((await keccak(prefix, 256)).slice(0, 8), 'hex')]); - const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; - const sizes: Record = { 8: 11, 5: 7 }; - let encoded = ''; - for (let i = 0; i < data.length; i += 8) { - const chunk = data.subarray(i, i + 8); let n = BigInt(`0x${chunk.toString('hex')}`), text = ''; - while (n) { text = alphabet[Number(n % 58n)] + text; n /= 58n; } - encoded += text.padStart(sizes[chunk.length], '1'); - } - return encoded; -} -function saleTranscript(order: CustodyOrder, custodianId: string, feeBps = 250) { - const policy = { coinbaseMaturity: 2, lockDepth: 2, outcomeDepth: 3, maxLockBlocks: 30, maxTransactions: 64 }; - const context = saleContext(policy); - const terms = termsForCustodyOrder(order, context, { custodianId, host: host.actor, feeBps, lockUntilHeight: 12, expiresHeight: 20 }); - const authorization = (who: typeof buyer, message: string) => ({ publicKey: who.publicKey, signature: who.sign(message) }); - const lock: Extract = { type: 'sale-lock', terms, - sellerAuthorization: authorization(seller, saleTermsMessage(terms)), buyerAuthorization: authorization(buyer, saleTermsMessage(terms)) }; - const input: SaleRehearsal = { policy, seed: { height: 9, mintedHeight: 1, pageId: 1 }, lock, blocks: [[lock]] }; - const reservationDigest = replayReservedSale(order, custodianId, input, feeBps).reservationDigest; - const reference = { protocol: SALE_CANDIDATE, chainId: context.chainId, saleId: saleId(terms) } as const; - const completion = { ...reference, reservationDigest }; - const complete: SaleTransaction = { type: 'sale-complete', ...completion, hostAuthorization: authorization(host, saleCompletionMessage(completion)) }; - const cancel: SaleTransaction = { type: 'sale-cancel', ...reference, - hostAuthorization: authorization(host, saleCancellationMessage(reference)), buyerAuthorization: authorization(buyer, saleCancellationMessage(reference)) }; - return { input, complete, cancel, reservationDigest }; -} -function canonicalPage(streamId: string, all: CanonicalInboxPage['events'], start: number, count: number): CanonicalInboxPage { - const events = all.slice(start, start + count), cursor = events.at(-1)?.sequence ?? start; - const previousAnchor = start === 0 ? GENESIS_HASH : all[start - 1].eventHash; - const anchor = events.at(-1)?.eventHash ?? previousAnchor; - return { streamId, previousAnchor, anchor, events, cursor, head: all.length, caughtUp: cursor === all.length }; -} -function canonicalEvents(streamId: string, linkedTxId = '1'.repeat(64)) { - const rows: CanonicalInboxPage['events'] = []; - let anchor = GENESIS_HASH; - const add = (type: 'connect' | 'disconnect', height: number, hash: string, previousHash: string, transactionIds: string[] = []) => { - const sequence = rows.length + 1; - const eventHash = sha256(JSON.stringify(['wikideck-canonical-event-v1', streamId, sequence, type, - height, hash, previousHash, transactionIds, anchor])); - rows.push({ sequence, type, height, hash, previousHash, transactionIds, eventHash }); anchor = eventHash; - }; - const a = 'a'.repeat(64), b = 'b'.repeat(64), c = 'c'.repeat(64); - add('connect', 1, a, GENESIS_HASH, [linkedTxId]); - add('disconnect', 1, a, GENESIS_HASH, [linkedTxId]); - add('connect', 1, b, GENESIS_HASH, [linkedTxId]); - add('connect', 2, c, b, ['3'.repeat(64)]); - return rows; -} -function reanchorCanonicalEvents(streamId: string, rows: CanonicalInboxPage['events']) { - let anchor = GENESIS_HASH; - for (const event of rows) { - event.eventHash = sha256(JSON.stringify(['wikideck-canonical-event-v1', streamId, event.sequence, event.type, - event.height, event.hash, event.previousHash, event.transactionIds, anchor])); - anchor = event.eventHash; - } -} -class FixturePow { - async hash(key: string, blob: Uint8Array): Promise { return sha256(Buffer.concat([Buffer.from(key, 'hex'), Buffer.from(blob)])); } - close(): void {} -} -class EasyFixturePow extends FixturePow { - async hash(_key: string, _blob: Uint8Array): Promise { return '0'.repeat(64); } -} -function fixtureArticle(pageId = 50000) { - return { pageId, title: 'Canonical inbox fixture', extract: `Synthetic local-only canonical observer test fixture ${pageId}.`, - image: 'https://upload.wikimedia.org/wikipedia/commons/test-image.jpg', url: `https://en.wikipedia.org/?curid=${pageId}`, - revisionId: 1, source: 'live' as const, category: 'Culture' }; -} -function fixtureStats() { - const pageBytes = 2048, languageCount = 3; - return { capturedAt: '2026-10-19T00:00:00.000Z', revisionId: 1, pageBytes, languageCount, - languagesTruncated: false, pageviews: { status: 'unavailable' as const, months: [], latestMonth: '2026-09', - total: null, dailyAverage: null, trendPercent: null }, stats: deriveStats(pageBytes, languageCount, null) }; -} -async function fixtureBlock(ancestors: TestnetBlock[], state: NftStateView, pageId = 50000, proof = new FixturePow()) { - const miner = seller; - const template = makeTemplate(ancestors, state, miner.actor, miner.publicKey, [], fixtureArticle(pageId), fixtureStats(), - GENESIS_TIME + (ancestors.length + 1) * 30); - const header = structuredClone(template.header); - for (header.nonce = 0; header.nonce < 100000; header.nonce++) { - const blob = miningBlob(header), powHash = await proof.hash(header.key, blob); - if (BigInt(`0x${powHash}`) > BigInt(`0x${header.target}`)) continue; - const hash = sha256(blob); - return { ...header, hash, tokenId: mintId(header), powHash, - signature: miner.sign(blockClaimMessage(hash)), transactions: [] } satisfies TestnetBlock; - } - throw new Error('Canonical fixture nonce search exhausted'); -} -const options = { feeBps: 250, card: (token: string, owner: string, sequence: number) => - token === hash && owner === seller.actor && sequence === 0 ? { title: 'PostgreSQL fixture' } : undefined }; -function envelope(id: string, who: typeof buyer, action: CustodyAction, nonce: string = randomUUID()): CustodyCommand { - const c: Omit = { version: 1, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK, - chainId: CHAIN_ID, custodianId: id, actor: who.actor, publicKey: who.publicKey, nonce, expiresAt: Date.now() + 240000, action }; - return { ...c, signature: who.sign(custodyMessage(c)) }; -} -async function fixture(t: { after(fn: () => Promise): void }) { - const admin = new Pool(config); - await admin.query('DROP SCHEMA IF EXISTS wk_custody CASCADE'); // This database exists ONLY in isolated tmpfs fixture. - await migrateCustody(admin); - await admin.query('DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname=\'custody_runtime\') THEN CREATE ROLE custody_runtime LOGIN; END IF; END $$'); - await grantCustodyRuntime(admin, 'custody_runtime'); - const runtime = { ...config, user: 'custody_runtime' }; - const market = await PostgresCustody.open(runtime, options); - t.after(async () => { await market.close(); await admin.end(); }); - const execute = (who: typeof buyer, a: CustodyAction) => market.execute(envelope(market.id, who, a)); - const activity = async (who = buyer) => await execute(who, { type: 'activity', offset: 0 }) as CustodyActivity; - return { admin, runtime, market, execute, activity }; -} - -test('explicit PostgreSQL migrations are idempotent and runtime role cannot alter financial history or schema', async t => { - const f = await fixture(t); await migrateCustody(f.admin); - await assert.rejects(PostgresCustody.open(config, options), /non-administrative/); - await f.execute(buyer, { type: 'mock-credit' }); - const runtime = new Pool(f.runtime); - try { - for (const sql of ["UPDATE wk_custody.accounts SET balance=999", 'DELETE FROM wk_custody.entries', 'TRUNCATE wk_custody.journals', - "ALTER TABLE wk_custody.accounts ADD COLUMN bad text", "UPDATE wk_custody.receipts SET digest='bad'"]) await assert.rejects(runtime.query(sql)); - await assert.rejects(grantCustodyRuntime(f.admin, 'postgres'), /administrative/); - await assert.rejects(grantCustodyRuntime(f.admin, 'bad;DROP SCHEMA'), /identifier/); - } finally { await runtime.end(); } - assert.equal((await f.market.audit()).assetsAtomicXmr, '1000000000000'); -}); - -test('custody schema v1 upgrades additively through v14 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(); - try { - await client.query('BEGIN'); await client.query(CUSTODY_SCHEMA_V1); - await client.query('INSERT INTO wk_custody.schema_versions VALUES(1,$1)', [CUSTODY_SCHEMA_V1_DIGEST]); - await client.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2)', ['v1-preserved-marker', 'existing-test-data']); - await client.query('COMMIT'); - } catch (error) { await client.query('ROLLBACK'); throw error; } - finally { client.release(); } - 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, 11, 12, 13, 14]); - assert.equal((await admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='v1-preserved-marker'")).rows[0].value, - 'existing-test-data'); - for (const table of ['monero_observer_state', 'monero_transfer_observations', 'monero_observation_events', 'monero_observation_scans']) { - assert.ok((await admin.query('SELECT to_regclass($1) AS name', [`wk_custody.${table}`])).rows[0].name); - } - assert.ok((await admin.query("SELECT to_regclass('wk_custody.monero_deposit_allocations') AS name")).rows[0].name); - 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); - assert.ok((await admin.query("SELECT to_regclass('wk_custody.pilot_monero_observation_events') 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); - } - } finally { await admin.end(); } -}); - -test('custody schema v2 upgrades explicitly through v14 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(); - try { - await client.query('BEGIN'); await client.query(CUSTODY_SCHEMA_V1); - await client.query('INSERT INTO wk_custody.schema_versions VALUES(1,$1)', [CUSTODY_SCHEMA_V1_DIGEST]); - await client.query(CUSTODY_MIGRATION_V2); - await client.query('INSERT INTO wk_custody.schema_versions VALUES(2,$1)', [CUSTODY_SCHEMA_V2_DIGEST]); - await client.query("INSERT INTO wk_custody.custody_meta VALUES('v2-marker','preserve')"); - await client.query('COMMIT'); - await migrateCustody(admin); await migrateCustody(admin); - const versions = await admin.query('SELECT version FROM wk_custody.schema_versions ORDER BY version'); - assert.deepEqual(versions.rows.map(row => Number(row.version)), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]); - 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(); } -}); - -test('pilot custody identity binds the private market chain to Monero Mainnet without enabling funds', async t => { - assert.equal(parsePilotCustodianId('AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA'), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); - assert.throws(() => parsePilotCustodianId('not-a-uuid'), /Invalid pilot custodian UUID/); - const admin = new Pool(config), database = `pilot_identity_${randomUUID().replaceAll('-', '')}`; - await admin.query(`CREATE DATABASE "${database}"`); - const target = new Pool({ ...config, database }); - const run = promisify(execFile), custodianId = randomUUID(), runtimeRole = `pilot_identity_${randomUUID().replaceAll('-', '')}`; - const code = `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.TEST_DATABASE,user:'postgres',ssl:false}); - try { console.log(JSON.stringify(await initializePilotCustodyIdentity(pool,process.env.TEST_CUSTODIAN_ID,1234))); } - finally { await pool.end(); }`; - const env = { ...process.env, WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: 'private-mock-only', - TEST_DATABASE: database, TEST_CUSTODIAN_ID: custodianId }; - try { - await migrateCustody(target); - await admin.query(`CREATE ROLE "${runtimeRole}" LOGIN`); - assert.equal((await target.query('SELECT has_table_privilege($1,$2,$3) AS allowed', - [runtimeRole, 'wk_custody.pilot_custody_identity', 'SELECT'])).rows[0].allowed, false); - const first = JSON.parse((await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], { env })).stdout.trim()); - const replay = JSON.parse((await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], { env })).stdout.trim()); - assert.equal(first.mode, 'pilot-custody'); - assert.equal(first.marketNetwork, 'wikideck-randomx-pilot-1'); - assert.equal(first.moneroNetwork, 'mainnet'); - assert.equal(first.activationStatus, 'identity-only'); - assert.equal(first.custodianId, custodianId); - assert.deepEqual(replay, first); - await assert.rejects(run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], { - env: { ...env, TEST_CUSTODIAN_ID: randomUUID() }, - })); - await assert.rejects(run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], { - env: { ...env, WIKIDECK_NETWORK: 'testnet' }, - })); - const stored = await target.query('SELECT * FROM wk_custody.pilot_custody_identity'); - assert.equal(stored.rowCount, 1); - assert.equal((await target.query('SELECT count(*)::int AS n FROM wk_custody.journals')).rows[0].n, 0); - assert.equal((await target.query('SELECT count(*)::int AS n FROM wk_custody.monero_deposit_allocations')).rows[0].n, 0); - assert.equal((await target.query('SELECT count(*)::int AS n FROM wk_custody.monero_withdrawal_intents')).rows[0].n, 0); - assert.equal((await target.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key='identity'")).rows[0].n, 0); - } finally { - await target.end(); - await admin.query(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`); - await admin.query(`DROP ROLE IF EXISTS "${runtimeRole}"`); - await admin.end(); - } -}); - -test('pilot custody identity refuses a database containing pre-existing mock or financial data', async t => { - const admin = new Pool(config), database = `pilot_identity_dirty_${randomUUID().replaceAll('-', '')}`; - await admin.query(`CREATE DATABASE "${database}"`); - const target = new Pool({ ...config, database }), run = promisify(execFile); - try { - await migrateCustody(target); - await target.query("INSERT INTO wk_custody.accounts(id,normal) VALUES('pre-existing-mock','credit')"); - const code = `import { Pool } from 'pg'; - import { initializePilotCustodyIdentity } from './server/market/pilot-custody-identity.ts'; - const p=new Pool({host:'127.0.0.1',database:process.env.TEST_DATABASE,user:'postgres',ssl:false}); - try { await initializePilotCustodyIdentity(p,process.env.TEST_CUSTODIAN_ID,1234); } - finally { await p.end(); }`; - await assert.rejects(run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], { - env: { ...process.env, WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: 'private-mock-only', - TEST_DATABASE: database, TEST_CUSTODIAN_ID: randomUUID() }, - }), /separate empty custody database/); - assert.equal((await target.query('SELECT count(*)::int AS n FROM wk_custody.pilot_custody_identity')).rows[0].n, 0); - } 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('pilot Wallet-RPC address creation recovers a lost response and registers only the reserved index once', async t => { - const admin = new Pool(config), database = `pilot_address_create_${randomUUID().replaceAll('-', '')}`; - const role = `pilot_address_${randomUUID().replaceAll('-', '')}`; - await admin.query(`CREATE DATABASE "${database}"`); - const owner = new Pool({ ...config, database }), run = promisify(execFile), custodianId = randomUUID(); - const now = 1_800_000_000_000; - const address = await syntheticSubaddress(17); - const bootstrap = `import { Pool } from 'pg'; import { initializePilotCustodyIdentity } from './server/market/pilot-custody-identity.ts'; - const p=new Pool({host:'127.0.0.1',database:process.env.DB,user:'postgres',ssl:false}); - try { await initializePilotCustodyIdentity(p,process.env.CUSTODIAN,Number(process.env.NOW)-1); } finally { await p.end(); }`; - const script = `import { Pool } from 'pg'; - import { generateKeyPairSync, sign, randomUUID } from 'node:crypto'; - import { address as ownerAddress, CHAIN_ID } from './server/testnet/consensus.ts'; - import { pilotMoneroDepositRequestMessage } from './shared/pilot-monero-deposit.ts'; - import { createOrRecoverPilotMoneroDepositAddress } 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 now=Number(process.env.NOW), generation='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', fingerprint='c'.repeat(64); - function newPair(){return generateKeyPairSync('ec',{namedCurve:'prime256v1'});} - function makeRequest(pair,accountIndex=0){ - const publicKey=pair.publicKey.export({type:'spki',format:'der'}).toString('base64'); - const unsigned={version:1,marketNetwork:'wikideck-randomx-pilot-1',marketChainId:CHAIN_ID,moneroNetwork:'mainnet', - custodianId:process.env.CUSTODIAN,beneficiary:ownerAddress(publicKey),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')}; - } - const request=makeRequest(newPair()); - const addresses=new Map(), counters={create:0,verify:0}, failFirstCreate={value:true}; - const rpc={verifyWallet:async()=>{counters.verify++;return fingerprint;}, - getSubaddress:async(account,index)=>addresses.get(account+':'+index)??null, - createSubaddress:async(account,label)=>{counters.create++;if(account===1)throw Error('ambiguous create with no visible address'); - const entry={address:process.env.MONERO_ADDRESS,label}; - addresses.set(account+':0',entry);if(failFirstCreate.value){failFirstCreate.value=false;throw Error('simulated lost response');} - return {address:entry.address,addressIndex:0};}}; - try { - let firstRejected=false;try{await createOrRecoverPilotMoneroDepositAddress(pool,rpc, - {walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:0,now:()=>now},request,now);}catch{firstRejected=true;} - const afterFailure=await pool.query('SELECT status,create_started_at,monero_address FROM wk_custody.pilot_monero_deposit_reservations WHERE request_id=$1',[request.requestId]); - const recovered=await createOrRecoverPilotMoneroDepositAddress(pool,rpc, - {walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:0,now:()=>now},request,now); - const replay=await createOrRecoverPilotMoneroDepositAddress(pool,rpc, - {walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:0,now:()=>now},request,now); - const uncertainRequest=makeRequest(newPair(),1); - let uncertainFirst=false,uncertainRetry=false; - try{await createOrRecoverPilotMoneroDepositAddress(pool,rpc, - {walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:1,now:()=>now},uncertainRequest,now);}catch{uncertainFirst=true;} - try{await createOrRecoverPilotMoneroDepositAddress(pool,rpc, - {walletGenerationId:generation,walletFingerprint:fingerprint,accountIndex:1,now:()=>now},uncertainRequest,now);}catch(error){uncertainRetry=/outcome is uncertain/.test(error.message);} - const uncertainState=await pool.query('SELECT status FROM wk_custody.pilot_monero_deposit_reservations WHERE request_id=$1',[uncertainRequest.requestId]); - const result={firstRejected,afterFailure:afterFailure.rows[0],recovered,replay,uncertainFirst,uncertainRetry, - uncertainStatus:uncertainState.rows[0]?.status,counters}; - console.log(JSON.stringify(result)); - } finally { await pool.end(); }`; - const env = { ...process.env, WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: 'private-mock-only', - DB: database, CUSTODIAN: custodianId, NOW: String(now), MONERO_ADDRESS: address }; - try { - await migrateCustody(owner); - await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', bootstrap], { - env: { ...process.env, WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: 'private-mock-only', - DB: database, CUSTODIAN: custodianId, NOW: String(now) }, - }); - await admin.query(`CREATE ROLE "${role}" LOGIN`); - await grantPilotDepositAddressRuntime(owner, role); - assert.equal((await owner.query("SELECT has_table_privilege($1,'wk_custody.pilot_monero_deposit_reservations','UPDATE') AS allowed", [role])).rows[0].allowed, false); - assert.equal((await owner.query("SELECT has_column_privilege($1,'wk_custody.pilot_monero_deposit_reservations','status','UPDATE') AS allowed", [role])).rows[0].allowed, true); - assert.equal((await owner.query("SELECT has_column_privilege($1,'wk_custody.pilot_monero_deposit_reservations','monero_address','UPDATE') AS allowed", [role])).rows[0].allowed, true); - assert.equal((await owner.query("SELECT has_column_privilege($1,'wk_custody.pilot_monero_deposit_reservations','address_index','UPDATE') AS allowed", [role])).rows[0].allowed, false); - assert.equal((await owner.query("SELECT has_table_privilege($1,'wk_custody.accounts','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.firstRejected, true); - assert.equal(result.afterFailure.status, 'creating'); - assert.notEqual(result.afterFailure.create_started_at, null); - assert.equal(result.afterFailure.monero_address, null); - assert.equal(result.recovered.address, address); - assert.equal(result.recovered.replayed, false); - assert.equal(result.replay.address, address); - assert.equal(result.replay.replayed, true); - assert.equal(result.uncertainFirst, true); - assert.equal(result.uncertainRetry, true); - assert.equal(result.uncertainStatus, 'creating'); - assert.equal(result.counters.create, 2, 'ambiguous create must never be retried automatically'); - assert.equal((await owner.query("SELECT count(*)::int AS n FROM wk_custody.pilot_monero_deposit_reservations WHERE status='registered' AND monero_address=$1", [address])).rows[0].n, 1); - const runtime = new Pool({ ...config, database, user: role }); - try { - await assert.rejects(runtime.query("UPDATE wk_custody.pilot_monero_deposit_reservations SET monero_address=$1", ['5'.repeat(95)])); - await assert.rejects(runtime.query('DELETE FROM wk_custody.pilot_monero_deposit_reservations')); - } 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('pilot observation persistence classifies allocated and suspense outputs and records reorg evidence only', async t => { - const admin = new Pool(config), database = `pilot_observations_${randomUUID().replaceAll('-', '')}`; - const role = `pilot_observer_${randomUUID().replaceAll('-', '')}`; - await admin.query(`CREATE DATABASE "${database}"`); - const owner = new Pool({ ...config, database }), run = promisify(execFile); - const custodianId = randomUUID(), generation = randomUUID(), fingerprint = 'c'.repeat(64), now = 1_800_000_000_000; - const allocatedAddress = await syntheticSubaddress(21), suspenseAddress = await syntheticSubaddress(22); - const code = `import { Pool } from 'pg'; - import { randomUUID } from 'node:crypto'; - import { CHAIN_ID } from './server/testnet/consensus.ts'; - import { initializePilotCustodyIdentity } from './server/market/pilot-custody-identity.ts'; - import { reservePilotMoneroDepositIndex } from './server/market/pilot-monero-deposit-allocation.ts'; - import { persistPilotMoneroObservationScan } from './server/market/pilot-monero-observation-store.ts'; - const config={host:'127.0.0.1',database:process.env.DB,user:'postgres',ssl:false,max:4}; - const owner=new Pool(config), observer=new Pool({...config,user:process.env.OBSERVER_ROLE}); - const now=Number(process.env.NOW), walletGenerationId=process.env.GENERATION, walletFingerprint=process.env.FINGERPRINT; - try { - const identity=await initializePilotCustodyIdentity(owner,process.env.CUSTODIAN,now-100); - const intent={requestId:randomUUID(),custodianId:identity.custodianId,marketNetwork:identity.marketNetwork, - marketChainId:CHAIN_ID,moneroNetwork:'mainnet',beneficiary:'a'.repeat(64),walletGenerationId,walletFingerprint, - accountIndex:0,requestDigest:'b'.repeat(64)}; - const allocation=(await reservePilotMoneroDepositIndex(owner,identity,intent,now-50)).reservation; - await owner.query("UPDATE wk_custody.pilot_monero_deposit_reservations SET status='creating',create_started_at=$2 WHERE allocation_id=$1", - [allocation.allocationId,now-40]); - await owner.query("UPDATE wk_custody.pilot_monero_deposit_reservations SET status='registered',monero_address=$2,registered_at=$3 WHERE allocation_id=$1", - [allocation.allocationId,process.env.ALLOCATED_ADDRESS,now-30]); - const assigned={observationId:'d'.repeat(64)+':'+'e'.repeat(64),transactionId:'d'.repeat(64),address:process.env.ALLOCATED_ADDRESS, - amountAtomicXmr:'9007199254740993',height:100,confirmations:11,timestamp:now-1000,unlockTime:'0',accountIndex:0,addressIndex:0, - outputPublicKey:'e'.repeat(64),globalOutputIndex:50,locked:false,doubleSpendSeen:false}; - const unallocated={...assigned,observationId:'f'.repeat(64)+':'+'1'.repeat(64),transactionId:'f'.repeat(64), - address:process.env.SUSPENSE_ADDRESS,amountAtomicXmr:'7',height:101,confirmations:10,addressIndex:1, - outputPublicKey:'1'.repeat(64),globalOutputIndex:51}; - const persist=(scanId,expectedScanHeight,fromHeight,throughHeight,walletHeight,incoming)=>persistPilotMoneroObservationScan(observer, - {scanId,walletGenerationId,walletFingerprint,expectedScanHeight,scannedAt:now+walletHeight, - page:{network:'mainnet',walletHeight,fromHeight,throughHeight,primaryAddress:process.env.PRIMARY_ADDRESS,incoming}}); - const firstId=randomUUID(),first=await persist(firstId,0,0,110,110,[assigned,unallocated]); - const replay=await persist(firstId,0,0,110,110,[assigned,unallocated]); - const missing=await persist(randomUUID(),110,100,120,120,[]); - const reappeared=await persist(randomUUID(),120,100,130,130,[{...assigned,confirmations:31}]); - const rows=await observer.query('SELECT classification,currently_present,amount_atomic_xmr::text AS amount FROM wk_custody.pilot_monero_observations ORDER BY classification'); - const events=await observer.query('SELECT event_type,count(*)::int AS n FROM wk_custody.pilot_monero_observation_events GROUP BY event_type ORDER BY event_type'); - const journals=await owner.query('SELECT count(*)::int AS n FROM wk_custody.journals'); - const credits=await owner.query('SELECT count(*)::int AS n FROM wk_custody.monero_deposit_credit_events'); - console.log(JSON.stringify({first,replay,missing,reappeared,rows:rows.rows,events:events.rows,journals:journals.rows[0].n,credits:credits.rows[0].n})); - } finally { await observer.end(); await owner.end(); }`; - try { - await migrateCustody(owner); - await admin.query(`CREATE ROLE "${role}" LOGIN`); - await grantPilotMoneroObserverRuntime(owner, role); - const result = JSON.parse((await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], { - env: { ...process.env, WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: 'private-mock-only', DB: database, - OBSERVER_ROLE: role, CUSTODIAN: custodianId, GENERATION: generation, FINGERPRINT: fingerprint, - NOW: String(now), ALLOCATED_ADDRESS: allocatedAddress, SUSPENSE_ADDRESS: suspenseAddress, - PRIMARY_ADDRESS: ADDITIONAL_MARKET_FEE.address }, - })).stdout.trim()); - assert.equal(result.first.observed, 2); assert.equal(result.first.assigned, 1); assert.equal(result.first.unallocated, 1); - assert.equal(result.replay.replayed, true); - assert.equal(result.missing.notSeen, 2); - assert.equal(result.reappeared.reappeared, 1); - assert.deepEqual(result.rows.map((row: { classification: string; currently_present: boolean; amount: string }) => - [row.classification,row.currently_present,row.amount]), [['assigned',true,'9007199254740993'],['unallocated',false,'7']]); - assert.deepEqual(result.events.map((row: { event_type: string; n: number }) => [row.event_type,row.n]), - [['not-seen',2],['reappeared',1],['seen',2]]); - assert.equal(result.journals, 0); assert.equal(result.credits, 0); - assert.equal((await owner.query("SELECT has_table_privilege($1,'wk_custody.accounts','SELECT') AS allowed", [role])).rows[0].allowed, false); - const observer = new Pool({ ...config, database, user: role }); - try { - await assert.rejects(observer.query('DELETE FROM wk_custody.pilot_monero_observation_events')); - await assert.rejects(observer.query("UPDATE wk_custody.pilot_monero_observations SET monero_address=$1", [suspenseAddress])); - await assert.rejects(observer.query('SELECT * FROM wk_custody.journals')); - } finally { await observer.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('pilot observations classify reserved-wallet outputs, persist replay/reorg evidence, and never post credits', async t => { - const admin = new Pool(config), database = `pilot_observations_${randomUUID().replaceAll('-', '')}`; - const role = `pilot_observer_${randomUUID().replaceAll('-', '')}`; - await admin.query(`CREATE DATABASE "${database}"`); - const owner = new Pool({ ...config, database }), run = promisify(execFile); - const custodianId = randomUUID(), generation = randomUUID(), fingerprint = 'c'.repeat(64), now = 1_800_000_000_000; - const allocatedAddress = await syntheticSubaddress(21), suspenseAddress = await syntheticSubaddress(22); - const primaryAddress = ADDITIONAL_MARKET_FEE.address; - const code = `import { Pool } from 'pg'; - import { randomUUID } from 'node:crypto'; - import { CHAIN_ID } from './server/testnet/consensus.ts'; - import { initializePilotCustodyIdentity } from './server/market/pilot-custody-identity.ts'; - import { reservePilotMoneroDepositIndex } from './server/market/pilot-monero-deposit-allocation.ts'; - import { persistPilotMoneroObservationScan } from './server/market/pilot-monero-observation-store.ts'; - const config={host:'127.0.0.1',database:process.env.DB,user:'postgres',ssl:false,max:4}; - const owner=new Pool(config), observer=new Pool({...config,user:process.env.OBSERVER_ROLE}); - const now=Number(process.env.NOW), walletGenerationId=process.env.GENERATION, walletFingerprint=process.env.FINGERPRINT; - try { - const identity=await initializePilotCustodyIdentity(owner,process.env.CUSTODIAN,now-100); - const intent={requestId:randomUUID(),custodianId:identity.custodianId,marketNetwork:identity.marketNetwork, - marketChainId:CHAIN_ID,moneroNetwork:'mainnet',beneficiary:'a'.repeat(64),walletGenerationId,walletFingerprint, - accountIndex:0,requestDigest:'b'.repeat(64)}; - const allocation=(await reservePilotMoneroDepositIndex(owner,identity,intent,now-50)).reservation; - await owner.query("UPDATE wk_custody.pilot_monero_deposit_reservations SET status='creating',create_started_at=$2 WHERE allocation_id=$1", - [allocation.allocationId,now-40]); - await owner.query("UPDATE wk_custody.pilot_monero_deposit_reservations SET status='registered',monero_address=$2,registered_at=$3 WHERE allocation_id=$1", - [allocation.allocationId,process.env.ALLOCATED_ADDRESS,now-30]); - const assigned={observationId:'d'.repeat(64)+':'+'e'.repeat(64),transactionId:'d'.repeat(64),address:process.env.ALLOCATED_ADDRESS, - amountAtomicXmr:'9007199254740993',height:100,confirmations:11,timestamp:now-1000,unlockTime:'0',accountIndex:0,addressIndex:0, - outputPublicKey:'e'.repeat(64),globalOutputIndex:50,locked:false,doubleSpendSeen:false}; - const unallocated={...assigned,observationId:'f'.repeat(64)+':'+'1'.repeat(64),transactionId:'f'.repeat(64), - address:process.env.SUSPENSE_ADDRESS,amountAtomicXmr:'7',height:101,confirmations:10,addressIndex:1, - outputPublicKey:'1'.repeat(64),globalOutputIndex:51}; - const persist=(scanId,expectedScanHeight,fromHeight,throughHeight,walletHeight,incoming)=>persistPilotMoneroObservationScan(observer, - {scanId,walletGenerationId,walletFingerprint,expectedScanHeight,scannedAt:now+walletHeight, - page:{network:'mainnet',walletHeight,fromHeight,throughHeight,primaryAddress:process.env.PRIMARY_ADDRESS,incoming}}); - const firstId=randomUUID(),first=await persist(firstId,0,0,110,110,[assigned,unallocated]); - const replay=await persist(firstId,0,0,110,110,[assigned,unallocated]); - const missing=await persist(randomUUID(),110,100,120,120,[]); - const reappeared=await persist(randomUUID(),120,100,130,130,[{...assigned,confirmations:31}]); - const rows=await observer.query('SELECT classification,currently_present,amount_atomic_xmr::text AS amount FROM wk_custody.pilot_monero_observations ORDER BY classification'); - const events=await observer.query('SELECT event_type,count(*)::int AS n FROM wk_custody.pilot_monero_observation_events GROUP BY event_type ORDER BY event_type'); - const journals=await owner.query('SELECT count(*)::int AS n FROM wk_custody.journals'); - const credits=await owner.query('SELECT count(*)::int AS n FROM wk_custody.monero_deposit_credit_events'); - console.log(JSON.stringify({first,replay,missing,reappeared,rows:rows.rows,events:events.rows,journals:journals.rows[0].n,credits:credits.rows[0].n})); - } finally { await observer.end(); await owner.end(); }`; - try { - await migrateCustody(owner); - await admin.query(`CREATE ROLE "${role}" LOGIN`); - await grantPilotMoneroObserverRuntime(owner, role); - const result = JSON.parse((await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], { - env: { ...process.env, WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: 'private-mock-only', DB: database, - OBSERVER_ROLE: role, CUSTODIAN: custodianId, GENERATION: generation, FINGERPRINT: fingerprint, - NOW: String(now), ALLOCATED_ADDRESS: allocatedAddress, SUSPENSE_ADDRESS: suspenseAddress, - PRIMARY_ADDRESS: primaryAddress }, - })).stdout.trim()); - assert.equal(result.first.observed, 2); assert.equal(result.first.assigned, 1); assert.equal(result.first.unallocated, 1); - assert.equal(result.replay.replayed, true); - assert.equal(result.missing.notSeen, 2); - assert.equal(result.reappeared.reappeared, 1); - assert.deepEqual(result.rows.map((row: { classification: string; currently_present: boolean; amount: string }) => - [row.classification,row.currently_present,row.amount]), [['assigned',true,'9007199254740993'],['unallocated',false,'7']]); - assert.deepEqual(result.events.map((row: { event_type: string; n: number }) => [row.event_type,row.n]), - [['not-seen',2],['reappeared',1],['seen',2]]); - assert.equal(result.journals, 0); assert.equal(result.credits, 0); - assert.equal((await owner.query("SELECT has_table_privilege($1,'wk_custody.accounts','SELECT') AS allowed", [role])).rows[0].allowed, false); - const observer = new Pool({ ...config, database, user: role }); - try { - await assert.rejects(observer.query('DELETE FROM wk_custody.pilot_monero_observation_events')); - await assert.rejects(observer.query("UPDATE wk_custody.pilot_monero_observations SET monero_address=$1", [suspenseAddress])); - await assert.rejects(observer.query('SELECT * FROM wk_custody.journals')); - } finally { await observer.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('pilot review preparation is read-only, identity-bound, and fails closed on ineligible evidence', async t => { - const admin = new Pool(config), database = `pilot_review_${randomUUID().replaceAll('-', '')}`; - const role = `pilot_review_${randomUUID().replaceAll('-', '')}`; - await admin.query(`CREATE DATABASE "${database}"`); - const owner = new Pool({ ...config, database }), run = promisify(execFile); - const custodianId = randomUUID(), generation = randomUUID(), fingerprint = 'c'.repeat(64), now = Date.now(); - const firstAddress = await syntheticSubaddress(31), secondAddress = await syntheticSubaddress(32); - const suspenseAddress = await syntheticSubaddress(33); - const code = `import { Pool } from 'pg'; - import { randomUUID } from 'node:crypto'; - import { CHAIN_ID } from './server/testnet/consensus.ts'; - import { initializePilotCustodyIdentity } from './server/market/pilot-custody-identity.ts'; - import { reservePilotMoneroDepositIndex } from './server/market/pilot-monero-deposit-allocation.ts'; - import { persistPilotMoneroObservationScan } from './server/market/pilot-monero-observation-store.ts'; - import { preparePilotMoneroDepositReview } from './server/market/pilot-monero-deposit-review.ts'; - const config={host:'127.0.0.1',database:process.env.DB,user:'postgres',ssl:false,max:4}; - const owner=new Pool(config), review=new Pool({...config,user:process.env.REVIEW_ROLE}); - const now=Number(process.env.NOW), generation=process.env.GENERATION, fingerprint=process.env.FINGERPRINT; - const policy={minimumConfirmations:10,maximumObservationAgeMs:30000}; - const failed=async (generationId,observationId,at=now+300,selectedPolicy=policy)=>{ - try { await preparePilotMoneroDepositReview(review,{walletGenerationId:generationId,observationId},selectedPolicy,at); return false; } - catch { return true; } - }; - try { - const identity=await initializePilotCustodyIdentity(owner,process.env.CUSTODIAN,now-100); - const reserve=async (beneficiary,requestDigest)=>{ - const intent={requestId:randomUUID(),custodianId:identity.custodianId,marketNetwork:identity.marketNetwork, - marketChainId:CHAIN_ID,moneroNetwork:'mainnet',beneficiary,walletGenerationId:generation,walletFingerprint:fingerprint, - accountIndex:0,requestDigest}; - return (await reservePilotMoneroDepositIndex(owner,identity,intent,now-50)).reservation; - }; - const first=await reserve('a'.repeat(64),'b'.repeat(64)); - const second=await reserve('c'.repeat(64),'d'.repeat(64)); - for (const [reservation,address] of [[first,process.env.FIRST_ADDRESS],[second,process.env.SECOND_ADDRESS]]) { - await owner.query("UPDATE wk_custody.pilot_monero_deposit_reservations SET status='creating',create_started_at=$2 WHERE allocation_id=$1", - [reservation.allocationId,now-40]); - await owner.query("UPDATE wk_custody.pilot_monero_deposit_reservations SET status='registered',monero_address=$2,registered_at=$3 WHERE allocation_id=$1", - [reservation.allocationId,address,now-30]); - } - const assigned={observationId:'e'.repeat(64)+':'+'f'.repeat(64),transactionId:'e'.repeat(64),address:process.env.FIRST_ADDRESS, - amountAtomicXmr:'9007199254740993',height:100,confirmations:11,timestamp:Math.floor(now/1000)-100, - unlockTime:'0',accountIndex:0,addressIndex:0,outputPublicKey:'f'.repeat(64),globalOutputIndex:50, - locked:false,doubleSpendSeen:false}; - const futureUnlock={...assigned,observationId:'1'.repeat(64)+':'+'2'.repeat(64),transactionId:'1'.repeat(64), - address:process.env.SECOND_ADDRESS,height:101,confirmations:10,unlockTime:String(Math.floor((now+60000)/1000)), - addressIndex:1,outputPublicKey:'2'.repeat(64),globalOutputIndex:51}; - const suspense={...assigned,observationId:'3'.repeat(64)+':'+'4'.repeat(64),transactionId:'3'.repeat(64), - address:process.env.SUSPENSE_ADDRESS,amountAtomicXmr:'7',height:102,confirmations:9,unlockTime:'0',addressIndex:2, - outputPublicKey:'4'.repeat(64),globalOutputIndex:52}; - const persist=(scanId,expectedScanHeight,fromHeight,throughHeight,walletHeight,incoming,scannedAt)=>persistPilotMoneroObservationScan(owner, - {scanId,walletGenerationId:generation,walletFingerprint:fingerprint,expectedScanHeight,scannedAt, - page:{network:'mainnet',walletHeight,fromHeight,throughHeight,primaryAddress:process.env.PRIMARY_ADDRESS,incoming}}); - await persist(randomUUID(),0,0,110,110,[assigned,futureUnlock,suspense],now+100); - const subject=await preparePilotMoneroDepositReview(review,{walletGenerationId:generation,observationId:assigned.observationId},policy,now+300); - const stricterFloorRejected=await failed(generation,assigned.observationId,now+300,{...policy,minimumConfirmations:12}); - const futureUnlockRejected=await failed(generation,futureUnlock.observationId); - const unallocatedRejected=await failed(generation,suspense.observationId); - const staleRejected=await failed(generation,assigned.observationId,now+40000); - const before=await owner.query('SELECT (SELECT count(*)::int FROM wk_custody.journals) AS journals, '+ - '(SELECT count(*)::int FROM wk_custody.entries) AS entries, '+ - '(SELECT count(*)::int FROM wk_custody.accounts) AS accounts, '+ - '(SELECT count(*)::int FROM wk_custody.monero_deposit_credit_events) AS credits'); - const changed={...assigned,amountAtomicXmr:'9007199254740994',confirmations:21}; - await persist(randomUUID(),110,100,120,120,[changed,{...futureUnlock,confirmations:20}],now+200); - const changedHistoryRejected=await failed(generation,assigned.observationId,now+300); - let updateDenied=false, financialReadDenied=false; - try { await review.query("UPDATE wk_custody.pilot_monero_observations SET locked=true"); } catch { updateDenied=true; } - try { await review.query('SELECT * FROM wk_custody.journals'); } catch { financialReadDenied=true; } - console.log(JSON.stringify({subject,stricterFloorRejected,futureUnlockRejected,unallocatedRejected,staleRejected, - changedHistoryRejected,before:before.rows[0],updateDenied,financialReadDenied})); - } finally { await review.end(); await owner.end(); }`; - try { - await migrateCustody(owner); - await admin.query(`CREATE ROLE "${role}" LOGIN`); - await grantPilotMoneroDepositReviewRuntime(owner, role); - const privileges = await owner.query(`SELECT - has_table_privilege($1,'wk_custody.pilot_monero_observations','SELECT') AS read_observations, - has_table_privilege($1,'wk_custody.pilot_monero_observations','UPDATE') AS write_observations, - has_table_privilege($1,'wk_custody.pilot_monero_observation_events','INSERT') AS append_events, - has_table_privilege($1,'wk_custody.accounts','SELECT') AS read_accounts, - has_table_privilege($1,'wk_custody.journals','SELECT') AS read_journals`, [role]); - assert.deepEqual(Object.values(privileges.rows[0]), [true, false, false, false, false]); - const result = JSON.parse((await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], { - env: { ...process.env, WIKIDECK_NETWORK: 'pilot', WIKIDECK_PILOT_MODE: 'private-mock-only', DB: database, - REVIEW_ROLE: role, CUSTODIAN: custodianId, GENERATION: generation, FINGERPRINT: fingerprint, NOW: String(now), - FIRST_ADDRESS: firstAddress, SECOND_ADDRESS: secondAddress, SUSPENSE_ADDRESS: suspenseAddress, - PRIMARY_ADDRESS: ADDITIONAL_MARKET_FEE.address }, - })).stdout.trim()); - assert.equal(result.subject.policyVersion, 'pilot-monero-deposit-review-v1'); - assert.equal(result.subject.preparationOnly, true); assert.equal(result.subject.crediting, 'disabled'); - assert.equal(result.subject.marketNetwork, 'wikideck-randomx-pilot-1'); assert.equal(result.subject.moneroNetwork, 'mainnet'); - assert.equal(result.subject.amountAtomicXmr, '9007199254740993'); - assert.equal(result.subject.confirmations, 11); assert.equal(result.subject.minimumConfirmations, 10); - assert.equal(result.subject.eventHistoryCount, 1); assert.match(result.subject.subjectDigest, /^[a-f0-9]{64}$/); - assert.equal(result.stricterFloorRejected, true); assert.equal(result.futureUnlockRejected, true); - assert.equal(result.unallocatedRejected, true); assert.equal(result.staleRejected, true); - assert.equal(result.changedHistoryRejected, true); assert.equal(result.updateDenied, true); - assert.equal(result.financialReadDenied, true); - assert.deepEqual(result.before, { journals: 0, entries: 0, accounts: 0, credits: 0 }); - } 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/); - const admin = new Pool(config), database = `mainnet_identity_${randomUUID().replaceAll('-', '')}`; - await admin.query(`CREATE DATABASE "${database}"`); - const target = new Pool({ ...config, database }); - const run = promisify(execFile), custodianId = randomUUID(); - try { - await migrateCustody(target); - const code = `import { Pool } from 'pg'; - import { initializeMainnetCustodyIdentity } from './server/market/mainnet-custody-identity.ts'; - const pool = new Pool({host:'127.0.0.1',database:process.env.TEST_DATABASE,user:'postgres',ssl:false}); - try { console.log(JSON.stringify(await initializeMainnetCustodyIdentity(pool, process.env.TEST_CUSTODIAN_ID))); } - finally { await pool.end(); }`; - const env = { ...process.env, WIKIDECK_NETWORK: 'mainnet', TEST_DATABASE: database, TEST_CUSTODIAN_ID: custodianId }; - const first = JSON.parse((await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], { env })).stdout.trim()); - const replay = JSON.parse((await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], { env })).stdout.trim()); - assert.equal(first.mode, 'mainnet-custody'); assert.equal(first.network, 'wikideck-randomx-mainnet-1'); - assert.equal(first.id, custodianId); assert.deepEqual(replay, first); - await assert.rejects(run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], { - env: { ...env, TEST_CUSTODIAN_ID: randomUUID() }, - })); - assert.equal((await target.query('SELECT count(*)::int AS n FROM wk_custody.journals')).rows[0].n, 0); - } finally { await target.end(); await admin.query(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`); await admin.end(); } -}); - -test('customer-signed mainnet withdrawal reserves available balance and can cancel before signer handoff', async t => { - const admin = new Pool(config), database = `mainnet_withdraw_${randomUUID().replaceAll('-', '')}`; - await admin.query(`CREATE DATABASE "${database}"`); - const owner = new Pool({ ...config, database }), custodianId = randomUUID(), run = promisify(execFile); - const now = Date.now(), withdrawalRole = 'monero_withdrawal_test', observerRole = 'monero_observer', - creditRole = 'monero_deposit_credit'; - await admin.query(`DO $$ BEGIN - IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='${withdrawalRole}') THEN CREATE ROLE ${withdrawalRole} LOGIN; END IF; - IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='${observerRole}') THEN CREATE ROLE ${observerRole} LOGIN; END IF; - IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='${creditRole}') THEN CREATE ROLE ${creditRole} LOGIN; END IF; - END $$`); - const runtime = new Pool({ ...config, database, user: withdrawalRole }); - const observer = new Pool({ ...config, database, user: observerRole }); - const creditRuntime = new Pool({ ...config, database, user: creditRole }); - try { - await migrateCustody(owner); - const bootstrap = `import { Pool } from 'pg'; import { initializeMainnetCustodyIdentity } from './server/market/mainnet-custody-identity.ts'; - const p=new Pool({host:'127.0.0.1',database:process.env.DB,user:'postgres',ssl:false}); - try { await initializeMainnetCustodyIdentity(p,process.env.CUSTODIAN); } finally { await p.end(); }`; - await run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', bootstrap], { - env: { ...process.env, WIKIDECK_NETWORK: 'mainnet', DB: database, CUSTODIAN: custodianId }, - }); - const mainnetChain = JSON.parse((await owner.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'")).rows[0].value).chainId as string; - await grantMoneroObserverRuntime(owner, observerRole); - await grantMoneroDepositCreditRuntime(owner, creditRole); - await grantMoneroWithdrawalRuntime(owner, withdrawalRole); - assert.equal((await owner.query("SELECT has_table_privilege($1,'wk_custody.accounts','SELECT') AS allowed", [withdrawalRole])).rows[0].allowed, true); - assert.equal((await owner.query("SELECT has_table_privilege($1,'wk_custody.withdrawals','SELECT') AS allowed", [withdrawalRole])).rows[0].allowed, false); - assert.equal((await owner.query("SELECT has_table_privilege($1,'wk_custody.monero_transfer_observations','SELECT') AS allowed", [withdrawalRole])).rows[0].allowed, false); - const walletFingerprint = 'd'.repeat(64), generation = randomUUID(), allocationRequest = randomUUID(); - const depositAddress = await syntheticSubaddress(11), outputTx = 'e'.repeat(64), outputKey = 'f'.repeat(64); - const allocation = await reserveMoneroDepositAllocation(owner, { requestId: allocationRequest, custodianId, - marketNetwork: 'wikideck-randomx-mainnet-1', marketChainId: mainnetChain, beneficiary: buyer.actor, - walletGenerationId: generation, walletFingerprint, accountIndex: 0, reservedAt: now - 1 }); - await registerMoneroDepositSubaddress(owner, { allocationId: allocation.allocation.allocationId, - walletGenerationId: generation, moneroAddress: depositAddress, registeredAt: now }); - const observationId = `${outputTx}:${outputKey}`; - const observation = { observationId, transactionId: outputTx, amountAtomicXmr: '1000', height: 190, - address: depositAddress, confirmations: 11, timestamp: now, unlockTime: '0', accountIndex: 0, - addressIndex: allocation.allocation.addressIndex, outputPublicKey: outputKey, globalOutputIndex: 90, - locked: false, doubleSpendSeen: false }; - const page = { network: 'mainnet' as const, walletHeight: 200, fromHeight: 0, throughHeight: 200, - primaryAddress: ADDITIONAL_MARKET_FEE.address, incoming: [observation] }; - await persistMoneroObservationScan(observer, { scanId: randomUUID(), walletFingerprint, - expectedScanHeight: 0, scannedAt: now, page }); - const policy: DepositCreditPolicy = { approvedReviewerOwners: [host.actor], minimumConfirmations: 10, - maximumObservationAgeMs: 120_000 }; - const creditSubject = await prepareDepositCreditReview(creditRuntime, walletFingerprint, observationId, policy, now); - const creditNonce = randomUUID(), creditExpiry = now + 60_000; - const creditApproval = { subject: creditSubject, reviewerOwner: host.actor, reviewerPublicKey: host.publicKey, - nonce: creditNonce, expiresAt: creditExpiry, - signature: host.sign(depositCreditApprovalMessage(creditSubject, host.actor, creditNonce, creditExpiry)) }; - const pendingCredit = await postReviewedDepositPendingCredit(creditRuntime, creditApproval, policy, now); - const releaseSubject = await prepareDepositCreditReleaseReview(creditRuntime, pendingCredit.creditId, policy, now); - const releaseNonce = randomUUID(), releaseExpiry = now + 60_000; - const releaseApproval = { subject: releaseSubject, reviewerOwner: host.actor, reviewerPublicKey: host.publicKey, - nonce: releaseNonce, expiresAt: releaseExpiry, - signature: host.sign(depositCreditReleaseApprovalMessage(releaseSubject, host.actor, releaseNonce, releaseExpiry)) }; - await releaseReviewedDepositCredit(creditRuntime, releaseApproval, policy, now); - assert.equal((await owner.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1', - [`customer:${buyer.actor}:available`])).rows[0].balance, '1000', 'mainnet reviewed deposit release funds customer available balance'); - const balanceUnsigned: MainnetCustodyBalanceRequestUnsigned = { version: 1, - network: 'wikideck-randomx-mainnet-1', chainId: mainnetChain, custodianId, - beneficiary: buyer.actor, publicKey: buyer.publicKey, nonce: randomUUID(), expiresAt: now + 60_000 }; - const balanceRequest = { ...balanceUnsigned, signature: buyer.sign(mainnetCustodyBalanceMessage(balanceUnsigned)) }; - assert.deepEqual(await readMainnetCustodyBalance(runtime, balanceRequest, now), { - availableAtomicXmr: '1000', depositPendingAtomicXmr: '0', withdrawalReservedAtomicXmr: '0', - }); - await assert.rejects(readMainnetCustodyBalance(runtime, { ...balanceRequest, beneficiary: seller.actor }, now), /signature/); - - // Model an accidental/operator-created liability balance above reviewed on-chain credits. - const fixtureClient = await owner.connect(); - try { - await fixtureClient.query('BEGIN'); - await fixtureClient.query("INSERT INTO wk_custody.accounts(id,normal) VALUES('treasury:fixture-extra', 'debit') ON CONFLICT DO NOTHING"); - await fixtureClient.query("INSERT INTO wk_custody.journals(id,kind,created) VALUES('fixture-unbacked-extra','fixture-unbacked-extra',$1)", [now]); - await fixtureClient.query("INSERT INTO wk_custody.entries VALUES('fixture-unbacked-extra',0,'treasury:fixture-extra',500,0),('fixture-unbacked-extra',1,$1,0,500)", - [`customer:${buyer.actor}:available`]); - await fixtureClient.query('COMMIT'); - } catch (error) { await fixtureClient.query('ROLLBACK').catch(() => {}); throw error; } - finally { fixtureClient.release(); } - - const requestId = randomUUID(); - const unsigned: MainnetWithdrawalRequestUnsigned = { version: 1, network: 'wikideck-randomx-mainnet-1', - chainId: mainnetChain, custodianId, beneficiary: buyer.actor, publicKey: buyer.publicKey, requestId, - destination: ADDITIONAL_MARKET_FEE.address, amountAtomicXmr: '700', maxFeeAtomicXmr: '50', expiresAt: now + 60_000 }; - const request = { ...unsigned, signature: buyer.sign(mainnetWithdrawalMessage(unsigned)) }; - const invalidDestinationUnsigned = { ...unsigned, requestId: randomUUID(), destination: '1'.repeat(95) }; - await assert.rejects(reserveMainnetWithdrawal(runtime, { ...invalidDestinationUnsigned, - signature: buyer.sign(mainnetWithdrawalMessage(invalidDestinationUnsigned)) }, now), /address/); - const overBackingUnsigned = { ...unsigned, requestId: randomUUID(), amountAtomicXmr: '1100', maxFeeAtomicXmr: '0' }; - await assert.rejects(reserveMainnetWithdrawal(runtime, { ...overBackingUnsigned, - signature: buyer.sign(mainnetWithdrawalMessage(overBackingUnsigned)) }, now), /exceed reviewed released deposit backing/); - const first = await reserveMainnetWithdrawal(runtime, request, now); - assert.equal(first.status, 'reserved'); assert.equal(first.amountAtomicXmr, '700'); - const replay = await reserveMainnetWithdrawal(runtime, request, now); - assert.equal(replay.requestId, requestId); assert.equal(replay.status, 'reserved'); - assert.equal((await reserveMainnetWithdrawal(runtime, request, now + 120_000)).status, 'reserved', - 'replay of a committed request remains idempotent after its authorization expires'); - assert.equal((await owner.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1', - [`customer:${buyer.actor}:available`])).rows[0].balance, '750'); - assert.equal((await owner.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1', - [`customer:${buyer.actor}:monero-withdrawal:${requestId}`])).rows[0].balance, '750'); - const afterReserveUnsigned = { ...balanceUnsigned, nonce: randomUUID() }; - const afterReserve = { ...afterReserveUnsigned, signature: buyer.sign(mainnetCustodyBalanceMessage(afterReserveUnsigned)) }; - assert.deepEqual(await readMainnetCustodyBalance(runtime, afterReserve, now), { - availableAtomicXmr: '750', depositPendingAtomicXmr: '0', withdrawalReservedAtomicXmr: '750', - }); - await assert.rejects(runtime.query('SELECT * FROM wk_custody.journals'), /permission denied/); - await assert.rejects(reserveMainnetWithdrawal(runtime, { ...request, beneficiary: seller.actor }, now), /signature/); - const cancelUnsigned: MainnetWithdrawalCancellationUnsigned = { version: 1, network: 'wikideck-randomx-mainnet-1', - chainId: mainnetChain, custodianId, beneficiary: buyer.actor, publicKey: buyer.publicKey, - cancellationId: randomUUID(), withdrawalRequestId: requestId, expiresAt: now + 60_000 }; - const cancel = { ...cancelUnsigned, signature: buyer.sign(mainnetWithdrawalCancellationMessage(cancelUnsigned)) }; - const cancelled = await cancelMainnetWithdrawal(runtime, cancel, now); - assert.equal(cancelled.status, 'cancelled'); - assert.equal((await cancelMainnetWithdrawal(runtime, cancel, now + 120_000)).status, 'cancelled', - 'replay of an acknowledged cancellation remains idempotent after expiry'); - assert.equal((await owner.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1', - [`customer:${buyer.actor}:available`])).rows[0].balance, '1500'); - assert.equal((await owner.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1', - [`customer:${buyer.actor}:monero-withdrawal:${requestId}`])).rows[0].balance, '0'); - const audit = await owner.connect(); - try { await audit.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'); await auditBusinessLiabilities(audit); await audit.query('COMMIT'); } - catch (error) { await audit.query('ROLLBACK').catch(() => {}); throw error; } - finally { audit.release(); } - const alternateCancelUnsigned = { ...cancelUnsigned, cancellationId: randomUUID() }; - const alternateCancel = { ...alternateCancelUnsigned, - signature: buyer.sign(mainnetWithdrawalCancellationMessage(alternateCancelUnsigned)) }; - await assert.rejects(cancelMainnetWithdrawal(runtime, alternateCancel, now), /different signed terms/); - } finally { await creditRuntime.end(); await observer.end(); await runtime.end(); await owner.end(); await admin.query(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`); await admin.end(); } -}); - -test('deposit allocation reserves unique beneficiary-bound indexes before RPC and only registers an immutable subaddress', async t => { - const f = await fixture(t); - await f.admin.query("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='monero_allocation_observer') THEN CREATE ROLE monero_allocation_observer LOGIN; END IF; END $$"); - await grantMoneroObserverRuntime(f.admin, 'monero_allocation_observer'); - const observer = new Pool({ ...config, user: 'monero_allocation_observer' }); - const custodyRuntime = new Pool(f.runtime); - const secondAllocatorProcess = new Pool(config); - const generation = randomUUID(), wallet = 'f'.repeat(64), reservedAt = 1_800_000_000; - const input = (requestId: string, beneficiary: string) => ({ requestId, custodianId: f.market.id, - marketNetwork: NETWORK, marketChainId: CHAIN_ID, beneficiary, walletGenerationId: generation, - walletFingerprint: wallet, accountIndex: 0, reservedAt }); - try { - const firstRequest = randomUUID(); - const firstInput = input(firstRequest, buyer.actor); - const first = await reserveMoneroDepositAllocation(f.admin, firstInput); - assert.equal(first.allocation.addressIndex, 0); - assert.equal(first.allocation.status, 'reserved'); - assert.equal(first.allocation.moneroAddress, null, 'reservation is committed before any address is available'); - assert.equal(first.replayed, false); - const replay = await reserveMoneroDepositAllocation(f.admin, firstInput); - assert.equal(replay.replayed, true); assert.deepEqual(replay.allocation, first.allocation); - assert.deepEqual((await reserveMoneroDepositAllocation(f.admin, input(randomUUID(), buyer.actor))).allocation, - first.allocation, 'beneficiary receives the same reserved allocation rather than a new index'); - await assert.rejects(reserveMoneroDepositAllocation(f.admin, input(firstRequest, seller.actor)), /reused with different terms/); - await assert.rejects(reserveMoneroDepositAllocation(f.admin, { ...firstInput, accountIndex: 1 }), /reused with different terms/); - - const concurrent = await Promise.all([ - reserveMoneroDepositAllocation(f.admin, input(randomUUID(), other.actor)), - reserveMoneroDepositAllocation(secondAllocatorProcess, input(randomUUID(), seller.actor)), - ]); - assert.deepEqual(concurrent.map(item => item.allocation.addressIndex).sort((a, b) => a - b), [1, 2]); - - const subaddress = await syntheticSubaddress(); - await assert.rejects(registerMoneroDepositSubaddress(f.admin, { allocationId: concurrent[0].allocation.allocationId, - walletGenerationId: generation, moneroAddress: await syntheticFeeAddress(), registeredAt: reservedAt + 1 }), /Expected a Monero mainnet subaddress/); - const registered = await registerMoneroDepositSubaddress(f.admin, { allocationId: first.allocation.allocationId, - walletGenerationId: generation, moneroAddress: subaddress, registeredAt: reservedAt + 1 }); - assert.equal(registered.allocation.status, 'registered'); - assert.equal(registered.allocation.moneroAddress, subaddress); - assert.equal((await registerMoneroDepositSubaddress(f.admin, { allocationId: first.allocation.allocationId, - walletGenerationId: generation, moneroAddress: subaddress, registeredAt: reservedAt + 2 })).replayed, true); - await assert.rejects(registerMoneroDepositSubaddress(f.admin, { allocationId: first.allocation.allocationId, - walletGenerationId: generation, moneroAddress: await syntheticSubaddress(4), registeredAt: reservedAt + 3 }), /cannot be replaced/); - await assert.rejects(registerMoneroDepositSubaddress(f.admin, { allocationId: concurrent[0].allocation.allocationId, - walletGenerationId: generation, moneroAddress: subaddress, registeredAt: reservedAt + 4 }), /already registered/); - - await assert.rejects(custodyRuntime.query('SELECT * FROM wk_custody.monero_deposit_allocations'), /permission denied/); - await assert.rejects(observer.query('SELECT * FROM wk_custody.monero_deposit_allocations'), /permission denied/); - await assert.rejects(observer.query("INSERT INTO wk_custody.monero_deposit_allocations(allocation_id) VALUES($1)", [randomUUID()]), /permission denied/); - await assert.rejects(f.admin.query('DELETE FROM wk_custody.monero_deposit_allocations WHERE allocation_id=$1', - [first.allocation.allocationId]), /cannot be deleted/); - await assert.rejects(f.admin.query('UPDATE wk_custody.monero_deposit_allocations SET beneficiary=$2 WHERE allocation_id=$1', - [first.allocation.allocationId, other.actor]), /identity is immutable/); - - const recoveryGeneration = randomUUID(); - const recoveryFingerprint = await moneroWalletFingerprint(ADDITIONAL_MARKET_FEE.address); - const recoveryReservation = await reserveMoneroDepositAllocation(f.admin, { ...input(randomUUID(), buyer.actor), - walletGenerationId: recoveryGeneration, walletFingerprint: recoveryFingerprint }); - const walletAddresses = new Map(); - const recoveryAddress = await syntheticSubaddress(6); - let createCalls = 0, addressReads = 0, liveFingerprint = 'b'.repeat(64); - const fakeRpc = { - async verifyWallet() { return liveFingerprint; }, - async getSubaddress(accountIndex: number, addressIndex: number) { - addressReads++; - return walletAddresses.get(`${accountIndex}:${addressIndex}`) ?? null; - }, - async createSubaddress(accountIndex: number, label: string) { - createCalls++; - const row = { address: recoveryAddress, label }; - walletAddresses.set(`${accountIndex}:${recoveryReservation.allocation.addressIndex}`, row); - if (createCalls === 1) throw new Error('fixture: RPC response lost after wallet persisted address'); - return { address: row.address, addressIndex: recoveryReservation.allocation.addressIndex }; - }, - }; - await assert.rejects(createOrRecoverMoneroDepositSubaddress(f.admin, fakeRpc, - recoveryReservation.allocation.allocationId, recoveryGeneration, reservedAt + 4), /identity differs/); - assert.equal(addressReads, 0); assert.equal(createCalls, 0, 'wallet mismatch must fail before address operations'); - liveFingerprint = recoveryFingerprint; - await assert.rejects(createOrRecoverMoneroDepositSubaddress(f.admin, fakeRpc, - recoveryReservation.allocation.allocationId, recoveryGeneration, reservedAt + 5), /response lost/); - const recovered = await createOrRecoverMoneroDepositSubaddress(f.admin, fakeRpc, - recoveryReservation.allocation.allocationId, recoveryGeneration, reservedAt + 6); - assert.equal(recovered.allocation.status, 'registered'); - assert.equal(recovered.allocation.moneroAddress, recoveryAddress); - assert.equal(recovered.replayed, false); - assert.equal(createCalls, 1, 'retry must recover by deterministic wallet label rather than create a duplicate'); - assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.journals')).rows[0].n, 0, - 'address allocation must never create financial postings'); - } finally { await secondAllocatorProcess.end(); await custodyRuntime.end(); await observer.end(); } -}); - -test('signed mainnet deposit enrollment reserves and recovers one beneficiary address with the allocator role', async t => { - const f = await fixture(t); - await f.admin.query("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='monero_deposit_allocator_test') THEN CREATE ROLE monero_deposit_allocator_test LOGIN; END IF; END $$"); - await grantMoneroDepositAllocatorRuntime(f.admin, 'monero_deposit_allocator_test'); - await assert.rejects(grantMoneroDepositAllocatorRuntime(f.admin, 'custody_runtime'), /separate role/); - const allocator = new Pool({ ...config, user: 'monero_deposit_allocator_test' }); - const walletGenerationId = randomUUID(), walletFingerprint = 'a'.repeat(64), chainId = 'b'.repeat(64); - const now = Date.now(), depositAddress = await syntheticSubaddress(8), labels = new Map(); - let createCalls = 0, verifyCalls = 0; - const rpc = { - async verifyWallet() { verifyCalls++; return walletFingerprint; }, - async getSubaddress(accountIndex: number, addressIndex: number) { - const label = labels.get(`${accountIndex}:${addressIndex}`); - return label ? { address: depositAddress, label } : null; - }, - async createSubaddress(accountIndex: number, label: string) { - createCalls++; - labels.set(`${accountIndex}:${createCalls - 1}`, label); - return { address: depositAddress, addressIndex: createCalls - 1 }; - }, - }; - const context = { custodianId: f.market.id, chainId, walletGenerationId, walletFingerprint, accountIndex: 0, - now: () => now }; - const signedRequest = (requestId: string) => { - const unsigned: MoneroDepositAddressRequestUnsigned = { version: 1, network: 'wikideck-randomx-mainnet-1', - chainId, custodianId: f.market.id, actor: buyer.actor, publicKey: buyer.publicKey, requestId, - walletGenerationId, walletFingerprint, accountIndex: 0, expiresAt: now + 60_000 }; - return { ...unsigned, signature: buyer.sign(moneroDepositAddressMessage(unsigned)) }; - }; - try { - const request = signedRequest(randomUUID()); - const created = await enrollMoneroDepositAddress(allocator, rpc, context, request); - assert.equal(created.network, 'mainnet'); assert.equal(created.address, depositAddress); - assert.equal(created.addressIndex, 0); assert.equal(created.replayed, false); - const replay = await enrollMoneroDepositAddress(allocator, rpc, context, request); - assert.equal(replay.allocationId, created.allocationId); assert.equal(replay.address, created.address); - assert.equal(replay.replayed, true); assert.equal(createCalls, 1); - assert.equal(verifyCalls, 1); - const newNonce = await enrollMoneroDepositAddress(allocator, rpc, context, signedRequest(randomUUID())); - assert.equal(newNonce.allocationId, created.allocationId, 'one beneficiary keeps one address per wallet generation'); - assert.equal(newNonce.replayed, true); assert.equal(createCalls, 1); - await assert.rejects(enrollMoneroDepositAddress(allocator, rpc, context, { ...request, actor: other.actor }), /signature|beneficiary/); - const wrongChain = { ...signedRequest(randomUUID()), chainId: 'c'.repeat(64) }; - await assert.rejects(enrollMoneroDepositAddress(allocator, rpc, context, wrongChain), /does not match/); - assert.equal(verifyCalls, 1, 'invalid signed terms must be rejected before Wallet RPC'); - await assert.rejects(allocator.query('SELECT * FROM wk_custody.journals'), /permission denied/, - 'the allocation role cannot read financial journals'); - await assert.rejects(allocator.query('DELETE FROM wk_custody.monero_deposit_allocations WHERE allocation_id=$1', - [created.allocationId]), /permission denied/); - await assert.rejects(allocator.query(`INSERT INTO wk_custody.monero_deposit_allocations( - allocation_id,request_id,custodian_id,market_network,market_chain_id,beneficiary,wallet_generation_id, - wallet_fingerprint,monero_network,account_index,address_index,status,monero_address,request_digest,reserved_at,registered_at) - VALUES($1,$2,$3,'wikideck-randomx-mainnet-1',$4,$5,$6,$7,'mainnet',0,100,'registered',$8,$9,$10,$10)` , - [randomUUID(), randomUUID(), f.market.id, chainId, other.actor, walletGenerationId, walletFingerprint, - await syntheticSubaddress(9), 'd'.repeat(64), now]), /inserted as address-free reservations/i, - 'allocator credentials must not bypass the reserve-then-register state transition'); - assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.journals')).rows[0].n, 0, - 'deposit address enrollment must not credit balances or post journals'); - } finally { await allocator.end(); } -}); - -test('Monero scan persistence is idempotent, reorg-visible and never posts customer credit', async t => { - const f = await fixture(t); - await f.admin.query("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='monero_observer') THEN CREATE ROLE monero_observer LOGIN; END IF; END $$"); - await grantMoneroObserverRuntime(f.admin, 'monero_observer'); - await assert.rejects(grantMoneroObserverRuntime(f.admin, 'custody_runtime'), /separate role/); - const runtime = new Pool({ ...f.runtime, user: 'monero_observer' }); - const custodyRuntime = new Pool(f.runtime), wallet = 'd'.repeat(64), scan1 = randomUUID(); - const txid = 'e'.repeat(64), outputPublicKey = 'f'.repeat(64), observationId = `${txid}:${outputPublicKey}`; - const observation = { observationId, transactionId: txid, amountAtomicXmr: '9007199254740993', height: 95, - address: ADDITIONAL_MARKET_FEE.address, - confirmations: 6, timestamp: 1_800_000_000, unlockTime: '0', accountIndex: 0, addressIndex: 4, - outputPublicKey, globalOutputIndex: 123, locked: false, doubleSpendSeen: false }; - const page = { network: 'mainnet' as const, walletHeight: 100, fromHeight: 0, throughHeight: 100, - primaryAddress: ADDITIONAL_MARKET_FEE.address, incoming: [observation] }; - try { - await assert.rejects(custodyRuntime.query('SELECT * FROM wk_custody.monero_transfer_observations')); - await assert.rejects(runtime.query('SELECT * FROM wk_custody.accounts')); - const first = await persistMoneroObservationScan(runtime, { scanId: scan1, walletFingerprint: wallet, - expectedScanHeight: 0, scannedAt: 1_800_000_100, page }); - assert.equal(first.observed, 1); assert.equal(first.scanHeight, 100); assert.equal(first.replayed, false); - const replay = await persistMoneroObservationScan(runtime, { scanId: scan1, walletFingerprint: wallet, - expectedScanHeight: 0, scannedAt: 1_800_000_100, page }); - assert.equal(replay.replayed, true); - await assert.rejects(persistMoneroObservationScan(runtime, { scanId: scan1, walletFingerprint: wallet, - expectedScanHeight: 0, scannedAt: 1_800_000_100, page: { ...page, incoming: [] } }), /reused/); - - const confirmationRefresh = await persistMoneroObservationScan(runtime, { scanId: randomUUID(), walletFingerprint: wallet, - expectedScanHeight: 100, scannedAt: 1_800_000_150, - page: { ...page, fromHeight: 90, walletHeight: 105, throughHeight: 105, - incoming: [{ ...observation, confirmations: 11 }] } }); - assert.equal(confirmationRefresh.changed, 0, 'normal confirmation growth is a projection update, not an immutable event per poll'); - - const missing = await persistMoneroObservationScan(runtime, { scanId: randomUUID(), walletFingerprint: wallet, - expectedScanHeight: 105, scannedAt: 1_800_000_200, - page: { ...page, fromHeight: 90, walletHeight: 110, throughHeight: 110, incoming: [] } }); - assert.equal(missing.notSeen, 1); // Review signal only; never an automatic financial reversal. - const reappeared = await persistMoneroObservationScan(runtime, { scanId: randomUUID(), walletFingerprint: wallet, - expectedScanHeight: 110, scannedAt: 1_800_000_300, - page: { ...page, fromHeight: 90, walletHeight: 120, throughHeight: 120, - incoming: [{ ...observation, confirmations: 26 }] } }); - assert.equal(reappeared.reappeared, 1); - - const observed = await runtime.query(`SELECT amount_atomic_xmr::text AS amount,monero_address,output_public_key,global_output_index,currently_present FROM wk_custody.monero_transfer_observations - WHERE wallet_fingerprint=$1 AND observation_id=$2`, [wallet, observationId]); - assert.equal(observed.rows[0].amount, '9007199254740993'); assert.equal(observed.rows[0].currently_present, true); - assert.equal(observed.rows[0].monero_address, ADDITIONAL_MARKET_FEE.address); - assert.equal(observed.rows[0].output_public_key, outputPublicKey); assert.equal(Number(observed.rows[0].global_output_index), 123); - - const reorgMovedOutput = await persistMoneroObservationScan(runtime, { scanId: randomUUID(), walletFingerprint: wallet, - expectedScanHeight: 120, scannedAt: 1_800_000_325, - page: { ...page, fromHeight: 90, walletHeight: 121, throughHeight: 121, - incoming: [{ ...observation, height: 96, confirmations: 26 }] } }); - assert.equal(reorgMovedOutput.changed, 1, 'same output re-included at a different height must be visible as changed evidence'); - const updated = await runtime.query(`SELECT block_height,output_public_key FROM wk_custody.monero_transfer_observations - WHERE wallet_fingerprint=$1 AND observation_id=$2`, [wallet, observationId]); - assert.equal(Number(updated.rows[0].block_height), 96); - assert.equal(updated.rows[0].output_public_key, outputPublicKey, 'output identity survives a reorg/re-inclusion'); - - await assert.rejects(persistMoneroObservationScan(runtime, { scanId: randomUUID(), walletFingerprint: wallet, - expectedScanHeight: 121, scannedAt: 1_800_000_350, - page: { ...page, fromHeight: 90, walletHeight: 121, throughHeight: 121, - incoming: [{ ...observation, transactionId: 'a'.repeat(64), observationId: `${'a'.repeat(64)}:${outputPublicKey}`, height: 96, confirmations: 26 }] } }), - /output key|unique/i, 'one wallet output key cannot be assigned to two transaction identities'); - const eventTypes = await runtime.query('SELECT event_type FROM wk_custody.monero_observation_events ORDER BY sequence'); - assert.deepEqual(eventTypes.rows.map(row => row.event_type), ['seen', 'not-seen', 'reappeared', 'changed']); - await assert.rejects(runtime.query('UPDATE wk_custody.monero_observation_events SET event_type=\'seen\'')); - await assert.rejects(runtime.query('DELETE FROM wk_custody.monero_observation_scans')); - await assert.rejects(runtime.query('SELECT count(*) FROM wk_custody.journals'), /permission denied/); - assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.journals')).rows[0].n, 0, - 'observation persistence must never create financial postings'); - await assert.rejects(persistMoneroObservationScan(runtime, { scanId: randomUUID(), walletFingerprint: wallet, - expectedScanHeight: 110, scannedAt: 1_800_000_400, - page: { ...page, fromHeight: 90, walletHeight: 121, throughHeight: 121, - incoming: [{ ...observation, confirmations: 27 }] } }), /cursor changed/); - } finally { await custodyRuntime.end(); await runtime.end(); } -}); - -test('Monero observer worker catches up through bounded durable windows, resumes, and admits one leader', async t => { - const f = await fixture(t); - await f.admin.query("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='monero_worker_test') THEN CREATE ROLE monero_worker_test LOGIN; END IF; END $$"); - await grantMoneroObserverRuntime(f.admin, 'monero_worker_test'); - const workerPool = new Pool({ ...config, user: 'monero_worker_test' }); - const secondWorkerPool = new Pool({ ...config, user: 'monero_worker_test' }); - const wallet = '7'.repeat(64), walletHeight = 250_005, now = Date.now(); - const requested: number[] = []; - const scanner = { - async scanIncoming(fromHeight: number, maxBlocks = 100_000) { - requested.push(fromHeight); - const throughHeight = Math.min(walletHeight, fromHeight + maxBlocks - 1); - return { network: 'mainnet' as const, walletHeight, fromHeight, throughHeight, - primaryAddress: ADDITIONAL_MARKET_FEE.address, incoming: [] }; - }, - }; - try { - const failedScanner = { scanIncoming: async () => { throw new Error('fixture RPC failure'); } }; - await assert.rejects(runMoneroObserverBatch(workerPool, wallet, failedScanner)); - assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.monero_observer_state WHERE wallet_fingerprint=$1', - [wallet])).rows[0].n, 0, 'failed RPC must not create or advance durable cursor state'); - - const batch = await runMoneroObserverBatch(workerPool, wallet, scanner, { now: () => now }); - assert.equal(batch.pages.length, 3, 'catch-up is split into a bounded number of pages'); - assert.equal(batch.caughtUp, true); - assert.equal(batch.scanHeight, walletHeight); - assert.deepEqual(requested, [0, 98_999, 197_998]); - const restarted = await runMoneroObserverBatch(secondWorkerPool, wallet, scanner, { now: () => now + 1, maxPages: 1 }); - assert.equal(restarted.pages.length, 1); - assert.equal(restarted.scanHeight, walletHeight); - assert.equal(requested.at(-1), walletHeight - 1_000, 'restart resumes at a bounded reorg overlap'); - - const controller = new AbortController(); - let signalReady!: () => void; - const batchObserved = new Promise(resolve => { signalReady = resolve; }); - const firstWorker = runMoneroObserverWorker(workerPool, wallet, scanner, { pollIntervalMs: 60_000, - signal: controller.signal, onBatch: () => signalReady() }); - await batchObserved; - await assert.rejects(runMoneroObserverWorker(secondWorkerPool, wallet, scanner, { pollIntervalMs: 60_000 }), /already holds/); - controller.abort(); await firstWorker; - assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.journals')).rows[0].n, 0, - 'observer worker only persists chain observations and never posts money'); - } finally { await secondWorkerPool.end(); await workerPool.end(); } -}); - -test('reviewed Monero outputs post once to pending liability and reorgs create holds without reversal', async t => { - const f = await fixture(t); - await f.admin.query(`DO $$ BEGIN - IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='monero_observer') THEN CREATE ROLE monero_observer LOGIN; END IF; - IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='monero_deposit_credit') THEN CREATE ROLE monero_deposit_credit LOGIN; END IF; - END $$`); - await grantMoneroObserverRuntime(f.admin, 'monero_observer'); - await grantMoneroDepositCreditRuntime(f.admin, 'monero_deposit_credit'); - await assert.rejects(grantMoneroDepositCreditRuntime(f.admin, 'monero_deposit_allocator'), /separate role/); - const creditPrivileges = await f.admin.query(`SELECT has_table_privilege('monero_deposit_credit', - 'wk_custody.monero_deposit_credit_events','SELECT') AS can_read_credit, - has_table_privilege('monero_deposit_credit','wk_custody.monero_observation_events','SELECT') AS can_read_events`); - assert.equal(creditPrivileges.rows[0].can_read_credit, true); - assert.equal(creditPrivileges.rows[0].can_read_events, true); - const observer = new Pool({ ...config, user: 'monero_observer' }); - const creditRuntime = new Pool({ ...config, user: 'monero_deposit_credit' }); - const secondCreditRuntime = new Pool({ ...config, user: 'monero_deposit_credit' }); - const custodyRuntime = new Pool(f.runtime); - const wallet = '8'.repeat(64), txid = '9'.repeat(64), outputPublicKey = 'a'.repeat(64); - const observationId = `${txid}:${outputPublicKey}`, now = Date.now(), scanId = randomUUID(); - const generationId = randomUUID(), requestId = randomUUID(); - const depositAddress = await syntheticSubaddress(10); - const policy: DepositCreditPolicy = { approvedReviewerOwners: [host.actor], minimumConfirmations: 10, - maximumObservationAgeMs: 120_000 }; - try { - const allocation = await reserveMoneroDepositAllocation(f.admin, { requestId, custodianId: f.market.id, - marketNetwork: NETWORK, marketChainId: CHAIN_ID, beneficiary: buyer.actor, walletGenerationId: generationId, - walletFingerprint: wallet, accountIndex: 0, reservedAt: now - 1 }); - await registerMoneroDepositSubaddress(f.admin, { allocationId: allocation.allocation.allocationId, - walletGenerationId: generationId, moneroAddress: depositAddress, registeredAt: now }); - const allocationId = allocation.allocation.allocationId; - const observation = { observationId, transactionId: txid, amountAtomicXmr: '9007199254740993', height: 90, - address: depositAddress, confirmations: 11, timestamp: now, unlockTime: '0', - accountIndex: 0, addressIndex: allocation.allocation.addressIndex, outputPublicKey, globalOutputIndex: 123, - locked: false, doubleSpendSeen: false }; - const page = { network: 'mainnet' as const, walletHeight: 100, fromHeight: 0, throughHeight: 100, - primaryAddress: ADDITIONAL_MARKET_FEE.address, incoming: [observation] }; - await persistMoneroObservationScan(observer, { scanId, walletFingerprint: wallet, expectedScanHeight: 0, - scannedAt: now, page }); - await assert.rejects(custodyRuntime.query('SELECT * FROM wk_custody.monero_transfer_observations')); - await assert.rejects(observer.query('SELECT * FROM wk_custody.accounts')); - await assert.rejects(creditRuntime.query('SELECT balance FROM wk_custody.accounts')); - await assert.rejects(creditRuntime.query('UPDATE wk_custody.monero_transfer_observations SET currently_present=false')); - await assert.rejects(creditRuntime.query('DELETE FROM wk_custody.journals')); - await assert.rejects(creditRuntime.query('SELECT * FROM wk_custody.entries')); - await assert.rejects(creditRuntime.query('UPDATE wk_custody.accounts SET balance=0')); - - await assert.rejects(prepareDepositCreditReview(creditRuntime, wallet, observationId, - { ...policy, minimumConfirmations: 12 }, now), /not eligible/); - const subject = await prepareDepositCreditReview(creditRuntime, wallet, observationId, policy, now); - const nonce = randomUUID(), expiresAt = now + 60_000; - const approval = { subject, reviewerOwner: host.actor, reviewerPublicKey: host.publicKey, nonce, expiresAt, - signature: host.sign(depositCreditApprovalMessage(subject, host.actor, nonce, expiresAt)) }; - const unapproved = { ...approval, reviewerOwner: buyer.actor, reviewerPublicKey: buyer.publicKey, - signature: buyer.sign(depositCreditApprovalMessage(subject, buyer.actor, nonce, expiresAt)) }; - await assert.rejects(postReviewedDepositPendingCredit(creditRuntime, unapproved, policy, now), /signature is invalid/); - const expiredNonce = randomUUID(), expiredAt = now - 1; - await assert.rejects(postReviewedDepositPendingCredit(creditRuntime, { ...approval, nonce: expiredNonce, - expiresAt: expiredAt, signature: host.sign(depositCreditApprovalMessage(subject, host.actor, expiredNonce, expiredAt)) }, - policy, now), /expired or outside/); - - const concurrent = await Promise.all([ - postReviewedDepositPendingCredit(creditRuntime, approval, policy, now), - postReviewedDepositPendingCredit(secondCreditRuntime, approval, policy, now), - ]); - assert.equal(concurrent.filter(result => !result.replayed).length, 1, 'parallel retries must commit one pending credit'); - const first = concurrent.find(result => !result.replayed)!; - assert.equal(first.amountAtomicXmr, observation.amountAtomicXmr); - const replay = await postReviewedDepositPendingCredit(creditRuntime, approval, policy, now + 1); - assert.equal(replay.replayed, true); assert.equal(replay.journalId, first.journalId); - const lateReplay = await postReviewedDepositPendingCredit(creditRuntime, approval, policy, expiresAt + 1); - assert.equal(lateReplay.replayed, true, 'a retry of an already committed review returns its receipt after expiry'); - await assert.rejects(postReviewedDepositPendingCredit(creditRuntime, - { ...approval, subject: { ...subject, amountAtomicXmr: '1' } }, policy, now + 2), /signature/); - - const pendingId = `customer:${buyer.actor}:deposit-pending:${first.creditId}`; - const posted = await f.admin.query('SELECT id,normal,balance::text AS balance FROM wk_custody.accounts WHERE id=ANY($1::text[])', - [[pendingId, 'treasury:monero:pending', `customer:${buyer.actor}:available`]]); - assert.deepEqual(posted.rows.map(row => [row.id, row.normal, row.balance]).sort((a,b) => String(a[0]).localeCompare(String(b[0]))), [ - [pendingId, 'credit', observation.amountAtomicXmr], ['treasury:monero:pending', 'debit', observation.amountAtomicXmr], - ].sort((a,b) => String(a[0]).localeCompare(String(b[0]))), 'pending credit must never enter spendable available balance'); - assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.journals WHERE kind='monero-deposit-pending-credit'")).rows[0].n, 1); - assert.equal((await f.market.audit()).assetsAtomicXmr, observation.amountAtomicXmr); - - const missing = await persistMoneroObservationScan(observer, { scanId: randomUUID(), walletFingerprint: wallet, - expectedScanHeight: 100, scannedAt: now + 100, - page: { ...page, fromHeight: 90, walletHeight: 101, throughHeight: 101, incoming: [] } }); - assert.equal(missing.notSeen, 1); - assert.equal(await reconcileDepositCreditReorgHolds(creditRuntime, wallet, now + 110), 1); - assert.equal(await reconcileDepositCreditReorgHolds(creditRuntime, wallet, now + 111), 0, 'hold replay is idempotent'); - const reappeared = await persistMoneroObservationScan(observer, { scanId: randomUUID(), walletFingerprint: wallet, - expectedScanHeight: 101, scannedAt: now + 200, - page: { ...page, fromHeight: 90, walletHeight: 102, throughHeight: 102, - incoming: [{ ...observation, confirmations: 13 }] } }); - assert.equal(reappeared.reappeared, 1); - assert.equal(await reconcileDepositCreditReorgHolds(creditRuntime, wallet, now + 210), 1); - assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.monero_deposit_reorg_holds')).rows[0].n, 2); - assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.journals')).rows[0].n, 1, - 'reorg/reappearance must not reverse or duplicate the pending credit journal'); - assert.equal((await f.admin.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1', [pendingId])).rows[0].balance, - observation.amountAtomicXmr); - await assert.rejects(creditRuntime.query('UPDATE wk_custody.monero_deposit_credit_events SET amount_atomic_xmr=1')); - await assert.rejects(creditRuntime.query('DELETE FROM wk_custody.monero_deposit_reorg_holds')); - - const releaseSubject = await prepareDepositCreditReleaseReview(creditRuntime, first.creditId, policy, now + 220); - assert.equal(releaseSubject.unresolvedHolds.length, 2, 'release review must include prior disappearance and reappearance evidence'); - const releaseNonce = randomUUID(), releaseExpiresAt = now + 280; - const releaseApproval = { subject: releaseSubject, reviewerOwner: host.actor, reviewerPublicKey: host.publicKey, - nonce: releaseNonce, expiresAt: releaseExpiresAt, - signature: host.sign(depositCreditReleaseApprovalMessage(releaseSubject, host.actor, releaseNonce, releaseExpiresAt)) }; - const releases = await Promise.all([ - releaseReviewedDepositCredit(creditRuntime, releaseApproval, policy, now + 221), - releaseReviewedDepositCredit(secondCreditRuntime, releaseApproval, policy, now + 221), - ]); - assert.equal(releases.filter(item => !item.replayed).length, 1); - const released = releases.find(item => !item.replayed)!; - assert.equal(released.amountAtomicXmr, observation.amountAtomicXmr); - const releaseReplay = await releaseReviewedDepositCredit(creditRuntime, releaseApproval, policy, releaseExpiresAt + 1); - assert.equal(releaseReplay.replayed, true, 'exact release retry returns the committed result after signature expiry'); - assert.equal((await f.admin.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1', [pendingId])).rows[0].balance, '0'); - assert.equal((await f.admin.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1', [`customer:${buyer.actor}:available`])).rows[0].balance, - observation.amountAtomicXmr); - assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.monero_deposit_reorg_resolutions WHERE release_id=$1', - [released.releaseId])).rows[0].n, 2); - assert.equal((await f.market.audit()).assetsAtomicXmr, observation.amountAtomicXmr); - - const laterMissing = await persistMoneroObservationScan(observer, { scanId: randomUUID(), walletFingerprint: wallet, - expectedScanHeight: 102, scannedAt: now + 300, - page: { ...page, fromHeight: 90, walletHeight: 103, throughHeight: 103, incoming: [] } }); - assert.equal(laterMissing.notSeen, 1); - assert.equal(await reconcileDepositCreditReorgHolds(creditRuntime, wallet, now + 310), 1); - await assert.rejects(f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' }), - /reorg review/); - - const laterReappeared = await persistMoneroObservationScan(observer, { scanId: randomUUID(), walletFingerprint: wallet, - expectedScanHeight: 103, scannedAt: now + 400, - page: { ...page, fromHeight: 90, walletHeight: 104, throughHeight: 104, - incoming: [{ ...observation, confirmations: 15 }] } }); - assert.equal(laterReappeared.reappeared, 1); - assert.equal(await reconcileDepositCreditReorgHolds(creditRuntime, wallet, now + 410), 1); - const unresolved = await f.admin.query(`SELECT hold_id::text FROM wk_custody.monero_deposit_reorg_holds h - WHERE h.credit_id=$1 AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions r WHERE r.hold_id=h.hold_id) - ORDER BY h.observation_event_sequence`, [first.creditId]); - assert.equal(unresolved.rowCount, 2, 'both the later disappearance and reappearance events require explicit resolution'); - let resolutionClock = now + 420; - for (const hold of unresolved.rows) { - const resolutionSubject = await prepareDepositReorgResolution(creditRuntime, first.creditId, hold.hold_id, policy, resolutionClock); - const resolutionNonce = randomUUID(), resolutionExpiresAt = resolutionClock + 60; - const resolutionApproval = { subject: resolutionSubject, reviewerOwner: host.actor, reviewerPublicKey: host.publicKey, - nonce: resolutionNonce, expiresAt: resolutionExpiresAt, - signature: host.sign(depositReorgResolutionMessage(resolutionSubject, host.actor, resolutionNonce, resolutionExpiresAt)) }; - const resolved = await resolveReleasedDepositReorgHold(creditRuntime, resolutionApproval, policy, resolutionClock + 1); - assert.equal(resolved.replayed, false); - assert.equal((await resolveReleasedDepositReorgHold(creditRuntime, resolutionApproval, policy, resolutionClock + 2)).replayed, true); - resolutionClock += 10; - } - assert.equal((await f.admin.query(`SELECT count(*)::int AS n FROM wk_custody.monero_deposit_reorg_holds h - WHERE h.credit_id=$1 AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions r WHERE r.hold_id=h.hold_id)`, - [first.creditId])).rows[0].n, 0); - await f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' }); - assert.equal((await f.admin.query('SELECT count(*)::int AS n FROM wk_custody.journals')).rows[0].n, 3, - 'one pending credit, one release, and one authorized sandbox withdrawal are journaled'); - assert.equal((await f.market.audit()).assetsAtomicXmr, observation.amountAtomicXmr); - } finally { await custodyRuntime.end(); await secondCreditRuntime.end(); await creditRuntime.end(); await observer.end(); } -}); - -test('database rejects unbalanced, fractional, negative, historical append and oversized postings at commit', async t => { - const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); - const c = await f.admin.connect(); - try { - await c.query('BEGIN'); - await c.query("INSERT INTO wk_custody.journals(id,kind,created) VALUES('bad','test',0)"); - await c.query("INSERT INTO wk_custody.entries VALUES('bad',0,'treasury:mock',1,0)"); - await assert.rejects(c.query('COMMIT'), /Unbalanced/); await c.query('ROLLBACK'); - await assert.rejects(c.query("INSERT INTO wk_custody.entries VALUES($1,9,'treasury:mock',1,0)", [`credit:${buyer.actor}`]), /committed/); - for (const amount of ['-1', '1.5', '1000000000000000000000000000000']) await assert.rejects(c.query('INSERT INTO wk_custody.accounts VALUES($1,$2,$3)', [randomUUID(), 'debit', amount])); - } finally { c.release(); } - assert.equal((await f.market.audit()).journals, 1); -}); - -test('two independent pools serialize duplicate receipts, competing reservations and withdrawals without overspending', async t => { - const f = await fixture(t), second = await PostgresCustody.open(f.runtime, options); - try { - const credit = envelope(f.market.id, buyer, { type: 'mock-credit' }); - const credits = await Promise.all([f.market.execute(credit), second.execute(credit)]); - assert.deepEqual(credits[0], credits[1]); - const listing = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '800000000000', feeBps: 250 }) as CustodyListing; - await f.execute(other, { type: 'mock-credit' }); - const races = await Promise.allSettled([f.market.execute(envelope(f.market.id, buyer, { type: 'reserve', listingId: listing.id, priceAtomicXmr: listing.priceAtomicXmr, feeBps: 250 })), - second.execute(envelope(f.market.id, other, { type: 'reserve', listingId: listing.id, priceAtomicXmr: listing.priceAtomicXmr, feeBps: 250 }))]); - assert.equal(races.filter(r => r.status === 'fulfilled').length, 1); - const winner = races.find(r => r.status === 'fulfilled') as PromiseFulfilledResult; - assert.equal(winner.value.sellerAtomicXmr, '772000000000'); - assert.equal((await f.activity(seller)).availableAtomicXmr, '0'); // No unimplemented NFT completion credit. - const loser = winner.value.buyer === buyer.actor ? other : buyer; - const withdrawals = await Promise.allSettled([f.market.execute(envelope(f.market.id, loser, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '900000000000', maxFeeAtomicXmr: '1000' })), - second.execute(envelope(f.market.id, loser, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '900000000000', maxFeeAtomicXmr: '1000' }))]); - assert.equal(withdrawals.filter(r => r.status === 'fulfilled').length, 1); - assert.equal((await f.activity(loser)).withdrawalReservedAtomicXmr, '900000001000'); - assert.equal((await f.market.audit()).assetsAtomicXmr, '2000000000000'); - assert.equal((await f.activity(identity())).orders.length, 0); - } finally { await second.close(); } -}); - -test('cancel/replay/reconnect preserves exact money, and receipt-insert failure rolls back balance and outbox', async t => { - const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); - const w = await f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '123', maxFeeAtomicXmr: '5' }) as CustodyWithdrawal; - const cancel = envelope(f.market.id, buyer, { type: 'cancel-withdrawal', withdrawalId: w.id }); - await f.market.execute(cancel); - const reopened = await PostgresCustody.open(f.runtime, options); - try { - assert.equal(reopened.id, f.market.id); assert.deepEqual(await reopened.execute(cancel), await f.market.execute(cancel)); - await assert.rejects(reopened.execute(envelope(f.market.id, buyer, { type: 'mock-credit' }, cancel.nonce)), /Nonce/); - await f.admin.query("CREATE FUNCTION wk_custody.inject_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture failure'; END $$; CREATE TRIGGER fail_receipt BEFORE INSERT ON wk_custody.receipts FOR EACH ROW EXECUTE FUNCTION wk_custody.inject_failure()"); - await assert.rejects(f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '200', maxFeeAtomicXmr: '0' })); - assert.equal((await f.activity()).availableAtomicXmr, '1000000000000'); - assert.equal((await f.activity()).withdrawals.length, 1); - await f.admin.query('DROP TRIGGER fail_receipt ON wk_custody.receipts'); - assert.equal((await f.market.audit()).journals, 3); - } finally { await reopened.close(); } -}); - -test('exact amounts above JS safe integer survive NUMERIC, JSON signatures and audit', async t => { - const f = await fixture(t); - // Seed a balanced synthetic fixture through SQL, not an HTTP mint/admin endpoint. - const c = await f.admin.connect(), large = '9007199254740993'; - try { - await c.query('BEGIN'); - await c.query("INSERT INTO wk_custody.accounts(id,normal) VALUES('treasury:mock','debit'),($1,'credit')", - [`customer:${buyer.actor}:available`]); - await c.query("INSERT INTO wk_custody.journals(id,kind,created) VALUES('large','fixture',0)"); - await c.query("INSERT INTO wk_custody.entries VALUES('large',0,'treasury:mock',$1,0),('large',1,$2,0,$1)", [large, `customer:${buyer.actor}:available`]); - await c.query('COMMIT'); - } finally { c.release(); } - assert.equal((await f.activity()).availableAtomicXmr, large); - await f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: large, maxFeeAtomicXmr: '0' }); - assert.equal((await f.activity()).withdrawalReservedAtomicXmr, large); assert.equal((await f.market.audit()).assetsAtomicXmr, large); -}); - -test('SQLite cutover preserves identity, history, prepared attempts, ordering and replay; source cannot restart', async t => { - const f = await fixture(t); - // Import requires truly empty target, even a previously initialized empty custodian is rejected. - await f.admin.query('DELETE FROM wk_custody.custody_meta'); - const dir = await mkdtemp(join(tmpdir(), 'custody-pg-import-')); - t.after(() => rm(dir, { recursive: true, force: true })); - const source = await CustodyMarket.open(dir, options); - const originalId = source.id, credit = envelope(originalId, buyer, { type: 'mock-credit' }); - source.execute(credit); - const w = source.execute(envelope(originalId, buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '12345', maxFeeAtomicXmr: '100' })) as CustodyWithdrawal; - const attempt = randomUUID(), tx = `fixture:${'b'.repeat(64)}`; - source.prepareWithdrawalFixture(w.id, attempt, tx, '50'); - const listing = source.execute(envelope(originalId, seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '10000', feeBps: 250 })) as CustodyListing; - const order = source.execute(envelope(originalId, buyer, { type: 'reserve', listingId: listing.id, priceAtomicXmr: listing.priceAtomicXmr, feeBps: 250 })) as CustodyOrder; - const oldAudit = source.audit(); source.close(); - const file = join(dir, 'testnet', 'custody-sandbox.sqlite'); - const imported = await importSqliteCustody(file, f.admin); - assert.equal(imported.custodianId, originalId); - assert.deepEqual(await importSqliteCustody(file, f.admin), imported); - await assert.rejects(CustodyMarket.open(dir, options), /frozen/); - const pg = await PostgresCustody.open(f.runtime, options); - try { - assert.deepEqual(await pg.audit(), oldAudit); - assert.equal((await pg.execute(credit) as CustodyActivity).availableAtomicXmr, '1000000000000'); // Original receipt, not current balance. - const activity = await pg.execute(envelope(originalId, buyer, { type: 'activity', offset: 0 })) as CustodyActivity; - assert.equal(activity.withdrawals[0].attemptId, attempt); assert.equal(activity.withdrawals[0].transactionId, tx); - assert.equal(activity.orders[0].id, order.id); - await pg.execute(envelope(originalId, buyer, { type: 'cancel-reservation', orderId: order.id })); - assert.equal((await pg.execute(envelope(originalId, buyer, { type: 'activity', offset: 0 })) as CustodyActivity).purchaseReservedAtomicXmr, '0'); - await assert.rejects(pg.execute(envelope(originalId, buyer, { type: 'cancel-withdrawal', withdrawalId: w.id })), /unprepared/); - } finally { await pg.close(); } - const sqlite = new DatabaseSync(file); try { sqlite.prepare("UPDATE accounts SET balance='1' WHERE id=?").run(`customer:${buyer.actor}:available`); } finally { sqlite.close(); } - await assert.rejects(importSqliteCustody(file, f.admin), /source changed/); -}); - -test('PostgreSQL HTTP custody integration, safe storage binding and backup refusal use the real runtime role', { timeout: 45000 }, async t => { - const f = await fixture(t), dir = await mkdtemp(join(tmpdir(), 'custody-pg-http-')); - const urlFile = join(dir, 'pg-url'); await writeFile(urlFile, 'postgresql://custody_runtime@127.0.0.1/custody_test', { mode: 0o600 }); - const env = { ...process.env, HOST: '127.0.0.1', PORT: '0', NODE_ENV: 'test', WIKIDECK_DATA_DIR: dir, - WIKIDECK_CUSTODY_MODE: 'sandbox', WIKIDECK_CUSTODY_STORAGE: 'postgres', WIKIDECK_CUSTODY_PG_URL_FILE: urlFile, - WIKIDECK_CUSTODY_PG_LOCAL_TEST: '1', WIKIDECK_PG_DISPOSABLE_TEST: '1', WIKIDECK_CUSTODY_CANONICAL_REHEARSAL: '1', - WIKIDECK_PAYMENT_MODE: 'mock', WIKIDECK_PEERS: '' }; - const child = spawn(process.execPath, ['--experimental-strip-types', 'server/index.ts'], { env, stdio: ['ignore', 'pipe', 'pipe'] }); - const closed = new Promise(resolve => child.once('close', () => resolve())); - t.after(async () => { if (child.exitCode === null) child.kill('SIGTERM'); await closed; await rm(dir, { recursive: true, force: true }); }); - const base = await new Promise((resolve, reject) => { - let log = ''; const timer = setTimeout(() => reject(new Error('HTTP startup timeout')), 25000); - const listener = (chunk: Buffer) => { log += chunk.toString(); const m = /Wikideck RandomX testnet: (http:\/\/127\.0\.0\.1:\d+)/.exec(log); if (m) { clearTimeout(timer); resolve(m[1]); } }; - child.stdout.on('data', listener); child.stderr.on('data', listener); - child.once('exit', () => { clearTimeout(timer); reject(new Error('HTTP startup failed')); }); - }); - const page = await (await fetch(`${base}/api/custody`)).json(); assert.equal(page.info.custodianId, f.market.id); - const observerStream = await f.admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='canonical-inbox:v1:stream'"); - assert.match(observerStream.rows[0].value, /^[a-f0-9-]{36}$/); - const command = envelope(f.market.id, buyer, { type: 'mock-credit' }); - const response = await fetch(`${base}/api/custody/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(command) }); - assert.equal(response.status, 200); assert.equal((await response.json()).availableAtomicXmr, '1000000000000'); - assert.match(response.headers.get('cache-control') ?? '', /no-store/); - assert.equal(JSON.parse(await readFile(join(dir, 'testnet', 'custody-postgres.json'), 'utf8')).custodianId, f.market.id); - child.kill('SIGTERM'); await closed; assert.equal(child.exitCode, 0, 'opt-in observer should drain before custody shutdown'); - await assert.rejects(createBackup(dir, join(dir, '..', `refused-${randomUUID()}`)), /coordinated/); - await assert.rejects(openCustodyStorage(dir, options, { WIKIDECK_CUSTODY_STORAGE: 'sqlite' }), /fallback prohibited/); -}); - -test('configuration requires secret files, verified TLS, literal backend, and loopback-only plaintext test transport', async t => { - const dir = await mkdtemp(join(tmpdir(), 'custody-pg-config-')); t.after(() => rm(dir, { recursive: true, force: true })); - const file = join(dir, 'connection'); - assert.equal(custodyStorage({}), 'sqlite'); assert.throws(() => custodyStorage({ WIKIDECK_CUSTODY_STORAGE: 'other' })); - await assert.rejects(openCustodyStorage(dir, options, { WIKIDECK_CUSTODY_STORAGE: 'sqlite', - WIKIDECK_CUSTODY_CANONICAL_REHEARSAL: '1', WIKIDECK_PG_DISPOSABLE_TEST: '1' }), /requires PostgreSQL/); - await assert.rejects(openCustodyStorage(dir, options, { WIKIDECK_CUSTODY_STORAGE: 'postgres', - WIKIDECK_CUSTODY_CANONICAL_REHEARSAL: '1' }), /disposable database test guard/); - await assert.rejects(openCustodyStorage(dir, options, { NODE_ENV: 'production', WIKIDECK_CUSTODY_STORAGE: 'postgres', - WIKIDECK_CUSTODY_CANONICAL_REHEARSAL: '1', WIKIDECK_PG_DISPOSABLE_TEST: '1' }), /requires PostgreSQL/); - await assert.rejects(openCustodyStorage(dir, options, { WIKIDECK_CUSTODY_STORAGE: 'postgres', - WIKIDECK_CUSTODY_CANONICAL_REHEARSAL: 'yes', WIKIDECK_PG_DISPOSABLE_TEST: '1' }), /exactly 0 or 1/); - await assert.rejects(postgresConfig({}), /configuration/); - await writeFile(file, 'postgresql://user:secret@database.example/custody', { mode: 0o600 }); - const secure = await postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: file }); assert.equal((secure.ssl as { rejectUnauthorized: boolean }).rejectUnauthorized, true); - await assert.rejects(postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: file, WIKIDECK_CUSTODY_PG_LOCAL_TEST: '1' }), /configuration/); - await writeFile(file, 'postgresql://user:secret@127.0.0.1/custody?sslmode=disable'); - await assert.rejects(postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: file }), /configuration/); -}); - -test('PostgreSQL market disable preserves existing claims and signed withdrawal cancellation', async t => { - const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); - const listing = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '100', feeBps: 250 }) as CustodyListing; - const order = await f.execute(buyer, { type: 'reserve', listingId: listing.id, priceAtomicXmr: '100', feeBps: 250 }) as CustodyOrder; - const disabled = await PostgresCustody.open(f.runtime, { ...options, marketEnabled: false }); - try { - assert.equal(disabled.info().marketEnabled, false); assert.deepEqual((await disabled.browse()).listings, []); - await assert.rejects(disabled.execute(envelope(disabled.id, other, { type: 'mock-credit' })), /disabled/); - await disabled.execute(envelope(disabled.id, buyer, { type: 'cancel-reservation', orderId: order.id })); - const w = await disabled.execute(envelope(disabled.id, buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' })) as CustodyWithdrawal; - await disabled.execute(envelope(disabled.id, buyer, { type: 'cancel-withdrawal', withdrawalId: w.id })); - assert.equal((await disabled.audit()).assetsAtomicXmr, '1000000000000'); - } finally { await disabled.close(); } -}); - -test('full PostgreSQL dump/isolated restore preserves custody identity, receipts and journal backing', async t => { - const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); - const cmd = envelope(f.market.id, buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '500', maxFeeAtomicXmr: '20' }); - const original = await f.market.execute(cmd), audit = await f.market.audit(); - const dir = await mkdtemp(join(tmpdir(), 'custody-pg-restore-')), archive = join(dir, 'custody.dump'); - const run = promisify(execFile), database = `restore_${randomUUID().replaceAll('-', '')}`; - try { - // Fixture role and TCP transport are local/trust-only on the isolated tmpfs network. No production credentials. - await run('pg_dump', ['-h', '127.0.0.1', '-U', 'postgres', '-d', 'custody_test', '-Fc', '-f', archive]); - await f.admin.query(`CREATE DATABASE "${database}"`); - await run('pg_restore', ['-h', '127.0.0.1', '-U', 'postgres', '-d', database, '--exit-on-error', archive]); - const restored = await PostgresCustody.open({ ...f.runtime, database }, options); - try { - assert.equal(restored.id, f.market.id); assert.deepEqual(await restored.audit(), audit); - assert.deepEqual(await restored.execute(cmd), original); - const balance = await restored.execute(envelope(restored.id, buyer, { type: 'activity', offset: 0 })) as CustodyActivity; - assert.equal(balance.availableAtomicXmr, '999999999480'); assert.equal(balance.withdrawalReservedAtomicXmr, '520'); - } finally { await restored.close(); } - } finally { - await f.admin.query(`DROP DATABASE IF EXISTS "${database}" WITH (FORCE)`); - await rm(dir, { recursive: true, force: true }); - } -}); - -test('coordinated node/PostgreSQL backup holds both writer fences and verifies matching custodian identity', async t => { - const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); - const dir = await mkdtemp(join(tmpdir(), 'wikideck-coordinated-backup-')); - t.after(() => rm(dir, { recursive: true, force: true })); - const data = join(dir, 'node-data'), networkDir = join(data, 'testnet'); - await mkdir(networkDir, { recursive: true }); - const nativeEngine = new RandomX({ binary: process.env.RANDOMX_BINARY }); - const sourceLedger = await Ledger.open(data, nativeEngine); - const blockTemplate = makeTemplate([], {}, seller.actor, seller.publicKey, [], fixtureArticle(56000), fixtureStats(), GENESIS_TIME + 30); - const header = structuredClone(blockTemplate.header); - let mined: Awaited> = null; - for (let batch = 0; batch < 16 && !mined; batch++) { - mined = await nativeEngine.mine(header.key, miningBlob(header), header.target, 32, 64); - if (!mined) header.nonce += 64; - } - assert.ok(mined, 'Native RandomX cold-backup fixture mining exceeded bounded attempts'); - header.nonce = mined.nonce; - const blockHash = sha256(miningBlob(header)); - const sourceBlock: TestnetBlock = { ...header, hash: blockHash, tokenId: mintId(header), powHash: mined.hash, - signature: seller.sign(blockClaimMessage(blockHash)), transactions: [] }; - await sourceLedger.submit(sourceBlock); - sourceLedger.close(); nativeEngine.close(); - const marketDb = new DatabaseSync(join(networkDir, 'market.sqlite')); - try { - marketDb.exec('CREATE TABLE market_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL)'); - marketDb.prepare('INSERT INTO market_meta VALUES(?,?)').run('identity', JSON.stringify({ version: 1, - network: NETWORK, chainId: CHAIN_ID, id: randomUUID() })); - } finally { marketDb.close(); } - await writeFile(join(networkDir, 'custody-postgres.json'), JSON.stringify({ version: 1, custodianId: f.market.id }), { mode: 0o600 }); - // Model a legitimate post-cutover node: its frozen SQLite source is historical and PostgreSQL is authoritative. - const cutoverDigest = 'a'.repeat(64), legacyDb = new DatabaseSync(join(networkDir, 'custody-sandbox.sqlite')); - try { - legacyDb.exec('CREATE TABLE custody_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL)'); - legacyDb.prepare('INSERT INTO custody_meta VALUES(?,?)').run('postgres_cutover', cutoverDigest); - } finally { legacyDb.close(); } - await f.admin.query('INSERT INTO wk_custody.custody_meta(key,value) VALUES($1,$2)', ['import_source', cutoverDigest]); - const runtimeUrl = join(dir, 'runtime-url'), backupUrl = join(dir, 'backup-url'); - await writeFile(runtimeUrl, 'postgresql://custody_runtime@127.0.0.1/custody_test', { mode: 0o600 }); - const backupRole = `custody_backup_${randomUUID().replaceAll('-', '')}`; - await f.admin.query(`CREATE ROLE ${backupRole} LOGIN; GRANT CONNECT ON DATABASE custody_test TO ${backupRole}; - GRANT USAGE ON SCHEMA wk_custody TO ${backupRole}; GRANT SELECT ON ALL TABLES IN SCHEMA wk_custody TO ${backupRole}; - GRANT SELECT ON ALL SEQUENCES IN SCHEMA wk_custody TO ${backupRole}`); - await writeFile(backupUrl, `postgresql://${backupRole}@127.0.0.1/custody_test`, { mode: 0o600 }); - const env = { ...process.env, WIKIDECK_CUSTODY_PG_URL_FILE: runtimeUrl, - WIKIDECK_CUSTODY_PG_BACKUP_URL_FILE: backupUrl, WIKIDECK_CUSTODY_PG_LOCAL_TEST: '1' }; - const destination = join(dir, 'paired-backup'); - const inheritedLibpq = { hostAddr: process.env.PGHOSTADDR, sslMode: process.env.PGSSLMODE, sslRoot: process.env.PGSSLROOTCERT }; - let manifest: Awaited>; - try { - process.env.PGHOSTADDR = '192.0.2.123'; process.env.PGSSLMODE = 'disable'; process.env.PGSSLROOTCERT = 'invalid-inherited-root.pem'; - manifest = await createCoordinatedCustodyBackup(data, destination, env); - } finally { - if (inheritedLibpq.hostAddr === undefined) delete process.env.PGHOSTADDR; else process.env.PGHOSTADDR = inheritedLibpq.hostAddr; - if (inheritedLibpq.sslMode === undefined) delete process.env.PGSSLMODE; else process.env.PGSSLMODE = inheritedLibpq.sslMode; - if (inheritedLibpq.sslRoot === undefined) delete process.env.PGSSLROOTCERT; else process.env.PGSSLROOTCERT = inheritedLibpq.sslRoot; - } - assert.equal(manifest.custodianId, f.market.id); assert.equal(manifest.database, 'custody_test'); - assert.match(manifest.postgresServerVersion, /^\d+\.\d+/); - assert.match(manifest.postgresServerVersionNum, /^\d{5,6}$/); - assert.match(manifest.pgDumpVersion, /^pg_dump \(PostgreSQL\) /); - assert.match(manifest.pgRestoreVersion, /^pg_restore \(PostgreSQL\) /); - assert.equal(manifest.sqliteCutoverSourceDigest, cutoverDigest); - assert.equal((await verifyCoordinatedCustodyBackup(destination)).nodeManifestSha256, manifest.nodeManifestSha256); - if (process.platform !== 'win32') { - const oldRestore = join(dir, 'old-pg-restore'); - await writeFile(oldRestore, '#!/bin/sh\nprintf "pg_restore (PostgreSQL) 9.6\\n"\n', { mode: 0o700 }); - await chmod(oldRestore, 0o700); - await assert.rejects(verifyCoordinatedCustodyBackup(destination, oldRestore), /older than archive pg_dump/); - } - const extraNodeFile = join(destination, 'node', 'unmanifested.txt'); - await writeFile(extraNodeFile, 'not part of backup manifest'); - await assert.rejects(verifyCoordinatedCustodyBackup(destination), /Unexpected coordinated node backup contents/); - await rm(extraNodeFile); - await assert.rejects(lstat(join(destination, 'node', 'custody-sandbox.sqlite')), { code: 'ENOENT' }); - const restoredNodeDir = join(dir, 'restored-node'); - await restoreBackup(join(destination, 'node'), restoredNodeDir); - const verifierEngine = new RandomX({ binary: process.env.RANDOMX_BINARY }); - const verifierLedger = await Ledger.open(restoredNodeDir, verifierEngine); - try { - assert.equal(verifierLedger.summary().tip, sourceBlock.hash); - assert.equal(verifierLedger.nft(sourceBlock.tokenId)?.owner, seller.actor); - } finally { verifierLedger.close(); verifierEngine.close(); } - const restoreDatabase = `pair_restore_${randomUUID().replaceAll('-', '')}`, run = promisify(execFile); - await f.admin.query(`CREATE DATABASE "${restoreDatabase}"`); - try { - await run('pg_restore', ['-h', '127.0.0.1', '-U', 'postgres', '-d', restoreDatabase, '--exit-on-error', join(destination, 'custody.dump')]); - const restored = await PostgresCustody.open({ ...f.runtime, database: restoreDatabase }, options); - try { - assert.equal(restored.id, f.market.id); - assert.deepEqual(await restored.audit(), await f.market.audit()); - assert.equal(await restored.importSource(), cutoverDigest); - const activity = await restored.execute(envelope(restored.id, buyer, { type: 'activity', offset: 0 })) as CustodyActivity; - assert.equal(activity.availableAtomicXmr, '1000000000000'); - } finally { await restored.close(); } - } finally { await f.admin.query(`DROP DATABASE IF EXISTS "${restoreDatabase}" WITH (FORCE)`); } - const backupRolePool = new Pool({ ...config, user: backupRole }); - try { - await assert.rejects(backupRolePool.query("UPDATE wk_custody.accounts SET balance=0")); - await assert.rejects(backupRolePool.query("DELETE FROM wk_custody.entries")); - await assert.rejects(backupRolePool.query("SELECT nextval('wk_custody.journals_sequence_seq')")); - } finally { await backupRolePool.end(); } - assert.equal((await f.market.audit()).assetsAtomicXmr, '1000000000000'); - await assert.rejects(createCoordinatedCustodyBackup(data, destination, env), /already exists/); - const staleLock = join(data, 'testnet-node.lock'), lockedDestination = join(dir, 'locked-backup'); - await writeFile(staleLock, 'operator-lock'); - await assert.rejects(createCoordinatedCustodyBackup(data, lockedDestination, env), /Node directory is locked/); - await assert.rejects(lstat(lockedDestination)); await rm(staleLock); - - const failedDump = join(dir, 'failed-dump'), brokenEnv = { ...env, WIKIDECK_PG_DUMP_BINARY: join(dir, 'missing-pg-dump') }; - await assert.rejects(createCoordinatedCustodyBackup(data, failedDump, brokenEnv), /client utility failed to start/); - await assert.rejects(verifyCoordinatedCustodyBackup(failedDump), /ENOENT/); - // A failed external dump releases PostgreSQL's shared-writer fence; normal sandbox commands still work. - const recoveredWrite = await f.execute(other, { type: 'mock-credit' }) as CustodyActivity; - assert.equal(recoveredWrite.availableAtomicXmr, '1000000000000'); - - const mismatchedBinding = join(dir, 'mismatch-binding'), bindingPath = join(networkDir, 'custody-postgres.json'); - const originalBinding = await readFile(bindingPath, 'utf8'); - await writeFile(bindingPath, JSON.stringify({ version: 1, custodianId: randomUUID() }), { mode: 0o600 }); - await assert.rejects(createCoordinatedCustodyBackup(data, mismatchedBinding, env), /identities differ/); - await writeFile(bindingPath, originalBinding, { mode: 0o600 }); - await assert.rejects(verifyCoordinatedCustodyBackup(mismatchedBinding), /ENOENT/); - - const corruptDump = join(destination, 'custody.dump'); - await writeFile(corruptDump, 'corrupt'); - await assert.rejects(verifyCoordinatedCustodyBackup(destination), /hash\/size mismatch/); - // Even if an operator or attacker rewrites the unsigned manifest hashes, pg_restore parsing rejects junk bytes. - const manifestPath = join(destination, 'manifest.json'), altered = JSON.parse(await readFile(manifestPath, 'utf8')); - altered.postgresDump.bytes = Buffer.byteLength('corrupt'); - altered.postgresDump.sha256 = createHash('sha256').update('corrupt').digest('hex'); - await writeFile(manifestPath, JSON.stringify(altered)); - await assert.rejects(verifyCoordinatedCustodyBackup(destination), /dump archive is invalid/); - const unrelatedDatabase = `empty_${randomUUID().replaceAll('-', '')}`, unrelatedDump = join(dir, 'unrelated.dump'); - await f.admin.query(`CREATE DATABASE "${unrelatedDatabase}"`); - try { - await run('pg_dump', ['-h', '127.0.0.1', '-U', 'postgres', '-d', unrelatedDatabase, '-Fc', '-f', unrelatedDump]); - await writeFile(corruptDump, await readFile(unrelatedDump)); - const validButWrong = JSON.parse(await readFile(manifestPath, 'utf8')); - const unrelated = await readFile(unrelatedDump); - validButWrong.postgresDump.bytes = unrelated.length; - validButWrong.postgresDump.sha256 = createHash('sha256').update(unrelated).digest('hex'); - await writeFile(manifestPath, JSON.stringify(validButWrong)); - await assert.rejects(verifyCoordinatedCustodyBackup(destination), /missing required custody accounting tables/); - } finally { await f.admin.query(`DROP DATABASE IF EXISTS "${unrelatedDatabase}" WITH (FORCE)`); } -}); - -test('failed cutover freezes the source, rolls back the entire target, and resumes without duplicate balances', async t => { - const f = await fixture(t); await f.admin.query('DELETE FROM wk_custody.custody_meta'); - const dir = await mkdtemp(join(tmpdir(), 'custody-pg-failed-import-')); t.after(() => rm(dir, { recursive: true, force: true })); - const source = await CustodyMarket.open(dir, options); - source.execute(envelope(source.id, buyer, { type: 'mock-credit' })); source.close(); - const file = join(dir, 'testnet', 'custody-sandbox.sqlite'); - await f.admin.query("CREATE FUNCTION wk_custody.import_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'injected import failure'; END $$; CREATE TRIGGER fail_import BEFORE INSERT ON wk_custody.receipts FOR EACH ROW EXECUTE FUNCTION wk_custody.import_failure()"); - await assert.rejects(importSqliteCustody(file, f.admin), /injected import/); - assert.equal((await f.admin.query('SELECT count(*)::integer AS n FROM wk_custody.accounts')).rows[0].n, 0); - await assert.rejects(CustodyMarket.open(dir, options), /frozen/); - await f.admin.query('DROP TRIGGER fail_import ON wk_custody.receipts'); - await importSqliteCustody(file, f.admin); - const restored = await PostgresCustody.open(f.runtime, options); - try { assert.equal((await restored.audit()).assetsAtomicXmr, '1000000000000'); } - finally { await restored.close(); } -}); - -test('business audit rejects metadata corruption even when every journal and total remains balanced', async t => { - const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); - const l = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '10000', feeBps: 250 }) as CustodyListing; - const o = await f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: l.feeBps }) as CustodyOrder; - const w = await f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '500', maxFeeAtomicXmr: '20' }) as CustodyWithdrawal; - const probes: [string, unknown[], string][] = [ - ["UPDATE wk_custody.orders SET data=jsonb_set(data,'{buyer}',to_jsonb($1::text)) WHERE id=$2", [other.actor, o.id], 'order-terms-mismatch'], - ["UPDATE wk_custody.orders SET data=jsonb_set(data,'{sellerAtomicXmr}','\"1\"') WHERE id=$1", [o.id], 'order-allocation-mismatch'], - ["UPDATE wk_custody.orders SET data=jsonb_set(data,'{grossAtomicXmr}','\"1.0\"') WHERE id=$1", [o.id], 'order-record-invalid'], - ["UPDATE wk_custody.orders SET status='settled',data=jsonb_set(data,'{status}','\"settled\"') WHERE id=$1", [o.id], 'order-reservation-mismatch'], - ["DELETE FROM wk_custody.outbox WHERE id=$1", [o.id], 'order-state-mismatch'], - ["UPDATE wk_custody.outbox SET data='{}' WHERE id=$1", [o.id], 'order-outbox-invalid'], - ["UPDATE wk_custody.listings SET data=jsonb_set(data,'{feeBps}','500') WHERE id=$1", [l.id], 'order-allocation-mismatch'], - ["UPDATE wk_custody.withdrawals SET data=jsonb_set(data,'{maxFeeAtomicXmr}','\"21\"') WHERE id=$1", [w.id], 'withdrawal-reservation-mismatch'], - ["UPDATE wk_custody.withdrawals SET data=jsonb_set(data,'{actor}',to_jsonb($1::text)) WHERE id=$2", [other.actor, w.id], 'withdrawal-identity-mismatch'], - ["UPDATE wk_custody.outbox SET status='fixture-prepared' WHERE id=$1", [w.id], 'withdrawal-outbox-mismatch'], - ["INSERT INTO wk_custody.outbox VALUES($1,'withdrawal','blocked','{}')", [randomUUID()], 'orphan-outbox'], - ["INSERT INTO wk_custody.accounts(id,normal) VALUES($1,'credit')", [`customer:${buyer.actor}:purchase:${randomUUID()}`], 'orphan-purchase-reserve'], - ]; - const c = await f.admin.connect(); - try { - for (const [sql, args, code] of probes) { - await c.query('BEGIN'); - try { - await c.query(sql, args); - const totals = await c.query("SELECT sum(CASE WHEN normal='debit' THEN balance ELSE -balance END)::text AS net FROM wk_custody.accounts"); - assert.equal(totals.rows[0].net, '0'); // This would pass the old aggregate-only accounting audit. - await assert.rejects(auditPostgres(c), (e: unknown) => e instanceof CustodyIntegrityError && e.code === code, code); - } finally { await c.query('ROLLBACK'); } - } - } finally { c.release(); } - assert.equal((await f.market.audit()).journals, 3); -}); - -test('integrity incident blocks new mutations across instances/restarts but preserves private reads and original receipts', async t => { - const f = await fixture(t), second = await PostgresCustody.open(f.runtime, options); - const credit = envelope(f.market.id, buyer, { type: 'mock-credit' }); await f.market.execute(credit); - const w = await f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '100', maxFeeAtomicXmr: '0' }) as CustodyWithdrawal; - try { - await f.admin.query("UPDATE wk_custody.withdrawals SET data=jsonb_set(data,'{amountAtomicXmr}','\"101\"') WHERE id=$1", [w.id]); - await assert.rejects(f.market.audit(), (e: unknown) => e instanceof CustodyIntegrityError && e.code === 'withdrawal-reservation-mismatch'); - const status = f.market.diagnostics(); assert.equal(status.integrityHold, true); assert.equal(status.mutationHold, true); - const text = JSON.stringify(status); for (const secret of [buyer.actor, w.id, 'postgresql:', 'password']) assert.equal(text.includes(secret), false); - const marker = await f.admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'"); - assert.equal(JSON.parse(marker.rows[0].value).code, 'withdrawal-reservation-mismatch'); - await assert.rejects(second.execute(envelope(second.id, other, { type: 'mock-credit' })), /integrity incident/); - assert.equal((await f.market.execute(credit) as CustodyActivity).availableAtomicXmr, '1000000000000'); - assert.equal((await f.activity()).withdrawalReservedAtomicXmr, '100'); - await assert.rejects(PostgresCustody.open(f.runtime, options), /unresolved-integrity-incident/); - // Restore this disposable corruption to prove that repair alone does not silently clear the incident. - await f.admin.query('UPDATE wk_custody.withdrawals SET data=$1 WHERE id=$2', [JSON.stringify(w), w.id]); - await assert.rejects(f.market.audit(), /unresolved-integrity-incident/); - assert.equal((await f.activity()).availableAtomicXmr, '999999999900'); - const runtimePool = new Pool(f.runtime); - try { await assert.rejects(resolveCustodyIncident(runtimePool, randomUUID()), /Schema-owner/); } - finally { await runtimePool.end(); } - const resolved = await resolveCustodyIncident(f.admin, randomUUID()); assert.ok(resolved.resolutionId); - // Previously detecting instances remain latched even after authorized acknowledgement. - await f.market.audit(); assert.equal(f.market.diagnostics().integrityHold, true); - await assert.rejects(f.execute(other, { type: 'mock-credit' }), /reconciliation/); - const verified = await PostgresCustody.open(f.runtime, options); - try { assert.equal(verified.diagnostics().mutationHold, false); await verified.audit(); } - finally { await verified.close(); } - } finally { await second.close(); } -}); - -test('targeted mutation checks reject corrupt reservations before posting or cancelling their outbox', async t => { - const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); - const w = await f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '500', maxFeeAtomicXmr: '20' }) as CustodyWithdrawal; - await f.admin.query("UPDATE wk_custody.outbox SET data='{}' WHERE id=$1", [w.id]); - await assert.rejects(f.execute(buyer, { type: 'cancel-withdrawal', withdrawalId: w.id }), /integrity check/); - assert.equal((await f.admin.query('SELECT status FROM wk_custody.withdrawals WHERE id=$1', [w.id])).rows[0].status, 'reserved'); - assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.journals WHERE kind='withdrawal-cancellation'")).rows[0].n, 0); - assert.equal((await f.activity()).availableAtomicXmr, '999999999480'); - assert.equal(f.market.diagnostics().integrityHold, true); - await assert.rejects(resolveCustodyIncident(f.admin, randomUUID()), /integrity check/); - assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'")).rows[0].n, 1); -}); - -test('business audit pages past 128 records and catches later corrupted output without dropping claims', async t => { - const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); - let last: CustodyWithdrawal | undefined; - for (let i = 0; i < 130; i++) { - const w = await f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' }) as CustodyWithdrawal; - if (!last || last.id < w.id) last = w; - } - assert.equal((await f.market.audit()).journals, 131); - await f.admin.query("UPDATE wk_custody.outbox SET data='{}' WHERE id=$1", [last!.id]); - await assert.rejects(f.market.audit(), /withdrawal-outbox-invalid/); - assert.equal((await f.activity()).withdrawalReservedAtomicXmr, '130'); -}); - -test('imported fixture completion and cancellation states reconcile their exact terminal postings', async t => { - const f = await fixture(t); await f.admin.query('DELETE FROM wk_custody.custody_meta'); - const dir = await mkdtemp(join(tmpdir(), 'custody-terminal-audit-')); t.after(() => rm(dir, { recursive: true, force: true })); - const source = await CustodyMarket.open(dir, options), id = source.id; - const run = (who: typeof buyer, action: CustodyAction) => source.execute(envelope(id, who, action)); - run(buyer, { type: 'mock-credit' }); - const l = run(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '1000', feeBps: 250 }) as CustodyListing; - const o = run(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 }) as CustodyOrder; - source.settleFixture(o.id, `fixture:${'c'.repeat(64)}`); - const w = run(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1000', maxFeeAtomicXmr: '50' }) as CustodyWithdrawal; - source.prepareWithdrawalFixture(w.id, randomUUID(), `fixture:${'d'.repeat(64)}`, '10'); source.confirmWithdrawalFixture(w.id, `fixture:${'d'.repeat(64)}`); - const cancelled = run(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '100', maxFeeAtomicXmr: '5' }) as CustodyWithdrawal; - run(buyer, { type: 'cancel-withdrawal', withdrawalId: cancelled.id }); - const expected = source.audit(); source.close(); - await importSqliteCustody(join(dir, 'testnet', 'custody-sandbox.sqlite'), f.admin); - const pg = await PostgresCustody.open(f.runtime, options); - try { assert.deepEqual(await pg.audit(), expected); } - finally { await pg.close(); } -}); - -test('sale rehearsal requires explicit disposable instance consent and exact trader-signed reservation terms', async t => { - const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); - await assert.rejects(PostgresCustody.open({ ...f.runtime, database: 'postgres' }, { ...options, disposableSaleRehearsal: true }), /disposable database/); - const l = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '100000000000', feeBps: 250 }) as CustodyListing; - const o = await f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 }) as CustodyOrder; - const t1 = saleTranscript(o, f.market.id); - assert.throws(() => f.market.observeSaleRehearsal(o.id, t1.input), /unavailable/); - await assert.rejects(f.market.observeCanonicalEvents({}), /unavailable/); - const rehearsal = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); - try { - const wrong = structuredClone(t1.input); wrong.lock = { ...t1.input.lock, terms: { ...t1.input.lock.terms, priceAtomicXmr: '1' } } as typeof wrong.lock; - await assert.rejects(rehearsal.observeSaleRehearsal(o.id, wrong), /operation failed/); - const locked = await rehearsal.observeSaleRehearsal(o.id, t1.input); - assert.equal(locked.action, 'hold'); assert.equal(locked.reservationDigest, t1.reservationDigest); - await assert.rejects(rehearsal.observeSaleRehearsal(o.id, { ...t1.input, seed: { ...t1.input.seed, pageId: 2 } }), /authorization changed/); - await assert.rejects(rehearsal.observeSaleRehearsal(o.id, { ...t1.input, blocks: Array(129).fill([]) }), /operation failed/); - const cycle: Record = {}; cycle.self = cycle; - await assert.rejects(rehearsal.observeSaleRehearsal(o.id, cycle), /operation failed/); - await assert.rejects(f.execute(buyer, { type: 'cancel-reservation', orderId: o.id }), /authorization exists/); - assert.equal((await f.activity()).purchaseReservedAtomicXmr, o.grossAtomicXmr); - assert.equal((await f.activity(seller)).availableAtomicXmr, '0'); - assert.equal(rehearsal.info().nftSettlement, false); - await rehearsal.audit(); - } finally { await rehearsal.close(); } -}); - -test('canonical connect/disconnect inbox resumes bounded pages, retries idempotently, and audits after reconnect', async t => { - const f = await fixture(t); - const observer = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); - const streamId = randomUUID(), events = canonicalEvents(streamId); - const first = canonicalPage(streamId, events, 0, 2), second = canonicalPage(streamId, events, 2, 2); - try { - const accepted = await observer.observeCanonicalEvents(first); - assert.equal(accepted.cursor, 2); assert.equal(accepted.height, 0); assert.equal(accepted.tip, GENESIS_HASH); - assert.equal((await observer.observeCanonicalEvents(first)).replayed, true); - // A second consumer may have fetched a larger page before the first writer advanced the inbox. - const overlapping = await observer.observeCanonicalEvents(canonicalPage(streamId, events, 0, 4)); - assert.equal(overlapping.cursor, 4); assert.equal(overlapping.replayed, false); - const stale = await observer.observeCanonicalEvents({ ...first, head: 2, caughtUp: true }); - assert.equal(stale.cursor, 4); assert.equal(stale.head, 4); assert.equal(stale.replayed, true); - const tip = await observer.observeCanonicalEvents(second); - assert.equal(tip.cursor, 4); assert.equal(tip.head, 4); assert.equal(tip.height, 2); assert.equal(tip.tip, 'c'.repeat(64)); - assert.equal(tip.replayed, true); - assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'canonical-inbox:v1:event:%'")).rows[0].n, 4); - await observer.audit(); - } finally { await observer.close(); } - const reopened = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); - try { - const replay = await reopened.observeCanonicalEvents(second); - assert.equal(replay.cursor, 4); assert.equal(replay.height, 2); assert.equal(replay.replayed, true); - await reopened.audit(); - } finally { await reopened.close(); } -}); - -test('gated adapter consumes actual validated local Ledger connect and reorg pages', async t => { - const dir = await mkdtemp(join(tmpdir(), 'wikideck-pg-local-ledger-')); - const pow = new FixturePow(), ledger = await Ledger.open(dir, pow), f = await fixture(t); - const observer = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); - t.after(async () => { await observer.close(); ledger.close(); pow.close(); await rm(dir, { recursive: true, force: true }); }); - const canonical = await fixtureBlock([], {}); - await ledger.submit(canonical); - const first = await observer.observeLedgerCanonicalEvents(ledger); - assert.equal(first.cursor, 1); assert.equal(first.height, 1); assert.equal(first.tip, canonical.hash); - const side = await fixtureBlock([], {}, 50001); - const sideResult = await ledger.submit(side); // Equal-work branch remains noncanonical and emits no event. - assert.equal(sideResult.accepted, true); - assert.equal(ledger.canonicalHead(), 1); - const sideChain = [side]; let sideState = transition({}, side, []), sideChild = side; - let reorgResult = { accepted: true, reorg: false }; - for (let i = 0; i < 16 && !reorgResult.reorg; i++) { - sideChild = await fixtureBlock(sideChain, sideState, 50002 + i); - reorgResult = await ledger.submit(sideChild); - sideState = transition(sideState, sideChild, []); sideChain.push(sideChild); - } - assert.equal(reorgResult.reorg, true, `Expected longer-work side branch to win; events=${JSON.stringify(ledger.canonicalEvents(0))}`); - const expectedCursor = sideChain.length + 2; // initial connect, old-tip disconnect, then each side-branch connect - assert.equal(ledger.canonicalHead(), expectedCursor); - const reorg = await observer.observeLedgerCanonicalEvents(ledger); - assert.equal(reorg.cursor, expectedCursor); assert.equal(reorg.height, sideChain.length); assert.equal(reorg.tip, sideChild.hash); - assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'canonical-inbox:v1:event:%'")).rows[0].n, expectedCursor); - await observer.audit(); -}); - -test('observer consumes a block independently validated with the native RandomX engine', { timeout: 120000 }, async t => { - const binary = resolve(process.env.RANDOMX_BINARY ?? join(process.cwd(), 'native', 'build', 'wikideck-randomx')); - try { await access(binary, constants.X_OK); } - catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - t.skip(`Native RandomX binary absent: ${binary}`); return; - } - const dir = await mkdtemp(join(tmpdir(), 'wikideck-pg-real-randomx-')); - const engine = new RandomX({ binary }), ledger = await Ledger.open(dir, engine), f = await fixture(t); - const observer = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); - t.after(async () => { await observer.close(); ledger.close(); engine.close(); await rm(dir, { recursive: true, force: true }); }); - const template = makeTemplate([], {}, seller.actor, seller.publicKey, [], fixtureArticle(55000), fixtureStats(), GENESIS_TIME + 30); - const header = structuredClone(template.header); - let mined: Awaited> = null; - for (let batch = 0; batch < 16 && !mined; batch++) { - mined = await engine.mine(header.key, miningBlob(header), header.target, 32, 64); - if (!mined) header.nonce += 64; - } - assert.ok(mined, 'Native RandomX fixture mining exceeded bounded attempts'); - header.nonce = mined.nonce; - const blockHash = sha256(miningBlob(header)); - const block: TestnetBlock = { ...header, hash: blockHash, tokenId: mintId(header), powHash: mined.hash, - signature: seller.sign(blockClaimMessage(blockHash)), transactions: [] }; - await ledger.submit(block); // Ledger independently recalculates and validates native RandomX. - const page = await observer.observeLedgerCanonicalEvents(ledger); - assert.equal(page.cursor, 1); assert.equal(page.height, 1); assert.equal(page.tip, block.hash); - await observer.audit(); -}); - -test('scheduled observer bounds catch-up, stops cleanly, and resumes its durable cursor after restart', async t => { - const dir = await mkdtemp(join(tmpdir(), 'wikideck-pg-observer-runner-')); - const pow = new EasyFixturePow(), ledger = await Ledger.open(dir, pow), f = await fixture(t); - const observer = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); - t.after(async () => { await observer.close(); ledger.close(); pow.close(); await rm(dir, { recursive: true, force: true }); }); - const chain: TestnetBlock[] = []; let state: NftStateView = {}; - for (let i = 0; i < 66; i++) { - const block = await fixtureBlock(chain, state, 51000 + i, pow); - await ledger.submit(block); state = transition(state, block, []); chain.push(block); - } - const firstRunner = new CanonicalObserver(observer, ledger, { intervalMs: 60000, pagesPerCycle: 1 }); - try { - const priorMode = process.env.NODE_ENV, priorGate = process.env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL; - try { - process.env.NODE_ENV = 'production'; - assert.throws(() => new CanonicalObserver(observer, ledger), /non-production/); - process.env.NODE_ENV = 'test'; delete process.env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL; - assert.throws(() => new CanonicalObserver(observer, ledger), /non-production/); - } finally { - if (priorMode === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = priorMode; - if (priorGate === undefined) delete process.env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL; - else process.env.WIKIDECK_CUSTODY_CANONICAL_REHEARSAL = priorGate; - } - const first = await firstRunner.start(); - assert.equal(first.state, 'running'); assert.equal(first.cursor, 64); assert.equal(first.head, 66); assert.equal(first.caughtUp, false); - const caughtUp = await firstRunner.pollNow(); - assert.equal(caughtUp.cursor, 66); assert.equal(caughtUp.caughtUp, true); assert.equal(caughtUp.tip, chain.at(-1)!.hash); - await firstRunner.close(); assert.equal(firstRunner.status().state, 'stopped'); - const next = await fixtureBlock(chain, state, 52000, pow); - await ledger.submit(next); - assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'canonical-inbox:v1:event:%'")).rows[0].n, 66); - await assert.rejects(firstRunner.pollNow(), /not running/); - const restarted = new CanonicalObserver(observer, ledger, { intervalMs: 60000, pagesPerCycle: 1 }); - try { - const resumed = await restarted.start(); - assert.equal(resumed.cursor, 67); assert.equal(resumed.head, 67); assert.equal(resumed.caughtUp, true); - assert.equal(resumed.tip, next.hash); - } finally { await restarted.close(); } - assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'canonical-inbox:v1:event:%'")).rows[0].n, 67); - chain.push(next); state = transition(state, next, []); - const automatic = new CanonicalObserver(observer, ledger, { intervalMs: 25, pagesPerCycle: 1 }); - try { - assert.equal((await automatic.start()).cursor, 67); - const automaticBlock = await fixtureBlock(chain, state, 52001, pow); - await ledger.submit(automaticBlock); - const deadline = Date.now() + 3000; - while (automatic.status().cursor < 68 && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10)); - assert.equal(automatic.status().cursor, 68, 'scheduled poll did not consume the new ledger event'); - } finally { await automatic.close(); } - } finally { await firstRunner.close(); } -}); - -test('canonical inbox rejects a disconnected block with a forged but rehashed ancestor', async t => { - const f = await fixture(t), observer = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); - const streamId = randomUUID(), events = canonicalEvents(streamId); - try { - events[1].previousHash = '9'.repeat(64); - reanchorCanonicalEvents(streamId, events); - await assert.rejects(observer.observeCanonicalEvents(canonicalPage(streamId, events, 0, 2)), /disconnect-ancestry-mismatch/); - assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'canonical-inbox:v1:event:%'")).rows[0].n, 0); - await assert.rejects(PostgresCustody.open(f.runtime, options), /unresolved-integrity/); - } finally { await observer.close(); } -}); - -test('canonical inbox rejects tampered event anchors, persists an integrity hold and refuses another stream', async t => { - const f = await fixture(t), observer = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); - const streamId = randomUUID(), events = canonicalEvents(streamId); - try { - const tampered = canonicalPage(streamId, events, 0, 2); - tampered.events[0].transactionIds = ['f'.repeat(64)]; - await assert.rejects(observer.observeCanonicalEvents(tampered), /canonical-inbox-event-hash-invalid/); - assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'canonical-inbox:v1:event:%'")).rows[0].n, 0); - await assert.rejects(PostgresCustody.open(f.runtime, options), /unresolved-integrity/); - } finally { await observer.close(); } -}); - -test('canonical inbox startup audit detects owner-side history mutation and durably fences writers', async t => { - const f = await fixture(t), observer = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); - const streamId = randomUUID(), events = canonicalEvents(streamId); - try { - await observer.observeCanonicalEvents(canonicalPage(streamId, events, 0, 2)); - const changed = { ...events[0], transactionIds: ['f'.repeat(64)] }; - await f.admin.query('UPDATE wk_custody.custody_meta SET value=$1 WHERE key=$2', - [JSON.stringify(changed), `canonical-inbox:v1:event:${'1'.padStart(16, '0')}`]); - await assert.rejects(observer.audit(), /canonical-inbox-event-anchor-invalid/); - assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'")).rows[0].n, 1); - await assert.rejects(PostgresCustody.open(f.runtime, options), /unresolved-integrity/); - } finally { await observer.close(); } -}); - -test('signed candidate completion settles actual PostgreSQL balances once across pools and reconnect', async t => { - const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); - const l = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '100000000000', feeBps: 250 }) as CustodyListing; - const o = await f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 }) as CustodyOrder; - const transcript = saleTranscript(o, f.market.id); - const a = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); - const b = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); - const mature = { ...transcript.input, blocks: [[transcript.input.lock], [], [transcript.complete], [], []] }; - try { - const immature = { ...mature, blocks: mature.blocks.slice(0, 3) }; - assert.equal((await a.observeSaleRehearsal(o.id, immature)).action, 'hold'); - const results = await Promise.all([a.observeSaleRehearsal(o.id, mature), b.observeSaleRehearsal(o.id, mature)]); - assert.deepEqual(results.map(r => r.action).sort(), ['none', 'settle']); - assert.equal((await f.activity(seller)).availableAtomicXmr, '96500000000'); - assert.equal((await f.activity()).purchaseReservedAtomicXmr, '0'); - const claims = await f.admin.query("SELECT id,balance::text FROM wk_custody.accounts WHERE id LIKE 'author:%' OR id LIKE 'revenue:host:%'"); - assert.deepEqual(claims.rows.map(r => r.balance).sort(), ['1000000000', '2500000000']); - assert.equal((await a.audit()).journals, 3); - const reopened = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); - try { assert.equal((await reopened.observeSaleRehearsal(o.id, mature)).action, 'none'); await reopened.audit(); } - finally { await reopened.close(); } - // Fork replaces completion with valid cancellation. Financial history stays intact and incident is durable. - const fork = { ...mature, blocks: [[transcript.input.lock], [], [transcript.cancel], [], []] }; - await assert.rejects(a.observeSaleRehearsal(o.id, fork), /outcome-reorg/); - await assert.rejects(b.observeSaleRehearsal(o.id, mature), /incident unresolved/); - assert.equal((await f.activity(seller)).availableAtomicXmr, '96500000000'); - assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.journals WHERE kind='mock-sale-settlement'")).rows[0].n, 1); - await assert.rejects(PostgresCustody.open(f.runtime, options), /unresolved-integrity/); - } finally { await a.close(); await b.close(); } -}); - -test('disconnect of the exact posted candidate terminal transaction commits an order-linked review hold', async t => { - const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); - const l = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '100000000000', feeBps: 250 }) as CustodyListing; - const o = await f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 }) as CustodyOrder; - const s = saleTranscript(o, f.market.id), transcript = { ...s.input, blocks: [[s.input.lock], [], [s.complete], [], []] }; - const terminalTxId = saleCandidateTransactionId(s.complete); - const rehearsal = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); - const observer = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); - const streamId = randomUUID(), events = canonicalEvents(streamId, terminalTxId); - try { - assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'settle'); - const credited = await f.activity(seller); - assert.equal(credited.availableAtomicXmr, '96500000000'); - const first = await observer.observeCanonicalEvents(canonicalPage(streamId, events, 0, 1)); - assert.equal(first.cursor, 1); - // The same transaction reappears on the replacement branch in this page; still review, never auto-clear. - const review = await observer.observeCanonicalEvents(canonicalPage(streamId, events, 1, 2)); - assert.equal(review.reviewRequired, true); - assert.equal(review.cursor, 3); // Disconnect and reappearance are durably retained with the review marker. - await assert.rejects(observer.observeCanonicalEvents(canonicalPage(streamId, events, 1, 2)), /hold|incident unresolved/); - assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'canonical-inbox:v1:event:%'")).rows[0].n, 3); - assert.equal((await f.activity(seller)).availableAtomicXmr, credited.availableAtomicXmr); - assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.journals WHERE id=$1", [`settle:${o.id}`])).rows[0].n, 1); - const incident = await f.admin.query("SELECT value FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'"); - assert.match(incident.rows[0].value, /posted-sale-terminal-disconnected/); - await assert.rejects(observer.audit(), /unresolved-integrity-incident/); - await assert.rejects(resolveCustodyIncident(f.admin, randomUUID()), /posted-sale-terminal-disconnected/); - assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'sale-reorg-review:%'")).rows[0].n, 1); - await assert.rejects(PostgresCustody.open(f.runtime, options), /unresolved-integrity/); - await assert.rejects(f.execute(other, { type: 'mock-credit' }), /incident is unresolved/); - } finally { await observer.close(); await rehearsal.close(); } -}); - -test('candidate cancellation and withheld-lock expiry release actual reservations only after depth', async t => { - const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); - const rehearsal = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); - try { - for (const withheld of [false, true]) { - const l = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '1000', feeBps: 250 }) as CustodyListing; - const o = await f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 }) as CustodyOrder; - const s = saleTranscript(o, f.market.id); - const transcript = { ...s.input, blocks: withheld ? [[], [], [], [], []] : [[s.input.lock], [s.cancel], []] }; - assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'hold'); - transcript.blocks.push([]); - assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'refund'); - assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'none'); - assert.equal((await f.activity()).availableAtomicXmr, '1000000000000'); - await rehearsal.audit(); - } - } finally { await rehearsal.close(); } -}); - -test('rehearsal rejects a different fee rate even when tiny-order commission rounding is identical', async t => { - const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); - const l = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '1000', feeBps: 250 }) as CustodyListing; - const o = await f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 }) as CustodyOrder; - const s = saleTranscript(o, f.market.id, 251); - assert.throws(() => replayReservedSale(o, f.market.id, s.input, l.feeBps), /fee rate/); - const rehearsal = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); - try { - await assert.rejects(rehearsal.observeSaleRehearsal(o.id, s.input), /operation failed/); - assert.equal((await f.activity()).purchaseReservedAtomicXmr, '1000'); - assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'sale-rehearsal-%'")).rows[0].n, 0); - } finally { await rehearsal.close(); } -}); - -test('rehearsal preserves one-atomic sales and the signed custom host recipient across policy changes', async t => { - const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); - const recipient = await syntheticFeeAddress(); - const custom = await PostgresCustody.open(f.runtime, { ...options, hostFeeAddress: recipient }); - const rehearsal = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); - try { - for (const price of ['1', '1000']) { - const l = await custom.execute(envelope(custom.id, seller, { type: 'list', cardHash: hash, sequence: 0, - priceAtomicXmr: price, feeBps: 250, hostFeeAddress: recipient })) as CustodyListing; - const o = await f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: price, feeBps: 250, hostFeeAddress: recipient }) as CustodyOrder; - const s = saleTranscript(o, f.market.id); - const changed = structuredClone(s.input); changed.lock.terms.hostFeeAddress = f.market.info().hostFeeAddress; - await assert.rejects(rehearsal.observeSaleRehearsal(o.id, changed), /operation failed/); - const transcript = { ...s.input, blocks: [[s.input.lock], [], [s.complete], [], []] }; - assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'settle'); - assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'none'); - await rehearsal.audit(); - } - assert.equal((await f.activity(seller)).availableAtomicXmr, '966'); - assert.equal((await f.admin.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1', [`revenue:host:${recipient}`])).rows[0].balance, '25'); - assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'sale-rehearsal-observation:%'")).rows[0].n, 2); - } finally { await custom.close(); await rehearsal.close(); } -}); - -test('sale terminal-posting evidence failure rolls back seller credit and remains safely retryable', async t => { - const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); - const l = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '1000', feeBps: 250 }) as CustodyListing; - const o = await f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 }) as CustodyOrder; - const s = saleTranscript(o, f.market.id), transcript = { ...s.input, blocks: [[s.input.lock], [], [s.complete], [], []] }; - const rehearsal = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true }); - try { - await f.admin.query("CREATE FUNCTION wk_custody.fail_terminal() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.key LIKE 'sale-rehearsal-terminal:%' THEN RAISE EXCEPTION 'fixture terminal failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER fail_terminal BEFORE INSERT ON wk_custody.custody_meta FOR EACH ROW EXECUTE FUNCTION wk_custody.fail_terminal()"); - await assert.rejects(rehearsal.observeSaleRehearsal(o.id, transcript), /operation failed/); - assert.equal((await f.activity(seller)).availableAtomicXmr, '0'); - assert.equal((await f.activity()).purchaseReservedAtomicXmr, '1000'); - await f.admin.query('DROP TRIGGER fail_terminal ON wk_custody.custody_meta'); - assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'settle'); - await rehearsal.audit(); - await f.admin.query("UPDATE wk_custody.custody_meta SET value='{}' WHERE key=$1", [`sale-rehearsal-terminal:${o.id}`]); - await assert.rejects(rehearsal.audit(), /evidence-mismatch/); - } finally { await rehearsal.close(); } -}); diff --git a/tests/project-copy.test.ts b/tests/project-copy.test.ts index f90d23b..9c7df70 100755 --- a/tests/project-copy.test.ts +++ b/tests/project-copy.test.ts @@ -4,16 +4,18 @@ import { readFile } from 'node:fs/promises'; test('project overview focuses on mining and player-to-player card trades',async()=>{ const readme=await readFile(new URL('../README.md',import.meta.url),'utf8'); - assert.match(readme,/mining a block rewards the miner with a collectible card based on a Wikipedia page/); + assert.match(readme,/Mine a block with the native RandomX CPU miner to earn a card/); assert.match(readme,/algorithm also used by Monero/); assert.match(readme,/consumer CPUs competitive/); assert.match(readme,/trade cards directly with another player/); - assert.match(readme,/not part of the default card-trading experience/); - assert.match(readme,/WIKIDECK_MARKET_RUNTIME_ENABLED=1/); + assert.match(readme,/docs\/card-only-production-plan\.md/); assert.doesNotMatch(readme,/## Peer ownership and customization/); assert.match(readme,/does not yet contain a project LICENSE file/); - for(const name of ['../compose.yaml','../compose.testnet.yaml']) - assert.match(await readFile(new URL(name,import.meta.url),'utf8'),/WIKIDECK_MARKET_RUNTIME_ENABLED:.*:-0/); + const plan=await readFile(new URL('../docs/card-only-production-plan.md',import.meta.url),'utf8'); + assert.match(plan,/Markets and real money/); + assert.match(plan,/Mainnet or valuable cards/); + for(const name of ['../compose.yaml','../compose.testnet.yaml','../compose.mainnet.yaml']) + assert.doesNotMatch(await readFile(new URL(name,import.meta.url),'utf8'),/MARKET|CUSTODY|PAYMENT_MODE/); }); test('homepage presents mining, collections, and direct card trading without marketplace UI',async()=>{ const app=await readFile(new URL('../src/App.tsx',import.meta.url),'utf8'); @@ -22,28 +24,7 @@ test('homepage presents mining, collections, and direct card trading without mar assert.match(app,/designed to resist specialized ASICs/); assert.match(app,/ASIC resistance is a design goal, not a guarantee/); assert.match(app,/Trade cards directly with another collector/); - assert.doesNotMatch(app,/Marketplace|Custody preview|fixed-price listings/); + assert.doesNotMatch(app,/marketplace|custody preview|fixed-price listings/i); assert.match(app,/
Want to run a miner\? Setup & safety details/); assert.match(app,/--consent/); // Documentation-only change: runnable miner invocation keeps its existing required flag. }); -test('market transparency explains real controls without promising escrow, refunds or live payments',async()=>{ - const guide=await readFile(new URL('../docs/market-transparency.md',import.meta.url),'utf8'); - for(const required of [ - /does not process real cryptocurrency payments/, - /Card delivery is real/, - /Listing is not escrow/, - /That would be custodial/, - /30 minutes after auction close/, - /48 hours after mock payment/, - /0\.096500000000 XMR/, - /cannot revoke a signed NFT transfer/, - /no built-in arbitration service/, - /the hosting operator and anyone with its database\/backups can read orders/, - /no live payment integration or supported setting/, - ])assert.match(guide,required); - for(const name of ['../README.md','../docs/marketplace.md']){ - assert.match(await readFile(new URL(name,import.meta.url),'utf8'),/market-transparency\.md/); - } - // Resolve every workspace-relative Markdown link in the new guide, catching missing documentation. - for(const match of guide.matchAll(/\]\(([^)]+\.md)(?:#[^)]*)?\)/g))await readFile(new URL(`../docs/${match[1]}`,import.meta.url),'utf8'); -}); diff --git a/tests/recovery.test.ts b/tests/recovery.test.ts index 2fa1147..3be928b 100755 --- a/tests/recovery.test.ts +++ b/tests/recovery.test.ts @@ -1,25 +1,26 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp,rm,writeFile,readFile,mkdir,access } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { mkdtemp,rm,writeFile,readFile,mkdir,access,stat } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { DatabaseSync } from 'node:sqlite'; import { Ledger } from '../server/testnet/ledger.ts'; -import { Market } from '../server/market/store.ts'; +import { NETWORK } from '../shared/network.ts'; +import { CHAIN_ID } from '../server/testnet/consensus.ts'; import { createBackup,verifyBackup,restoreBackup } from '../server/recovery.ts'; -test('stopped-node pair backup restores journal identity and replays ledger in isolated destination',async t=>{ +test('stopped-node backup snapshots only the ledger and replays it in an isolated destination',async t=>{ const root=await mkdtemp(join(tmpdir(),'wikideck-recovery-'));t.after(()=>rm(root,{recursive:true,force:true})); const source=join(root,'source'); const pow={hash:async()=>{throw new Error('Empty ledger should require no proof work');},close(){}}; - const ledger=await Ledger.open(source,pow),market=await Market.open(source,ledger); - const id=market.id,snapshot=ledger.snapshot();market.close();ledger.close(); + const ledger=await Ledger.open(source,pow),snapshot=ledger.snapshot();ledger.close(); const backup=join(root,'backup');const manifest=await createBackup(source,backup); - assert.equal(manifest.files.length,2);assert.deepEqual(await verifyBackup(backup),manifest); + assert.equal(manifest.version,2);assert.deepEqual(manifest.files.map(file=>file.name),['ledger.sqlite']); + assert.deepEqual(await verifyBackup(backup),manifest); const dest=join(root,'restore');assert.equal((await restoreBackup(backup,dest)).proofsRevalidated,false); - const restored=await Ledger.open(dest,pow),journal=await Market.open(dest,restored); - assert.deepEqual(restored.snapshot(),snapshot);assert.equal(journal.id,id); - journal.close();restored.close(); + const restored=await Ledger.open(dest,pow); + assert.deepEqual(restored.snapshot(),snapshot);restored.close(); await assert.rejects(restoreBackup(backup,dest),/EEXIST/); await assert.rejects(createBackup(source,backup),/EEXIST/); await assert.rejects(createBackup(source,join(source,'inside')),/outside/); @@ -28,16 +29,40 @@ test('stopped-node pair backup restores journal identity and replays ledger in i assert.equal(await readFile(join(source,'testnet-node.lock'),'utf8'),'simulated-running'); }); +test('new ledger backup refuses to omit legacy journals, and old paired backups remain portable',async t=>{ + const root=await mkdtemp(join(tmpdir(),'wikideck-recovery-legacy-'));t.after(()=>rm(root,{recursive:true,force:true})); + const source=join(root,'source'),ledger=await Ledger.open(source,{hash:async()=>'',close(){}});ledger.close(); + const legacyFile=join(source,'testnet','market.sqlite'); + const legacyDb=new DatabaseSync(legacyFile);legacyDb.exec('CREATE TABLE legacy_archive(value TEXT)');legacyDb.close(); + await assert.rejects(createBackup(source,join(root,'blocked-backup')),/Legacy financial data/); + + const backup=join(root,'legacy-backup');await mkdir(backup); + const ledgerCopy=join(backup,'ledger.sqlite'); + const ledgerDb=new DatabaseSync(join(source,'testnet','ledger.sqlite'),{readOnly:true}); + try{ledgerDb.prepare('VACUUM INTO ?').run(ledgerCopy);}finally{ledgerDb.close();} + const marketCopy=join(backup,'market.sqlite'); + const marketDb=new DatabaseSync(legacyFile,{readOnly:true}); + try{marketDb.prepare('VACUUM INTO ?').run(marketCopy);}finally{marketDb.close();} + const fileInfo=async(name:string,path:string)=>({name,bytes:(await stat(path)).size, + sha256:createHash('sha256').update(await readFile(path)).digest('hex')}); + const oldManifest={format:'wikideck-stopped-backup',version:1,network:NETWORK,chainId:CHAIN_ID, + profile:'testnet',createdAt:new Date().toISOString(),files:[await fileInfo('ledger.sqlite',ledgerCopy),await fileInfo('market.sqlite',marketCopy)]}; + await writeFile(join(backup,'manifest.json'),JSON.stringify(oldManifest)); + assert.equal((await verifyBackup(backup)).version,1); + const restored=join(root,'legacy-restore');await restoreBackup(backup,restored); + assert.equal((await stat(join(restored,'testnet','market.sqlite'))).size,(await stat(marketCopy)).size); +}); + test('backup verifier rejects tampering, sidecars, network mismatch and incomplete packages',async t=>{ const root=await mkdtemp(join(tmpdir(),'wikideck-recovery-bad-'));t.after(()=>rm(root,{recursive:true,force:true})); - const source=join(root,'source'),ledger=await Ledger.open(source,{hash:async()=>'',close(){}}),market=await Market.open(source,ledger); - market.close();ledger.close();const backup=join(root,'backup');await createBackup(source,backup); + const source=join(root,'source'),ledger=await Ledger.open(source,{hash:async()=>'',close(){}}); + ledger.close();const backup=join(root,'backup');await createBackup(source,backup); const original=await readFile(join(backup,'manifest.json'),'utf8'); await writeFile(join(backup,'ledger.sqlite-wal'),'unexpected');await assert.rejects(verifyBackup(backup),/sidecar/); await rm(join(backup,'ledger.sqlite-wal')); await writeFile(join(backup,'manifest.json'),original.replace('wikideck-randomx-testnet-6','wrong-network'));await assert.rejects(verifyBackup(backup)); await writeFile(join(backup,'manifest.json'),original); - const db=new DatabaseSync(join(backup,'market.sqlite'));db.exec("UPDATE market_meta SET value='tampered' WHERE key='identity'");db.close(); + await writeFile(join(backup,'ledger.sqlite'),Buffer.concat([await readFile(join(backup,'ledger.sqlite')),Buffer.from('tampered')])); await assert.rejects(verifyBackup(backup),/hash\/size/); const missing=join(root,'incomplete');await mkdir(missing);await assert.rejects(verifyBackup(missing)); await assert.rejects(access(join(source,'testnet-node.lock'))); // maintenance lock released, not silently left behind diff --git a/tests/sale-candidate.test.ts b/tests/sale-candidate.test.ts deleted file mode 100755 index df0ec72..0000000 --- a/tests/sale-candidate.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { generateKeyPairSync, sign, randomUUID } from 'node:crypto'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { NETWORK, nftTransferMessage, nftTradeMessage, type NftTransfer, type NftTrade } from '../shared/testnet.ts'; -import { SALE_CANDIDATE, saleTermsMessage, saleCompletionMessage, saleCancellationMessage, - saleTermsSchema, type SaleTerms, type SaleTransaction } from '../shared/sale-candidate.ts'; -import { applySaleBlock, initialSaleState, saleContext, saleDecision, saleId, saleStateRoot, termsForCustodyOrder, - type SaleState, type CandidateTransaction } from '../server/testnet/sale-candidate.ts'; -import { address, transactionSchema, CHAIN_ID } from '../server/testnet/consensus.ts'; -import { CustodyMarket } from '../server/market/custody.ts'; -import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts'; -import { CUSTODY_POLICY, custodyMessage, type CustodyCommand, type CustodyAction, type CustodyOrder, type CustodyListing } from '../shared/custody.ts'; - -function identity() { - const k = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); - const publicKey = k.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); - const owner = address(publicKey); - return { owner, authorization: (message: string) => ({ publicKey, signature: sign('sha256', Buffer.from(message), - { key: k.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') }) }; -} -const seller = identity(), buyer = identity(), host = identity(), attacker = identity(); -const card = 'a'.repeat(64), second = 'b'.repeat(64); -// These heights are deliberately tiny test parameters, NOT production confirmation-policy approval. -const context = saleContext({ coinbaseMaturity: 2, lockDepth: 2, outcomeDepth: 3, maxLockBlocks: 30, maxTransactions: 64 }); -function fixture(overrides: Partial = {}) { - const terms: SaleTerms = { protocol: SALE_CANDIDATE, network: NETWORK, chainId: context.chainId, - policyDigest: context.policyDigest, custodianId: randomUUID(), orderId: randomUUID(), cardHash: card, - sequence: 0, seller: seller.owner, buyer: buyer.owner, host: host.owner, priceAtomicXmr: '100000000000', - feeBps: 250, lockUntilHeight: 12, expiresHeight: 20, ...overrides }; - const state = initialSaleState({ [card]: { owner: seller.owner, sequence: 0, mintedHeight: 1, pageId: 1 }, - [second]: { owner: buyer.owner, sequence: 0, mintedHeight: 1, pageId: 2 } }, 9); - const lock: SaleTransaction = { type: 'sale-lock', terms, sellerAuthorization: seller.authorization(saleTermsMessage(terms)), - buyerAuthorization: buyer.authorization(saleTermsMessage(terms)) }; - const ref: { protocol: typeof SALE_CANDIDATE; chainId: string; saleId: string } = { - protocol: SALE_CANDIDATE, chainId: context.chainId, saleId: saleId(terms) }; - const completion = { ...ref, reservationDigest: 'f'.repeat(64) }; - const complete: SaleTransaction = { type: 'sale-complete', ...completion, hostAuthorization: host.authorization(saleCompletionMessage(completion)) }; - const cancel: SaleTransaction = { type: 'sale-cancel', ...ref, hostAuthorization: host.authorization(saleCancellationMessage(ref)), - buyerAuthorization: buyer.authorization(saleCancellationMessage(ref)) }; - const expire: SaleTransaction = { type: 'sale-expire', ...ref }; - return { terms, state, lock, complete, cancel, expire }; -} -const apply = (s: SaleState, txs: unknown[] = []) => applySaleBlock(s, txs, context); -function at(s: SaleState, height: number) { while (s.height < height) s = apply(s); return s; } -function gift(sequence: number): CandidateTransaction { - const terms = { network: NETWORK, chainId: context.chainId, cardHash: card, from: seller.owner, to: attacker.owner, sequence }; - const signature = seller.authorization(nftTransferMessage(terms)); - return { type: 'native-transfer', transfer: { ...terms, ...signature } }; -} -function bundle(sequence: number): CandidateTransaction { - const terms = { type: 'trade' as const, network: NETWORK, chainId: context.chainId, proposer: seller.owner, counterparty: buyer.owner, - offered: [{ cardHash: card, sequence }], requested: [{ cardHash: second, sequence: 0 }] }; - const a = seller.authorization(nftTradeMessage(terms)), b = buyer.authorization(nftTradeMessage(terms)); - return { type: 'native-trade', trade: { ...terms, proposerPublicKey: a.publicKey, proposerSignature: a.signature, - counterpartyPublicKey: b.publicKey, counterpartySignature: b.signature } }; -} - -test('sale candidate is domain separated and all candidate transactions remain rejected by active consensus', () => { - const f = fixture(); assert.notEqual(context.chainId, CHAIN_ID); - for (const tx of [f.lock, f.complete, f.cancel, f.expire]) assert.equal(transactionSchema.safeParse(tx).success, false); - const transfer = gift(0) as { type: 'native-transfer'; transfer: NftTransfer }; - const trade = bundle(0) as { type: 'native-trade'; trade: NftTrade }; - assert.equal(transactionSchema.safeParse(transfer.transfer).success, false); - assert.equal(transactionSchema.safeParse(trade.trade).success, false); - assert.throws(() => applySaleBlock(f.state, [f.lock], { ...context, chainId: CHAIN_ID }), /context/); - assert.equal(saleTermsSchema.safeParse({ ...f.terms, unexpected: true }).success, false); - assert.equal(saleTermsSchema.safeParse({ ...f.terms, priceAtomicXmr: '1.0' }).success, false); -}); - -test('lock requires both parties, commits all economic/context/window fields and never changes owner', () => { - const f = fixture(), locked = apply(f.state, [f.lock]); - assert.equal(locked.nfts[card].owner, seller.owner); assert.equal(locked.nfts[card].sequence, 1); - assert.equal(locked.locks[card], saleId(f.terms)); assert.equal(f.state.nfts[card].sequence, 0); - assert.throws(() => apply(f.state, [{ ...f.lock, buyerAuthorization: attacker.authorization(saleTermsMessage(f.terms)) }]), /both trader/); - for (const patch of [{ orderId: randomUUID() }, { custodianId: randomUUID() }, { priceAtomicXmr: '1' }, - { feeBps: 500 }, { lockUntilHeight: 13 }, { expiresHeight: 21 }, { buyer: attacker.owner }, - { host: attacker.owner }, { sequence: 1 }, { policyDigest: 'c'.repeat(64) }]) { - assert.throws(() => apply(f.state, [{ ...f.lock, terms: { ...f.terms, ...patch } }])); - } - const changedPolicy = saleContext({ ...context.policy, outcomeDepth: 4 }); - assert.notEqual(changedPolicy.chainId, context.chainId); - assert.notEqual(saleId({ ...f.terms, custodianId: randomUUID() }), saleId(f.terms)); -}); - -test('host cannot redirect delivery or alter signed reservation evidence, and completion waits for lock depth', () => { - const f = fixture(), locked = apply(f.state, [f.lock]); - assert.throws(() => apply(locked, [f.complete]), /mature lock window/); - const mature = at(locked, 11); - assert.throws(() => apply(mature, [{ ...f.complete, reservationDigest: 'e'.repeat(64) }]), /host authorization/); - assert.throws(() => apply(mature, [{ ...f.complete, hostAuthorization: attacker.authorization(saleCompletionMessage(f.complete as Extract)) }]), /host authorization/); - assert.throws(() => apply(mature, [{ ...f.complete, to: attacker.owner }])); - const done = apply(mature, [f.complete]); - assert.equal(done.nfts[card].owner, buyer.owner); assert.equal(done.nfts[card].sequence, 2); - assert.equal(done.sales[saleId(f.terms)].reservationDigest, 'f'.repeat(64)); - assert.equal(saleDecision(done, f.terms, context).action, 'hold'); - assert.equal(saleDecision(at(done, 14), f.terms, context).action, 'settle'); - assert.equal(saleDecision(at(done, 14), f.terms, context, 'settled').action, 'none'); -}); - -test('candidate host recipient extension is signed and omission preserves historical terms bytes', () => { - const legacy = fixture(), t = legacy.terms; - assert.equal(saleTermsMessage(t), JSON.stringify([SALE_CANDIDATE, 'terms', t.network, t.chainId, t.policyDigest, - t.custodianId, t.orderId, t.cardHash, t.sequence, t.seller, t.buyer, t.host, 'XMR', t.priceAtomicXmr, - t.feeBps, ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address, t.lockUntilHeight, t.expiresHeight])); - const custom = fixture({ hostFeeAddress: '8'.repeat(95) }); // Syntax-only candidate, no wallet/RPC claim. - assert.notEqual(saleId(custom.terms), saleId({ ...custom.terms, hostFeeAddress: undefined })); - assert.equal(apply(custom.state, [custom.lock]).sales[saleId(custom.terms)].terms.hostFeeAddress, '8'.repeat(95)); - assert.throws(() => apply(custom.state, [{ ...custom.lock, terms: { ...custom.terms, hostFeeAddress: ADDITIONAL_MARKET_FEE.address } }]), /both trader/); - assert.throws(() => apply(custom.state, [{ ...custom.lock, terms: { ...custom.terms, hostFeeAddress: undefined } }]), /both trader/); -}); - -test('locked cards cannot escape through free transfers or any leg of an atomic trade', () => { - const f = fixture(), locked = apply(f.state, [f.lock]); - assert.throws(() => apply(locked, [gift(1)]), /locked/); - assert.throws(() => apply(locked, [bundle(1)]), /locked/); - assert.equal(apply(f.state, [gift(0)]).nfts[card].owner, attacker.owner); - const trade = apply(f.state, [bundle(0)]); - assert.equal(trade.nfts[card].owner, buyer.owner); assert.equal(trade.nfts[second].owner, seller.owner); - // A gift before lock invalidates the seller's stale lock authorization. - assert.throws(() => apply(f.state, [gift(0), f.lock]), /stale/); -}); - -test('cancellation needs buyer and host, releases only after canonical depth and invalidates all old signatures', () => { - const f = fixture(), locked = apply(f.state, [f.lock]); - assert.throws(() => apply(locked, [{ ...f.cancel, buyerAuthorization: attacker.authorization(saleCancellationMessage(f.cancel as Extract)) }]), /host and buyer/); - const cancelled = apply(locked, [f.cancel]); - assert.equal(cancelled.nfts[card].owner, seller.owner); assert.equal(cancelled.nfts[card].sequence, 2); - assert.equal(saleDecision(cancelled, f.terms, context).action, 'hold'); - assert.equal(saleDecision(at(cancelled, 13), f.terms, context).action, 'refund'); - assert.throws(() => apply(cancelled, [f.complete]), /No active/); - assert.throws(() => apply(cancelled, [f.lock]), /stale/); - assert.throws(() => apply(cancelled, [gift(0)]), /stale/); - assert.throws(() => apply(cancelled, [gift(1)]), /stale/); - assert.equal(apply(cancelled, [gift(2)]).nfts[card].owner, attacker.owner); -}); - -test('expiry is permissionless exactly at expiry height, but never a website-clock-only release', () => { - const f = fixture(), locked = apply(f.state, [f.lock]); - assert.throws(() => apply(locked, [f.expire]), /not expired/); - const before = at(locked, 19); - assert.throws(() => apply(before, [f.complete]), /outside/); - assert.throws(() => apply(before, [f.cancel]), /permissionless expiry/); - assert.throws(() => apply(before, [gift(1)]), /locked/); // Explicit expiry transaction required, no implicit timer. - const expired = apply(before, [f.expire]); - assert.equal(expired.nfts[card].owner, seller.owner); assert.equal(expired.nfts[card].sequence, 2); - assert.equal(saleDecision(expired, f.terms, context).action, 'hold'); - assert.equal(saleDecision(at(expired, 22), f.terms, context).action, 'refund'); - const last = apply(at(locked, 18), [f.complete]); assert.equal(last.sales[saleId(f.terms)].status, 'completed'); -}); - -test('withheld lock has a strict admission deadline and missing lock refunds wait beyond that deadline', () => { - const f = fixture(); - assert.equal(saleDecision(at(f.state, 12), f.terms, context).action, 'hold'); - assert.equal(apply(at(f.state, 11), [f.lock]).sales[saleId(f.terms)].lockHeight, 12); - assert.throws(() => apply(at(f.state, 12), [f.lock]), /height window/); - assert.equal(saleDecision(at(f.state, 14), f.terms, context).action, 'hold'); - assert.equal(saleDecision(at(f.state, 15), f.terms, context).action, 'refund'); -}); - -test('one canonical outcome wins: cancel/complete race rejects the whole invalid block, never partially updates state', () => { - const f = fixture(), ready = at(apply(f.state, [f.lock]), 11), prior = structuredClone(ready); - assert.throws(() => apply(ready, [f.complete, f.cancel]), /No active/); - assert.throws(() => apply(ready, [f.cancel, f.complete]), /No active/); - assert.deepEqual(ready, prior); - assert.throws(() => apply(ready, [f.complete, f.complete]), /Duplicate/); - const completed = apply(ready, [f.complete]), cancelled = apply(ready, [f.cancel]); - assert.equal(completed.sales[saleId(f.terms)].status, 'completed'); - assert.equal(cancelled.sales[saleId(f.terms)].status, 'cancelled'); - // Reorg to a sibling outcome after a financial posting must quarantine rather than pay/refund again. - assert.equal(saleDecision(at(cancelled, 14), f.terms, context, 'settled').action, 'review'); - assert.equal(saleDecision(at(completed, 14), f.terms, context, 'refunded').action, 'review'); - assert.equal(saleDecision(at(completed, 14), f.terms, context, 'review').action, 'review'); -}); - -test('cross-market competing locks are exclusive and cancelled order IDs cannot be recycled under fresh terms', () => { - const f = fixture(), otherMarket = fixture({ custodianId: randomUUID() }); - const locked = apply(f.state, [f.lock]); - assert.throws(() => apply(locked, [otherMarket.lock]), /locked/); - const cancelled = apply(locked, [f.cancel]); - const replay = fixture({ ...f.terms, sequence: 2, priceAtomicXmr: '2' }); - assert.throws(() => apply(cancelled, [replay.lock]), /already used/); - const nextOrder = fixture({ sequence: 2 }); - assert.equal(apply(cancelled, [nextOrder.lock]).nfts[card].sequence, 3); -}); - -test('policy/height bounds, immature issuance, sequence exhaustion and transaction limits reject safely', () => { - const f = fixture(); - assert.throws(() => saleContext({ ...context.policy, maxLockBlocks: 1 }), /window/); - assert.throws(() => apply(f.state, Array(65).fill(f.lock)), /budget/); - assert.throws(() => applySaleBlock({ ...f.state, height: Number.MAX_SAFE_INTEGER }, [], context), /exhausted/); - const tooEarly = initialSaleState(f.state.nfts, 1); assert.throws(() => apply(tooEarly, [f.lock]), /Immature/); - for (const sequence of [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER - 1]) { - const huge = fixture({ sequence }); huge.state.nfts[card].sequence = sequence; - assert.throws(() => apply(huge.state, [huge.lock]), /exhausted/); - } - assert.throws(() => apply(f.state, [fixture({ expiresHeight: 100 }).lock]), /window/); -}); - -test('candidate drafts from real sandbox reservations without signing, changing balances or activating NFT delivery', async t => { - const dir = await mkdtemp(join(tmpdir(), 'wikideck-sale-draft-')); - const market = await CustodyMarket.open(dir, { feeBps: 250, card: () => ({ title: 'Draft test' }) }); - t.after(async () => { market.close(); await rm(dir, { recursive: true, force: true }); }); - const execute = (who: typeof buyer, action: CustodyAction) => { - const unsigned: Omit = { version: 1, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK, - chainId: CHAIN_ID, custodianId: market.id, actor: who.owner, publicKey: '', nonce: randomUUID(), expiresAt: Date.now() + 60000, action }; - const publicKey = who.authorization('lookup').publicKey; unsigned.publicKey = publicKey; - return market.execute({ ...unsigned, signature: who.authorization(custodyMessage(unsigned)).signature }); - }; - execute(buyer, { type: 'mock-credit' }); - const listing = execute(seller, { type: 'list', cardHash: card, sequence: 0, priceAtomicXmr: '1', feeBps: 250 }) as CustodyListing; - const order = execute(buyer, { type: 'reserve', listingId: listing.id, priceAtomicXmr: '1', feeBps: 250 }) as CustodyOrder; - const before = market.audit(), options = { custodianId: market.id, host: host.owner, feeBps: 250, lockUntilHeight: 12, expiresHeight: 20 }; - const terms = termsForCustodyOrder(order, context, options); - assert.equal(terms.priceAtomicXmr, '1'); assert.equal(terms.orderId, order.id); - assert.equal(terms.custodianId, market.id); assert.deepEqual(market.audit(), before); - assert.equal(market.info().nftSettlement, false); - assert.throws(() => termsForCustodyOrder({ ...order, authorAtomicXmr: '1' }, context, options), /allocations/); - assert.throws(() => termsForCustodyOrder({ ...order, status: 'cancelled' }, context, options), /awaiting-lock/); - assert.throws(() => termsForCustodyOrder({ ...order, hostFeeAddress: 'different-recipient' }, context, options)); -}); - -test('enumerated adversarial histories preserve exclusivity and deterministic replay across snapshot restores', () => { - const f = fixture(), start = apply(f.state, [f.lock]); - let explored = 0; - const explore = (state: SaleState, depth: number) => { - if (depth === 0) return; - const choices = [[], [f.complete], [f.cancel], [f.expire], [gift(state.nfts[card].sequence)], [bundle(state.nfts[card].sequence)]]; - for (const txs of choices) { - const before = structuredClone(state); - try { - const next = apply(state, txs); - assert.deepEqual(next, apply(JSON.parse(JSON.stringify(state)), txs)); - const sale = next.sales[saleId(f.terms)]; - if (sale.status === 'locked') assert.equal(next.nfts[card].owner, seller.owner); - assert.equal(!!next.locks[card], sale.status === 'locked'); - explored++; explore(next, depth - 1); - } catch (e) { - if (e instanceof assert.AssertionError) throw e; - } - assert.deepEqual(state, before); - } - }; - // Reach the exact expiry boundary too; short horizons mostly contain correctly rejected actions. - explore(start, 12); assert.ok(explored > 100, `Explored only ${explored} accepted transitions`); -}); - -test('candidate state commitment binds ownership, lock authority, terminal outcomes and order-use tombstones', () => { - const f = fixture(), state = apply(f.state, [f.lock]), root = saleStateRoot(state, context); - assert.equal(root, saleStateRoot(JSON.parse(JSON.stringify(state)), context)); - for (const mutate of [ - (s: SaleState) => { s.nfts[card].sequence++; }, - (s: SaleState) => { s.sales[saleId(f.terms)].terms.host = attacker.owner; }, - (s: SaleState) => { s.sales[saleId(f.terms)].status = 'cancelled'; }, - (s: SaleState) => { delete s.locks[card]; }, - (s: SaleState) => { s.orders = {}; }, - ]) { const copy = structuredClone(state); mutate(copy); assert.notEqual(saleStateRoot(copy, context), root); } -}); diff --git a/tests/xmr-amount.test.ts b/tests/xmr-amount.test.ts deleted file mode 100755 index bff0ab5..0000000 --- a/tests/xmr-amount.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { MAX_XMR_AMOUNT, XMR_ATOMIC_UNITS, XMR_RPC_UINT64_MAX, parseXmr, formatXmr, - parseAtomicXmr, atomicXmrString, assertXmrRpcAmount, xmrCommission } from '../shared/xmr-amount.ts'; -import { allocateXmrSale, assessXmrSaleBudget } from '../shared/xmr-pricing.ts'; -import { atomicXmr, fiatCents } from '../server/market/xmr.ts'; -import { xmrString, mockQuote } from '../server/market/payments.ts'; - -test('exact XMR decimal input preserves legacy bounds and canonicalizes without floats', () => { - for (const [input, expected] of [['0', 0n], ['0.000000000001', 1n], ['1.2', 1_200_000_000_000n], - ['9999999999.999999999999', MAX_XMR_AMOUNT]] as const) { - assert.equal(parseXmr(input), expected); - assert.equal(atomicXmr(input), expected); - assert.equal(parseXmr(formatXmr(expected)), expected); - assert.equal(xmrString(expected), formatXmr(expected)); - } - assert.equal(formatXmr(0n), '0.000000000000'); - assert.equal(formatXmr(1n), '0.000000000001'); - assert.equal(formatXmr(XMR_ATOMIC_UNITS), '1.000000000000'); - for (const bad of ['', ' ', ' 1', '1 ', '\n1', '1\n', '+1', '-0', '-1', '01', '.1', '1.', - '1e-12', '1_000', '1,000', 'NaN', 'Infinity', '12', '0.0000000000001', '10000000000', - '9'.repeat(100_000), 1, 1n, null, {}, ['1']] as unknown[]) { - assert.throws(() => parseXmr(bad as string)); - assert.throws(() => atomicXmr(bad as string)); - } -}); - -test('atomic wire strings reject alternate spellings, coercion and unsafe numbers', () => { - for (const value of [0n, 1n, BigInt(Number.MAX_SAFE_INTEGER) + 2n, XMR_RPC_UINT64_MAX, MAX_XMR_AMOUNT]) { - const wire = atomicXmrString(value); - assert.equal(parseAtomicXmr(JSON.parse(JSON.stringify(wire))), value); - } - for (const value of ['', '00', '01', '+1', '-1', '1.0', '1e12', '1\n', ' 1', - (MAX_XMR_AMOUNT + 1n).toString(), '9'.repeat(100_000), 1, null, {}]) { - assert.throws(() => parseAtomicXmr(value as string)); - } - for (const bad of [-1n, MAX_XMR_AMOUNT + 1n, 1, null] as unknown[]) { - assert.throws(() => formatXmr(bad as bigint)); - assert.throws(() => atomicXmrString(bad as bigint)); - } - assert.doesNotThrow(() => assertXmrRpcAmount(XMR_RPC_UINT64_MAX)); - assert.throws(() => assertXmrRpcAmount(XMR_RPC_UINT64_MAX + 1n)); -}); - -test('legacy processor USD parser also requires absolute end-of-input', () => { - assert.equal(fiatCents('0'), 0); - assert.equal(fiatCents('1.2'), 120); - assert.equal(fiatCents('9999999.99'), 999999999); - for (const value of ['1\n', '1\r', '1\r\n', '1\u2028', '1\u2029', '1.00\n', '01', '1e2', - '10000000', '0.001', '9'.repeat(100_000), 1, null]) { - assert.throws(() => fiatCents(value as string)); - } -}); - -test('tiny prices are representable and commissions round independently without a minimum charge', () => { - assert.deepEqual(allocateXmrSale(1n, 5000), { gross: 1n, hosting: 0n, author: 0n, sellerBeforeNetworkCosts: 1n }); - assert.equal(allocateXmrSale(99n, 0).author, 0n); - assert.equal(allocateXmrSale(100n, 0).author, 1n); - assert.deepEqual(allocateXmrSale(199n, 250), { gross: 199n, hosting: 4n, author: 1n, sellerBeforeNetworkCosts: 194n }); - assert.throws(() => allocateXmrSale(0n, 0)); - for (const bad of [-1, 5001, 0.5, NaN, Infinity, '100', null] as unknown[]) { - assert.throws(() => allocateXmrSale(1n, bad as number)); - } - for (const bad of [-1, 10001, 0.5, NaN]) assert.throws(() => xmrCommission(1n, bad)); -}); - -test('seller network budget preflight distinguishes positive net, exact exhaustion and fee spikes', () => { - const gross = parseXmr('1'), available = allocateXmrSale(gross, 250).sellerBeforeNetworkCosts; - const good = assessXmrSaleBudget(gross, 250, available - 1n); - assert.equal(good.viable, true); assert.equal(good.sellerMinimumNet, 1n); assert.equal(good.fundingShortfall, 0n); - for (const extra of [0n, 1n, 1_000_000n]) { - const blocked = assessXmrSaleBudget(gross, 250, available + extra); - assert.equal(blocked.viable, false); assert.equal(blocked.sellerMinimumNet, 0n); - assert.equal(blocked.fundingShortfall, extra + 1n); - } - assert.equal(assessXmrSaleBudget(1n, 0, 0n).viable, true); - assert.equal(assessXmrSaleBudget(1n, 0, 1n).viable, false); - assert.throws(() => assessXmrSaleBudget(gross, 0, -1n)); - assert.throws(() => assessXmrSaleBudget(gross, 0, MAX_XMR_AMOUNT + 1n)); -}); - -test('deterministic generated amounts conserve value, bound rounding and round-trip exactly', () => { - let seed = 0x584d52n; - for (let i = 0; i < 4000; i++) { - seed = (seed * 6364136223846793005n + 1442695040888963407n) % (1n << 96n); - const gross = seed % MAX_XMR_AMOUNT + 1n, bps = i % 5001; - assert.equal(parseXmr(formatXmr(gross)), gross); - assert.equal(parseAtomicXmr(atomicXmrString(gross)), gross); - const a = allocateXmrSale(gross, bps); - assert.equal(a.hosting + a.author + a.sellerBeforeNetworkCosts, gross); - for (const [fee, rate] of [[a.hosting, BigInt(bps)], [a.author, 100n]]) { - assert.ok(fee * 10000n <= gross * rate); - assert.ok((fee + 1n) * 10000n > gross * rate); - } - const budget = seed % (a.sellerBeforeNetworkCosts + 2n); - const q = assessXmrSaleBudget(gross, bps, budget); - assert.equal(q.viable, a.sellerBeforeNetworkCosts > budget); - if (q.viable) assert.equal(q.hosting + q.author + budget + q.sellerMinimumNet, gross); - } -}); - -test('mock USD caller retains its original independently rounded amounts', () => { - for (const cents of [1, 19, 1500, 100_000_000]) for (const bps of [0, 250, 5000]) { - const quote = mockQuote(cents, bps, 19999); - const gross = (BigInt(cents) * XMR_ATOMIC_UNITS + 19998n) / 19999n; - assert.equal(parseXmr(quote.invoice.amountXmr), gross); - assert.equal(parseXmr(quote.feeXmr), gross * BigInt(bps) / 10000n); - assert.equal(parseXmr(quote.additionalFee.amountXmr), gross / 100n); - assert.equal(parseXmr(quote.sellerNetXmr), gross - gross * BigInt(bps) / 10000n - gross / 100n); - assert.equal(quote.invoice.address, null); - } -}); diff --git a/tsconfig.json b/tsconfig.json index e035755..dfaccbc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,5 +6,5 @@ "allowImportingTsExtensions": true, "resolveJsonModule": true, "noEmit": true, "types": ["node", "vite/client"] }, - "include": ["src", "server", "shared", "tests", "scripts/custody-db.ts", "scripts/custody-deployment-init.ts", "scripts/custody-deployment-check.ts", "vite.config.ts"] + "include": ["src", "server", "shared", "tests", "scripts/rpc-load.ts", "vite.config.ts"] }