From 636f5b9c7700c92711c7b8ef3ccbe854b92d7d2f Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Sun, 1 Nov 2026 22:07:51 +0000 Subject: [PATCH] V2.1 (#21) Working version Reviewed-on: https://gitea.eom.dev/WikiDeck/wikideck/pulls/21 --- .dockerignore | 17 ++ Dockerfile | 26 +- Dockerfile.lockgen | 4 + README.md | 86 +++++- compose.mainnet.yaml | 39 +++ compose.testnet.yaml | 20 +- compose.yaml | 24 +- deploy/kubernetes/README.md | 45 ++++ deploy/nginx.conf.example | 41 +++ docs/canonical-indexes.md | 17 ++ docs/card-only-production-plan.md | 113 ++++++++ docs/consensus-calibration.md | 62 +++++ docs/mainnet-launch.md | 57 ++++ docs/node-resources.md | 45 ++++ docs/operations-recovery.md | 42 +++ docs/operator-launch.md | 60 +++++ docs/randomx-native.md | 6 +- docs/security-review.md | 31 +++ docs/testnet-consensus.md | 38 +-- docs/wallet-security.md | 45 ++++ index.html | 6 +- package-lock.json | 348 ++++++++++++++----------- package.json | 12 +- public/Wikideck.png | Bin 0 -> 10288 bytes public/original-logo.svg | 1 + scripts/benchmark-canonical-index.ts | 59 +++++ scripts/benchmark-randomx.ts | 48 ++++ scripts/consensus-analysis.ts | 4 + scripts/dev-testnet.mjs | 20 ++ scripts/miner-testnet.mjs | 10 + scripts/node-backup.ts | 12 + scripts/ops-check.ts | 11 + scripts/rpc-load.ts | 33 +++ scripts/run-tests.mjs | 18 ++ scripts/testnet-smoke.ts | 5 +- scripts/worker-probe.ts | 1 + server/analysis/calibration.ts | 66 +++++ server/index.ts | 12 +- server/launch.ts | 29 +++ server/operations-check.ts | 22 ++ server/operations.ts | 63 +++++ server/preflight.ts | 27 ++ server/recovery.ts | 117 +++++++++ server/resources.ts | 89 +++++++ server/testnet/canonical-index.ts | 186 +++++++++++++ server/testnet/consensus.ts | 149 +++++++++-- server/testnet/http.ts | 102 ++++++-- server/testnet/ledger.ts | 350 ++++++++++++++++++------- server/testnet/miner.ts | 7 +- server/testnet/peers.ts | 27 +- server/testnet/randomx.ts | 1 + server/wiki.ts | 2 +- shared/chain-identity.ts | 16 ++ shared/network.ts | 41 +++ shared/rpc-client.ts | 105 ++++++++ shared/testnet.ts | 56 +++- shared/trade-offer.ts | 24 ++ shared/wallet-backup.ts | 41 ++- src/App.css | 1 + src/App.tsx | 96 ++++--- src/Card.tsx | 4 +- src/TradeDesk.tsx | 223 ++++++++++++++++ src/WalletPanel.tsx | 68 ++++- src/testnet.css | 5 + src/trade.css | 3 + src/wallet.ts | 181 ++++++++++--- start.sh | 6 +- tests/api.test.ts | 61 ++++- tests/branding.test.ts | 16 ++ tests/calibration.test.ts | 56 ++++ tests/network.test.ts | 166 ++++++++++++ tests/operations.test.ts | 48 ++++ tests/project-copy.test.ts | 30 +++ tests/recovery.test.ts | 69 +++++ tests/resources.test.ts | 73 ++++++ tests/rpc-client.test.ts | 67 +++++ tests/testnet-consensus.test.ts | 4 +- tests/testnet-ledger.test.ts | 375 ++++++++++++++++++++++++--- tests/wallet-backup.test.ts | 120 +++++++-- tsconfig.json | 4 +- 80 files changed, 4065 insertions(+), 549 deletions(-) create mode 100755 Dockerfile.lockgen create mode 100755 compose.mainnet.yaml create mode 100755 deploy/kubernetes/README.md create mode 100755 deploy/nginx.conf.example create mode 100755 docs/canonical-indexes.md create mode 100755 docs/card-only-production-plan.md create mode 100755 docs/consensus-calibration.md create mode 100755 docs/mainnet-launch.md create mode 100755 docs/node-resources.md create mode 100755 docs/operations-recovery.md create mode 100755 docs/operator-launch.md create mode 100755 docs/security-review.md create mode 100755 docs/wallet-security.md mode change 100644 => 100755 package-lock.json create mode 100644 public/Wikideck.png create mode 100755 public/original-logo.svg create mode 100755 scripts/benchmark-canonical-index.ts create mode 100755 scripts/benchmark-randomx.ts create mode 100755 scripts/consensus-analysis.ts create mode 100755 scripts/dev-testnet.mjs create mode 100755 scripts/miner-testnet.mjs create mode 100755 scripts/node-backup.ts create mode 100755 scripts/ops-check.ts create mode 100755 scripts/rpc-load.ts create mode 100755 scripts/run-tests.mjs create mode 100755 scripts/worker-probe.ts create mode 100755 server/analysis/calibration.ts create mode 100755 server/launch.ts create mode 100755 server/operations-check.ts create mode 100755 server/operations.ts create mode 100755 server/preflight.ts create mode 100755 server/recovery.ts create mode 100755 server/resources.ts create mode 100755 server/testnet/canonical-index.ts create mode 100755 shared/chain-identity.ts create mode 100755 shared/network.ts create mode 100755 shared/rpc-client.ts create mode 100755 shared/trade-offer.ts create mode 100755 src/TradeDesk.tsx create mode 100755 src/trade.css create mode 100755 tests/branding.test.ts create mode 100755 tests/calibration.test.ts create mode 100755 tests/network.test.ts create mode 100755 tests/operations.test.ts create mode 100755 tests/project-copy.test.ts create mode 100755 tests/recovery.test.ts create mode 100755 tests/resources.test.ts create mode 100755 tests/rpc-client.test.ts diff --git a/.dockerignore b/.dockerignore index 4175924..61eefac 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +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 diff --git a/Dockerfile b/Dockerfile index 7aef427..db9f721 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,8 +12,23 @@ WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . +ARG WIKIDECK_NETWORK=mainnet +ARG WIKIDECK_GENESIS_TIME=1793491200 +ENV VITE_WIKIDECK_NETWORK=$WIKIDECK_NETWORK +ENV VITE_WIKIDECK_GENESIS_TIME=$WIKIDECK_GENESIS_TIME RUN npm run build +FROM build AS test +RUN apk add --no-cache libstdc++ +ENV WIKIDECK_NETWORK=testnet \ + WIKIDECK_GENESIS_TIME=1792411200 \ + VITE_WIKIDECK_NETWORK=testnet \ + VITE_WIKIDECK_GENESIS_TIME=1792411200 +RUN npm run build +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 ./ @@ -21,17 +36,25 @@ RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force FROM node:24-alpine AS runtime RUN apk add --no-cache libstdc++ +ARG WIKIDECK_NETWORK=mainnet +ARG WIKIDECK_GENESIS_TIME=1793491200 ENV NODE_ENV=production \ HOST=0.0.0.0 \ PORT=3001 \ WIKIDECK_DATA_DIR=/app/data \ - RANDOMX_BINARY=/app/native/wikideck-randomx + RANDOMX_BINARY=/app/native/wikideck-randomx \ + WIKIDECK_NETWORK=$WIKIDECK_NETWORK \ + WIKIDECK_GENESIS_TIME=$WIKIDECK_GENESIS_TIME \ + WIKIDECK_IMAGE_NETWORK=$WIKIDECK_NETWORK \ + WIKIDECK_IMAGE_GENESIS_TIME=$WIKIDECK_GENESIS_TIME 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 --from=randomx-build /native/build/wikideck-randomx ./native/wikideck-randomx RUN mkdir -p /app/data && chown node:node /app/data USER node @@ -40,3 +63,4 @@ 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"] + diff --git a/Dockerfile.lockgen b/Dockerfile.lockgen new file mode 100755 index 0000000..14db763 --- /dev/null +++ b/Dockerfile.lockgen @@ -0,0 +1,4 @@ +FROM node:24-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install --package-lock-only --ignore-scripts diff --git a/README.md b/README.md index 796a019..c8649e4 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,53 @@ # Wikideck -Wikideck is an experimental, self-hosted RandomX testnet where each mined block creates a native NFT from a random English Wikipedia article. A page may be minted more than once; each NFT commits its own article and mint-time statistics snapshot, including a Wikimedia-hosted picture. It includes a React interface, a validating node, local P-256 wallets, transfers, and current off-chain Wikipedia statistics. +Wikideck is an experimental blockchain game about **mining, collecting, and trading illustrated Wikipedia cards**. Mine a block with the native RandomX CPU miner to earn a card, build a collection in a local wallet, and trade cards directly with another player. -**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. +**Mainnet-first, still NO-GO:** the default image, runtime profile, and Compose service are mainnet, but the network is experimental, unaudited, and not launched. A local build is not production approval. See the [card-only production plan](docs/card-only-production-plan.md) and [mainnet launch gates](docs/mainnet-launch.md). -## Run the three-node testnet (Docker) +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. + +**Mainnet is a gated candidate, not an operating public network or an investment product.** There are no hosted nodes, public bootnodes, or automatic peer discovery. No external audit is planned; the operator accepts that residual risk. Cards have no promised monetary value. Do not use funds or valuable assets. This project is not affiliated with Wikipedia or the Wikimedia Foundation. + +## Mine, collect, and trade cards + +- **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.** Browse chain discoveries or focus on a subject, series, or theme. Cards are controlled by your encrypted local wallet. +- **Trade directly.** Look up another public wallet and exchange up to eight cards on each side in one signed, atomic trade. Offers are shared between the two players; the app does not list cards for sale or process payments. +- **Gameplay is not included yet.** The current release supports mining, collecting, transfers, and direct trades. Playable card games are future work. + +**Licensing status:** this checkout does not yet contain a project LICENSE file. Source availability alone does not grant open-source modification/redistribution rights. Wikipedia text/images and dependencies retain their separate licenses. + +## What's available now + +| Available in the mainnet-first codebase | Not included | +| --- | --- | +| RandomX mining and illustrated Wikipedia card rewards | Playable card games | +| Encrypted local wallets, collections, transfers, and atomic card trades | Production network launch | +| Mainnet candidate by default; testnet only as an explicit local development fixture | Public bootstrap network or production readiness | + +## Default: mainnet candidate (Docker) + +The default mainnet genesis is fixed at **November 1, 2026, 00:00 UTC** +(`1793491200`). Rebuilding the current Dockerfile requires no special genesis +argument and no wait until January. Publish the rebuilt image as +`ericomeehan/wikideck:latest` before using the three-node Ansible playbook; it reads +the chain ID automatically and configures the risk acknowledgment and internal +peers. This updates source defaults, not an already published image or an existing +ledger's chain identity. Create/unlock a wallet in the deployed HTTPS UI and run +the native miner to produce cards and confirm transfers/trades. + +Install Docker Desktop with Linux containers and Docker Compose v2. From the repository root: + +```sh +docker compose up --build -d +docker compose ps +``` + +The default Compose service is mainnet-first and binds only to **http://127.0.0.1:3201** behind a separately configured HTTPS edge. It intentionally exits until the exact chain approval, explicit unaudited-risk acceptance, genesis time, HTTPS origin, and at least two valid public HTTPS peers are configured. Do not set these to placeholders just to make the process start. See [mainnet launch gates](docs/mainnet-launch.md). The node does not mine automatically. Stop it with `docker compose down`; the ledger volume remains. **Do not add `-v` unless you intend to delete that data.** + +For isolated development, use the explicitly testnet-only [three-node fixture](#explicit-testnet-development-fixture-docker). `npm run dev` also pins both the API and Vite browser build to that testnet fixture; it does not silently change a production build. + +## Explicit testnet development fixture (Docker) Install Docker Desktop with Linux containers and Docker Compose v2. From the repository root, start three connected nodes: @@ -22,9 +65,9 @@ Stop the nodes with: docker compose -f compose.testnet.yaml down ``` -The volumes persist across stops and rebuilds. **Do not add `-v` unless you intend to delete all three ledgers.** Back up wallets separately. Protocol v4 uses a new chain identity; earlier v2/v3 ledgers and encrypted backups are not compatible. To keep old Docker data intact while trying v4, use a separate Compose project name, for example `docker compose -p wikideck-v4 -f compose.testnet.yaml up --build -d`, and use the same `-p wikideck-v4` 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. +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 v4. The node intentionally fails closed on an old ledger; it does not reset or migrate it. +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. `npm start` defaults to the gated mainnet identity; use `npm run dev` only for local testnet development. ## Run on your host @@ -47,21 +90,21 @@ The node listens at **http://127.0.0.1:3001** by default. For development, `npm ## Wallets and backups -The browser creates a local, recoverable P-256 wallet. Open the wallet panel and use **Wallet backup & recovery** to download an encrypted backup. Choose a unique password of at least 12 characters, keep the file outside the repository, and store the password separately. Test recovery in another browser profile before relying on the backup. +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. -The password encrypts the downloaded backup; it does **not** encrypt the key stored in browser IndexedDB. Same-origin scripts and access to the browser profile may expose that key. Clearing site data without a usable backup loses access. 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. A restored wallet controls the same address; it does not move assets between chains. +New/migrated local vaults encrypt the key in an IndexedDB database scoped to the exact chain ID; v2 encrypted backups authenticate that same chain ID. Original fixed-testnet v1 backups remain recoverable only on that original testnet. 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. +Each genesis has a separate browser wallet store, and backups cannot cross chain identities. Testnet remains only as an explicit development fixture; there is no automatic migration from older demos. See [consensus, wallets, and operational limits](docs/testnet-consensus.md) and the in-app backup warnings for more detail. -## Mine only by explicit choice +## Mine Wikipedia cards with RandomX -Mining uses the native CPU RandomX engine. The browser page never mines or launches a native program. 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: +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 +npm run miner:testnet -- --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. +The example is intentionally testnet-only and uses node-a; the dedicated command pins that profile and its fixed genesis. For mainnet, use `npm run miner` with a chain-bound mainnet backup and the configured HTTPS node origin after satisfying the mainnet launch gates. 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. @@ -72,13 +115,22 @@ Light mode needs a 256 MiB cache plus scratchpad and process overhead. `--fast` - **Discover** shows preview examples until the node has mined blocks; previews are not owned NFTs. Minted cards can reference the same page, but each carries its own captured stats snapshot. - **My collection** shows NFTs owned by the active local wallet on the connected node's canonical chain. - **Block explorer** distinguishes block IDs, NFT token IDs, and RandomX proof hashes. A submitted transfer is pending until a block confirms it. Reorganizations can change canonical ownership and confirmations. +- **Trade cards** lets you look up another public wallet by receive address, select up to eight cards on each side, prepare a signed offer, and accept another player's offer. Offers are shared as JSON for the other player to review; they are not submitted until accepted. The two parties sign the same card list, and validators apply the whole bundle atomically or reject it entirely. Offers are not reservations: before acceptance the app re-checks ownership, maturity, sequences, and pending activity; stale offers must be recreated. - Each minted card shows current Wikipedia statistics alongside its immutable at-mint snapshot: pageviews, article size, language links, revision, and derived scores. Current values may change; at-mint values are committed to the block. Missing pageview data stays unavailable, not zero. - The appearance selector offers Catppuccin Mocha, Macchiato, Frappé, and Latte themes. 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 network, capacity, deployment, and recovery evidence.** External audits are not planned; the operator accepts the residual risk, and the candidate must not be described as audited or certified. 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. + +The mainnet profile is the default and has a deployment-pinned network/genesis identity, separate ledger directory, chain-ID-scoped browser wallet, and chain-bound backups. The alternate [Compose definition](compose.mainnet.yaml) is also explicitly mainnet. Testnet is available only through the dedicated development/test harness. **No production network has been launched or certified by this work.** Startup requires explicit chain-specific approval, explicit unaudited-risk acceptance, the chosen genesis time in the built image, HTTPS origin, and multiple configured public peers. See [the mainnet 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 @@ -87,6 +139,8 @@ 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 @@ -97,9 +151,13 @@ This mines three real blocks using disposable identities. It changes the testnet ## Network and safety limits -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 fees or economic security, and remains vulnerable to resource exhaustion, isolation/eclipsing, censorship, majority-hashpower attacks, and reorganizations. Storage limits are stop limits rather than a scalable production design. HTTPS, container hardening, and passing tests do not constitute a security audit. +[Deployment and recovery preparation](docs/operations-recovery.md) includes explicit trusted-proxy configuration, opt-in loopback-only diagnostics and `ops:check`, plus stopped-node `node:backup` create/verify/new-directory restore tooling. New backups contain the ledger only; creation refuses to proceed if legacy financial journals or bindings remain, so archive those separately before retiring old deployments. Tools do not configure TLS/alerts or authorize production launch/restore. -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. +[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 established economic security and remains vulnerable to resource exhaustion, isolation/eclipsing, censorship, majority-hashpower attacks, and reorganizations. The ledger now uses transactional SQLite/WAL storage and sparse per-block NFT-state deltas, but nodes still retain DAG block records in memory, rebuild state roots over the full NFT set, and revalidate all proofs at startup. It is not yet an archival-scale production design. HTTPS, container hardening, and passing tests do not constitute a security audit. + +Before considering any broader deployment, complete adversarial network testing, hardened abuse controls, and recovery procedures. External protocol/cryptography audits are not planned; this leaves accepted residual risk. Do not expose a public node or use valuable assets on this experimental network. ## Attribution diff --git a/compose.mainnet.yaml b/compose.mainnet.yaml new file mode 100755 index 0000000..498476f --- /dev/null +++ b/compose.mainnet.yaml @@ -0,0 +1,39 @@ +# Gated candidate profile, NOT an announcement of a production-ready or launched network. +# Review docs/mainnet-launch.md before attempting activation. +services: + mainnet: + build: + context: . + target: runtime + args: + WIKIDECK_NETWORK: mainnet + WIKIDECK_GENESIS_TIME: "${WIKIDECK_GENESIS_TIME:-1793491200}" + ports: + - "127.0.0.1:${WIKIDECK_MAINNET_PORT:-3201}:3001" + environment: + WIKIDECK_NETWORK: mainnet + WIKIDECK_GENESIS_TIME: "${WIKIDECK_GENESIS_TIME:-1793491200}" + PUBLIC_ORIGIN: "${WIKIDECK_MAINNET_ORIGIN:-}" + WIKIDECK_PEERS: "${WIKIDECK_MAINNET_PEERS:-}" + WIKIDECK_MAINNET_LAUNCH: "${WIKIDECK_MAINNET_LAUNCH:-}" + WIKIDECK_MAINNET_RISK_ACK: "${WIKIDECK_MAINNET_RISK_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. + 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: diff --git a/compose.testnet.yaml b/compose.testnet.yaml index e67f38b..468dd7c 100644 --- a/compose.testnet.yaml +++ b/compose.testnet.yaml @@ -1,10 +1,16 @@ # Three independent validators, separate volumes, no miner auto-starts. +x-node-environment: &node-environment + WIKIDECK_NETWORK: testnet services: node-a: image: wikideck:testnet - build: { context: ., target: runtime } + build: + context: . + target: runtime + args: { WIKIDECK_NETWORK: testnet, WIKIDECK_GENESIS_TIME: "1792411200" } ports: ["127.0.0.1:3101:3001"] environment: + <<: *node-environment PUBLIC_ORIGIN: http://127.0.0.1:3101 WIKIDECK_PEERS: http://node-b:3001,http://node-c:3001 WIKIDECK_ALLOW_PRIVATE_PEERS: "1" @@ -20,9 +26,13 @@ services: stop_grace_period: 30s node-b: image: wikideck:testnet - build: { context: ., target: runtime } + build: + context: . + target: runtime + args: { WIKIDECK_NETWORK: testnet, WIKIDECK_GENESIS_TIME: "1792411200" } ports: ["127.0.0.1:3102:3001"] environment: + <<: *node-environment PUBLIC_ORIGIN: http://127.0.0.1:3102 WIKIDECK_PEERS: http://node-a:3001,http://node-c:3001 WIKIDECK_ALLOW_PRIVATE_PEERS: "1" @@ -38,9 +48,13 @@ services: stop_grace_period: 30s node-c: image: wikideck:testnet - build: { context: ., target: runtime } + build: + context: . + target: runtime + args: { WIKIDECK_NETWORK: testnet, WIKIDECK_GENESIS_TIME: "1792411200" } ports: ["127.0.0.1:3103:3001"] environment: + <<: *node-environment PUBLIC_ORIGIN: http://127.0.0.1:3103 WIKIDECK_PEERS: http://node-a:3001,http://node-b:3001 WIKIDECK_ALLOW_PRIVATE_PEERS: "1" diff --git a/compose.yaml b/compose.yaml index fb004d0..d3cf17b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,17 +3,25 @@ services: build: context: . target: runtime + args: + WIKIDECK_NETWORK: mainnet + WIKIDECK_GENESIS_TIME: "${WIKIDECK_GENESIS_TIME:-1793491200}" ports: - # Keep the prototype private; listen on all interfaces only inside the container. - - "127.0.0.1:${WIKIDECK_PORT:-3001}:3001" + # Loopback only; terminate reviewed HTTPS at the operator's edge. + - "127.0.0.1:${WIKIDECK_MAINNET_PORT:-3201}:3001" environment: - 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_NETWORK: mainnet + WIKIDECK_GENESIS_TIME: "${WIKIDECK_GENESIS_TIME:-1793491200}" + PUBLIC_ORIGIN: "${WIKIDECK_MAINNET_ORIGIN:-}" + WIKIDECK_PEERS: "${WIKIDECK_MAINNET_PEERS:-}" + WIKIDECK_MAINNET_LAUNCH: "${WIKIDECK_MAINNET_LAUNCH:-}" + WIKIDECK_MAINNET_RISK_ACK: "${WIKIDECK_MAINNET_RISK_ACK:-}" + WIKIDECK_ALLOW_PRIVATE_PEERS: "0" + WIKIDECK_TRUSTED_PROXIES: "${WIKIDECK_TRUSTED_PROXIES:-}" volumes: - - wikideck-data:/app/data + - wikideck-mainnet-data:/app/data init: true - restart: unless-stopped + restart: "no" read_only: true tmpfs: - /tmp:size=32m,mode=1777,noexec,nosuid @@ -27,4 +35,4 @@ services: stop_grace_period: 30s volumes: - wikideck-data: + wikideck-mainnet-data: diff --git a/deploy/kubernetes/README.md b/deploy/kubernetes/README.md new file mode 100755 index 0000000..e721500 --- /dev/null +++ b/deploy/kubernetes/README.md @@ -0,0 +1,45 @@ +# Kubernetes mainnet preparation + +This directory intentionally contains no apply-ready manifests yet. The code is mainnet-first and supports a genesis frozen into the browser bundle and runtime image, but the current launch gate requires deployment facts that were not supplied. Placeholder origins, approval IDs, TLS secrets, or storage settings would not produce a working or safe deployment. + +## Required topology for this implementation + +- Build and publish one immutable image with `WIKIDECK_NETWORK=mainnet` and one chosen `WIKIDECK_GENESIS_TIME`. Give every peer and browser bundle that same chain identity. A runtime-only genesis override is rejected by the image manifest check. +- Give each node a stable, unique public HTTPS origin. TLS must terminate at an operator-managed ingress/reverse proxy; the application listener itself is HTTP on port 3001. +- Configure at least two other distinct public HTTPS peer origins per node for public peering. Private IPs/DNS and HTTP are rejected by default. An operator-controlled private candidate cluster may explicitly set `WIKIDECK_ALLOW_PRIVATE_PEERS=1` and use distinct per-node ClusterIP DNS origins. This opt-in does not change the exact chain-ID/risk/genesis gates or the HTTPS browser-origin requirement. +- Route each public peer hostname deterministically to exactly one node. A shared load-balanced hostname is not a stable peer identity. Separate per-peer Deployments/Services/Ingress routes are the simplest current shape; each node must exclude its own origin from its peer list. +- Give every node an exclusive persistent volume mounted at `/app/data`. Never share a ledger volume between pods. Agree the storage class, capacity, backup/restore procedure, resource limits, failure domains, and upgrade/reorg policy before enabling the launch approval. +- Configure the exact manifest `CHAIN_ID` as `WIKIDECK_MAINNET_LAUNCH`, and explicitly set `WIKIDECK_MAINNET_RISK_ACK=operator-accepts-unaudited-risk`. The latter accepts unaudited residual risk; it is not an audit or readiness check. + +## Pod hardening baseline + +Use a dedicated namespace and least-privilege service account (or disable token automount if no Kubernetes API access is needed), non-root execution matching the image's `node` user, read-only root filesystem, dropped Linux capabilities, `allowPrivilegeEscalation: false`, `seccompProfile: RuntimeDefault`, and a bounded writable `/tmp` volume. Use resource requests and limits based on measured RandomX/replay/sync behavior, not guesses. Readiness/liveness should use `/healthz`; allow a startup grace period for ledger replay and native engine initialization. Restrict ingress to the reviewed edge and egress to DNS, the configured public peers, and required Wikimedia endpoints. Add NetworkPolicy, TLS, image provenance/signature enforcement, monitoring, and tested encrypted backups through the cluster's established platform controls. + +Do not make the public origin or configured peers imply that peers are independent or that the network is secure. This software has no external audit, public bootstrap service, peer discovery, or demonstrated production capacity. + +## Private candidate cluster + +Deploy the public node and two internal peers as three separate single-replica +role instances, each with its own Service and PVC. Configure each peer list with +the other two Services, for example `http://wikideck-peer-a.wikideck.svc.cluster.local:80`. +Allow same-namespace validator pods on target TCP 3001 through both ingress and +egress NetworkPolicy; Service port 80 is translated to that target port. Only the +public node needs ingress; all nodes may use the public node's HTTPS browser +origin. Keep API credentials and wallet keys out of validator pods. + +Read `--network-manifest` from the exact published image to obtain the candidate +chain ID without opening storage or starting a listener. Its +`privatePeersSupported: true` capability distinguishes updated images from older +images that reject this topology. Do not override genesis at runtime. The default +image genesis is now fixed at November 1, 2026, 00:00 UTC (`1793491200`), so no +January wait or custom build argument is needed. Rebuild and publish the updated +image; an older published image still contains its previous genesis. Existing +ledgers and wallets must retain their original chain identity. + +Private peer RPC is unauthenticated and, with HTTP, unencrypted. Use a trusted +cluster boundary and scoped policies; add mesh transport protection if needed. +This topology does not demonstrate independent peers/miners or production readiness. + +## Values needed before manifests can be finalized + +Choose the genesis timestamp and image registry/digest; provide the namespace, ingress class and TLS/certificate mechanism, public DNS name per node, peer topology, storage class/size, resource budget, network-policy egress model, backup destination/retention, and desired availability/recovery behavior. Then calculate and publish the exact manifest and chain approval. Until those values and the peer topology are available, this checklist is the safe preparation artifact rather than a misleading runnable YAML template. diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example new file mode 100755 index 0000000..2c5220e --- /dev/null +++ b/deploy/nginx.conf.example @@ -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; + } +} diff --git a/docs/canonical-indexes.md b/docs/canonical-indexes.md new file mode 100755 index 0000000..c8cb727 --- /dev/null +++ b/docs/canonical-indexes.md @@ -0,0 +1,17 @@ +# Canonical ledger indexes + +The ledger maintains SQLite indexes for canonical NFT ownership/sequence, mint ordering, block identity, and transaction inclusion. The UI uses these indexes for wallet decks, NFT metadata, and current-chain pagination; they do not change consensus rules or replace proof validation. + +## Persistence and reorganization behavior + +Accepted blocks, pending transactions, index connect/disconnect updates, undo records, and canonical event history commit in one SQLite transaction. Losing side branches and mempool submissions do not create canonical ownership events. On a reorganization, disconnects apply tip-first and connects ancestor-first. A failed write leaves both the stored ledger and in-memory tip unchanged. + +On initial open, stored blocks are replayed and validated before the local index is built or checked. Later opens verify indexed block identity, ownership, transaction inclusion, and event continuity against that validated chain. Inconsistent state fails closed; no automatic reset or history pruning occurs. These local indexes and event records are not consensus data or a public proof that a node observed an honest network. + +Wallet-deck results are paged and can be pinned to a canonical tip; stale tips return HTTP 409. Canonical block pages are bounded. Direct trades continue to be validated as complete atomic transactions against the canonical ownership state. No market journal, reconciliation worker, payment observer, or NFT sale-lock signer is part of this design. + +## Validation and capacity limits + +The regression suite covers indexed ownership and sequence against replay for transfers, atomic multi-card trades and forks; append/disconnect/reconnect ordering; side-branch and mempool exclusion; durable restart; failed writes; and stale-tip pagination. + +The [canonical-index benchmark](../scripts/benchmark-canonical-index.ts), available as `npm run benchmark:index -- 10000`, creates and removes its own temporary SQL fixture. Its synthetic results are a baseline only—not production acceptance limits. Real PoW validation, startup replay, full DAG memory, concurrent HTTP load, and the intended deployment hardware must be measured independently. Indexes improve lookups but do not solve the retained-DAG and full-state costs described in the [consensus reference](testnet-consensus.md) and [production plan](card-only-production-plan.md). diff --git a/docs/card-only-production-plan.md b/docs/card-only-production-plan.md new file mode 100755 index 0000000..571f193 --- /dev/null +++ b/docs/card-only-production-plan.md @@ -0,0 +1,113 @@ +# Card-only production readiness plan + +**Status:** proposed readiness plan, updated 2026-10-28. This is not launch approval, security certification, or permission to expose a public node. + +## Product and release boundary + +Wikideck's current product scope is **RandomX mining, collecting cards, and direct player-to-player card transfers/trades**. This release will not implement or offer a marketplace, auctions, sales, custody, XMR deposits/withdrawals, or real-money checkout. Direct card trades are signed and applied atomically by the chain; they are not paid orders. + +The nearest reasonable release target is a **publicly hosted, non-monetary testnet beta**. It must be labeled experimental and valueless. It is not a mainnet launch, a promise of ownership permanence, an investment product, or an independently secured network. Mining consumes compute, electricity, and time; no reward rate or profitability is promised. + +Mainnet or valuable-card use is a separate, higher bar. It remains **NO-GO** until consensus, wallet, network, capacity, deployment, and operator risk-acceptance gates below have evidence and named sign-off. The operator has chosen not to commission external audits; this is a conscious acceptance of additional residual risk, not evidence that the protocol or implementation is secure. + +## Current starting point + +- Mining, local encrypted wallets, collections, transfers, and atomic card-for-card trades are implemented for the experimental testnet. +- Marketplace, custody, sale-settlement, and Monero payment source, UI, APIs, deployment profiles, active tests, and operator documentation have been removed from the supported repository. Direct on-chain card transfers/trades remain core functionality. +- Existing runtime volumes and historical backup packages may contain local financial journals or external database bindings. Removing source code does not delete or resolve those records; preserve them separately and do not claim new ledger-only backups include them. +- The current network has no public bootnodes or automatic peer discovery. Peers must be configured by operators; containers on one host do not provide independent network security. +- The inherited initial proof-of-work target accepts one in sixteen hashes, and the ten-block retarget is not production-calibrated. Nodes retain the DAG in memory, rebuild state roots across the NFT set, and revalidate stored proofs at startup. +- Browser-wallet encryption is implemented, but same-origin code can abuse an unlocked wallet; old plaintext copies and cross-browser recovery remain concerns. + +See the [mainnet launch limitations](mainnet-launch.md), [consensus reference](testnet-consensus.md), [wallet limitations](wallet-security.md), and [security review](security-review.md). Existing passing tests and local rehearsals are useful evidence, not production certification. + +## Ordered release gates + +### Gate 0 — freeze the card-only scope · implemented + +- Publish the scope above in the product and operator docs. Market/custody/payment source, UI, APIs, dependencies, tests, deployment files, and operator tasks are removed from the supported repository. +- Preserve existing node and financial data during retirement. Do not remove Compose volumes, old journals, PostgreSQL schemas, or historical backups. Review/archive any existing market journal before stopping a market deployment; the card-only node will not reconcile or resolve it. +- Version the node backup contract for ledger-only backups. Refuse to create a ledger-only package if legacy financial data or a PostgreSQL custody binding remains in the selected data directory. Keep old paired backup verification/restore available as a data-preservation path; restored legacy files are inert and are not reopened by the card-only application. +- Update the production plan, operator launch checklist, security scope, support text, and release notes so they no longer imply that market, custody, or payment work is part of this release. +- Choose and document the only supported launch target: testnet beta or a separately reviewed mainnet. Do not mix the two identities or reuse testnet data/backups as mainnet state. + +**Exit evidence:** scope owner approval is still required. Source/deployment removals are complete; API regression checks cover removed routes while preserving card trades; backup v2 and legacy-data refusal are covered; old paired backups remain portable. No active product docs promise sales or payments. + +### Gate 1 — internal protocol and application review · complete by operator decision + +**Operator decision (2026-10-28):** no external audit or independent review will be commissioned for this release. The product will proceed using internal code review, regression tests, local adversarial exercises, and explicit operator risk acceptance. This does not certify the software or establish that undiscovered critical defects are absent. + +Internal source-assisted review covered consensus serialization/validation, signatures, state commitments, fork choice, timestamps/retarget, peer URL/DNS/pagination/response handling, RandomX native boundary, wallet signing/recovery documentation and tests. + +Internal findings and dispositions: + +- **Clock rollback could strand accepted SQLite history.** Startup replay applied the live future-skew admission bound to every persisted block. A block accepted at the allowed future edge could fail replay if the clock later moved backward. Startup now replays previously accepted SQLite history without reapplying that live-time bound. New blocks imported from legacy JSON retain a pinned import-time clock and imported-block cutoff; their future timestamps are checked on import and on later replays, so an untrusted legacy file cannot seed arbitrarily future-dated history. Regression tests cover both rollback recovery and repeated rejection of future-dated legacy data. +- **Invalid work reached full NFT-state materialization.** Block validation computed the state transition/root before checking the claimed and actual RandomX proof. Cheap commitment/signature/claimed-target checks now precede native proof verification, and the complete state transition/root is computed only after the native proof matches. A regression uses an instrumented state to assert that a wrong claimed proof is rejected before NFT-state enumeration. +- Added a deterministic 30-block versus 16-block competing-fork regression: equal cumulative work keeps the current tip, and a strictly higher-work branch then causes a 30-block reorganization while preserving both DAG branches. This is a local FakePow consensus-state test, not a multi-host security rehearsal. + +Current validation evidence: the Docker test image and TypeScript/Vite build succeeded; the complete offline suite passed **95/95** with the native RandomX HTTP integration; the packaged native engine's upstream known-vector self-test passed. Tests cover signatures/transaction replay, atomic trades, commitments, retarget/key boundaries, equal-work fork choice, reorg/requeue, malformed schemas, peer DNS/manifest/pagination/response rejection, mempool and RPC bounds, wallet backup crypto, and restart/revalidation. These checks are the selected assurance approach; they are not a security certification. + +**Residual risks carried forward:** no external auditor or independent reviewer will sign off. The candidate is not frozen. Multi-host eclipse/partition and clock-skew rehearsals, hostile populated-state load/reorg tests, and broad browser-wallet/XSS/recovery exercises remain open engineering work for Gates 2–5. The operator accepts proceeding without external audit; do not describe the result as audited or independently certified. + +**Exit evidence:** internal findings fixed with regression coverage; current build, offline suite and native known-vector results recorded; operator accepts the unaudited-risk boundary. Gate 1 is **complete under this explicitly reduced scope**. This completion is not external assurance, and its remaining multi-host/load/wallet exercises continue under the following gates. + +### Gate 2 — establish a credible network · in progress + +- **Mainnet candidate inspection (2026-10-28, read-only):** `wikideck-randomx-mainnet-1`, candidate chain ID `3ef36901f49d57127ebb0bf43625c7334e9b8ef817fd31953761723b5474ef6c`, genesis `72be7cdac8e625562bc8a49a82a406f41b96a6fde285c9961dceafdbaca8cd1d`, candidate genesis time 2027-01-01 00:00 UTC. The genesis time has not arrived; this is a candidate manifest, not an announced launch. No production node was started and no approval or data volume was created. +- **Repeat benchmark (2026-10-28):** same executable and container limits, 32 hashes measured 25.11 H/s warm (p50 39.13 ms, p95 48.18 ms), 883 ms cold initialization, and 642 ms for a key/cache switch. These two short samples are not a confidence interval and do not establish normal host variability. Both are verification-path hashes, not miner throughput, dedicated-host measurements, aggregate network hashpower, or a security budget. Do not extrapolate them to choose consensus parameters. +- The first bounded sample in the same container was 23.35 H/s warm (p50 41.09 ms, p95 55.81 ms), 796 ms cold initialization, and 594 ms for a key/cache switch. Together these two observations show run-to-run variation only; they are not intended-host miner measurements. +- **Bootstrap policy for the first beta:** use a fixed, operator-curated HTTPS peer allowlist supplied as `WIKIDECK_PEERS`; do not add DHT, automatic discovery, or peers learned from untrusted network messages. Treat configured peers as trust/bootstrap contacts only; each node independently validates every block and transaction. The current protocol provides no peer identity or independent-operation proof beyond the configured URL and TLS connection. +- **Local rehearsal status:** the complete offline suite's native RandomX HTTP integration syncs two separately started node processes over loopback, gossips a mined transfer, and verifies restart/replay. This is local functional evidence only. A disposable three-node Compose attempt on 2026-10-28 could not allocate a Docker bridge subnet because the configured address pools were exhausted; Compose failed before creating services or volumes. No multi-host/failure-domain rehearsal has been performed. +- **Mainnet peer configuration remains empty/unverified:** the repository has no operator-supplied public HTTPS origins, ownership/failure-domain evidence, or exercised TLS endpoints for the two required mainnet peers. The compose profile only forwards operator-provided peers and origin; placeholders are not launch infrastructure. +- For a multi-node rehearsal, use at least three separately administered nodes across distinct failure domains and network providers; record operator, host/region/provider, address/origin, chain ID, software digest, availability window, and which node supplies each bootstrap connection. Three containers or aliases on one host do not count as independent. +- Measure expected and adversarial hashpower on intended hardware; quantify rewrite costs and concentration risk. Decide whether this experimental beta retains the current easy target or whether a future mainnet identity needs different initial difficulty, retarget, timestamp, and stall-recovery rules. Do not alter a launched chain's consensus. +- Rehearse bootstrap with an empty node, peer loss/recovery, partition/rejoin, conflicting branches, stale/malicious configured peer, and independent mining. Record the observed tips, cumulative work, sync lag, and recovery time. Peer URL count alone is not evidence that peers are online or independent. +- State the finality/reorganization limits honestly. There is no formal finality today; a confirmation count is not an absolute guarantee. + +**Exit evidence:** explicit testnet-only beta decision (or separately approved mainnet candidate), documented bootstrap peer allowlist and ownership/failure domains, repeated intended-hardware hashrate data with variance, multi-host partition/reorg/stall rehearsal, a written risk/attack-cost estimate, and an operator-owned peer/upgrade/incident plan. Internal sign-off only; no external audit is planned. + +### Gate 3 — demonstrate capacity and recovery limits + +- Define the expected chain length, block rate, peer count, user/API load, disk budget, memory ceiling, startup time, and latency/error objectives. +- Run repeated tests on populated long-chain fixtures and the intended hardware, with concurrent mining, peer sync, wallet/deck reads, transfers/trades, reorgs, and hostile traffic. Measure tail latency, memory, CPU, disk/WAL growth, and restart/revalidation time. +- Test clean shutdown, process crash, disk pressure, corrupted/stale state, isolated restore, and replay using the exact release binary. Do not treat `/healthz` alone as readiness. +- Define and test an archival, pruning, or capacity transition before resource growth can make nodes unable to sync or restart. Do not alter consensus history silently to reclaim storage. + +**Exit evidence:** reproducible benchmark report with workload and limits, enforced resource budgets, failure/reorg results, and a successful isolated restore/replay drill. + +### Gate 4 — wallet, trades, and user protection + +- Test create, lock, unlock, backup, restore in a fresh browser/profile, and recovery after app/browser upgrade across the supported browser matrix. Test signed transfer/trade review, stale offers, pending state, and reorg outcomes. +- Perform an internal threat review of same-origin script compromise, extensions, password entry, old plaintext-wallet migration, downloaded backup handling, and key-loss support. Decide whether the beta accepts browser-only signing; document residual risks rather than implying external wallet assurance. +- Publish experimental/no-value disclosures, mining resource risks, irreversible-transfer warnings, privacy/data-retention terms, Wikipedia text/image attribution and license handling, support contact, abuse-report path, and stop conditions. +- Select and publish an appropriate project software license before public distribution. A Wikideck license does not replace Wikipedia or dependency licenses. + +**Exit evidence:** internal wallet threat review, verified recovery drill, tested trade flows, reviewed participant disclosures, public support/abuse contacts, and license decision. + +### Gate 5 — operate and release safely + +- Prefer a managed host unless the intended self-hosted site proves ISP permission, reachable ingress/CGNAT handling, DNS/TLS renewal, trusted-proxy correctness, no direct backend exposure, firewalling, restricted administration, patching, and power/network resilience. +- Deploy independent monitoring and alert delivery for peer/tip freshness, reorgs, native miner failures, latency/queue saturation, disk/WAL growth, and service availability. Assign a real responder and incident/rollback procedure; do not imply HA or 24/7 response without it. +- Encrypt backups off-host and rehearse cold restore. Define and meet recovery point/time objectives; protect wallet recovery separately from node data. Preserve database/WAL consistency and verify restored proofs/ownership before resuming service. +- Freeze the release artifact: reviewed base-image digest, dependency/native versions, SBOM, vulnerability and native-code scans, build provenance/signature, configuration manifest, and rollback image. Re-run the complete suite and smoke test against that exact artifact. +- Run a limited, non-monetary soak and record go/no-go, known risks, release hashes, operator, reviewer, and stop criteria. Do not convert a beta into mainnet by changing an environment variable or changing the chain identity after launch. + +**Exit evidence:** intended-host deployment test, alert drill, off-host restore result with measured RPO/RTO, signed/frozen artifacts, exact-release test report, and recorded operator/reviewer approval for the beta's limited exposure. + +## Release decisions + +### Non-monetary public beta + +May be considered only after all applicable gates pass, the node remains on the explicitly labeled experimental testnet, cards are not represented as valuable, and the operator accepts the single-host or stated availability limits. This is a production-hosted beta, not production chain security. + +### Mainnet or valuable cards + +Requires all gates plus measured and operator-accepted consensus/economic risks, calibrated and frozen consensus parameters, a credible peer/miner topology, reviewed wallet protections, sustained capacity and adversarial evidence, and explicit governance/upgrade and incident procedures. External audit is intentionally not a requirement under the operator's decision, so residual risk remains higher and must be explicitly accepted before any mainnet activation. Until then, retain **NO-GO**. + +### Markets and real money + +Out of scope for this product plan. No market/custody/payment milestone is a prerequisite to the card-only beta. Any later decision to add sales or funds requires a new product scope, threat model, legal review, settlement/custody design, and separately approved release plan; it must not be inferred from this document. + +## Evidence record + +For every gate, record an owner, exact release/manifest, test or rehearsal, measured result, artifact location/hash, reviewer, residual risk, and disposition. Missing evidence is **unverified**, not passed. A successful build, local container startup, peer count, test total, or environment flag is never launch approval by itself. diff --git a/docs/consensus-calibration.md b/docs/consensus-calibration.md new file mode 100755 index 0000000..ca1e403 --- /dev/null +++ b/docs/consensus-calibration.md @@ -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 or an operational network. Consensus, initial target, genesis, and chain identity 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** a guarantee that a block is final: attacker progress while a block accumulates confirmations is not modeled. There is no network latency, eclipse, selfish mining, timestamp manipulation, adaptive difficulty, economic cost or attack detection model. + +## Local native measurement (2026-10-20) + +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 the selected rules and actual genesis date with a new identity where required. Publish the manifest/artifact hashes and record internal findings plus explicit operator risk acceptance. Do not patch an active chain's rules in place. +- Document reorganization and confirmation limitations honestly; no finite confirmation count is absolute finality on this unbootstrapped PoW chain. + +See [security review](security-review.md), [launch evidence register](operator-launch.md) and the read-only preflight. The calibration blocker remains open. diff --git a/docs/mainnet-launch.md b/docs/mainnet-launch.md new file mode 100755 index 0000000..ee88952 --- /dev/null +++ b/docs/mainnet-launch.md @@ -0,0 +1,57 @@ +# Mainnet-first, gated candidate + +The repository defaults to a **mainnet candidate**, not an audited or launched production network. Shipping code does not establish independent miners, economic security, or an operational public network. Market and payment features have been removed from the card-only product. + +**Current production decision: NO-GO.** See the [2026-10-20 security review](security-review.md) and [operator evidence register and launch procedure](operator-launch.md). The read-only `launch:preflight` package script (or node entry-point `--launch-preflight`) reports local gates and unresolved blockers without starting a node or setting approvals. It deliberately exits nonzero while production readiness is blocked. + +## What is implemented + +| Property | Mainnet-first default | Explicit development fixture | +| --- | --- | --- | +| Selection | `WIKIDECK_NETWORK=mainnet` (or unset) | `WIKIDECK_NETWORK=testnet` | +| Network ID | `wikideck-randomx-mainnet-1` | `wikideck-randomx-testnet-6` | +| Protocol | Version 6 | Existing fixed local testnet version 6 | +| Genesis timestamp | Fixed launch default: 2026-11-01 00:00 UTC (`1793491200`); ready to start now | Fixed at 2026-10-19 00:00 UTC | +| Ledger directory | mainnet | testnet | +| Browser wallet | IndexedDB name derived from exact chain ID | Separate chain identity | +| New backup | v2 header authenticates exact chain ID | Same v2 format; legacy v1 is accepted only on the original testnet | + +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. + +**Cards and backups do not migrate across chain identities.** Browser storage and backup headers are bound to the exact chain ID; peers reject mismatched manifests, and transactions are checked against the active chain. The selected profile and genesis are pinned at startup/browser build, never silently adopted from an RPC response. The original fixed-testnet v1 backups remain supported only on that testnet. + +## 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. `WIKIDECK_GENESIS_TIME` is also part of the chain identity. Docker's network/genesis **build arguments** pin the browser and immutable image manifest; a runtime variable cannot alter an already compiled UI. To choose another genesis, set it for the image build and runtime together, publish the resulting manifest, then use that immutable image and exact timestamp for every node and browser. A runtime-only genesis override fails closed. A mismatched UI rejects the node rather than signing for it. + +## Explicit launch gates + +[compose.yaml](../compose.yaml) and [compose.mainnet.yaml](../compose.mainnet.yaml) are mainnet-first definitions, use separate named volumes, bind only loopback for an external HTTPS reverse proxy, and do **not** auto-restart a failed candidate. They have no default peers, approval, or public origin. The default service therefore exits until the operator supplies real deployment values. The testnet Docker fixture is separate and explicit. **This code change does not establish or certify a public mainnet.** + +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_RISK_ACK=operator-accepts-unaudited-risk` records the operator's explicit acceptance that no external audit is planned and residual risks remain. This is not a safety certification; the manifest honestly reports `audited: false`. +3. The candidate genesis timestamp has arrived. If the operator 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 before launch. +4. An HTTPS `PUBLIC_ORIGIN` and at least two distinct configured peer origins. Public deployments require public HTTPS peers by default. An operator-controlled private candidate cluster may explicitly set `WIKIDECK_ALLOW_PRIVATE_PEERS=1` to permit private/HTTP peer RPC; this does not relax browser HTTPS, chain approval, risk acknowledgment, or genesis-time gates. Configuration count cannot prove the peers are online, independent, or sufficiently distributed; verify that operationally. Existing public peer DNS/origin controls still apply when the private opt-in is off, and HTTPS certificate verification is never disabled. + +For Kubernetes public peering, give every node a stable, unique public HTTPS endpoint. For an explicitly private candidate, separate single-replica Deployments, Services and exclusive PVCs may use distinct ClusterIP DNS origins with the private opt-in. Each validator lists the other two internal Services, never itself. The browser-facing node needs a TLS ingress/reverse proxy; internal-only validators may share that HTTPS browser origin without their own ingress. NetworkPolicy must restrict peer RPC to the selected validator pods on target TCP port 3001. Private HTTP RPC is unauthenticated and unencrypted unless protected by an independently configured mesh; do not use it across untrusted networks. Three validators in one cluster/storage failure domain are not independent operators or proof of production readiness. + +See the [Kubernetes mainnet preparation checklist](../deploy/kubernetes/README.md) for the deployment contract and the values needed before manifests can be finalized. +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 and operator risk acceptance, not a security boundary against someone who edits the source. + +## Required before an actual production launch + +- **Internal readiness review:** track code findings and regression evidence in-repository. No external audit is planned; the operator accepts the additional risk and must not describe the candidate as audited or certified. +- **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 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 identity/configuration, publish binaries and configuration hashes, agree peer/miner readiness and upgrade policy, and explicitly record the operator's risk acceptance. Do not treat the flag as proof of safety or peer readiness. +- **User and legal review:** no monetary value or security is promised. NFT rights do not imply article/image copyright ownership. Publish experimental-use, privacy, support, and abuse-report terms appropriate to the release. + +## Still not production-ready + +This remains experimental software. Nodes retain the DAG and overlays in memory, rebuild state roots over the full NFT set, and revalidate proofs at startup. The HTTP UI trusts its connected node rather than independently validating the chain. There is no automatic peer discovery, established bootstrap infrastructure, formal finality, independent audit, or production abuse system. Mainnet selection does not remove any of these limitations. See the [card-only production plan](card-only-production-plan.md) for the current readiness gates. diff --git a/docs/node-resources.md b/docs/node-resources.md new file mode 100755 index 0000000..eb91f78 --- /dev/null +++ b/docs/node-resources.md @@ -0,0 +1,45 @@ +# Node resource controls — stage 1 + +This stage bounds public RPC transport/admission and browser state loading without changing consensus, chain identity, or launch approvals. **It does not make the node production-ready.** + +## Admission and transport + +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=&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. + +## 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 and canonical-chain endpoints and checks health, with response/time bounds. It never submits a block or transaction. Use only a disposable isolated node: even read traffic consumes CPU/IO. Output includes status counts, bytes and p50/p95 latency; rejected load is visible, not counted as successful capacity. + +Initial smoke measurements on a disposable empty-chain loopback Docker node are historical local baselines, not performance guarantees. They do not measure hashing load, long-chain costs, or hostile Internet traffic. The deterministic held-response test separately verifies overload shedding and peer/health-lane availability. Final test totals must be rerun against the selected release artifact. + +Neither measurement establishes that a realistic long-chain public deployment meets its latency/throughput SLO. Production-scale chain/journal fixtures, slow clients, adversarial payloads, sustained concurrency, disk pressure and multi-host recovery measurements remain outstanding. No launch-readiness finding is marked resolved by this stage. diff --git a/docs/operations-recovery.md b/docs/operations-recovery.md new file mode 100755 index 0000000..9c665ba --- /dev/null +++ b/docs/operations-recovery.md @@ -0,0 +1,42 @@ +# Deployment, private monitoring, and recovery + +**Production remains NO-GO.** These procedures are operator preparation, not an installed public edge, configured alerts, independent review, or deployment approval. Do not remove persistent node volumes as part of source cleanup. + +## Reverse-proxy trust + +`WIKIDECK_TRUSTED_PROXIES` is empty/untrusted by default. It accepts up to 16 comma-separated literal IPs or bounded CIDRs. Configure only the actual source address of a trusted edge connection. The edge must overwrite forwarded headers, and firewall rules must prevent direct backend access. A trusted-proxy setting does not configure the firewall. + +The [nginx example](../deploy/nginx.conf.example) is not installed or deployment-validated. Replace its reserved hostname and certificate paths, and validate TLS, renewal, forwarded-IP handling, body/connection/time limits, and edge admission controls on the intended host. Container proxy source addresses may differ from host loopback; inspect the actual topology instead of copying a trust list blindly. + +## Private diagnostics + +Set `WIKIDECK_OPS_PORT` to an unused port (1–65535) to enable a separate HTTP listener bound to `127.0.0.1`; leave it unset to disable it. Never publish or reverse-proxy this port. Only `GET /status` is supported. It returns bounded, no-store aggregate JSON; any local process with loopback access can read it. + +Reported diagnostics include node uptime/RSS, event-loop delay, canonical height and tip age, DAG and pending counts, native-engine status, configured/connected peers, RPC budgets, and available disk plus ledger database/WAL sizes. Native subprocess memory is not included in Node RSS. Peer count is not a security score. Health is liveness, not sync readiness or consensus assurance. + +`ops:check -- --port ` reads only literal loopback with bounded time and response size. It emits warnings for stale/empty chain, peer isolation, native-engine failure, queue pressure, event-loop delay, and unavailable/low disk. Thresholds are starting points, not production SLOs. Route alerts to an external system and test delivery; this repository does not install a scheduler or alert destination. + +## Stopped-node ledger backup + +`node:backup` supports `create`, `verify`, and `restore`. New version-2 packages contain only the selected network's ledger database. Stop the node cleanly first; the tool takes the same exclusive network lock as startup, uses SQLite `VACUUM INTO`, checks SQLite integrity and chain metadata, hashes the snapshot, and writes the manifest last. It refuses to overwrite a destination, restore in place, or run when a node lock exists. Restore targets must not exist. + +Example workflow, using the `node:backup` package command with explicit `--source`, `--destination`, and `--consent` as required by the CLI: + +1. Stop the node and confirm all writers to its data directory are stopped. +2. Create the backup in a new directory outside the node data directory. Keep wallet backups separately; this tool does not include browser wallets, secrets, configuration, binaries, or logs. +3. Run `verify` against the backup before copying it off-host. +4. Restore only to a new isolated data directory. The tool reports `proofsRevalidated: false`; reopen with the trusted RandomX implementation and verify the chain before resuming service. + +Backups are not encrypted or authenticated by this tool. Store them in restricted, encrypted off-host storage, protect the manifest separately, and test cold restoration. Hashes detect accidental corruption, not a coherent malicious replacement. Filesystem, power-loss, Windows ACL, and network-share durability need validation on the actual storage platform. + +## Retiring legacy financial data safely + +Removing market/custody source code does **not** delete or resolve data left by an older deployment. The card-only node does not open or reconcile those files. If the selected network directory contains `market.sqlite`, `custody-sandbox.sqlite`, or a `custody-postgres.json` binding, new ledger-only backup creation refuses to proceed rather than silently omit those records. Preserve the existing volume and any PostgreSQL database; arrange an operator-reviewed archive before retiring it. + +Historical version-1 paired backups containing `ledger.sqlite` and `market.sqlite` remain verifiable and restorable as opaque files for preservation. Restoration does not reactivate market services or imply that financial obligations have been resolved. External PostgreSQL data is not contained in a SQLite package. Do not delete old volumes, journals, database credentials, or backups as part of this source cleanup. + +An operator must choose retention, recovery point/time objectives, off-host storage, and restore ownership. Never remove a lock or replace a running volume just to make recovery succeed. + +## Remaining operational evidence + +Verify the intended HTTPS/proxy/firewall topology, peer and tip freshness, alert delivery, disk growth, backup encryption/retention, and restore/replay time on the actual host. Assign an incident responder and document stop/rollback criteria. See the [card-only production plan](card-only-production-plan.md), [operator launch register](operator-launch.md), and [security review](security-review.md). No startup or payment approval is granted by these tools. diff --git a/docs/operator-launch.md b/docs/operator-launch.md new file mode 100755 index 0000000..5357388 --- /dev/null +++ b/docs/operator-launch.md @@ -0,0 +1,60 @@ +# Operator launch preparation and hold points + +The [card-only production readiness plan](card-only-production-plan.md) defines the product scope and release gates. The supported product is mining, collecting, and direct card-for-card trades. Market, custody, and payment source has been removed. **Current decision: HOLD / NO-GO for mainnet, valuable assets, or public exposure until the relevant evidence below is reviewed.** + +A successful build, local container startup, peer URL count, genesis timestamp, or configured approval flag is not proof of network security. The read-only launch preflight reports local blockers; it does not certify the release. The operator has chosen not to commission external audits and accepts the corresponding residual risk. + +## Safe inspection + +Use Node.js 24 and a clean, reviewed checkout. For mainnet assessment, select `WIKIDECK_NETWORK=mainnet` and run `launch:preflight`. Use `--network-manifest` for chain identity inspection. These commands do not start a node, create a ledger, start RandomX, or contact peers. + +Mainnet startup is gated by a chain-specific approval, explicit unaudited-risk acceptance, the candidate genesis time, HTTPS origin, and multiple configured HTTPS peers. These are accident-prevention checks only; they do not prove peers are online/independent or that a network is secure. Keep deployment and release permissions restricted. + +## Evidence required before a public non-monetary testnet beta + +A beta is not mainnet production, valuable-asset use, or a claim of independent network security. Keep testnet explicitly selected, cards valueless, and the deployment described as experimental. Before public exposure: + +- Freeze the card-only scope, testnet identity, known risks, privacy and data-retention terms, user support/contact, abuse-report path, and stop conditions. +- Prefer managed hosting unless the intended host proves ISP permission/reachability, TLS renewal, trusted-proxy correctness, no direct backend exposure, firewall and admin controls, patching, and power/network resilience. +- Run repeated load, mining, peer-sync, reorganization, crash/restart, and restore tests on the exact release candidate and intended hardware. Set measurable latency, error, memory, CPU, disk, and recovery limits. +- Review wallet migration, lock/unlock, signing, backup, and fresh-profile recovery. Explain same-origin compromise and irreversible-transfer risks. +- Deploy external monitoring and verify alert delivery for peer/tip freshness, reorgs, native failures, overload, disk growth, and service availability. Name a responder and test incident/rollback procedures. +- Encrypt backups off-host and rehearse a cold restore/replay. Define recovery objectives and keep wallet backups separate from node backups. +- Freeze image/dependency/native versions, generate an SBOM, scan the final artifacts, and record exact release hashes and a beta go/no-go. Publish a project license before public distribution. + +If these conditions cannot be verified on a self-hosted site, do not expose it publicly; use a managed host or remain local-only. + +## Mainnet and valuable-card blockers + +Mainnet or valuable-card use requires the beta evidence plus internally reviewed and measured consensus parameters, adversarial multi-host partition/reorg/hashpower testing, a credible bootstrap and independent-peer/miner plan, production capacity evidence, and explicit governance/upgrade and incident procedures. External audit is not planned; the operator must record acceptance of this extra risk. The current initial target is deliberately easy and the retarget is not production-calibrated. Nodes retain the DAG in memory and replay proofs at startup. There is no formal finality or automatic peer discovery. Keep mainnet **NO-GO** until the remaining network and operational evidence is recorded and the operator explicitly accepts the residual risks. + +## Hold-point register + +Record owner, exact release/manifest, evidence location and hash, test/rehearsal, reviewer, residual risk, and disposition. Empty or unverified means **not passed**. + +| Hold point | Required evidence | Current status | +| --- | --- | --- | +| Internal protocol/application review | Findings documented against the release; fixes have regression coverage; operator records unaudited-risk acceptance | Internal review and regression tests recorded; no external audit planned | +| Consensus launch design | Final manifest; measured starting target/hashpower; timestamp, retarget, partition, and reorg analysis | Missing | +| Node availability | Realistic long-chain and hostile-traffic capacity report with explicit limits | Missing | +| Production wallet | Same-origin threat model, user confirmation, supported-browser lifecycle and offline recovery drill | Unverified | +| Peer/miner topology | Independently administered hosts/failure domains, verified online peers, and sufficient independent hashpower | Unverified | +| Edge and host security | TLS/certificate renewal, firewall, backend isolation, proxy trust, patching and restricted admin | Unverified | +| Monitoring/on-call | External alert routes and successful drills for isolation, reorg, native failure, overload and disk pressure | Unverified | +| Restore/reconciliation | Encrypted off-host ledger backup, isolated restore and proof replay; separate wallet recovery | Unverified | +| Release supply chain | Frozen image digest, SBOM, dependency/native/container scans, provenance, and restricted release access | Partial; repeat for final artifact | +| Incident/governance | Named decision owners, upgrade/fork policy, communications, rollback and stop criteria | Missing | + +## Freeze and authorize + +1. Fix known critical code findings in a new release and add regression coverage; do not change consensus rules under a launched chain ID. This is internal engineering, not external audit. +2. Freeze the reviewed genesis/manifest, exact build/runtime profile, image digest, peer origins, and artifact hashes. +3. Rehearse initial sync, independent mining, partitions/reconnects, invalid traffic, disk pressure, restart, and isolated full restore. Do not delete production data or weaken rules to pass a drill. +4. Verify DNS/TLS, proxy forwarded-IP handling, no direct backend access, permissions, resource capacity, and monitoring on intended hosts. `/healthz` is liveness only. +5. Record a go/no-go with operator name, evidence, known residual risks, change window, recovery objectives, and stop conditions in the controlled operations system. + +Do not set the risk acknowledgement merely to bypass gates. It records acceptance of known risks; it does not attest that an audit occurred or make the system secure. + +## Stop conditions + +Treat deep reorgs, isolation, divergent manifests, sustained overload, disk exhaustion, native-engine failure, key compromise, or loss of monitoring as incidents. Stop accepting new exposure using the approved procedure. Stopping a node does not undo confirmed transactions. Preserve logs and backups; never delete the ledger, shorten history, or change genesis to get a node to start. Remove a stale lock only after verifying all owning processes have exited. Resolve chain conflicts under a reviewed incident policy, not with an ad hoc checkpoint or stale restore. diff --git a/docs/randomx-native.md b/docs/randomx-native.md index af9aa8f..9a023b0 100644 --- a/docs/randomx-native.md +++ b/docs/randomx-native.md @@ -1,8 +1,8 @@ # Native RandomX engine -This is the CPU proof engine used by the Wikideck testnet node and explicit opt-in -native miner. Mining runs only when the operator starts the native CLI; the browser -does not mine or launch native programs. The Docker image builds this pinned +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. diff --git a/docs/security-review.md b/docs/security-review.md new file mode 100755 index 0000000..a4ef6ca --- /dev/null +++ b/docs/security-review.md @@ -0,0 +1,31 @@ +# Security review and production readiness + +## Decision: NO-GO for public production or valuable assets + +This is an internal source-assisted readiness review, not a penetration test, formal consensus proof, or production certification. The operator has chosen not to commission external audits and accepts that residual risk. The card-only product consists of RandomX mining, wallets, collections, transfers, and direct atomic card trades. Market, custody, and payment components have been removed from the supported source tree; no real-money feature is part of this release. + +A limited, non-monetary testnet beta may be considered only after the release-specific operational, wallet, capacity, recovery, and review gates in the [card-only production plan](card-only-production-plan.md) are met. Mainnet or valuable-card use remains **NO-GO**. + +## Remaining production blockers + +| Finding | Risk | Required disposition | +| --- | --- | --- | +| Consensus/economic security | Current initial proof-of-work target is deliberately easy and the ten-block retarget is not production-calibrated. Hashpower concentration, timestamp/retarget manipulation, eclipse, censorship, and reorganization costs have not been independently measured. RandomX use does not inherit Monero's security. | Measure representative hashpower; run multi-host clock, partition, stall, fork, and reorg simulations; document operator-accepted risk and freeze parameters/identity before launch. | +| Node availability and scale | The DAG remains in memory, state-root work visits the NFT set, and startup revalidates stored proofs. Work, RAM, disk, and recovery time grow with history. | Repeated long-chain and hostile-traffic tests on intended hardware; define and validate latency, resource, storage, and recovery limits; establish an archival/capacity strategy. | +| Wallet/key protection | Encrypted local storage helps while locked, but a compromised same-origin page, extension, browser, or OS can abuse an unlocked signer. Old plaintext copies and cross-browser recovery require careful handling. | Independent wallet review; supported-browser migration/lock/sign/backup/restore drills; document residual compromise and key-loss risks. Decide whether isolated/hardware signing is required. | +| Deployment and operations | The real TLS/proxy/firewall topology, peer/miner independence, off-host recovery, alert delivery, and on-call response have not been verified. A liveness response is not readiness. | Verify intended hosts and external edge; test alert routes and incident procedures; encrypt off-host backups and complete an isolated restore/replay with measured recovery objectives. | +| Release supply chain | A successful build or dependency advisory check does not establish artifact integrity or native/container safety. | Freeze base/dependency/native versions and image digest; produce an SBOM, scans, build provenance/signature, and restricted release process for the exact artifact. | +| Governance and user terms | No production operator/reviewer sign-off, public support/abuse path, or published project software license is established. | Name accountable decision makers; publish license, support, privacy, experimental/no-value disclosures, abuse reporting, upgrade policy, and stop conditions. | + +These are readiness gaps, not claims that each item is an exploited vulnerability. Local tests and rehearsals are useful, but they do not establish security or verify the intended production host. + +## Implemented groundwork (not certification) + +- Global RPC admission and response-byte budgets, bounded canonical pages, and client response/history checks reduce some resource-exhaustion exposure; they do not solve retained-DAG growth or prove a production capacity envelope. See [node resource controls](node-resources.md). +- Local wallet vault encryption, explicit legacy migration, session expiry, and lock-aware signing improve storage lifecycle. They cannot protect an unlocked page from same-origin compromise. See [wallet security](wallet-security.md). +- Stopped-node ledger backup/verify/restore and native-proof replay tests provide a local recovery starting point. They are not encrypted/off-host backup service, filesystem power-loss certification, or production RPO/RTO evidence. See [operations and recovery](operations-recovery.md). +- The launch preflight is read-only and intentionally reports NO-GO while evidence is missing. Startup approval flags are accident-prevention gates, not proof of audit or readiness. + +## Review evidence and limits + +The exact release candidate must be rebuilt and fully retested after scope cleanup. No claims are made here about the user's production host, public peer topology, Internet-facing testing, native-code assurance, alert delivery, off-host recovery, or mainnet security. External audits are not planned; retain the NO-GO for mainnet/valuable assets until the remaining network, deployment, and recovery evidence is complete and the operator records informed risk acceptance. A successful test suite or container startup alone is insufficient. diff --git a/docs/testnet-consensus.md b/docs/testnet-consensus.md index 7d4bdf4..736d63c 100644 --- a/docs/testnet-consensus.md +++ b/docs/testnet-consensus.md @@ -17,7 +17,7 @@ implementation-specific, and changing them can create a different chain. ## Network, genesis and article selection -- Network: `wikideck-randomx-testnet-4`, block version **4**. Genesis has no blocks, NFTs or work; first mined height is 1. No privileged issuance/admin mint key. Version 4 has a new chain identity; prior-version ledger files and wallet backups are not compatible. +- 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. @@ -42,47 +42,55 @@ The miner signs `J([NETWORK, 'block-claim', blockID])` using ECDSA P-256/SHA-256 ## Targets, timestamps and epoch boundaries -- Desired spacing: **30 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 × 30 = 270 seconds**. Clamp elapsed to `floor(270/4)` through `270*4`, multiply the parent target by elapsed/270 with integer division, then clamp target to `[1, MAX_TARGET]`. Selection uses work, not height or wall-clock estimates. +- 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 transfers 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. Transfers cannot spend this block's new issuance. +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. -A 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. +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. -`transactionsRoot` commits the **ordered transfer IDs**. `stateRoot` commits the complete **post-transfer, 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. +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. -The mempool holds at most **256** ordered transfers, validating sequentially against the next inclusion height; a template takes the first **64**. The protocol can accept chained transfers of the same NFT in order; the browser conservatively blocks a second pending transfer for a card. 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. +`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 transfers are reconsidered before existing pending entries, in order, only if still valid and not already committed. Conflicting, immature or orphan-issuance transfers are discarded. No checkpoints/finality or rollback protection beyond observed work; an isolated node has no peer-backed security. +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) stores the entire DAG and pending pool in `data/testnet/ledger.json`, under the `WIKIDECK_DATA_DIR` testnet subdirectory. The file is created when the node first writes ledger data. Defaults use project data; Docker uses `/app/data`. Disk caps: **128 MiB, 10,000 total blocks including side branches**; blocks: **64 KiB and 64 transfers**. Each in-memory DAG node stores a full NFT-state snapshot: potentially **quadratic memory growth**, plus full-file rewrite cost. These are experimental stop limits, not scalable storage or a public DoS defense. +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. -Mutations are serialized (queue cap 256); persist via a synchronized temporary file and atomic rename before exposing new state. Restart independently revalidates every saved proof and transaction; malformed, oversized or mismatched files fail closed without automatic reset. 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 stopped-node volumes/files and wallet exports separately; destructive volume reset cannot recover assets from a wallet alone. +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/transfers are gossiped to available 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. +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 transfers, tip/work and peer count. | +| `GET /api/state`, `/api/chain` | Canonical blocks, mined/pending transactions, tip/work and peer count. | +| `GET /api/summary`, `/api/canonical?tip=&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, and candidate launch status. | | `GET /api/nfts/:id`, `/api/peers` | Canonical NFT owner/sequence/confirmations, article and at-mint statistics; configured peer status. | +| `GET /api/decks/:address?offset=0&limit=24` | Public canonical deck for a wallet address at one tip; paged, maximum 24 cards per response. This reveals public address holdings. | | `GET /api/wikipedia/:pageId` | Cached current enrichment for positive English Wikipedia page IDs: categories, views, size, languages and display-only scores. Never a consensus input. | | `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 transfer admission; 202 means pending. | +| `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/transfer submission. | +| `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/transfer POSTs additionally 30/minute. There are no fees, peer authentication or economic spam deterrents. Same-origin browser RPC trusts the node; it does not independently verify these commitments. Deploy no funds: majority-hashpower, eclipse, censorship, reorg and resource-exhaustion risks require independent audits and adversarial testing before any broader launch. +POSTs require JSON, enforce supplied browser origins and signatures where applicable; peers without an Origin header are not authenticated by this check. HTTP parsing is capped at 68 KiB; API/P2P rate limits are 300/minute and block/transaction/trade submissions additionally 30/minute. There are no consensus fees, peer authentication, or economic spam deterrents. Same-origin browser RPC trusts the node; it does not independently verify these commitments. Deploy no valuable assets: majority-hashpower, eclipse, censorship, reorg and resource-exhaustion risks remain and are not externally audited under the operator's current policy. + +Testnet storage and chain identities above remain unchanged. The [gated mainnet candidate](mainnet-launch.md) selects separate chain/signature domains, storage, lock and wallet identities; it is not a running or certified production network. diff --git a/docs/wallet-security.md b/docs/wallet-security.md new file mode 100755 index 0000000..baf24a9 --- /dev/null +++ b/docs/wallet-security.md @@ -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 and trade signatures go through its session signer. A stale handle from before locking/re-unlocking is rejected even for the same address. + +- Explicit **Lock wallet now** clears the module's session references immediately. +- Five minutes without trusted pointer/keyboard input locks the session. RPC polling and automated signing do **not** reset idle time. +- 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. + +**Wallet risks remain accepted but unresolved.** 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 or a browser-extension wallet. External wallet audits are not planned; multi-browser/background/cross-tab races and destructive crash/restore drills remain internal engineering work. Do not use valuable assets merely because local encryption exists. diff --git a/index.html b/index.html index 1e8e949..71289fd 100644 --- a/index.html +++ b/index.html @@ -4,9 +4,9 @@ - - Wikideck — Mine knowledge. - + + Wikideck — Experimental mainnet candidate. +
diff --git a/package-lock.json b/package-lock.json old mode 100644 new mode 100755 index 23846c9..b5eca26 --- a/package-lock.json +++ b/package-lock.json @@ -172,9 +172,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", "dev": true, "license": "MIT", "engines": { @@ -216,13 +216,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", - "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz", + "integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.8" + "@babel/types": "^7.29.11" }, "bin": { "parser": "bin/babel-parser.js" @@ -298,13 +298,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-string-parser": "^7.29.11", "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { @@ -811,6 +811,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -828,9 +831,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.10.tgz", - "integrity": "sha512-oJ9ZfVIZjSjts0ZIYA48+5T0vurE5fQfX/Liad2U52Y6EN99x/OCdjGGuKMDP6jSk2i2lY6jwwA3X0drEc4PBg==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.66.0.tgz", + "integrity": "sha512-7y4XeoUHPecjfUDRmap8U0q6bjfLAPn7uOGT8BKyqy8uNDOjsGS8rlBhOx6sZLVwwjN6hapvGl2mW9/IS6UNww==", "cpu": [ "arm" ], @@ -842,9 +845,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.10.tgz", - "integrity": "sha512-LylBINgYYwgHNz8OMCy5+Y0w/LEgRYE0PcAIZfEuTuLNMf0S40GSLX6SFwqZsBLplS7RIHHy4zOAbl8Y5cQEvg==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.66.0.tgz", + "integrity": "sha512-e8WugYZXpgqrlMXLCudYVRAxAcK7AIY36JxRDwFRD38L9q62G1VsEUW6ijXxzjxYjNB8i1zo3B/ycIQ/qjLC/Q==", "cpu": [ "arm64" ], @@ -856,9 +859,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.10.tgz", - "integrity": "sha512-vbuT3VT1GBI2fAOoUJUrsHVoOg9nbrFXTM7e3DSnb9PYf4n6jZCN8v4Vo8O6x4u5B67+/c803b8BtCZe2nIeqA==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.66.0.tgz", + "integrity": "sha512-6vkDCfyDjNLokh+Rt1D2KtNgFFMEPdkWha/mbtP4cSjh9xiN8T/JBS/A7nDYKjmMAXHrvR9J3AzUk2LtFbIBLg==", "cpu": [ "arm64" ], @@ -870,9 +873,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.10.tgz", - "integrity": "sha512-qYUp/qYvM3T4cF+VWalPl+przBUESILxi+qZ1m1MVRHpg06vS+jHaxXubjCaRpXjLJPDFdXzOZ0FABDghHOhNQ==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.66.0.tgz", + "integrity": "sha512-/mzZAX0+Q6eXxvkR0HB0RZCCrsbmFD8DpsvCQoMYABDfq/DjqkbfytuJ7GvZ4AhR/Mgis2fXbZrGqfbcK2Z5fA==", "cpu": [ "x64" ], @@ -884,9 +887,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.10.tgz", - "integrity": "sha512-v9NRQG+JztbP4DP/0yuhSyDcEJk047lYbqeNnqIgbXITcVDjGDS7OpkW5nadSY3tJ7MY8W1qIMMqP4Q3Q8BDjQ==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.66.0.tgz", + "integrity": "sha512-JQJx9E8tUjGqed1RevDmoHaS3V30ODdV8uyvGxlwd4K5se6bkPrMeltSybqAtpJ+ltDRfo7FORQaJG2Hd5nGog==", "cpu": [ "arm64" ], @@ -898,9 +901,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.10.tgz", - "integrity": "sha512-CHc9LBHdxhn6/NXfb9IegXCWoz5VlvcDOx9QrzZ9q+XYhFlfzwymSWJjOlZcEMMtC1zgMV7qxDe6U18AI1SG0A==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.66.0.tgz", + "integrity": "sha512-GEaUQQjMaG7X1304qMgqLjzCKv6CdDirzJSS+UqUs3xhJ9X2Kjzu0JDbLoUU42FWxwmZqz+SsgU1SlQv9GNRVA==", "cpu": [ "x64" ], @@ -912,13 +915,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.10.tgz", - "integrity": "sha512-ble2WR7kWfzZFJUd3ODMGCJn1XDszptfRC0QvWc0h9/EA6MxnrQ1mvbjPZifEdgHyfHhDrwWeKeJONDITcrHSg==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.66.0.tgz", + "integrity": "sha512-DgmAU9N0NhDQDzHgFUocDio7zRO8jR/nY/obDGvCR8zbJ6J77Fg6X49yPuExJUpYPbC82EOaRhG969pWzUSnHQ==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -926,13 +932,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.10.tgz", - "integrity": "sha512-DMCYegihxBMKvLpODdk4OsvIgxtHTi+Oddf1Pm3d6wMVNjcBiG2pqyXjMPy55HgG1gCdEFr9VfhB1CmvuKn1lw==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.66.0.tgz", + "integrity": "sha512-zG09YZ+sk/HoHq+P9gYB0EseQ3M20RbDuKGWey6fbiMDV1LxfXvhgSW9jDaCcvFPMgfLXpimzXY+UYvA8R2mLQ==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -940,13 +949,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.10.tgz", - "integrity": "sha512-HQ6HuVH9TmhvPWIas7MA/KeAROgdwqH3REpUU6hZq47NW7m1b1QCrHgM+M3+N/B8DfMt88x1/z79ppRc9RflCQ==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.66.0.tgz", + "integrity": "sha512-vlArhgyXbcVc1D4WZPoiYuqJIaMiSHBj6etRXua0KlfjlZeuiQyVAPYi0/N49O4mSp3u1iGUBLEjNa8ec81XZg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -954,13 +966,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.10.tgz", - "integrity": "sha512-avBhxba1DdctX0kxKkN3JWdTSg72QsxSn6FdfhZ9MkL3TTYDOXqxY1GqbC7VNttlgviK7tCe2No5btfRkFgeIg==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.66.0.tgz", + "integrity": "sha512-RxJZtk0G87YPotDaRDvRqStV8cbqLSC5TroHvQHHKsqBg0ok64RxKgtXeFsa7FBcy7mb4crBrG3xK8USWIqWNA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -968,13 +983,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.10.tgz", - "integrity": "sha512-UtQMex7fiXq0W/GqcWRpy6XKqUbXgQeGKB+Bd5rJngmHzpI+3kjwpWFLAhKg02onFsIMSh+Zx0zp9JIMQ7/05A==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.66.0.tgz", + "integrity": "sha512-qlGcw959PrLLkZhiBxF3OZ6SQvj+OKY85mCqGNgSRs4LZlDJ6ICqmz6UdxtINdhcXYwNIbZoapQVBAmZlD2hUQ==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -982,13 +1000,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.10.tgz", - "integrity": "sha512-0Aum2pUsCSuOopi6oka+nR1DTj/nfvc1xjL979lvh6kXUjNa30TCX7sKFjyMsrX7bK3bNVs0FsJwQcE2HCWX1Q==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.66.0.tgz", + "integrity": "sha512-TvTI/7Cy0WvRA1SXgwzu6bwXBnMpmeT/ln5q7WvSpqmXy1VbmEnY8HVIDQNBiaHJvoWqRr0M4FNo7qiqtyMaFw==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -996,13 +1017,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.10.tgz", - "integrity": "sha512-4dgsouKal3qfMFORgtNigLPt7K3HXiO6jTr7+nFJv1GWu1OkZx0kvQIXbK4sjrIGnaLBk0s80eQCQ9hPIelLrw==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.66.0.tgz", + "integrity": "sha512-UZEDkSoDRZ50NyLj70nO6mpIK3dvs2cPTTvQb1bA/PBDmr9fvn5KiESuGfh6srd7Zi6Lt9Sv2sVrFr1QoHLvPQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1010,13 +1034,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.10.tgz", - "integrity": "sha512-d7NE89try3jisYzBD7J9M5iwfgcrXYC4dwHEjQ3HIWBftvwch1U/cy3iZ26SthtUOyClKiu7iqYiFjQEwiOlqQ==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.66.0.tgz", + "integrity": "sha512-VRbxTvagLh3HRcysx1CNq+HHcvJbSebNI6fCbk7jwmmWHkXjrSIV3igXBpRHUTFdg0pagWGu50xoSZXnrZHjVQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1024,13 +1051,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.10.tgz", - "integrity": "sha512-dqB5ChSBp91JWXxpCKAS5ZVAE38esKQvdfyqANm0lF5JPiQrXnYwgG3oVBQmuNRcQ1WHkA3j5qYLsrl2CtfhCA==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.66.0.tgz", + "integrity": "sha512-uWdJpvYqh0LE/i+YBL3sMX0a2kUkICENjmrPmyBmMOFc3jMrs6MSQH/pMD+VIpGEoUcr3XkHGLLfymzIqPSRRg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1038,13 +1068,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.10.tgz", - "integrity": "sha512-7GMf3vbQQslorWLmfsbi5fdt9RVnglk205XpOMFvW6UY5jE5oJc6JLieQJPHtsYlZ2EO0PtC06nY9Hz/IG8guw==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.66.0.tgz", + "integrity": "sha512-3FUd88pxgWAjK60OgCrika1AcNG+u8zaCdWjg4TlcZTDkt5TGyci8ZU64l3OJ9ax0MEjw3P80B/3PoSQDCLwJQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1052,13 +1085,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.10.tgz", - "integrity": "sha512-tuKbkB23DKc5LG+irPSUnZZztRHqC3CNmyxeWloueBteHB+i+1HkRCMzAE41lseLDlarybeLhqfibFitKOxKMg==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.66.0.tgz", + "integrity": "sha512-qT2rMmgyej62g5AdwktK+Bt2lBu497pzWZe12TTQ5JmZjOYPnEwcPEAOSLTBxBV+6gIES75rXm219v/QkT397A==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1066,13 +1102,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.10.tgz", - "integrity": "sha512-ntHtQOMw7oIshHRF1swV3vyBw+beQt9PeZ04O3D32EeDZUMoHMJuZKp4/fDllH/+xogeK0GUCONVfjjKusqKLg==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.66.0.tgz", + "integrity": "sha512-E46rnZgp7J84Qwe8RMlF+rgimnDo/9fuiGbUArGB0ZkMWf97O7yeSCfKls5x/YJOF0RcuSe9RctuKe1hlxICiA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1080,13 +1119,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.10.tgz", - "integrity": "sha512-Dlh8q8r145aYgh+N3gNIG1dz35Fz/FCxe5hs/43orDsffjP8S1P4Xz5I4FpSJsDS4LpQw1QmswZkQlUbkCldjQ==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.66.0.tgz", + "integrity": "sha512-Uz1HiZe+qBzqgdPsON+z0wRx3WdATuyzwvGT6cr2VZVrqqko0ZJVfkrQJtwDMuDcAfXYdo7XucaxJ7lTLjEcgg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1094,9 +1136,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.10.tgz", - "integrity": "sha512-VBJhHWz/55u45CWFv9OmfSRDgJDVENESgFxSAn13gIKJpTuFJbP4/7ymMZhDfeKFx1zGn1XBsLnNB7Ryy71qww==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.66.0.tgz", + "integrity": "sha512-Ph7TX3Ex5qdfcb7Tc6eEPugVkRfJ501eTovO4T5029YObfpZ7dzn3RiwpFvGp50nk2QUhvBMZ0PFGlEj6pOYTg==", "cpu": [ "x64" ], @@ -1108,9 +1150,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.10.tgz", - "integrity": "sha512-Vjcazg+EEzUfIz+LcaxS6YtbQHo+sv0sCD+5VbwhY504bCKNW4OOreVgKq61pHT2esRQ/+AXYVPStDwxSvAOdw==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.66.0.tgz", + "integrity": "sha512-nZYPEya7ZkeNxUEiF7qmNZbyLTyRN3MK5cO22KqsaoneUzXJuzBIQRw+7BEQy9LGj2O5vrCE9V3EpK06FYo1tQ==", "cpu": [ "arm64" ], @@ -1122,9 +1164,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.10.tgz", - "integrity": "sha512-jJAaP4El+HWxYkM0jeJghASaFgfnluY0FRHhyHXxU6STs/nBhKMIxIMDrJEFg2ftKz6orX9WAo4diVan5NugNA==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.66.0.tgz", + "integrity": "sha512-u7aTfS3R6C4hRsqXSzaO6qLZYLVuMkHMvcWlux4IM8IJif1VCl2cgPBSo5gSNoFoStQypQP+qwq8W1IyFBf1QQ==", "cpu": [ "arm64" ], @@ -1136,9 +1178,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.10.tgz", - "integrity": "sha512-bXXdRUfRx9PaHCToKt8R/7wjDV6Tbk/hmSHK0uQBKmD3rj4jkuYndct8uGX77ZuZsePrVkQbWUgPR2jJhCTMpQ==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.66.0.tgz", + "integrity": "sha512-e+i9ueHrH1Bafb0cf0woAIVJbYcoC8vRaD+vz+Xi8w6jz9cYJSV3vyKO6ELGSZ6g4W4MJOm2WPLN4/nQf77h7w==", "cpu": [ "ia32" ], @@ -1150,9 +1192,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.10.tgz", - "integrity": "sha512-57Idx+VkEOQeG6Ys6sZOBFT7FfbeyxGv9A3k1m+Lf/z2SwqT40rNWbAINzjPHSMoCCBvY7Fnb9RGZWQin/HHAw==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.66.0.tgz", + "integrity": "sha512-KfTCbrtr2zXTqH2E1F+uYlFTrYibxHKqPAHobpd8Kfg49cEHmuHebOidCTDRgqG1suaPFX7oejFQeGE71zWKfA==", "cpu": [ "x64" ], @@ -1164,9 +1206,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.10.tgz", - "integrity": "sha512-vrvuasIpm/xf+BpZbzgirku0WnrQZMAc+H2Ai26jzqBxYEmQQE4DdJb7h8erkJMfRRipx0ma2Cty8jJwUm9ocQ==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.66.0.tgz", + "integrity": "sha512-xA+neBh1bqkqaS3b48T2UHZnDsTIpb9B0h+TdBzynjF/ymCpYndhfY10RqW0qSKB1HqqQA83mINVhAdwA+mETg==", "cpu": [ "x64" ], @@ -1408,9 +1450,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.29", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.29.tgz", - "integrity": "sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==", + "version": "2.11.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz", + "integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1421,18 +1463,18 @@ } }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", - "qs": "^6.15.2", + "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" }, @@ -1445,9 +1487,9 @@ } }, "node_modules/browserslist": { - "version": "4.29.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", - "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", + "version": "4.29.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", + "integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==", "dev": true, "funding": [ { @@ -1465,10 +1507,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.29", - "caniuse-lite": "^1.0.30001816", - "electron-to-chromium": "^1.5.459", - "node-releases": "^2.0.59", + "baseline-browser-mapping": "^2.11.33", + "caniuse-lite": "^1.0.30001819", + "electron-to-chromium": "^1.5.464", + "node-releases": "^2.0.60", "update-browserslist-db": "^1.3.4" }, "bin": { @@ -1517,9 +1559,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001816", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", - "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", + "version": "1.0.30001819", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", + "integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==", "dev": true, "funding": [ { @@ -1732,9 +1774,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.459", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.459.tgz", - "integrity": "sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg==", + "version": "1.5.467", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.467.tgz", + "integrity": "sha512-Nw9X371rtZmYcBSnEijnCDONILjs93biCZO34juC7MBOmK8E+GFnyD4T/WvyrlJkppul7oxg6TwvtKst9SODTw==", "dev": true, "license": "ISC" }, @@ -2158,9 +2200,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.7.4", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", - "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", + "version": "10.7.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz", + "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==", "license": "MIT", "engines": { "node": ">= 12" @@ -2344,9 +2386,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.59", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", - "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", + "version": "2.0.60", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz", + "integrity": "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==", "dev": true, "license": "MIT", "engines": { @@ -2366,15 +2408,19 @@ } }, "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", "license": "MIT", "dependencies": { "ee-first": "1.1.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/once": { @@ -2426,9 +2472,9 @@ } }, "node_modules/postcss": { - "version": "8.5.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", - "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "dev": true, "funding": [ { @@ -2472,9 +2518,9 @@ } }, "node_modules/qs": { - "version": "6.16.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", - "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.0.tgz", + "integrity": "sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -2557,9 +2603,9 @@ } }, "node_modules/rollup": { - "version": "4.64.10", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.10.tgz", - "integrity": "sha512-8N52YH1B5JzBP+B1rkcBERG7rfRTGAM0RmUpyd76yyRfTljuG452GCNbVm6MPzs1+gXBha6Rw5/oHRFohOpfDg==", + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.66.0.tgz", + "integrity": "sha512-Onq6/qO4ARhxIs+jYiHE0pYehadLyMECAiGEqQ+0/aWND3+2PmifJIIqBLYtuBODqHTlOUEQFtIVPyCy0Lck7A==", "dev": true, "license": "MIT", "dependencies": { @@ -2574,31 +2620,31 @@ }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.64.10", - "@rollup/rollup-android-arm64": "4.64.10", - "@rollup/rollup-darwin-arm64": "4.64.10", - "@rollup/rollup-darwin-x64": "4.64.10", - "@rollup/rollup-freebsd-arm64": "4.64.10", - "@rollup/rollup-freebsd-x64": "4.64.10", - "@rollup/rollup-linux-arm-gnueabihf": "4.64.10", - "@rollup/rollup-linux-arm-musleabihf": "4.64.10", - "@rollup/rollup-linux-arm64-gnu": "4.64.10", - "@rollup/rollup-linux-arm64-musl": "4.64.10", - "@rollup/rollup-linux-loong64-gnu": "4.64.10", - "@rollup/rollup-linux-loong64-musl": "4.64.10", - "@rollup/rollup-linux-ppc64-gnu": "4.64.10", - "@rollup/rollup-linux-ppc64-musl": "4.64.10", - "@rollup/rollup-linux-riscv64-gnu": "4.64.10", - "@rollup/rollup-linux-riscv64-musl": "4.64.10", - "@rollup/rollup-linux-s390x-gnu": "4.64.10", - "@rollup/rollup-linux-x64-gnu": "4.64.10", - "@rollup/rollup-linux-x64-musl": "4.64.10", - "@rollup/rollup-openbsd-x64": "4.64.10", - "@rollup/rollup-openharmony-arm64": "4.64.10", - "@rollup/rollup-win32-arm64-msvc": "4.64.10", - "@rollup/rollup-win32-ia32-msvc": "4.64.10", - "@rollup/rollup-win32-x64-gnu": "4.64.10", - "@rollup/rollup-win32-x64-msvc": "4.64.10", + "@rollup/rollup-android-arm-eabi": "4.66.0", + "@rollup/rollup-android-arm64": "4.66.0", + "@rollup/rollup-darwin-arm64": "4.66.0", + "@rollup/rollup-darwin-x64": "4.66.0", + "@rollup/rollup-freebsd-arm64": "4.66.0", + "@rollup/rollup-freebsd-x64": "4.66.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.66.0", + "@rollup/rollup-linux-arm-musleabihf": "4.66.0", + "@rollup/rollup-linux-arm64-gnu": "4.66.0", + "@rollup/rollup-linux-arm64-musl": "4.66.0", + "@rollup/rollup-linux-loong64-gnu": "4.66.0", + "@rollup/rollup-linux-loong64-musl": "4.66.0", + "@rollup/rollup-linux-ppc64-gnu": "4.66.0", + "@rollup/rollup-linux-ppc64-musl": "4.66.0", + "@rollup/rollup-linux-riscv64-gnu": "4.66.0", + "@rollup/rollup-linux-riscv64-musl": "4.66.0", + "@rollup/rollup-linux-s390x-gnu": "4.66.0", + "@rollup/rollup-linux-x64-gnu": "4.66.0", + "@rollup/rollup-linux-x64-musl": "4.66.0", + "@rollup/rollup-openbsd-x64": "4.66.0", + "@rollup/rollup-openharmony-arm64": "4.66.0", + "@rollup/rollup-win32-arm64-msvc": "4.66.0", + "@rollup/rollup-win32-ia32-msvc": "4.66.0", + "@rollup/rollup-win32-x64-gnu": "4.66.0", + "@rollup/rollup-win32-x64-msvc": "4.66.0", "fsevents": "~2.3.2" } }, diff --git a/package.json b/package.json index 2c8c74f..ef62ab5 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,22 @@ "private": true, "type": "module", "scripts": { - "dev": "concurrently -k -n api,web \"tsx watch server/index.ts\" \"vite --host 127.0.0.1\"", + "dev": "node scripts/dev-testnet.mjs", "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", + "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", + "miner:testnet": "node scripts/miner-testnet.mjs", "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" + "test": "node scripts/run-tests.mjs" }, "dependencies": { "express": "^5.1.0", diff --git a/public/Wikideck.png b/public/Wikideck.png new file mode 100644 index 0000000000000000000000000000000000000000..fb34fad83f9c97b15ae7b3171b6a80e238d4852c GIT binary patch literal 10288 zcmV-0D9_i4P)z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy32;bRa{vGf6951U69E94oEQKA00(qQO+^Rl3KRew3!i1q_y7PP z07*naRCwC0dwG;ycX8%dzu$dZzuxzjy4BkE-Lhrbl5As1#&|c2F@}H%34uUDfFVE< z=0GNpkTWn7Colv^07E=1Mz&?eyJTCIC2Ln}ZFQ^neSQ0TcllM#ANRi3@{p4mGV{)9 zsh9WeQn%`>s;|Bx{FX08L@XkThy=iGH@^dsiZCoI1j7bkAe~7e7K_0Og+L%ErC>Y* z*K-gKM@egibUH2Zco{^1WHO1$%1QtN0YFkb8v!KHO2ZfiNDvBz;kqs)BjA`pwXVhG zQx|b|?5yBF@S@-5&3BaDL0Dc(sE?`g^4jW>~ z1c4@kCq(c~;2(p6V}QUA2>ZWdVIcqlf`Eyj0ElRhIvBDdPYjNY51u{S zA%<8J0m#2l!2j0&-Zy{(`35N733dG+%?khgQ+wr!_6ctk>gFbe?^c&vZ1#Ul|2iu~&* z0zn9?moLQn6|1SPrV-`Q8oYJ-sI3ax3ihO$sAYdp`00<$=%0?ow&!tjRkM|CqcE`qge!~dXZ-QJ4 zZP;+L)lgmYm#IwhbH*3|Bpeu;1qA=eO5oi^BT&(`p)9&@_pP*|eJw4Rw-^y!f$5n9 zCT2!4F+PpK;bEMdxG1-;YbQ+_KRa+5m9>j7HZ~>Im9<#adNb|ZcrUq}r=js4boQN> zz0bca2(SVq5}3e`s^WD}Uy&yCts({B%n-|&FV>+Z2? z>zclvFJwMzSb~@b+sftkU=B*JKGYNc%(cVf-b4OrZ^456r?v%f?3zW9vv z4vs(|rI3>nra=6NAm3M&fJA{bAsh-nmP#kTbo$IbhsDUhX@Cu@?^X4U^}k~m^8e_u zN2PPnTuM?v5d2bl^o?~j>cbB_LMs<-#Kd?4C(gZzXAeCo)e)jwSFeRuI90JB#7joZSD8_tNzncA%xP0i6S9jHr};MwP+Pl+Hmu#y>N?ID&$t!E*kh(5C=02;4TKP! znLcpuy>#n!cTpmd!;6Ra$y2ZV3Peggp5Tb*r^DxMSP(1*H+SJKJrJZp?oo3 zJ9+^B_^Zc+g+(a~L`)(!0L*4C{sLr0kS+cARR~!YRyVa` z@7d?^t%pAVV_by8F%W?LuRkmMU)v`if8VFnty>YP<^qwDjVVYK z!~*d=w^r-Wf3kGlX8||>paDRsHMC@5YpY@JF=h_=b+(*8s z+*}mjdE%S0@3p-GQcy|MPCz59=;zkd{e^TRJm_i(QeDTpvFUb~WR*z>U3cQ)dL8XKGfDPVMR6i3g#itAQxr`RlF)}&DK}qFbKS&Ar_X0q`645$z z6_h?bG0`g)0F*`Zni1Y99Mm2Na$4~p2#W}9UB6a+^!`uNrS8l4>UaNC1i;GcHq)9d zcTmIpHSk1Z;L0VOKD1A=g*<^7MnsG;AVmZgSX6{-0*m-)9zb7MYX~#oSA8VH!iJHU zP2;r}56O~MOK|Hw@1VOMzK2Ez265@kWqI@EQ~2`VeOkWsv9Hr7ANqp&??3%3Ip5VQ zbG3<-oWL)%m>_1mrK0}~z*veD0V*oO>xBd&T&!&bB1QM7)r%LaM<4tYojKouU!UHK z2R{BKYFV%rwksIw=|sogpMX6;ETShob*c5SCU58Q#lo__3m>xU@V zAH<{g|B?Qy?|p@*Q>maSCJ%l4+5&_N~J|hI8)@AMJ=^jE~?13j|&nfRq)Eyc?0IX*FUX z%rY170u~QTH*Hiq@BRbI+b)KBF5~2p$00_+hzGY&z|=@5*m!;t5(8#~k)Gp-mN!%0 zcHwx2m_3w7E79Jv6ow6X#y~`13j-i5ZmEG%6f{AE!Lc66*%>&VM;Y5iWn&F`E_8{L zw@GV_{);^zr9^8Bt;<`fuXhan{o@eV!NO$=u=vJB_|w}yrJnoMzskkqL#&iy%t1O> zB*N@fK;JcPId&RaSC_kPRv=7Vs*ywtd$jh3yJ(=d4;PNUg7V5Hq!TkpjQ4_tArM3& z6=Itd`QGBUt#&>IE2b%(&7wTh?V+$zL2p?~k+7_A?WPS?u!L&BY_zcWfGGk$mkMgiYiMe?AJOUssA*V? z{!53D&rSz`O3;>t8{hFky43M9#s)6?Sy&(fL%GyACQ}*cx-cz@wn%sP5K_IT=%$<2 z%a_0SH((};Mx*cy!}A1F(=({6pGQlVu0r?KD`;z70FekTU%rCGD=wXQ70Ic-pkN^p5o%q$9+7AT=C`etbb6W^ z+g2l1R!@oPe$>~-u=|m_aPPYx#OX7A=kvQ++_AR&L_=V#_N}<#z_;tb0XJ)EL5fjP+vZmTBE-xHMdIQ(1#uih3zy%7P^bc0$8hT{+wf1{{|+39;H&@TdpLgL zEqKO-XAA}h`p|LqCG6a>0W*mtlBpCDi6lJ3B_2U3f@Ot3N+XlaVBw-=_{<-D6>C?m zhw%);(WtluS6tf>Xoa53-NI}HEU0d(hH)H#1YXv|b?dfKYf~eM@LcO2+(Siq7pZ){ zyIAT{kogG^dhZ<%(d=$pq4= zDQsRF!$ajiUXJp31>*5C zM8YA2!%@GRRzN0`#O&-OW@g603@EMe-~%7UjazqMVt5?2&2<>*8%DgMTqXu4f=<$) zys83?O|=;AAHb6b9+zY?hj;IOP>C>+1g)}PF)Q0~pa2aPCZE^y-x1OwTD5o+ox1d< zXl-Gn|C}@~SPk|JSOg98m(j?;6`39zl1yqAlcQZ2xOyB*=Q;Sp-+L6=(zs#EMqIei zjYEe{fkn{Lynu4~JZ5HRaO%`0%*;-MS->JtS|gju;?$XA$Yc|E?)kmw>+eQGeKYFn z8(>))+R{+ULfic1;53tGm};9F$S$~~Ekyz$fxvSOTH5Dhuy;T#ZK0#{C{{1No{FuK zNWeCc;;L9k6v;3M#DQ_ZEcDQwcav*}u>bWZ0g#+~V;^ekTj6;&h%|%&HZF|kAUQKk zql4!V56$3nfAT3@ynG4yLLSC2e)!`jQC}CQojdj*pD&1KyukjDOg1a2R0gS33J{>O zsv6N~45v<=!tP!7p}(&S*=z<%`J{*O1deMX8ZE=>|83y7ChV$G^GNT+jn@~P)BHa03O zjM>>)1Mm3+bbezL!Km(P#vbNEs4|G+g6h+0xZ$YHG&COJ}ia^+p^z^b(Q@ z-#}K#$AtN93lIrMv2E+^2w4%TQ1#SM*^a96TGY>LkZ_ogOJ^uKorNMLJtI9lGTD!H z%eDmg#!`fNCkt4j)<8tar#d0bpys-Tc=_7_*bTR8?7z z%JN0XmA3MH4EhvpZGXMqj4BdV3^^0 z4$|oyEK7lnfLX9`;c}cfc@(v^aqQT+2gzg-hS`6@0;JPv)Ydg260N}L(?^lXX5bkI z0)vWZ9L;S_7#SRfRw0m5Oi<__=}~K!ucLsY<)FOhs%8Zh)&ivzNkDdPTfdPsMRB(C zm;g$Y@(nQ3dseC%mV%9gvREaI@g$v|qFi>C?%4A#B$6pi&n7TElf?Vqvxk20z5j}@ z{P~x$Zrvu>PJwc{JjJ5ra9!gA77Mc3EGjE1VTGb_Jp<2WxE^Eux*O2g*oJTWX^A zqY)~4IuT(!8G~}YB!t-%sjuaWR#GAF;&kW9pqU~NDO@=6D}>7$;kXV8c3#5aIJs^? zKKSsRXlb4Y&vPI`upJxg*Kb5wStUHfXkXBRkA3VR{L!aB4cB9^h(Fw5f#Whf!*E>B zFNm077aU|VY2@<-IIe?Swtz@D21OwV6N~{|*M+u1SkS(T3WWkqP7g^YpAgS+5iKjj z$=6PS0%jNl96tLxj0f4aaSI7E1xToyn*|C;gG4ES0SGF~>yXYR;4_LSO*lvxiRp2~ zs@q`2>M%CeCF#s8ZQpSl`Um@Q{(J{2$}13#L?Hqg2ACNw%`KSMG7lFncHrE(Gcd-# zY1d=K<33wFH93n+CW~Ay3n(BQi6b0}!F414m!LPoDV)zk@1v z(WFEvOQy#FRy^{1X{;n48d_cxUPX?yI=;oyDwsRs29d~@H_*@abep986E3G zV`IB8dvHCED(kB;GB_++Sx}_q8X8-E{PbgZ{lu$2*pU(ucwRxEdIiw?S|`FupNeB1=}{r7hH@_%)quixGn?GNF580fD zT;4?}6vcsqFCv-Fp{%?XEDEVq7Rhu5w(TMsi$kdhmx;_gyr=ix6sxJ_+b&>}VQGDO0lLl=i68mUwj@n(ueDkv7IqHwGp z-!qjvMvxy8QCX!%Q5CK}|wPOCfB`{2=tg3@CKsu8{I9vwT z732#JY}-R7mxBnQP;lTGgKRE`bLWm@Udv)kPE8`8&!a3}jcnGH+38vGj3AXvqL3?q zSRr0s4M>QP3n5-sOJ&h&h!7cLtIywigW9sVO={vXge<7Tmxl#Jzi3!U-InoE01*Qu zU;ODoQ}L*!XLEUc$VLz^;HgW60YWSRZ@}c@GC(zJiuT zwJ0x-K?I1$%P=vKfa|(YTA@%7ctK1X!zkD`l*%BNwGoSzL7)NB$YgDRG};y{#mqzj z@4J5+wJs?48#E$Z%L4n;Vi1vn>kz%T|C0RRiLaUe{PV-6Di+mw*TFZQd{eXzffPyq zD1(&RP8&DY>~d`TS-{Ae<_bMBo5NJjVP>KB#%*e0>n7Us&c)cia{(d|6^z2@H|A>j zyo;A!>A=r_egvU1;PyLig&2b8_?;lLfD|F0x4~>+m|+ZG)1k2l3q03Qq%4eZL=X-~ zAuK33HU@eJkQ!s$`L1<%;Qq}BM>ObdG&sQ+!SQ3g*!NTidipx0f9g1Et$9sLrJhXZ zF_HCHlZFmi_ZgFa!D4gb%f;Sj z<*j2UaOjObkkZBR;2hT|K+C*3uz0AfZX&J2DA=z2U2q78)VqqW_CWI|u|Gu~IsZTy8|NN6DkjW*G&F7I$XOKy!p>+tARgK6MW>6N7 z2YQR5_JDBWcn_X`{wU6NJ%?~42Boz>Di@+1*X^d2?Q5yXm5Tu3up>(O1e_}JT&c`7 z%A)b&G!$A{5(nVBe={{t#PKh-tPKQMV>(;hn{io11xtMThzO*#SXKn21f!FeapL3%00oeO--QMfU12=*@J<-lC1xSd zFet=2!m=WwlqIA{APQSntwSUnlAgg%DNc8ZlqAzC zfX!>*?6nhz#fXP3YqnieNP(5IBovCsm4TP!rTs&&U772_nf=PoFg9J+hKBeG%*>1- zk(h?0zT{u;g(+D%u z)J!E5XlpSl004$Pj7|<<)#42_XJZmY#9C|dI4i>=SJ82H%&+&w|1v&?pe!1~{SR(O zCN+yfAx97*5l}2viC9?$>gywjl!g4!SYR~I^Rt0tyV!DlJC-zUMLIo0g?tv-Tt>3l zjAXNEIC%#T|IV!t<5z=h0Aa$hqum%B=>&@dt;3>}Vj|^>bpWhhvK~XDeStl=$Pa7w zGEmG%gx{{5k@H=rWpUdwl$VvwNq>M~7!k&$p6BJ27lvWjPdmjgX27vsY~Q&8an*oy zdKSslj5v-X%#7CBP4vh|w!yVspAKWdc;Xu{_&F90;o%SMK`J#ZnM_g&`79N3S!A=b z*t%{v)~v6C>v;Y-1xTkI?Av<=UBhpR6$%HFpgE6jZ)v9aO$+Jll1SnFhw8eRMO1itR;_B5Icm^=W&m^7!JR@jsuEfpT zwj!CFKqfOo*-V16*#z#t=UyyWSP9PsW@hs+#=mi6d@u40kk7fe@unqcsa!#s^duz` z<4C0^39gn$K70$Dg6p4?{qr6_-i^W0PO!MpIs`@P+K{FPcE3jz92ZX=+$#ZI6g5sp zq);+|H&;;+jZRJAO7A(@vUVqx5Kchi3q_%=5V{8r%Ao_JAX4y*z%>HT2s{I1vkvaL zZ!6#s(&;H=(lgRjzJl&~a2+zK0+PwReEXXR;CfODBQNL@JtHWOhw;F>cOjQeqL5FE zZKrYj&3Dt%x%~~mJT)7RW&%Y&}@q9X8 ziXAKKhDHlYsb&&}1XE!UghhaTul`c1%j@NRdmf}fl|V(UqgIy8WUk`u>23@TCjBSr z35+N3T!w8Mv@fi|##P&qN{mY;Gfj^?ayQ~}4bKG*9=Jfg{a117)DVmTyx?~qEsmlJ+d9ywClgu^W*>c@oo%^T?UAub;uWQsnTSRKKbxR6(Nef z2cD2QVPU`r0I)PcK_(#L3nhdhEJ7f`sm`-QmX2`G&=EOt zYz!;}M69%y z(b*Gt>y17n5_xzo!+0nGVj=Hg&6;`Gxa2;3{15K|8-_8!D=(cx&+su=T1(f}GjgtD z3~UIV5f~3}T|bl(i2~kr*9}-c|2BHh!`qNb*zjC|7o6kNsV)qUbpm9dRER-JijE2a zed7IpNZGW9$M=2Tmo-6XPCG;_0xKR%k^(h=6f3`8ViEDj6##H`xK|Dy+b>(!?Z&qC zn^Xx8pnNRQVHq4dBNxw4!DGPjj8AHK4A=FK^8257A2x2Dhjhxp@W>1f95^Y7Y(FC5 z7+o294ln%cWiS(rVHjqxk0q6qet1j0Z3_4j0O zbO0ZH@H13bSwY3pi%3D~kaP{bh=VWm!t?x`Zh{+dJ%ibqJZ|6Jj#ScyK;htl4(T5` z?$b07QA*=V*D0L8FzP>j&%k&L;|W~Dn3*ZyzV|G{%v8Pve$2p`vppCby$BFPDN6$3 zCW(k@Z)w!;-t!UaxO^5*9{7dt_aeT?Cs3V0K*qSa>4XvG_s)e;B88H~s;F2C=+MW0 z`q!+e3}5{HuaFdH;k&3O!@I^{Iq6_z%z{g{uHuV7ybR?c;nD1noai# zvj@+!VLXQ~^gS&HUg&{{f)^Oj_54=ZaSR3qQn2kH_6;Ln@UVa1DGZJuDC zCr^DxS{qm4i@*B?Uj$7=ibw)4kT+i+^i!hi--OQ!1G&5lW`$Q@J%|30<6>DMgd;Hu zhszKSmr=+H$>l4@r1R2*|KvSC8<-%i=CUqakArN%=;-Li(8xt71!xr#rF=aj1nO_U z@C_A49lrmIzvb!Vj4$*pP5i0Uw-QiLO6Bar3>5Q8fd})NmjD01 z0+Y^_26XVm0sfa~zAqbB-h$73@H2`4MWje6Ee5t+?YWHMp;S=V_+PFO7$yu3j^o&| z)5tp$5(>qjb%eE!a3~Z-I2=Rg;IlaRQa1n5%qNl~RA04$maS-n>zQJ?2@-{U`;Oqm znP-Kxz_OyEl_gp$AyO#HdW{{yqQy(8vNGxqPv(ZTC>Ag0&tJu>ufHaFdmNS(VIrcc z@-p>}&wgDsRj;DQ_kCAhJn|g-ohsCv*v(gn6-#6Sy~^S~IWsfpDge-y=K*x2SV9MC zF=4(|HRkJPpMG_(`N_WTNPE);dhE}>t6CeHlqL%~cLL|mb|NvI14{sCMld)qfiq{$ zz%gl9Is$)Wr9gowN+}J?ilBS=dA$B|7eIbZVO+l|^a2BTF5{J#&SGThgx~OxlIz#4 zp>O@^W2z>;2#@{rulV`H&oL+v`S<}}1a<+$NU2J|apuCDRCZ=2hN_Clgwp!`A`%Zm zpeWr&X%iq=gX!w;l-}V>v}N@zbk{BSlTtQa>bWe1LK>~j>#1da1y~rC(%84}2u_`U zRoKivtH=LEfE!Wku*?@A)u& z=-yAM>6sM1`GddU^F0?CDiDWCk>em#Sase1_N`I5RFYv$SX&mllFNF)$SYLL+h7qqR!r9G&NC)mU(4JXVMrP7{|*m zzb>g2T+h{K%{7V<^tks12r^n$!rSEX)wMV2 znksi;C{(`R^Ss3Y(N(MrC1?bKeE~sH`z~UzedBg@-)$eFs8vmePP~p|=MQ5lJqbWb zBGm^SB4{hbN@)oa2msL1`ZDqiqjx4J%mT;GQ$=h(X%#6_cY)FrSUj&4JGR`4>(*|= z=wvURc<%drq35Cy)ZFe)Ak~`-WGO9Q0RpY9t4<;F0ngC6q2c4^Z3a*(Ol@uJV^%2q zAR+Yk%%<#G;kjQ$`2Q$>j-Uc^Em#O+^^Prf(1zvPsXE?-sl+HI5`EG+a0VUS=Rt~? zN^5hzaP_}EvYm`E_}mkRSp?!4R-R{*F)oZTxMkBWT0DOpnrauIrm7CJnK3zb;eZ_J zI9$~6`!0`SoEK$!J{9Og7ekRIrBv2<&L4Zm-a9mOCd16~8yh1H4Xafo8mW(jDN(LokUb5#%Dmw5)XnZQs0`mM&ODi&_@}%0VJMfpk8J$>|CC zr(gd7pWCwn_ibJcB9>ntzeN9dcvv33^Mh1XQH!Wmjj~8B8TK$V)`v^I9dh*C0U4hi zm*8X)6#k&nx(FjUJr(8GzNwkCvK(gjZ(O(VqkJwmGBw@F|K`%vk}I00_y2 z{&Bi<%@SbT0sGORc7KJQJ*O3nG)K=sR5UdNN?Lx&U^P8?> zQCM1-#2jxhx7UUube0zD1Yr5|JJ)H?6MX%J4ld3bWDff0P)tDn)>>&*#{`#A+#@VO zw)l#4kWwJvr03b+GM;nTGdwjm*5UqN?F}kYR8hW6#jB#_TIt0hEBXMS?h#;_Fo)i@ z+~Yq1E&}?s1|TYG?|q;zNj8Evg|(3534~N}<<#G5C?ZdZ!zjuAgBnfd7;z~o>-}RZ zq8uPsM7-xbukbW8U(IG_)2Y<3@c+(!{(Fm)iWGqwFt52K1f{B!!hC>MYZci{q*?`1 zMMQCcv_KpJLLkzFYxX|OFaEl1ZaRf z01kwsEKEd{0cZvgN5l+!o_&&lP6m${&rObx4|>cg`G49Sri%5{Sf*4sOcr282oeV& zCL)%IXaF7&w4aVh1LC2$GYuFBv=9^#H2Ez#`$`QeuG08F`hysM^{coGNy-hXa}9zL z5hfuABw7RpM23JoAg+km#uHnJyz%j*u(|qwT%!K1w#Q4}q1RI^8bc(KrK(B~lT#5? zR2UG#utGU7$0U{3aBQ2Jn_c8`1k17jct|FLDbh9TaZODE+croh{l&^~m|$5xMV8HK zG&Z_HUJn5!ih~c_Xc;01U&ivLZ^#2QYu6yy`edCD$0000 diff --git a/scripts/benchmark-canonical-index.ts b/scripts/benchmark-canonical-index.ts new file mode 100755 index 0000000..ee01d58 --- /dev/null +++ b/scripts/benchmark-canonical-index.ts @@ -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 }); } diff --git a/scripts/benchmark-randomx.ts b/scripts/benchmark-randomx.ts new file mode 100755 index 0000000..5b8f17d --- /dev/null +++ b/scripts/benchmark-randomx.ts @@ -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;i2.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)||valuemax)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;ia-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);} diff --git a/scripts/consensus-analysis.ts b/scripts/consensus-analysis.ts new file mode 100755 index 0000000..2e9abe1 --- /dev/null +++ b/scripts/consensus-analysis.ts @@ -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)); diff --git a/scripts/dev-testnet.mjs b/scripts/dev-testnet.mjs new file mode 100755 index 0000000..ba8441c --- /dev/null +++ b/scripts/dev-testnet.mjs @@ -0,0 +1,20 @@ +import { spawn } from 'node:child_process'; + +const env = { ...process.env, WIKIDECK_NETWORK: 'testnet', WIKIDECK_GENESIS_TIME: '1792411200', + VITE_WIKIDECK_NETWORK: 'testnet', VITE_WIKIDECK_GENESIS_TIME: '1792411200' }; +const children = [ + spawn(process.execPath, ['--import', 'tsx', 'server/index.ts'], { env, stdio: 'inherit', shell: false }), + spawn(process.execPath, ['node_modules/vite/bin/vite.js', '--host', '127.0.0.1'], { env, stdio: 'inherit', shell: false }), +]; +let stopping = false; +const stop = code => { + if (stopping) return; + stopping = true; + for (const child of children) if (child.exitCode === null) child.kill('SIGTERM'); + process.exitCode = code; +}; +for (const child of children) { + child.once('error', () => stop(1)); + child.once('exit', code => stop(code ?? 1)); +} +for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => stop(0)); diff --git a/scripts/miner-testnet.mjs b/scripts/miner-testnet.mjs new file mode 100755 index 0000000..34da5d3 --- /dev/null +++ b/scripts/miner-testnet.mjs @@ -0,0 +1,10 @@ +import { spawn } from 'node:child_process'; + +const env = { ...process.env, WIKIDECK_NETWORK: 'testnet', WIKIDECK_GENESIS_TIME: '1792411200' }; +const child = spawn(process.execPath, ['--import', 'tsx', 'server/testnet/miner.ts', ...process.argv.slice(2)], { + env, stdio: 'inherit', shell: false, +}); +child.once('error', () => { process.exitCode = 1; }); +child.once('exit', (code, signal) => { + process.exitCode = code ?? (signal ? 1 : 0); +}); diff --git a/scripts/node-backup.ts b/scripts/node-backup.ts new file mode 100755 index 0000000..3eb4b3b --- /dev/null +++ b/scripts/node-backup.ts @@ -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{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)); diff --git a/scripts/ops-check.ts b/scripts/ops-check.ts new file mode 100755 index 0000000..4d5f5b2 --- /dev/null +++ b/scripts/ops-check.ts @@ -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; diff --git a/scripts/rpc-load.ts b/scripts/rpc-load.ts new file mode 100755 index 0000000..1c8111b --- /dev/null +++ b/scripts/rpc-load.ts @@ -0,0 +1,33 @@ +export {}; + +// Opt-in loopback-only, read-only smoke load. Not a production capacity certification. +const args=process.argv.slice(2); +const argument=(key:string,fallback:string)=>{const i=args.indexOf(key);return i<0?fallback:args[i+1];}; +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={};const latencies:number[]=[]; +const started=performance.now(); +await Promise.all(Array.from({length:concurrency},async()=>{ + while(next2*1024*1024)throw new Error('Response too large');}}finally{await reader.cancel();reader.releaseLock();} + totalBytes+=bytes;statuses[response.status]=(statuses[response.status]??0)+1; + }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; diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100755 index 0000000..ee10020 --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,18 @@ +import { readdirSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; + +const testDirectory = fileURLToPath(new URL('../tests/', import.meta.url)); +const files = readdirSync(testDirectory) + .filter(name => name.endsWith('.test.ts')) + .sort() + .map(name => join(testDirectory, name)); +if (!files.length) throw new Error('No test files found.'); + +const result = spawnSync(process.execPath, ['--import', 'tsx', '--test', ...files], { + stdio: 'inherit', + env: { ...process.env, WIKIDECK_NETWORK: 'testnet', WIKIDECK_GENESIS_TIME: '1792411200' }, +}); +if (result.error) throw result.error; +process.exitCode = result.status ?? 1; diff --git a/scripts/testnet-smoke.ts b/scripts/testnet-smoke.ts index 72b6753..311e002 100644 --- a/scripts/testnet-smoke.ts +++ b/scripts/testnet-smoke.ts @@ -4,7 +4,8 @@ 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, type MiningTemplate, type TestnetSnapshot } from '../shared/testnet.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(','); @@ -31,7 +32,7 @@ try { 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=>t.cardHash===first)); + 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; diff --git a/scripts/worker-probe.ts b/scripts/worker-probe.ts new file mode 100755 index 0000000..4db9376 --- /dev/null +++ b/scripts/worker-probe.ts @@ -0,0 +1 @@ +// Temporary runtime probe removed after verification. diff --git a/server/analysis/calibration.ts b/server/analysis/calibration.ts new file mode 100755 index 0000000..6ad13cd --- /dev/null +++ b/server/analysis/calibration.ts @@ -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 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 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.' }; +} diff --git a/server/index.ts b/server/index.ts index 65b8932..ada04d6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,2 +1,12 @@ // A node independently validates RandomX work and follows cumulative-work consensus. -import './testnet/http.ts'; +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'); +} diff --git a/server/launch.ts b/server/launch.ts new file mode 100755 index 0000000..e6e7808 --- /dev/null +++ b/server/launch.ts @@ -0,0 +1,29 @@ +import { IS_MAINNET, NETWORK, PROFILE } from '../shared/network.ts'; +import { CHAIN_ID, GENESIS_HASH, GENESIS_TIME } from './testnet/consensus.ts'; + +export function launchManifest() { + return { profile: PROFILE, network: NETWORK, chainId: CHAIN_ID, genesis: GENESIS_HASH, + genesisTime: GENESIS_TIME, launchStatus: IS_MAINNET ? 'candidate-gated' : 'testnet', audited: false, + privatePeersSupported: true }; +} +/** Operator risk acceptance is an accident-prevention gate, NOT evidence of external review. */ +export function assertLaunchAllowed(env: NodeJS.ProcessEnv = process.env, now = Date.now()) { + if (!IS_MAINNET) return; + if (env.WIKIDECK_MAINNET_LAUNCH !== CHAIN_ID || env.WIKIDECK_MAINNET_RISK_ACK !== 'operator-accepts-unaudited-risk') { + throw new Error(`Mainnet candidate is launch-gated. Explicit chain approval and operator risk acceptance 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'); + // Explicit operator-controlled cluster opt-in; never inferred from peer input. + // This permits private/HTTP RPC, not weaker browser HTTPS or chain/genesis gates. + const allowPrivate = env.WIKIDECK_ALLOW_PRIVATE_PEERS === '1'; + 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 distinct peers'); + for (const text of peers) { + if (!/^https?:\/\/[^/?#\\@\s]+\/?$/i.test(text)) throw new Error('Invalid mainnet peer origin'); + const peer = new URL(text); + if ((peer.protocol !== 'https:' && !(allowPrivate && peer.protocol === 'http:')) || 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) throw new Error('Mainnet requires distinct peer origins'); +} diff --git a/server/operations-check.ts b/server/operations-check.ts new file mode 100755 index 0000000..9600783 --- /dev/null +++ b/server/operations-check.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; +const nonnegative=z.number().finite().nonnegative(); +const schema=z.object({schema:z.literal('wikideck-operations-v1'),observedAt:z.string().datetime(), + native:z.object({available:z.boolean(),queued:nonnegative}), + ledger:z.object({height:nonnegative,mutationQueue:nonnegative,tipAgeSeconds:nonnegative.nullable()}), + peers:z.object({configured:nonnegative,connected:nonnegative}), + eventLoop:z.object({meanMs:nonnegative}),disk:z.unknown(),diskSampledAt:nonnegative.nullable()}); +export function checkOperations(raw:unknown,now=Date.now()){ + const s=schema.parse(raw),warnings:string[]=[]; + const observation=Date.parse(s.observedAt); + if(Math.abs(now-observation)>60000)warnings.push('diagnostics-clock-or-freshness'); + if(!s.native.available)warnings.push('native-engine-unavailable'); + if(s.native.queued>=48||s.ledger.mutationQueue>=192)warnings.push('queue-pressure'); + if(!s.peers.configured||!s.peers.connected)warnings.push('peer-isolation'); + if(s.ledger.height===0||s.ledger.tipAgeSeconds===null||s.ledger.tipAgeSeconds>3600)warnings.push('empty-or-stale-chain'); + if(s.eventLoop.meanMs>250)warnings.push('event-loop-delay'); + const disk=z.object({availableBytes:nonnegative}).safeParse(s.disk); + if(!disk.success||!s.diskSampledAt||now-s.diskSampledAt>90000)warnings.push('disk-sample-unavailable-or-stale'); + else if(disk.data.availableBytes<512*1024*1024)warnings.push('disk-low'); + return {healthy:warnings.length===0,warnings, + note:'Local threshold hints only, not consensus readiness or launch approval. Reorg/rejection counters and native RSS need external time-series monitoring.'}; +} diff --git a/server/operations.ts b/server/operations.ts new file mode 100755 index 0000000..c5884a5 --- /dev/null +++ b/server/operations.ts @@ -0,0 +1,63 @@ +import { createServer } from 'node:http'; +import { isIP } from 'node:net'; +import { monitorEventLoopDelay } from 'node:perf_hooks'; +import { stat,statfs } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { AddressInfo } from 'node:net'; + +/** No hop-count/boolean trust. Match only explicit proxy addresses/subnets. The edge must + * overwrite forwarded headers and prevent direct backend access from trusted addresses. */ +export function trustedProxies(env: NodeJS.ProcessEnv = process.env): string[] { + const raw = env.WIKIDECK_TRUSTED_PROXIES; + if (!raw) return []; + const values=raw.split(',').map(s=>s.trim()); + if(values.length>16)throw new Error('At most 16 trusted proxy addresses/subnets'); + for(const value of values){ + const [ip,prefix,...extra]=value.split('/'),family=isIP(ip); + if(!family||extra.length||(prefix!==undefined&&(!/^\d{1,3}$/.test(prefix)||Number(prefix)<(family===4?8:32)||Number(prefix)>(family===4?32:128)))) + throw new Error('Trusted proxies must be literal IPs or bounded CIDRs, never booleans, names or hop counts'); + } + return [...new Set(values)]; +} +export function operationsPort(env: NodeJS.ProcessEnv = process.env): number | undefined { + if(env.WIKIDECK_OPS_PORT===undefined||env.WIKIDECK_OPS_PORT==='')return undefined; + const n=Number(env.WIKIDECK_OPS_PORT); + if(!/^\d+$/.test(env.WIKIDECK_OPS_PORT)||!Number.isInteger(n)||n<1||n>65535)throw new Error('Invalid WIKIDECK_OPS_PORT'); + return n; +} +/** Separate loopback listener, disabled by default. No bearer tokens or details on public RPC. + * Local users/processes share this trust boundary; never forward/publish this port. */ +export function startOperations(port: number | undefined, snapshot: ()=>unknown, directory?:string) { + if(port===undefined)return {close() {}}; + const delay=monitorEventLoopDelay({resolution:20});delay.enable(); + let disk:unknown=null,lastDiskSample=0; + const sampleDisk=async()=>{ + if(!directory)return; + try { + const fs=await statfs(directory); + const sizes=await Promise.all(['ledger.sqlite','ledger.sqlite-wal'].map(async name=>{ + try{return {name,bytes:(await stat(join(directory,name))).size};} + catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return {name,bytes:0};throw error;} + })); + disk={availableBytes:fs.bavail*fs.bsize,files:sizes};lastDiskSample=Date.now(); + }catch{disk={available:false,error:'Disk sample failed; inspect local filesystem and permissions.'};lastDiskSample=Date.now();} + }; + void sampleDisk();const diskTimer=setInterval(()=>{void sampleDisk();},30000);diskTimer.unref(); + const server=createServer((req,res)=>{ + res.setHeader('Cache-Control','no-store');res.setHeader('Content-Type','application/json'); + if(req.method!=='GET'||req.url!=='/status'){res.writeHead(404).end('{"error":"Not found"}');return;} + try { + const data=JSON.stringify({schema:'wikideck-operations-v1',observedAt:new Date().toISOString(), + uptimeSeconds:process.uptime(),nodeRssBytes:process.memoryUsage().rss, + eventLoop:{meanMs:Number.isFinite(delay.mean)?delay.mean/1e6:0,maxMs:delay.max/1e6}, + disk,diskSampledAt:lastDiskSample||null,...snapshot() as object}); + if(Buffer.byteLength(data)>64*1024)throw new Error('Diagnostics size'); + res.end(data); + }catch{res.writeHead(503).end('{"error":"Diagnostics unavailable"}');} + }); + server.maxConnections=4;server.headersTimeout=5000;server.requestTimeout=5000;server.keepAliveTimeout=1000; + server.setTimeout(5000,socket=>socket.destroy()); + server.on('error',()=>{clearInterval(diskTimer);delay.disable();console.error('Private operations listener failed; check local monitoring configuration.');}); + server.listen(port,'127.0.0.1'); + return {close(){clearInterval(diskTimer);delay.disable();server.closeAllConnections();server.close();},address:()=>server.address() as AddressInfo|null}; +} diff --git a/server/preflight.ts b/server/preflight.ts new file mode 100755 index 0000000..f5395b7 --- /dev/null +++ b/server/preflight.ts @@ -0,0 +1,27 @@ +import { IS_MAINNET } from '../shared/network.ts'; +import { assertLaunchAllowed, launchManifest } from './launch.ts'; + +export type LaunchCheck = { id: string; status: 'pass' | 'blocked' | 'unverified'; detail: string }; +/** Read-only local assessment. Never opens a ledger, starts PoW, contacts peers, or sets approvals. + * Configured approvals cannot resolve findings that require new code or independent evidence. */ +export function launchPreflight(env: NodeJS.ProcessEnv = process.env, now = Date.now()) { + const checks: LaunchCheck[] = [{ id: 'network-selection', status: IS_MAINNET ? 'pass' : 'blocked', + detail: IS_MAINNET ? 'Separate mainnet candidate selected.' : 'Testnet selected; this is not a production profile.' }]; + if (IS_MAINNET) { + try { + assertLaunchAllowed(env, now); + checks.push({ id: 'operator-startup-gates', status: 'pass', detail: 'Local startup gates satisfied; this does not establish production readiness or peer independence.' }); + } catch (error) { + checks.push({ id: 'operator-startup-gates', status: 'blocked', detail: error instanceof Error ? error.message : 'Startup gate failed.' }); + } + } + checks.push( + { id: 'public-node-resource-controls', status: 'blocked', detail: 'Global RPC budgets, bounded pages, and browser history controls exist. In-memory DAG growth and production-scale load/capacity evidence remain unresolved.' }, + { id: 'consensus-launch-calibration', status: 'blocked', detail: 'Bounded offline native benchmarks and deterministic retarget/timestamp analysis exist. Representative network hashrate, multi-host adversarial evidence, and the operator-frozen launch rules/genesis remain missing; external audit is not planned under the current policy.' }, + { id: 'wallet-key-protection', status: 'blocked', detail: 'Encrypted vault storage, explicit migration/unlock, and lock-aware signing exist. Legacy plaintext copies, unlocked-page compromise, supported-browser lifecycle coverage, and wallet recovery drills remain unresolved; the operator accepts that no external wallet audit is planned.' }, + { id: 'release-risk-acceptance', status: 'unverified', detail: 'External audit is not planned by operator choice. Record explicit acceptance of unaudited protocol/wallet risk and complete release-specific dependency/native/container scans.' }, + { id: 'deployment-rehearsal', status: 'unverified', detail: 'Explicit proxy trust, private diagnostics/checker, and stopped-node ledger backup/restore with local native replay tests exist. Actual HTTPS/proxy topology, independent hosts/miners, alert delivery, off-host recovery, and on-call procedures remain unverified.' }, + ); + return { ...launchManifest(), assessedAt: new Date(now).toISOString(), productionReady: false, + decision: 'NO-GO', scope: 'Local code/configuration preflight; not an independent audit or launch approval.', checks }; +} diff --git a/server/recovery.ts b/server/recovery.ts new file mode 100755 index 0000000..9e41ecb --- /dev/null +++ b/server/recovery.ts @@ -0,0 +1,117 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { mkdir, open, unlink, lstat, readFile, writeFile, chmod, copyFile, realpath } from 'node:fs/promises'; +import { join, resolve, relative, isAbsolute } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { z } from 'zod'; +import { NETWORK, PROFILE } from '../shared/network.ts'; +import { CHAIN_ID } from './testnet/consensus.ts'; + +const ledgerFile='ledger.sqlite'; +const legacyFiles=['ledger.sqlite','market.sqlite','custody-sandbox.sqlite'] as const; +const legacyManifestSchema=z.object({format:z.literal('wikideck-stopped-backup'),version:z.literal(1), + network:z.literal(NETWORK),chainId:z.literal(CHAIN_ID),profile:z.literal(PROFILE),createdAt:z.string().datetime(), + files:z.array(z.object({name:z.enum(legacyFiles),bytes:z.number().int().positive().max(64*1024**3),sha256:z.string().regex(/^[a-f0-9]{64}$/)}).strict()).min(2).max(3)}).strict() + .refine(m => new Set(m.files.map(f=>f.name)).size === m.files.length && + m.files.some(f=>f.name==='ledger.sqlite') && m.files.some(f=>f.name==='market.sqlite'), 'Missing or duplicate legacy backup database'); +const manifestSchema=z.object({format:z.literal('wikideck-stopped-backup'),version:z.literal(2), + network:z.literal(NETWORK),chainId:z.literal(CHAIN_ID),profile:z.literal(PROFILE),createdAt:z.string().datetime(), + files:z.tuple([z.object({name:z.literal(ledgerFile),bytes:z.number().int().positive().max(64*1024**3), + sha256:z.string().regex(/^[a-f0-9]{64}$/)}).strict()])}).strict(); +const supportedManifestSchema=z.union([manifestSchema,legacyManifestSchema]); +async function regular(path:string){const s=await lstat(path);if(!s.isFile()||s.isSymbolicLink())throw new Error('Expected regular non-symlink database file');return s;} +async function digest(path:string){const hash=createHash('sha256');for await(const chunk of createReadStream(path))hash.update(chunk);return hash.digest('hex');} +async function flush(path:string){const handle=await open(path,'r+');try{await handle.sync();}finally{await handle.close();}} +async function flushDirectory(path:string){ + if(process.platform==='win32')return; // Windows directory durability requires filesystem-specific operational validation. + const handle=await open(path,'r');try{await handle.sync();}finally{await handle.close();} +} +function inspect(file:string,name:string) { + const db=new DatabaseSync(file,{readOnly:true}); + try { + const result=db.prepare('PRAGMA quick_check').all(); + if(result.length!==1||Object.values(result[0])[0]!=='ok')throw new Error('SQLite integrity check failed'); + if(name==='ledger.sqlite'){ + const meta=(key:string)=>(db.prepare('SELECT value FROM metadata WHERE key=?').get(key) as {value:string}|undefined)?.value; + if(meta('schema_version')!=='1'||meta('network')!==NETWORK||meta('chain_id')!==CHAIN_ID)throw new Error('Ledger network/schema mismatch'); + } + }finally{db.close();} +} +/** Share the exact node startup lock. A stale lock is never removed automatically. */ +async function offlineLock(dataDir:string,fn:()=>Promise):Promise{ + const lockPath=join(dataDir,`${PROFILE}-node.lock`); + const handle=await open(lockPath,'wx',0o600).catch(()=>{throw new Error('Node directory is locked; stop the node and review stale locks manually.');}); + try{await handle.writeFile(`maintenance:${process.pid}`);return await fn();} + finally{await handle.close();await unlink(lockPath);} +} +/** Run a pair-backup operation while holding the exact exclusive lock used by node startup. */ +export async function withStoppedNodeLock(dataDir:string,operation:(source:string)=>Promise):Promise{ + const source=await realpath(dataDir); + return offlineLock(source,()=>operation(source)); +} +/** Snapshot the local SQLite half while the caller holds withStoppedNodeLock(). */ +export async function createNodeSnapshotUnlocked(source:string,destination:string){ + const dest=resolve(destination); + const rel=relative(source,dest); + if(!rel||(!rel.startsWith('..')&&!isAbsolute(rel)))throw new Error('Backup destination must be outside the node data directory'); + // File-only backups cannot represent an external financial database. Fail rather than omit liabilities. + // Never create a misleading ledger-only backup when retired financial journals remain. + for(const name of ['market.sqlite','custody-sandbox.sqlite','custody-postgres.json']){ + try { await lstat(join(source,PROFILE,name)); throw new Error(`Legacy financial data (${name}) requires a separately retained pre-retirement backup`); } + catch(e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; } + } + if(await realpath(join(source,PROFILE))!==join(source,PROFILE))throw new Error('Network data directory must not be symlinked'); + await mkdir(dest,{mode:0o700}); // Exclusive: never overwrite or recursively create a surprising destination. + const src=join(source,PROFILE,ledgerFile),target=join(dest,ledgerFile); + await regular(src);inspect(src,ledgerFile); + const db=new DatabaseSync(src,{readOnly:true}); + try{db.prepare('VACUUM INTO ?').run(target);}finally{db.close();} + if(process.platform!=='win32')await chmod(target,0o600); + await flush(target); + inspect(target,ledgerFile);const info=await regular(target); + const manifest=manifestSchema.parse({format:'wikideck-stopped-backup',version:2,network:NETWORK,chainId:CHAIN_ID,profile:PROFILE, + createdAt:new Date().toISOString(),files:[{name:ledgerFile,bytes:info.size,sha256:await digest(target)}]}); + // Completion marker written last. Partial output is preserved for operator inspection, never treated as usable. + await writeFile(join(dest,'manifest.json'),JSON.stringify(manifest,null,2),{flag:'wx',mode:0o600}); + await flush(join(dest,'manifest.json')); + await flushDirectory(dest); + return manifest; +} +export async function createBackup(dataDir:string,destination:string){ + return withStoppedNodeLock(dataDir,source=>createNodeSnapshotUnlocked(source,destination)); +} +export async function verifyBackup(directory:string){ + const root=await realpath(directory),manifestFile=join(root,'manifest.json'); + if((await regular(manifestFile)).size>16384)throw new Error('Backup manifest too large'); + const manifest=supportedManifestSchema.parse(JSON.parse(await readFile(manifestFile,'utf8'))); + if(new Set(manifest.files.map(f=>f.name)).size!==manifest.files.length)throw new Error('Duplicate backup database'); + for(const file of manifest.files){ + const path=join(root,file.name),info=await regular(path); + if(info.size!==file.bytes||await digest(path)!==file.sha256)throw new Error('Backup database hash/size mismatch'); + // A completed package has standalone DBs. Sidecars would change what SQLite reads versus the hashed file. + for(const suffix of ['-wal','-shm','-journal']){ + try{await lstat(path+suffix);}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')continue;throw e;} + throw new Error('Backup database has unexpected SQLite sidecar'); + } + inspect(path,file.name); + } + return manifest; +} +export async function restoreBackup(directory:string,destination:string){ + const manifest=await verifyBackup(directory),dest=resolve(destination); + await mkdir(dest,{mode:0o700}); // Must not exist, even if empty. Never reset a node in place. + return offlineLock(dest,async()=>{ + const folder=join(dest,PROFILE);await mkdir(folder,{mode:0o700}); + for(const file of manifest.files){ + const target=join(folder,file.name); + await copyFile(join(directory,file.name),target,1); // COPYFILE_EXCL + if(process.platform!=='win32')await chmod(target,0o600); + await flush(target); + if(await digest(target)!==file.sha256)throw new Error('Restored copy hash mismatch'); + inspect(target,file.name); + } + await flushDirectory(folder);await flushDirectory(dest); + return {network:NETWORK,chainId:CHAIN_ID,restored:true,proofsRevalidated:false, + warning:'SQLite/hash checks only. Replay with the reference engine in an isolated reviewed rehearsal before production use.'}; + }); +} diff --git a/server/resources.ts b/server/resources.ts new file mode 100755 index 0000000..68aa4e5 --- /dev/null +++ b/server/resources.ts @@ -0,0 +1,89 @@ +import type { RequestHandler } from 'express'; + +export class RpcError extends Error { + readonly status: number; + constructor(status: number, message: string) { super(message); this.status = status; } +} +export type ResourceConfig = { requestsPerSecond: number; writesPerSecond: number; active: number; + responseBytes: number; bytesPerSecond: number; legacyBlocks: number }; +export function resourceConfig(env: NodeJS.ProcessEnv = process.env): ResourceConfig { + const number = (name: string, fallback: number, min: number, max: number) => { + const text = env[name] ?? String(fallback), n = Number(text); + if (!/^\d+$/.test(text) || !Number.isSafeInteger(n) || n < min || n > max) throw new Error(`Invalid ${name}`); + return n; + }; + return { requestsPerSecond: number('WIKIDECK_RPC_REQUESTS_PER_SECOND', 120, 1, 1000), + writesPerSecond: number('WIKIDECK_RPC_WRITES_PER_SECOND', 10, 1, 100), + active: number('WIKIDECK_RPC_MAX_ACTIVE', 32, 1, 256), + responseBytes: number('WIKIDECK_RPC_RESPONSE_BYTES', 2 * 1024 * 1024, 128 * 1024, 2 * 1024 * 1024), + bytesPerSecond: number('WIKIDECK_RPC_BYTES_PER_SECOND', 8 * 1024 * 1024, 2 * 1024 * 1024, 128 * 1024 * 1024), + legacyBlocks: number('WIKIDECK_RPC_LEGACY_BLOCKS', 128, 0, 128) }; +} + +/** Bounded, constant-storage global budgets. No per-client map or retry queue. + * Peer traffic has its own lane so a saturated browser lane cannot consume its tokens. */ +export class RpcBudget { + private tokens: number; + private writeTokens: number; + private byteTokens: number; + private updated: number; + private active = 0; + private rejected = 0; + readonly config: ResourceConfig; + constructor(config: ResourceConfig, privateClock: () => number = () => performance.now()) { + this.config = config; + this.clock = privateClock; + this.tokens = config.requestsPerSecond; this.writeTokens = config.writesPerSecond; + this.byteTokens = config.bytesPerSecond; this.updated = this.clock(); + } + private readonly clock: () => number; + private refill() { + const now = this.clock(), elapsed = Math.max(0, now - this.updated) / 1000; + this.updated = now; + this.tokens = Math.min(this.config.requestsPerSecond, this.tokens + elapsed * this.config.requestsPerSecond); + this.writeTokens = Math.min(this.config.writesPerSecond, this.writeTokens + elapsed * this.config.writesPerSecond); + this.byteTokens = Math.min(this.config.bytesPerSecond, this.byteTokens + elapsed * this.config.bytesPerSecond); + } + acquire(write: boolean): () => void { + this.refill(); + if (this.active >= this.config.active || this.tokens < 1 || (write && this.writeTokens < 1)) { + this.rejected++; throw new RpcError(503, 'Node RPC admission budget exhausted; retry later.'); + } + this.tokens--; if (write) this.writeTokens--; + this.active++; + let released = false; + return () => { if (!released) { this.active--; released = true; } }; + } + encode(value: unknown): string { + const encoded = JSON.stringify(value), bytes = Buffer.byteLength(encoded); + if (bytes > this.config.responseBytes) { + this.rejected++; throw new RpcError(413, 'RPC response limit exceeded; use smaller paged requests.'); + } + this.refill(); + if (bytes > this.byteTokens) { this.rejected++; throw new RpcError(503, 'Node RPC response-byte budget exhausted; retry later.'); } + this.byteTokens -= bytes; + return encoded; + } + status() { return { active: this.active, rejected: this.rejected }; } + middleware(): RequestHandler { + return (req, res, next) => { + res.setHeader('Cache-Control', 'no-store'); + let release: () => void; + try { release = this.acquire(req.method !== 'GET' && req.method !== 'HEAD'); } + catch (error) { + res.setHeader('Retry-After', '1'); + res.status(503).json({ error: (error as Error).message }); return; + } + res.once('finish', release); res.once('close', release); + // Express.json normally serializes before send; intercept here so no oversized payload is sent. + const originalJson = res.json.bind(res); + res.json = (value: unknown) => { + try { + const encoded = this.encode(value); + return res.type('application/json').send(encoded); + } catch (error) { res.json = originalJson; throw error; } + }; + next(); + }; + } +} diff --git a/server/testnet/canonical-index.ts b/server/testnet/canonical-index.ts new file mode 100755 index 0000000..4460fe8 --- /dev/null +++ b/server/testnet/canonical-index.ts @@ -0,0 +1,186 @@ +import type { DatabaseSync } from 'node:sqlite'; +import { randomUUID } from 'node:crypto'; +import { isNftTrade, transactionCardIds, type NftState, type TestnetBlock } from '../../shared/testnet.ts'; +import { GENESIS_HASH, sha256, transactionId } from './consensus.ts'; +import { RpcError } from '../resources.ts'; + +export type CanonicalEvent = { sequence: number; type: 'connect' | 'disconnect'; + height: number; hash: string; previousHash: string; transactionIds: string[]; eventHash: string }; +export type CanonicalEvents = { streamId: string; previousAnchor: string; anchor: string; + events: CanonicalEvent[]; cursor: number; head: number; caughtUp: boolean }; +type NftRow = { token: string; owner: string; sequence: number; minted_height: number; block_hash: string }; +export type IndexedNft = { block: TestnetBlock; owner: string; sequence: number; confirmations: number }; + +/** Local derived indexes, NOT consensus. Writes share the ledger's block/pending transaction. */ +export class CanonicalIndex { + private readonly db: DatabaseSync; + private streamId = ''; + constructor(db: DatabaseSync) { this.db = db; } + + initialize(chain: TestnetBlock[], state: NftState): void { + this.db.exec('BEGIN IMMEDIATE'); + try { + this.db.exec(`CREATE TABLE IF NOT EXISTS canonical_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS canonical_blocks(height INTEGER PRIMARY KEY,hash TEXT NOT NULL UNIQUE REFERENCES blocks(hash)); + CREATE TABLE IF NOT EXISTS canonical_nfts(token TEXT PRIMARY KEY,owner TEXT NOT NULL,sequence INTEGER NOT NULL, + minted_height INTEGER NOT NULL,block_hash TEXT NOT NULL REFERENCES blocks(hash)); + CREATE INDEX IF NOT EXISTS canonical_owner ON canonical_nfts(owner,minted_height,token); + CREATE TABLE IF NOT EXISTS canonical_transactions(id TEXT PRIMARY KEY,height INTEGER NOT NULL,block_hash TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS canonical_tx_height ON canonical_transactions(height); + CREATE TABLE IF NOT EXISTS canonical_undo(height INTEGER PRIMARY KEY,data TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS canonical_events(sequence INTEGER PRIMARY KEY AUTOINCREMENT,type TEXT NOT NULL, + height INTEGER NOT NULL,hash TEXT NOT NULL,previous_hash TEXT NOT NULL,transactions_json TEXT NOT NULL, + previous_event_hash TEXT NOT NULL,event_hash TEXT NOT NULL); + CREATE TRIGGER IF NOT EXISTS canonical_events_no_update BEFORE UPDATE ON canonical_events + BEGIN SELECT RAISE(ABORT,'Immutable canonical event'); END; + CREATE TRIGGER IF NOT EXISTS canonical_events_no_delete BEFORE DELETE ON canonical_events + BEGIN SELECT RAISE(ABORT,'Immutable canonical event'); END;`); + const version = this.db.prepare("SELECT value FROM canonical_meta WHERE key='version'").get() as { value: string } | undefined; + if (!version) { + // Additive backfill only after Ledger.open has independently validated all persisted proofs/state. + const count = this.db.prepare('SELECT COUNT(*) AS n FROM canonical_events').get() as { n: number }; + if (count.n || (this.db.prepare('SELECT COUNT(*) AS n FROM canonical_blocks').get() as { n: number }).n) { + throw new Error('Partial canonical index; offline operator review required'); + } + this.streamId = randomUUID(); + this.db.prepare('INSERT INTO canonical_meta VALUES(?,?)').run('stream_id', this.streamId); + for (const block of chain) this.connect(block); + this.db.prepare('INSERT INTO canonical_meta VALUES(?,?)').run('version', '1'); + } else if (version.value !== '1') throw new Error('Canonical index version mismatch'); + else { + const row = this.db.prepare("SELECT value FROM canonical_meta WHERE key='stream_id'").get() as { value: string } | undefined; + if (!row || !/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(row.value)) throw new Error('Canonical stream identity missing'); + this.streamId = row.value; + } + this.verify(chain, state); + this.db.exec('COMMIT'); + } catch (error) { this.db.exec('ROLLBACK'); throw error; } + } + private verify(chain: TestnetBlock[], state: NftState): void { + const blocks = this.db.prepare('SELECT height,hash FROM canonical_blocks ORDER BY height').all() as { height: number; hash: string }[]; + if (blocks.length !== chain.length || blocks.some((b, i) => b.height !== chain[i].height || b.hash !== chain[i].hash)) { + throw new Error('Canonical block index mismatch'); + } + const nfts = this.db.prepare('SELECT * FROM canonical_nfts').all() as NftRow[]; + const expected = new Map(chain.map(b => [b.tokenId, b])); + if (nfts.length !== expected.size) throw new Error('Canonical NFT index mismatch'); + for (const row of nfts) { + const nft = state[row.token], block = expected.get(row.token); + if (!nft || !block || row.owner !== nft.owner || row.sequence !== nft.sequence || + row.minted_height !== block.height || row.block_hash !== block.hash) throw new Error('Canonical ownership index mismatch'); + } + const transactions = this.db.prepare('SELECT id,height,block_hash FROM canonical_transactions').all() as { id: string; height: number; block_hash: string }[]; + const expectedTxs = new Map(chain.flatMap(b => b.transactions.map(tx => [transactionId(tx), b] as const))); + if (transactions.length !== expectedTxs.size || transactions.some(row => { + const block = expectedTxs.get(row.id); return !block || row.height !== block.height || row.block_hash !== block.hash; + })) throw new Error('Canonical transaction index mismatch'); + const undo = this.db.prepare('SELECT COUNT(*) AS n FROM canonical_undo').get() as { n: number }; + if (undo.n !== chain.length) throw new Error('Canonical undo index mismatch'); + // A cursor is only useful if history has no gaps and describes the same validated DAG transitions. + const stack: string[] = []; + let sequence = 0, anchor = GENESIS_HASH; + for (const raw of this.db.prepare(`SELECT e.*,b.block_json FROM canonical_events e + LEFT JOIN blocks b ON b.hash=e.hash ORDER BY e.sequence`).iterate() as Iterable<{ + sequence: number; type: string; height: number; hash: string; previous_hash: string; + transactions_json: string; block_json: string | null; previous_event_hash: string; event_hash: string }>) { + const block = raw.block_json ? JSON.parse(raw.block_json) as TestnetBlock : undefined; + if (raw.sequence !== ++sequence || !block || block.height !== raw.height || block.previousHash !== raw.previous_hash || + raw.transactions_json !== JSON.stringify(block.transactions.map(transactionId))) throw new Error('Canonical event integrity mismatch'); + if (raw.previous_event_hash !== anchor || raw.event_hash !== this.eventHash(raw.sequence, raw.type, + block, anchor)) throw new Error('Canonical event anchor mismatch'); + anchor = raw.event_hash; + if (raw.type === 'connect') { + if (raw.height !== stack.length + 1 || raw.previous_hash !== (stack.at(-1) ?? GENESIS_HASH)) throw new Error('Canonical event connect mismatch'); + stack.push(raw.hash); + } else if (raw.type === 'disconnect') { + if (raw.height !== stack.length || stack.pop() !== raw.hash) throw new Error('Canonical event disconnect mismatch'); + } else throw new Error('Unknown canonical event'); + } + if (stack.length !== chain.length || stack.some((hash, i) => hash !== chain[i].hash)) throw new Error('Canonical event tip mismatch'); + } + private event(type: CanonicalEvent['type'], block: TestnetBlock) { + const prior = this.db.prepare('SELECT sequence,event_hash FROM canonical_events ORDER BY sequence DESC LIMIT 1').get() as + { sequence: number; event_hash: string } | undefined; + const sequence = (prior?.sequence ?? 0) + 1, anchor = prior?.event_hash ?? GENESIS_HASH; + this.db.prepare('INSERT INTO canonical_events(sequence,type,height,hash,previous_hash,transactions_json,previous_event_hash,event_hash) VALUES(?,?,?,?,?,?,?,?)') + .run(sequence, type, block.height, block.hash, block.previousHash, JSON.stringify(block.transactions.map(transactionId)), + anchor, this.eventHash(sequence, type, block, anchor)); + } + private eventHash(sequence: number, type: string, block: TestnetBlock, previousAnchor: string): string { + return sha256(JSON.stringify(['wikideck-canonical-event-v1', this.streamId, sequence, type, + block.height, block.hash, block.previousHash, block.transactions.map(transactionId), previousAnchor])); + } + connect(block: TestnetBlock): void { + const tokens = new Set([block.tokenId, ...block.transactions.flatMap(transactionCardIds)]); + const undo = [...tokens].map(token => [token, this.db.prepare('SELECT * FROM canonical_nfts WHERE token=?').get(token) ?? null]); + this.db.prepare('INSERT INTO canonical_undo VALUES(?,?)').run(block.height, JSON.stringify(undo)); + this.db.prepare('INSERT INTO canonical_blocks VALUES(?,?)').run(block.height, block.hash); + this.db.prepare('INSERT INTO canonical_nfts VALUES(?,?,?,?,?)').run(block.tokenId, block.owner, 0, block.height, block.hash); + const update = this.db.prepare('UPDATE canonical_nfts SET owner=?,sequence=sequence+1 WHERE token=?'); + for (const tx of block.transactions) { + if (isNftTrade(tx)) { + for (const leg of tx.offered) update.run(tx.counterparty, leg.cardHash); + for (const leg of tx.requested) update.run(tx.proposer, leg.cardHash); + } else update.run(tx.to, tx.cardHash); + this.db.prepare('INSERT INTO canonical_transactions VALUES(?,?,?)').run(transactionId(tx), block.height, block.hash); + } + this.event('connect', block); + } + disconnect(block: TestnetBlock): void { + const row = this.db.prepare('SELECT data FROM canonical_undo WHERE height=?').get(block.height) as { data: string } | undefined; + if (!row) throw new Error('Missing canonical undo record'); + for (const [token, prior] of JSON.parse(row.data) as [string, NftRow | null][]) { + this.db.prepare('DELETE FROM canonical_nfts WHERE token=?').run(token); + if (prior) this.db.prepare('INSERT INTO canonical_nfts VALUES(?,?,?,?,?)') + .run(prior.token, prior.owner, prior.sequence, prior.minted_height, prior.block_hash); + } + this.db.prepare('DELETE FROM canonical_transactions WHERE height=?').run(block.height); + this.db.prepare('DELETE FROM canonical_blocks WHERE height=?').run(block.height); + this.db.prepare('DELETE FROM canonical_undo WHERE height=?').run(block.height); + this.event('disconnect', block); + } + head(): number { + return (this.db.prepare('SELECT COALESCE(MAX(sequence),0) AS n FROM canonical_events').get() as { n: number }).n; + } + events(after: number, limit = 64): CanonicalEvents { + const head = this.head(); + if (!Number.isSafeInteger(after) || after < 0 || after > head || !Number.isSafeInteger(limit) || limit < 1 || limit > 64) { + throw new RpcError(409, 'Invalid canonical event cursor; restore/reconcile local consumer state.'); + } + const rows = this.db.prepare('SELECT * FROM canonical_events WHERE sequence>? ORDER BY sequence LIMIT ?').all(after, limit) as + { sequence: number; type: CanonicalEvent['type']; height: number; hash: string; previous_hash: string; + transactions_json: string; event_hash: string }[]; + if (rows.some((r, i) => r.sequence !== after + i + 1)) throw new Error('Canonical event history gap'); + const events = rows.map(r => ({ sequence: r.sequence, type: r.type, height: r.height, hash: r.hash, + previousHash: r.previous_hash, transactionIds: JSON.parse(r.transactions_json) as string[], eventHash: r.event_hash })); + const cursor = events.at(-1)?.sequence ?? after; + if (cursor < head && !events.length) throw new Error('Missing canonical event history'); + const previousAnchor = after === 0 ? GENESIS_HASH : (this.db.prepare('SELECT event_hash FROM canonical_events WHERE sequence=?').get(after) as { event_hash: string } | undefined)?.event_hash; + if (!previousAnchor) throw new Error('Missing canonical cursor anchor'); + return { streamId: this.streamId, previousAnchor, anchor: events.at(-1)?.eventHash ?? previousAnchor, + events, cursor, head, caughtUp: cursor === head }; + } + nft(token: string, height: number): IndexedNft | undefined { + const row = this.db.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) as + { owner: string; sequence: number; minted_height: number; block_json: string } | undefined; + return row ? { block: JSON.parse(row.block_json), owner: row.owner, sequence: row.sequence, + confirmations: height - row.minted_height + 1 } : undefined; + } + transaction(id: string): { height: number; blockHash: string } | undefined { + const row = this.db.prepare('SELECT height,block_hash FROM canonical_transactions WHERE id=?').get(id) as + { height: number; block_hash: string } | undefined; + return row ? { height: row.height, blockHash: row.block_hash } : undefined; + } + deck(owner: string, offset: number, limit: number, height: number) { + const total = (this.db.prepare('SELECT COUNT(*) AS n FROM canonical_nfts WHERE owner=?').get(owner) as { n: number }).n; + const rows = this.db.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.owner=? ORDER BY n.minted_height,n.token LIMIT ? OFFSET ?`) + .all(owner, limit, offset) as { owner: string; sequence: number; minted_height: number; block_json: string }[]; + return { total, nfts: rows.map(r => ({ block: JSON.parse(r.block_json) as TestnetBlock, + sequence: r.sequence, confirmations: height - r.minted_height + 1 })) }; + } + tip(): string { + return (this.db.prepare('SELECT hash FROM canonical_blocks ORDER BY height DESC LIMIT 1').get() as { hash: string } | undefined)?.hash ?? GENESIS_HASH; + } +} diff --git a/server/testnet/consensus.ts b/server/testnet/consensus.ts index b4ee5b7..c7b402a 100644 --- a/server/testnet/consensus.ts +++ b/server/testnet/consensus.ts @@ -1,15 +1,17 @@ import { createHash, createPublicKey, verify } from 'node:crypto'; import { z } from 'zod'; -import { NETWORK, CONSENSUS, headerMessage, blockClaimMessage, nftTransferMessage, statsSnapshotTuple, - type BlockHeader, type TestnetBlock, type NftTransfer, type NftState, type MiningTemplate } from '../../shared/testnet.ts'; +import { NETWORK, CONSENSUS, MAX_TARGET, headerMessage, blockClaimMessage, nftTransferMessage, nftTradeMessage, statsSnapshotTuple, + type BlockHeader, type TestnetBlock, type NftTransfer, type NftState, type NftStateLayer, + isNftTrade, type NftStateView, type NftTrade, type TestnetTransaction, type MiningTemplate } from '../../shared/testnet.ts'; import type { Article } from '../../shared/protocol.ts'; import { deriveStats, type MintStatsSnapshot } from '../../shared/enrichment.ts'; import type { PowEngine } from './randomx.ts'; +import { NETWORK_GENESIS_TIME } from '../../shared/network.ts'; export const sha256 = (data: string | Uint8Array) => createHash('sha256').update(data).digest('hex'); -export const MAX_TARGET = `0fff${'f'.repeat(60)}`; +export { MAX_TARGET }; // Consensus manifest: an independently running node derives exactly the same genesis. -export const GENESIS_TIME = 1_792_411_200; +export const GENESIS_TIME = NETWORK_GENESIS_TIME; export const CHAIN_ID = sha256(JSON.stringify([NETWORK, CONSENSUS, GENESIS_TIME, MAX_TARGET])); export const GENESIS_HASH = sha256(`${NETWORK}:genesis:${CHAIN_ID}`); export const INITIAL_KEY = sha256(`${NETWORK}:initial-key:${CHAIN_ID}`); @@ -19,6 +21,25 @@ const signature = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).length(88); const integer = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); export const transferSchema = z.object({ network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID), cardHash: hex, from: hex, to: hex, sequence: integer, publicKey: key, signature }).strict(); +const tradeLegSchema = z.object({ cardHash: hex, sequence: integer }).strict(); +export const tradeSchema = z.object({ type: z.literal('trade'), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID), + proposer: hex, counterparty: hex, offered: z.array(tradeLegSchema).min(1).max(CONSENSUS.maxTradeCards), + requested: z.array(tradeLegSchema).min(1).max(CONSENSUS.maxTradeCards), + proposerPublicKey: key, proposerSignature: signature, counterpartyPublicKey: key, + counterpartySignature: signature }).strict().superRefine((trade, ctx) => { + const offered = new Set(trade.offered.map(leg => leg.cardHash)); + const requested = new Set(trade.requested.map(leg => leg.cardHash)); + if (trade.proposer === trade.counterparty) ctx.addIssue({ code: 'custom', message: 'Trade parties must differ' }); + if (offered.size !== trade.offered.length || requested.size !== trade.requested.length || + trade.requested.some(leg => offered.has(leg.cardHash))) { + ctx.addIssue({ code: 'custom', message: 'Trade cards must be unique and cannot appear on both sides' }); + } + if (!verifySignature(trade.proposerPublicKey, trade.proposer, nftTradeMessage(trade), trade.proposerSignature) || + !verifySignature(trade.counterpartyPublicKey, trade.counterparty, nftTradeMessage(trade), trade.counterpartySignature)) { + ctx.addIssue({ code: 'custom', message: 'Trade requires valid signatures from both parties' }); + } + }); +export const transactionSchema = z.union([transferSchema, tradeSchema]); const articleSchema = z.object({ pageId: integer.positive(), title: z.string().trim().min(1).max(200), extract: z.string().trim().min(1).max(1000), image: z.string().url().max(1500), url: z.string().url().max(500), revisionId: integer.positive(), @@ -70,10 +91,10 @@ const statsSnapshotSchema = z.object({ capturedAt: z.string().datetime(), revisi ctx.addIssue({ code: 'custom', message: 'Derived statistics do not match the page metrics', path: ['stats'] }); } }); -export const blockSchema = z.object({ version: z.literal(4), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID), +export const blockSchema = z.object({ version: z.literal(6), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID), height: integer, previousHash: hex, createdAt: integer, article: articleSchema, statsSnapshot: statsSnapshotSchema, owner: hex, publicKey: key, nonce: integer, target: hex, key: hex, transactionsRoot: hex, stateRoot: hex, - hash: hex, tokenId: hex, powHash: hex, signature, transactions: z.array(transferSchema).max(CONSENSUS.maxTransactions) }).strict(); + hash: hex, tokenId: hex, powHash: hex, signature, transactions: z.array(transactionSchema).max(CONSENSUS.maxTransactions) }).strict(); export function address(publicKey: string): string { return sha256(Buffer.from(publicKey, 'base64')); } export function verifySignature(publicKey: string, owner: string, message: string, sig: string): boolean { @@ -90,6 +111,10 @@ export function transferId(t: NftTransfer): string { // Exclude malleable ECDSA signature bytes from transaction identity. return sha256(nftTransferMessage(t)); } +export function transactionId(transaction: TestnetTransaction): string { + if (isNftTrade(transaction)) return sha256(nftTradeMessage(transaction)); + return transferId(transaction); +} export function merkleRoot(leaves: string[]): string { if (!leaves.length) return sha256(`${NETWORK}:empty`); let layer = leaves.map(leaf => sha256(Buffer.concat([Buffer.from([0]), Buffer.from(leaf,'hex')]))); @@ -102,9 +127,32 @@ export function merkleRoot(leaves: string[]): string { // Commit the count as well: odd-leaf padding must not make [a,b,c] equal [a,b,c,c]. return sha256(JSON.stringify([NETWORK,'merkle',leaves.length,layer[0]])); } -export function stateRoot(state: NftState): string { - return merkleRoot(Object.keys(state).sort().map(id => { - const n = state[id]; return sha256(JSON.stringify([id, n.owner, n.sequence, n.mintedHeight, n.pageId])); +export function materializeState(state: NftStateView): NftState { + const layers: NftState[] = []; + let cursor: NftStateView = state; + while (isStateLayer(cursor)) { + layers.push(cursor.changes); + cursor = cursor.parent; + } + const result: NftState = Object.assign(Object.create(null) as NftState, cursor as NftState); + for (let i = layers.length - 1; i >= 0; i--) Object.assign(result, layers[i]); + return result; +} +function stateEntry(state: NftStateView, id: string): NftState[string] | undefined { + let cursor: NftStateView = state; + while (isStateLayer(cursor)) { + if (Object.hasOwn(cursor.changes, id)) return cursor.changes[id]; + cursor = cursor.parent; + } + return (cursor as NftState)[id]; +} +function isStateLayer(state: NftStateView): state is NftStateLayer { + return (state as NftStateLayer).kind === 'layer'; +} +export function stateRoot(state: NftStateView): string { + const materialized = materializeState(state); + return merkleRoot(Object.keys(materialized).sort().map(id => { + const n = materialized[id]; return sha256(JSON.stringify([id, n.owner, n.sequence, n.mintedHeight, n.pageId])); })); } export function mintId(h: Pick): string { @@ -143,47 +191,94 @@ export function applyTransfer(state: NftState, tx: NftTransfer, height: number): if (!verifySignature(tx.publicKey, tx.from, nftTransferMessage(tx), tx.signature)) throw new Error('Invalid transfer signature'); state[tx.cardHash] = { ...nft, owner: tx.to, sequence: tx.sequence + 1 }; } -export function transition(parent: NftState, header: BlockHeader, txs: NftTransfer[]): NftState { - const next = structuredClone(parent); +export function applyTransferOverlay(state: NftStateView, tx: NftTransfer, height: number): NftStateLayer { + if (!transferSchema.safeParse(tx).success) throw new Error('Invalid transfer encoding/network'); + const nft = stateEntry(state, tx.cardHash); + if (!nft || nft.owner !== tx.from || tx.from === tx.to || nft.sequence !== tx.sequence) { + throw new Error('Invalid NFT owner/sequence'); + } + if (height - nft.mintedHeight < CONSENSUS.coinbaseMaturity) throw new Error('NFT coinbase is immature'); + if (!verifySignature(tx.publicKey, tx.from, nftTransferMessage(tx), tx.signature)) throw new Error('Invalid transfer signature'); + const changes: NftState = Object.create(null) as NftState; + changes[tx.cardHash] = { ...nft, owner: tx.to, sequence: tx.sequence + 1 }; + return { kind: 'layer', parent: state, changes }; +} +export function applyTradeOverlay(state: NftStateView, trade: NftTrade, height: number): NftStateLayer { + const parsed = tradeSchema.parse(trade); + const allLegs = [...parsed.offered, ...parsed.requested]; + const current = allLegs.map(leg => stateEntry(state, leg.cardHash)); + if (current.some((entry, index) => !entry || entry.sequence !== allLegs[index].sequence || + height - entry.mintedHeight < CONSENSUS.coinbaseMaturity)) { + throw new Error('Trade contains missing, stale, or immature NFTs'); + } + if (current.slice(0, parsed.offered.length).some(entry => entry!.owner !== parsed.proposer) || + current.slice(parsed.offered.length).some(entry => entry!.owner !== parsed.counterparty)) { + throw new Error('Trade NFT owners do not match the signed parties'); + } + const changes: NftState = Object.create(null) as NftState; + parsed.offered.forEach((leg, index) => { + const nft = current[index]!; + changes[leg.cardHash] = { ...nft, owner: parsed.counterparty, sequence: nft.sequence + 1 }; + }); + parsed.requested.forEach((leg, index) => { + const nft = current[parsed.offered.length + index]!; + changes[leg.cardHash] = { ...nft, owner: parsed.proposer, sequence: nft.sequence + 1 }; + }); + return { kind: 'layer', parent: state, changes }; +} +export function applyTransactionOverlay(state: NftStateView, transaction: TestnetTransaction, height: number): NftStateLayer { + return isNftTrade(transaction) + ? applyTradeOverlay(state, transaction, height) + : applyTransferOverlay(state, transaction, height); +} +export function transition(parent: NftStateView, header: BlockHeader, txs: TestnetTransaction[]): NftStateLayer { + let next = parent; const ids = new Set(); - for (const tx of txs) { - const id = transferId(tx); + for (const transaction of txs) { + const id = transactionId(transaction); if (ids.has(id)) throw new Error('Duplicate transfer'); - ids.add(id); applyTransfer(next, tx, header.height); + ids.add(id); + next = applyTransactionOverlay(next, transaction, header.height); } const id = mintId(header); - if (next[id]) throw new Error('Duplicate NFT issuance'); - next[id] = { owner: header.owner, sequence: 0, mintedHeight: header.height, pageId: header.article.pageId }; - return next; + if (stateEntry(next, id)) throw new Error('Duplicate NFT issuance'); + const changes: NftState = Object.create(null) as NftState; + changes[id] = { owner: header.owner, sequence: 0, mintedHeight: header.height, pageId: header.article.pageId }; + return { kind: 'layer', parent: next, changes }; } -export function makeTemplate(ancestors: TestnetBlock[], state: NftState, owner: string, - publicKey: string, transactions: NftTransfer[], article: Article, statsSnapshot: MintStatsSnapshot, +export function makeTemplate(ancestors: TestnetBlock[], state: NftStateView, owner: string, + publicKey: string, transactions: TestnetTransaction[], article: Article, statsSnapshot: MintStatsSnapshot, now = Math.floor(Date.now()/1000)): MiningTemplate { if (!hex.safeParse(owner).success || address(publicKey) !== owner) throw new Error('Invalid miner identity'); const parsedArticle = articleSchema.parse(article); const parsedStats = statsSnapshotSchema.parse(statsSnapshot); const previousHash = ancestors.at(-1)?.hash ?? GENESIS_HASH; - const header: BlockHeader = { version: 4, network: NETWORK, chainId: CHAIN_ID, + const header: BlockHeader = { version: 6, network: NETWORK, chainId: CHAIN_ID, height: ancestors.length + 1, previousHash, createdAt: Math.max(now, medianTime(ancestors) + 1), article: parsedArticle, statsSnapshot: parsedStats, owner, publicKey, nonce: 0, target: expectedTarget(ancestors), - key: expectedKey(ancestors, ancestors.length + 1), transactionsRoot: merkleRoot(transactions.map(transferId)), stateRoot: '' }; + key: expectedKey(ancestors, ancestors.length + 1), transactionsRoot: merkleRoot(transactions.map(transactionId)), stateRoot: '' }; header.stateRoot = stateRoot(transition(state, header, transactions)); return { header, transactions, blob: miningBlob(header).toString('hex'), nonceOffset: 32 }; } -export async function validateBlock(raw: unknown, ancestors: TestnetBlock[], parentState: NftState, - pow: PowEngine, now = Math.floor(Date.now()/1000)): Promise<{block:TestnetBlock;state:NftState}> { +export async function validateBlock(raw: unknown, ancestors: TestnetBlock[], parentState: NftStateView, + pow: PowEngine, now = Math.floor(Date.now()/1000), allowHistoricalFutureTimestamp = false): Promise<{block:TestnetBlock;state:NftStateView}> { if (Buffer.byteLength(JSON.stringify(raw)) > CONSENSUS.maxBlockBytes) throw new Error('Block size limit exceeded'); const b = blockSchema.parse(raw); if (b.height !== ancestors.length + 1 || b.previousHash !== (ancestors.at(-1)?.hash ?? GENESIS_HASH)) throw new Error('Invalid parent/height'); - if (b.createdAt <= medianTime(ancestors) || b.createdAt > now + CONSENSUS.maxFutureSeconds) throw new Error('Invalid block timestamp'); + if (b.createdAt <= medianTime(ancestors) || (!allowHistoricalFutureTimestamp && b.createdAt > now + CONSENSUS.maxFutureSeconds)) { + throw new Error('Invalid block timestamp'); + } if (b.target !== expectedTarget(ancestors) || b.key !== expectedKey(ancestors, b.height)) throw new Error('Invalid difficulty or RandomX epoch key'); - if (b.transactionsRoot !== merkleRoot(b.transactions.map(transferId))) throw new Error('Invalid transaction root'); - const next = transition(parentState, b, b.transactions); - if (b.stateRoot !== stateRoot(next) || b.tokenId !== mintId(b)) throw new Error('Invalid NFT state commitment'); + if (b.transactionsRoot !== merkleRoot(b.transactions.map(transactionId))) throw new Error('Invalid transaction root'); + if (b.tokenId !== mintId(b)) throw new Error('Invalid NFT state commitment'); const blob = miningBlob(b); if (b.hash !== sha256(blob)) throw new Error('Invalid block ID'); if (!verifySignature(b.publicKey, b.owner, blockClaimMessage(b.hash), b.signature)) throw new Error('Invalid miner signature'); if (BigInt(`0x${b.powHash}`) > BigInt(`0x${b.target}`)) throw new Error('Insufficient work'); if (await pow.hash(b.key, blob) !== b.powHash) throw new Error('Invalid RandomX proof'); + // Verify native work before materializing/sorting the complete NFT state. A malformed + // low-work block must not force O(total NFTs) commitment work on the request path. + const next = transition(parentState, b, b.transactions); + if (b.stateRoot !== stateRoot(next)) throw new Error('Invalid NFT state commitment'); return { block: b, state: next }; } diff --git a/server/testnet/http.ts b/server/testnet/http.ts index f816f81..d0f2807 100644 --- a/server/testnet/http.ts +++ b/server/testnet/http.ts @@ -6,19 +6,29 @@ import { fileURLToPath } from 'node:url'; import { mkdir, open, unlink } from 'node:fs/promises'; import { z } from 'zod'; import { NETWORK } from '../../shared/testnet.ts'; -import { CHAIN_ID, blockSchema, transferSchema } from './consensus.ts'; +import { CHAIN_ID, blockSchema, tradeSchema, transferSchema, transactionSchema } from './consensus.ts'; import { RandomX } from './randomx.ts'; import { Ledger } from './ledger.ts'; import { PeerNetwork } from './peers.ts'; import { wikipediaEnrichment } from '../enrichment.ts'; import { snapshotStats } from '../../shared/enrichment.ts'; import { wikipediaArticles } from '../wiki.ts'; +import { IS_MAINNET, PROFILE } from '../../shared/network.ts'; +import { assertLaunchAllowed, launchManifest } from '../launch.ts'; +import { resourceConfig, RpcBudget, RpcError } from '../resources.ts'; +import { trustedProxies, operationsPort, startOperations } from '../operations.ts'; const root = fileURLToPath(new URL('../../', import.meta.url)); +assertLaunchAllowed(); +const limits = resourceConfig(); +const proxyTrust=trustedProxies(); +const opsPort=operationsPort(); +const apiBudget = new RpcBudget(limits); +const peerBudget = new RpcBudget({ ...limits, requestsPerSecond: 40, writesPerSecond: 10, active: 16 }); const dataDir = process.env.WIKIDECK_DATA_DIR ?? resolve(root,'data'); await mkdir(dataDir,{recursive:true}); // Never permit two processes to rewrite the same ledger. A stale lock requires operator review. -const lockPath = resolve(dataDir,'testnet-node.lock'); +const lockPath = resolve(dataDir,`${PROFILE}-node.lock`); const lock = await open(lockPath,'wx',0o600).catch(()=>{throw new Error('Data directory locked. Stop its other node; remove stale testnet-node.lock only after verifying no node is running.');}); await lock.writeFile(String(process.pid)); let engine: RandomX; @@ -30,15 +40,18 @@ catch(e) {engine.close();await lock.close();await unlink(lockPath);throw e;} let peers: PeerNetwork; try { peers = new PeerNetwork({ ledger, urls:(process.env.WIKIDECK_PEERS ?? '').split(',').map(s=>s.trim()).filter(Boolean), allowPrivate:process.env.WIKIDECK_ALLOW_PRIVATE_PEERS === '1' }); } -catch(e) {engine.close();await lock.close();await unlink(lockPath);throw e;} +catch(e) {ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;} const app = express(); app.disable('x-powered-by'); +if(proxyTrust.length)app.set('trust proxy',proxyTrust); app.use(helmet({contentSecurityPolicy:{directives:{defaultSrc:["'self'"],scriptSrc:["'self'"], imgSrc:["'self'",'data:','https://upload.wikimedia.org','https://thumb.wikimedia.org'],styleSrc:["'self'","'unsafe-inline'"], connectSrc:["'self'"],upgradeInsecureRequests:null}},crossOriginEmbedderPolicy:false, strictTransportSecurity:process.env.NODE_ENV==='production'})); app.get('/healthz',(_req,res)=>{res.setHeader('Cache-Control','no-store');res.json({status:'ok',network:NETWORK});}); -app.use(express.json({limit:'68kb',strict:true})); +// Admission comes before body parsing and signature/schema work. Disable compressed request inflation. +app.use('/api',apiBudget.middleware()); +app.use('/p2p',peerBudget.middleware()); app.use(['/api','/p2p'],rateLimit({windowMs:60000,limit:300,standardHeaders:'draft-8',legacyHeaders:false})); app.use(['/api','/p2p'],(req,res,next)=>{ res.setHeader('Cache-Control','no-store'); @@ -50,9 +63,31 @@ app.use(['/api','/p2p'],(req,res,next)=>{ } next(); }); -app.get('/api/state',(_req,res)=>res.json(ledger.snapshot())); -app.get('/api/chain',(_req,res)=>res.json(ledger.snapshot())); +app.use(['/api','/p2p'],express.json({limit:'68kb',strict:true,inflate:false})); +app.get('/api/state',(_req,res)=>res.json(ledger.legacySnapshot(limits.legacyBlocks))); +app.get('/api/summary',(_req,res)=>res.json(ledger.summary())); +app.get('/api/canonical',(req,res)=>{ + const q=z.object({tip:z.string().regex(/^[a-f0-9]{64}$/),offset:z.string().regex(/^\d{1,12}$/).optional(), + limit:z.string().regex(/^\d{1,2}$/).optional()}).strict().parse(req.query); + res.json(ledger.canonicalPage(q.tip,Number(q.offset??0),Number(q.limit??16))); +}); +app.get('/api/network',(_req,res)=>res.json(launchManifest())); +app.get('/api/chain',(_req,res)=>res.json(ledger.legacySnapshot(limits.legacyBlocks))); app.get('/api/peers',(_req,res)=>res.json({peers:peers.status()})); +app.get('/api/decks/:address', rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}), (req,res)=>{ + if(typeof req.params.address!=='string'||!/^[a-f0-9]{64}$/.test(req.params.address)){ + res.status(400).json({error:'Invalid wallet address'});return; + } + const offset=Number(req.query.offset??0),limit=Number(req.query.limit??24); + if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(limit)||limit<1||limit>24){ + res.status(400).json({error:'Invalid deck page'});return; + } + res.setHeader('Cache-Control','no-store'); + if(req.query.tip!==undefined && req.query.tip!==ledger.summary().tip){ + res.status(409).json({error:'Canonical tip changed; restart deck pagination.'});return; + } + res.json(ledger.deck(req.params.address,offset,limit)); +}); // Optional live data is intentionally not used by genesis, block validation or NFT ownership. app.get('/api/wikipedia/:pageId', rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}), async(req,res)=>{ if(typeof req.params.pageId!=='string'||!/^[1-9]\d{0,9}$/.test(req.params.pageId)){res.status(400).json({error:'Invalid Wikipedia page ID'});return;} @@ -60,22 +95,24 @@ app.get('/api/wikipedia/:pageId', rateLimit({windowMs:60000,limit:60,standardHea try {res.json(await wikipediaEnrichment.get(pageId));} catch {res.status(503).json({error:'Wikipedia enrichment is temporarily unavailable. NFT data is unaffected.'});} }); -app.get('/p2p/status',(_req,res)=>res.json({network:NETWORK,chainId:CHAIN_ID,size:ledger.size,tip:ledger.snapshot().tip})); +app.get('/p2p/status',(_req,res)=>res.json(ledger.peerStatus())); app.get('/p2p/blocks',(req,res)=>{ const offset=Number(req.query.offset??0); - if(!Number.isSafeInteger(offset)||offset<0||offset>10000){res.status(400).json({error:'Invalid offset'});return;} + if(!Number.isSafeInteger(offset)||offset<0){res.status(400).json({error:'Invalid offset'});return;} const blocks=ledger.inventory(offset,32); res.json({blocks,next:offset+blocks.lengthres.json({transactions:ledger.snapshot().pendingTransfers})); +app.get('/p2p/mempool',(_req,res)=>res.json({transactions:ledger.pendingTransactions()})); app.get('/api/nfts/:id', (req,res)=>{ - const s=ledger.snapshot();const block=s.blocks.find(b=>b.tokenId===req.params.id); - if(!block){res.status(404).json({error:'NFT not found on the canonical chain'});return;} - let owner=block.owner,sequence=0; - for(const tx of s.transfers)if(tx.cardHash===block.tokenId){owner=tx.to;sequence++;} - res.json({network:NETWORK,chainId:CHAIN_ID,tokenId:block.tokenId,owner,sequence,blockHash:block.hash, - confirmations:s.blocks.length-block.height+1,article:block.article,statsSnapshot:block.statsSnapshot, - stateRoot:s.blocks.at(-1)?.stateRoot}); + if(typeof req.params.id!=='string'||!/^[a-f0-9]{64}$/.test(req.params.id)){ + res.status(400).json({error:'Invalid NFT identifier'});return; + } + const nft=ledger.nft(req.params.id); + if(!nft){res.status(404).json({error:'NFT not found on the canonical chain'});return;} + const block=nft.block; + res.json({network:NETWORK,chainId:CHAIN_ID,tokenId:block.tokenId,owner:nft.owner,sequence:nft.sequence,blockHash:block.hash, + confirmations:nft.confirmations,article:block.article,statsSnapshot:block.statsSnapshot, + stateRoot:ledger.block(ledger.summary().tip)?.stateRoot}); }); const templateLimit=rateLimit({windowMs:60000,limit:20,standardHeaders:'draft-8',legacyHeaders:false}); app.post('/api/mining/template', templateLimit, async (req,res)=>{ @@ -95,12 +132,23 @@ app.post(['/api/blocks','/p2p/block'],workLimit,async(req,res)=>{ res.status(result.accepted?201:200).json(result); if(result.accepted)void peers.broadcastBlock(block).catch(()=>{}); }); -app.post(['/api/transfer','/p2p/transaction'],workLimit,async(req,res)=>{ +app.post('/api/transfer',workLimit,async(req,res)=>{ const tx=transferSchema.parse(req.body); const id=await ledger.submitTransfer(tx); res.status(202).json({id,status:'pending',message:'Ownership changes only when this transaction is mined.'}); void peers.broadcastTransfer(tx).catch(()=>{}); }); +app.post('/api/trades',workLimit,async(req,res)=>{ + const trade=tradeSchema.parse(req.body); + const id=await ledger.submitTransaction(trade); + res.status(202).json({id,status:'pending',message:'The complete multi-card trade is pending; ownership changes atomically when it is mined.'}); + void peers.broadcastTransaction(trade).catch(()=>{}); +}); +app.post('/p2p/transaction',workLimit,async(req,res)=>{ + const transaction=transactionSchema.parse(req.body); + const id=await ledger.submitTransaction(transaction); + res.status(202).json({id,status:'pending'}); +}); app.use(['/api','/p2p'],(_req,res)=>{res.status(404).json({error:'Unknown testnet endpoint'});}); app.use(express.static(resolve(root,'dist'))); app.get('/{*path}',(_req,res)=>res.sendFile(resolve(root,'dist/index.html'))); @@ -108,17 +156,27 @@ app.use((err:Error & {status?:number},_req:express.Request,res:express.Response, const input=err instanceof z.ZodError||err instanceof SyntaxError; const message=input?'Invalid request encoding':err.message; // No keys/passwords are accepted by any node endpoint. - res.status(err.status===413?413:400).json({error:message.slice(0,200)}); + if(err instanceof RpcError&&err.status===503)res.setHeader('Retry-After','1'); + res.status(err instanceof RpcError?err.status:err.status===413?413:err.status===415?415:400).json({error:message.slice(0,200)}); }); const port=Number(process.env.PORT??3001),host=process.env.HOST??'127.0.0.1'; const server=app.listen(port,host,()=>{ - const a=server.address();console.log(`Wikideck RandomX testnet: http://${host}:${typeof a==='object'&&a?a.port:port} · chain ${CHAIN_ID} · ${ledger.snapshot().blocks.length} blocks`); + const a=server.address();console.log(`Wikideck RandomX ${IS_MAINNET?'mainnet candidate':'testnet'}: http://${host}:${typeof a==='object'&&a?a.port:port} · chain ${CHAIN_ID} · ${ledger.snapshot().blocks.length} blocks`); peers.start(); }); +const operations=startOperations(opsPort,()=>({network:NETWORK, + ledger:ledger.diagnostics(),native:engine.diagnostics(), + peers:{configured:peers.status().length,connected:peers.status().filter(p=>p.connected).length}, + rpc:{api:apiBudget.status(),p2p:peerBudget.status()}}),resolve(dataDir,PROFILE)); +server.maxConnections=256; +server.headersTimeout=10000; +server.requestTimeout=15000; +server.keepAliveTimeout=5000; +server.setTimeout(30000,socket=>socket.destroy()); let stopping=false; for(const signal of ['SIGTERM','SIGINT']as const)process.once(signal,()=>{ - if(stopping)return;stopping=true;peers.close(); + if(stopping)return;stopping=true;peers.close();operations.close(); const timeout=setTimeout(()=>process.exit(1),25000);timeout.unref(); - server.close(async()=>{engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);}); + server.close(async()=>{ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);}); }); -server.on('error',async()=>{peers.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);}); +server.on('error',async()=>{stopping=true;operations.close();peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);}); diff --git a/server/testnet/ledger.ts b/server/testnet/ledger.ts index 675f135..035ab34 100644 --- a/server/testnet/ledger.ts +++ b/server/testnet/ledger.ts @@ -1,88 +1,191 @@ -import { mkdir, open, rename, unlink } from 'node:fs/promises'; +import { chmod, mkdir, readFile, stat } from 'node:fs/promises'; import { join } from 'node:path'; -import { randomUUID } from 'node:crypto'; -import { setTimeout as delay } from 'node:timers/promises'; +import { DatabaseSync } from 'node:sqlite'; import { z } from 'zod'; -import { NETWORK, CONSENSUS, type TestnetBlock, type NftTransfer, type NftState, - type MiningTemplate, type TestnetSnapshot } from '../../shared/testnet.ts'; -import { CHAIN_ID, GENESIS_HASH, applyTransfer, blockSchema, makeTemplate, - transferId, transferSchema, validateBlock, workFor } from './consensus.ts'; +import { NETWORK, CONSENSUS, type TestnetBlock, + type NftState, type NftStateView, type TestnetTransaction, type MiningTemplate, + type TestnetDeck, type TestnetSnapshot, type TestnetSummary, type CanonicalPage } from '../../shared/testnet.ts'; +import { CHAIN_ID, GENESIS_HASH, applyTransactionOverlay, blockSchema, makeTemplate, + applyTransferOverlay, materializeState, transactionId, transactionSchema, transferSchema, + validateBlock, workFor } from './consensus.ts'; import type { PowEngine } from './randomx.ts'; import type { Article } from '../../shared/protocol.ts'; import type { MintStatsSnapshot } from '../../shared/enrichment.ts'; +import { PROFILE } from '../../shared/network.ts'; +import { RpcError } from '../resources.ts'; +import { CanonicalIndex } from './canonical-index.ts'; -const MAX_BLOCKS = 10_000; const MAX_PENDING = 256; -const MAX_FILE = 128 * 1024 * 1024; +const MAX_DECK_PAGE = 24; +const MAX_LEGACY_FILE = 128 * 1024 * 1024; const diskSchema = z.object({ version: z.literal(1), network: z.literal(NETWORK), - chainId: z.literal(CHAIN_ID), blocks: z.array(blockSchema).max(MAX_BLOCKS), - pendingTransfers: z.array(transferSchema).max(MAX_PENDING) }).strict(); -type Node = { block: TestnetBlock; state: NftState; work: bigint }; + chainId: z.literal(CHAIN_ID), blocks: z.array(blockSchema).max(10_000), + pendingTransfers: z.array(transactionSchema).max(MAX_PENDING) }).strict(); +type Node = { block: TestnetBlock; state: NftStateView; work: bigint }; +const DATABASE_SCHEMA_VERSION = '1'; -/** Experimental, bounded DAG. The supplied engine is always used, including on restart. - * One process must own a data directory; there is no cross-process writer locking. +/** SQLite-backed DAG. Blocks append transactionally; pending rows are replaced transactionally. + * Each block state retains only its NFT delta, sharing the parent's state overlay. */ export class Ledger { private readonly nodes = new Map(); private tip = GENESIS_HASH; - private pending: NftTransfer[] = []; + private pending: TestnetTransaction[] = []; + private pendingState: NftStateView = {}; private queue: Promise = Promise.resolve(); private queued = 0; /** Application-owned connectivity metadata; never part of consensus or persistence. */ peers = 0; private readonly file: string; + private readonly db: DatabaseSync; private readonly pow: PowEngine; - private constructor(file: string, pow: PowEngine) { this.file = file; this.pow = pow; } + private readonly index: CanonicalIndex; + private closed = false; + private revision = 0; + private reorgs = 0; + private readonly nowSeconds: () => number; + private canonicalCache: { tip: string; blocks: TestnetBlock[] } | undefined; + private constructor(file: string, db: DatabaseSync, pow: PowEngine, nowSeconds: () => number) { + this.file = file; this.db = db; this.pow = pow; this.nowSeconds = nowSeconds; this.index = new CanonicalIndex(db); + } - static async open(dir: string, pow: PowEngine): Promise { - const directory = join(dir, 'testnet'); + static async open(dir: string, pow: PowEngine, nowSeconds: () => number = () => Math.floor(Date.now() / 1000)): Promise { + const directory = join(dir, PROFILE); await mkdir(directory, { recursive: true, mode: 0o700 }); - const ledger = new Ledger(join(directory, 'ledger.json'), pow); - let handle; - try { handle = await open(ledger.file, 'r'); } - catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - await ledger.persist([], []); - return ledger; - } - let text: string; + const file = join(directory, 'ledger.sqlite'); + let databaseExists = false; + try { databaseExists = (await stat(file)).size > 0; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + const db = new DatabaseSync(file); try { - if ((await handle.stat()).size > MAX_FILE) throw new Error('Testnet ledger file exceeds 128 MiB'); - // Bounded reads also protect against a file growing after stat(). - const chunks: Buffer[] = []; - let size = 0; - for (;;) { - const buffer = Buffer.alloc(Math.min(64 * 1024, MAX_FILE + 1 - size)); - const { bytesRead } = await handle.read(buffer, 0, buffer.length, null); - if (!bytesRead) break; - size += bytesRead; - if (size > MAX_FILE) throw new Error('Testnet ledger file exceeds 128 MiB'); - chunks.push(buffer.subarray(0, bytesRead)); + const tableExists = (name: string) => Boolean(db.prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)); + if (databaseExists && !tableExists('metadata')) { + throw new Error('SQLite ledger metadata missing; refusing to initialize over unknown data'); } - text = Buffer.concat(chunks).toString('utf8'); - } finally { await handle.close(); } - const disk = diskSchema.parse(JSON.parse(text)); // Mismatches/corruption fail closed, never reset. - for (const raw of disk.blocks) { - if (ledger.nodes.has(raw.hash)) throw new Error('Duplicate persisted block'); - const node = await ledger.validate(raw); - ledger.nodes.set(node.block.hash, node); - if (node.work > ledger.tipWork()) ledger.tip = node.block.hash; + const readMeta = databaseExists ? db.prepare('SELECT value FROM metadata WHERE key = ?') : undefined; + if (!databaseExists) { + db.exec('BEGIN IMMEDIATE'); + try { + db.exec(`CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL) WITHOUT ROWID; + CREATE TABLE blocks ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + hash TEXT NOT NULL UNIQUE, + previous_hash TEXT NOT NULL, + block_json TEXT NOT NULL + ); + CREATE INDEX blocks_by_parent ON blocks(previous_hash); + CREATE TABLE pending_transfers ( + position INTEGER PRIMARY KEY, + id TEXT NOT NULL UNIQUE, + transaction_json TEXT NOT NULL + );`); + const insertMeta = db.prepare('INSERT INTO metadata(key, value) VALUES(?, ?)'); + insertMeta.run('schema_version', DATABASE_SCHEMA_VERSION); + insertMeta.run('network', NETWORK); + insertMeta.run('chain_id', CHAIN_ID); + db.exec('COMMIT'); + } catch (error) { db.exec('ROLLBACK'); throw error; } + } else { + if (!tableExists('blocks') || !tableExists('pending_transfers')) { + throw new Error('SQLite ledger schema is incomplete; refusing to repair stored data automatically'); + } + const schemaVersion = readMeta!.get('schema_version') as { value: string } | undefined; + const storedNetwork = readMeta!.get('network') as { value: string } | undefined; + const storedChain = readMeta!.get('chain_id') as { value: string } | undefined; + if (schemaVersion?.value !== DATABASE_SCHEMA_VERSION || storedNetwork?.value !== NETWORK || storedChain?.value !== CHAIN_ID) { + throw new Error('SQLite ledger schema or chain mismatch; refusing to reset stored data'); + } + } + if (process.platform !== 'win32') await chmod(file, 0o600); + db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;'); + + const migrated = db.prepare('SELECT value FROM metadata WHERE key = ?').get('legacy_json_migrated') as { value: string } | undefined; + if (!migrated) { + const legacyFile = join(directory, 'ledger.json'); + let legacy: unknown; + try { + const info = await stat(legacyFile); + if (info.size > MAX_LEGACY_FILE) throw new Error('Legacy testnet ledger exceeds 128 MiB; preserve it and migrate offline'); + legacy = JSON.parse(await readFile(legacyFile, 'utf8')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + const insertMeta = db.prepare('INSERT OR REPLACE INTO metadata(key, value) VALUES(?, ?)'); + db.exec('BEGIN IMMEDIATE'); + try { + if (legacy !== undefined) { + const oldDisk = diskSchema.parse(legacy); + const insertBlock = db.prepare('INSERT INTO blocks(hash, previous_hash, block_json) VALUES(?, ?, ?)'); + for (const block of oldDisk.blocks) insertBlock.run(block.hash, block.previousHash, JSON.stringify(block)); + const insertPending = db.prepare('INSERT INTO pending_transfers(position, id, transaction_json) VALUES(?, ?, ?)'); + oldDisk.pendingTransfers.forEach((tx, index) => insertPending.run(index, transactionId(tx), JSON.stringify(tx))); + insertMeta.run('legacy_json_imported_at', String(nowSeconds())); + insertMeta.run('legacy_json_block_count', String(oldDisk.blocks.length)); + } + insertMeta.run('legacy_json_migrated', '1'); + db.exec('COMMIT'); + } catch (error) { db.exec('ROLLBACK'); throw error; } + } + + const ledger = new Ledger(file, db, pow, nowSeconds); + const importedAtText = (db.prepare('SELECT value FROM metadata WHERE key=?').get('legacy_json_imported_at') as { value: string } | undefined)?.value; + const importedCountText = (db.prepare('SELECT value FROM metadata WHERE key=?').get('legacy_json_block_count') as { value: string } | undefined)?.value; + const legacyImportedAt = importedAtText !== undefined && /^(0|[1-9]\d{0,10})$/.test(importedAtText) + ? Number(importedAtText) : undefined; + const legacyImportedCount = importedCountText !== undefined && /^(0|[1-9]\d{0,9})$/.test(importedCountText) + ? Number(importedCountText) : undefined; + if ((importedAtText !== undefined && (legacyImportedAt === undefined || !Number.isSafeInteger(legacyImportedAt))) || + (importedCountText !== undefined && (legacyImportedCount === undefined || !Number.isSafeInteger(legacyImportedCount))) || + (legacyImportedAt === undefined) !== (legacyImportedCount === undefined)) { + throw new Error('Legacy ledger import timestamp metadata is invalid'); + } + for (const row of db.prepare('SELECT sequence, block_json FROM blocks ORDER BY sequence').iterate() as Iterable<{ sequence: number; block_json: string }>) { + const raw = blockSchema.parse(JSON.parse(row.block_json)); + if (ledger.nodes.has(raw.hash)) throw new Error('Duplicate persisted block'); + const importedLegacyBlock = legacyImportedCount !== undefined && row.sequence <= legacyImportedCount; + // Historical SQLite blocks already passed admission; wall-clock rollback must not + // strand them. Imported JSON blocks retain their original admission clock so an + // untrusted legacy file cannot inject arbitrarily future-dated history. + const node = importedLegacyBlock + ? await ledger.validate(raw, false, legacyImportedAt!) + : await ledger.validate(raw, true); + ledger.nodes.set(node.block.hash, node); + if (node.work > ledger.tipWork()) ledger.tip = node.block.hash; + } + let state = ledger.tipState(); + const seen = new Set(ledger.canonical().flatMap(b => b.transactions.map(transactionId))); + for (const row of db.prepare('SELECT id, transaction_json FROM pending_transfers ORDER BY position').iterate() as Iterable<{ id: string; transaction_json: string }>) { + const tx = transactionSchema.parse(JSON.parse(row.transaction_json)); + const id = transactionId(tx); + if (row.id !== id || seen.has(id)) throw new Error('Duplicate or mismatched persisted transfer'); + state = applyTransactionOverlay(state, tx, ledger.height() + 1); + seen.add(id); + ledger.pending.push(tx); + } + ledger.pendingState = state; + ledger.index.initialize(ledger.canonical(), materializeState(ledger.tipState())); + return ledger; + } catch (error) { + db.close(); + throw error; } - const state = structuredClone(ledger.tipState()); - const seen = new Set(ledger.canonical().flatMap(b => b.transactions.map(transferId))); - for (const tx of disk.pendingTransfers) { - const id = transferId(tx); - if (seen.has(id)) throw new Error('Duplicate persisted transfer'); - applyTransfer(state, tx, ledger.height() + 1); - seen.add(id); - } - ledger.pending = disk.pendingTransfers; - return ledger; } get size(): number { return this.nodes.size; } - get allBlocks(): TestnetBlock[] { return this.inventory(0, MAX_BLOCKS); } + diagnostics() { const createdAt=this.nodes.get(this.tip)?.block.createdAt??null; + return { height:this.height(), dagBlocks:this.size, pending:this.pending.length, mutationQueue:this.queued, + reorgsSinceStartup:this.reorgs,tipCreatedAt:createdAt,tipAgeSeconds:createdAt===null?null:Math.max(0,Date.now()/1000-createdAt) }; } + get allBlocks(): TestnetBlock[] { + const rows = this.db.prepare('SELECT block_json FROM blocks ORDER BY sequence').all() as { block_json: string }[]; + return rows.map(row => blockSchema.parse(JSON.parse(row.block_json))); + } + close(): void { + if (this.closed) return; + this.closed = true; + this.db.close(); + } block(hash: string): TestnetBlock | undefined { const block = this.nodes.get(hash)?.block; return block ? structuredClone(block) : undefined; @@ -91,10 +194,12 @@ export class Ledger { * HTTP callers should request limit=32 and return next=offset+length or null. */ inventory(offset: number, limit: number): TestnetBlock[] { - if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > MAX_BLOCKS) { + if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 1000) { throw new Error('Invalid inventory range'); } - return structuredClone(Array.from(this.nodes.values()).slice(offset, offset + limit).map(n => n.block)); + const rows = this.db.prepare('SELECT block_json FROM blocks ORDER BY sequence LIMIT ? OFFSET ?') + .all(limit, offset) as { block_json: string }[]; + return rows.map(row => blockSchema.parse(JSON.parse(row.block_json))); } async submit(raw: unknown): Promise<{ accepted: boolean; reorg: boolean }> { @@ -102,37 +207,47 @@ export class Ledger { return this.serialize(async () => { const parsed = blockSchema.parse(input); if (this.nodes.has(parsed.hash)) return { accepted: false, reorg: false }; - if (this.size >= MAX_BLOCKS) throw new Error('Experimental testnet limit: 10000 stored blocks'); const node = await this.validate(parsed); const wins = node.work > this.tipWork(); const oldChain = this.canonical(); const chain = wins ? [...this.branch(node.block.previousHash), node.block] : oldChain; const hashes = new Set(chain.map(b => b.hash)); const disconnected = wins ? oldChain.filter(b => !hashes.has(b.hash)) : []; - const pending = wins ? this.filterPending(node.state, chain, - [...disconnected.flatMap(b => b.transactions), ...this.pending]) : this.pending; + const filtered = wins ? this.filterPending(node.state, chain, + [...disconnected.flatMap(b => b.transactions), ...this.pending]) : undefined; + const pending = filtered?.pending ?? this.pending; // Nothing visible changes until the atomic replacement succeeds. - await this.persist([...Array.from(this.nodes.values(), n => n.block), node.block], pending); + const oldHashes = new Set(oldChain.map(b => b.hash)); + const connected = wins ? chain.filter(b => !oldHashes.has(b.hash)) : []; + this.persist(node.block, pending, disconnected, connected); this.nodes.set(node.block.hash, node); if (wins) this.tip = node.block.hash; + if (disconnected.length) this.reorgs++; this.pending = pending; + if (filtered) this.pendingState = filtered.state; + this.revision++; return { accepted: true, reorg: disconnected.length > 0 }; }); } async submitTransfer(raw: unknown): Promise { + const tx = transferSchema.parse(structuredClone(raw)); + return this.submitTransaction(tx); + } + + async submitTransaction(raw: unknown): Promise { const input = structuredClone(raw); return this.serialize(async () => { - const tx = transferSchema.parse(input); - const id = transferId(tx); - if (this.pending.some(t => transferId(t) === id)) return id; + const tx = transactionSchema.parse(input); + const id = transactionId(tx); + if (this.pending.some(t => transactionId(t) === id)) return id; if (this.pending.length >= MAX_PENDING) throw new Error('Testnet mempool limit: 256 transfers'); - const state = structuredClone(this.tipState()); - for (const pending of this.pending) applyTransfer(state, pending, this.height() + 1); - applyTransfer(state, tx, this.height() + 1); // No speculative issuance. + const nextPendingState = applyTransactionOverlay(this.pendingState, tx, this.height() + 1); const pending = [...this.pending, tx]; - await this.persist(Array.from(this.nodes.values(), n => n.block), pending); + this.persist(undefined, pending); this.pending = pending; + this.pendingState = nextPendingState; + this.revision++; return id; }); } @@ -150,9 +265,43 @@ export class Ledger { peers: this.peers, algorithm: 'RandomX', maturity: CONSENSUS.coinbaseMaturity }); } + /** Small metadata/mempool response. No full-chain traversal or clone. */ + summary(): TestnetSummary { + return { network: NETWORK, chainId: CHAIN_ID, genesis: GENESIS_HASH, tip: this.tip, + height: this.height(), revision: this.revision, cumulativeWork: this.tipWork().toString(), + pendingTransfers: structuredClone(this.pending), peers: this.peers, algorithm: 'RandomX', maturity: CONSENSUS.coinbaseMaturity }; + } + peerStatus() { return { network: NETWORK, chainId: CHAIN_ID, size: this.size, tip: this.tip }; } + pendingTransactions(): TestnetTransaction[] { return structuredClone(this.pending); } + canonicalEvents(after: number, limit = 64) { return this.index.events(after, limit); } + canonicalHead(): number { return this.index.head(); } + nft(token: string) { return this.index.nft(token, this.height()); } + canonicalTransaction(id: string) { return this.index.transaction(id); } + legacySnapshot(maxBlocks: number): TestnetSnapshot { + // Reject before any full-chain copy/serialization. Never truncate ownership history silently. + if (this.height() > maxBlocks) throw new RpcError(413, 'Full snapshot disabled at this height; use /api/summary and /api/canonical pages.'); + return this.snapshot(); + } + canonicalPage(tip: string, offset: number, limit = 16): CanonicalPage { + if (tip !== this.tip) throw new RpcError(409, 'Canonical tip changed; restart paged loading.'); + if (!Number.isSafeInteger(offset) || offset < 0 || offset > this.height() || + !Number.isSafeInteger(limit) || limit < 1 || limit > 16) throw new RpcError(400, 'Invalid canonical page'); + const blocks = structuredClone(this.canonical().slice(offset, offset + limit)); + return { network: NETWORK, chainId: CHAIN_ID, tip: this.tip, height: this.height(), offset, blocks, + next: offset + blocks.length < this.height() ? offset + blocks.length : null }; + } + deck(address: string, offset = 0, limit = MAX_DECK_PAGE): TestnetDeck { + if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > MAX_DECK_PAGE) { + throw new Error('Invalid deck page'); + } + const { total, nfts } = this.index.deck(address, offset, limit, this.height()); + const next = offset + nfts.length < total ? offset + nfts.length : null; + return structuredClone({ network: NETWORK, chainId: CHAIN_ID, address, tip: this.tip, + offset, limit, total, next, nfts }); + } private height(): number { return this.nodes.get(this.tip)?.block.height ?? 0; } - private tipState(): NftState { return this.nodes.get(this.tip)?.state ?? {}; } + private tipState(): NftStateView { return this.nodes.get(this.tip)?.state ?? {}; } private tipWork(): bigint { return this.nodes.get(this.tip)?.work ?? 0n; } private branch(hash: string): TestnetBlock[] { const result: TestnetBlock[] = []; @@ -164,26 +313,31 @@ export class Ledger { } return result.reverse(); } - private canonical(): TestnetBlock[] { return this.branch(this.tip); } - private async validate(raw: TestnetBlock): Promise { + private canonical(): TestnetBlock[] { + if (this.canonicalCache?.tip !== this.tip) this.canonicalCache = { tip: this.tip, blocks: this.branch(this.tip) }; + return this.canonicalCache.blocks; // Internal immutable block references only; public APIs clone their slice. + } + private async validate(raw: TestnetBlock, historicalReplay = false, now = this.nowSeconds()): Promise { const ancestors = this.branch(raw.previousHash); const parent = this.nodes.get(raw.previousHash); - const { block, state } = await validateBlock(raw, ancestors, parent?.state ?? {}, this.pow); + const { block, state } = await validateBlock(raw, ancestors, parent?.state ?? {}, this.pow, + now, historicalReplay); return { block, state, work: (parent?.work ?? 0n) + workFor(block.target) }; } - private filterPending(parent: NftState, chain: TestnetBlock[], candidates: NftTransfer[]): NftTransfer[] { - const state = structuredClone(parent); - const seen = new Set(chain.flatMap(b => b.transactions.map(transferId))); - const pending: NftTransfer[] = []; + private filterPending(parent: NftStateView, chain: TestnetBlock[], candidates: TestnetTransaction[]): + { pending: TestnetTransaction[]; state: NftStateView } { + let state = parent; + const seen = new Set(chain.flatMap(b => b.transactions.map(transactionId))); + const pending: TestnetTransaction[] = []; for (const tx of candidates) { if (pending.length === MAX_PENDING) break; - const id = transferId(tx); + const id = transactionId(tx); if (seen.has(id)) continue; - try { applyTransfer(state, tx, chain.length + 1); } + try { state = applyTransactionOverlay(state, tx, chain.length + 1); } catch { continue; } // Disconnected issuance, immature coins and conflicts are not replayable. seen.add(id); pending.push(tx); } - return pending; + return { pending, state }; } private serialize(operation: () => Promise): Promise { if (this.queued >= 256) return Promise.reject(new Error('Testnet mutation queue full')); @@ -192,25 +346,21 @@ export class Ledger { this.queue = result.catch(() => undefined).finally(() => { this.queued--; }); return result; } - private async persist(blocks: TestnetBlock[], pendingTransfers: NftTransfer[]): Promise { - const data = JSON.stringify({ version: 1, network: NETWORK, chainId: CHAIN_ID, blocks, pendingTransfers }); - if (Buffer.byteLength(data) > MAX_FILE) throw new Error('Testnet ledger file exceeds 128 MiB'); - const temp = `${this.file}.${randomUUID()}.tmp`; + private persist(block: TestnetBlock | undefined, pendingTransfers: TestnetTransaction[], + disconnected: TestnetBlock[] = [], connected: TestnetBlock[] = []): void { + this.db.exec('BEGIN IMMEDIATE'); try { - const handle = await open(temp, 'wx', 0o600); - try { await handle.writeFile(data); await handle.sync(); } - finally { await handle.close(); } - // Windows scanners can briefly hold the destination open without delete sharing. - // Retry only transient sharing failures; never unlink the old durable ledger. - for (let attempt = 0; ; attempt++) { - try { await rename(temp, this.file); break; } - catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (process.platform !== 'win32' || attempt >= 5 || - !['EPERM', 'EACCES', 'EBUSY'].includes(code ?? '')) throw error; - await delay(10 * 2 ** attempt); - } - } - } finally { await unlink(temp).catch(() => undefined); } + if (block) this.db.prepare('INSERT INTO blocks(hash, previous_hash, block_json) VALUES(?, ?, ?)') + .run(block.hash, block.previousHash, JSON.stringify(block)); + this.db.exec('DELETE FROM pending_transfers'); + const insertPending = this.db.prepare('INSERT INTO pending_transfers(position, id, transaction_json) VALUES(?, ?, ?)'); + pendingTransfers.forEach((tx, index) => insertPending.run(index, transactionId(tx), JSON.stringify(tx))); + for (const old of [...disconnected].reverse()) this.index.disconnect(old); + for (const next of connected) this.index.connect(next); + this.db.exec('COMMIT'); + } catch (error) { + this.db.exec('ROLLBACK'); + throw error; + } } } diff --git a/server/testnet/miner.ts b/server/testnet/miner.ts index aa29ae8..a2372f9 100644 --- a/server/testnet/miner.ts +++ b/server/testnet/miner.ts @@ -3,9 +3,12 @@ import { resolve } from 'node:path'; import { stdin, stdout } from 'node:process'; import { randomInt } from 'node:crypto'; import { decryptWallet } from '../../shared/wallet-backup.ts'; -import { blockClaimMessage, NETWORK, type MiningTemplate, type TestnetBlock } from '../../shared/testnet.ts'; +import { blockClaimMessage, CONSENSUS, NETWORK, type MiningTemplate, type TestnetBlock } from '../../shared/testnet.ts'; import { CHAIN_ID, sha256, miningBlob, mintId, blockSchema } from './consensus.ts'; import { RandomX } from './randomx.ts'; +import { assertLaunchAllowed } from '../launch.ts'; + +assertLaunchAllowed(); const args = process.argv.slice(2); const arg = (key: string) => { const i = args.indexOf(key); return i < 0 ? undefined : args[i+1]; }; @@ -83,7 +86,7 @@ try { header.nonce += 16; stdout.write(`\r${(attempts / ((Date.now()-started)/1000)).toFixed(1)} H/s · searching block #${header.height} `); const status = await api('/p2p/status'); - if (status.tip !== header.previousHash || Date.now()/1000 - header.createdAt > 60) { console.log('\nRefreshing stale work'); break; } + if (status.tip !== header.previousHash || Date.now()/1000 - header.createdAt > CONSENSUS.targetSeconds * 2) { console.log('\nRefreshing stale work'); break; } // Explicit idle interval; native miner never starts with the web page. await new Promise(resolve => setTimeout(resolve,100)); } diff --git a/server/testnet/peers.ts b/server/testnet/peers.ts index 5f527b3..6546084 100644 --- a/server/testnet/peers.ts +++ b/server/testnet/peers.ts @@ -3,8 +3,8 @@ import { request as httpsRequest } from 'node:https'; import dns from 'node:dns/promises'; import { BlockList, isIP } from 'node:net'; import { z } from 'zod'; -import { NETWORK, type TestnetBlock, type NftTransfer } from '../../shared/testnet.ts'; -import { CHAIN_ID, blockSchema, transferId, transferSchema } from './consensus.ts'; +import { NETWORK, type TestnetBlock, type TestnetTransaction } from '../../shared/testnet.ts'; +import { CHAIN_ID, blockSchema, transactionId, transactionSchema } from './consensus.ts'; import type { Ledger } from './ledger.ts'; const MAX_BODY = 2 * 1024 * 1024; @@ -24,14 +24,14 @@ function publicAddress(ip: string): boolean { return family === 6 && globalV6.check(ip, 'ipv6') && !excluded.check(ip, 'ipv6'); } const remoteStatus = z.object({ network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID), - size: z.number().int().min(0).max(10_000), tip: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict(); + size: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), tip: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict(); const pageSchema = z.object({ blocks: z.array(blockSchema).max(32), - next: z.number().int().min(0).max(10_000).nullable() }).strict(); -const poolSchema = z.object({ transactions: z.array(transferSchema).max(256) }).strict(); + next: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable() }).strict(); +const poolSchema = z.object({ transactions: z.array(transactionSchema).max(256) }).strict(); type Peer = { url: string; connected: boolean; error?: string; offset: number; busy: boolean }; /** Outbound-only fixed allowlist. HTTP routes are deliberately left to the caller. - * allowPrivate is an explicit operator permission for local/Docker networks, not peer input. + * allowPrivate is an explicit operator permission for local/Docker/cluster networks, not peer input. */ export class PeerNetwork { private readonly peers: Peer[]; @@ -90,10 +90,13 @@ export class PeerNetwork { if (!known) throw new Error('Cannot broadcast an unaccepted block'); await this.broadcast('/p2p/block', known); } - async broadcastTransfer(tx: NftTransfer): Promise { - const id = transferId(transferSchema.parse(tx)); + async broadcastTransfer(tx: TestnetTransaction): Promise { + return this.broadcastTransaction(tx); + } + async broadcastTransaction(tx: TestnetTransaction): Promise { + const id = transactionId(transactionSchema.parse(tx)); const snapshot = this.ledger.snapshot(); - const known = [...snapshot.pendingTransfers, ...snapshot.transfers].find(t => transferId(t) === id); + const known = [...snapshot.pendingTransfers, ...snapshot.transfers].find(t => transactionId(t) === id); if (!known) throw new Error('Cannot broadcast an unaccepted transfer'); await this.broadcast('/p2p/transaction', known); } @@ -121,8 +124,8 @@ export class PeerNetwork { if (peer.offset < status.size) { const page = pageSchema.parse(await this.request(peer, `/p2p/blocks?offset=${peer.offset}`)); const end = peer.offset + page.blocks.length; - if (!page.blocks.length || end > 10_000 || - (page.next !== null && (page.next !== end || end >= 10_000)) || + if (!page.blocks.length || !Number.isSafeInteger(end) || + (page.next !== null && page.next !== end) || (page.next === null && end < status.size)) throw new Error('Invalid peer pagination'); for (const block of page.blocks) { if (this.closed) throw new Error('Peer network closed'); @@ -134,7 +137,7 @@ export class PeerNetwork { for (const tx of pool.transactions) { if (this.closed) throw new Error('Peer network closed'); // Valid remote transactions may be immature/conflicting on our current tip. - try { await this.ledger.submitTransfer(tx); } catch { /* Independently reject; do not relay. */ } + try { await this.ledger.submitTransaction(tx); } catch { /* Independently reject; do not relay. */ } } } diff --git a/server/testnet/randomx.ts b/server/testnet/randomx.ts index 55bf2a8..0e734e8 100644 --- a/server/testnet/randomx.ts +++ b/server/testnet/randomx.ts @@ -87,6 +87,7 @@ export class RandomX implements PowEngine { return line; }); } + diagnostics() { return { available: !this.failure && !this.exited, active: !!this.active, queued: this.queue.length }; } async mine( key: string, blob: Uint8Array, target: string, nonceOffset: number, maxAttempts: number, diff --git a/server/wiki.ts b/server/wiki.ts index 53efb97..71d8405 100644 --- a/server/wiki.ts +++ b/server/wiki.ts @@ -74,7 +74,7 @@ export class WikipediaArticleSource { generator: 'random', grnnamespace: '0', grnlimit: '10', prop: 'extracts|pageimages|info|categories', exintro: '1', explaintext: '1', exchars: '900', piprop: 'thumbnail', pithumbsize: '800', cllimit: '20' }).toString(); const response = await this.fetcher(url, { signal: AbortSignal.timeout(3500), redirect: 'error', - headers: { User-Agent: 'Wikideck/0.1 (experimental Wikipedia article cards)', Accept: 'application/json' } }); + headers: { 'User-Agent': 'Wikideck/0.1 (experimental Wikipedia article cards)', Accept: 'application/json' } }); const data = responseSchema.parse(await readJson(response)); for (const rawPage of data.query.pages) { const parsedPage = pageSchema.safeParse(rawPage); diff --git a/shared/chain-identity.ts b/shared/chain-identity.ts new file mode 100755 index 0000000..4ca9f51 --- /dev/null +++ b/shared/chain-identity.ts @@ -0,0 +1,16 @@ +import { CONSENSUS, MAX_TARGET, NETWORK } from './testnet.ts'; +import { NETWORK_GENESIS_TIME } from './network.ts'; + +export type ChainIdentity = { chainId: string; genesis: string }; +let identity: Promise | undefined; + +/** Derive the same public manifest as the node from this immutable browser build. */ +export function expectedChainIdentity(): Promise { + if (!identity) identity = (async () => { + const digestHex = async (value: string) => Array.from(new Uint8Array(await crypto.subtle.digest( + 'SHA-256', new TextEncoder().encode(value))), byte => byte.toString(16).padStart(2, '0')).join(''); + const chainId = await digestHex(JSON.stringify([NETWORK, CONSENSUS, NETWORK_GENESIS_TIME, MAX_TARGET])); + return { chainId, genesis: await digestHex(`${NETWORK}:genesis:${chainId}`) }; + })(); + return identity; +} diff --git a/shared/network.ts b/shared/network.ts new file mode 100755 index 0000000..1b6a50e --- /dev/null +++ b/shared/network.ts @@ -0,0 +1,41 @@ +/** Selection is fixed at server startup / browser build, never learned from an untrusted RPC. */ +export type NetworkProfile = 'testnet' | 'mainnet'; +export function networkProfile(value: string | undefined): NetworkProfile { + const profile = value ?? 'mainnet'; + if (profile !== 'testnet' && profile !== 'mainnet') { + throw new Error('WIKIDECK_NETWORK must be testnet or mainnet'); + } + return profile; +} +const isNodeRuntime = typeof process !== 'undefined' && !!process.versions?.node; +export const PROFILE = networkProfile(isNodeRuntime + ? process.env.WIKIDECK_NETWORK : import.meta.env?.VITE_WIKIDECK_NETWORK); +export const IS_MAINNET = PROFILE === 'mainnet'; +export const NETWORK = IS_MAINNET ? 'wikideck-randomx-mainnet-1' as const : 'wikideck-randomx-testnet-6' as const; +const TESTNET_GENESIS_TIME = 1_792_411_200; +const DEFAULT_MAINNET_GENESIS_TIME = 1_793_491_200; // 2026-11-01 00:00 UTC; fixed launch identity, never derived from the current clock. +function genesisTime(raw: string | undefined, profile: NetworkProfile): number { + const value = raw ?? String(profile === 'mainnet' ? DEFAULT_MAINNET_GENESIS_TIME : TESTNET_GENESIS_TIME); + if (!/^(0|[1-9]\d{8,10})$/.test(value)) throw new Error('WIKIDECK_GENESIS_TIME must be a canonical Unix-seconds integer'); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1_700_000_000 || parsed > 4_102_444_800) { + throw new Error('WIKIDECK_GENESIS_TIME is outside the supported range'); + } + return parsed; +} +export const NETWORK_GENESIS_TIME = genesisTime(isNodeRuntime + ? process.env.WIKIDECK_GENESIS_TIME : import.meta.env?.VITE_WIKIDECK_GENESIS_TIME, PROFILE); +if (isNodeRuntime) { + const imageProfile = process.env.WIKIDECK_IMAGE_NETWORK; + const imageGenesisTime = process.env.WIKIDECK_IMAGE_GENESIS_TIME; + if (imageProfile !== undefined && imageProfile !== PROFILE) { + throw new Error('Runtime network profile does not match the immutable image manifest'); + } + if (imageGenesisTime !== undefined && Number(imageGenesisTime) !== NETWORK_GENESIS_TIME) { + throw new Error('Runtime genesis time does not match the immutable image manifest'); + } + if (imageGenesisTime !== undefined && !/^(0|[1-9]\d{8,10})$/.test(imageGenesisTime)) { + throw new Error('Image genesis time manifest is malformed'); + } +} +export const NETWORK_LABEL = IS_MAINNET ? 'MAINNET' : 'TESTNET'; diff --git a/shared/rpc-client.ts b/shared/rpc-client.ts new file mode 100755 index 0000000..ad5e820 --- /dev/null +++ b/shared/rpc-client.ts @@ -0,0 +1,105 @@ +import { NETWORK, type CanonicalPage, type TestnetBlock, type TestnetSnapshot, type TestnetSummary } from './testnet.ts'; +import { expectedChainIdentity } from './chain-identity.ts'; + +const MAX_PAGE_BYTES = 2 * 1024 * 1024; +const MAX_HISTORY_BYTES = 64 * 1024 * 1024; +const MAX_CLIENT_BLOCKS = 10000; +const hex = /^[a-f0-9]{64}$/; +/** Bounded transport and continuity checks, NOT independent consensus/SPV verification. */ +export class StateClient { + private cache: { chainId: string; blocks: TestnetBlock[]; bytes: number } | undefined; + private inFlight: Promise | undefined; + private readonly fetcher: typeof fetch; + constructor(fetcher: typeof fetch = globalThis.fetch) { + // Browser Web APIs require the Window/global receiver, not this StateClient instance. + this.fetcher = fetcher.bind(globalThis); + } + async json(path: string, signal: AbortSignal): Promise { + const response = await this.fetcher(`/api/${path}`, { cache: 'no-store', redirect: 'error', signal }); + if (!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '') || !response.body) throw new Error('Node must return JSON.'); + const announced = response.headers.get('content-length'); + if (announced !== null && (!/^\d+$/.test(announced) || Number(announced) > MAX_PAGE_BYTES)) { + await response.body.cancel(); throw new Error('Node response exceeds client page limit.'); + } + const reader = response.body.getReader(), chunks: Uint8Array[] = []; + let bytes = 0; + try { + for (;;) { + const item = await reader.read(); if (item.done) break; + bytes += item.value.byteLength; + if (bytes > MAX_PAGE_BYTES) throw new Error('Node response exceeds client page limit.'); + chunks.push(item.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.byteLength; } + const data = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(joined)); + if (!response.ok) throw Object.assign(new Error(typeof data.error === 'string' ? data.error : 'Node RPC failed.'), { status: response.status }); + return data; + } + load(): Promise { + if (this.inFlight) return this.inFlight; + const result = this.loadFresh(); + this.inFlight = result; + void result.finally(() => { if (this.inFlight === result) this.inFlight = undefined; }).catch(() => {}); + return result; + } + private async summary(signal: AbortSignal) { + const s = await this.json('summary', signal); + const expected = await expectedChainIdentity(); + if (s.network !== NETWORK || s.chainId !== expected.chainId || s.genesis !== expected.genesis || + s.algorithm !== 'RandomX' || !hex.test(s.chainId) || !hex.test(s.tip) || !hex.test(s.genesis) || + !Number.isSafeInteger(s.height) || s.height < 0 || !Array.isArray(s.pendingTransfers) || s.pendingTransfers.length > 256 || + !/^\d{1,256}$/.test(s.cumulativeWork)) throw new Error('Incompatible node summary.'); + if (s.height > MAX_CLIENT_BLOCKS) throw new Error('This node exceeds the browser history limit (10,000 blocks). A paged explorer/wallet is required; no partial ownership state will be shown.'); + return s; + } + private async page(s: TestnetSummary, offset: number, signal: AbortSignal, limit = 16) { + const p = await this.json(`canonical?tip=${s.tip}&offset=${offset}&limit=${limit}`, signal); + const count = Math.min(limit, s.height - offset), next = offset + count < s.height ? offset + count : null; + if (p.network !== s.network || p.chainId !== s.chainId || p.tip !== s.tip || p.height !== s.height || p.offset !== offset || + p.next !== next || !Array.isArray(p.blocks) || p.blocks.length !== count) throw new Error('Invalid canonical pagination.'); + for (let i = 0; i < p.blocks.length; i++) { + const b = p.blocks[i]; + if (b.network !== s.network || b.chainId !== s.chainId || b.height !== offset + i + 1 || !hex.test(b.hash) || + !Array.isArray(b.transactions) || b.transactions.length > 64) throw new Error('Invalid canonical block page.'); + } + return p; + } + private async loadFresh(): Promise { + const signal = AbortSignal.timeout(30000); + for (let attempt = 0; attempt < 2; attempt++) { + try { + const s = await this.summary(signal); + let blocks: TestnetBlock[] = [], bytes = 0; + if (this.cache?.chainId === s.chainId && this.cache.blocks.length <= s.height) { + const cached = this.cache; + if (!cached.blocks.length || cached.blocks.at(-1)!.hash === s.tip || + (await this.page(s, cached.blocks.length - 1, signal, 1)).blocks[0]?.hash === cached.blocks.at(-1)!.hash) { + blocks = cached.blocks.slice(); bytes = cached.bytes; + } + } + while (blocks.length < s.height) { + const p = await this.page(s, blocks.length, signal); + const encodedBytes = new TextEncoder().encode(JSON.stringify(p.blocks)).byteLength; + bytes += encodedBytes; + if (bytes > MAX_HISTORY_BYTES) throw new Error('Browser history exceeds 64 MiB; no incomplete ownership state will be used.'); + for (const b of p.blocks) { + if (b.previousHash !== (blocks.at(-1)?.hash ?? s.genesis)) throw new Error('Canonical block continuity mismatch.'); + blocks.push(b); + } + } + if ((blocks.at(-1)?.hash ?? s.genesis) !== s.tip) throw new Error('Canonical tip does not match loaded history.'); + const final = await this.summary(signal); + if (final.chainId !== s.chainId || final.tip !== s.tip || final.height !== s.height) throw Object.assign(new Error('Canonical tip changed during loading.'), { status: 409 }); + this.cache = { chainId: s.chainId, blocks, bytes }; + const { height: _height, revision: _revision, ...metadata } = final; + return { ...metadata, blocks: blocks.slice(), transfers: blocks.flatMap(b => b.transactions) }; + } catch (error) { + if (attempt === 0 && (error as { status?: number }).status === 409) { this.cache = undefined; continue; } + throw error; + } + } + throw new Error('Could not load a stable canonical chain.'); + } +} diff --git a/shared/testnet.ts b/shared/testnet.ts index d0c18b9..76f73b7 100644 --- a/shared/testnet.ts +++ b/shared/testnet.ts @@ -1,39 +1,67 @@ import type { Article } from './protocol.ts'; import type { MintStatsSnapshot } from './enrichment.ts'; -export const NETWORK = 'wikideck-randomx-testnet-4' as const; +import { NETWORK } from './network.ts'; +export { NETWORK } from './network.ts'; export const CONSENSUS = { - version: 4, targetSeconds: 30, retargetInterval: 10, medianWindow: 11, + version: 6, targetSeconds: 15 * 60, retargetInterval: 10, medianWindow: 11, commitments: 'sha256-domain-separated-counted-merkle-v1', randomx: '1.2.1', powEndian: 'big', maxFutureSeconds: 120, epochBlocks: 2048, epochDelay: 64, - maxTransactions: 64, coinbaseMaturity: 2, maxBlockBytes: 64 * 1024, - articleSource: 'wikipedia-random-with-image-and-stats-v1', + maxTransactions: 64, maxTradeCards: 8, coinbaseMaturity: 2, maxBlockBytes: 64 * 1024, + articleSource: 'wikipedia-random-with-image-and-stats-v1', atomicTrades: 'signed-two-party-bundle-v1', } as const; +export const MAX_TARGET = `0fff${'f'.repeat(60)}`; export type NftTransfer = { network: typeof NETWORK; chainId: string; cardHash: string; from: string; to: string; sequence: number; publicKey: string; signature: string; }; +export type TradeLeg = { cardHash: string; sequence: number }; +export type NftTrade = { + type: 'trade'; network: typeof NETWORK; chainId: string; proposer: string; counterparty: string; + offered: TradeLeg[]; requested: TradeLeg[]; + proposerPublicKey: string; proposerSignature: string; + counterpartyPublicKey: string; counterpartySignature: string; +}; +export type TestnetTransaction = NftTransfer | NftTrade; +export function isNftTrade(transaction: TestnetTransaction): transaction is NftTrade { + return (transaction as NftTrade).type === 'trade'; +} export type BlockHeader = { - version: 4; network: typeof NETWORK; chainId: string; height: number; + version: 6; network: typeof NETWORK; chainId: string; height: number; previousHash: string; createdAt: number; article: Article; statsSnapshot: MintStatsSnapshot; owner: string; publicKey: string; nonce: number; target: string; key: string; transactionsRoot: string; stateRoot: string; }; export type TestnetBlock = BlockHeader & { - hash: string; tokenId: string; powHash: string; signature: string; transactions: NftTransfer[]; + hash: string; tokenId: string; powHash: string; signature: string; transactions: TestnetTransaction[]; }; export type MiningTemplate = { - header: BlockHeader; transactions: NftTransfer[]; blob: string; nonceOffset: number; + header: BlockHeader; transactions: TestnetTransaction[]; blob: string; nonceOffset: number; }; export type NftState = Record; +/** Persistent overlay: a block stores only NFT entries it changed, sharing its parent's state. */ +export type NftStateLayer = { kind: 'layer'; parent: NftStateView; changes: NftState }; +export type NftStateView = NftState | NftStateLayer; export type TestnetSnapshot = { network: typeof NETWORK; chainId: string; genesis: string; tip: string; cumulativeWork: string; - blocks: TestnetBlock[]; transfers: NftTransfer[]; pendingTransfers: NftTransfer[]; + blocks: TestnetBlock[]; transfers: TestnetTransaction[]; pendingTransfers: TestnetTransaction[]; peers: number; algorithm: 'RandomX'; maturity: number; }; +export type TestnetSummary = Omit & { height: number; revision: number }; +export type CanonicalPage = { network: typeof NETWORK; chainId: string; tip: string; height: number; + offset: number; next: number | null; blocks: TestnetBlock[] }; +export type TestnetDeck = { + network: typeof NETWORK; chainId: string; address: string; tip: string; + offset: number; limit: number; total: number; next: number | null; + nfts: { block: TestnetBlock; sequence: number; confirmations: number }[]; +}; export function nftTransferMessage(t: Omit): string { return JSON.stringify([NETWORK, 'nft-transfer', t.chainId, t.cardHash, t.from, t.to, t.sequence]); } +export function nftTradeMessage(t: Pick): string { + return JSON.stringify([NETWORK, 'nft-trade', t.chainId, t.proposer, t.counterparty, + t.offered.map(leg => [leg.cardHash, leg.sequence]), t.requested.map(leg => [leg.cardHash, leg.sequence])]); +} export function articleTuple(a: Article): unknown[] { return [a.pageId, a.revisionId, a.title, a.extract, a.image ?? '', a.url, a.source, a.category]; } @@ -51,8 +79,18 @@ export function headerMessage(h: BlockHeader): string { export function blockClaimMessage(hash: string): string { return JSON.stringify([NETWORK, 'block-claim', hash]); } +export function transactionCardIds(transaction: TestnetTransaction): string[] { + return isNftTrade(transaction) + ? [...transaction.offered, ...transaction.requested].map(leg => leg.cardHash) + : [transaction.cardHash]; +} export function testnetOwnerOf(state: Pick, hash: string): string | undefined { let owner = state.blocks.find(b => b.tokenId === hash)?.owner; - for (const t of state.transfers) if (t.cardHash === hash) owner = t.to; + for (const tx of state.transfers) { + if (isNftTrade(tx)) { + if (tx.offered.some(leg => leg.cardHash === hash)) owner = tx.counterparty; + if (tx.requested.some(leg => leg.cardHash === hash)) owner = tx.proposer; + } else if (tx.cardHash === hash) owner = tx.to; + } return owner; } diff --git a/shared/trade-offer.ts b/shared/trade-offer.ts new file mode 100755 index 0000000..6317ade --- /dev/null +++ b/shared/trade-offer.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { NETWORK, CONSENSUS } from './testnet.ts'; + +const hex = z.string().regex(/^[a-f0-9]{64}$/); +const signature = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).length(88); +const leg = z.object({ cardHash: hex, sequence: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER) }).strict(); + +export const tradeOfferSchema = z.object({ + format: z.literal('wikideck-trade-offer'), + version: z.literal(1), + network: z.literal(NETWORK), + chainId: hex, + proposer: hex, + counterparty: hex, + offered: z.array(leg).min(1).max(CONSENSUS.maxTradeCards), + requested: z.array(leg).min(1).max(CONSENSUS.maxTradeCards), + proposerPublicKey: z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).max(256), + proposerSignature: signature, +}).strict().superRefine((offer, ctx) => { + if (offer.proposer === offer.counterparty) ctx.addIssue({ code: 'custom', message: 'Trade parties must differ' }); + const ids = [...offer.offered, ...offer.requested].map(item => item.cardHash); + if (new Set(ids).size !== ids.length) ctx.addIssue({ code: 'custom', message: 'Each NFT may appear only once in a trade' }); +}); +export type TradeOffer = z.infer; diff --git a/shared/wallet-backup.ts b/shared/wallet-backup.ts index 67290d1..adc6cd4 100644 --- a/shared/wallet-backup.ts +++ b/shared/wallet-backup.ts @@ -1,13 +1,13 @@ // Browser/Node WebCrypto only: this module never reads storage or sends data. -const NETWORK = 'wikideck-randomx-testnet-4'; +import { IS_MAINNET, NETWORK, NETWORK_GENESIS_TIME } from './network.ts'; +import { expectedChainIdentity } from './chain-identity.ts'; const ITERATIONS = 600_000; const MAX_BYTES = 32 * 1024; const encoder = new TextEncoder(); -export type WalletBackup = { +type WalletBackupBase = { format: 'wikideck-wallet-backup'; - version: 1; - network: 'wikideck-randomx-testnet-4'; + network: typeof NETWORK; kdf: 'PBKDF2-SHA256'; iterations: 600000; cipher: 'AES-256-GCM'; @@ -16,6 +16,10 @@ export type WalletBackup = { publicKey: string; ciphertext: string; }; +export type WalletBackup = WalletBackupBase & ( + | { version: 1; chainId?: never } + | { version: 2; chainId: string } +); function encode(bytes: ArrayBuffer | Uint8Array): string { return btoa(String.fromCharCode(...new Uint8Array(bytes))); @@ -54,36 +58,50 @@ async function encryptionKey(password: string, salt: Uint8Array, us // Fixed-order AAD authenticates every header field, including identity/network. function additionalData(backup: WalletBackup): Uint8Array { return encoder.encode(JSON.stringify([ - backup.format, backup.version, backup.network, backup.kdf, backup.iterations, + backup.format, backup.version, backup.network, + ...(backup.version === 2 ? [backup.chainId] : []), backup.kdf, backup.iterations, backup.cipher, backup.salt, backup.iv, backup.publicKey, ])); } -function validateBackup(value: unknown): WalletBackup { +async function validateBackup(value: unknown): Promise { if (!value || typeof value !== 'object' || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) { throw new Error('Invalid wallet backup.'); } - const names = ['format', 'version', 'network', 'kdf', 'iterations', 'cipher', 'salt', 'iv', 'publicKey', 'ciphertext']; const descriptors = Object.getOwnPropertyDescriptors(value); + const version = descriptors.version && Object.hasOwn(descriptors.version, 'value') ? descriptors.version.value : undefined; + if (version !== 1 && version !== 2) throw new Error('Unsupported wallet backup format, network, or cryptography.'); + const names = ['format', 'version', 'network', ...(version === 2 ? ['chainId'] : []), + 'kdf', 'iterations', 'cipher', 'salt', 'iv', 'publicKey', 'ciphertext']; if (Reflect.ownKeys(value).length !== names.length || names.some(name => !Object.hasOwn(descriptors, name) || !Object.hasOwn(descriptors[name], 'value'))) { throw new Error('Invalid wallet backup fields.'); } const backup = value as WalletBackup; - if (backup.format !== 'wikideck-wallet-backup' || backup.version !== 1 || backup.network !== NETWORK || + if (backup.format !== 'wikideck-wallet-backup' || backup.network !== NETWORK || backup.kdf !== 'PBKDF2-SHA256' || backup.iterations !== ITERATIONS || backup.cipher !== 'AES-256-GCM') { throw new Error('Unsupported wallet backup format, network, or cryptography.'); } + if (backup.version === 2) { + const expected = await expectedChainIdentity(); + if (backup.chainId !== expected.chainId) throw new Error('Wallet backup is for a different chain.'); + } else if (IS_MAINNET || NETWORK_GENESIS_TIME !== 1_792_411_200) { + // Legacy v1 headers had no chain identity, so accept them only on the original testnet. + throw new Error('Legacy wallet backups are supported only on the original testnet.'); + } decode(backup.salt, 16, 16); decode(backup.iv, 12, 12); decode(backup.publicKey, 91, 91); decode(backup.ciphertext, 17, 4096); if (encoder.encode(JSON.stringify(backup)).length > MAX_BYTES) throw new Error('Backup exceeds 32 KB.'); // Snapshot primitives so callers cannot change headers during awaits. - return { format: backup.format, version: backup.version, network: backup.network, + const snapshot: WalletBackupBase = { format: backup.format, network: backup.network, kdf: backup.kdf, iterations: backup.iterations, cipher: backup.cipher, salt: backup.salt, iv: backup.iv, publicKey: backup.publicKey, ciphertext: backup.ciphertext }; + return backup.version === 2 + ? { ...snapshot, version: 2, chainId: backup.chainId } + : { ...snapshot, version: 1 }; } async function validatePair(privateKey: CryptoKey, publicKey: string): Promise { @@ -110,9 +128,10 @@ async function validatePair(privateKey: CryptoKey, publicKey: string): Promise { passwordBytes(password, 12).fill(0); await validatePair(privateKey, publicKey); + const { chainId } = await expectedChainIdentity(); const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); - const backup: WalletBackup = { format: 'wikideck-wallet-backup', version: 1, network: NETWORK, + const backup: WalletBackup = { format: 'wikideck-wallet-backup', version: 2, network: NETWORK, chainId, kdf: 'PBKDF2-SHA256', iterations: ITERATIONS, cipher: 'AES-256-GCM', salt: encode(salt), iv: encode(iv), publicKey, ciphertext: '' }; const key = await encryptionKey(password, salt, 'encrypt'); @@ -128,7 +147,7 @@ export async function encryptWallet(privateKey: CryptoKey, publicKey: string, pa export async function decryptWallet(backup: unknown, password: string): Promise<{ privateKey: CryptoKey; publicKey: string; address: string; }> { - const validated = validateBackup(backup); // Bound work before PBKDF2. + const validated = await validateBackup(backup); // Bound work before PBKDF2. const key = await encryptionKey(password, decode(validated.salt, 16, 16), 'decrypt'); let plaintext: Uint8Array | undefined; try { diff --git a/src/App.css b/src/App.css index b36ddc1..8701e44 100644 --- a/src/App.css +++ b/src/App.css @@ -1,3 +1,4 @@ +.project-pillars{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;margin:24px 0 36px}.project-pillars article{padding:24px;border:1px solid var(--theme-border);background:var(--ctp-mantle);border-radius:12px;display:flex;flex-direction:column;align-items:flex-start}.project-pillars article>svg{color:var(--theme-accent);margin-bottom:18px}.project-pillars .section-eyebrow{font-size:8px;line-height:1.7}.project-pillars h2{font-size:21px;line-height:1.3;letter-spacing:-.5px;margin:12px 0}.project-pillars p{font-size:12px;line-height:1.85;color:var(--ctp-subtext0);margin:0 0 18px}.project-pillars button{margin-top:auto;text-align:left}@media(max-width:1000px){.project-pillars{grid-template-columns:1fr}.project-pillars article{padding:22px}.project-pillars p{max-width:680px}} .app-shell{min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:224px;background:#151918;color:#f1f4ef;padding:34px 19px 22px;display:flex;flex-direction:column;z-index:20}.brand{display:flex;align-items:center;font-size:27px;letter-spacing:-1.3px;font-weight:750;padding:0 12px;gap:10px}.brand-dot{color:#d0ed9b;margin-left:-10px}.brand-mark{position:relative;display:grid;place-items:center;background:#d2ef9c;color:#1d2618;border-radius:9px;width:33px;height:36px;font-family:Georgia,serif;font-size:28px;font-weight:bold;letter-spacing:-4px;padding-right:4px}.brand-mark>span{position:absolute;font-size:12px;right:3px;top:0;letter-spacing:0}.sidebar-label{color:#737b74;font-size:9px;letter-spacing:1.7px;margin:53px 13px 19px}.nav-item{display:flex;align-items:center;gap:12px;width:100%;padding:14px 14px;margin:4px 0;border-radius:7px;background:transparent;color:#a1aaa3;font-size:12px;font-weight:500;text-align:left}.nav-item.active{color:#d7efaa;background:#2a3427}.nav-item:hover{background:#242d25}.nav-count{margin-left:auto;display:grid;place-items:center;min-width:20px;height:19px;border:1px solid #424b40;border-radius:4px;font-size:10px}.sidebar-note{margin:auto 4px 51px;border:1px solid #343d31;background:linear-gradient(135deg,#212a20,#1a211c);border-radius:10px;padding:19px 16px}.tiny-spark{font-size:27px;color:#c1dc95}.sidebar-note h3{font-size:17px;font-weight:500;line-height:1.5;letter-spacing:-.4px;margin:12px 0 9px}.sidebar-note p{font-size:11px;line-height:1.8;color:#97a18f}.sidebar-note button{display:flex;align-items:center;justify-content:space-between;width:100%;color:#d3e5b8;background:transparent;padding:17px 0 1px;font-size:11px}.sidebar-bottom{display:flex;align-items:center;gap:8px;font-size:10px;color:#adb6a9;border-top:1px solid #2c322c;padding:22px 10px 18px}.network-dot{display:inline-block;height:6px;width:6px;border-radius:50%;background:#83ae5f;box-shadow:0 0 0 3px #83ae5f12;flex-shrink:0}.disconnected{background:#c79a5b}.prototype{font-size:8px;letter-spacing:.8px;background:#293127;color:#a8bd90;border:1px solid #3b4735;border-radius:3px;padding:3px 5px;margin-left:auto}.help-link{display:flex;align-items:center;gap:9px;background:none;color:#8d978b;font-size:11px;padding:9px 10px}.help-link svg:last-child{margin-left:auto}.main-shell{margin-left:224px}.topbar{height:79px;border-bottom:1px solid #e7eae2;display:flex;align-items:center;justify-content:space-between;padding:0 43px;background:#fcfdfb}.breadcrumbs{display:flex;align-items:center;gap:12px;color:#8c9289;font-size:11px}.breadcrumbs span{color:#3d4639}.top-actions{display:flex;align-items:center;gap:24px}.network-pill{font-size:10px;color:#7b8574;display:flex;align-items:center;gap:8px}.wallet-button{display:flex;align-items:center;gap:10px;border:1px solid #e1e5dc;border-radius:6px;padding:7px 9px;background:#fff;font-size:10px;font-weight:600;letter-spacing:.2px}.wallet-avatar{display:grid;place-items:center;width:24px;height:24px;border-radius:4px;color:#566740;background:#e8efde}.wallet-button>svg{color:#8f9788;margin-left:6px}main{max-width:1540px;margin:0 auto;padding:35px 42px 0}.hero{min-height:367px;display:grid;grid-template-columns:1.15fr 1fr;position:relative;isolation:isolate;background:#edf1e7;border:1px solid #e6ebde;border-radius:12px;overflow:hidden}.hero-copy{padding:39px 0 36px 34px;z-index:3}.eyebrow{display:flex;align-items:center;gap:8px;font-size:8px;font-weight:650;letter-spacing:1.3px;color:#667459;margin-bottom:23px}.eyebrow>span{width:5px;height:5px;background:#6e8c4c;border-radius:50%}.hero h1{font-size:clamp(30px,3.05vw,49px);font-weight:600;line-height:1.18;letter-spacing:-1.8px;color:#263323;position:relative;white-space:nowrap}.hero h1>span{font-family:Georgia,'Times New Roman',serif;font-style:italic;font-weight:400;color:#6b8258}.hero-copy>p{max-width:365px;font-size:12px;line-height:1.85;color:#7b8372;margin:18px 0 23px}.hero-actions{display:flex;align-items:center;gap:18px}.button{display:inline-flex;align-items:center;justify-content:center;gap:10px;padding:12px 16px;border-radius:6px;font-size:11px;font-weight:600;white-space:nowrap;min-height:40px}.button.dark{background:#273320;color:#f3f8ea}.button.dark:hover{background:#374a2b}.button.dark svg:last-child{margin-left:8px}.text-button{font-size:11px;display:inline-flex;align-items:center;gap:11px;padding:8px 0;background:none;white-space:nowrap}.hero-foot{display:flex;gap:18px;color:#8a9580;font-size:9px;margin-top:20px}.hero-foot>span{display:flex;align-items:center;gap:4px}.hero-visual{position:relative;min-height:360px}.orbit{border:1px solid #c7d3ba80;border-radius:50%;width:340px;height:340px;position:absolute;left:4%;top:12px;transform:rotate(-25deg)}.orbit-two{height:235px;width:450px;left:-12%;top:65px;transform:rotate(-36deg)}.orbit-spark{position:absolute;color:#879b66}.spark-one{top:55px;left:14%;font-size:38px}.spark-two{bottom:58px;right:9%;font-size:31px;font-weight:200}.hero-card{position:absolute;width:196px}.hero-card .wiki-card{box-shadow:0 12px 25px #3a4d2d2b}.hero-card .card-art{height:135px}.hero-card .card-content{padding:13px 12px 10px}.hero-card .card-content p{font-size:8px;line-height:1.6;min-height:27px;-webkit-line-clamp:2}.hero-card .card-title h3{font-size:16px}.hero-card .card-stats{margin-top:10px;padding-top:9px}.hero-card .card-stats span{font-size:9px}.hero-card .card-bottom{font-size:6px;padding:9px 10px}.hero-card .card-top{font-size:7px;padding:9px}.hero-card .card-category{font-size:7px;padding:4px 7px}.front-card{top:57px;left:34%;transform:rotate(12deg);z-index:2}.back-card{top:34px;left:3%;transform:rotate(-13deg);z-index:1}.floating-label{display:flex;align-items:center;gap:8px;position:absolute;background:#ffffffeb;box-shadow:0 3px 12px #3644290c;border:1px solid #e4eadd;border-radius:5px;font-size:9px;padding:10px 13px;z-index:4;white-space:nowrap;color:#536249}.label-top{top:22px;right:6%;transform:rotate(5deg)}.label-bottom{bottom:22px;left:5%;transform:rotate(-5deg)}.label-bottom svg:first-child{color:#788f59}.metrics{display:grid;grid-template-columns:repeat(4,1fr);padding:25px 0;margin:0 0 4px;gap:8px}.metrics>div{display:flex;gap:12px;align-items:center;padding:0 15px;border-right:1px solid #e3e7dd}.metrics>div:first-child{padding-left:3px}.metrics>div:last-child{border:0}.metric-icon{height:38px;width:38px;display:grid;place-items:center;border-radius:9px;background:#edf0e8;color:#778366}.metrics>div>div>span{color:#89907f;font-size:10px;display:block;margin-bottom:4px}.metrics strong{font-size:22px;font-weight:600;letter-spacing:-.6px;display:flex;align-items:baseline;gap:9px}.metrics small{font-size:9px;font-weight:400;letter-spacing:0;color:#909684;white-space:nowrap}.metrics strong.algorithm{font-size:18px;flex-direction:column;gap:1px}.leaf{color:#88a761;background:#edf2e5}.mining-panel{display:grid;grid-template-columns:1.25fr 1fr;gap:35px;padding:27px 28px;background:#fff;border:1px solid #e4e8df;border-radius:10px;margin:10px 0 36px;position:relative}.section-eyebrow{display:flex;align-items:center;gap:7px;color:#8a937f;font-size:8px;letter-spacing:1.5px;font-weight:600;margin-bottom:10px}.idle-dot{height:5px;width:5px;border-radius:50%;background:#a5b295}.mining-description{display:flex;flex-direction:column;justify-content:center}.mining-description h2{font-size:22px;font-weight:550;letter-spacing:-.7px;margin:4px 0 11px}.mining-description p{max-width:380px;font-size:11px;line-height:1.9;color:#87907e}.mining-meta{display:flex;gap:16px;margin-top:24px;font-size:8px;color:#8e9684}.mining-meta>span{display:flex;align-items:center;gap:5px}.miner-controls{border-left:1px solid #edf0e7;padding-left:30px}.miner-display{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:13px}.miner-display>div:first-child{min-width:0}.miner-display span{font-size:8px;letter-spacing:1.2px;color:#8f9884;display:block;margin-bottom:6px}.miner-display strong{font-size:13px;font-weight:550;display:block;max-width:225px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.signal-bars{display:flex;align-items:center;gap:2px;height:36px}.signal-bars i{width:3px;border-radius:2px;background:#cbd9ba}.signal-bars i:nth-child(3n){background:#a7bd8b}.running i{animation:equalize .75s infinite alternate}.intensity{display:flex;align-items:center;justify-content:space-between;gap:5px;font-size:9px;color:#87917b;margin-bottom:11px}.intensity select{border:1px solid #e4e9dd;border-radius:4px;padding:4px 6px;font-size:9px;background:#fafcf7;color:#63734e}.consent{font-size:9px;color:#6d795f;display:flex;align-items:center;gap:6px;margin:8px 0 11px}.consent input{accent-color:#6c8748;margin:0;width:12px;height:12px}.lime{background:#d2eca7;color:#334723;width:100%;font-weight:650}.lime:hover{background:#c3e58c}.lime svg:last-child{margin-left:auto}.lime svg:first-child{margin-right:auto}.miner-footnote{display:block;font-size:8px;color:#a0a795;text-align:center;margin-top:8px}.mining-stop{width:100%;background:#edf2e4;border:1px solid #d9e3cb;color:#4a6334;margin-top:12px}.hash-line{font-family:Consolas,monospace;font-size:10px;color:#7b8f65;padding:11px 9px;background:#f4f7f0;border-radius:5px}.section-heading{display:flex;justify-content:space-between;align-items:center;gap:15px;margin-bottom:22px}.section-heading h2{font-size:23px;font-weight:550;letter-spacing:-.7px;display:flex;align-items:center;gap:9px}.section-heading p{font-size:11px;color:#929887;margin-top:7px;line-height:1.6}.section-heading>.text-button{color:#718260;font-size:10px}.count-badge{font:500 10px 'Segoe UI',sans-serif;color:#8a967b;border:1px solid #dfe5d7;border-radius:5px;padding:4px 6px;letter-spacing:0;display:inline-flex;vertical-align:middle}.filter-bar{display:flex;align-items:center;justify-content:space-between;gap:15px;padding:0 0 18px;border-bottom:1px solid #e9ece2;margin-bottom:22px}.category-tabs{display:flex;gap:4px;flex-wrap:wrap}.category-tabs button{background:transparent;color:#8e9684;font-size:10px;padding:7px 12px;border-radius:5px;white-space:nowrap}.category-tabs button.selected{color:#3b4e29;background:#e8eedf;font-weight:600}.category-tabs button:hover{background:#eef2e8}.search-input{display:flex;align-items:center;gap:7px;border:1px solid #e3e8db;border-radius:5px;padding:8px 10px;color:#97a08a;background:#fcfdfb;max-width:178px}.search-input input{border:0;outline:0;min-width:0;width:100%;background:transparent;font-size:9px;color:#58634d}.search-input input::placeholder{color:#a1a993}.card-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:20px}.wiki-card{display:block;position:relative;background:#fff;border:1px solid #dce4d3;border-radius:9px;overflow:hidden;text-align:left;width:100%;padding:0;box-shadow:0 3px 4px #202e1810;transition:transform .2s,box-shadow .2s}.wiki-card:hover{transform:translateY(-5px);box-shadow:0 9px 20px #293b211b}.card-top{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;color:#657b51;background:#f1f5e9;font-size:8px;letter-spacing:.5px}.card-top>span:first-child{display:flex;align-items:center;gap:4px}.card-top>span:last-child{color:#909b83;font-size:7px}.legendary .card-top{background:#f5f0e2;color:#9b8350}.legendary{border-color:#e3d9bd}.epic .card-top{background:#eeebf6;color:#8c78a9}.rare .card-top{background:#eaf1f6;color:#64839c}.card-art{height:183px;position:relative;background:radial-gradient(ellipse at top right,#688b8e,#192929);overflow:hidden;display:flex;align-items:center;justify-content:center;color:#b7cbb3}.card-art img{width:100%;height:100%;object-fit:cover;display:block;transition:transform .4s;mix-blend-mode:normal}.wiki-card:hover .card-art img{transform:scale(1.04)}.art-space{background:#0a1015}.art-space img{object-fit:contain;padding:11px}.art-people{background:#3b3d35}.art-people img{object-position:50% 30%;filter:sepia(.15)}.art-science{background:#e9e8d9}.art-science img{object-fit:contain;padding:12px}.art-nature{background:#224440}.art-nature img{filter:saturate(.65);object-position:45% 50%}.card-art::after{content:'';position:absolute;inset:0;background:linear-gradient(transparent 70%,#16201545);pointer-events:none}.card-category{position:absolute;bottom:10px;left:11px;font-size:8px;padding:4px 8px;color:#f1f5eb;background:#182d2999;backdrop-filter:blur(6px);border:1px solid #ffffff30;border-radius:4px;z-index:1}.card-content{padding:14px 14px 11px}.card-title{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:8px}.card-title h3{font-family:Georgia,'Times New Roman',serif;font-size:20px;font-weight:400;letter-spacing:-.4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.card-title>svg{color:#a7ad9c;width:13px}.card-content>p{font-size:9px;color:#909684;line-height:1.75;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:32px}.card-stats{display:flex;align-items:center;justify-content:space-between;border-top:1px solid #eff1e9;margin-top:13px;padding-top:12px;color:#6b785e}.card-stats>span{display:flex;align-items:center;gap:5px;font-size:10px}.card-stats svg{color:#98a489}.card-bottom{display:flex;align-items:center;justify-content:space-between;background:#fafbf7;border-top:1px solid #eff1e9;padding:9px 12px;font-size:6px;letter-spacing:.65px;color:#a0a790}.card-list-caption{display:flex;align-items:center;gap:5px;font-size:8px;color:#a0a891;margin:12px 2px}.card-list-caption>span:first-child{color:#8b967e;margin-right:auto}.caption-dot{height:4px;width:4px;background:#a2b78a;border-radius:50%}.empty-state{text-align:center;padding:58px 20px 65px;background:#f0f4eace;border:1px dashed #dce5d0;border-radius:9px;color:#8e9e7d}.empty-state>svg{margin-bottom:18px;color:#90a273}.empty-state h3{font-size:21px;font-weight:500;letter-spacing:-.5px;color:#4b5d3b}.empty-state p{font-size:12px;margin:11px auto 22px;max-width:410px;line-height:1.7}.explorer-section{min-height:65vh;padding-top:14px}.explorer-section h1{font-size:37px;font-weight:550;letter-spacing:-1.3px}.explorer-section h1>.count-badge{margin-left:12px}.outline{border:1px solid #dfe6d5;background:#fff;color:#647653}.explorer-note{display:flex;align-items:center;gap:13px;border:1px solid #dfe7d6;background:#eff4e8;padding:16px 19px;border-radius:7px;color:#7b8c67;margin:25px 0}.explorer-note p{font-size:11px;line-height:1.7}.table-scroll{overflow:auto;border:1px solid #e4e9de;border-radius:8px}table{border-collapse:collapse;width:100%;text-align:left;background:#fff;white-space:nowrap}th{font-weight:500;color:#98a18b;font-size:9px;letter-spacing:.5px;text-transform:uppercase;padding:16px 18px;background:#f3f6ef}td{border-top:1px solid #edf1e7;padding:19px 18px;font-size:11px;color:#778668}td strong{display:block;color:#3f5031;font-weight:550}td small{display:block;font-size:8px;margin-top:5px;color:#99a28e}tbody tr{cursor:pointer}tbody tr:hover{background:#f6f9f0}.mono{font-family:Consolas,monospace}.rarity-tag{display:inline-block;font-size:9px;padding:5px 8px;background:#edf2e6;color:#6c8352;border-radius:4px}.rarity-tag.legendary{color:#9f854f;background:#f5f0e2}.rarity-tag.epic{color:#8e78a5;background:#f0eaf6}.rarity-tag.rare{color:#63849d;background:#eaf2f7}.transfer-title{font-size:19px;font-weight:500;letter-spacing:-.4px;margin:34px 0 18px}.journal>div{display:flex;align-items:center;gap:18px;font-size:10px;color:#839271;padding:16px;background:#fff;border-bottom:1px solid #e5ecda}.journal>div>svg:last-child{margin-left:auto}.muted{color:#8a9580;font-size:12px;line-height:1.9}.error-banner{display:flex;align-items:center;gap:9px;background:#fcf0e8;border:1px solid #eedace;border-radius:6px;font-size:11px;color:#9f745c;padding:12px 15px;margin-bottom:18px}.error-banner>span{flex:1}.error-banner button{background:none;color:#9e715b;font-size:11px}.transfer-error{color:#ae6850;font-size:12px;line-height:1.7;padding:10px;background:#fff2eb;border-radius:5px}footer{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;border-top:1px solid #e4e9dc;margin-top:37px;padding:24px 0 20px;color:#929c85;font-size:9px}footer>a{display:flex;align-items:center;gap:8px}footer>a>svg{color:#7f8c70}footer>span{display:flex;align-items:center;gap:15px;font-family:Georgia,serif;font-style:italic;font-size:11px}.footer-star{font-size:21px;color:#8b9f71}footer>p{width:100%;font-size:8px;line-height:1.8;color:#a1a993}footer p a{text-decoration:underline;text-underline-offset:2px}.toast{position:fixed;bottom:28px;left:calc(50% + 112px);transform:translateX(-50%);z-index:70;display:flex;align-items:center;gap:12px;padding:15px 17px;background:#263421;color:#e4f0d6;border:1px solid #495a3d;border-radius:8px;box-shadow:0 6px 35px #1b2e252f;font-size:12px;max-width:calc(100% - 35px)}.toast>span{display:grid;place-items:center;background:#3e512f;border-radius:50%;padding:5px;color:#c4e99d}.toast>button{background:transparent;color:#b6c5a9;margin-left:14px}.modal-backdrop{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:#131c18a8;backdrop-filter:blur(6px);z-index:50;padding:28px}.modal{position:relative;padding:36px;max-height:90vh;overflow:auto;background:#fbfcf8;border:1px solid #e5ebdc;border-radius:14px;box-shadow:0 20px 70px #0c1a2140;width:505px;max-width:100%;animation:modal-in .2s ease-out}.modal-close{position:absolute;top:14px;right:14px;background:#edf1e7;color:#80936a;border-radius:50%;padding:6px;display:flex;z-index:2}.modal h2{font-size:28px;font-weight:550;letter-spacing:-.9px;line-height:1.3;margin:10px 0 16px}.modal-icon{display:grid;place-items:center;width:48px;height:48px;border-radius:12px;background:#e7efd9;color:#799458;margin-bottom:24px}.guide-steps{margin:25px 0}.guide-steps>div{display:flex;gap:15px;margin-bottom:24px}.guide-steps>div>span{color:#a2b18d;font-size:11px;padding-top:3px}.guide-steps h3{font-size:14px;font-weight:600;margin-bottom:7px}.guide-steps p{font-size:11px;color:#8b977e;line-height:1.9}.info-box{padding:15px 17px;border:1px solid #e1e8d6;background:#eff4e8;border-radius:7px;margin:24px 0}.info-box strong{font-size:11px;color:#687c51;font-weight:600}.info-box p{font-size:10px;line-height:1.9;color:#869473;margin-top:5px}.full{width:100%}.field-label{display:block;font-size:8px;letter-spacing:1.3px;color:#9aa68d;margin:24px 0 9px}.address-box{font-family:Consolas,monospace;font-size:11px;line-height:1.8;overflow-wrap:anywhere;padding:13px;border:1px solid #e0e8d6;background:#f0f5e9;border-radius:6px;color:#72845b;margin-bottom:18px;user-select:all}.wallet-count{display:flex;align-items:center;justify-content:center;gap:8px;color:#7b8d64;font-size:11px}.recipient-input{width:100%;padding:12px;font-size:11px;color:#647c4e;background:#fff;border:1px solid #dce7cf;border-radius:6px}.card-modal{display:grid;grid-template-columns:.8fr 1.2fr;gap:32px;width:850px;padding:42px 36px}.detail-card{align-self:center;pointer-events:none}.detail-card .card-art{height:230px}.detail-copy{align-self:center}.detail-copy>p{font-size:12px;line-height:1.9;color:#839173}.detail-facts{display:flex;justify-content:space-between;gap:10px;border-block:1px solid #e5ebdc;padding:15px 0;margin-top:21px;font-size:9px;color:#95a284}.detail-facts strong{display:block;color:#72865b;font-size:10px;margin-top:5px;font-weight:550}.detail-links{display:flex;flex-wrap:wrap;gap:18px;margin:20px 0 12px}.detail-links a{display:flex;align-items:center;gap:6px;font-size:10px;color:#6f8755}.detail-copy p.attribution{font-size:9px;line-height:1.8;color:#a3af96;margin:14px 0 20px}.detail-copy .field-label{margin-top:17px}.detail-copy .address-box{font-size:9px}.spin{animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}@keyframes equalize{from{transform:scaleY(.5);opacity:.6}to{transform:scaleY(1.2);opacity:1}}@keyframes modal-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}} @media(min-width:1500px){.hero{min-height:415px}.hero-copy{padding:48px 0 40px 45px}.hero-visual{margin-right:20px}.hero-card{width:225px}.hero-card .card-art{height:160px}.front-card{top:68px}.back-card{top:40px}.hero h1{font-size:51px}.hero-copy>p{font-size:13px;max-width:400px}.orbit-one{width:390px;height:390px}.orbit-two{width:500px;height:270px}.label-bottom{bottom:30px}.card-art{height:215px}.card-content>p{font-size:10px}.sidebar{width:235px}.main-shell{margin-left:235px}} @media(max-width:1200px){.sidebar{width:200px;padding-inline:14px}.main-shell{margin-left:200px}main{padding:28px 28px 0}.topbar{padding:0 28px}.hero-copy{padding:31px 0 28px 25px}.hero h1{font-size:36px}.hero-visual{min-height:350px}.hero-card{width:173px}.hero-card .card-art{height:120px}.back-card{left:3%;top:50px}.front-card{left:32%;top:80px}.floating-label{font-size:8px;padding:8px}.label-top{right:7%;top:30px}.label-bottom{bottom:22px;left:1%}.hero-copy>p{max-width:300px}.hero-actions{gap:12px}.hero-actions .text-button{font-size:10px}.hero-actions .button{font-size:10px;padding:11px 12px}.hero-foot{font-size:8px;gap:10px}.eyebrow{font-size:7px}.metrics>div{padding:0 10px;gap:9px}.metrics strong{gap:6px;font-size:20px}.metrics small{font-size:8px}.metric-icon{height:31px;width:31px}.mining-panel{padding:23px;gap:22px}.mining-description h2{font-size:21px}.mining-meta{gap:10px;font-size:7px}.miner-controls{padding-left:23px}.card-grid{gap:14px}.card-art{height:155px}.card-title h3{font-size:18px}.card-content{padding:12px}.section-heading h2{font-size:21px}.filter-bar{align-items:flex-start;flex-wrap:wrap}.search-input{max-width:170px}.category-tabs button{padding:6px 10px}.toast{left:calc(50% + 100px)}} diff --git a/src/App.tsx b/src/App.tsx index f224be3..ca9d178 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,29 +1,32 @@ import { useEffect, useRef, useState } from 'react'; -import { ArrowDownLeft, ArrowRight, ArrowUpRight, BookOpen, Check, ChevronRight, CircleHelp, +import { ArrowDownLeft, ArrowLeftRight, ArrowRight, ArrowUpRight, BookOpen, Check, ChevronRight, CircleHelp, Copy, Cpu, Download, ExternalLink, Globe2, Layers3, LayoutDashboard, LoaderCircle, Pickaxe, Search, Shield, Sparkles, Wallet as WalletIcon, X } from 'lucide-react'; import { rarity } from '../shared/protocol.ts'; -import { CONSENSUS, NETWORK, nftTransferMessage, testnetOwnerOf, +import { CONSENSUS, NETWORK, isNftTrade, nftTransferMessage, testnetOwnerOf, transactionCardIds, type TestnetBlock, type TestnetSnapshot } from '../shared/testnet.ts'; -import { getWallet, sign, type Wallet } from './wallet.ts'; +import { getWallet, sign, subscribeWallet, watchWalletLifecycle, type Wallet } from './wallet.ts'; import { Card, samples, imageCredit, type CardData } from './Card.tsx'; import { WalletPanel } from './WalletPanel.tsx'; import { useModalFocus } from './useModalFocus.ts'; import { ThemePicker } from './ThemePicker.tsx'; +import { TradeDesk } from './TradeDesk.tsx'; +import { IS_MAINNET, NETWORK_GENESIS_TIME, NETWORK_LABEL } from '../shared/network.ts'; +import { StateClient } from '../shared/rpc-client.ts'; import './App.css'; import './testnet.css'; -type View = 'Discover' | 'My collection' | 'Block explorer'; +type View = 'Discover' | 'My collection' | 'Block explorer' | 'Trade cards'; const short = (s: string) => s ? `${s.slice(0, 6)}…${s.slice(-4)}` : '—'; const asCard = (block: TestnetBlock): CardData => ({ article: block.article, hash: block.powHash, block }); const message = (error: unknown) => error instanceof Error ? error.message : 'Request failed. Please try again.'; async function request(path: string, body?: unknown, expectedStatus?: number): Promise { const res = await fetch(`/api/${path}`, { cache: 'no-store', signal: AbortSignal.timeout(15000), ...(body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) }); - if (!res.headers.get('content-type')?.includes('application/json')) throw new Error('The testnet RPC is unavailable. Start the node and web services.'); + if (!res.headers.get('content-type')?.includes('application/json')) throw new Error('The Wikideck node RPC is unavailable. Start the node and web services.'); const data = await res.json(); if (!res.ok) throw new Error(typeof data.error === 'string' ? data.error : 'Request failed'); - if (expectedStatus !== undefined && res.status !== expectedStatus) throw new Error('Unexpected testnet RPC response. Refresh before trying again.'); + if (expectedStatus !== undefined && res.status !== expectedStatus) throw new Error('Unexpected node RPC response. Refresh before trying again.'); return data; } @@ -35,7 +38,7 @@ function transferProblem(state: TestnetSnapshot, card: CardData, address: string if (testnetOwnerOf(state, block.tokenId) !== address) return 'This wallet is not the confirmed owner.'; if (state.blocks.length + 1 - block.height < CONSENSUS.coinbaseMaturity) return 'Immature NFT: transfers require two subsequent blocks at the next inclusion height.'; - if (state.pendingTransfers.some(t => t.cardHash === block.tokenId)) return 'A transfer for this NFT is already pending mining.'; + if (state.pendingTransfers.some(tx => transactionCardIds(tx).includes(block.tokenId))) return 'A transfer or trade involving this NFT is already pending mining.'; return ''; } @@ -56,15 +59,16 @@ export default function App() { const transferInFlight = useRef(false); const refreshVersion = useRef(0); const minerPanel = useRef(null); + const stateClient = useRef(new StateClient()); useModalFocus(Boolean(dialog || selected)); const refresh = async () => { const version = ++refreshVersion.current; try { - const data = await request('state'); + const data = await stateClient.current.load(); if (data.network !== NETWORK || data.algorithm !== 'RandomX' || typeof data.chainId !== 'string' || !Array.isArray(data.blocks) || !Array.isArray(data.transfers) || !Array.isArray(data.pendingTransfers)) - throw new Error('Incompatible node. This interface requires the Wikideck RandomX testnet API.'); + throw new Error(`Incompatible node. This interface is pinned to ${NETWORK} at genesis ${NETWORK_GENESIS_TIME}.`); if (version === refreshVersion.current) { setSnapshot(data); setRpcAvailable(true); setError(''); } return data; } catch (e) { @@ -74,10 +78,14 @@ export default function App() { }; useEffect(() => { let active = true; + const stopLifecycle = watchWalletLifecycle(); + const unsubscribe = subscribeWallet(() => { + void getWallet().then(value => { if(active) {setWallet(value);setRecipient('');setTransferError('');} }).catch(()=>{if(active)setWallet(undefined);}); + }); getWallet().then(value => { if (active) setWallet(value); }).catch(e => { if (active) setError(message(e)); }); void refresh().catch(() => {}); const timer = setInterval(() => { void refresh().catch(() => {}); }, 8000); - return () => { active = false; clearInterval(timer); refreshVersion.current++; }; + return () => { active = false; unsubscribe(); stopLifecycle(); clearInterval(timer); refreshVersion.current++; }; }, []); useEffect(() => { if (toast) { const timer = setTimeout(() => setToast(''), 8000); return () => clearTimeout(timer); } @@ -102,13 +110,13 @@ export default function App() { if (!/^[a-f0-9]{64}$/.test(to) || to === wallet.address) throw new Error('Enter another wallet’s 64-character receive address.'); const tokenId = selected.block.tokenId; const t = { network: NETWORK, chainId: state.chainId, cardHash: tokenId, - from: wallet.address, to, sequence: state.transfers.filter(tx => tx.cardHash === tokenId).length }; + from: wallet.address, to, sequence: state.transfers.filter(tx => transactionCardIds(tx).includes(tokenId)).length }; const tx = { ...t, publicKey: wallet.publicKey, signature: await sign(wallet, nftTransferMessage(t)) }; await request('transfer', tx, 202); // A 202 is a mempool acknowledgement, NOT a change of confirmed ownership. refreshVersion.current++; setSnapshot(current => current && current.chainId === state.chainId ? { ...current, - pendingTransfers: [...current.pendingTransfers.filter(p => p.cardHash !== tx.cardHash), tx] } : current); + pendingTransfers: [...current.pendingTransfers.filter(p => !transactionCardIds(p).includes(tx.cardHash)), tx] } : current); setDialog(null); setRecipient(''); setToast('Transfer pending. You remain the confirmed owner until it is mined into the canonical chain.'); void refresh().catch(() => {}); @@ -124,7 +132,7 @@ export default function App() { const selectedBlock = selectedMint && snapshot?.blocks.find(b => b.tokenId === selectedMint.tokenId && b.hash === selectedMint.hash); const confirmations = selectedBlock && snapshot ? snapshot.blocks.length - selectedBlock.height + 1 : 0; const selectedOwner = selectedBlock && snapshot ? testnetOwnerOf(snapshot, selectedBlock.tokenId) : undefined; - const pending = !!selectedBlock && !!snapshot?.pendingTransfers.some(t => t.cardHash === selectedBlock.tokenId); + const pending = !!selectedBlock && !!snapshot?.pendingTransfers.some(tx => transactionCardIds(tx).includes(selectedBlock.tokenId)); const transferDisabledReason = selected && wallet && snapshot ? transferProblem(snapshot, selected, wallet.address) : 'Wallet and node state are required.'; const peerLabel = !rpcAvailable ? 'Peers unknown' : snapshot?.peers ? `${snapshot.peers} peers` : 'Isolated node'; const nativeCommand = `npm run miner -- --wallet --consent --node ${window.location.origin}`; @@ -151,24 +159,24 @@ export default function App() { return
-
Explore {view}
RandomX TESTNET{peerLabel}
+
Explore {view}
RandomX {NETWORK_LABEL}{peerLabel}
-
RPC {rpcAvailable ? 'available' : 'unavailable'}{peerLabel}Unbootstrapped testnet · no public mainnet{snapshot && !rpcAvailable && Showing last known state}
+
RPC {rpcAvailable ? 'available' : 'unavailable'}{peerLabel}{IS_MAINNET?'MAINNET · experimental · unaudited · no value promised':'TESTNET · experimental · no value promised'}{snapshot && !rpcAvailable && Showing last known state}
{error &&
{error}
} {view === 'Discover' && <>
-
KNOWLEDGE, COLLECTED ON ITS OWN CHAIN.

Mine knowledge.
Collect the extraordinary.

Random Wikipedia articles, each with a picture. A chain of new discoveries. Mine native NFTs with an opt-in RandomX miner, and keep the keys to your collection.

Independent testnet No browser mining
+
MINE · COLLECT · TRADE CARDS

Mine with your CPU.
Collect the world.

Turn illustrated Wikipedia discoveries into collectible cards. Mine with RandomX, build a collection around what you love, and trade cards directly with other collectors.

CPU-friendly RandomX Direct card-for-card trades
✳+
Preview cards · not owned
setSelected(samples[2])} />
setSelected(samples[0])} />
A world worth collecting
@@ -178,29 +186,47 @@ export default function App() {
Proof of work{snapshot?.algorithm ?? 'RandomX'}native CPU mining
-
EXPLICIT OPT-IN · OUTSIDE YOUR BROWSER

Native RandomX mining

This page never runs mining code or launches a native program. Build the trusted native binary, download an encrypted wallet backup, then start the miner yourself in an interactive terminal.

One native worker Independent validating peers
Light · default256 MiB cache + scratchpad and overhead.Fast · explicit --fast~2 GiB dataset plus cache; budget over 2.3 GiB per process. Higher CPU, energy and memory use. RandomX is not a promise of ASIC-proof mining.
-
NEXT WIKIPEDIA ARTICLE{snapshot ? 'Chosen when the miner requests work' : 'Waiting for testnet RPC…'}

Each mining template requests a random English Wikipedia article with a Wikimedia picture. Pages may repeat; every minted card carries its own statistics snapshot.

  1. Build wikideck-randomx using the repository’s native build instructions.
  2. Save your encrypted backup and keep its password separate.
  3. Replace <backup.json> with your local file path (quote paths with spaces), then run:
{nativeCommand}

Node: {window.location.origin}. Same-origin RPC, including the development proxy. Use HTTPS for remote nodes. Password entry stays local; never put it in the command. Defaults to one worker and at most one block. Ctrl+C stops the native miner; closing this page does not.

+
RANDOMX · DESIGNED FOR GENERAL-PURPOSE CPUS

Mining should be for more people.

Wikideck and Monero use RandomX, an algorithm designed to resist specialized ASICs and keep general-purpose CPUs competitive. That can make mining more accessible—not make every machine equally powerful or promise rewards. Each accepted Wikideck block earns its miner a distinct illustrated Wikipedia card. Wikideck is its own independent network.

Mine with a CPU Independently validating peers
Hardware, rewards & network details
Light · default256 MiB cache plus scratchpad and process overhead.Fast · --fastAbout 2 GiB dataset plus cache; budget over 2.3 GiB per process. Mining uses CPU, memory, electricity and time. ASIC resistance is a design goal, not a guarantee; results depend on hardware and network difficulty. Wikideck does not share Monero’s network, hashpower or security.
+
NEXT WIKIPEDIA ARTICLE{snapshot ? 'Chosen when the miner requests work' : `Waiting for ${NETWORK_LABEL} RPC…`}

The miner selects a random illustrated English Wikipedia article. The same article can appear again; each card keeps its own mint-time statistics.

Want to run a miner? Setup & safety details
  1. Build wikideck-randomx using the repository’s native build instructions.
  2. Save your encrypted backup and keep its password separate.
  3. Replace <backup.json> with your local file path (quote paths with spaces), then run:
{nativeCommand}

Node: {window.location.origin}. The command connects to this node; use HTTPS for remote nodes. Password entry stays local—never put it in the command. It defaults to one worker and at most one block. Ctrl+C stops the native miner; closing this page does not.

+
+
+
MINE · EARN A CARD

Discover the next card.

Run the native RandomX miner to compete for blocks. Each accepted block rewards its miner with a distinct illustrated Wikipedia card.

+
COLLECT · BUILD YOUR DECK

Make every collection your own.

Browse discoveries on the chain, keep cards in your local wallet, and follow the subjects that interest you.

+
TRADE · PLAYER TO PLAYER

Swap cards directly.

Prepare a signed offer for another collector and exchange card bundles atomically when the trade is mined. No prices or checkout.

} - {view !== 'Block explorer' ?
-
{view === 'My collection' ? 'YOUR PERSONAL ENCYCLOPEDIA' : 'AN ENCYCLOPEDIA, REIMAGINED'}

{view === 'My collection' ? 'Your cabinet of curiosities' : 'A world of possible discoveries'}{visibleCards.length}

{view === 'My collection' ? 'Confirmed native NFT ownership. Pending transfers stay here until mined.' : snapshot?.blocks.length ? 'Recent native NFTs on this node’s canonical testnet chain.' : 'Sample previews only; mined NFTs use random Wikipedia pages with pictures. The same page may be minted more than once.'}

{view === 'Discover' && }
+ {view === 'Trade cards' ? : view !== 'Block explorer' ?
+
{view === 'My collection' ? 'YOUR PERSONAL ENCYCLOPEDIA' : 'AN ENCYCLOPEDIA, REIMAGINED'}

{view === 'My collection' ? 'Your cabinet of curiosities' : 'A world of possible discoveries'}{visibleCards.length}

{view === 'My collection' ? 'Confirmed native NFT ownership. Pending transfers stay here until mined.' : snapshot?.blocks.length ? 'Recent native NFTs on this node’s canonical chain.' : 'Sample previews only; mined NFTs use random Wikipedia pages with pictures. The same page may be minted more than once.'}

{view === 'Discover' && }
{['All cards', 'Nature', 'Space', 'Science', 'People', 'History', 'Culture'].map(c => )}
- {filtered.length ?
{filtered.map(c =>
{ setSelected(c); setTransferError(''); }} />
{c.block ? `Block #${c.block.height}` : 'Example discovery'}{c.block && snapshot ? short(testnetOwnerOf(snapshot, c.block.tokenId) ?? '') : 'Not owned'}
{c.block && snapshot?.pendingTransfers.some(t => t.cardHash === c.block!.tokenId) && Transfer pending mining}
)}
:

{query || category !== 'All cards' ? 'No discoveries here. Yet.' : 'Your first discovery is waiting.'}

{query || category !== 'All cards' ? 'Try another search or explore a different category.' : 'Use the native miner or receive a mined transfer on this chain. Restore a backup to access an existing wallet.'}

} + {filtered.length ?
{filtered.map(c =>
{ setSelected(c); setTransferError(''); }} />
{c.block ? `Block #${c.block.height}` : 'Example discovery'}{c.block && snapshot ? short(testnetOwnerOf(snapshot, c.block.tokenId) ?? '') : 'Not owned'}
{c.block && snapshot?.pendingTransfers.some(tx => transactionCardIds(tx).includes(c.block!.tokenId)) && Transfer or trade pending mining}
)}
:
Wikideck

{query || category !== 'All cards' ? 'No discoveries here. Yet.' : 'Your first discovery is waiting.'}

{query || category !== 'All cards' ? 'Try another search or explore a different category.' : 'Use the native miner or receive a mined transfer on this chain. Restore a backup to access an existing wallet.'}

}
:
FOLLOW THE CANONICAL CHAIN

Block explorer{snapshot?.blocks.length ?? 0}

Native NFT mints, RandomX proofs, and mined ownership transfers.

View full chain JSON
-

Sovereign testnet with independently validating peers and cumulative-work chain selection. This network is unbootstrapped, not a public mainnet. Zero peers means an isolated node, not a connected network. Canonical ownership can change during a reorganization.

+

Sovereign network with independently validating peers and cumulative-work chain selection. This network is experimental and may be unbootstrapped. Zero peers means an isolated node, not a connected network. Canonical ownership can change during a reorganization.

Cumulative work
{snapshot?.cumulativeWork ?? '—'}
Peer count
{rpcAvailable ? snapshot?.peers ?? '—' : 'Unknown'} · {peerLabel}
Chain ID · pinned genesis
{snapshot?.chainId ?? '—'}
Network
{NETWORK}
Tip block ID
{snapshot?.tip ?? '—'}
Mint maturity
{snapshot?.maturity ?? CONSENSUS.coinbaseMaturity} subsequent blocks at inclusion height
- {snapshot?.blocks.length ?
{[...snapshot.blocks].reverse().map(b => )}
Block / confirmationsDiscoveryBlock ID / NFT token IDConfirmed ownerRandomX proof / rarity
{snapshot.blocks.length - b.height + 1} confirmations{b.article.title}Wikipedia #{b.article.pageId}Block {short(b.hash)}NFT {short(b.tokenId)} {short(testnetOwnerOf(snapshot, b.tokenId) ?? '')}{short(b.powHash)}{rarity(b.powHash)}
:

A new chain. A blank page.

No canonical NFT blocks loaded. Native mining is an explicit, separate step.

} -

Mined ownership transfers

{snapshot?.transfers.length ?
{snapshot.transfers.map((t, i) =>
{short(t.cardHash)}{short(t.from)} → {short(t.to)}Sequence {t.sequence} · mined #{snapshot.blocks.find(b => b.transactions.some(tx => tx.signature === t.signature))?.height ?? '—'}
)}
:

No mined transfers. Ownership changes only when a transfer is included in a canonical block.

} -

Pending transfers {snapshot?.pendingTransfers.length ?? 0}

Mempool only · not confirmed ownership. Pending transactions may be dropped or invalidated by a reorganization.

{snapshot?.pendingTransfers.map(t =>
{short(t.cardHash)}{short(t.from)} → {short(t.to)}Awaiting mining
)}
+ {snapshot?.blocks.length ?
{[...snapshot.blocks].reverse().map(b => )}
Block / confirmationsDiscoveryBlock ID / NFT token IDConfirmed ownerRandomX proof / rarity
{snapshot.blocks.length - b.height + 1} confirmations{b.article.title}Wikipedia #{b.article.pageId}Block {short(b.hash)}NFT {short(b.tokenId)} {short(testnetOwnerOf(snapshot, b.tokenId) ?? '')}{short(b.powHash)}{rarity(b.powHash)}
:
Wikideck

A new chain. A blank page.

No NFT blocks loaded yet. Mine a block to earn the next Wikipedia card.

} +

Mined transfers and trades

{snapshot?.transfers.length ?
{snapshot.transfers.map((tx, i) => isNftTrade(tx) + ?
{tx.offered.length} cards {short(tx.proposer)} ⇄ {tx.requested.length} cards {short(tx.counterparty)}Atomic trade · mined #{snapshot.blocks.find(b => b.transactions.some(item => isNftTrade(item) && item.proposerSignature === tx.proposerSignature))?.height ?? '—'}
+ :
{short(tx.cardHash)}{short(tx.from)} → {short(tx.to)}Sequence {tx.sequence} · mined #{snapshot.blocks.find(b => b.transactions.some(item => !isNftTrade(item) && item.signature === tx.signature))?.height ?? '—'}
)}
:

No mined transfers or trades.

} +

Pending transfers and trades {snapshot?.pendingTransfers.length ?? 0}

Mempool only · not confirmed ownership. A trade bundle is included in full or not at all; pending transactions may be dropped or invalidated by a reorganization.

{snapshot?.pendingTransfers.map((tx, i) => isNftTrade(tx) + ?
{tx.offered.length} cards {short(tx.proposer)} ⇄ {tx.requested.length} cards {short(tx.counterparty)}Atomic trade · awaiting mining
+ :
{short(tx.cardHash)}{short(tx.from)} → {short(tx.to)}Awaiting mining
)}
} -
Built on the shoulders of Wikipedia.Knowledge, not financial promises.✦

Independent experiment, not affiliated with Wikimedia. Mined article snapshots are selected from Wikipedia; attribution and image licenses appear on each card. Native NFTs are not ERC-721 tokens, fiat, or rights to article content. No promised monetary value.

+
Wikipedia pages. Collectible discoveries.Mine. Collect. Trade.✦

Independent project, not affiliated with Wikimedia or Monero. NFT rewards represent Wikipedia article snapshots, not ownership of Wikipedia pages or their copyright. Attribution and image licenses appear on each card. Current network: {NETWORK_LABEL}; experimental, with no promised monetary value.

{toast &&
{toast}
} - {(dialog || selected) &&
{ if (e.target === e.currentTarget) close(); }}>
- {dialog === 'guide' ? <>
THE TESTNET FIELD GUIDE

A universe in every card.

01

Mine natively. Only by choice.

No browser mining or automatic native execution. Build the trusted RandomX binary and run the miner yourself with an encrypted wallet backup and explicit --consent. One worker defaults to light mode (256 MiB cache plus overhead); --fast adds a ~2 GiB dataset. Mining uses CPU and energy, may warm your device, and is not guaranteed ASIC-proof. Ctrl+C stops it.

02

A page, a snapshot. A sovereign chain.

Each mining template asks Wikipedia for a random English article with a picture. A page can appear on multiple NFTs; each block commits its own article and mint-time statistics snapshot. Validators verify the snapshot's structure and commitments without contacting Wikimedia during consensus validation. Independent peers validate RandomX work, native NFT mints, and signed transactions, choosing the cumulative-work canonical chain. This testnet is unbootstrapped: there is no public mainnet, and an isolated node has no peer-backed network security.

03

Keep your keys. Confirm your transfers.

Your P-256 wallet is a local signing identity, not a server login. The new testnet wallet is separate from the original demo wallet. Download a password-encrypted backup and restore it locally to control the same NFTs. A submitted transfer is pending, not final ownership. Mints require two subsequent blocks at inclusion height; reorganizations can change confirmations and ownership.

Native NFTs, not content ownership.

Token IDs identify native assets on this chain, not ERC-721 tokens on another network. RandomX proof hashes determine game rarity and stats; block IDs identify blocks. An NFT does not grant copyright or other intellectual-property rights to Wikipedia text or images. Follow their licenses and attribution. No fiat, financial return, or monetary value is promised.

- : dialog === 'wallet' ? <>
YOUR LOCAL TESTNET IDENTITY

A wallet for your wonders.

No server login. This recoverable wallet controls native NFTs through local signatures. It is separate from the original demo wallet.

{wallet?.address ?? 'Wallet unavailable. Enable local browser storage and retry.'}

{owned.length} confirmed native NFTs in your collection

- : dialog === 'transfer' ? <>
PASS ON THE CURIOSITY

Send {selected?.article.title}.

Use the recipient’s 64-character testnet wallet address on this same chain. Signing submits a pending transaction; it does not immediately transfer ownership.

{selected?.block?.tokenId}
setRecipient(e.target.value)} placeholder="Paste a 64-character wallet address" autoComplete="off" maxLength={64} />
Review before signing.

Transfers take effect only when mined into the canonical chain. Once confirmed, there is no undo button or recovery for a wrong address. Mints require two subsequent blocks at inclusion height; only one pending transfer per NFT is allowed.

{(!rpcAvailable || transferDisabledReason) &&

{!rpcAvailable ? 'RPC unavailable. Reconnect before signing.' : transferDisabledReason}

}{transferError &&

{transferError}

} + {(dialog || selected) &&
{ if (e.target === e.currentTarget) close(); }}>
+ {dialog === 'guide' ? <> +
THE WIKIDECK FIELD GUIDE

Mine. Collect. Trade cards.

+
+
01

Mine with a CPU. Discover a card.

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

Mining, rewards & network facts

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

+
02

Keep your collection in your wallet.

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

Wallet and ownership basics

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

+
03

Trade cards directly with another collector.

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

What happens when I accept?

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

+
+
Collectible snapshots—not ownership of Wikipedia.

Cards do not grant rights to Wikipedia text or images. Check each card’s attribution and licenses. This is an experimental {NETWORK_LABEL.toLowerCase()} network; cards have no promised monetary value.

+ + + : dialog === 'wallet' ? <>
YOUR LOCAL {NETWORK_LABEL} IDENTITY

A wallet for your wonders.

No server login. This recoverable wallet controls native NFTs through local signatures. It is separate from the original demo wallet.

{wallet?.address ?? 'Wallet unavailable. Enable local browser storage and retry.'}

{owned.length} confirmed native NFTs in your collection

+ : dialog === 'transfer' ? <>
PASS ON THE CURIOSITY

Send {selected?.article.title}.

Use the recipient’s 64-character wallet receive address on this same network. Signing submits a pending transaction; it does not immediately transfer ownership.

{selected?.block?.tokenId}
setRecipient(e.target.value)} placeholder="Paste a 64-character wallet address" autoComplete="off" maxLength={64} />
Review before signing.

Transfers take effect only when mined into the canonical chain. Once confirmed, there is no undo button or recovery for a wrong address. Mints require two subsequent blocks at inclusion height; only one pending transfer per NFT is allowed.

{(!rpcAvailable || transferDisabledReason) &&

{!rpcAvailable ? 'RPC unavailable. Reconnect before signing.' : transferDisabledReason}

}{transferError &&

{transferError}

} : selected && <>
{selected.block ? `MINTED IN BLOCK #${selected.block.height}` : 'EXAMPLE CARD · NOT OWNED'}

{selected.article.title}

{selected.article.extract}

Page ID#{selected.article.pageId}Rarity{rarity(selected.hash)}Content{selected.article.source === 'live' ? `Pinned revision ${selected.article.revisionId}` : 'Sample preview'}
{selected.block && <>
{selected.block.tokenId}
{selected.block.hash}
{selected.hash}

{confirmations} canonical confirmations{!rpcAvailable ? ' · last known state' : ''}. {selectedBlock ? `Confirmed owner: ${selectedOwner}` : 'This mint is not on the loaded canonical chain.'}

{pending &&

Transfer pending mining · confirmed owner unchanged.

}}

{selected.article.source === 'live' ? 'Pinned text by Wikipedia contributors, CC BY-SA 4.0. Excerpt shortened for card display.' : 'Original fixed starter summary, linked to the real Wikipedia article; no live revision captured.'} Stats are game values, not encyclopedia facts. NFT ownership grants no rights to article text or images.

{selected.block && selectedOwner === wallet?.address && <>{transferDisabledReason &&

{transferDisabledReason}

}}
}
}
; diff --git a/src/Card.tsx b/src/Card.tsx index 930f56a..10e0471 100644 --- a/src/Card.tsx +++ b/src/Card.tsx @@ -1,4 +1,4 @@ -import { ArrowUpRight, BookOpen, Globe2, Shield, Sparkles, Swords } from 'lucide-react'; +import { ArrowUpRight, BookOpen, Shield, Sparkles, Swords } from 'lucide-react'; import { rarity, stats, type Article } from '../shared/protocol.ts'; import type { TestnetBlock } from '../shared/testnet.ts'; import { STARTER_ARTICLES } from '../shared/catalog.ts'; @@ -25,7 +25,7 @@ export function Card({ card, onClick }: { card: CardData; onClick?: () => void } return <>

Wallet holdings are public on this chain. Looking up an address reveals its confirmed cards at the node’s current chain tip.

+
+ toggle(setOffered, offered, id)} disabled={!wallet || busy}/> +
Swap
+ toggle(setRequested, requested, id)} disabled={busy || !remoteDeck}/> +
+
{offered.size} offered · {requested.size} requested
+
SHARE AN OFFER

Your wallet signs the offer; it becomes actionable only after the named player reviews and accepts it. Offers are shared as text, so send them through a channel you trust. Both signatures are checked before the complete card swap is submitted.