84 Commits
Author SHA1 Message Date
eric 216a419c0b Observer integration step 2026-10-23 10:20:58 -04:00
eric bd3d36a7e1 Bounded test succeeded; native RandomX miner accepted one block 2026-10-22 21:25:39 -04:00
eric 176d1927a8 Added pilot-bound Monero observation persistence and the one-shot monero:pilot-observe command 2026-10-22 20:04:05 -04:00
eric 36963fed30 Added the guarded, one-shot monero:pilot-address command 2026-10-22 19:10:08 -04:00
eric 83d3d104c1 operator/library-only Wallet-RPC address creation and recovery 2026-10-22 18:55:05 -04:00
eric 2ecb42c019 Durable, beneficiary-bound subaddress index reservation 2026-10-22 17:26:13 -04:00
eric 84e6e08209 Validation-only pilot contract 2026-10-22 16:11:36 -04:00
eric 359f0df32d Separate identity-only pilot custody foundation 2026-10-22 15:40:37 -04:00
eric d8780701e6 monerod get_info 2026-10-22 15:22:23 -04:00
eric 3f04c3afa9 Real XMR pilot 2026-10-22 14:40:00 -04:00
eric 52559f65d7 Ledger-side credit and withdrawal-intent paths 2026-10-22 13:28:55 -04:00
eric 4fea176877 Opt-in, mainnet-only deposit-address API 2026-10-22 11:44:32 -04:00
eric b865807272 Signed, beneficiary-bount Monero subaddress enrollment 2026-10-22 11:33:38 -04:00
eric 5d607c1521 Removed SHKeeper 2026-10-22 10:45:32 -04:00
eric e835238d05 Hardened systemd deployment contract for read-only Monero observer 2026-10-22 10:08:15 -04:00
eric 158981e965 Implemented and validated the separate Monero observation-worker path 2026-10-22 09:38:20 -04:00
eric 12f80fdc53 Private CLI workflow for reviewed depowit operations 2026-10-22 02:09:07 -04:00
eric 05aead39ae Reviewed release and reorg-hold resolution 2026-10-21 22:24:42 -04:00
eric 9f82f050b4 Verified incoming transfers against Monero Wallet PRC 2026-10-21 21:34:46 -04:00
eric af646dcc32 First deposit integration slice 2026-10-21 20:07:27 -04:00
eric e3b2261da3 Stagenet rehearsal passed 2026-10-21 18:38:25 -04:00
eric 40f4aa6d4e Started Walet RPC auth work without adding MD5 Digest support 2026-10-21 17:33:33 -04:00
eric f00ea55dfd Stagenet wallet rehearsal passed 2026-10-21 16:53:59 -04:00
eric 0534cde1a9 Added wallet-identity check before allocation 2026-10-21 15:25:00 -04:00
eric c8cf8245fc Wallet subaddress creation with retry recovery 2026-10-21 15:12:05 -04:00
eric e70199ecb0 Implemented PostgreSQL v3 allocation-registry groundwork 2026-10-21 14:59:15 -04:00
eric ee5dc35330 Implemented the next deposit-observation step without activating live deposits 2026-10-21 14:09:55 -04:00
eric 79abaa78f2 Read-only Monero wallet-RPC observer 2026-10-21 13:05:57 -04:00
eric c07f3a737b Loopback HTTP benchmark 2026-10-21 12:40:00 -04:00
eric 74f269923c Moved canonical transaction, NFT, and mempool reads into a separate read-only Ledger SQLite connection 2026-10-21 12:24:53 -04:00
eric 4566271ce3 Moved canonical transaction, NFT, and mempool fact reads into the reconciliation worker's own read-only SQLite snapshot 2026-10-21 12:18:33 -04:00
eric 9282c0234d Moved reconciliation row selection and order/listing status evaluation into the worker thread 2026-10-21 11:35:37 -04:00
eric 7b6873921a Worker-owned reconciliation commits 2026-10-21 11:07:36 -04:00
eric a127a40293 Reconciliation uses 32-row SQLite slices 2026-10-21 10:29:45 -04:00
eric 6adfda3cf0 Extended benchmark 2026-10-21 10:14:34 -04:00
eric 687b628424 Market-journal reconciliation benchmark 2026-10-21 10:06:56 -04:00
eric 1f11210b5b Bounded reconciliation increment 2026-10-21 09:57:50 -04:00
eric 8b0e2287a5 Paired-backup verifier now checks more than the manifest hash 2026-10-21 02:39:02 -04:00
eric 0ac2073038 Cold-restore rehearsal 2026-10-20 23:18:14 -04:00
eric 5b21074462 Separate one-shot utility 2026-10-20 23:04:29 -04:00
eric ccc6eef951 Added native RandomX integration check 2026-10-20 20:20:31 -04:00
eric 7025c37390 Added opt-in canonical observer polling lifecycle 2026-10-20 20:11:57 -04:00
eric 8cfcf6c025 Strengthened the disposable reorg rehearsal 2026-10-20 19:31:08 -04:00
eric 22da9764eb Per-order terminal reorg linkage 2026-10-20 19:08:19 -04:00
eric e1fbf5e5af Disposable canonical-event inbox rehearsal 2026-10-20 18:50:02 -04:00
eric ca8ff48628 Paid-sale workflow 2026-10-20 17:38:34 -04:00
eric 621611ecd9 TLS 2026-10-20 17:09:22 -04:00
eric 447314440b Strengthen financial reconciliation checks 2026-10-20 16:56:13 -04:00
eric 7734d8ae90 Environment variables 2026-10-20 16:24:01 -04:00
eric 4cac26d84b Persistent PostgreSQL sandbox deployment 2026-10-20 16:02:18 -04:00
eric 84a505c4a5 NFT sale-lock protocol 2026-10-20 15:38:37 -04:00
eric c254c4c537 Custodial financial storage 2026-10-20 15:21:47 -04:00
eric e48081f041 Canonical indexes and recoverable market observation 2026-10-20 14:35:45 -04:00
eric fc65e497a8 Custodial markets 2026-10-20 14:04:59 -04:00
eric 4e753b35fe Recommendation 2026-10-20 13:08:57 -04:00
eric bcd8630a8e Planning 2026-10-20 13:00:08 -04:00
eric a1de67ea14 XMR native 2026-10-20 12:09:58 -04:00
eric 558427545f Production plan 2026-10-20 11:48:34 -04:00
eric d6f230d6b9 Marketplace documentation 2026-10-20 10:54:50 -04:00
eric 2814b46b73 Documentation and UI 2026-10-20 10:40:18 -04:00
eric f2bd3e38e2 RPC 2026-10-20 10:12:33 -04:00
eric 2dd7382004 SHKeeper 2026-10-20 10:06:38 -04:00
eric 55f26d3fad Deployment monitoring and recovery tooling 2026-10-20 09:49:41 -04:00
eric 9d61c177b7 Consensus calibration 2026-10-20 09:26:12 -04:00
eric 4080114085 encryption 2026-10-20 05:27:18 -04:00
eric 3cee89d023 Improvements 2026-10-20 04:07:37 -04:00
eric 0cc5032857 Security 2026-10-20 03:40:26 -04:00
eric 9850fd8554 Markets 2026-10-20 03:14:08 -04:00
eric b1d2b5c10e Trades 2026-10-19 21:47:38 -04:00
eric f8eeae826f Logo 2026-10-19 20:32:25 -04:00
eric baad477ca5 More scalable data storage 2026-10-19 20:17:10 -04:00
eric 0b9061372b More cards 2026-10-19 18:52:49 -04:00
eric 716c9612cc Merge pull request 'Rich card data' (#19) from v2 into main
Reviewed-on: WikiDeck/wikideck#19
2026-10-19 21:51:21 +00:00
eric 9c93c9ec15 Merge branch 'main' into v2 2026-10-19 21:50:52 +00:00
eric 45785fb627 Removed ignored files from repo 2026-10-19 17:39:37 -04:00
eric 6155e8b3ed Rich card data 2026-10-19 16:47:27 -04:00
eric 9625ee2db7 Merge pull request 'RandomX' (#18) from v2 into main
Reviewed-on: WikiDeck/wikideck#18
2026-10-19 20:41:11 +00:00
eric f26e38b096 Merge branch 'main' into v2 2026-10-19 20:39:43 +00:00
eric 4fefffe4a1 submodule 2026-10-19 15:49:04 -04:00
eric bc6471fc54 randomx-src 2026-10-19 15:48:49 -04:00
eric 4b47386853 randomx 2026-10-19 15:42:39 -04:00
eric 10b5466f88 Merge pull request 'v2' (#17) from v2 into main
Reviewed-on: WikiDeck/wikideck#17
2026-10-19 16:21:57 +00:00
eric 6d3f470922 v2 2026-10-19 12:16:49 -04:00
eric d7ecab6494 Getting ready for K8S deploy (#7)
Reviewed-on: WikiDeck/wikideck#7
2025-10-27 16:18:06 +00:00
237 changed files with 28484 additions and 2211 deletions
+20
View File
@@ -0,0 +1,20 @@
node_modules
dist
.git
data
native/build
.env
.env.*
!.env.example
**/*.wallet.json
**/*backup*.json
**/*.sqlite
**/*.sqlite-*
**/*.pem
**/*.key
**/*.p12
**/*.pfx
*.log
.vscode
.idea
View File
+30 -3
View File
@@ -1,3 +1,30 @@
venv/*
mine_data/*
mariadb_data/*
node_modules/
dist/
data/
native/build/
*.wallet.json
.env
*.log# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
[submodule "native/build/_deps/randomx-src"]
path = native/build/_deps/randomx-src
url = https://github.com/tevador/RandomX.git
+67 -6
View File
@@ -1,6 +1,67 @@
FROM ericomeehan/httpd-mod-wsgi
RUN apt update && apt upgrade -y
RUN apt install -y libmariadb3 libmariadb-dev python3-dev python3-pip
COPY . /usr/local/apache2/htdocs
RUN pip3 install --break-system-packages -r /usr/local/apache2/htdocs/requirements.txt
EXPOSE 80
# syntax=docker/dockerfile:1
FROM node:24-alpine AS randomx-build
RUN apk add --no-cache build-base cmake git linux-headers
WORKDIR /native
COPY native/CMakeLists.txt native/randomx-engine.cpp ./
RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DWIKIDECK_RANDOMX_TESTS=ON \
&& cmake --build build --parallel 2 \
&& ctest --test-dir build --output-on-failure
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG WIKIDECK_NETWORK=testnet
ENV VITE_WIKIDECK_NETWORK=$WIKIDECK_NETWORK
RUN npm run build
FROM build AS test
RUN apk add --no-cache libstdc++ postgresql-client openssl
COPY --from=randomx-build /native/build/wikideck-randomx /app/native/wikideck-randomx
ENV RANDOMX_BINARY=/app/native/wikideck-randomx
CMD ["npm", "test"]
FROM node:24-alpine AS production-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
FROM node:24-alpine AS runtime
RUN apk add --no-cache libstdc++
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=3001 \
WIKIDECK_DATA_DIR=/app/data \
RANDOMX_BINARY=/app/native/wikideck-randomx
WORKDIR /app
COPY --from=production-deps /app/node_modules ./node_modules
COPY package.json ./
COPY --from=build /app/dist ./dist
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
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3001/healthz', {signal: AbortSignal.timeout(4000)}).then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
# 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"]
+166 -10
View File
@@ -1,10 +1,166 @@
# WikiDeck
An [NFT](https://en.wikipedia.org/wiki/Non-fungible_token)
[blockchain](https://en.wikipedia.org/wiki/Blockchain) that rewards Wikipedia trading cards.
## Use
See `test/Mine/TestMine.py` for a demonstration of the basic client workflow.
## Status
At present, only the files in `wikideck/Mine` are functional - and only partially so. This
project is currently a proof-of-concept.
# Wikideck
**Production roadmap:** see the [production delivery plan](docs/production-plan.md) for XMR-native pricing, custodial-market implementation, fixed-price-first launch scope and release gates. New v3 listings use exact native-XMR prices; historical v2 USD records keep their original terms. All payments remain simulated. The [custodial design and implementation status](docs/custodial-market-design.md) distinguish the sandbox ledger from future live custody.
Wikideck is an NFT blockchain where mining a block rewards the miner with a collectible card based on a Wikipedia page. Build collections, trade cards with other collectors, or host a peer with its own marketplace. Trading card games (TCGs), including custom games hosted by peers, are planned for a later release.
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.
**This is a local test network, not a public mainnet or an investment product.** No public bootnodes or hosted network are provided. Test NFTs have no promised value. Do not use funds or valuable assets. This project is not affiliated with Wikipedia or the Wikimedia Foundation.
## Collect, trade, and build your own community
- **Mine Wikipedia cards.** Each accepted block mints a native NFT for the miner from a random illustrated English Wikipedia article. The card records the article snapshot and mint-time statistics. The same page can appear on more than one distinct card. The reward is a card representing a page—not ownership of the page, its copyright, or a payment to Wikipedia.
- **Build collections.** Assemble a broad cabinet of discoveries or focus on a subject, series or theme. Browse public decks and exchange up to eight cards on each side in one atomic trade.
- **Buy and sell in peer-hosted markets.** Running a peer lets you host a market for the shared chain's cards. Your community can be general-purpose or focus on a particular collection. Listings, auctions and orders belong to that market host; card ownership and mined transfers are shared through the blockchain.
- **Support the host and project author.** Sales allocate a configurable hosting fee to the peer's market operator (2.5% by default) and a separate hardcoded **1% author fee** to the project's fixed XMR address. Both are deducted from seller proceeds; the default total is 3.5%. In the intended cryptocurrency settlement model the host retains its fee, while the author allocation is paid separately. Today all payment, fee and payout accounting is simulated.
- **Cryptocurrency checkout.** New market prices are quoted directly in **Monero (XMR)** atomic units. The current testnet checkout is mock-only: no real cryptocurrency is accepted or sent.
- **Play TCGs — planned.** The roadmap is to use these same cards in trading card games and let peers host custom TCG experiences. There is no playable game engine, match system, deck legality or custom rules hosting yet. Current card stats are not a promise of future game balance.
## Peer ownership and customization
**Understand the market before trading:** [Payment flow and buyer/seller protections](docs/market-transparency.md) explains who controls cards and funds, both fees, deadlines, refund/reorg limits, privacy and host accountability. Current payments are mock-only; seller delivery still transfers the actual card. No guaranteed refund, insurance or built-in arbitration is provided.
The project's open-source direction is to give peer operators substantial freedom to adapt their own market: presentation, community focus, curation and future game rules. A collection-focused community can run the current general listing interface today; built-in collection filters, moderation/admission tooling and custom TCG hosting are future work, not existing operator controls. Operators can already configure their hosting fee and run their own local marketplace journal.
Market customization does not let one peer rewrite shared NFT ownership or unilaterally change consensus for other peers. Compatible peers must follow the same network rules. The author fee is hardcoded in the distributed implementation; it is not a hosting configuration option. Any source modifications and redistribution must respect the eventual project license and third-party licenses.
**Licensing status:** this checkout does not yet contain a project LICENSE file. Source availability alone does not grant open-source modification/redistribution rights. Publishing an appropriate license is still required before describing those freedoms as legally granted. Wikipedia text/images and dependencies retain their separate licenses; a Wikideck software license would not replace them.
## What's available now
| Available in the testnet | Planned / not enabled |
| --- | --- |
| RandomX mining and illustrated Wikipedia NFT rewards | Reviewed production network launch |
| Encrypted local wallets, collections and atomic card trades | Playable TCGs and peer-hosted custom games |
| Per-peer fixed-price listings and timed auctions | Built-in collection-specific curation/admission controls |
| Mock native-XMR checkout and host/author fee accounting | Real cryptocurrency settlement and production custody operations |
## Run the three-node testnet (Docker)
Install Docker Desktop with Linux containers and Docker Compose v2. From the repository root, start three connected nodes:
```sh
docker compose -f compose.testnet.yaml up --build -d
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. 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:
```sh
docker compose -f compose.testnet.yaml down
```
The target block interval is **15 minutes**. The volumes persist across stops and rebuilds. **Do not add `-v` unless you intend to delete all three ledgers.** Back up wallets separately. Protocol v6 adds atomic multi-card trades and a new chain identity; earlier v2–v5 ledgers and encrypted backups are not compatible. To keep old Docker data intact while trying v6, use a separate Compose project name, for example `docker compose -p wikideck-v6 -f compose.testnet.yaml up --build -d`, and use the same `-p wikideck-v6` for subsequent Compose commands. The default `docker compose up --build -d` uses [compose.yaml](compose.yaml): it starts one isolated node on port 3001 with a different volume.
For an existing host install, keep the old data directory intact and set `WIKIDECK_DATA_DIR` to a new empty directory before starting protocol v6. The node intentionally fails closed on an old-protocol ledger; it does not reset or migrate it. A v6 JSON ledger is imported into SQLite on first startup and left in place as a recovery copy.
## Run on your host
### Optional custody accounting preview
**Optional market hosting:** set `WIKIDECK_MARKET_ENABLED=0` to suppress public listings/new trading, or `WIKIDECK_MARKET_TRADING_ENABLED=0` for a browsable paused market. Configure hosting-fee payouts with `WIKIDECK_MARKET_HOST_FEE_ADDRESS` (defaults to the author wallet); the fixed 1% author fee remains separate. Existing orders/balance resolution stay accessible. See [host configuration](docs/market-host-configuration.md) for strict values, signed recipient snapshots and safe container recreation.
For a **persistent PostgreSQL-backed local stack**, use [the deployment guide](docs/persistent-custody-sandbox.md) and [compose.custody.yaml](compose.custody.yaml), or the VS Code **Start persistent custody sandbox** task. It starts PostgreSQL plus Wikideck at port 3422, with generated scoped credentials, verified database TLS and explicit migrations. Existing single/three-node commands are unchanged. This uses separate volumes and does not migrate existing SQLite custody or enable real payments.
The marketplace's **Custody preview** tab is separate from existing mock checkout. Explicit `WIKIDECK_CUSTODY_MODE=sandbox` on the server enables fictitious test balances, signed fixed-price reservations and withdrawal holds on testnet only. Default is `off`; live modes and mainnet sandbox fail closed. Use a disposable data directory and configure `PUBLIC_ORIGIN` to the exact browser origin. No Monero addresses, wallet RPC or real withdrawals are available. [PostgreSQL financial storage](docs/postgres-financial-storage.md) is selectable with `WIKIDECK_CUSTODY_STORAGE=postgres`, explicit schema/role setup and a secret-file connection configuration; existing SQLite custody requires a stopped-node import, never automatic fallback.
Reservations stop at `awaiting-lock` because reviewed NFT sale-lock consensus rules are not implemented. They can be cancelled while their outbox intent is blocked; they do not deliver cards. Local SQLite custody is included by stopped-node backup/restore when present; PostgreSQL-bound nodes require coordinated database/node backups and the SQLite-only backup command refuses them. Real treasury observation/settlement, signing controls and production review remain future work. See [the implementation record](docs/custodial-market-design.md).
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:
```sh
npm install
npm run native:configure
npm run native:build
npm run test:native
npm run build
npm start
```
On Windows with MinGW, configure with `npm run native:configure -- -G "MinGW Makefiles"`. If PowerShell blocks the `npm` shim, use `npm.cmd`; follow your organization's execution policy rather than bypassing it. The optional platform scripts are [scripts/build-randomx.ps1](scripts/build-randomx.ps1) and [scripts/build-randomx.sh](scripts/build-randomx.sh). See [native engine setup and protocol](docs/randomx-native.md) for toolchain details.
The node listens at **http://127.0.0.1:3001** by default. For development, `npm run dev` starts the API on port 3001 and Vite on **http://127.0.0.1:5173**. Keep both services on loopback; Vite proxies the browser's `/api` requests to the API. The native miner should connect directly to the API port, not Vite.
## Wallets and backups
Open the wallet panel to explicitly create an encrypted local P-256 wallet, unlock an existing vault, or encrypt a legacy plaintext wallet without changing its identity. Nothing is silently generated or unlocked at startup. Choose a strong local password of at least 12 characters. **Wallet backup & recovery** downloads a separately password-encrypted portable backup; keep the file outside the repository and its password separately, and test recovery in another browser profile.
New/migrated local vaults encrypt the key in IndexedDB; the unlocked key remains exportable in tab memory for signing and backup. Manual locking, five-minute inactivity, a four-hour absolute lifetime, reload, or hiding/leaving the tab ends the session. Same-origin compromise can still capture passwords or abuse an unlocked signer. Forgotten local passwords cannot be reset: recover from a usable backup. Legacy disk copies may retain old plaintext keys even after migration. Wallets are scoped to an origin (scheme, hostname, and port), so a backup must be restored at another origin to use the same identity there. Recovery does not move assets between chains. See [encrypted wallet lifecycle and remaining risks](docs/wallet-security.md).
The current testnet wallet is separate from any wallet used by an older demo. There is no automatic migration. See [consensus, wallets, and operational limits](docs/testnet-consensus.md) and the in-app backup warnings for more detail.
## Mine Wikipedia cards with RandomX
Mining uses the native CPU RandomX engine. First build the engine, download an encrypted wallet backup, then run the CLI from an interactive terminal and enter the password at its hidden prompt:
```sh
npm run miner -- --wallet "path/to/encrypted-backup.json" --consent --node http://127.0.0.1:3101 --blocks 3
```
Use port 3001 for the default single host/Docker node, or 3101–3103 for the three-node Docker testnet. The command above uses node-a. The miner defaults to one worker, light mode, and one block; `--blocks 3` requests up to three accepted blocks. Press **Ctrl+C** to stop it. Do not put the backup password in a command, environment variable, or repository file. The backup and password are decrypted locally and are not sent to the node.
Each template request asks English Wikipedia for up to ten random pages and retries at most three times to find one with an image and readable extract. The same page may be selected again. The node also captures page size, language-link count, revision, pageviews, and derived scores for the mint-time snapshot. If Wikipedia cannot supply the article or its metadata, template creation returns an error; there is no fixed-article fallback. Validators check and commit the included snapshot without contacting Wikipedia during consensus validation.
Light mode needs a 256 MiB cache plus scratchpad and process overhead. `--fast` is optional and needs over 2.3 GiB per miner process. Mining consumes CPU, power, and time; RandomX is not a guarantee of ASIC resistance. Do not expose an HTTP miner endpoint remotely; use HTTPS. Detailed resource, native build, and line-protocol notes are in [docs/randomx-native.md](docs/randomx-native.md).
## What the app shows
- **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.
- **Marketplace** is the connected peer's market: new fixed-price listings and timed auctions use native XMR amounts; historical USD listings remain read-only. Checkout, fees and payouts are simulated. Sales allocate a configurable fee to the hosting operator (default 2.5%) and a hardcoded 1% author fee (3.5% total by default). Sellers still sign real card delivery after the mock payment step. **Never send real XMR: no payable checkout address or live processor is enabled.** Listings/orders are local to the selected market host, not gossiped; listing does not lock a card on-chain. See [peer markets, fees and payment status](docs/marketplace.md).
- 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.
The browser interface trusts the connected node's reported state. It does not independently verify the chain or provide an SPV client. Transfers are irreversible once confirmed; check the recipient address carefully.
## Mainnet candidate · launch gated
**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).
## Tests and development checks
Offline preparation tools: `analyze:consensus` reports deterministic retarget/timestamp scenarios; `benchmark:randomx` runs a bounded native benchmark only with explicit `--consent`. Neither launches a node nor changes consensus or approval settings. See [calibration evidence and limitations](docs/consensus-calibration.md) before interpreting rates or illustrative targets.
```sh
npm test
npm run build
npm run test:native
```
`npm test` runs the application test suite, including fast test-only proof tests. `npm run test:native` runs the real upstream RandomX known-vector test and requires a native build configured with tests enabled; the documented `npm run native:configure` command enables them. The Docker build also builds RandomX and runs its native test.
The Dockerfile also provides a **test** target containing Node 24, development dependencies, the tests, and the compiled Linux-native RandomX binary. Build that target and run the resulting image to exercise the full native HTTP suite without relying on a host-platform binary. TypeScript compilation enforces erasable syntax because the production runtime strips types directly.
To explicitly test propagation and transfer behavior across the three local Docker nodes, first build the host native engine and start the three-node testnet, then run:
```sh
node --experimental-strip-types scripts/testnet-smoke.ts --consent
```
This mines three real blocks using disposable identities. It changes the testnet, and is not run automatically.
## 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.
[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.
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.
## Attribution
The four original starter summaries in the interface are preview examples only; they are not mintable. Mined cards contain short snapshots from Wikipedia contributors, licensed under [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), and a Wikimedia-hosted picture. Open each card's **Image & license** link and review the file-specific license and attribution before reuse. A native NFT does not grant copyright or exclusive rights to article text or images.
-293
View File
@@ -1,293 +0,0 @@
import os
import sys
import time
import requests
from typing import Optional
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "wikideck")));
from Mine.Block import Block
from Mine.Transaction import Transaction
WIKIDECK_URL = os.getenv('WIKIDECK_URL', 'http://localhost:8080/');
rsa_private_key: Optional[rsa.RSAPrivateKey] = None;
msg_array: list[str] = [""];
private_key_file: str = "privatekey.pem";
public_key_file: str = "publickey.pem";
def main():
while True:
if (msg_array[0]):
print(f"Last Action: {msg_array[0]}");
print("\n--- Wikideck CLI ---");
print("1. Create RSA Keypair");
print("2. Save Private Key");
print("3. Load RSA Keypair");
print("4. Save Public Key")
print("5. Mine a Block");
print("6. Generate a Transaction");
print("7. View Deck");
print("0. Exit");
print("\n-------------------");
try:
choice: int = int(input("Select an option: ").strip());
except ValueError:
msg_array[0] = "Invalid Input. Please Choose a Number Between 0-6.";
clear_console();
continue;
if choice == 0:
break;
elif choice == 1:
opt_generate_keypair(65537, 2048);
elif choice == 2:
opt_save_private_key(rsa_private_key);
elif choice == 3:
opt_load_private_key();
elif choice == 4:
opt_save_public_key(rsa_private_key);
elif choice == 5:
opt_mine_block();
elif choice == 6:
opt_generate_transaction();
elif choice == 7:
opt_view_deck();
else:
msg_array[0] = "Invalid Input. Please Choose a Number Between 0-6.";
clear_console();
def opt_generate_keypair(exponent=65537, size=2048):
global rsa_private_key;
rsa_private_key = rsa.generate_private_key(
public_exponent = exponent,
key_size = size,
)
msg_array[0] = "RSA Keypair Generated";
return rsa_private_key;
def opt_save_private_key(rsa, filename=private_key_file):
if rsa is None:
msg_array[0] = "No RSA Keypair to extract private key. Please generate one first.";
return None;
else:
with open(filename, "wb") as file:
pem = rsa.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
)
file.write(pem);
msg_array[0] = f"RSA Private Key Saved to {filename}";
def opt_save_public_key(rsa, filename=public_key_file):
if rsa is None:
msg_array[0] = "No RSA Keypair to extract public key. Please generate one first.";
return None;
public_key = rsa.public_key();
public_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
with open(filename, "wb") as file:
file.write(public_pem);
msg_array[0] = f"RSA Public Key Saved to {filename}";
def opt_load_private_key():
global rsa_private_key;
if not os.path.exists(private_key_file):
msg_array[0] = f"No saved key found ({private_key_file} missing)";
return None;
with open(private_key_file, "rb") as file:
rsa_private_key = serialization.load_pem_private_key(
file.read(),
password=None,
)
msg_array[0] = f"RSA Private Key Loaded From {private_key_file}";
def get_private_key_from_file(path):
if not os.path.exists(path):
msg_array[0] = f"No key file found ({path} missing)";
return None;
with open(path, "rb") as file:
key = serialization.load_pem_private_key(
file.read(),
password=None,
);
msg_array[0] = f"RSA Private Key Gotten From {path}"; #inaccessible
return key;
def opt_mine_block(path = None):
global rsa_private_key;
if (path):
rsa_private_key = get_private_key_from_file(path);
if not (rsa_private_key):
return None;
if rsa_private_key is None:
msg_array[0] = "You must load or generate an RSA keypair first.";
return;
try:
response = requests.get(f"{WIKIDECK_URL}/blocks");
if response.status_code != 200:
msg_array[0] = f"Failed to fetch block to mine: {response.status_code}";
return;
block_to_mine = Block(data = response.json());
block_to_mine.mine(rsa_private_key);
response = requests.post(f"{WIKIDECK_URL}/blocks", json=block_to_mine.as_dict());
if response.status_code == 200:
msg_array[0] = "Block successfully mined and submitted";
else:
msg_array[0] = f"Failed to submit mined block: {response.status_code}";
except Exception as e:
msg_array[0] = f"Error during mining: {str(e)}";
def opt_generate_transaction(path = None, recv = None):
global rsa_private_key;
if (path):
rsa_private_key = get_private_key_from_file(path);
if not (rsa_private_key):
return None;
if rsa_private_key is None:
msg_array[0] = "You must load or generate an RSA keypair first.";
return;
if not (recv):
print("\nPaste the recipient's RSA Public Key (PEM format). End with an Empty Line (Press enter twice): ");
lines = [];
while True:
line = input();
if not line.strip():
break;
lines.append(line);
receiver_pem = "\n".join(lines);
try:
receiver_key = serialization.load_pem_public_key(receiver_pem.encode("utf-8"));
except Exception:
msg_array[0] = "Invalid public key format";
return;
else:
if not os.path.isfile(recv):
msg_array[0] = f"Reciever public key file not found {recv}";
return None;
with open(recv, "rb") as recv_file:
receiver_key = serialization.load_pem_public_key(recv_file.read());
try:
cards = fetch_cards_json(rsa_private_key);
if not cards:
msg_array[0] = "You have no cards to send";
return;
print("\nYour cards:");
for i, card in enumerate(cards):
print(f"{i+1}. Card ID: {card['cardId']}, Page ID: {card['pageId']}");
try:
index: int = int(input("Select a card number to send: ").strip()) - 1;
if not (0 <= index < len(cards)):
raise ValueError;
except ValueError:
msg_array[0] = "Invalid card selection";
return;
selected_card = cards[index];
tx = Transaction(
cardId = selected_card['cardId'],
sender = rsa_private_key.public_key(),
receiver=receiver_key,
authorPrivateKey=rsa_private_key
);
response = requests.post(f"{WIKIDECK_URL}/transactions", json=tx.as_dict());
if response.status_code != 200:
msg_array[0] = f"Transaction failed: {response.status_code}";
else:
msg_array[0] = "Transaction submitted successfully";
except Exception as e:
msg_array[0] = f"Error creating transaction: {str(e)}";
def opt_view_deck():
global rsa_private_key;
if rsa_private_key is None:
msg_array[0] = "You must load or generate an RSA keypair first.";
return;
try:
cards = fetch_cards_json(rsa_private_key);
if len(cards) <= 0:
msg_array[0] = "You have no cards to view";
return;
for i, card in enumerate(cards):
print(f"{i+1}. Card ID: {card['cardId']}, Page ID: {card['pageId']}");
except Exception as e:
msg_array[0] = f"Error fetching cards: {str(e)}";
def fetch_cards_json(rsa):
pem = rsa.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode("utf-8");
response = requests.get(f"{WIKIDECK_URL}/cards", params={"publicKey": pem});
if response.status_code != 200:
msg_array[0] = f"Failed to fetch cards: {response.status_code}";
return;
cards = response.json();
return cards;
def clear_console():
os.system('cls' if os.name == 'nt' else 'clear');
if __name__ == "__main__":
main();
+48
View File
@@ -0,0 +1,48 @@
# 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/<name>.
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
+209
View File
@@ -0,0 +1,209 @@
# 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_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:
+109
View File
@@ -0,0 +1,109 @@
# 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_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:
+44
View File
@@ -0,0 +1,44 @@
# Gated candidate profile, NOT an announcement of a production-ready or launched network.
# Keep payments mocked. Review docs/mainnet-launch.md before attempting activation.
services:
mainnet:
profiles: [mainnet]
build:
context: .
target: runtime
args:
WIKIDECK_NETWORK: mainnet
ports:
- "127.0.0.1:${WIKIDECK_MAINNET_PORT:-3201}:3001"
environment:
WIKIDECK_NETWORK: mainnet
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:-}"
WIKIDECK_MAINNET_REVIEW_ACK: "${WIKIDECK_MAINNET_REVIEW_ACK:-}"
WIKIDECK_ALLOW_PRIVATE_PEERS: "0"
# 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
restart: "no"
read_only: true
tmpfs:
- /tmp:size=32m,mode=1777,noexec,nosuid
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
pids_limit: 128
mem_limit: 1g
cpus: 1.0
stop_grace_period: 30s
volumes:
mainnet-candidate-data:
+30
View File
@@ -0,0 +1,30 @@
# 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
+71
View File
@@ -0,0 +1,71 @@
# Three independent validators, separate volumes, no miner auto-starts.
x-market-environment: &market-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_MARKET_FEE_BPS: "${WIKIDECK_MARKET_FEE_BPS:-250}"
WIKIDECK_MARKET_CONFIRMATIONS: "${WIKIDECK_MARKET_CONFIRMATIONS:-10}"
services:
node-a:
image: wikideck:testnet
build: { context: ., target: runtime }
ports: ["127.0.0.1:3101:3001"]
environment:
<<: *market-environment
PUBLIC_ORIGIN: http://127.0.0.1:3101
WIKIDECK_PEERS: http://node-b:3001,http://node-c:3001
WIKIDECK_ALLOW_PRIVATE_PEERS: "1"
volumes: ["testnet-a:/app/data"]
init: true
read_only: true
tmpfs: ["/tmp:size=32m,mode=1777,noexec,nosuid"]
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
pids_limit: 128
mem_limit: 1g
cpus: 1.0
stop_grace_period: 30s
node-b:
image: wikideck:testnet
build: { context: ., target: runtime }
ports: ["127.0.0.1:3102:3001"]
environment:
<<: *market-environment
PUBLIC_ORIGIN: http://127.0.0.1:3102
WIKIDECK_PEERS: http://node-a:3001,http://node-c:3001
WIKIDECK_ALLOW_PRIVATE_PEERS: "1"
volumes: ["testnet-b:/app/data"]
init: true
read_only: true
tmpfs: ["/tmp:size=32m,mode=1777,noexec,nosuid"]
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
pids_limit: 128
mem_limit: 1g
cpus: 1.0
stop_grace_period: 30s
node-c:
image: wikideck:testnet
build: { context: ., target: runtime }
ports: ["127.0.0.1:3103:3001"]
environment:
<<: *market-environment
PUBLIC_ORIGIN: http://127.0.0.1:3103
WIKIDECK_PEERS: http://node-a:3001,http://node-b:3001
WIKIDECK_ALLOW_PRIVATE_PEERS: "1"
volumes: ["testnet-c:/app/data"]
init: true
read_only: true
tmpfs: ["/tmp:size=32m,mode=1777,noexec,nosuid"]
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
pids_limit: 128
mem_limit: 1g
cpus: 1.0
stop_grace_period: 30s
volumes:
testnet-a:
testnet-b:
testnet-c:
+37
View File
@@ -0,0 +1,37 @@
services:
wikideck:
build:
context: .
target: runtime
ports:
# Keep the prototype private; listen on all interfaces only inside the container.
- "127.0.0.1:${WIKIDECK_PORT:-3001}:3001"
environment:
WIKIDECK_NETWORK: testnet
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_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
restart: unless-stopped
read_only: true
tmpfs:
- /tmp:size=32m,mode=1777,noexec,nosuid
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 128
mem_limit: 1g
cpus: 1.0
stop_grace_period: 30s
volumes:
wikideck-data:
+41
View File
@@ -0,0 +1,41 @@
# Example for nginx on the SAME host as loopback-bound node :3201.
# Not installed by this repository. Replace host/cert paths and validate real topology.
# Node: WIKIDECK_TRUSTED_PROXIES=127.0.0.1,::1 (never use this for a container bridge without adjustment).
limit_req_zone $binary_remote_addr zone=wikideck_ip:10m rate=5r/s;
limit_conn_zone $binary_remote_addr zone=wikideck_conn:10m;
server {
listen 80;
server_name wikideck.example.invalid;
return 308 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name wikideck.example.invalid;
ssl_certificate /etc/ssl/wikideck/fullchain.pem;
ssl_certificate_key /etc/ssl/wikideck/private.key;
ssl_protocols TLSv1.2 TLSv1.3;
server_tokens off;
client_max_body_size 68k;
client_body_timeout 15s;
client_header_timeout 10s;
keepalive_timeout 10s;
send_timeout 30s;
limit_conn wikideck_conn 16;
limit_req_status 429;
# Do not proxy private operations listener. Its port must never be publicly published.
location / {
limit_req zone=wikideck_ip burst=20 nodelay;
proxy_pass http://127.0.0.1:3201;
proxy_http_version 1.1;
proxy_set_header Host $host;
# Overwrite, do NOT append an untrusted client-supplied X-Forwarded-For chain.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Forwarded "";
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_send_timeout 15s;
proxy_read_timeout 30s;
}
}
+4
View File
@@ -0,0 +1,4 @@
FROM node:24-alpine
RUN apk add --no-cache openssl
COPY deploy/postgres/setup.mjs /setup.mjs
CMD ["node", "/setup.mjs"]
+2
View File
@@ -0,0 +1,2 @@
FROM postgres:17-alpine
COPY deploy/postgres/pg_hba.conf /etc/wikideck/pg_hba.conf
+8
View File
@@ -0,0 +1,8 @@
# 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
+61
View File
@@ -0,0 +1,61 @@
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.');
}
+50
View File
@@ -0,0 +1,50 @@
# 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.
+6
View File
@@ -0,0 +1,6 @@
{
"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"
}
+4
View File
@@ -0,0 +1,4 @@
{
"network": "mainnet",
"url": "https://monero-rpc.internal.example/json_rpc"
}
+50
View File
@@ -0,0 +1,50 @@
[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
-29
View File
@@ -1,29 +0,0 @@
version: '3.8'
services:
wikideck-mine:
image: ericomeehan/wikideck:latest
build:
context: .
dockerfile: Dockerfile
environment:
ROLE: mine
DATA_PATH: /tmp
DIFFICULTY_REQUIREMENT: 10
MINE_DB_HOST: mariadb-mine
MINE_DB_USER: mine
MINE_DB_PASSWORD: 123abc
depends_on:
- mariadb-mine
ports:
- "8080:80"
mariadb-mine:
image: mariadb:latest
environment:
MARIADB_ROOT_PASSWORD: abc123
MARIADB_DATABASE: mine
MARIADB_USER: mine
MARIADB_PASSWORD: 123abc
ports:
- "3306:3306"
+58
View File
@@ -0,0 +1,58 @@
# M2 increment — canonical indexes and recoverable market observation
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).
## Delivered
- [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.
## Tests and measurements
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.
- 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.
[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.
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.
+62
View File
@@ -0,0 +1,62 @@
# 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.
## Tools and safety boundaries
- `analyze:consensus`: deterministic JSON report calling the **actual** `expectedTarget`, `medianTime` and `workFor` helpers. Six 60-record traces: steady 900-second spacing, rapid one-second spacing, slow 7,200-second spacing, a seven-day pause at a window boundary, minimum-median timestamps with normally advancing wall time, and timestamps at the allowed +120-second future edge.
- Records contain only fields read by those mathematical helpers. They are **not valid blocks**, have no PoW/signatures/state commitments, are never submitted to a ledger and do not add a fake-PoW production option. Spacing is externally imposed rather than sampled from constant-hashrate mining. The comparison of cumulative work is not proof that a miner can actually produce those traces.
- `benchmark:randomx`: requires `--consent`, uses the trusted local reference binary and accepts `--hashes` (4–256, default 32), optional `--fast` (explicit >2.3 GiB memory choice), and `--network-hps` (integer 1–10^12, default 1). Unknown/invalid arguments fail before starting native work. A 60-second wall-time deadline closes the engine; native requests also retain their own bounds. No endpoint, wallet, ledger, payout or launch option exists. CPU/energy consumption is explicit.
- Benchmark output includes executable SHA-256, platform/Node version, mode, cold hash time, repeat hash, warm sequential IPC-inclusive rate, hash latency samples and one epoch/cache switch. The declared network hashrate is a **hypothesis**, never inferred by multiplying a local worker rate by an assumed number of machines. Output targets are illustrative strings only and are never written into consensus/configuration.
These tools can be invoked via the package scripts on Node 24. The Docker test target includes the trusted Linux-native engine; offline benchmark execution can use a network-disabled disposable container. Do not benchmark a production node or enable fast mode without an explicit memory/energy budget. Keep reports with the exact image/native digest and host/container constraints.
## Verified arithmetic and interpretation
Wikideck interprets the 256-bit RandomX output as a big-endian integer and accepts hash <= target. Assuming uniform hash output, success probability is `(target + 1) / 2^256`, expected trials are `2^256 / (target + 1)`, and expected seconds at H hashes/s are expected trials divided by H. These are expectations, not schedules or upper time bounds.
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.
## Local native measurement (2026-10-20)
One sample, offline Linux x64 Docker, **1 CPU quota / 1 GiB memory limit**, Node v24.22.0, light mode, 32 sequential warm hashes, existing pinned RandomX v1.2.1 implementation:
| Measurement | Result |
| --- | --- |
| Executable SHA-256 | 5e1b99ea9df075d75a676a4ea1090e62d7738d03ac6316c41f59dedfffac4318 |
| Cold hash (including initialization/startup) | 7,379 ms |
| Immediate repeated same-key hash | 1,520 ms |
| Warm batch elapsed | 16,645 ms |
| Warm sequential IPC-inclusive rate | **1.9225 H/s** |
| Warm p50 / p95 individual request | 552 / 755 ms |
| One epoch/cache switch hash | 4,226 ms |
| Initial expected interval at this measured single-worker rate | **8.323 seconds** |
The host was **not a dedicated controlled benchmark machine**; co-tenancy, scheduling, container CPU throttling, virtualization and thermal conditions were not instrumented. The surprising repeat-vs-warm timings reinforce that one short sample cannot certify capacity. It is a `hash`/verification-path measurement with per-request IPC, not native miner batch throughput; it includes neither full block validation nor state-root/SQLite/network overhead. Cache initialization/switch measurements are single samples, not statistically characterized distributions. Fast mode was not measured. Node `process.memoryUsage` would omit the native subprocess, so no misleading whole-engine RSS claim is made.
For an **explicitly hypothetical** aggregate 100 H/s network and 900-second target, the tool produced illustrative target `0000ba69dbdd3ac13d7c4a74c8111cb7aecee4bd24e8d5b85d9222dc8bfdff5b` (90,000 expected hashes). This was **not applied** and is not a recommendation. Expected rewrite cost, miner diversity, rate variance, launch surge/decline and adversarial hashrate remain unmeasured.
## Deterministic scenario findings
1. **Steady/slow at maximum target:** stable 900-second spacing retains the initial target; 8× slower spacing cannot ease above maximum. There is no wall-clock emergency easing during a stall.
2. **Rapid trace:** difficulty does not change before the ten-block boundary. After ten one-second-spaced records the target is quartered by the elapsed-time floor (`floor(8100/4) = 2025 seconds`). Further imposed rapid windows keep increasing required work; this does not mean the same hashrate could sustain those arrival times.
3. **Seven-day boundary pause:** the pause is placed before the first block of the third ten-block window. The endpoint difference within that window excludes the inter-window gap. This illustrates a boundary blind spot, not a fully executed exploit: the choice of where a pause occurs matters and requires review of alternative retarget designs. The current maximum target also limits what this particular trace can reveal about recovery from already-high difficulty.
4. **Median-minimum trace:** timestamps just above the actual prior median can satisfy timestamp bounds while lagging wall time substantially. Retarget uses timestamp differences, not independently verified elapsed mining time. Analyze time-warp/oscillation attacks with quantified hashpower and viable block production before selecting rules.
5. **Constant future-edge offset:** +120 seconds is allowed by the current timestamp rule. A constant offset cancels out of interval differences; it is not by itself evidence of a difficulty exploit. Clock skew and shifting offsets still need adversarial analysis. Validator tests reject timestamps at/below median or beyond future allowance **before proof work** and exercise the exact allowed edge reaching subsequent commitment checks.
6. **Partition work comparison:** synthetic steady and rapid branches have different accumulated work, and the existing strict-work rule favors the greater-work branch. Existing ledger/native integration tests cover real validated branch/reorg handling separately. The synthetic report is not a multi-host partition, adversarial mining or economic-security rehearsal.
## Validation and remaining launch evidence
Build and full regression suite passed **92 tests, 0 failed, 0 skipped**, including real RandomX HTTP integration and six new calibration/timestamp tests. Tests assert inclusive-target arithmetic, bounded inputs, exact window/clamp behavior, reproducibility, scenario timestamp bounds, probability assumptions, consent refusal before engine access and validator timestamp rejection. There is no claim of formal consensus correctness.
Before production calibration can be approved:
- Benchmark the exact final native/image build on multiple intended independent miner/validator hosts; record CPU model, operating mode, effective quotas, competing load, cold/warm/cache-switch distributions and batch-miner throughput. Use repeated runs and documented confidence/variance, not this single sample.
- 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.
See [security review](security-review.md), [launch evidence register](operator-launch.md) and the read-only preflight. The calibration blocker remains open.
+158
View File
@@ -0,0 +1,158 @@
# 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.
+64
View File
@@ -0,0 +1,64 @@
# 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:<state-root>` 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.
+48
View File
@@ -0,0 +1,48 @@
# 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.
+57
View File
@@ -0,0 +1,57 @@
# 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.
**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.
## What is implemented
| Property | Testnet default | Mainnet candidate |
| --- | --- | --- |
| Selection | `WIKIDECK_NETWORK=testnet` (or unset) | `WIKIDECK_NETWORK=mainnet` |
| 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 |
| 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.
**Testnet cards and backups do not migrate.** Browser storage is separate, backup network headers are authenticated and checked, peers reject mismatched manifests, and transactions are checked against the active chain. The selected profile is pinned at startup/browser build, never silently adopted from an RPC response.
## Inspect without launching
The node entry point accepts `--network-manifest`. With `WIKIDECK_NETWORK=mainnet` set in the process environment, this prints the candidate network/chain/genesis/timestamp and exits **before** opening databases, starting a RandomX engine, listening, or contacting peers. It does not require activation credentials or approval and does not launch anything.
Browser builds select `VITE_WIKIDECK_NETWORK=mainnet`; server/miner processes select `WIKIDECK_NETWORK=mainnet`. These must match. Docker's `WIKIDECK_NETWORK` **build argument** sets the browser profile; a runtime variable cannot alter an already compiled UI. A mismatched UI rejects the node network instead of signing for it. The mainnet Compose definition supplies both the build and runtime selections.
## Explicit launch gates
[compose.mainnet.yaml](../compose.mainnet.yaml) is an opt-in Compose profile, uses a separate named volume, binds only loopback for an external HTTPS reverse proxy, and does **not** auto-restart a failed candidate. It has no default peers, approval, or public origin. It is not started by the default Compose definitions. **No mainnet nodes have been started by this change.**
Starting the candidate node or native miner requires all of:
1. `WIKIDECK_MAINNET_LAUNCH` equals the exact candidate chain ID printed by the manifest command.
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
- **Independent review:** protocol/cryptography, signatures and wallet lifecycle, RandomX integration, difficulty/time/retarget attacks, state commitments, fork choice, network message validation and denial-of-service limits. Publish findings, fixes and a versioned final manifest.
- **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.
- **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.
## 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.
+35
View File
@@ -0,0 +1,35 @@
# Optional market hosting and host fee wallet
These are **server runtime** environment variables, forwarded by the single-node, three-node, gated mainnet and persistent custody Compose files. They do not enable real payments.
| Variable | Default | Effect |
| --- | --- | --- |
| `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 the 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. Existing Compose defaults preserve current hosting behavior. 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
Disabling market hosting is **not a funds freeze or deletion**. Signed private activity, existing-order mock payment/delivery/refund, listing retirement/cancellation, custody cancellation and withdrawal-hold requests remain available under their existing checks. Reconciliation continues and data is retained. PostgreSQL remains required if that node already has PostgreSQL-backed liabilities; the switch is not a shortcut to abandoning custody responsibilities. Existing valid command receipts remain replayable without creating another mutation.
A peer that never wants custody can leave `WIKIDECK_CUSTODY_MODE=off` and use the ordinary single/three-node stack with `WIKIDECK_MARKET_ENABLED=0`; it does not need the dedicated PostgreSQL stack. Do not turn custody off merely to hide pre-existing liabilities; first resolve them under a reviewed shutdown procedure. The marketplace UI retains an activity/resolution surface and displays the disabled/paused policy rather than hiding recovery controls.
## 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.
+133
View File
@@ -0,0 +1,133 @@
# 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.
+94
View File
@@ -0,0 +1,94 @@
# 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.
+51
View File
@@ -0,0 +1,51 @@
# 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.**
## Admission and transport
API and P2P traffic have separate process-local token buckets. API saturation does not spend P2P tokens. Both lanes still share the event loop, ledger mutation queue, native engine and host resources: this is admission separation, not CPU/process isolation. Budgets are global per process rather than per wallet/IP, so changing wallet or source IP cannot bypass the global limit. Existing per-IP limits remain in place; no permissive proxy trust was enabled.
Admission precedes JSON parsing, signature verification and route work. Excess traffic receives 503 plus `Retry-After: 1`; there is no unbounded retry queue. JSON request bodies remain capped at 68 KiB and compressed request inflation is disabled. Response JSON is capped before transmission. Byte budget is charged on serialized JSON bytes, not network/TLS framing. Small error responses are not charged recursively when rejecting a payload/budget; edge limits are still needed.
| Variable | Default | Accepted range |
| --- | --- | --- |
| `WIKIDECK_RPC_REQUESTS_PER_SECOND` | 120 | 1–1000 |
| `WIKIDECK_RPC_WRITES_PER_SECOND` | 10 | 1–100 |
| `WIKIDECK_RPC_MAX_ACTIVE` | 32 | 1–256 |
| `WIKIDECK_RPC_RESPONSE_BYTES` | 2097152 (2 MiB) | 128 KiB–2 MiB |
| `WIKIDECK_RPC_BYTES_PER_SECOND` | 8388608 (8 MiB/s) | 2–128 MiB/s |
| `WIKIDECK_RPC_LEGACY_BLOCKS` | 128 | 0–128 |
Token bursts equal one second of the configured rate. P2P uses a separate fixed lane of 40 requests/s, 10 writes/s and 16 active HTTP responses, with the same configured byte limits. Active slots are released on response finish/connection close; asynchronous route work already admitted may still be completing after disconnection. Existing bounded native, ledger and Wikimedia queues/concurrency remain necessary.
HTTP has at most 256 accepted connections, 10-second headers timeout, 15-second request timeout, 5-second idle keepalive and a 30-second socket inactivity timeout. These are process transport protections, not a substitute for proxy timeouts/connection limits, global ingress limits, OS limits or load-balanced per-instance policy. Body parsing is no longer ahead of admission. Health bypasses the API lanes for liveness, but still shares server connections and CPU.
Configuration is validated before opening node resources. Lower limits can shed legitimate traffic, including sync; test settings against the intended topology before deploying. Do not raise limits merely to hide overload. Custom settings require container environment forwarding when deploying with Compose.
## Bounded state API
- `GET /api/summary`: network/chain/genesis, canonical tip/height/work, process-local revision, peers/maturity and bounded pending transactions. No full-chain copy. Revision is only a local cache invalidation counter, not consensus or a durable sequence.
- `GET /api/canonical?tip=<hash>&offset=0&limit=16`: up to **16 canonical blocks**, pinned to the current tip. Tip changes return 409 so the caller restarts. Offset cannot exceed current height. Each page uses the cached canonical branch and clones only its slice. The first page after a new tip may rebuild that branch in O(height).
- Legacy `/api/state` and `/api/chain` still return full snapshots only up to the configured legacy height and response-byte cap. Beyond it, they return 413 with paging instructions **before full snapshot cloning**. They never silently truncate ownership history. Legacy scripts/API clients must migrate to paging for longer chains.
- `/p2p/status` and `/p2p/mempool` now obtain only the fields needed instead of cloning the chain. P2P block inventory semantics are unchanged.
The browser and trade signing refresh use bounded streaming response reads (2 MiB/page), pagination/network/tip/parent continuity checks, one restart on tip conflict, shared in-flight loads, and append-history cache reuse. A final summary confirms the loaded tip. These checks are **not independent PoW/consensus verification**: the UI still trusts the node.
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.
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.
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.
+90
View File
@@ -0,0 +1,90 @@
# Deployment, private monitoring and recovery — stage 4
**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.
## Explicit 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.
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.
## 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.
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 aggregates:
- 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.
`ops:check -- --port <private-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.
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.
## Stopped-node pair backup
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.
### Create
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 <node-data-directory>`, **new** `--destination <backup-directory>` 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.
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.
### Verify
Action `verify` needs `--source <backup-directory>` 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.
**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 <verified-backup-directory>`, a **nonexistent** `--destination <new-data-directory>`, 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/<name>` (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.
+78
View File
@@ -0,0 +1,78 @@
# 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.
**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.
[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.
[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.
## 1. Safe inspection — does not launch anything
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.
The preflight:
- 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.
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.
**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.
## 2. Evidence register — all required before production sign-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.
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.
| 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 |
## 3. Freeze and rehearse before authorization
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.
## 4. Explicit operator go/no-go meeting
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.
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.
+54
View File
@@ -0,0 +1,54 @@
# 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.
+117
View File
@@ -0,0 +1,117 @@
# 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.
+236
View File
@@ -0,0 +1,236 @@
# 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.
+192
View File
@@ -0,0 +1,192 @@
# Native RandomX engine
Wikideck's native CPU proof engine uses RandomX, the algorithm also used by Monero,
designed to keep general-purpose consumer CPUs competitive. Mining a Wikideck block
rewards its miner with a Wikipedia NFT card on Wikideck's independent chain. The Docker image builds this pinned
implementation and runs its known-vector test. There is no GPU path, simulated hash,
or production fallback hash.
This document covers building the executable, its memory modes, its line protocol,
and the TypeScript adapter. For the user-facing install and run instructions, start
with the [README](../README.md).
## Contents
- [Source and build](#source-and-build)
- [Modes and resource use](#modes-and-resource-use)
- [Line protocol](#line-protocol)
- [Node API](#node-api)
- [Real upstream known-vector check](#real-upstream-known-vector-check)
## Source and build
[RandomX v1.2.1](https://github.com/tevador/RandomX/tree/102f8acf90a7649ada410de5499a7ec62e49e1da)
is fetched by CMake FetchContent from the official HTTPS Git repository, pinned to
**102f8acf90a7649ada410de5499a7ec62e49e1da** (also the upstream v1.2.1 tag).
The first configuration requires network access and Git; later builds reuse the
checkout. Keep upstream copyright/license notices when distributing binaries.
Do not override FetchContent sources with untrusted local code.
Requirements: CMake 3.24+, Git, a C/C++17 toolchain, and a 64-bit host for fast mode.
MSVC needs its C++ and MASM tools in a developer terminal. MinGW needs its compiler
and make on PATH; select the `MinGW Makefiles` generator. Linux/macOS need their
normal C/C++ build tools. No administrator privileges or huge pages are required.
On MSVC, upstream's pinned build invokes its own PowerShell assembly-generation
script; review upstream build tooling before running under restrictive policies.
From the repository root:
```sh
cmake -S native -B native/build -DCMAKE_BUILD_TYPE=Release -DWIKIDECK_RANDOMX_TESTS=OFF
cmake --build native/build --config Release --target wikideck-randomx --parallel 2
```
Convenience scripts (two compiler jobs by default):
```sh
sh scripts/build-randomx.sh
sh scripts/build-randomx.sh --test
```
```powershell
./scripts/build-randomx.ps1
./scripts/build-randomx.ps1 -Generator 'MinGW Makefiles' -Test
```
The scripts propagate configure/build/test failures. PowerShell execution policy
must permit the script; do not bypass a managed policy. POSIX customization:
`JOBS=2`, `RANDOMX_BUILD_DIR=/absolute/build/path`; PowerShell customization:
`-Jobs 2`, `-BuildDirectory <path>`. Select a toolchain/generator through the normal
CMake environment or a manual configuration. Don't switch generators in an
existing build directory.
All generators place the executable directly under `native/build` (with `.exe` on
Windows), including Release builds with multi-config generators.
Upstream tests/benchmark/codegen are excluded from the default build: v1.2.1 does
not provide a tests-disable option. No benchmark or dataset-heavy fast-mode test
runs automatically.
## Modes and resource use
Run `wikideck-randomx` with no arguments for **light** mode (256 MiB cache plus
approximately 2 MiB VM scratchpad and runtime overhead). `--fast` adds the real
approximately 2080 MiB dataset; the 256 MiB cache is retained for key changes, so
budget over 2.3 GiB per process. Dataset initialization is single-threaded and can
be expensive. Neither mode allocates RandomX memory until its first valid request.
Cache, dataset, and VM are reused for the same key. A changed key reinitializes the
cache and, in fast mode, the entire dataset before creating a fresh VM.
Flags start with `randomx_get_flags()` (CPU-supported AES/JIT/Argon2 optimization).
`RANDOMX_FLAG_SECURE` is explicitly added for VM JIT W^X, and upstream is compiled
with `RANDOMX_FORCE_SECURE` for cache JIT policy too. Platforms without JIT use the
reference interpreter; allocation/JIT failures are fatal, never a fake PoW result.
No large pages are requested. RAII releases VM, dataset, then cache on normal EOF
and caught errors. Public PoW key/cache data is not secret and is released, not
claimed to be securely erased from physical memory.
## Line protocol
One ASCII command per LF-terminated line; CRLF is also accepted. Exactly one ASCII
response line, flushed immediately, per valid command:
```text
hash <key64hex> <blobhex>
<hash64hex>
mine <key64hex> <blobhex> <target64hex> <nonceOffsetDecimal> <maxAttemptsDecimal>
found <hash64hex> <nonceDecimal>
exhausted
```
`found` and `exhausted` are alternatives, not two responses. Input hex may use
either case; output hashes are lowercase in the RandomX byte order. Keys and
targets are exactly 32 bytes (64 hex characters); blobs are 1..8192 bytes.
Tokens use exactly one ASCII space, without leading/trailing whitespace. Commands
are capped at 16600 bytes before LF. Invalid characters, malformed/overflowing
unsigned decimal, extra tokens, invalid bounds, or a partial final line cause a
concise stderr error and nonzero exit, with no fabricated response on stdout.
Empty stdin EOF exits successfully. There are no stdout banners or diagnostic logs.
Mining requires `maxAttempts` in 1..10000 and `nonceOffset + 8 <= blob.size`.
The initial nonce is read from the blob's existing **eight little-endian bytes**.
The first attempt uses that nonce, then increments by one and rewrites LE8 for
each attempt. Only those eight bytes change. At uint64 maximum, search ends
without wrapping. The native response contains an exact unsigned decimal nonce.
**Target convention: interpret the 32 output bytes and target bytes as unsigned
BIG-ENDIAN integers; accept `hash <= target`. This intentionally differs from
Monero's convention.** In particular, equal hashes pass. Never reverse the hash
or target when using this interface.
## Node API
[RandomX](../server/testnet/randomx.ts) exports:
```ts
interface PowEngine {
hash(key: string, blob: Uint8Array): Promise<string>;
close(): void;
}
class RandomX implements PowEngine {
constructor(options?: { binary?: string; fast?: boolean });
hash(key: string, blob: Uint8Array): Promise<string>;
mine(key: string, blob: Uint8Array, target: string,
nonceOffset: number, maxAttempts: number):
Promise<{ hash: string; nonce: number } | null>;
close(): void;
}
```
Binary precedence: `options.binary`, `RANDOMX_BINARY`, then the executable under
the repository's native build directory (resolved relative to this module,
independent of working directory). Relative explicit paths are resolved against
the caller's working directory. Configure only a trusted executable: this is a
native-code trust boundary, not a sandbox. The adapter uses `spawn` with a fixed
argument array, `shell: false`, and pipe stdio; user data only enters stdin after
strict validation. Missing/non-executable files throw in the constructor. Other
spawn failures are retained and reject current/future requests.
Each instance owns one long-lived child, one active command, and at most 64 total
active/queued requests. Calls are FIFO, including mixed hash/mine calls and key
changes. Inputs are snapshotted before queuing. The 120-second deadline starts
when each command is sent (includes cold cache/dataset initialization); queued
requests wait for earlier work. Keep mining batches small enough to finish on the
host CPU. The process is killed on timeout, malformed/unsolicited/oversized output,
stream error, or crash, rejecting all outstanding work and subsequent calls.
Responses and retained stderr are bounded. No automatic retry or process restart.
`mine` returns `null` for exhaustion and validates returned hash syntax, target,
and nonce range (it does not independently recompute a native result). Because the
API returns `number`, its entire requested nonce range must fit
`Number.MAX_SAFE_INTEGER`; higher uint64 nonces are supported only by the direct
native protocol. Invalid inputs reject without sending native work.
Always call `close()` in `finally`. It is idempotent, rejects pending/queued work,
and sends EOF for graceful RAII cleanup; a busy child is forcibly killed after
one second. A failed/closed instance cannot be reopened.
## Real upstream known-vector check
The opt-in `--self-test` executable argument validates upstream's exact
[Hash test 1a/2a](https://github.com/tevador/RandomX/blob/102f8acf90a7649ada410de5499a7ec62e49e1da/src/tests/tests.cpp):
- Key: ASCII `test key 000` (12 bytes, **not NUL-terminated or zero-padded**).
- Input: ASCII `This is a test` (14 bytes, no terminator).
- Expected RandomX hash:
`639183aae1bf4c9a35884cb46b09cad9175f04efd7684e7262a0ac1c2f0b4e3f`.
The self-test uses an internal API so the line protocol retains its strict
32-byte-key requirement. Padding this upstream key to 32 bytes would change the
hash and is not a valid reproduction of the vector. Self-test success is reported
on stderr only, with exit zero; mismatch exits nonzero. `--fast --self-test` checks
the same real vector in dataset mode, but requires the full memory allocation.
```sh
cmake -S native -B native/build -DCMAKE_BUILD_TYPE=Release -DWIKIDECK_RANDOMX_TESTS=ON
cmake --build native/build --config Release --target wikideck-randomx --parallel 2
ctest --test-dir native/build -C Release --output-on-failure
```
This test is OFF by default and is separate from the application's tests. Without
a built native binary, no native engine or native test is run by the application.
+94
View File
@@ -0,0 +1,94 @@
# 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 <CUSTODIAN_UUID>` 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 <ROLE>` 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 <ROLE>` 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 <ROLE>` 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":"<lowercase SHA-256 of certificate DER>","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 <ROLE>` 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 <WALLET_GENERATION_UUID> <TXID:OUTPUT_PUBLIC_KEY>` 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.
+131
View File
@@ -0,0 +1,131 @@
# 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.
+78
View File
@@ -0,0 +1,78 @@
# 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<E$, and bounded remaining window. Ownership unchanged; sequence advances once. |
| Locked → completed | Exact host signature, $h-L\ge d_L$ and $h<E$. Ownership moves only to the terms' buyer; sequence advances again. |
| Locked → cancelled | Host+buyer signatures and $h<E$. Ownership remains seller; sequence advances again. |
| Locked → expired | Explicit permissionless transaction with $h\ge E$. Ownership remains seller; sequence advances again. |
| Free transfer/trade | Every affected NFT must be unlocked, correctly owned/sequenced, mature and signed. A locked card in any trade leg rejects the entire transition. |
Only one terminal branch can succeed on a canonical history. An invalid transaction rejects the entire candidate block, not just that transaction; two conflicting outcomes in one block therefore reject the block. Block assembly/mempool policy will eventually need to filter these conflicts before proposing a block.
Lock and terminal transitions both consume sequence numbers, invalidating ordinary transfers signed before/during a lock. Admission requires room for **two** increments so terminal release cannot overflow. Expired/cancelled order-use tombstones prevent a fresh signature from recycling the same custodian/order ID. A new order can relist at the fresh sequence. A withheld lock authorization cannot be accepted after $A$, closing the never-submitted-lock refund hazard.
Candidate state commitment includes NFT entries, active locks, full signed-term messages, lock/terminal heights, reservation commitments and permanent order-use tombstones. Ownership alone would be an insufficient state commitment. Snapshot restore/replay is deterministic. This uses full cloned state and tombstone retention for an auditable model, **not a production-optimized overlay or storage design**.
## Financial observation and reorg discipline
`saleDecision()` is a pure non-posting recommendation based on a caller-supplied canonical candidate state:
- Keep the gross reserved while a lock could still be admitted, while it is active, or before a terminal outcome reaches $h-\text{outcomeHeight}+1\ge d_O$.
- Recommend seller/commission settlement only after mature canonical completion.
- Recommend buyer release only after mature canonical cancellation/expiry. If no lock was ever canonical, require $h-A\ge d_O$ before recommending release.
- Already posted outcomes never post twice. A conflicting or immature/missing previously posted outcome requires `review`, not another settlement/refund. Existing review remains review pending evidence-backed resolution.
This does not independently prove the supplied canonical state or observe intermediate forks. Integration must consume the existing durable connect/disconnect stream and retain a review hold even if an outcome reappears before polling. Deep reorgs can invalidate previously mature results and external XMR withdrawals cannot be undone. Loss/reserve/compensation procedures remain mandatory.
An already mature terminal record remains usable evidence if the NFT is later gifted/traded; current owner alone must not erase completed sale evidence. Conversely, a gift is not payment evidence. Cancel/expiry can be followed by a freshly signed free transfer at the new sequence; this is ordinary owner authority, not resurrection of the cancelled sale.
## Failure behavior and trust
| Failure | Candidate behavior / remaining risk |
| --- | --- |
| Seller lists elsewhere or withholds lock | Competing canonical transfer/lock wins by ordinary sequence rules; funds remain reserved until safe cancellation/non-admission boundary. No cross-host database claim is treated as a chain lock. |
| Buyer disappears after signing lock | Host may complete only to the signed buyer; otherwise eventual expiry restores seller control. Financial operator still owes customer funds. |
| Host disappears or loses key | Anyone can submit expiry after the height boundary. A stalled chain delays expiry; this does not recover custodial XMR keys. |
| Host lies about funding | Valid host signature can authorize delivery despite absent reserves. This is explicit custody/attestation trust; the protocol cannot enforce the honesty of an internal balance claim. |
| Cancel and complete race | Only canonical sequence/lock state chooses a winner; no refund before terminal evidence matures. Mempool order is not settlement. |
| Reorg after posting or withdrawal | Review/quarantine, never imaginary reversal of XMR. Operator loss handling and funded limits needed. |
| Host key compromise/rotation | Existing locks bind an exact key. Rotation/revocation and emergency authority are NOT implemented; do not silently redirect existing authority. |
## Evidence and next release gates
Initial candidate evidence: 162 offline tests passed, including 13 candidate tests. Current TypeScript/Vite Docker build and **172 offline tests plus 31 PostgreSQL integration tests passed, zero failures/skips**, including 14 candidate tests in [tests/sale-candidate.test.ts](../tests/sale-candidate.test.ts). Coverage includes current-consensus rejection, field/signature and hosting-recipient tampering, exact height boundaries, sequence exhaustion, native transfer/trade lock bypass, competing-market/reused-order locks, atomic conflicting-outcome rejection, reorg financial decisions, deterministic snapshot restore, state commitments, actual custody-order drafting and enumerated adversarial histories through expiry. The [accounting rehearsal](custody-sale-rehearsal.md) records actual PostgreSQL/fictitious-funds postings, a local-ledger event adapter with native RandomX validation, fake-PoW branch/reorg fixtures, and synthetic candidate-terminal disconnect linkage. Coordinated backup tests use a separate read-only fixture role and isolated restore. No protocol or production-recovery guarantee is inferred from a test count.
**This does not close M3.** Required next steps:
1. Independent protocol/consensus review and operator approval of authority, cancellation and numeric policy parameters; final specification and test vectors.
2. Versioned network/manifest and explicit migration/activation strategy. No enabling the candidate under the existing v6 chain identity.
3. Integrate new transaction encodings/state commitments with mint/transfer/trade validation, canonical SQL undo/indexes/events, actual RandomX blocks, peer sync, restart and multi-host fork/partition tests. The current model supplies no PoW, issuance or fork-choice implementation.
4. Production PostgreSQL sale witness/terms storage, financially reserved host-signing workflow, outbox fencing, local-ledger canonical observer/inbox, activated candidate transaction linkage, atomic terminal postings and per-order reorg holds. The disposable signed-transcript rehearsal exercises terminal postings and durable review; a synthetic event-page test links terminal IDs to orders and fences a disconnect. The local-ledger adapter separately reads canonical events but cannot observe candidate IDs while active v6 rejects those transactions. Existing sandbox signatures do not authorize the new sale protocol. No signing before final exact order consent.
5. Publication/review UI, key management/rotation, privacy and public-price disclosure, window/griefing/storage limits and operational recovery. Public terms would reveal sale linkage and amounts; privacy acceptance is a product decision.
6. Monero confirmation/unlock observation, real treasury controls, coordinated financial/node recovery and independently reviewed production release gates. No live wallet, funds or credentials were used in this increment.
+71
View File
@@ -0,0 +1,71 @@
# Security review and production readiness — 2026-10-20
## Decision: NO-GO for public production or valuable assets
**Stage-1 follow-up:** [Node resource controls](node-resources.md) adds global admission/byte budgets, bounded canonical pages, browser cache/history limits and marketplace read improvements. SEC-01 and SEC-06 remain open: full-history client/reconciliation, DAG/disk growth and production-scale capacity evidence are not solved. Original source line references below describe review-time locations and may shift as remediation proceeds.
**Stage-2 follow-up:** [Encrypted local wallets](wallet-security.md) now provide explicit same-identity migration, encrypted-at-rest vault records and lock-aware signing sessions. SEC-03's original new-record plaintext-storage behavior is remediated after migration, but the production wallet hold remains: old plaintext copies, unlocked-page compromise and independent multi-browser/recovery review are not resolved. The historical finding below records the pre-remediation state.
This is an internal, source-assisted review with automated regression and dependency checks, **not an independent security audit, penetration test, formal consensus proof, or production certification**. The candidate has not been launched and no approval/attestation was set. Do not interpret passing tests, the existence of a mainnet profile, or the arrival of its candidate genesis date as permission to launch.
Scope: current consensus/ledger/P2P/RandomX integration, HTTP entry point, browser wallet/backups, marketplace authorization/accounting/reconciliation, Docker configuration and candidate startup gates. The operating hosts, real reverse proxy, public peers, wallets and funds were **not** accessed or assessed. No active Internet-facing adversarial load test or independent native-code audit was performed.
## Findings that block production
Real-money settlement is not implemented end-to-end. The selected direction is direct Monero custody with a pooled liability ledger; no production deposit, credit, withdrawal, or customer-facing live settlement path exists. SEC-04/SEC-05 remain open.
**Stage-4 follow-up:** [Operations/recovery tooling](operations-recovery.md) provides explicit trusted proxy IP/CIDRs, opt-in separate loopback diagnostics and aggregate monitoring checks, a proxy example (not installed), stopped-node SQLite pair snapshots/verification and new-directory restore. Native integration replays restored reference proofs and preserves marketplace identity/orders/receipts. SEC-07 and SEC-09 remain open pending intended-host deployment, off-host recovery and real alert/on-call evidence.
**Stage-3 follow-up:** [Consensus calibration tools and evidence](consensus-calibration.md) add an offline native benchmark and deterministic actual-helper scenarios plus validator timestamp regressions. Initial target is 16 expected hashes; a short quota-limited sample is not a network calibration. SEC-02 remains open pending representative hashpower, adversarial/multi-host evidence and independent review; no consensus/genesis change was made.
| ID | Priority / scope | Evidence and impact | Required disposition |
| --- | --- | --- | --- |
| SEC-01 | High · public node availability | [HTTP full-state routes](../server/testnet/http.ts#L63) return [full cloned canonical snapshots](../server/testnet/ledger.ts#L223). The ledger retains [all DAG nodes in memory](../server/testnet/ledger.ts#L29); state-root calculation rebuilds the NFT set. Read cost and startup replay grow with chain/fork history. Per-IP request limits do not impose a global CPU, response-byte, memory or disk budget. | Bounded/paged public RPC and a compatible paged client; tested caching/indexing; validation admission budgets; disk/fork retention or a reviewed archival strategy. Demonstrate the intended workload and malicious traffic do not starve consensus or trigger OOM/disk exhaustion. Do not add an arbitrary consensus height cap. |
| SEC-02 | High · chain security | [Initial target](../server/testnet/consensus.ts#L12) succeeds with probability approximately 1/16 per hash, and [retarget](../server/testnet/consensus.ts#L172) uses ten-block windows. This is inherited testnet calibration, not evidence of a secure 15-minute production launch. Timestamp/retarget manipulation, hashpower concentration and private-chain rewrites require quantified analysis. | Independent consensus/economic review; measured launch hashpower; adversarial retarget/clock/partition simulations; choose and freeze final launch parameters and genesis before approval. Changing these changes chain identity. RandomX's shared algorithm does **not** inherit Monero's hashpower or security. |
| SEC-03 | High · valuable NFT keys | [Browser wallet creation/storage](../src/wallet.ts#L76) persists an extractable P-256 private key without password encryption in IndexedDB. Encrypted portable backups do not encrypt that stored key. Same-origin script compromise, profile access or a sufficiently privileged extension can sign/export it. | A reviewed production wallet model with encrypted-at-rest storage/unlock lifecycle or isolated/hardware signing, explicit XSS threat model and tested recovery. Non-extractable keys alone do not stop malicious same-origin signing; storage encryption does not protect an already unlocked page. |
| SEC-04 | High · real-money marketplace | [Payment adapter](../server/market/payments.ts) is deliberately mock-only. Buyer-controlled mock payment can lead a seller to sign a **real** card transfer. Listing reservations are not consensus locks. There is no reviewed live deposit/credit flow, seller XMR destination registration, treasury withdrawal reconciliation, fee-recipient payout, refund/dispute system or custody controls. | Keep real money blocked. For valuable cards, disable mock-sale delivery/listings or keep the entire deployment explicitly valueless. Implement and review the complete direct-custody ledger and treasury path; do not replace the mock adapter with a simple HTTP call. |
| SEC-05 | High · future live refunds/finality | [NFT transfer signatures](../shared/testnet.ts#L54) have no expiry/revocation and payment state is not a consensus commitment. A transfer absent during refund review may be rebroadcast or reappear after a reorg. An off-chain refund cannot cancel a previously signed NFT transfer. Recognizing current canonical delivery (fixed below) does not solve this general issue. | Explicit escrow/atomic-settlement design or a reviewed reserve/dispute/manual risk policy, late-payment/delivery rules, and recovery tools. Never promise trustless exchange or unconditional automatic real refunds. No finite confirmation count guarantees finality on a small PoW network. |
| SEC-06 | Medium · market availability | [Reconciliation](../server/market/store.ts#L115) synchronously scans journal rows/chain and writes journal state. Public browsing invokes it. Lifetime [10,000-listing/order caps](../server/market/store.ts) stop new business without a built-in archival workflow; signed unfunded bids and reservations have little economic cost. Nonces/receipts are not an economic spam defense. | Separate/cached read paths and indexed incremental reconciliation; measured event-loop/IO budgets; account/global anti-abuse policy; bounded receipt/storage management and a tested archive process that preserves payout/idempotency history. |
| SEC-07 | Medium · deployment availability | The app leaves Express proxy trust at its default. Behind a reverse proxy, IP-based limits may group clients under the proxy IP, allowing one client to throttle everyone. Conversely, blindly enabling proxy trust would permit spoofed forwarded IPs. [Health](../server/testnet/http.ts#L50) reports process liveness, not peer synchronization, native-engine readiness or market reconciliation health. | Configure trusted proxy hops/CIDRs against the real topology; reject direct backend access and untrusted forwarded headers; apply edge/global limits and connection/time/body budgets. Add private monitoring for peer freshness, tip lag, reorgs, queues, RandomX failures, disk/WAL usage and market review states. Test actual TLS/HSTS/certificate renewal. |
| SEC-08 | Medium · release supply chain | Lockfile installation and the pinned RandomX commit help, but [Docker base tags](../Dockerfile) and OS package selection are mutable. Native self-tests and npm advisory checks are not native/container security scans. Current builds require network retrieval. | Pin reviewed base image digests and publish exact resolved dependency/toolchain/native revisions, SBOM, image digest/signatures and provenance. Scan the actual runtime image and native build; protect CI/release access. Add native fuzz/sanitizer and reproducibility checks. |
| SEC-09 | Operational blocker | [Startup gates](../server/launch.ts) check configuration syntax/approvals/date, not actual independent review, peer independence/online status, TLS reachability, data recovery or economic security. No real topology, backups, alerting or incident ownership was verified here. | Named operator/reviewer sign-off with attached evidence, multi-host rehearsal and restore drill. Do not set `independent-review-complete` merely because this internal report exists. |
These are prioritized risks/readiness gaps, not claims that each is an already exploited vulnerability. In particular, the mock adapter cannot presently lose real XMR because it never sends or accepts any.
## Fixed during this review
### SEC-F01 · delivery submitted outside the market could be missed
Before the correction, reconciliation recognized delivery only using an order's stored `transferId`, which was set by the market's `deliver` command. A seller could pay/agree to a sale and submit its exact NFT transfer through the normal transfer endpoint, CLI or peer gossip. The market saw the buyer own the card but had no stored delivery identity, so it could classify the paid order as refund-required. That created incorrect mock accounting and would be dangerous if copied into live settlement.
The [new deterministic delivery identity](../server/market/store.ts#L108) derives from the agreed card, seller, buyer, sequence and chain, independent of submission route/signature bytes. Canonical and pending checks and the refund guard use that identity. Direct delivery of an **unpaid** order enters review instead of triggering a payout. Regression tests cover direct canonical delivery, direct pending delivery, refused duplicate refund, and unpaid delivery. This does **not** solve SEC-05's late/reorged-signature problem.
### SEC-F02 · overly broad Docker build context
The prior [Docker exclusions](../.dockerignore) covered only dependencies and compiled UI. The build stage's `COPY . .` could incorporate local environment files, ledger/market databases, Git history, host native binaries or wallet backups into build layers/cache. No evidence of an actual leaked secret was obtained, and no sensitive files were intentionally opened to investigate.
Added exclusions for the data directory, Git metadata, environment files, common wallet/backup/database/private-key formats, host native builds and editor state. This is defense in depth, **not** a guarantee against an arbitrarily named secret. Build from a clean controlled checkout, keep secrets outside the build tree, and review images/cache access. If a sensitive checkout was previously built or pushed, assess exposure, remove affected artifacts according to retention policy, and rotate affected secrets; a later ignore rule does not remove an old layer.
## Controls checked and retained
- Network/chain/signature domain separation, strict public-key/curve/SPKI checks and NFT sequence/maturity validation. Both counterparties sign atomic card trades.
- SQLite transactional ledger updates and serialized mutations; no automatic reset on chain mismatch/corruption. Marketplace instance/actor/action/nonce/expiry binding and fee policy signatures.
- Fixed peer-origin configuration, bounded uncompressed responses/timeouts, DNS answer checks and connection-pinned lookup, no automatic redirect following. Fixed Wikimedia HTTPS sources and bounded API responses.
- Pinned upstream RandomX source commit, bounded native input parsing and subprocess queues, shell-free spawning, and secure JIT/W^X flags. No selectable fake PoW production fallback.
- Local backup PBKDF2-SHA256 / AES-256-GCM with authenticated headers, bounded input, key-pair validation and network checking. This is separate from the browser-storage issue above.
- Non-root runtime, dropped capabilities, no-new-privileges, read-only root and separate data volume in candidate Compose. These settings do not establish host security.
- Mock-only payment enforcement on all profiles; fixed 1% fee rate/recipient is public policy, **not a hardcoded secret**. It is disclosed and bound to sale signatures and order accounting. Existing orders are not silently repriced.
Intentional behaviors **not** reported as vulnerabilities: first-seen equal-work ties; exact auction deadline rejection; delivery window beginning at payment; seller delivery recipient checks; fail-closed handling of corrupted persisted transactions; and keeping settled orders in manual review after reorg rather than risking a second payout. The source checks the buyer and sequence on each delivery; a claim that an alternate recipient can be submitted through that endpoint was not supported.
USD authorization followed by a separately displayed XMR quote is not itself a critical exploit in this mock-only implementation. A future live checkout must bind/review the actual invoice ID, currency, destination, amount, expiry and fee allocation at the payment authorization step; that work remains part of SEC-04.
## Checks performed
- Node 24 TypeScript/Vite build: passed.
- Full suite after corrections: **78 passed, 0 failed, 0 skipped**, including real reference RandomX known-vector/build coverage and two-node HTTP marketplace/transfer integration. Tests include the new read-only preflight and direct-delivery regressions.
- npm production-only and full dependency advisory queries: **0 known advisories reported** for the locked tree at review time. This does not cover unpublished issues, malicious packages, Alpine/Node/native vulnerabilities or registry availability. Repeat against the final artifact before release.
- Fixed fee address decoded independently as **95-character Monero Base58, 69 decoded bytes, prefix 18 (mainnet standard address)**; Keccak-256 checksum matched (`c752185b`). This proves formatting/checksum only, **not** ownership or intended recipient. Verify control out of band before any future live transfer; never share seed/private keys.
- Mainnet preflight exercised with approvals absent: reports `NO-GO`, `productionReady: false`, `paymentMode: mock`; no node/ledger/network startup. Mainnet manifest/configuration remains candidate-only.
Not performed: independent audit, production-host penetration test, adversarial sustained load/fuzz campaign, private-key/hardware review, actual XMR deposit/credit/withdrawal, real peer/miner independence validation, or a production backup/restore exercise. See [operator launch procedure](operator-launch.md) for evidence required before explicit approval.
+178
View File
@@ -0,0 +1,178 @@
# ADR 001 — XMR/NFT settlement feasibility investigation
Status: **proposed; research checkpoint, not an approved exchange protocol**. Source review: 2026-10-20. Related: [production plan](production-plan.md). No processor instance, credentials, wallet or funds were accessed. No swap implementation was installed or run.
**Direction update, 2026-10-20:** the operator has authorized implementing a pooled custodial XMR balance model instead. [ADR 002 — custodial market design](custodial-market-design.md) tracks the implemented isolated sandbox foundation and remaining production work. The recommendations and next gates below are retained as the earlier trustless/arbitrated research record, not a requirement to implement multisig first. Live funds, treasury key access, production fee-policy activation and new consensus activation remain gated. The distinctions between proof, trust, custody and atomicity below still apply.
## Decisions and recommendation
- Operator approved preparatory on-chain NFT locks/reservations for design research. They must not transfer completed-sale ownership before the agreed XMR condition. This does **not** approve a custodian, lock authority, timeout or new consensus rule.
- Continue research into an NFT-first, XMR-compatible conditional exchange. Do not port Bitcoin swap code as though Wikideck were Bitcoin. Current transfers cannot implement the researched protocol.
- No practical reviewed Wikideck-compatible trustless design has yet been demonstrated. This is an evidence gap, **not a proof of impossibility**. M0 remains open pending protocol specification, disposable-fixture spike, failure tests, specialist review and operator approval.
- The later approved direction is pooled custodial XMR balances under ADR 002; the trustless/arbitrated analysis below remains historical research, not the active implementation plan.
**Earlier recommendation (superseded as the preferred investigation):** build proof-backed order evidence and fair, host-scoped reputation as a non-settlement safety layer; evaluate per-order 2-of-3 Monero multisig escrow with independent dispute arbitration as a live-sale candidate. Keep permissionless conditional XMR↔NFT research separate. A payment proof and delivery record cannot return direct-to-seller XMR. The escrow candidate is **arbitrated, not trustless**: the arbitrator and wallet/recovery procedures become material trust and availability dependencies. No live funds, key custody, or new consensus rules were authorized by that recommendation.
## Evidence and compatibility boundaries
| Source | Obtained evidence | Implication / unresolved work |
| --- | --- | --- |
| [Hoenisch / Soriano del Pino, 2021, v2, sections 3–4](https://arxiv.org/html/2101.12332v2) | Section 3 describes Bitcoin lock/redeem/cancel/refund/punish transactions, adaptor signatures and cross-curve discrete-log equality proofs. Monero is funded into a shared-key output after Bitcoin is locked. Timely chain observation and reaction matter. Section 4 explicitly describes further work, not a completed proof/implementation of its alternative. | Potential structure for NFT-first research, not a ready-made NFT exchange. Cross-curve proof and adaptor implementations must match the chosen curves. Never infer current Monero capabilities solely from a 2021 proposal. |
| [COMIT upstream](https://github.com/comit-network/xmr-btc-swap) | Archived/unmaintained; README points to eigenwallet and warns of incompatible peer changes. README states its cryptography was not formally audited. GPL-3.0. | Do not adopt archived upstream as a production dependency. Its historical direction/limitations are not proof of current feasibility or impossibility. |
| [eigenwallet core](https://github.com/eigenwallet/core), [protocol explanation](https://docs.eigenwallet.org/advanced/refund_punish), [4.16.1 release](https://github.com/eigenwallet/core/releases/tag/4.16.1) | Maintained project with 4.16.1 release. Its current docs explicitly define taker as Bitcoin seller and maker as Monero seller; its lock/cancel/refund/punish flow is Bitcoin-timelock centric. The docs and site describe Bitcoin→Monero only. GPL-3.0; historical COMIT README says formal crypto audit absent, but that is not evidence about every current successor version. | Useful comparative protocol and operational lessons, but not the requested XMR-for-NFT direction or an arbitrary-NFT/P-256 integration. No audit artifact for a proposed Wikideck adaptation was found/evaluated. Review exact current source before considering any reuse. |
| [Monero payment-proof guide](https://www.getmonero.org/resources/user-guides/prove-payment.html), [wallet RPC `check_tx_proof`](https://docs.getmonero.org/rpc-library/wallet-rpc/#check_tx_proof) | The payer can give a payee a transaction ID, destination address and transaction proof/key. The payee checks the proof against an up-to-date Monero blockchain view; RPC reports validity, received amount, in-pool status and confirmations. The RPC explicitly warns proof does not guarantee spendability (locked, already spent or duplicated/burnt one-time outputs). | Buyers can prove payment to a seller/market verifier for accountability. This does not identify a real-world person, reverse payment, or give every Wikideck validator an independently verifiable canonical-chain proof. Bind each order to a unique destination and exact amount; treat proof verification/depth as sensitive evidence. |
| [Monero wallet RPC: transfer](https://docs.getmonero.org/rpc-library/wallet-rpc/#transfer) | Supports multiple recipients and atomic-unit amounts; warns against subtracting fees from outputs when exact recipient amounts are required. | Native wallet multi-recipient capability does not make NFT exchange atomic. Amounts need lossless JSON encoding, not conversion to JavaScript Number. |
| [Haveno trade protocol](https://docs.haveno.exchange/overview/trade-protocol/), [dispute resolution](https://docs.haveno.exchange/overview/dispute-resolution/), [project status](https://github.com/haveno-dex/haveno) | A maintained XMR P2P exchange documents per-trade 2-of-3 Monero multisig: one key for each trader and one for an arbitrator. Two keys are needed to spend. Parties normally co-release funds; when they disagree, an arbitrator investigates and co-signs with one party. Security deposits are locked to discourage protocol abuse. Its current project README says the upstream project does not support real trades directly; real networks use third-party builds/networks it does not endorse. | Strong prior art for an **arbitrated escrow**, not a trustless/automatic refund and not an NFT cross-chain protocol. The arbitrator is a real trust and availability dependency in disputes; deposits, key ceremony, wallet recovery, multisig fees, network governance and third-party network integrity matter. Its second asset/payment is fiat/other cryptocurrency and does not answer Wikideck card lock or consensus validation. Treat as a design pattern to adapt only after specialist review, not as a drop-in integration or endorsement. |
The initial Monero atomic-swaps glossary URL returned 404 and is not relied upon. Sources above are primary project/paper documentation, not independent certification. Their live pages can change; a release decision needs archived/pinned evidence against actual dependencies.
### What Wikideck lacks today
[Current transaction types](../shared/testnet.ts) implement ordinary P-256 ECDSA transfers and signed two-party card trades. There is no sale lock, expiry/revocation, conditional witness, payment evidence or cancel/refund branch. An ordinary signed transfer can be submitted later; an off-chain database reservation cannot stop a valid conflicting chain transfer.
Bitcoin's secp256k1 transaction graph, witness rules and timelocks cannot be assumed from the fact that Wikideck also uses ECDSA. P-256 is a different curve. Adding another per-sale cryptographic key scheme might be an alternative to changing wallet identity, but neither is selected. The NFT's uniqueness and absence of a fungible settlement asset also change the economic meaning of the paper's punishment paths.
Most importantly, the paper's participants observe Monero before cooperating; that is not a light client inside every validator. A signature may establish one party's authorization or reveal a secret without proving the full XMR payment condition to consensus. The operator's requirement that completed-sale inclusion require confirmed payment needs a separate explicit guarantee: is this validator-enforced Monero evidence, or participant-verified conditional authorization? Do not conflate them.
Mining remains roughly one card per 15-minute Wikideck block. Preparatory-lock inclusion, Monero funding confirmation/unlock, completed-sale inclusion, Wikideck depth, and any Monero withdrawal are separate milestones. Either chain may stall; no numerical threshold or recovery latency is approved here.
## Options under investigation
| Route | Possible guarantee | Named assumptions and release blockers |
| --- | --- | --- |
| Conditional NFT-first adaptor/shared-key exchange | Potential atomic control exchange without host custody under stated protocol assumptions | Requires new NFT lock/redeem/cancel rules, compatible reviewed cryptography and keys, both-chain reorg/timing analysis, recoverable secrets and funded fee handling. Payment-to-conditional-output versus seller-net-direct semantics unresolved. Participant observations alone are not global payment-proof validation. The maintained Eigenwallet implementation reviewed is BTC→XMR, not this direction. |
| Deterministic Monero evidence/light client | Validators could evaluate a precisely specified payment condition | Need canonical Monero chain selection/work/difficulty/upgrade verification, recipient/amount/output evidence, privacy disclosures, reorg handling and resource bounds. A header/txid or successful wallet-RPC response alone is insufficient. Not implemented or established feasible in scope. |
| Host/committee attestation | Deterministic verification of an authorized signature | Verifies what an attestor asserted, not Monero truth. Theft/censorship/key compromise and cross-host trust remain. Not trustless; explicit approval required. |
| Host custody plus chain-enforced lock | Coordinated payment/disbursement and refunds under an accountable host | Host can lose/steal/freeze XMR; lock releaser can censor or misrelease depending on rules. Requires explicit fallback approval, legal/policy/reserve controls and full journal/recovery design. Not selected. |
## Operator briefing: concrete flow choices (no choice made)
These are architecture shapes for discussion, not approved protocol specifications. “Confirmed” must eventually be a named Monero-chain inclusion/depth/unlock policy; none is selected here.
### A. Direct XMR payment, then seller delivery
1. Seller commits a card to a preparatory Wikideck lock, with a deadline and no ownership transfer.
2. Buyer sends the signed, exact XMR price to the seller's ordinary address.
3. Buyer (or a service with sufficient view/proof data) supplies payment evidence. Some trusted observer or a future Monero verifier decides whether the XMR transaction is in the accepted chain and meets the policy.
4. Seller signs the ordinary NFT transfer. Wikideck accepts it only if the applicable confirmation evidence has been attached/validated under the selected rules; otherwise the lock eventually expires.
**Guarantee:** the seller can receive XMR before delivering the card. A malicious seller can keep the payment, let the card lock expire, and never deliver. The NFT lock only prevents a competing card sale while it is live; it cannot reverse Monero. Seller signature or processor status does not fix this. This flow is not a trustless two-sided exchange. It may fit a deliberately seller-trusting product, but that would be a material policy change.
### B. Custodial/host payment escrow (explicit fallback only)
1. Seller locks the NFT. Buyer sends exact XMR to a host-controlled invoice/address or wallet.
2. Host observes and accounts for the incoming transaction; after its selected confirmation/unlock policy it directs NFT delivery and then sends seller/author/host allocations, or refunds if policy permits.
3. Wikideck completes the sale only under a host attestation or new host-controlled release rule.
**Guarantee:** operationally straightforward comparison point; host controls or can censor the funds and may fail, be compromised, refuse a valid action, or become insolvent. Buyers trust its accounting, keys, confirmation observations, recovery process and dispute decisions. A host signature proves only that the host authorized the assertion. Independent market hosts each need their own limits and responsibility policy. This is custody/attestation, not trustless settlement. No approval to build or use it exists.
### C. Conditional Monero funds + conditional NFT exchange (trustless target)
1. Seller locks the NFT on Wikideck; it is unavailable for conflicting transfers but remains seller-owned. Buyer and seller commit to one order, exact amount, destinations, fees and two-chain time windows.
2. Buyer funds a Monero output whose spend path is jointly/cryptographically conditional, not an ordinary immediate payment to the seller. The parties verify inclusion and selected finality/unlock condition.
3. A reviewed cross-chain adaptor/shared-secret protocol couples the Monero release path to the NFT completion witness. The NFT sale is included only after the payment condition is satisfied; the secret/redeem path lets the seller claim XMR. A separately secured cancel/refund path must ensure buyer funds return and the lock releases if the sale cannot finish.
4. Wikideck validators check the exact new lock/completion/cancel rules. Monero state is either independently verified by each validator, or a specifically named participant proof/attestation assumption is accepted. These are different security models.
**Potential guarantee:** no single party should be able to obtain both assets or take one asset while the other is permanently unrecoverable, within the explicitly modeled chain and liveness assumptions. **Not established:** the current chain has none of these transaction rules; no reference implementation was mapped for an NFT↔XMR trade; the maintained software reviewed covers BTC↔XMR, while the paper's XMR-first construction leaves Monero adaptor details as work in progress. Monero's lack of script timelocks means neither the NFT expiry nor a website timer can refund a Monero output by itself. The timeout/recovery construction is a cryptographic hard gate.
| Failure after conditional funding | Must be solved before this can be called trustless |
| --- | --- |
| Buyer funds, then refuses to complete or disappears | Buyer must have an enforceable recover path after a specified condition, without seller stealing the funds; no unilateral host decision. |
| Seller locks NFT, then refuses to cooperate | Buyer must not lose the conditional funds forever; NFT lock expiry alone is insufficient. |
| One chain stalls or reorgs after the other chain advances | Lock windows and secret revelation order must preserve the option to complete or recover, with a quantified depth/liveness assumption and adversarial race tests. |
| Completion witness/secret leaks in a mempool or losing Wikideck fork | Reorg must not permit a party to take XMR and then restore NFT ownership, or otherwise claim both sides. |
| Refunder/publisher loses local wallet or settlement journal | Required protocol material and recovery authorization must survive; Monero chain replay may not reconstruct wallet-only transaction metadata. |
| Author/host fees or payout network cost consume the conditional amount | Define exact output split, fee payer, quote bounds and whether fee legs are part of the atomic protocol. No automatic subsidy or royalty enforcement outside recognized sale rules. |
### Who verifies Monero? Separate consensus decision
| Model | Validator action | Trust / privacy / cost |
| --- | --- | --- |
| Monero light client in every Wikideck validator | Validate headers/work/forks and the relevant payment proof under deterministic, versioned rules. | Strongest independent-chain verification option, if correctly implemented; large cryptographic, maintenance, resource, upgrade and consensus-complexity burden. The public transaction/recipient proof may expose linkage/privacy. Must study whether the evidence is actually succinct and complete for Monero's confidential outputs. |
| Participant-generated conditional authorization | Parties verify Monero in their own wallets and provide a protocol witness; Wikideck validates signatures/witness but not Monero's canonical chain itself. | Reduces node requirements but shifts chain observation/finality and censorship/eclipse assumptions to named parties. A signature cannot prove that the signed observation was true. Must analyze how an honest party can safely refuse/force progress. |
| Market-host/committee/oracle assertion | Validators check a configured public key's attestation. | Explicit third-party trust, censorship/key-compromise risk, and operator governance. Not the trustless option; selecting it is an explicit fallback. |
No current Monero proof RPC response alone resolves this choice. `check_tx_proof` verifies a proof against the wallet/node's observed chain and reports confirmations/in-pool/received; it is not a proof that every independent Wikideck node can validate canonical Monero history. Its documentation also warns the received funds may not be spendable. A fixed threshold reduces but never eliminates reorg risk.
## Payment proof + delivery record + reputation: useful deterrence, not atomic settlement
The operator's proposed accountability route is technically plausible as a **market abuse-control layer**, with important limits:
1. **Buyer supplies evidence:** Monero's [wallet RPC `get_tx_proof`](https://docs.getmonero.org/rpc-library/wallet-rpc/#get_tx_proof) creates a proof for a transaction and destination address; [`check_tx_proof`](https://docs.getmonero.org/rpc-library/wallet-rpc/#check_tx_proof) can report `good`, `received`, `confirmations` and `in_pool`. A proof's optional message can bind the displayed order ID/domain, helping deter reuse of evidence for a different sale. The verifier still needs a Monero wallet/node view of the chain to determine confirmation state; a copied RPC response is not a trustless proof of canonical chain inclusion to all Wikideck validators. The RPC docs explicitly warn that a valid proof does **not** prove associated funds are spendable.
2. **Delivery can be independently checked:** Wikideck's signed NFT transfer and canonical block are public under its chain rules. A market host can check whether the exact card/sequence moved to the buyer and whether it reached its own configured depth. Proving that delivery did **not** occur by a deadline is harder than showing an inclusion: the observer must have a complete, current canonical view, handle reorgs/partitions and allow for pending or late-signed transfers. A single host's assertion about absence is not consensus evidence.
3. **A host can record non-delivery:** under a disclosed process, one host could record “verified XMR payment; no matching canonical NFT delivery by deadline; seller wallet address flagged.” This may warn that host's users, enable local order throttling/denial and deter repeat abuse. It does not reverse XMR, provide compensation, force another peer to enforce the flag, prove the buyer was the sender, or prevent the seller from changing wallet/host. Monero's proof shows receipt to an address/amount, not a real-world person or necessarily the sender's identity.
### Reputation and automatic-ban limits
- Start with **evidence records and opt-in/filterable indicators**, not an automatic global wallet ban or a single opaque emoji score. Show denominators and outcome states: completed orders, seller cancellations, expired buyer checkouts, independently verified non-delivery findings, disputed/reorged/under-review cases and their age. Do not count unresolved or pending cases as proven fraud.
- A marketplace host can ban an address **locally**. The address can create another wallet, and another peer can admit it. A network-wide blacklist would require a shared authority/registry or new consensus and governance rules, be easy to Sybil-evade absent identity, and create censorship/false-positive risks. Do not imply a local block is a chain ban.
- Threshold auto-bans can be gamed: attackers create many new wallets, trade cheaply to build reputation, coordinate false claims, or target a seller with buyer-side mispayments and outage/reorg disputes. A threshold does not establish intent. Any punitive case needs evidence rules, seller response/appeal, expiry/correction, privacy limits and a responsible decision-maker.
- Publicly linking XMR proofs, an XMR destination, Wikideck wallet, card ID, counterparties and order IDs can substantially weaken transaction privacy. Make evidence disclosure explicit and minimize retention/access. Do not require buyers to publish transaction keys or proofs by default without a privacy review.
- A good reputation reduces perceived counterparty risk; it is not escrow, a guarantee of delivery, a refund, or proof for consensus. If a seller keeps direct XMR and withholds the card, a ban is at best an ex-post deterrent. For positive buyer protection, research must still solve conditional exchange or obtain approval for an accountable custodial arrangement.
**Implementation recommendation:** proceed with an offline evidence and case-journal prototype for fixed-price abuse prevention; do not require buyers to publish transaction keys/proofs without explicit consent. A buyer can submit Monero proof, txid, order-specific recipient address and exact amount; a named verifier checks it against a synced chain view and later correlates it with the canonical Wikideck transfer. Record evidence and an appealable case; a host may apply a local restriction only under a published review policy. This is not automatic settlement, a global blacklist or buyer compensation. Keep M0's consensus proof and two-sided atomicity questions open.
## Earlier recommended product path (superseded by custodial investigation)
**Recommendation: use layered protection, with an opt-in evidence/reputation layer now and an arbitrated multisignature escrow as the first serious live-sale candidate. Do not treat proof/reputation as settlement, and do not make direct-to-seller XMR the default live purchase.**
This is a design recommendation, not authorization to accept money, use custody, or write consensus rules. Haveno documents per-trade 2-of-3 Monero multisig: each trader holds one key and an arbitrator holds the third; two keys are required to spend. Normal release is collaborative, while disputes require an arbitrator to decide and co-sign with one party. Security deposits are used to make some abuse costly. This is a more credible buyer-protection model than a website's “paid” flag or an XMR proof alone. It does **not** provide unilateral cryptographic timelocks, eliminate arbitrator trust, guarantee a refund, or supply Wikideck/NFT transaction semantics. Haveno is active, but its official project says the upstream does not directly support real trades; third-party networks are not endorsed. Treat it as protocol prior art, not a dependency or network recommendation. Adapt only after independent protocol, security, custody/legal and operational review.
### Stage 0 — proof-backed evidence and fair reputation (can be designed before settlement selection)
- For each order, bind a buyer-generated Monero payment proof to a fresh order nonce/message, expected recipient/subaddress, exact gross and expiry. Verify against a fully synchronized, trusted-by-the-verifier Monero wallet/node; show the verifier, check time, observed height/depth, transaction identity, amount and limitations. A positive proof establishes that the output was paid to the indicated address under the checked chain view; it does not establish buyer identity, spendability, absence of a deeper reorg, or that Wikideck consensus independently verified Monero.
- Correlate the proof to canonical Wikideck delivery using order ID, seller/buyer/card/sequence and transfer ID. Store an append-only, access-controlled case record with evidence digest and versions. Keep detailed proof material private by default; make the buyer consent to any disclosure to an arbitrator or portable verifier because the proof links XMR activity to a sale.
- Record states such as `payment evidence submitted`, `proof verified by [named verifier]`, `delivery observed`, `non-delivery alleged`, `under review`, and `adjudicated`. Never label an allegation or timeout “fraud” automatically. Preserve appeal, correction and expiry/audit history.
- Launch reputation as transparent **per-host, evidence-backed counters or filters**, not cross-host wallet blacklists, opaque emoji thresholds, or proof of safety. Report completion and adjudicated outcomes with denominators; distinguish abandoned buyer checkout, technical failure, chain reorg and proven deliberate non-delivery. Wallet addresses are pseudonymous and cheap to replace.
- A host may choose not to accept new orders from a wallet after a documented review, but that is a local service decision, not a ban from Wikideck consensus and not compensation. Add report-rate limits and seller response/appeal to reduce extortion, Sybil and false-claim abuse.
This layer improves detection, customer support and deterrence. If direct payment has already reached the seller, it still cannot recover the buyer's XMR when the seller refuses delivery. Reputation is a complement to the escrow mechanism, never a substitute.
### Stage 1 — fixed-price escrow candidate (blocking live-sale design)
1. Put the card in a reviewed consensus-enforced preparatory lock, with exact seller/card sequence/order ID, no completed ownership transfer, and safe cancel/release authorization. A node-local reservation alone is insufficient against conflicting sales at other hosts.
2. Buyer funds an order-specific 2-of-3 XMR multisig: buyer, seller and a selected independent arbitrator (or explicitly disclosed host-operated arbitrator). Routine success requires buyer+seller release signatures after verified canonical NFT delivery. In a dispute, the arbitrator can co-sign with the rightful party according to published evidence rules. No arbitrator can move funds alone; the arbitrator is nevertheless trusted to adjudicate, available to help, and may collude with one participant.
3. Define seller/buyer security deposits, if any, as a separate approved anti-griefing and liability policy. Haveno uses deposits as incentives; it does not make every cross-chain fact machine-verifiable. No deposits, amount, confiscation rule or operator reserve are approved here.
4. Make delivery and XMR fund release states explicit, with durable key/backup recovery, key loss and unresponsive-party procedures. Monero multisig has no automatically expiring refund merely because a Wikideck timer elapsed. Do not promise automatic refunds or instant settlement.
5. Fix the accounting model before enabling XMR: who contributes the hosting/author legs, who pays each Monero fee, how precise output amounts and fees are reviewed, and whether the 1% commission remains feasible for a conditional multisig protocol. Any arbitrator, operator or host fee/reserve is separately visible and authorized.
**Trust claim if approved:** “arbitrated, non-custodial multisig escrow” may be supportable only if the operator never holds enough keys to spend alone, key control is independently tested, and terms accurately identify arbitrator powers. Do not call it trustless atomic exchange. The arbitrator can censor/withhold cooperation or side with one party; disputes require human evidence review; funds can be locked during outages. Buyers and sellers must verify the locked amount and agreed release destinations. Do not reuse an unreviewed network or assume Haveno is production-certified for NFT sales.
### Release recommendation
- Keep current marketplace and XMR payment actions **mock only** until a reviewable order escrow + lock + recovery protocol is implemented and the operator approves an accountable arbitrator model, exposure limits/reserves, disputes, privacy disclosure and legal review.
- In parallel, implement an offline test harness for proof-bound order evidence and the non-punitive reputation case model; do not expose a global automatic-ban feature.
- Run separate multi-process/multi-host adversarial tests for false proof, wrong recipient/amount, duplicate/replayed proof, stale/out-of-date node, payment reorg, buyer false dispute, delayed NFT transfer/reorg, arbitrator unavailable/colluding, key loss, and partial payout. Require an external Monero multisig/security review before staging.
- If the operator's eventual priority is permissionless no-arbitrator exchange, keep pursuing the custom conditional XMR↔NFT protocol separately; no practical current implementation is established. Do not downgrade to direct payment while describing buyer protection.
**Historical research status:** this ADR records earlier trustless/arbitrated options. The operator subsequently selected pooled custodial XMR balances in [ADR 002](custodial-market-design.md). The public marketplace displays native-XMR prices under a mock-only v3 domain; actual XMR payments remain disabled. No payment processor is part of the selected path.
## Threat model and required failure evidence
Assets: NFT ownership/availability, buyer gross payment, seller proceeds, host/author allocations, refund liabilities, wallet/conditional secrets, order privacy and durable recovery evidence. Independent hosts, miners, relayers, processors and user wallets are separate trust boundaries. An author commission does not underwrite hosts.
| Adversary / failure | Required invariant and test before acceptance |
| --- | --- |
| Dishonest seller, duplicate listings across hosts, stale signatures | A canonical lock excludes conflicting spends under the selected rules. Exact card sequence/order/network/market binding; cancelled or expired authorizations cannot revive. Race tests at every boundary. |
| Dishonest buyer, late/partial/duplicate/overpayment | No NFT release for inadequate or misbound evidence; no reuse of one funded output across sales. Define legitimate incoming liabilities instead of discarding late funds. No refund plus later successful delivery. |
| Buyer aborts after seller locks; Sybil reservation spam | Explicit bounded liveness/cost exposure without assuming a wallet is a person. Quantify locking/griefing costs; no unapproved deposit requirement. |
| Dishonest host/relayer/processor or compromised authorization key | Forged observations do not bypass selected validity rules. Document which roles can steal, censor or lock funds/assets. Sender identity and authenticated transport are not economic truth. |
| Miner censorship, eclipse, partition, stalled chain | Deadline safety margins must cover publication/confirmation and observation assumptions. Secret publication in a losing transaction or mempool can be irreversible even when NFT inclusion fails. Test cancel/redeem races. |
| Reorg of either chain, including after payout/release | Explicit finality and residual-loss model. Never silently repay after an ambiguous send or infer that a local restore reverses XMR. Reorg tests before/after each critical action. |
| Fee spikes, dust and separately paid author leg | No rounding up sub-atomic commissions; no unapproved subsidy/balance accumulation. Pre-payment viability plus approved fee cap and overrun behavior; partial-success accounting. |
| Key loss, crash or malicious recovery operator | Required refund/redeem data survives authorized restore. Unknown external effects stay quarantined. No manual journal reset creates duplicate sends; public-chain replay alone cannot recover all wallet metadata. |
| Disputed or replayed terms | Versioned canonical terms bind price/currency, all destinations, commissions, cost caps, expiry, parties, card sequence and selected protocol. Negative signature/replay tests across market/network/order versions. |
### Fee enforcement limit
Fixed host and 1% author allocations can be signed and validated for the selected sale protocol, but ordinary free transfers already exist. Parties can exchange XMR off-platform and submit a nominal gift unless new protocol policy prevents it; modifiable peers can also fork policy. Do not claim universal royalty enforcement or infer the value of off-chain consideration. The commission rule for recognized sales and treatment of alternate transfers must be reviewed explicitly.
## Next decision gates
1. **Payment flow:** a direct ordinary XMR payment to the seller followed by seller-authorized NFT delivery cannot protect a buyer from an uncooperative seller: a timeout can unlock the NFT but cannot reverse confirmed payment. Host escrow is simpler but custodial and not approved. Conditional/shared-key funds could target two-sided atomic control but are not seller-spendable proceeds until the protocol releases them. Current Eigenwallet documentation and implementation cover BTC→XMR; the paper's reverse direction leaves Monero adaptor details incomplete. No current reviewed reference was found for conditional XMR↔NFT exchange. Prepare the concrete conditional flow and its failure matrix before a choice is made.
2. **Consensus evidence:** every Wikideck validator can either run a versioned Monero verifier/light client (larger cryptographic, privacy, maintenance and consensus burden), or validate participant-generated signatures/witnesses without independently proving Monero canonicality (explicit participant-observation trust). A host/committee signature is an oracle/attestation trust assumption, not trustless verification. `check_tx_proof` RPC output is not itself a consensus-verifiable Monero canonical-chain proof. The operator asked to see concrete options before choosing either the payment flow or verifier model; both remain open.
3. Map each required NFT-first transition and secret exposure to current maintained protocol/reference behavior; identify compatible crypto libraries, audit artifacts and licenses. No hand-written cryptographic substitute.
4. Build only a disposable, offline protocol/state-model spike after selecting its exact assumptions. Include abort, timeout, mempool leak, double-lock, reorg and recovery counterexamples; a happy-path demo cannot close M0.
5. Obtain specialist cryptographic/consensus review and operator ADR approval before any versioned consensus implementation. If no practical reviewed route meets requirements, request custodial/attested fallback approval with its explicit guarantees and liabilities.
No lock code, consensus version change, production migration or payment activation is authorized by this research checkpoint.
+97
View File
@@ -0,0 +1,97 @@
# Experimental testnet consensus
Implementation reference for the current tree, **not a public-mainnet specification or security certification**. See [README](../README.md) for setup/wallet commands and [native-engine details](randomx-native.md) for RandomX tooling. Consensus code: [shared serialization](../shared/testnet.ts), [validation](../server/testnet/consensus.ts), [DAG/persistence](../server/testnet/ledger.ts), [peer transport](../server/testnet/peers.ts), [HTTP](../server/testnet/http.ts), [miner](../server/testnet/miner.ts).
This document is for operators and developers who need the exact current protocol
rules. It is not a quick-start guide: consensus serialization and constants are
implementation-specific, and changing them can create a different chain.
## Contents
- [Network, genesis and article selection](#network-genesis-and-article-selection)
- [Header, work and three distinct identities](#header-work-and-three-distinct-identities)
- [Targets, timestamps and epoch boundaries](#targets-timestamps-and-epoch-boundaries)
- [Native NFT transition and commitments](#native-nft-transition-and-commitments)
- [Fork choice, reorgs and storage](#fork-choice-reorgs-and-storage)
- [Fixed peers and HTTP surface](#fixed-peers-and-http-surface)
## Network, genesis and article selection
- Network: `wikideck-randomx-testnet-6`, block version **6**. Genesis has no blocks, NFTs or work; first mined height is 1. No privileged issuance/admin mint key. Version 6 adds atomic NFT trade bundles and has a new chain identity; prior-version ledger files and wallet backups are not compatible.
- `GENESIS_TIME = 1792411200` (Unix seconds). Let `S(x)` mean lowercase hex SHA-256 of UTF-8 `x`, and `J(x)` mean compact JavaScript `JSON.stringify(x)`; field order below is consensus-critical.
- `MAX_TARGET` is `0fff` followed by 60 `f` digits. `CHAIN_ID = S(J([NETWORK, CONSENSUS, GENESIS_TIME, MAX_TARGET]))`, using the constants object's insertion order in the implementation. `CONSENSUS` identifies the random Wikipedia source and mint-statistics snapshot format.
- `GENESIS_HASH = S(NETWORK + ':genesis:' + CHAIN_ID)`; `INITIAL_KEY = S(NETWORK + ':initial-key:' + CHAIN_ID)`. Nodes independently derive the same manifest and reject other network/chain IDs.
- [Starter samples](../shared/catalog.ts) remain only as interface previews; they are not eligible for minting. Each mining-template request asks the fixed English Wikipedia API endpoint for up to ten random namespace-0 pages, with a plain-text extract and thumbnail. It makes at most three requests and returns a page with a usable extract and an HTTPS image on `upload.wikimedia.org` or `thumb.wikimedia.org`. A page may be minted repeatedly. There is no static fallback: if no pictured article or its statistics are available, template creation fails and the miner can retry.
- The selected `Article` snapshot and `statsSnapshot` are copied into the block header and covered by the miner's block signature and block ID. `articleTuple = [pageId, revisionId, title, extract, image-or-empty-string, url, source, category]`. The at-mint statistics contain capture time, metadata revision, page bytes, language-link count/truncation, six-month pageviews when available, and the derived Attention/Depth/Reach scores. Missing pageviews are represented as unavailable, never invented zeroes. The node forces a fresh enrichment request for this snapshot; it is captured when the mining template is prepared, since changing it after work begins would invalidate the proof. Current UI statistics are fetched separately and may reflect later edits.
- A repeated page still creates a distinct NFT: token identity includes the parent block, height, and owner. Validators check article/image and stats schemas, the page URL, Wikimedia HTTPS host, pageview summaries, derived scores, and block commitments, but do not constrain repeated page IDs.
- Validators do **not** contact Wikipedia during consensus validation. They verify the committed snapshot's structure and internal metric summaries, not the API provenance or ongoing existence of the page/image. A miner can construct a syntactically valid snapshot itself; this experimental design avoids making block acceptance dependent on an external service being online. The node's template endpoint fetches the article and fresh statistics for ordinary mining clients.
## Header, work and three distinct identities
`headerMessage(h)` is `J([NETWORK, version, chainId, height, previousHash, createdAt, articleTuple(article), statsSnapshotTuple, owner, publicKey, target, key, transactionsRoot, stateRoot])`. `statsSnapshotTuple` commits capture time, revision, size/language counts, ordered monthly views and summary values, plus the three derived scores. It excludes the nonce. Hash its UTF-8 bytes with SHA-256 to obtain **32 raw bytes**, then append the nonce encoded as **8 unsigned little-endian bytes**. The resulting **40-byte mining blob** has nonce offset **32** (not a 32-bit nonce). Node's numeric interface restricts nonces to nonnegative safe integers; the native line protocol supports uint64.
| Identifier | Exact construction / meaning |
| --- | --- |
| Block ID (`hash`) | SHA-256 of the raw mining blob, lowercase hex. Does not include ECDSA signature bytes or the separate `powHash` field. |
| RandomX proof (`powHash`) | Native RandomX of the mining blob using the 32-byte epoch key. Determines work eligibility and game rarity/stats, not the NFT ID. |
| NFT ID (`tokenId`) | `S(J([NETWORK, CHAIN_ID, 'mint', previousHash, height, owner, article.pageId, statsSnapshotTuple]))`. The capture time and page statistics help distinguish repeated mints of the same page. Independent of nonce/proof/signature, avoiding a circular dependency through state commitment. |
Interpret RandomX output and target as **unsigned big-endian** integers; accept `powHash <= target` including equality. This differs from Monero's target convention. The initial/easiest threshold accepts exactly **1/16** of uniformly distributed hashes (expected 16 attempts). No fake or browser PoW is accepted by the production node.
The miner signs `J([NETWORK, 'block-claim', blockID])` using ECDSA P-256/SHA-256, IEEE-P1363 encoding. Address is SHA-256 of canonical SPKI DER public-key bytes. Nodes check public-key/address consistency, signature, body schema, exact parent/height, article, target, key, roots, mint ID, block ID and recomputed native proof. Signatures remain required even though their bytes are excluded from block identity.
## Targets, timestamps and epoch boundaries
- Desired spacing: **15 minutes (900 seconds)**; retarget interval: **10 ancestors**. Heights 1–10 use the initial target; height 11 first retargets. Otherwise inherit the parent target.
- At each retarget, compare the last and first timestamps of the previous 10 blocks: expected span is **9 × 900 = 8,100 seconds**. Clamp elapsed to `floor(8,100/4) = 2,025` through `8,100*4 = 32,400` seconds, multiply the parent target by elapsed/8,100 with integer division, then clamp target to `[1, MAX_TARGET]`. Selection uses work, not height or wall-clock estimates.
- A timestamp must exceed the median of up to **11** ancestors (sorted upper-middle for even counts; genesis time if none) and must be at most **120 seconds** ahead of validation time. Keep operator clocks synchronized.
- Epoch key source height is `floor(max(0, height - 64) / 2048) * 2048`. Zero means `INITIAL_KEY`; otherwise use the ancestor block hash at that source height. Thus heights **1–2111** use the initial key, **2112–4159** use height 2048's hash, and height **4160** switches to height 4096's hash. Forks derive keys from their own ancestors.
## Native NFT transition and commitments
Each block executes its ordered transactions against its parent's NFT state, **then mints one NFT** to the block owner. State entries contain owner, sequence (initially 0), minted height and page ID. Transactions cannot spend this block's new issuance.
An individual transfer carries network, chain ID, `cardHash` (**NFT token ID**, despite the legacy field name), from, to, sequence, public key and signature. Its signed message is `J([NETWORK, 'nft-transfer', chainId, cardHash, from, to, sequence])`. **Transfer ID is SHA-256 of that message, excluding ECDSA signature bytes** to avoid signature-malleability identity changes. Require the current owner, matching public key, a different recipient, exact current sequence, valid signature, and inclusion height minus mint height **>= 2**; increment sequence on application. Replays and duplicate IDs within a block fail.
A trade is one `type: 'trade'` transaction with proposer and counterparty addresses, ordered offered/requested NFT legs (token ID plus current sequence), and one P-256 signature from each party over `J([NETWORK, 'nft-trade', chainId, proposer, counterparty, offeredLegs, requestedLegs])`, where each leg is `[cardHash, sequence]`. Both public keys must derive the respective party addresses. Each side must contain 1–8 distinct NFTs; duplicates across either side are rejected. At inclusion, validation checks **all** selected NFTs against the same pre-trade state, owners, sequences, maturity, and both signatures before writing any changes. If any leg is invalid, the entire trade is rejected; if valid, all offered ownerships move to the counterparty and all requested ownerships to the proposer, with each sequence incremented once. A trade counts as one mempool and block transaction and is committed by one transaction ID. It cannot be partially included or partially replayed. Offers are not reservations; stale offers fail validation.
`transactionsRoot` commits the **ordered transaction IDs** (individual transfer IDs or atomic trade IDs). `stateRoot` commits the complete **post-transaction, post-mint** state: sort token IDs lexicographically; each input leaf is `S(J([id, owner, sequence, mintedHeight, pageId]))`. `merkleRoot` returns `S(NETWORK + ':empty')` for no leaves. For nonempty input, hash each raw 32-byte input leaf with byte prefix `0x00`; hash pairs of raw children with byte prefix `0x01`, duplicating an odd final child at each layer. Finally return `S(J([NETWORK, 'merkle', originalLeafCount, treeRootHex]))`. Count commitment prevents odd-padding ambiguity; leaf/node prefixes separate domains. These commitment semantics, the RandomX version, and big-endian proof convention are pinned in the genesis consensus manifest.
The mempool holds at most **256** ordered transactions (transfers or trade bundles), validating sequentially against the next inclusion height; a template takes the first **64 transaction units**. A 202 response changes **no canonical ownership**. Mint at height `h` is eligible for inclusion at `h+2` (pool admission can happen at tip `h+1`). Confirmations are `tipHeight - mintedHeight + 1`, not a finality guarantee.
## Fork choice, reorgs and storage
Per-block work is `floor(2^256 / (target + 1))`; accumulate from genesis work zero. Keep all valid known-parent branches. Adopt a tip only for **strictly greater cumulative work**; equal-work ties retain the current tip, including across replay of persisted insertion order. A shorter, harder branch can win.
On reorg, reconstruct ownership from the winning branch. Orphan mints disappear from canonical ownership; disconnected transactions are reconsidered before existing pending entries, in order, only if still valid and not already committed. A trade is requeued as one unit only when every leg remains valid; otherwise the complete bundle is discarded. No checkpoints/finality or rollback protection beyond observed work; an isolated node has no peer-backed security.
The [ledger](../server/testnet/ledger.ts) is stored in `data/testnet/ledger.sqlite`, under the `WIKIDECK_DATA_DIR` testnet subdirectory (Docker uses `/app/data`). SQLite WAL mode with full synchronous commits stores each accepted block once in an indexed table and replaces the bounded mempool in the same transaction; writes no longer serialize/rewrite the entire DAG, and there is no 128 MiB/10,000-block ledger cap. A matching v6 legacy `ledger.json` is schema-checked and imported transactionally once; the source JSON is left untouched as a recovery copy. Earlier-protocol ledgers are not compatible and fail closed. Back up the complete stopped-node data directory, including SQLite database/WAL files. Do not copy only `ledger.sqlite` while the node is running.
The in-memory DAG keeps block records and cumulative work, while each block's NFT state is a persistent overlay containing only that block's changes rather than a full state copy. State-root calculation still visits the complete NFT state, and startup still revalidates every stored proof; CPU and startup work therefore grow with chain size. This is materially more storage-efficient, but not yet an archival-scale chain database or a public DoS defense. Blocks remain individually limited to **64 KiB and 64 transaction units**; the mempool remains capped at **256 transaction units**. Mutations are serialized (queue cap 256). HTTP startup exclusively creates a data-directory lock named `testnet-node.lock`. It is removed on normal shutdown, **not automatically after a crash**: stop and verify all owning processes have exited before manual stale-lock removal. Never bypass a live lock or share a writer directory. Back up node data and wallet exports separately; deleting a volume cannot be recovered from a wallet alone.
## Fixed peers and HTTP surface
Peers are **explicit operator-configured URL origins**, up to 16, not discovered from network input. Default: HTTPS/public addresses only, checked and pinned during DNS lookup; no credentials, paths, queries, fragments or redirects. `WIKIDECK_ALLOW_PRIVATE_PEERS=1` permits private/HTTP destinations only by explicit operator choice (used for local Docker tests). No bootnodes, DHT, automatic public deployment or claimed Sybil resistance.
Sync polls every **5 seconds**, checks network/chain ID, imports at most **32** topologically ordered DAG blocks per peer/pass, and validates remote pool entries locally. Accepted blocks and transactions (including complete trade bundles) are gossiped to configured peers; busy-peer gossip is dropped, with polling for catch-up. Responses are capped at 2 MiB with a 10-second deadline. Peer count is current outbound connectivity metadata, not consensus membership or a security score.
| Route | Purpose |
| --- | --- |
| `GET /healthz` | Process health/network, not sync or consensus security. |
| `GET /api/state`, `/api/chain` | Canonical blocks, mined/pending transactions, tip/work and peer count. |
| `GET /api/summary`, `/api/canonical?tip=<hash>&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/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. |
| `POST /api/mining/template` | Rate-limited public miner identity; fetches a random article with a picture plus fresh at-mint statistics. Returns 503 when either cannot be fetched. |
| `POST /api/blocks` | Independently validates a signed block, proof, article snapshot, required Wikimedia image URL, and statistics snapshot consistency. Repeated page IDs are accepted. |
| `POST /api/transfer` | Signed single-card transfer admission; 202 means pending. |
| `POST /api/trades` | Validates and admits one signed, atomic multi-card trade bundle; 202 means the whole bundle is pending. |
| `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.
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.
+45
View File
@@ -0,0 +1,45 @@
# Encrypted local wallet — stage 2
## What changes
The browser no longer creates or returns a plaintext wallet at startup. Open **Wallet backup & recovery** and choose:
- **Create encrypted wallet:** explicitly generate a new P-256 identity and choose a local password of 12–1024 characters.
- **Encrypt existing wallet:** migrate the previous same-origin plaintext record, preserving its exact public key/address. This is required before the old key can sign in the new UI.
- **Unlock wallet:** decrypt an existing local vault in this tab with its local password.
- **Recover and save encrypted wallet:** decrypt a portable backup with its backup password and re-encrypt it under a new local password. Existing storage, even when locked, requires explicit replacement confirmation. Back up that identity first; replacement does not transfer its assets.
The local password and backup password may differ. They have no reset endpoint, are not sent to a server, and are not stored in local/session storage. Forgotten local passwords require a working portable backup and its password. Without either a usable local vault/password or backup/password, access is lost.
## Storage and migration guarantees
Vault records use the existing reviewed backup encryption primitive: PBKDF2-SHA256 (600,000 iterations), random 16-byte salt, AES-256-GCM with random 12-byte IV and authenticated format/network/public-key headers. The local wrapper stores a format/version and random revision plus the encrypted backup. It stores **no plaintext private CryptoKey or password** for newly created/migrated vaults. Public-key metadata remains visible; encryption does not hide wallet identity or on-chain holdings.
The existing testnet/mainnet IndexedDB names remain separate and unchanged to find legacy records safely. Migration reads the legacy key only after the user opts in, encrypts and decrypts to verify identity, then replaces the record in a committed IndexedDB readwrite transaction. No automatic deletion, new identity or network migration occurs. Storage failure leaves the original record intact. The old CryptoKey is never removed before encrypted replacement commits.
Cross-tab readwrite compare-and-swap checks the expected stored revision/record in the same transaction; optional Web Locks additionally serialize storage. A competing create/migration/restore must fail rather than overwrite a newly stored identity. This is application consistency, not protection against a malicious script deliberately editing IndexedDB. Old application versions/tabs must be closed before migration; cached old bundles are not guaranteed to understand a vault record.
Replacing a record does **not** securely erase previous browser storage pages, OS backups, disk snapshots, old downloaded files or keys previously copied by other scripts. If the old identity may have been exposed, encryption is not revocation: a separately planned transfer to a fresh identity is required. This work never transfers assets or silently rotates keys.
## 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.
- 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.
- Four hours since unlocking locks it even if active.
- Background/hidden-tab, pagehide and reload end the session. Hiding a tab during encryption/export/unlock may cancel its result; encrypted storage may already have committed, in which case it remains locked and can be unlocked again.
- BroadcastChannel messages contain only a lock signal, never keys, passwords or decrypted backups. Manual/background locks and replacement invalidate other tabs where supported. If unavailable, storage revision is checked before returning signatures/backups; do not assume immediate cross-tab notification on every browser.
- Lock epochs are checked across asynchronous crypto/storage steps. A signature finishing after a detected lock/replacement is discarded. Signing/export also recheck vault revision. An already returned/submitted signature cannot be revoked by locking; signed transactions/offers can remain valid on-chain.
Timers can be delayed by the browser or event-loop load. Expiry is checked again on signer/export/get-wallet access so delayed timer callbacks do not authorize an expired session. JS does not provide guaranteed zeroization of strings or CryptoKey/browser memory; clearing references is not a memory-forensics guarantee.
Form password values clear after operations, lock notifications, hiding the page and unmounting. Browser/password-manager behavior is outside this application's memory guarantees. Backups remain the existing portable format for native miner compatibility. A downloaded backup password does not implicitly change the local password.
## Validation and limitations
Node/WebCrypto storage-boundary tests cover encrypted record-only persistence, explicit no-wallet startup, signature verification, locked/stale handles, idle/absolute expiry, lock during unlock/export, wrong passwords, replacement consent, storage retry/quota failure, conflicting storage revisions, same-identity legacy migration and preservation after failed migration. Existing cryptographic tamper, wrong-network and native-miner backup tests remain.
Real browser smoke checks on a disposable loopback origin passed create → lock → unlock and reload → locked. IndexedDB inspection confirmed the vault wrapper/ciphertext and absence of a top-level private key/password without retrieving private material. Tests use ephemeral test credentials only. Final Node 24 TypeScript/Vite build passed; the complete suite passed **86 tests, 0 failed, 0 skipped**, including real RandomX HTTP integration and expanded wallet storage/lifecycle assertions.
**Production wallet review remains open.** At-rest encryption reduces profile-theft exposure when locked but cannot protect an unlocked page or password entry from XSS, a malicious release, privileged extensions or a compromised browser/OS. The in-memory key intentionally remains extractable to support existing backup export. This is not hardware-isolated signing, a browser-extension wallet, or an independent audit. Multi-browser/background/cross-tab races and destructive crash/restore drills need broader coverage; no independent assurance or formal crypto verification is claimed. Do not use valuable assets merely because local encryption exists.
+57
View File
@@ -0,0 +1,57 @@
# 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.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" data-theme="mocha">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1e1e2e" />
<meta name="description" content="Mine Wikipedia NFT cards with RandomX. Build collections, trade cards, and explore peer-hosted cryptocurrency markets. TCGs planned; current checkout is simulated." />
<title>Wikideck — Mine knowledge.</title>
<link rel="icon" type="image/svg+xml" href="/original-logo.svg" />
<script src="/theme-init.js"></script>
</head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>
+48
View File
@@ -0,0 +1,48 @@
cmake_minimum_required(VERSION 3.24)
project(wikideck_randomx LANGUAGES C CXX)
include(FetchContent)
# Immutable v1.2.1 pin; never follow a branch or a moving tag.
FetchContent_Declare(randomx
GIT_REPOSITORY https://github.com/tevador/RandomX.git
GIT_TAG 102f8acf90a7649ada410de5499a7ec62e49e1da
GIT_SHALLOW FALSE
GIT_SUBMODULES ""
EXCLUDE_FROM_ALL
)
# Upstream still declares CMake 3.5 compatibility; support CMake 4 as well.
set(CMAKE_POLICY_VERSION_MINIMUM 3.5)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "Build static dependencies" FORCE)
FetchContent_MakeAvailable(randomx)
# v1.2.1 has no upstream BUILD_TESTING switch. Exclude its tools explicitly.
set_property(DIRECTORY "${randomx_SOURCE_DIR}" PROPERTY EXCLUDE_FROM_ALL TRUE)
foreach(tool randomx-tests randomx-codegen randomx-benchmark)
set_target_properties(${tool} PROPERTIES EXCLUDE_FROM_ALL TRUE)
endforeach()
# Enforce W^X for the upstream cache JIT as well as our VM JIT.
target_compile_definitions(randomx PRIVATE RANDOMX_FORCE_SECURE)
add_executable(wikideck-randomx randomx-engine.cpp)
target_compile_features(wikideck-randomx PRIVATE cxx_std_17)
set_target_properties(wikideck-randomx PROPERTIES CXX_EXTENSIONS OFF
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}")
foreach(config DEBUG RELEASE RELWITHDEBINFO MINSIZEREL)
set_target_properties(wikideck-randomx PROPERTIES
RUNTIME_OUTPUT_DIRECTORY_${config} "${CMAKE_BINARY_DIR}")
endforeach()
target_include_directories(wikideck-randomx PRIVATE "${randomx_SOURCE_DIR}/src")
target_link_libraries(wikideck-randomx PRIVATE randomx)
if(MSVC)
target_compile_options(wikideck-randomx PRIVATE /W4 /GS)
else()
target_compile_options(wikideck-randomx PRIVATE
-Wall -Wextra -Wpedantic -Wconversion -Wformat=2 -Wformat-security
-fstack-protector-strong)
endif()
option(WIKIDECK_RANDOMX_TESTS "Enable opt-in native known-vector test (256 MiB)" OFF)
if(WIKIDECK_RANDOMX_TESTS)
enable_testing()
add_test(NAME randomx-upstream-vector COMMAND wikideck-randomx --self-test)
set_tests_properties(randomx-upstream-vector PROPERTIES TIMEOUT 120)
endif()
+214
View File
@@ -0,0 +1,214 @@
#include <randomx.h>
#include <algorithm>
#include <array>
#include <charconv>
#include <cstdio>
#include <cstdint>
#include <iostream>
#include <limits>
#include <memory>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
#ifdef _WIN32
#include <fcntl.h>
#include <io.h>
#endif
namespace {
constexpr std::size_t MaxBlob = 8192;
constexpr std::size_t MaxLine = 16600;
using Hash = std::array<std::uint8_t, RANDOMX_HASH_SIZE>;
[[noreturn]] void invalid() { throw std::runtime_error("invalid command"); }
int nibble(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
invalid();
}
std::vector<std::uint8_t> unhex(std::string_view text, std::size_t min, std::size_t max) {
if (text.size() % 2 != 0 || text.size() / 2 < min || text.size() / 2 > max) invalid();
std::vector<std::uint8_t> bytes(text.size() / 2);
for (std::size_t i = 0; i < bytes.size(); ++i)
bytes[i] = static_cast<std::uint8_t>((nibble(text[2 * i]) << 4) | nibble(text[2 * i + 1]));
return bytes;
}
std::string hex(const Hash& hash) {
constexpr char digits[] = "0123456789abcdef";
std::string out(hash.size() * 2, '0');
for (std::size_t i = 0; i < hash.size(); ++i) {
out[2 * i] = digits[hash[i] >> 4];
out[2 * i + 1] = digits[hash[i] & 15];
}
return out;
}
std::uint64_t decimal(std::string_view text) {
if (text.empty() || text.size() > 20) invalid();
for (char c : text) if (c < '0' || c > '9') invalid();
std::uint64_t value = 0;
const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
if (result.ec != std::errc{} || result.ptr != text.data() + text.size()) invalid();
return value;
}
// Read incrementally into bounded storage; getline(string) alone allows unbounded allocation.
bool readLine(std::string& line) {
line.clear();
char c;
while (std::cin.get(c)) {
if (c == '\n') return true;
if (line.size() >= MaxLine || static_cast<unsigned char>(c) > 127 || c == '\0') invalid();
line.push_back(c);
}
if (!std::cin.eof()) throw std::runtime_error("stdin failure");
if (!line.empty()) throw std::runtime_error("truncated command");
return false;
}
std::vector<std::string_view> tokens(std::string& line) {
if (!line.empty() && line.back() == '\r') line.pop_back();
std::vector<std::string_view> parts;
std::string_view remaining(line);
while (true) {
const auto space = remaining.find(' ');
const auto part = remaining.substr(0, space);
if (part.empty() || parts.size() == 6) invalid();
parts.push_back(part);
if (space == std::string_view::npos) break;
remaining.remove_prefix(space + 1);
}
return parts;
}
class Engine {
using Cache = std::unique_ptr<randomx_cache, decltype(&randomx_release_cache)>;
using Dataset = std::unique_ptr<randomx_dataset, decltype(&randomx_release_dataset)>;
using Vm = std::unique_ptr<randomx_vm, decltype(&randomx_destroy_vm)>;
bool fast_;
randomx_flags flags_ = randomx_get_flags() | RANDOMX_FLAG_SECURE;
// Destruction order: VM, dataset, cache. No borrowed pointer outlives its allocation.
Cache cache_{nullptr, randomx_release_cache};
Dataset dataset_{nullptr, randomx_release_dataset};
Vm vm_{nullptr, randomx_destroy_vm};
std::vector<std::uint8_t> key_;
public:
explicit Engine(bool fast) : fast_(fast) {}
void key(const std::vector<std::uint8_t>& key) {
if (vm_ && key == key_) return;
if (!cache_) {
cache_.reset(randomx_alloc_cache(flags_));
if (!cache_) throw std::runtime_error("RandomX cache allocation failed");
}
// Destroy the old VM before changing memory it references; reuse allocated cache/dataset.
vm_.reset();
randomx_init_cache(cache_.get(), key.data(), key.size());
if (fast_) {
if (!dataset_) dataset_.reset(randomx_alloc_dataset(RANDOMX_FLAG_DEFAULT));
if (!dataset_) throw std::runtime_error("RandomX dataset allocation failed");
randomx_init_dataset(dataset_.get(), cache_.get(), 0, randomx_dataset_item_count());
}
const auto vmFlags = fast_ ? flags_ | RANDOMX_FLAG_FULL_MEM : flags_;
vm_.reset(randomx_create_vm(vmFlags, fast_ ? nullptr : cache_.get(), dataset_.get()));
if (!vm_) throw std::runtime_error("RandomX VM allocation failed");
key_ = key;
}
Hash hash(const std::vector<std::uint8_t>& blob) {
Hash result{};
randomx_calculate_hash(vm_.get(), blob.data(), blob.size(), result.data());
return result;
}
};
void command(Engine& engine, const std::vector<std::string_view>& parts) {
if (parts.size() != 3 && parts.size() != 6) invalid();
const bool mining = parts[0] == "mine";
if ((!mining && parts[0] != "hash") || parts.size() != (mining ? 6U : 3U)) invalid();
const auto key = unhex(parts[1], 32, 32);
auto blob = unhex(parts[2], 1, MaxBlob);
// All validation precedes expensive cache/dataset initialization.
if (!mining) {
engine.key(key);
std::cout << hex(engine.hash(blob)) << std::endl;
return;
}
const auto target = unhex(parts[3], 32, 32);
const auto offsetValue = decimal(parts[4]);
const auto attempts = decimal(parts[5]);
if (blob.size() < 8 || offsetValue > blob.size() - 8 || attempts < 1 || attempts > 10000) invalid();
const auto offset = static_cast<std::size_t>(offsetValue);
std::uint64_t nonce = 0;
for (unsigned i = 0; i < 8; ++i) nonce |= std::uint64_t{blob[offset + i]} << (8 * i);
engine.key(key);
for (std::uint64_t attempt = 0; attempt < attempts; ++attempt) {
for (unsigned i = 0; i < 8; ++i)
blob[offset + i] = static_cast<std::uint8_t>(nonce >> (8 * i));
const auto hash = engine.hash(blob);
// BIG ENDIAN integer comparison (intentionally NOT Monero's endian convention).
if (!std::lexicographical_compare(target.begin(), target.end(), hash.begin(), hash.end())) {
std::cout << "found " << hex(hash) << ' ' << nonce << std::endl;
return;
}
if (nonce == std::numeric_limits<std::uint64_t>::max()) break; // Never wrap/repeat work.
++nonce;
}
std::cout << "exhausted" << std::endl;
}
void selfTest(Engine& engine) {
// Exact upstream v1.2.1 Hash test 1a/2a, including the original 12-byte key.
// This argv-only test does not weaken the protocol's fixed 32-byte key validation.
const std::string key = "test key 000";
const std::string blob = "This is a test";
engine.key(std::vector<std::uint8_t>(key.begin(), key.end()));
if (hex(engine.hash(std::vector<std::uint8_t>(blob.begin(), blob.end()))) !=
"639183aae1bf4c9a35884cb46b09cad9175f04efd7684e7262a0ac1c2f0b4e3f")
throw std::runtime_error("upstream vector mismatch");
std::cerr << "RandomX upstream vector passed\n";
}
} // namespace
int main(int argc, char** argv) {
try {
#ifdef _WIN32
// CRT text-mode Ctrl-Z must not silently truncate an untrusted command stream.
if (_setmode(_fileno(stdin), _O_BINARY) == -1 ||
_setmode(_fileno(stdout), _O_BINARY) == -1)
throw std::runtime_error("stdio binary mode failed");
#endif
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
bool fast = false;
bool test = false;
for (int i = 1; i < argc; ++i) {
const std::string_view arg(argv[i]);
if (arg == "--fast" && !fast) fast = true;
else if (arg == "--self-test" && !test) test = true;
else throw std::runtime_error("usage: wikideck-randomx [--fast] [--self-test]");
}
Engine engine(fast);
if (test) { selfTest(engine); return 0; }
std::string line;
while (readLine(line)) {
command(engine, tokens(line));
if (!std::cout) throw std::runtime_error("stdout failure");
}
return 0;
} catch (const std::exception& error) {
std::cerr << "randomx: " << error.what() << '\n';
return 1;
} catch (...) {
std::cerr << "randomx: native failure\n";
return 1;
}
}
+3238
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
{
"name": "wikideck",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently -k -n api,web \"tsx watch server/index.ts\" \"vite --host 127.0.0.1\"",
"build": "tsc --noEmit && vite build",
"start": "tsx server/index.ts",
"launch:preflight": "node --experimental-strip-types server/index.ts --launch-preflight",
"test:rpc-load": "node --experimental-strip-types scripts/rpc-load.ts",
"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",
"native:configure": "cmake -S native -B native/build -DCMAKE_BUILD_TYPE=Release -DWIKIDECK_RANDOMX_TESTS=ON",
"native:build": "cmake --build native/build --config Release --parallel 2",
"test:native": "ctest --test-dir native/build -C Release --output-on-failure",
"test": "tsx --test tests/*.test.ts"
},
"dependencies": {
"express": "^5.1.0",
"express-rate-limit": "^8.1.0",
"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"
},
"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",
"concurrently": "^9.2.0",
"tsx": "^4.20.0",
"typescript": "~5.9.2",
"vite": "^7.1.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.5 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#d4f77d"/><path d="m11 19 10 28h5l6-17 6 17h5l10-28h-7l-6 19-6-19h-5l-6 19-6-19z" fill="#121510"/></svg>

After

Width:  |  Height:  |  Size: 209 B

+12
View File
@@ -0,0 +1,12 @@
// Run before the app paints. Keep theme names/base colors aligned with src/theme.ts.
(() => {
const bases = { latte: '#eff1f5', frappe: '#303446', macchiato: '#24273a', mocha: '#1e1e2e' };
let theme = 'mocha';
try {
const saved = localStorage.getItem('wikideck-theme');
if (Object.hasOwn(bases, saved)) theme = saved;
} catch { /* Storage can be unavailable in privacy-restricted browsers. */ }
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme === 'latte' ? 'light' : 'dark';
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', bases[theme]);
})();
-5
View File
@@ -1,5 +0,0 @@
cryptography
dotenv
flask
mariadb
wikipedia
+28
View File
@@ -0,0 +1,28 @@
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"]
+59
View File
@@ -0,0 +1,59 @@
import { DatabaseSync } from 'node:sqlite';
import { mkdtemp, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { performance } from 'node:perf_hooks';
import { CanonicalIndex } from '../server/testnet/canonical-index.ts';
import { CHAIN_ID, GENESIS_HASH, sha256, transactionId } from '../server/testnet/consensus.ts';
import { NETWORK, type TestnetBlock, type NftState, type NftTransfer } from '../shared/testnet.ts';
// Synthetic LOCAL SQL dataset only. Not valid mined blocks; never submitted to Ledger or a peer.
// This measures derived-index costs, not RandomX validation, consensus security or production SLOs.
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');
const count = Number(text), queries = 1000;
const directory = await mkdtemp(join(tmpdir(), 'wikideck-index-benchmark-'));
const file = join(directory, 'fixture.sqlite'), db = new DatabaseSync(file);
try {
db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON; CREATE TABLE blocks(hash TEXT PRIMARY KEY,block_json TEXT NOT NULL)');
const index = new CanonicalIndex(db), chain: TestnetBlock[] = [], state: NftState = Object.create(null);
index.initialize([], state);
const owners = Array.from({ length: 16 }, (_, i) => sha256(`fixture-owner-${i}`));
const insert = db.prepare('INSERT INTO blocks VALUES(?,?)');
const start = performance.now();
db.exec('BEGIN IMMEDIATE');
try {
for (let height = 1; height <= count; height++) {
const hash = sha256(`fixture-block-${height}`), token = sha256(`fixture-token-${height}`), owner = owners[height % owners.length];
const txs: NftTransfer[] = height > 2 ? [{ network: NETWORK, chainId: CHAIN_ID, cardHash: chain[height - 3].tokenId,
from: state[chain[height - 3].tokenId].owner, to: owner, sequence: 0, publicKey: '', signature: '' }] : [];
const block = { height, hash, tokenId: token, previousHash: chain.at(-1)?.hash ?? GENESIS_HASH, owner,
article: { title: `Synthetic index fixture ${height}`, pageId: height, extract: 'x'.repeat(512) }, transactions: txs } as unknown as TestnetBlock;
state[token] = { owner, sequence: 0, mintedHeight: height, pageId: height };
for (const tx of txs) state[tx.cardHash] = { ...state[tx.cardHash], owner: tx.to, sequence: 1 };
insert.run(hash, JSON.stringify(block)); index.connect(block); chain.push(block);
}
db.exec('COMMIT');
} catch (error) { db.exec('ROLLBACK'); throw error; }
const buildMs = performance.now() - start;
const latency: number[] = [];
for (let i = 0; i < queries; i++) {
const block = chain[(i * 7919) % count], before = performance.now();
const nft = index.nft(block.tokenId, count);
if (nft?.sequence !== state[block.tokenId].sequence || nft.owner !== state[block.tokenId].owner) throw new Error('Fixture ownership mismatch');
index.deck(owners[i % owners.length], 0, 24, count);
if (block.transactions.length && !index.transaction(transactionId(block.transactions[0]))) throw new Error('Fixture transaction missing');
latency.push(performance.now() - before);
}
let cursor = 0, batches = 0;
const consumeStart = performance.now();
do { const page = index.events(cursor, 64); cursor = page.cursor; batches++; } while (cursor < index.head());
const eventReadMs = performance.now() - consumeStart;
const verifyStart = performance.now(); index.initialize(chain, state); const verifyMs = performance.now() - verifyStart;
const bytes = (await stat(file)).size + (await stat(file + '-wal')).size;
latency.sort((a, b) => a - b);
console.log(JSON.stringify({ fixture: 'synthetic-derived-index-only', blocks: count, nfts: count,
transactions: Math.max(0, count - 2), queries, buildMs, lookupGroupP50Ms: latency[499], lookupGroupP95Ms: latency[949],
eventBatches: batches, maxEventsPerBatch: 64, eventReadMs, startupVerifyMs: verifyMs, databaseAndWalBytes: bytes,
warning: 'No PoW, HTTP load, real assets, target hardware or production SLO certification.' }, null, 2));
} finally { db.close(); await rm(directory, { recursive: true, force: true }); }
+171
View File
@@ -0,0 +1,171 @@
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<typeof createServer> | 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<void> | 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<void>(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<void>(resolve => loadServer!.close(() => resolve()));
if (market) await market.close();
ledgerDb?.close();
await rm(directory, { recursive: true, force: true });
}
+48
View File
@@ -0,0 +1,48 @@
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { arch, platform } from 'node:os';
import { RandomX } from '../server/testnet/randomx.ts';
import { MAX_TARGET, sha256 } from '../server/testnet/consensus.ts';
import { expectedHashes, targetForHashrate } from '../server/analysis/calibration.ts';
const args=process.argv.slice(2),known=new Set(['--consent','--fast','--hashes','--network-hps']);
for(let i=0;i<args.length;i++) {
if(!known.has(args[i]))throw new Error('Unknown option');
if(args[i]==='--hashes'||args[i]==='--network-hps')i++;
}
if(!args.includes('--consent'))throw new Error('Explicit --consent required: native RandomX consumes CPU, energy and ~256 MiB light / >2.3 GiB fast. No chain/network access.');
const number=(name:string,fallback:number,min:number,max:number)=>{
const i=args.indexOf(name),text=i<0?String(fallback):args[i+1];
const value=Number(text);
if(!text||!/^\d+$/.test(text)||!Number.isSafeInteger(value)||value<min||value>max)throw new Error(`Invalid ${name}`);
return value;
};
const count=number('--hashes',32,4,256),networkHps=number('--network-hps',1,1,1_000_000_000_000);
const binary=resolve(process.env.RANDOMX_BINARY??`native/build/wikideck-randomx${process.platform==='win32'?'.exe':''}`);
// Fingerprint the exact executable; it must come from the trusted pinned build, never a download URL.
const binarySha256=createHash('sha256').update(await readFile(binary)).digest('hex');
const engine=new RandomX({binary,fast:args.includes('--fast')});
const stop=()=>engine.close();process.once('SIGINT',stop);process.once('SIGTERM',stop);
const deadline=setTimeout(stop,60000);deadline.unref();
try {
const key=sha256('wikideck-offline-benchmark-epoch-a'),otherKey=sha256('wikideck-offline-benchmark-epoch-b');
const blob=Buffer.alloc(40);Buffer.from(sha256('wikideck-offline-benchmark-blob'),'hex').copy(blob);
let start=performance.now();const first=await engine.hash(key,blob);const coldMs=performance.now()-start;
start=performance.now();if(await engine.hash(key,blob)!==first)throw new Error('Repeated reference hash differs');
const repeatMs=performance.now()-start;
const samples:number[]=[];start=performance.now();
for(let i=0;i<count;i++) {
blob.writeBigUInt64LE(BigInt(i+1),32);const t=performance.now();await engine.hash(key,blob);samples.push(performance.now()-t);
}
const warmMs=performance.now()-start,warmHps=count/(warmMs/1000);
start=performance.now();await engine.hash(otherKey,blob);const epochSwitchMs=performance.now()-start;
samples.sort((a,b)=>a-b);
console.log(JSON.stringify({schema:'wikideck-randomx-benchmark-v1',mode:args.includes('--fast')?'fast':'light',
platform:platform(),arch:arch(),node:process.version,binarySha256,hashes:count,coldMs,repeatMs,warmMs,warmHashesPerSecond:warmHps,
p50HashMs:samples[Math.floor(samples.length*.5)],p95HashMs:samples[Math.min(samples.length-1,Math.floor(samples.length*.95))],epochSwitchMs,
currentInitialExpectedHashes:expectedHashes(MAX_TARGET),currentInitialExpectedSecondsAtMeasuredSingleWorker:expectedHashes(MAX_TARGET)/warmHps,
assumedNetworkHashesPerSecond:networkHps,illustrativeTargetForAssumedNetwork:targetForHashrate(networkHps),
note:'Local single-worker IPC-inclusive warm hash benchmark, not a network/miner-throughput estimate. Cold includes initialization; epoch switch is one cache change. Network H/s is an explicit assumption, not extrapolated from this machine.',
consensusChanged:false,productionReady:false},null,2));
} finally {clearTimeout(deadline);engine.close();process.off('SIGINT',stop);process.off('SIGTERM',stop);}
+25
View File
@@ -0,0 +1,25 @@
[CmdletBinding()]
param(
[string]$BuildDirectory = (Join-Path $PSScriptRoot '../native/build'),
[string]$Generator = '',
[ValidateRange(1, 64)][int]$Jobs = 2,
[switch]$Test
)
$ErrorActionPreference = 'Stop'
$SourceDirectory = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../native'))
$BuildDirectory = [System.IO.Path]::GetFullPath($BuildDirectory)
if (-not (Get-Command cmake -ErrorAction SilentlyContinue)) { throw 'CMake 3.24+ is required.' }
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { throw 'Git is required to fetch pinned RandomX.' }
$NativeTests = if ($Test) { 'ON' } else { 'OFF' }
$ConfigureArguments = @('-S', $SourceDirectory, '-B', $BuildDirectory,
'-DCMAKE_BUILD_TYPE=Release', "-DWIKIDECK_RANDOMX_TESTS=$NativeTests")
if ($Generator) { $ConfigureArguments += @('-G', $Generator) }
& cmake @ConfigureArguments
if ($LASTEXITCODE -ne 0) { throw 'RandomX CMake configuration failed.' }
& cmake --build $BuildDirectory --config Release --target wikideck-randomx --parallel $Jobs
if ($LASTEXITCODE -ne 0) { throw 'RandomX native build failed.' }
if ($Test) {
& ctest --test-dir $BuildDirectory -C Release --output-on-failure
if ($LASTEXITCODE -ne 0) { throw 'RandomX known-vector test failed.' }
}
Write-Host "Built native RandomX engine in $BuildDirectory"
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
set -eu
# Usage: sh scripts/build-randomx.sh [--test]; JOBS defaults to a modest two compiler jobs.
tests=OFF
case "${1-}" in
'') ;;
--test) tests=ON; shift ;;
*) printf '%s\n' 'usage: build-randomx.sh [--test]' >&2; exit 2 ;;
esac
[ "$#" -eq 0 ] || { printf '%s\n' 'unexpected argument' >&2; exit 2; }
root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
build=${RANDOMX_BUILD_DIR:-"$root/native/build"}
jobs=${JOBS:-2}
case "$jobs" in ''|*[!0-9]*) printf '%s\n' 'JOBS must be 1..64' >&2; exit 2 ;; esac
[ "$jobs" -ge 1 ] && [ "$jobs" -le 64 ] || { printf '%s\n' 'JOBS must be 1..64' >&2; exit 2; }
command -v cmake >/dev/null 2>&1 || { printf '%s\n' 'CMake 3.24+ is required' >&2; exit 1; }
command -v git >/dev/null 2>&1 || { printf '%s\n' 'Git is required' >&2; exit 1; }
cmake -S "$root/native" -B "$build" -DCMAKE_BUILD_TYPE=Release "-DWIKIDECK_RANDOMX_TESTS=$tests"
cmake --build "$build" --config Release --target wikideck-randomx --parallel "$jobs"
if [ "$tests" = ON ]; then ctest --test-dir "$build" -C Release --output-on-failure; fi
printf 'Built native RandomX engine in %s\n' "$build"
+4
View File
@@ -0,0 +1,4 @@
import { calibrationReport } from '../server/analysis/calibration.ts';
if (process.argv.slice(2).length) throw new Error('No options accepted. This deterministic analysis never selects launch parameters.');
console.log(JSON.stringify(calibrationReport(),null,2));
+243
View File
@@ -0,0 +1,243 @@
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;
}
+50
View File
@@ -0,0 +1,50 @@
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<CustodyCommand, 'signature'> = { 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(); }
+39
View File
@@ -0,0 +1,39 @@
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;
}
+29
View File
@@ -0,0 +1,29 @@
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;
}
+11
View File
@@ -0,0 +1,11 @@
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;
}
+155
View File
@@ -0,0 +1,155 @@
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()<deadline) {
if(walletRpc.exitCode!==null) throw new Error(`Wallet RPC exited during startup (${walletRpc.exitCode})`);
try { await rawRpc('get_version'); return; } catch { await new Promise(r=>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;
}
+54
View File
@@ -0,0 +1,54 @@
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;
}
+170
View File
@@ -0,0 +1,170 @@
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;
}
+12
View File
@@ -0,0 +1,12 @@
import { createBackup, verifyBackup, restoreBackup } from '../server/recovery.ts';
const [action,...args]=process.argv.slice(2);
if(!['create','verify','restore'].includes(action))throw new Error('Action must be create, verify or restore');
const allowed=new Set(['--source','--destination','--consent']);
for(let i=0;i<args.length;i++){if(!allowed.has(args[i]))throw new Error('Unknown option');if(args[i]!=='--consent')i++;}
const arg=(key:string)=>{const i=args.indexOf(key);return i<0?undefined:args[i+1];};
const source=arg('--source'),destination=arg('--destination');
if(!source||source.startsWith('--'))throw new Error('Explicit --source required');
if(action!=='verify'&&(!args.includes('--consent')||!destination||destination.startsWith('--')))throw new Error('Explicit --consent and new --destination required; stop the node first');
const result=action==='create'?await createBackup(source,destination!):action==='restore'?await restoreBackup(source,destination!):await verifyBackup(source);
console.log(JSON.stringify(result,null,2));
+11
View File
@@ -0,0 +1,11 @@
import { checkOperations } from '../server/operations-check.ts';
const args=process.argv.slice(2);
if(args.length!==2||args[0]!=='--port'||!/^\d{1,5}$/.test(args[1])||Number(args[1])<1||Number(args[1])>65535)throw new Error('Explicit --port 1..65535 required; loopback diagnostics only.');
const response=await fetch(`http://127.0.0.1:${Number(args[1])}/status`,{redirect:'error',signal:AbortSignal.timeout(5000)});
if(!response.ok||!response.body||!/^application\/json(?:;|$)/i.test(response.headers.get('content-type')??''))throw new Error('Local diagnostics unavailable');
const reader=response.body.getReader(),chunks:Uint8Array[]=[];let bytes=0;
try{for(;;){const part=await reader.read();if(part.done)break;bytes+=part.value.byteLength;if(bytes>65536)throw new Error('Diagnostics too large');chunks.push(part.value);}}
finally{await reader.cancel();reader.releaseLock();}
const joined=new Uint8Array(bytes);let offset=0;for(const chunk of chunks){joined.set(chunk,offset);offset+=chunk.length;}
const report=checkOperations(JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(joined)));
console.log(JSON.stringify(report,null,2));if(!report.healthy)process.exitCode=1;
+64
View File
@@ -0,0 +1,64 @@
import { Pool } from 'pg';
import { z } from 'zod';
import { IS_PILOT } from '../shared/network.ts';
import { createMoneroDepositAddressRpc, loadMoneroObserverConfig } from '../server/market/monero-observer.ts';
import { loadMoneroMtlsTransport, loadMoneroMtlsTransportConfig } from '../server/market/monero-mtls-transport.ts';
import { postgresConfig } from '../server/market/postgres-config.ts';
import { createOrRecoverPilotMoneroDepositAddress } from '../server/market/pilot-monero-deposit-allocation.ts';
import { parsePilotCustodianId } from '../server/market/pilot-custody-identity.ts';
import { loadPilotMoneroDepositRequestFile } from '../server/market/pilot-monero-deposit-request-file.ts';
if (!IS_PILOT || process.env.WIKIDECK_PILOT_MODE !== 'private-mock-only') {
throw new Error('Pilot address command requires the private mock-only pilot profile');
}
if (process.env.WIKIDECK_PILOT_CUSTODY_OPERATIONS !== 'operator-triggered-addresses-only') {
throw new Error('Set WIKIDECK_PILOT_CUSTODY_OPERATIONS=operator-triggered-addresses-only for this explicit operator action');
}
if (process.argv.length !== 3) throw new Error('Usage: pilot-monero-deposit-address.ts SIGNED_REQUEST_JSON_FILE');
if (process.platform === 'win32') throw new Error('Pilot address command requires POSIX protected-file permission checks');
const request = await loadPilotMoneroDepositRequestFile(process.argv[2]);
const custodianId = parsePilotCustodianId(process.env.WIKIDECK_PILOT_CUSTODIAN_ID ?? '');
const requestCustodian = z.object({ custodianId: z.string().uuid() }).passthrough().parse(request).custodianId.toLowerCase();
if (requestCustodian !== custodianId) throw new Error('Signed request does not match the configured pilot custodian');
const walletGenerationId = process.env.WIKIDECK_MONERO_WALLET_GENERATION_ID ?? '';
const walletFingerprint = process.env.WIKIDECK_MONERO_WALLET_FINGERPRINT ?? '';
const accountText = process.env.WIKIDECK_MONERO_DEPOSIT_ACCOUNT_INDEX;
if (!/^[a-f0-9-]{36}$/i.test(walletGenerationId) || !/^[a-f0-9]{64}$/.test(walletFingerprint) ||
!accountText || !/^(0|[1-9]\d{0,9})$/.test(accountText) || Number(accountText) > 0xffff_ffff) {
throw new Error('Invalid protected pilot watch-only wallet generation, fingerprint, or account configuration');
}
const observerConfig = await loadMoneroObserverConfig({ ...process.env, WIKIDECK_MONERO_OBSERVER: '1' });
const mtlsConfig = await loadMoneroMtlsTransportConfig();
if (observerConfig.network !== 'mainnet' || new URL(observerConfig.url).href !== new URL(mtlsConfig.url).href) {
throw new Error('Pilot Wallet-RPC config must match the protected Mainnet mTLS gateway endpoint');
}
const transport = await loadMoneroMtlsTransport(mtlsConfig);
const rpc = createMoneroDepositAddressRpc(observerConfig, transport);
const actualFingerprint = await rpc.verifyWallet();
if (actualFingerprint !== walletFingerprint) throw new Error('Configured pilot wallet fingerprint does not match the protected Wallet-RPC wallet');
const pool = new Pool(await postgresConfig({
...process.env,
WIKIDECK_CUSTODY_PG_URL_FILE: process.env.WIKIDECK_PILOT_ADDRESS_PG_URL_FILE,
WIKIDECK_CUSTODY_PG_CA_FILE: process.env.WIKIDECK_PILOT_ADDRESS_PG_CA_FILE,
WIKIDECK_CUSTODY_PG_TRANSPORT: 'tls',
}));
pool.on('error', () => console.error(JSON.stringify({ component: 'pilot-address', event: 'database-unavailable' })));
try {
const result = await createOrRecoverPilotMoneroDepositAddress(pool, rpc, {
walletGenerationId,
walletFingerprint: actualFingerprint,
accountIndex: Number(accountText),
}, request);
// This is an operator-invoked address response only; no balance credit or deposit acceptance occurs here.
console.log(JSON.stringify({ network: 'mainnet', marketNetwork: result.reservation.marketNetwork,
marketChainId: result.reservation.marketChainId, custodianId: result.reservation.custodianId,
allocationId: result.reservation.allocationId, beneficiary: result.reservation.beneficiary,
addressIndex: result.reservation.addressIndex, address: result.address, replayed: result.replayed,
crediting: 'disabled' }));
} catch {
console.error('Pilot address allocation failed or requires manual reconciliation; no funding/credit action was performed.');
process.exitCode = 1;
} finally { await pool.end(); }
+68
View File
@@ -0,0 +1,68 @@
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(); }
+30
View File
@@ -0,0 +1,30 @@
// 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];};
if(!args.includes('--consent'))throw new Error('Explicit --consent required; use only an isolated disposable local node.');
const endpoint=new URL(argument('--node','http://127.0.0.1:3001'));
if(!['127.0.0.1','[::1]'].includes(endpoint.hostname)||!['http:','https:'].includes(endpoint.protocol)||
endpoint.username||endpoint.password||endpoint.pathname!=='/'||endpoint.search||endpoint.hash)throw new Error('Only literal loopback HTTP(S) origins are allowed. No remote load targets.');
const concurrency=Number(argument('--concurrency','8')),count=Number(argument('--requests','100'));
if(!Number.isInteger(concurrency)||concurrency<1||concurrency>32||!Number.isInteger(count)||count<1||count>1000)throw new Error('Concurrency 1–32; requests 1–1000.');
let next=0,totalBytes=0;const statuses:Record<string,number>={};const latencies:number[]=[];
const started=performance.now();
await Promise.all(Array.from({length:concurrency},async()=>{
while(next<count){const i=next++,start=performance.now();
try{
const response=await fetch(new URL(i%3===0?'/api/market':'/api/summary',endpoint),{redirect:'error',signal:AbortSignal.timeout(5000)});
const reader=response.body?.getReader();let bytes=0;
if(reader)try{for(;;){const part=await reader.read();if(part.done)break;bytes+=part.value.byteLength;if(bytes>2*1024*1024)throw new Error('Response too large');}}finally{await reader.cancel();reader.releaseLock();}
totalBytes+=bytes;statuses[response.status]=(statuses[response.status]??0)+1;
}catch{statuses.error=(statuses.error??0)+1;}
latencies.push(performance.now()-start);
}
}));
latencies.sort((a,b)=>a-b);
const health=await fetch(new URL('/healthz',endpoint),{redirect:'error',signal:AbortSignal.timeout(5000)});
console.log(JSON.stringify({scope:'loopback read-only smoke, not production benchmark',requests:count,concurrency,
elapsedMs:Math.round(performance.now()-started),statuses,totalBytes,
p50Ms:Math.round(latencies[Math.floor(latencies.length*.5)]??0),p95Ms:Math.round(latencies[Math.min(latencies.length-1,Math.floor(latencies.length*.95))]??0),
healthStatus:health.status},null,2));
await health.body?.cancel();
if(statuses.error||health.status!==200)process.exitCode=1;
+23
View File
@@ -0,0 +1,23 @@
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;
+20
View File
@@ -0,0 +1,20 @@
$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
+60
View File
@@ -0,0 +1,60 @@
// Explicit opt-in live-network test. Creates disposable wallets and mines three test NFTs.
import assert from 'node:assert/strict';
import { generateKeyPairSync, sign } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
import { RandomX } from '../server/testnet/randomx.ts';
import { address, CHAIN_ID, miningBlob, mintId, sha256 } from '../server/testnet/consensus.ts';
import { blockClaimMessage, nftTransferMessage, NETWORK, testnetOwnerOf, transactionCardIds,
type MiningTemplate, type TestnetSnapshot } from '../shared/testnet.ts';
if(!process.argv.includes('--consent'))throw new Error('This test mines three blocks with a native CPU worker. Pass --consent explicitly. Disposable NFT rewards are not recoverable.');
const nodes=(process.env.WIKIDECK_SMOKE_NODES??'http://127.0.0.1:3101,http://127.0.0.1:3102,http://127.0.0.1:3103').split(',');
for(const endpoint of nodes)if(!/^http:\/\/127\.0\.0\.1:\d+$/.test(endpoint))throw new Error('Smoke test only accepts explicit loopback test nodes');
async function api(node:string,path:string,body?:unknown){
const r=await fetch(node+path,{method:body?'POST':'GET',headers:body?{'Content-Type':'application/json'}:{},
body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(30000),redirect:'error'});
const value=await r.json();if(!r.ok)throw new Error(value.error??`HTTP${r.status}`);return value;
}
const keys=generateKeyPairSync('ec',{namedCurve:'prime256v1'});
const publicKey=keys.publicKey.export({format:'der',type:'spki'}).toString('base64'),owner=address(publicKey);
const receiver=generateKeyPairSync('ec',{namedCurve:'prime256v1'});
const to=address(receiver.publicKey.export({format:'der',type:'spki'}).toString('base64'));
const signed=(m:string)=>sign('sha256',Buffer.from(m),{key:keys.privateKey,dsaEncoding:'ieee-p1363'}).toString('base64');
const engine=new RandomX();
try {
const initial=await api(nodes[0],'/api/state') as TestnetSnapshot;
assert.equal(initial.chainId,CHAIN_ID);
console.log(`Testing ${nodes.length} independent nodes on ${CHAIN_ID}; starting height ${initial.blocks.length}`);
let first='';
for(let count=0;count<3;count++) {
if(count===2){
const tx={network:NETWORK,chainId:CHAIN_ID,cardHash:first,from:owner,to,sequence:0};
await api(nodes[0],'/api/transfer',{...tx,publicKey,signature:signed(nftTransferMessage(tx))});
const pending=await api(nodes[0],'/api/state') as TestnetSnapshot;
assert.equal(testnetOwnerOf(pending,first),owner);
assert.ok(pending.pendingTransfers.some(t=>transactionCardIds(t).includes(first)));
}
const template=await api(nodes[0],'/api/mining/template',{owner,publicKey}) as MiningTemplate;
let found;
for(let batch=0;batch<100;batch++){
found=await engine.mine(template.header.key,miningBlob(template.header),template.header.target,32,32);
if(found)break;template.header.nonce+=32;
}
assert.ok(found,'Native proof not found within smoke budget');
template.header.nonce=found.nonce;
const hash=sha256(miningBlob(template.header)),tokenId=mintId(template.header);
await api(nodes[0],'/api/blocks',{...template.header,hash,tokenId,powHash:found.hash,signature:signed(blockClaimMessage(hash)),transactions:template.transactions});
if(count===0)first=tokenId;
console.log(`Native RandomX block #${template.header.height} accepted: ${hash}`);
}
const canonical=await api(nodes[0],'/api/state') as TestnetSnapshot;
const deadline=Date.now()+60000;
for(;;){
const states=await Promise.all(nodes.map(n=>api(n,'/api/state') as Promise<TestnetSnapshot>));
if(states.every(s=>s.tip===canonical.tip&&s.cumulativeWork===canonical.cumulativeWork&&testnetOwnerOf(s,first)===to)){
console.log(`PASS: ${states.length} independent nodes agreed on work ${canonical.cumulativeWork}, tip, NFT issuance and mined transfer.`);break;
}
if(Date.now()>deadline)throw new Error('Peer convergence timeout');
await delay(500);
}
}finally{engine.close();}
+1
View File
@@ -0,0 +1 @@
// Temporary runtime probe removed after verification.
+66
View File
@@ -0,0 +1,66 @@
import { CONSENSUS, type TestnetBlock } from '../../shared/testnet.ts';
import { CHAIN_ID, GENESIS_TIME, MAX_TARGET, expectedTarget, medianTime, workFor } from '../testnet/consensus.ts';
const SPACE = 1n << 256n;
/** Analysis only. Calls the actual retarget/median helpers but DOES NOT construct valid blocks,
* perform PoW, invoke Ledger, or provide a production fake-PoW selector. */
export type Scenario = 'steady' | 'rapid' | 'slow' | 'stall' | 'median-minimum' | 'future-edge';
export type TracePoint = { height: number; wallTime: number; timestamp: number; target: string;
cumulativeWork: string; expectedHashes: number; nextTarget: string };
export function expectedHashes(target: string): number {
if (!/^[a-f0-9]{64}$/.test(target)) throw new Error('Invalid target');
return Number(SPACE) / Number(BigInt(`0x${target}`) + 1n);
}
export function targetForHashrate(hashesPerSecond: number, seconds = CONSENSUS.targetSeconds): string {
if (!Number.isSafeInteger(hashesPerSecond) || hashesPerSecond < 1 || hashesPerSecond > 1_000_000_000_000 ||
!Number.isSafeInteger(seconds) || seconds < 1 || seconds > 86400) throw new Error('Hashrate/interval outside analysis bounds');
const denominator = BigInt(hashesPerSecond) * BigInt(seconds);
const target = SPACE / denominator - 1n;
const maximum = BigInt(`0x${MAX_TARGET}`);
return (target < 1n ? 1n : target > maximum ? maximum : target).toString(16).padStart(64,'0');
}
export function simulate(scenario: Scenario, count = 60): TracePoint[] {
if (!['steady','rapid','slow','stall','median-minimum','future-edge'].includes(scenario) ||
!Number.isSafeInteger(count) || count < 1 || count > 1000) throw new Error('Invalid bounded simulation');
const history: TestnetBlock[] = [], result: TracePoint[] = [];
let wallTime = GENESIS_TIME, cumulativeWork = 0n;
for (let i = 0; i < count; i++) {
const target = expectedTarget(history);
const spacing = scenario === 'rapid' ? 1 : scenario === 'slow' ? CONSENSUS.targetSeconds * 8
: scenario === 'stall' && i === 20 ? 7 * 86400 : CONSENSUS.targetSeconds;
wallTime += spacing;
const timestamp = scenario === 'median-minimum' ? medianTime(history) + 1
: scenario === 'future-edge' ? wallTime + CONSENSUS.maxFutureSeconds : wallTime;
if (timestamp <= medianTime(history) || timestamp > wallTime + CONSENSUS.maxFutureSeconds) throw new Error('Analysis generated an invalid timestamp');
// Only these fields are read by expectedTarget/medianTime. Never submit these records to a node.
history.push({ createdAt: timestamp, target } as TestnetBlock);
cumulativeWork += workFor(target);
result.push({ height:i+1,wallTime,timestamp,target,cumulativeWork:cumulativeWork.toString(),
expectedHashes:expectedHashes(target),nextTarget:expectedTarget(history) });
}
return result;
}
/** Idealized fixed-difficulty Poisson race: probability of ever catching up from a CURRENT
* known z-block deficit, q<p. NOT probability of double-spend after z confirmations. */
export function idealCatchup(attackerShare: number, deficit: number): number {
if (!Number.isFinite(attackerShare) || attackerShare < 0 || attackerShare > 1 ||
!Number.isSafeInteger(deficit) || deficit < 0 || deficit > 1000) throw new Error('Invalid race assumptions');
if (deficit === 0 || attackerShare >= .5) return 1;
return (attackerShare / (1-attackerShare)) ** deficit;
}
export function calibrationReport() {
const scenarios = Object.fromEntries((['steady','rapid','slow','stall','median-minimum','future-edge'] as const)
.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.',
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,
partitionExample:{steadyHeight:a.height,rapidHeight:b.height,steadyWork:a.cumulativeWork,rapidWork:b.cumulativeWork,
winnerByStrictWork:BigInt(b.cumulativeWork)>BigInt(a.cumulativeWork)?'rapid':'steady',
warning:'Models fork-choice inputs, not peer synchronization, block validity or achievable hashpower.'},
knownDeficitCatchup:[.1,.25,.4,.49].map(share=>({attackerShare:share,probability:idealCatchup(share,10),currentDeficit:10})),
candidateTargets:[1,10,100,1000,10000].map(hps=>({assumedNetworkHashesPerSecond:hps,target:targetForHashrate(hps)})),
decision:'NO-GO: measurement, independent analysis, multi-host rehearsal and explicit operator approval still required.' };
}
+58
View File
@@ -0,0 +1,58 @@
import { createHash, createPublicKey, verify } from 'node:crypto';
import { argon2id } from 'hash-wasm';
import { z } from 'zod';
import { claimMessage, transferMessage, proofMessage, POW, GENESIS, DIFFICULTY, ownerOf,
type Block, type Challenge, type ChainState, type Transfer } from '../shared/protocol.ts';
const hex = z.string().regex(/^[a-f0-9]{64}$/);
const publicKey = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).max(256);
const signature = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).max(128);
export const mineSchema = z.object({ owner: hex, publicKey, signature,
nonce: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), hash: hex,
seed: hex }).strict();
export const transferSchema = z.object({ cardHash: hex, from: hex, to: hex,
sequence: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), publicKey, signature }).strict();
export function address(key: string): string {
return createHash('sha256').update(Buffer.from(key, 'base64')).digest('hex');
}
export function validSignature(key: string, owner: string, message: string, sig: string): boolean {
try {
if (address(key) !== owner) return false;
const imported = createPublicKey({ key: Buffer.from(key, 'base64'), format: 'der', type: 'spki' });
if (imported.asymmetricKeyType !== 'ec' || imported.asymmetricKeyDetails?.namedCurve !== 'prime256v1') return false;
return verify('sha256', Buffer.from(message), { key: imported, dsaEncoding: 'ieee-p1363' }, Buffer.from(sig, 'base64'));
} catch { return false; }
}
export async function powHash(c: Challenge, owner: string, nonce: number): Promise<string> {
return argon2id({ ...POW, password: proofMessage(c, owner, nonce),
salt: Buffer.from(c.seed, 'hex'), outputType: 'hex' });
}
export async function verifyBlock(block: Block, previousHash: string, height: number): Promise<boolean> {
if (block.previousHash !== previousHash || block.height !== height || block.difficulty !== DIFFICULTY) return false;
if (!/^[a-f0-9]{64}$/.test(block.seed) || !/^[a-f0-9]{64}$/.test(block.hash)) return false;
if (!Number.isSafeInteger(block.nonce) || block.nonce < 0 || !Number.isSafeInteger(block.createdAt)) return false;
if (!Number.isSafeInteger(block.article.pageId) || block.article.pageId <= 0) return false;
if (!block.hash.startsWith('0'.repeat(DIFFICULTY))) return false;
if (!validSignature(block.publicKey, block.owner, claimMessage(block, block.owner, block.nonce, block.hash), block.signature)) return false;
return await powHash(block, block.owner, block.nonce) === block.hash;
}
export function validTransfer(state: ChainState, t: Transfer): boolean {
return transferSchema.safeParse(t).success && t.from !== t.to &&
ownerOf(state, t.cardHash) === t.from &&
t.sequence === state.transfers.filter(x => x.cardHash === t.cardHash).length &&
validSignature(t.publicKey, t.from, transferMessage(t), t.signature);
}
export async function verifyState(state: ChainState): Promise<boolean> {
let previous = GENESIS;
for (const [index, block] of state.blocks.entries()) {
if (!await verifyBlock(block, previous, index + 1)) return false;
previous = block.hash;
}
const checked: ChainState = { blocks: state.blocks, transfers: [] };
for (const t of state.transfers) {
if (!validTransfer(checked, t)) return false;
checked.transfers.push(t);
}
return true;
}
+282
View File
@@ -0,0 +1,282 @@
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<typeof pairManifestSchema>;
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<string> {
const hash = createHash('sha256');
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest('hex');
}
async function flush(path: string): Promise<void> {
const handle = await open(path, 'r+'); try { await handle.sync(); } finally { await handle.close(); }
}
async function flushDirectory(path: string): Promise<void> {
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<ReturnType<typeof postgresConfig>>, 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<ReturnType<typeof postgresConfig>>, output: string, binary: string): Promise<void> {
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<void>((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<string> {
return new Promise<string>((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<CoordinatedBackupManifest> {
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<void> {
if (binary !== 'pg_restore' && !isAbsolute(binary)) throw new Error('Custom PostgreSQL restore binary must use an absolute path');
await new Promise<void>((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<CoordinatedBackupManifest> {
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;
}
+174
View File
@@ -0,0 +1,174 @@
import { z } from 'zod';
import { deriveStats, type PageEnrichment } from '../shared/enrichment.ts';
const MAX_BYTES = 1024 * 1024;
const integer = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
const text = z.string().max(4096);
const pageSchema = z.object({
pageid: integer.positive(), ns: z.literal(0), title: text.min(1),
missing: z.never().optional(), invalid: z.never().optional(),
lastrevid: integer.positive(), length: integer,
description: text.optional(),
pageprops: z.object({ wikibase_item: z.string().regex(/^Q[1-9]\d{0,19}$/).optional() }).optional(),
categories: z.array(z.object({ ns: z.literal(14), title: text.min(1) })).max(50).optional(),
langlinks: z.array(z.object({ lang: z.string().min(1).max(64) })).max(500).optional(),
});
const metadataSchema = z.object({
query: z.object({ pages: z.array(pageSchema).length(1) }),
continue: z.object({ clcontinue: text.optional(), llcontinue: text.optional() }).optional(),
});
const viewsSchema = z.object({ items: z.array(z.object({
timestamp: z.string().regex(/^\d{6}0100$/), views: integer,
project: z.literal('en.wikipedia'), access: z.literal('all-access'),
agent: z.literal('user'), granularity: z.literal('monthly'),
})).max(6) });
type CacheEntry = { expires: number; value: PageEnrichment } | { expires: number; error: Error };
type Options = { fetch?: typeof globalThis.fetch; now?: () => Date; ttlMs?: number; maxEntries?: number };
function monthWindow(now: Date): string[] {
return Array.from({ length: 6 }, (_, index) =>
new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 6 + index, 1)).toISOString().slice(0, 7));
}
/** Fixed-source read-only enrichment; no consensus, persistence, URL or header inputs. */
export class WikipediaEnrichment {
private readonly fetcher: typeof globalThis.fetch;
private readonly now: () => Date;
private readonly ttlMs: number;
private readonly maxEntries: number;
private readonly cache = new Map<number, CacheEntry>();
private readonly pending = new Map<number, Promise<PageEnrichment>>();
private active = 0;
constructor(options: Options = {}) {
this.fetcher = options.fetch ?? globalThis.fetch;
this.now = options.now ?? (() => new Date());
this.ttlMs = options.ttlMs ?? 6 * 60 * 60 * 1000;
this.maxEntries = options.maxEntries ?? 128;
if (!Number.isSafeInteger(this.ttlMs) || this.ttlMs <= 0 ||
!Number.isSafeInteger(this.maxEntries) || this.maxEntries <= 0 || this.maxEntries > 128) {
throw new RangeError('Invalid enrichment cache options');
}
}
async get(pageId: number): Promise<PageEnrichment> {
return this.loadCached(pageId, false);
}
/** Bypass cached values when the result is being pinned into a newly minted block. */
async getFresh(pageId: number): Promise<PageEnrichment> {
return this.loadCached(pageId, true);
}
private async loadCached(pageId: number, fresh: boolean): Promise<PageEnrichment> {
if (!Number.isSafeInteger(pageId) || pageId <= 0) throw new RangeError('Invalid Wikipedia page ID');
const now = this.now();
const cached = this.cache.get(pageId);
if (!fresh && cached && cached.expires > now.getTime() &&
(!('value' in cached) || cached.value.pageviews.latestMonth === monthWindow(now)[5])) {
this.cache.delete(pageId);
this.cache.set(pageId, cached);
if ('error' in cached) throw cached.error;
return structuredClone(cached.value);
}
this.cache.delete(pageId);
const existing = this.pending.get(pageId);
if (existing) return structuredClone(await existing);
// Reject immediately: no unbounded waiting queue. Four active jobs also keep
// the pending-map ceiling of eight conservative (one upstream request/job).
if (this.pending.size >= 8 || this.active >= 4) throw new Error('Wikipedia enrichment busy');
this.active++;
const job = this.load(pageId, now).then(value => {
const ttl = value.pageviews.status === 'available' ? this.ttlMs : Math.min(this.ttlMs, 5 * 60 * 1000);
this.remember(pageId, { value, expires: this.now().getTime() + ttl });
return value;
}, () => {
const error = new Error('Wikipedia page enrichment unavailable');
this.remember(pageId, { error, expires: this.now().getTime() + 30_000 });
throw error;
}).finally(() => { this.active--; this.pending.delete(pageId); });
this.pending.set(pageId, job);
return structuredClone(await job);
}
private remember(pageId: number, entry: CacheEntry): void {
this.cache.delete(pageId);
this.cache.set(pageId, entry);
while (this.cache.size > this.maxEntries) this.cache.delete(this.cache.keys().next().value!);
}
private async json(url: URL): Promise<unknown> {
const signal = AbortSignal.timeout(6000);
const response = await this.fetcher(url, { signal, redirect: 'error',
headers: { Accept: 'application/json', 'User-Agent': 'Wikideck/0.1 (Wikipedia off-chain page enrichment)' } });
const length = response.headers.get('content-length');
if (!response.ok || response.redirected ||
!/^application\/json(?:\s*;|\s*$)/i.test(response.headers.get('content-type') ?? '') ||
(length !== null && (!/^\d+$/.test(length) || Number(length) > MAX_BYTES)) || !response.body) {
await response.body?.cancel();
throw new Error('Invalid upstream response');
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
signal.throwIfAborted();
const { done, value } = await reader.read();
signal.throwIfAborted();
if (done) break;
size += value.byteLength;
if (size > MAX_BYTES) throw new Error('Upstream response too large');
chunks.push(value);
}
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
} finally {
await reader.cancel();
reader.releaseLock();
}
}
private async load(pageId: number, now: Date): Promise<PageEnrichment> {
const url = new URL('https://en.wikipedia.org/w/api.php');
url.search = new URLSearchParams({ action: 'query', format: 'json', formatversion: '2',
pageids: String(pageId), prop: 'info|categories|pageprops|description|langlinks',
clshow: '!hidden', cllimit: '50', lllimit: '500', ppprop: 'wikibase_item' }).toString();
const metadata = metadataSchema.parse(await this.json(url));
const page = metadata.query.pages[0];
if (page.pageid !== pageId) throw new Error('Wikipedia page ID mismatch');
const months = monthWindow(now);
const latestMonth = months[5];
let pageviews: PageEnrichment['pageviews'] = { status: 'unavailable', months: [], latestMonth,
total: null, dailyAverage: null, trendPercent: null };
try {
const title = encodeURIComponent(page.title.replace(/ /g, '_'));
const stamp = (month: string) => `${month.replace('-', '')}0100`;
const endpoint = new URL(`https://wikimedia.org/api/rest_v1/metrics/pageviews/per-article/en.wikipedia.org/all-access/user/${title}/monthly/${stamp(months[0])}/${stamp(latestMonth)}`);
const result = viewsSchema.parse(await this.json(endpoint));
const byMonth = new Map<string, number>();
for (const item of result.items) {
const month = `${item.timestamp.slice(0, 4)}-${item.timestamp.slice(4, 6)}`;
if (!months.includes(month) || byMonth.has(month)) throw new Error('Invalid pageview month');
byMonth.set(month, item.views);
}
if (byMonth.size !== 6) throw new Error('Incomplete pageviews');
const ordered = months.map(month => ({ month, views: byMonth.get(month)! }));
const total = ordered.reduce((sum, item) => sum + item.views, 0);
if (!Number.isSafeInteger(total)) throw new Error('Pageview total overflow');
const latest = ordered[5].views, prior = ordered[4].views;
const days = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0)).getUTCDate();
pageviews = { status: 'available', months: ordered, latestMonth, total,
dailyAverage: latest / days, trendPercent: prior > 0 ? (latest - prior) / prior * 100 : null };
} catch { /* Metadata remains useful; unknown views must not become fake zeroes. */ }
return { pageId, title: page.title, description: page.description ?? null,
wikidataId: page.pageprops?.wikibase_item ?? null, revisionId: page.lastrevid, pageBytes: page.length,
languageCount: page.langlinks?.length ?? 0, languagesTruncated: metadata.continue?.llcontinue !== undefined,
categories: (page.categories ?? []).map(item => item.title.replace(/^Category:/, '')),
categoriesTruncated: metadata.continue?.clcontinue !== undefined, fetchedAt: now.toISOString(), pageviews,
stats: deriveStats(page.length, page.langlinks?.length ?? 0,
pageviews.status === 'available' ? pageviews.months[5].views : null) };
}
}
export const wikipediaEnrichment = new WikipediaEnrichment();
+12
View File
@@ -0,0 +1,12 @@
// A node independently validates RandomX work and follows cumulative-work consensus.
import { assertLaunchAllowed, launchManifest } from './launch.ts';
import { launchPreflight } from './preflight.ts';
if (process.argv.includes('--launch-preflight')) {
console.log(JSON.stringify(launchPreflight(), null, 2));
process.exitCode = 1; // Fail closed: a report with unresolved production blockers is never approval.
} else if (process.argv.includes('--network-manifest')) console.log(JSON.stringify(launchManifest(), null, 2));
else {
assertLaunchAllowed();
await import('./testnet/http.ts');
}
+35
View File
@@ -0,0 +1,35 @@
import { IS_MAINNET, IS_PILOT, 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 };
}
/** 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}`);
}
if (now < GENESIS_TIME * 1000) throw new Error('Mainnet candidate genesis time has not arrived; this is not an announced launch date');
const origin = new URL(env.PUBLIC_ORIGIN ?? 'http://invalid');
if (origin.protocol !== 'https:' || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash) throw new Error('Mainnet requires an HTTPS PUBLIC_ORIGIN');
const peers = (env.WIKIDECK_PEERS ?? '').split(',').map(s => s.trim()).filter(Boolean);
if (new Set(peers).size < 2) throw new Error('Mainnet launch requires at least two explicitly configured independent HTTPS peers');
for (const text of peers) {
const peer = new URL(text);
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');
}
+151
View File
@@ -0,0 +1,151 @@
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<typeof pageSchema>;
export type CanonicalInboxResult = { streamId: string; cursor: number; head: number; height: number; tip: string;
replayed: boolean; appendedEvents: z.infer<typeof eventSchema>[]; 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<typeof eventSchema> {
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<string | undefined> {
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<typeof eventSchema>, 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<typeof eventSchema>): 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<CanonicalInboxResult> {
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<typeof eventSchema>[] = [];
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<void> {
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');
}
+87
View File
@@ -0,0 +1,87 @@
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<typeof setTimeout> | undefined;
private cycle: Promise<void> | 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<CanonicalObserverStatus> {
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<CanonicalObserverStatus> {
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<void> {
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<void> {
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;
}
}
+122
View File
@@ -0,0 +1,122 @@
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] };
}
+89
View File
@@ -0,0 +1,89 @@
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<string, string | number>;
let snapshot: Record<typeof names[number], Row[]>;
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<string, Row[]>();
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(); }
}
+29
View File
@@ -0,0 +1,29 @@
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(); }
}
+480
View File
@@ -0,0 +1,480 @@
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<T>(schema: z.ZodType<T>, 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<string, unknown>, y = b as Record<string, unknown>;
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<Map<string, Journal>> {
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<string, Journal>, 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<string, string>(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<DepositCreditRow> = await client.query<DepositCreditRow>(`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<void> {
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<string, unknown>;
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<WithdrawalIntentAuditRow> = await client.query<WithdrawalIntentAuditRow>(`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<void> {
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<OrderRow> = 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<WithdrawalRow> = 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); }
}
+55
View File
@@ -0,0 +1,55 @@
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<CustodyMarket | PostgresCustody> {
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; }
}
+351
View File
@@ -0,0 +1,351 @@
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<CustodyMarket> {
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<T>(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<T>(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<CustodyListing>('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<CustodyOrder>('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<CustodyListing>('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<CustodyWithdrawal>('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<CustodyOrder>('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<CustodyListing>('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<CustodyWithdrawal>('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<CustodyWithdrawal>('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() };
});
}
}
+71
View File
@@ -0,0 +1,71 @@
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<MainnetCustodyIdentity> {
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<MainnetCustodyIdentity>;
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<MainnetCustodyIdentity> {
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<MainnetCustodyIdentity>;
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();
}
+194
View File
@@ -0,0 +1,194 @@
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<void> {
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<void> {
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<string, unknown>;
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<MainnetWithdrawalIntent> {
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<MainnetWithdrawalIntent> {
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<MainnetCustodyBalance> {
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(); }
}
+146
View File
@@ -0,0 +1,146 @@
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<typeof configSchema>;
export type MoneroDaemonProbeTransport = (url: string, init: RequestInit) => Promise<Response>;
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<MoneroDaemonProbeConfig> {
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<string> {
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<MoneroDaemonProbeResult> {
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<typeof envelopeSchema>;
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<typeof infoSchema>;
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);
}
}

Some files were not shown because too many files have changed in this diff Show More