90 Commits
Author SHA1 Message Date
eric 5dcdc1cdc6 Merge branch 'main' into v2.1 2026-11-01 22:07:09 +00:00
eric b88dfb1e94 deploy 2026-11-01 16:43:28 -05:00
eric b427fa4217 Mainnet 2026-10-29 11:45:51 -04:00
eric b3a1179703 Remove markets 2026-10-28 18:33:52 -04:00
eric eea2deb1fb Removed markets 2026-10-28 17:09:58 -04:00
eric 216a419c0b Observer integration step 2026-10-23 10:20:58 -04:00
eric bd3d36a7e1 Bounded test succeeded; native RandomX miner accepted one block 2026-10-22 21:25:39 -04:00
eric 176d1927a8 Added pilot-bound Monero observation persistence and the one-shot monero:pilot-observe command 2026-10-22 20:04:05 -04:00
eric 36963fed30 Added the guarded, one-shot monero:pilot-address command 2026-10-22 19:10:08 -04:00
eric 83d3d104c1 operator/library-only Wallet-RPC address creation and recovery 2026-10-22 18:55:05 -04:00
eric 2ecb42c019 Durable, beneficiary-bound subaddress index reservation 2026-10-22 17:26:13 -04:00
eric 84e6e08209 Validation-only pilot contract 2026-10-22 16:11:36 -04:00
eric 359f0df32d Separate identity-only pilot custody foundation 2026-10-22 15:40:37 -04:00
eric d8780701e6 monerod get_info 2026-10-22 15:22:23 -04:00
eric 3f04c3afa9 Real XMR pilot 2026-10-22 14:40:00 -04:00
eric 52559f65d7 Ledger-side credit and withdrawal-intent paths 2026-10-22 13:28:55 -04:00
eric 4fea176877 Opt-in, mainnet-only deposit-address API 2026-10-22 11:44:32 -04:00
eric b865807272 Signed, beneficiary-bount Monero subaddress enrollment 2026-10-22 11:33:38 -04:00
eric 5d607c1521 Removed SHKeeper 2026-10-22 10:45:32 -04:00
eric e835238d05 Hardened systemd deployment contract for read-only Monero observer 2026-10-22 10:08:15 -04:00
eric 158981e965 Implemented and validated the separate Monero observation-worker path 2026-10-22 09:38:20 -04:00
eric 12f80fdc53 Private CLI workflow for reviewed depowit operations 2026-10-22 02:09:07 -04:00
eric 05aead39ae Reviewed release and reorg-hold resolution 2026-10-21 22:24:42 -04:00
eric 9f82f050b4 Verified incoming transfers against Monero Wallet PRC 2026-10-21 21:34:46 -04:00
eric af646dcc32 First deposit integration slice 2026-10-21 20:07:27 -04:00
eric e3b2261da3 Stagenet rehearsal passed 2026-10-21 18:38:25 -04:00
eric 40f4aa6d4e Started Walet RPC auth work without adding MD5 Digest support 2026-10-21 17:33:33 -04:00
eric f00ea55dfd Stagenet wallet rehearsal passed 2026-10-21 16:53:59 -04:00
eric 0534cde1a9 Added wallet-identity check before allocation 2026-10-21 15:25:00 -04:00
eric c8cf8245fc Wallet subaddress creation with retry recovery 2026-10-21 15:12:05 -04:00
eric e70199ecb0 Implemented PostgreSQL v3 allocation-registry groundwork 2026-10-21 14:59:15 -04:00
eric ee5dc35330 Implemented the next deposit-observation step without activating live deposits 2026-10-21 14:09:55 -04:00
eric 79abaa78f2 Read-only Monero wallet-RPC observer 2026-10-21 13:05:57 -04:00
eric c07f3a737b Loopback HTTP benchmark 2026-10-21 12:40:00 -04:00
eric 74f269923c Moved canonical transaction, NFT, and mempool reads into a separate read-only Ledger SQLite connection 2026-10-21 12:24:53 -04:00
eric 4566271ce3 Moved canonical transaction, NFT, and mempool fact reads into the reconciliation worker's own read-only SQLite snapshot 2026-10-21 12:18:33 -04:00
eric 9282c0234d Moved reconciliation row selection and order/listing status evaluation into the worker thread 2026-10-21 11:35:37 -04:00
eric 7b6873921a Worker-owned reconciliation commits 2026-10-21 11:07:36 -04:00
eric a127a40293 Reconciliation uses 32-row SQLite slices 2026-10-21 10:29:45 -04:00
eric 6adfda3cf0 Extended benchmark 2026-10-21 10:14:34 -04:00
eric 687b628424 Market-journal reconciliation benchmark 2026-10-21 10:06:56 -04:00
eric 1f11210b5b Bounded reconciliation increment 2026-10-21 09:57:50 -04:00
eric 8b0e2287a5 Paired-backup verifier now checks more than the manifest hash 2026-10-21 02:39:02 -04:00
eric 0ac2073038 Cold-restore rehearsal 2026-10-20 23:18:14 -04:00
eric 5b21074462 Separate one-shot utility 2026-10-20 23:04:29 -04:00
eric ccc6eef951 Added native RandomX integration check 2026-10-20 20:20:31 -04:00
eric 7025c37390 Added opt-in canonical observer polling lifecycle 2026-10-20 20:11:57 -04:00
eric 8cfcf6c025 Strengthened the disposable reorg rehearsal 2026-10-20 19:31:08 -04:00
eric 22da9764eb Per-order terminal reorg linkage 2026-10-20 19:08:19 -04:00
eric e1fbf5e5af Disposable canonical-event inbox rehearsal 2026-10-20 18:50:02 -04:00
eric ca8ff48628 Paid-sale workflow 2026-10-20 17:38:34 -04:00
eric 621611ecd9 TLS 2026-10-20 17:09:22 -04:00
eric 447314440b Strengthen financial reconciliation checks 2026-10-20 16:56:13 -04:00
eric 7734d8ae90 Environment variables 2026-10-20 16:24:01 -04:00
eric 4cac26d84b Persistent PostgreSQL sandbox deployment 2026-10-20 16:02:18 -04:00
eric 84a505c4a5 NFT sale-lock protocol 2026-10-20 15:38:37 -04:00
eric c254c4c537 Custodial financial storage 2026-10-20 15:21:47 -04:00
eric e48081f041 Canonical indexes and recoverable market observation 2026-10-20 14:35:45 -04:00
eric fc65e497a8 Custodial markets 2026-10-20 14:04:59 -04:00
eric 4e753b35fe Recommendation 2026-10-20 13:08:57 -04:00
eric bcd8630a8e Planning 2026-10-20 13:00:08 -04:00
eric a1de67ea14 XMR native 2026-10-20 12:09:58 -04:00
eric 558427545f Production plan 2026-10-20 11:48:34 -04:00
eric d6f230d6b9 Marketplace documentation 2026-10-20 10:54:50 -04:00
eric 2814b46b73 Documentation and UI 2026-10-20 10:40:18 -04:00
eric f2bd3e38e2 RPC 2026-10-20 10:12:33 -04:00
eric 2dd7382004 SHKeeper 2026-10-20 10:06:38 -04:00
eric 55f26d3fad Deployment monitoring and recovery tooling 2026-10-20 09:49:41 -04:00
eric 9d61c177b7 Consensus calibration 2026-10-20 09:26:12 -04:00
eric 4080114085 encryption 2026-10-20 05:27:18 -04:00
eric 3cee89d023 Improvements 2026-10-20 04:07:37 -04:00
eric 0cc5032857 Security 2026-10-20 03:40:26 -04:00
eric 9850fd8554 Markets 2026-10-20 03:14:08 -04:00
eric b1d2b5c10e Trades 2026-10-19 21:47:38 -04:00
eric f8eeae826f Logo 2026-10-19 20:32:25 -04:00
eric baad477ca5 More scalable data storage 2026-10-19 20:17:10 -04:00
eric 1edf2e6200 Merge pull request 'More cards' (#20) from v2 into main
Reviewed-on: WikiDeck/wikideck#20
2026-10-19 22:57:02 +00:00
eric 0b9061372b More cards 2026-10-19 18:52:49 -04:00
eric 716c9612cc Merge pull request 'Rich card data' (#19) from v2 into main
Reviewed-on: WikiDeck/wikideck#19
2026-10-19 21:51:21 +00:00
eric 9c93c9ec15 Merge branch 'main' into v2 2026-10-19 21:50:52 +00:00
eric 45785fb627 Removed ignored files from repo 2026-10-19 17:39:37 -04:00
eric 6155e8b3ed Rich card data 2026-10-19 16:47:27 -04:00
eric 9625ee2db7 Merge pull request 'RandomX' (#18) from v2 into main
Reviewed-on: WikiDeck/wikideck#18
2026-10-19 20:41:11 +00:00
eric f26e38b096 Merge branch 'main' into v2 2026-10-19 20:39:43 +00:00
eric 4fefffe4a1 submodule 2026-10-19 15:49:04 -04:00
eric bc6471fc54 randomx-src 2026-10-19 15:48:49 -04:00
eric 4b47386853 randomx 2026-10-19 15:42:39 -04:00
eric 10b5466f88 Merge pull request 'v2' (#17) from v2 into main
Reviewed-on: WikiDeck/wikideck#17
2026-10-19 16:21:57 +00:00
eric 6d3f470922 v2 2026-10-19 12:16:49 -04:00
eric d7ecab6494 Getting ready for K8S deploy (#7)
Reviewed-on: WikiDeck/wikideck#7
2025-10-27 16:18:06 +00:00
139 changed files with 12099 additions and 2211 deletions
+20
View File
@@ -0,0 +1,20 @@
node_modules
dist
.git
data
native/build
.env
.env.*
!.env.example
**/*.wallet.json
**/*backup*.json
**/*.sqlite
**/*.sqlite-*
**/*.pem
**/*.key
**/*.p12
**/*.pfx
*.log
.vscode
.idea
View File
+30 -3
View File
@@ -1,3 +1,30 @@
venv/*
mine_data/*
mariadb_data/*
node_modules/
dist/
data/
native/build/
*.wallet.json
.env
*.log# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
[submodule "native/build/_deps/randomx-src"]
path = native/build/_deps/randomx-src
url = https://github.com/tevador/RandomX.git
+66 -6
View File
@@ -1,6 +1,66 @@
FROM ericomeehan/httpd-mod-wsgi
RUN apt update && apt upgrade -y
RUN apt install -y libmariadb3 libmariadb-dev python3-dev python3-pip
COPY . /usr/local/apache2/htdocs
RUN pip3 install --break-system-packages -r /usr/local/apache2/htdocs/requirements.txt
EXPOSE 80
# syntax=docker/dockerfile:1
FROM node:24-alpine AS randomx-build
RUN apk add --no-cache build-base cmake git linux-headers
WORKDIR /native
COPY native/CMakeLists.txt native/randomx-engine.cpp ./
RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DWIKIDECK_RANDOMX_TESTS=ON \
&& cmake --build build --parallel 2 \
&& ctest --test-dir build --output-on-failure
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG WIKIDECK_NETWORK=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 ./
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 \
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
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3001/healthz', {signal: AbortSignal.timeout(4000)}).then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
# Node 24 strips the server's erasable TypeScript; no tsx/dev dependencies at runtime.
CMD ["node", "--experimental-strip-types", "server/index.ts"]
+4
View File
@@ -0,0 +1,4 @@
FROM node:24-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --package-lock-only --ignore-scripts
+164 -10
View File
@@ -1,10 +1,164 @@
# WikiDeck
An [NFT](https://en.wikipedia.org/wiki/Non-fungible_token)
[blockchain](https://en.wikipedia.org/wiki/Blockchain) that rewards Wikipedia trading cards.
## Use
See `test/Mine/TestMine.py` for a demonstration of the basic client workflow.
## Status
At present, only the files in `wikideck/Mine` are functional - and only partially so. This
project is currently a proof-of-concept.
# Wikideck
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.
**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).
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:
```sh
docker compose -f compose.testnet.yaml up --build -d
docker compose -f compose.testnet.yaml ps
docker compose -f compose.testnet.yaml logs -f node-a
```
Open **http://127.0.0.1:3101**. The other nodes are at **http://127.0.0.1:3102** and **http://127.0.0.1:3103**. Each node validates blocks independently and stores its ledger in a separate Docker volume. Mining does not start automatically. A healthy node is not necessarily connected to its peers; check the peer status in the interface or request `/api/peers`. Nodes need outbound access to Wikipedia when generating mining templates and capturing mint-time statistics. If that data is unavailable, the node returns an error and mining can be retried.
Stop the nodes with:
```sh
docker compose -f compose.testnet.yaml down
```
The target block interval is **15 minutes**. The volumes persist across stops and rebuilds. **Do not add `-v` unless you intend to delete all three ledgers.** Back up wallets separately. Protocol v6 adds atomic multi-card trades and a new chain identity; earlier v2–v5 ledgers and encrypted backups are not compatible. To keep old Docker data intact while trying v6, use a separate Compose project name, for example `docker compose -p wikideck-v6 -f compose.testnet.yaml up --build -d`, and use the same `-p wikideck-v6` for subsequent Compose commands. The default `docker compose up --build -d` uses [compose.yaml](compose.yaml): it starts one isolated node on port 3001 with a different volume.
For an existing host install, keep the old data directory intact and set `WIKIDECK_DATA_DIR` to a new empty directory before starting protocol v6. The node intentionally fails closed on an old-protocol ledger; it does not reset or migrate it. A v6 JSON ledger is imported into SQLite on first startup and left in place as a recovery copy. `npm start` defaults to the gated mainnet identity; use `npm run dev` only for local testnet development.
## Run on your host
Requirements: Node.js 24, npm, Git, CMake 3.24+, and a C/C++17 toolchain. The first native-engine configuration downloads the pinned RandomX source over HTTPS. On Windows, install a compatible toolchain (for example, MinGW-w64 and make) and use the matching CMake generator. Do not change generators in an existing `native/build` directory; use a fresh build directory when switching toolchains.
Install dependencies and build the native RandomX engine:
```sh
npm install
npm run native:configure
npm run native:build
npm run test:native
npm run build
npm start
```
On Windows with MinGW, configure with `npm run native:configure -- -G "MinGW Makefiles"`. If PowerShell blocks the `npm` shim, use `npm.cmd`; follow your organization's execution policy rather than bypassing it. The optional platform scripts are [scripts/build-randomx.ps1](scripts/build-randomx.ps1) and [scripts/build-randomx.sh](scripts/build-randomx.sh). See [native engine setup and protocol](docs/randomx-native.md) for toolchain details.
The node listens at **http://127.0.0.1:3001** by default. For development, `npm run dev` starts the API on port 3001 and Vite on **http://127.0.0.1:5173**. Keep both services on loopback; Vite proxies the browser's `/api` requests to the API. The native miner should connect directly to the API port, not Vite.
## Wallets and backups
Open the wallet panel to explicitly create an encrypted local P-256 wallet, unlock an existing vault, or encrypt a legacy plaintext wallet without changing its identity. Nothing is silently generated or unlocked at startup. Choose a strong local password of at least 12 characters. **Wallet backup & recovery** downloads a separately password-encrypted portable backup; keep the file outside the repository and its password separately, and test recovery in another browser profile.
New/migrated local vaults encrypt the key in 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).
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 Wikipedia cards with RandomX
Mining uses the native CPU RandomX engine. First build the engine, download an encrypted wallet backup, then run the CLI from an interactive terminal and enter the password at its hidden prompt:
```sh
npm run miner:testnet -- --wallet "path/to/encrypted-backup.json" --consent --node http://127.0.0.1:3101 --blocks 3
```
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.
Light mode needs a 256 MiB cache plus scratchpad and process overhead. `--fast` is optional and needs over 2.3 GiB per miner process. Mining consumes CPU, power, and time; RandomX is not a guarantee of ASIC resistance. Do not expose an HTTP miner endpoint remotely; use HTTPS. Detailed resource, native build, and line-protocol notes are in [docs/randomx-native.md](docs/randomx-native.md).
## What the app shows
- **Discover** shows preview examples until the node has mined blocks; previews are not owned NFTs. Minted cards can reference the same page, but each carries its own captured stats snapshot.
- **My collection** shows NFTs owned by the active local wallet on the connected node's canonical chain.
- **Block explorer** distinguishes block IDs, NFT token IDs, and RandomX proof hashes. A submitted transfer is pending until a block confirms it. Reorganizations can change canonical ownership and confirmations.
- **Trade 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
npm run test:native
```
`npm test` runs the application test suite, including fast test-only proof tests. `npm run test:native` runs the real upstream RandomX known-vector test and requires a native build configured with tests enabled; the documented `npm run native:configure` command enables them. The Docker build also builds RandomX and runs its native test.
The Dockerfile also provides a **test** target containing Node 24, development dependencies, the tests, and the compiled Linux-native RandomX binary. Build that target and run the resulting image to exercise the full native HTTP suite without relying on a host-platform binary. TypeScript compilation enforces erasable syntax because the production runtime strips types directly.
To explicitly test propagation and transfer behavior across the three local Docker nodes, first build the host native engine and start the three-node testnet, then run:
```sh
node --experimental-strip-types scripts/testnet-smoke.ts --consent
```
This mines three real blocks using disposable identities. It changes the testnet, and is not run automatically.
## Network and safety limits
[Deployment and recovery preparation](docs/operations-recovery.md) includes explicit trusted-proxy configuration, opt-in loopback-only diagnostics and `ops:check`, plus stopped-node `node:backup` create/verify/new-directory restore tooling. New backups contain the ledger only; creation refuses to proceed if legacy financial journals or bindings remain, so archive those separately before retiring old deployments. Tools do not configure TLS/alerts or authorize production launch/restore.
[Stage-1 node resource controls](docs/node-resources.md) bound global RPC admission, response bytes and canonical page sizes. The UI reuses paged history; legacy full-state responses stop beyond 128 blocks. A consent-gated loopback-only smoke-load script is available as `test:rpc-load`. This does not resolve long-chain/DAG storage limits or establish production capacity.
Nodes use only explicitly configured peers; there is no discovery service or public bootstrap network. The peer, storage, and HTTP configuration is documented in [docs/testnet-consensus.md](docs/testnet-consensus.md). The implementation is experimental: it has no 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
The four original starter summaries in the interface are preview examples only; they are not mintable. Mined cards contain short snapshots from Wikipedia contributors, licensed under [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), and a Wikimedia-hosted picture. Open each card's **Image & license** link and review the file-specific license and attribution before reuse. A native NFT does not grant copyright or exclusive rights to article text or images.
-293
View File
@@ -1,293 +0,0 @@
import os
import sys
import time
import requests
from typing import Optional
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "wikideck")));
from Mine.Block import Block
from Mine.Transaction import Transaction
WIKIDECK_URL = os.getenv('WIKIDECK_URL', 'http://localhost:8080/');
rsa_private_key: Optional[rsa.RSAPrivateKey] = None;
msg_array: list[str] = [""];
private_key_file: str = "privatekey.pem";
public_key_file: str = "publickey.pem";
def main():
while True:
if (msg_array[0]):
print(f"Last Action: {msg_array[0]}");
print("\n--- Wikideck CLI ---");
print("1. Create RSA Keypair");
print("2. Save Private Key");
print("3. Load RSA Keypair");
print("4. Save Public Key")
print("5. Mine a Block");
print("6. Generate a Transaction");
print("7. View Deck");
print("0. Exit");
print("\n-------------------");
try:
choice: int = int(input("Select an option: ").strip());
except ValueError:
msg_array[0] = "Invalid Input. Please Choose a Number Between 0-6.";
clear_console();
continue;
if choice == 0:
break;
elif choice == 1:
opt_generate_keypair(65537, 2048);
elif choice == 2:
opt_save_private_key(rsa_private_key);
elif choice == 3:
opt_load_private_key();
elif choice == 4:
opt_save_public_key(rsa_private_key);
elif choice == 5:
opt_mine_block();
elif choice == 6:
opt_generate_transaction();
elif choice == 7:
opt_view_deck();
else:
msg_array[0] = "Invalid Input. Please Choose a Number Between 0-6.";
clear_console();
def opt_generate_keypair(exponent=65537, size=2048):
global rsa_private_key;
rsa_private_key = rsa.generate_private_key(
public_exponent = exponent,
key_size = size,
)
msg_array[0] = "RSA Keypair Generated";
return rsa_private_key;
def opt_save_private_key(rsa, filename=private_key_file):
if rsa is None:
msg_array[0] = "No RSA Keypair to extract private key. Please generate one first.";
return None;
else:
with open(filename, "wb") as file:
pem = rsa.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
)
file.write(pem);
msg_array[0] = f"RSA Private Key Saved to {filename}";
def opt_save_public_key(rsa, filename=public_key_file):
if rsa is None:
msg_array[0] = "No RSA Keypair to extract public key. Please generate one first.";
return None;
public_key = rsa.public_key();
public_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
with open(filename, "wb") as file:
file.write(public_pem);
msg_array[0] = f"RSA Public Key Saved to {filename}";
def opt_load_private_key():
global rsa_private_key;
if not os.path.exists(private_key_file):
msg_array[0] = f"No saved key found ({private_key_file} missing)";
return None;
with open(private_key_file, "rb") as file:
rsa_private_key = serialization.load_pem_private_key(
file.read(),
password=None,
)
msg_array[0] = f"RSA Private Key Loaded From {private_key_file}";
def get_private_key_from_file(path):
if not os.path.exists(path):
msg_array[0] = f"No key file found ({path} missing)";
return None;
with open(path, "rb") as file:
key = serialization.load_pem_private_key(
file.read(),
password=None,
);
msg_array[0] = f"RSA Private Key Gotten From {path}"; #inaccessible
return key;
def opt_mine_block(path = None):
global rsa_private_key;
if (path):
rsa_private_key = get_private_key_from_file(path);
if not (rsa_private_key):
return None;
if rsa_private_key is None:
msg_array[0] = "You must load or generate an RSA keypair first.";
return;
try:
response = requests.get(f"{WIKIDECK_URL}/blocks");
if response.status_code != 200:
msg_array[0] = f"Failed to fetch block to mine: {response.status_code}";
return;
block_to_mine = Block(data = response.json());
block_to_mine.mine(rsa_private_key);
response = requests.post(f"{WIKIDECK_URL}/blocks", json=block_to_mine.as_dict());
if response.status_code == 200:
msg_array[0] = "Block successfully mined and submitted";
else:
msg_array[0] = f"Failed to submit mined block: {response.status_code}";
except Exception as e:
msg_array[0] = f"Error during mining: {str(e)}";
def opt_generate_transaction(path = None, recv = None):
global rsa_private_key;
if (path):
rsa_private_key = get_private_key_from_file(path);
if not (rsa_private_key):
return None;
if rsa_private_key is None:
msg_array[0] = "You must load or generate an RSA keypair first.";
return;
if not (recv):
print("\nPaste the recipient's RSA Public Key (PEM format). End with an Empty Line (Press enter twice): ");
lines = [];
while True:
line = input();
if not line.strip():
break;
lines.append(line);
receiver_pem = "\n".join(lines);
try:
receiver_key = serialization.load_pem_public_key(receiver_pem.encode("utf-8"));
except Exception:
msg_array[0] = "Invalid public key format";
return;
else:
if not os.path.isfile(recv):
msg_array[0] = f"Reciever public key file not found {recv}";
return None;
with open(recv, "rb") as recv_file:
receiver_key = serialization.load_pem_public_key(recv_file.read());
try:
cards = fetch_cards_json(rsa_private_key);
if not cards:
msg_array[0] = "You have no cards to send";
return;
print("\nYour cards:");
for i, card in enumerate(cards):
print(f"{i+1}. Card ID: {card['cardId']}, Page ID: {card['pageId']}");
try:
index: int = int(input("Select a card number to send: ").strip()) - 1;
if not (0 <= index < len(cards)):
raise ValueError;
except ValueError:
msg_array[0] = "Invalid card selection";
return;
selected_card = cards[index];
tx = Transaction(
cardId = selected_card['cardId'],
sender = rsa_private_key.public_key(),
receiver=receiver_key,
authorPrivateKey=rsa_private_key
);
response = requests.post(f"{WIKIDECK_URL}/transactions", json=tx.as_dict());
if response.status_code != 200:
msg_array[0] = f"Transaction failed: {response.status_code}";
else:
msg_array[0] = "Transaction submitted successfully";
except Exception as e:
msg_array[0] = f"Error creating transaction: {str(e)}";
def opt_view_deck():
global rsa_private_key;
if rsa_private_key is None:
msg_array[0] = "You must load or generate an RSA keypair first.";
return;
try:
cards = fetch_cards_json(rsa_private_key);
if len(cards) <= 0:
msg_array[0] = "You have no cards to view";
return;
for i, card in enumerate(cards):
print(f"{i+1}. Card ID: {card['cardId']}, Page ID: {card['pageId']}");
except Exception as e:
msg_array[0] = f"Error fetching cards: {str(e)}";
def fetch_cards_json(rsa):
pem = rsa.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode("utf-8");
response = requests.get(f"{WIKIDECK_URL}/cards", params={"publicKey": pem});
if response.status_code != 200:
msg_array[0] = f"Failed to fetch cards: {response.status_code}";
return;
cards = response.json();
return cards;
def clear_console():
os.system('cls' if os.name == 'nt' else 'clear');
if __name__ == "__main__":
main();
+39
View File
@@ -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:
+74
View File
@@ -0,0 +1,74 @@
# 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
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"
volumes: ["testnet-a:/app/data"]
init: true
read_only: true
tmpfs: ["/tmp:size=32m,mode=1777,noexec,nosuid"]
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
pids_limit: 128
mem_limit: 1g
cpus: 1.0
stop_grace_period: 30s
node-b:
image: wikideck:testnet
build:
context: .
target: runtime
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"
volumes: ["testnet-b:/app/data"]
init: true
read_only: true
tmpfs: ["/tmp:size=32m,mode=1777,noexec,nosuid"]
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
pids_limit: 128
mem_limit: 1g
cpus: 1.0
stop_grace_period: 30s
node-c:
image: wikideck:testnet
build:
context: .
target: runtime
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"
volumes: ["testnet-c:/app/data"]
init: true
read_only: true
tmpfs: ["/tmp:size=32m,mode=1777,noexec,nosuid"]
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
pids_limit: 128
mem_limit: 1g
cpus: 1.0
stop_grace_period: 30s
volumes:
testnet-a:
testnet-b:
testnet-c:
+38
View File
@@ -0,0 +1,38 @@
services:
wikideck:
build:
context: .
target: runtime
args:
WIKIDECK_NETWORK: mainnet
WIKIDECK_GENESIS_TIME: "${WIKIDECK_GENESIS_TIME:-1793491200}"
ports:
# Loopback only; terminate reviewed HTTPS at the operator's edge.
- "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"
WIKIDECK_TRUSTED_PROXIES: "${WIKIDECK_TRUSTED_PROXIES:-}"
volumes:
- wikideck-mainnet-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:
wikideck-mainnet-data:
+45
View File
@@ -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.
+41
View File
@@ -0,0 +1,41 @@
# Example for nginx on the SAME host as loopback-bound node :3201.
# Not installed by this repository. Replace host/cert paths and validate real topology.
# Node: WIKIDECK_TRUSTED_PROXIES=127.0.0.1,::1 (never use this for a container bridge without adjustment).
limit_req_zone $binary_remote_addr zone=wikideck_ip:10m rate=5r/s;
limit_conn_zone $binary_remote_addr zone=wikideck_conn:10m;
server {
listen 80;
server_name wikideck.example.invalid;
return 308 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name wikideck.example.invalid;
ssl_certificate /etc/ssl/wikideck/fullchain.pem;
ssl_certificate_key /etc/ssl/wikideck/private.key;
ssl_protocols TLSv1.2 TLSv1.3;
server_tokens off;
client_max_body_size 68k;
client_body_timeout 15s;
client_header_timeout 10s;
keepalive_timeout 10s;
send_timeout 30s;
limit_conn wikideck_conn 16;
limit_req_status 429;
# Do not proxy private operations listener. Its port must never be publicly published.
location / {
limit_req zone=wikideck_ip burst=20 nodelay;
proxy_pass http://127.0.0.1:3201;
proxy_http_version 1.1;
proxy_set_header Host $host;
# Overwrite, do NOT append an untrusted client-supplied X-Forwarded-For chain.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Forwarded "";
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_send_timeout 15s;
proxy_read_timeout 30s;
}
}
-29
View File
@@ -1,29 +0,0 @@
version: '3.8'
services:
wikideck-mine:
image: ericomeehan/wikideck:latest
build:
context: .
dockerfile: Dockerfile
environment:
ROLE: mine
DATA_PATH: /tmp
DIFFICULTY_REQUIREMENT: 10
MINE_DB_HOST: mariadb-mine
MINE_DB_USER: mine
MINE_DB_PASSWORD: 123abc
depends_on:
- mariadb-mine
ports:
- "8080:80"
mariadb-mine:
image: mariadb:latest
environment:
MARIADB_ROOT_PASSWORD: abc123
MARIADB_DATABASE: mine
MARIADB_USER: mine
MARIADB_PASSWORD: 123abc
ports:
- "3306:3306"
+17
View File
@@ -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).
+113
View File
@@ -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.
+62
View File
@@ -0,0 +1,62 @@
# Consensus calibration and adversarial analysis — stage 3
**Decision remains NO-GO.** This stage supplies reproducible offline tools and bounded measurements, not new launch parameters 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.
+57
View File
@@ -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.
+45
View File
@@ -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=<hash>&offset=0&limit=16`: up to **16 canonical blocks**, pinned to the current tip. Tip changes return 409 so the caller restarts. Offset cannot exceed current height. Each page uses the cached canonical branch and clones only its slice. The first page after a new tip may rebuild that branch in O(height).
- Legacy `/api/state` and `/api/chain` still return full snapshots only up to the configured legacy height and response-byte cap. Beyond it, they return 413 with paging instructions **before full snapshot cloning**. They never silently truncate ownership history. Legacy scripts/API clients must migrate to paging for longer chains.
- `/p2p/status` and `/p2p/mempool` now obtain only the fields needed instead of cloning the chain. P2P block inventory semantics are unchanged.
The browser and trade signing refresh use bounded streaming response reads (2 MiB/page), pagination/network/tip/parent continuity checks, one restart on tip conflict, shared in-flight loads, and append-history cache reuse. A final summary confirms the loaded tip. These checks are **not independent PoW/consensus verification**: the UI still trusts the node.
The existing snapshot-based UI still assembles complete ownership history, capped at **10,000 blocks or 64 MiB of encoded history** with a 30-second fetch deadline. If exceeded, it fails explicitly rather than presenting partial ownership as complete. That is a client resource guard, not a solution for an archival-sized wallet/explorer. Full state objects and parsed JS memory cost more than encoded JSON. A truly indexed wallet and fully paged explorer remain required before closing SEC-01. An unchanged refresh requests two summaries and no block pages; append loading verifies the cached prefix and requests only new blocks.
## 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.
+42
View File
@@ -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 <private-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.
+60
View File
@@ -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.
+192
View File
@@ -0,0 +1,192 @@
# Native RandomX engine
Wikideck's native CPU proof engine uses RandomX, the algorithm also used by Monero,
designed to keep general-purpose consumer CPUs competitive. Mining a Wikideck block
rewards its miner with a Wikipedia NFT card on Wikideck's independent chain. The Docker image builds this pinned
implementation and runs its known-vector test. There is no GPU path, simulated hash,
or production fallback hash.
This document covers building the executable, its memory modes, its line protocol,
and the TypeScript adapter. For the user-facing install and run instructions, start
with the [README](../README.md).
## Contents
- [Source and build](#source-and-build)
- [Modes and resource use](#modes-and-resource-use)
- [Line protocol](#line-protocol)
- [Node API](#node-api)
- [Real upstream known-vector check](#real-upstream-known-vector-check)
## Source and build
[RandomX v1.2.1](https://github.com/tevador/RandomX/tree/102f8acf90a7649ada410de5499a7ec62e49e1da)
is fetched by CMake FetchContent from the official HTTPS Git repository, pinned to
**102f8acf90a7649ada410de5499a7ec62e49e1da** (also the upstream v1.2.1 tag).
The first configuration requires network access and Git; later builds reuse the
checkout. Keep upstream copyright/license notices when distributing binaries.
Do not override FetchContent sources with untrusted local code.
Requirements: CMake 3.24+, Git, a C/C++17 toolchain, and a 64-bit host for fast mode.
MSVC needs its C++ and MASM tools in a developer terminal. MinGW needs its compiler
and make on PATH; select the `MinGW Makefiles` generator. Linux/macOS need their
normal C/C++ build tools. No administrator privileges or huge pages are required.
On MSVC, upstream's pinned build invokes its own PowerShell assembly-generation
script; review upstream build tooling before running under restrictive policies.
From the repository root:
```sh
cmake -S native -B native/build -DCMAKE_BUILD_TYPE=Release -DWIKIDECK_RANDOMX_TESTS=OFF
cmake --build native/build --config Release --target wikideck-randomx --parallel 2
```
Convenience scripts (two compiler jobs by default):
```sh
sh scripts/build-randomx.sh
sh scripts/build-randomx.sh --test
```
```powershell
./scripts/build-randomx.ps1
./scripts/build-randomx.ps1 -Generator 'MinGW Makefiles' -Test
```
The scripts propagate configure/build/test failures. PowerShell execution policy
must permit the script; do not bypass a managed policy. POSIX customization:
`JOBS=2`, `RANDOMX_BUILD_DIR=/absolute/build/path`; PowerShell customization:
`-Jobs 2`, `-BuildDirectory <path>`. Select a toolchain/generator through the normal
CMake environment or a manual configuration. Don't switch generators in an
existing build directory.
All generators place the executable directly under `native/build` (with `.exe` on
Windows), including Release builds with multi-config generators.
Upstream tests/benchmark/codegen are excluded from the default build: v1.2.1 does
not provide a tests-disable option. No benchmark or dataset-heavy fast-mode test
runs automatically.
## Modes and resource use
Run `wikideck-randomx` with no arguments for **light** mode (256 MiB cache plus
approximately 2 MiB VM scratchpad and runtime overhead). `--fast` adds the real
approximately 2080 MiB dataset; the 256 MiB cache is retained for key changes, so
budget over 2.3 GiB per process. Dataset initialization is single-threaded and can
be expensive. Neither mode allocates RandomX memory until its first valid request.
Cache, dataset, and VM are reused for the same key. A changed key reinitializes the
cache and, in fast mode, the entire dataset before creating a fresh VM.
Flags start with `randomx_get_flags()` (CPU-supported AES/JIT/Argon2 optimization).
`RANDOMX_FLAG_SECURE` is explicitly added for VM JIT W^X, and upstream is compiled
with `RANDOMX_FORCE_SECURE` for cache JIT policy too. Platforms without JIT use the
reference interpreter; allocation/JIT failures are fatal, never a fake PoW result.
No large pages are requested. RAII releases VM, dataset, then cache on normal EOF
and caught errors. Public PoW key/cache data is not secret and is released, not
claimed to be securely erased from physical memory.
## Line protocol
One ASCII command per LF-terminated line; CRLF is also accepted. Exactly one ASCII
response line, flushed immediately, per valid command:
```text
hash <key64hex> <blobhex>
<hash64hex>
mine <key64hex> <blobhex> <target64hex> <nonceOffsetDecimal> <maxAttemptsDecimal>
found <hash64hex> <nonceDecimal>
exhausted
```
`found` and `exhausted` are alternatives, not two responses. Input hex may use
either case; output hashes are lowercase in the RandomX byte order. Keys and
targets are exactly 32 bytes (64 hex characters); blobs are 1..8192 bytes.
Tokens use exactly one ASCII space, without leading/trailing whitespace. Commands
are capped at 16600 bytes before LF. Invalid characters, malformed/overflowing
unsigned decimal, extra tokens, invalid bounds, or a partial final line cause a
concise stderr error and nonzero exit, with no fabricated response on stdout.
Empty stdin EOF exits successfully. There are no stdout banners or diagnostic logs.
Mining requires `maxAttempts` in 1..10000 and `nonceOffset + 8 <= blob.size`.
The initial nonce is read from the blob's existing **eight little-endian bytes**.
The first attempt uses that nonce, then increments by one and rewrites LE8 for
each attempt. Only those eight bytes change. At uint64 maximum, search ends
without wrapping. The native response contains an exact unsigned decimal nonce.
**Target convention: interpret the 32 output bytes and target bytes as unsigned
BIG-ENDIAN integers; accept `hash <= target`. This intentionally differs from
Monero's convention.** In particular, equal hashes pass. Never reverse the hash
or target when using this interface.
## Node API
[RandomX](../server/testnet/randomx.ts) exports:
```ts
interface PowEngine {
hash(key: string, blob: Uint8Array): Promise<string>;
close(): void;
}
class RandomX implements PowEngine {
constructor(options?: { binary?: string; fast?: boolean });
hash(key: string, blob: Uint8Array): Promise<string>;
mine(key: string, blob: Uint8Array, target: string,
nonceOffset: number, maxAttempts: number):
Promise<{ hash: string; nonce: number } | null>;
close(): void;
}
```
Binary precedence: `options.binary`, `RANDOMX_BINARY`, then the executable under
the repository's native build directory (resolved relative to this module,
independent of working directory). Relative explicit paths are resolved against
the caller's working directory. Configure only a trusted executable: this is a
native-code trust boundary, not a sandbox. The adapter uses `spawn` with a fixed
argument array, `shell: false`, and pipe stdio; user data only enters stdin after
strict validation. Missing/non-executable files throw in the constructor. Other
spawn failures are retained and reject current/future requests.
Each instance owns one long-lived child, one active command, and at most 64 total
active/queued requests. Calls are FIFO, including mixed hash/mine calls and key
changes. Inputs are snapshotted before queuing. The 120-second deadline starts
when each command is sent (includes cold cache/dataset initialization); queued
requests wait for earlier work. Keep mining batches small enough to finish on the
host CPU. The process is killed on timeout, malformed/unsolicited/oversized output,
stream error, or crash, rejecting all outstanding work and subsequent calls.
Responses and retained stderr are bounded. No automatic retry or process restart.
`mine` returns `null` for exhaustion and validates returned hash syntax, target,
and nonce range (it does not independently recompute a native result). Because the
API returns `number`, its entire requested nonce range must fit
`Number.MAX_SAFE_INTEGER`; higher uint64 nonces are supported only by the direct
native protocol. Invalid inputs reject without sending native work.
Always call `close()` in `finally`. It is idempotent, rejects pending/queued work,
and sends EOF for graceful RAII cleanup; a busy child is forcibly killed after
one second. A failed/closed instance cannot be reopened.
## Real upstream known-vector check
The opt-in `--self-test` executable argument validates upstream's exact
[Hash test 1a/2a](https://github.com/tevador/RandomX/blob/102f8acf90a7649ada410de5499a7ec62e49e1da/src/tests/tests.cpp):
- Key: ASCII `test key 000` (12 bytes, **not NUL-terminated or zero-padded**).
- Input: ASCII `This is a test` (14 bytes, no terminator).
- Expected RandomX hash:
`639183aae1bf4c9a35884cb46b09cad9175f04efd7684e7262a0ac1c2f0b4e3f`.
The self-test uses an internal API so the line protocol retains its strict
32-byte-key requirement. Padding this upstream key to 32 bytes would change the
hash and is not a valid reproduction of the vector. Self-test success is reported
on stderr only, with exit zero; mismatch exits nonzero. `--fast --self-test` checks
the same real vector in dataset mode, but requires the full memory allocation.
```sh
cmake -S native -B native/build -DCMAKE_BUILD_TYPE=Release -DWIKIDECK_RANDOMX_TESTS=ON
cmake --build native/build --config Release --target wikideck-randomx --parallel 2
ctest --test-dir native/build -C Release --output-on-failure
```
This test is OFF by default and is separate from the application's tests. Without
a built native binary, no native engine or native test is run by the application.
+31
View File
@@ -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.
+96
View File
@@ -0,0 +1,96 @@
# Experimental testnet consensus
Implementation reference for the current tree, **not a public-mainnet specification or security certification**. See [README](../README.md) for setup/wallet commands and [native-engine details](randomx-native.md) for RandomX tooling. Consensus code: [shared serialization](../shared/testnet.ts), [validation](../server/testnet/consensus.ts), [DAG/persistence](../server/testnet/ledger.ts), [peer transport](../server/testnet/peers.ts), [HTTP](../server/testnet/http.ts), [miner](../server/testnet/miner.ts).
This document is for operators and developers who need the exact current protocol
rules. It is not a quick-start guide: consensus serialization and constants are
implementation-specific, and changing them can create a different chain.
## Contents
- [Network, genesis and article selection](#network-genesis-and-article-selection)
- [Header, work and three distinct identities](#header-work-and-three-distinct-identities)
- [Targets, timestamps and epoch boundaries](#targets-timestamps-and-epoch-boundaries)
- [Native NFT transition and commitments](#native-nft-transition-and-commitments)
- [Fork choice, reorgs and storage](#fork-choice-reorgs-and-storage)
- [Fixed peers and HTTP surface](#fixed-peers-and-http-surface)
## Network, genesis and article selection
- Network: `wikideck-randomx-testnet-6`, block version **6**. Genesis has no blocks, NFTs or work; first mined height is 1. No privileged issuance/admin mint key. Version 6 adds atomic NFT trade bundles and has a new chain identity; prior-version ledger files and wallet backups are not compatible.
- `GENESIS_TIME = 1792411200` (Unix seconds). Let `S(x)` mean lowercase hex SHA-256 of UTF-8 `x`, and `J(x)` mean compact JavaScript `JSON.stringify(x)`; field order below is consensus-critical.
- `MAX_TARGET` is `0fff` followed by 60 `f` digits. `CHAIN_ID = S(J([NETWORK, CONSENSUS, GENESIS_TIME, MAX_TARGET]))`, using the constants object's insertion order in the implementation. `CONSENSUS` identifies the random Wikipedia source and mint-statistics snapshot format.
- `GENESIS_HASH = S(NETWORK + ':genesis:' + CHAIN_ID)`; `INITIAL_KEY = S(NETWORK + ':initial-key:' + CHAIN_ID)`. Nodes independently derive the same manifest and reject other network/chain IDs.
- [Starter samples](../shared/catalog.ts) remain only as interface previews; they are not eligible for minting. Each mining-template request asks the fixed English Wikipedia API endpoint for up to ten random namespace-0 pages, with a plain-text extract and thumbnail. It makes at most three requests and returns a page with a usable extract and an HTTPS image on `upload.wikimedia.org` or `thumb.wikimedia.org`. A page may be minted repeatedly. There is no static fallback: if no pictured article or its statistics are available, template creation fails and the miner can retry.
- The selected `Article` snapshot and `statsSnapshot` are copied into the block header and covered by the miner's block signature and block ID. `articleTuple = [pageId, revisionId, title, extract, image-or-empty-string, url, source, category]`. The at-mint statistics contain capture time, metadata revision, page bytes, language-link count/truncation, six-month pageviews when available, and the derived Attention/Depth/Reach scores. Missing pageviews are represented as unavailable, never invented zeroes. The node forces a fresh enrichment request for this snapshot; it is captured when the mining template is prepared, since changing it after work begins would invalidate the proof. Current UI statistics are fetched separately and may reflect later edits.
- A repeated page still creates a distinct NFT: token identity includes the parent block, height, and owner. Validators check article/image and stats schemas, the page URL, Wikimedia HTTPS host, pageview summaries, derived scores, and block commitments, but do not constrain repeated page IDs.
- Validators do **not** contact Wikipedia during consensus validation. They verify the committed snapshot's structure and internal metric summaries, not the API provenance or ongoing existence of the page/image. A miner can construct a syntactically valid snapshot itself; this experimental design avoids making block acceptance dependent on an external service being online. The node's template endpoint fetches the article and fresh statistics for ordinary mining clients.
## Header, work and three distinct identities
`headerMessage(h)` is `J([NETWORK, version, chainId, height, previousHash, createdAt, articleTuple(article), statsSnapshotTuple, owner, publicKey, target, key, transactionsRoot, stateRoot])`. `statsSnapshotTuple` commits capture time, revision, size/language counts, ordered monthly views and summary values, plus the three derived scores. It excludes the nonce. Hash its UTF-8 bytes with SHA-256 to obtain **32 raw bytes**, then append the nonce encoded as **8 unsigned little-endian bytes**. The resulting **40-byte mining blob** has nonce offset **32** (not a 32-bit nonce). Node's numeric interface restricts nonces to nonnegative safe integers; the native line protocol supports uint64.
| Identifier | Exact construction / meaning |
| --- | --- |
| Block ID (`hash`) | SHA-256 of the raw mining blob, lowercase hex. Does not include ECDSA signature bytes or the separate `powHash` field. |
| RandomX proof (`powHash`) | Native RandomX of the mining blob using the 32-byte epoch key. Determines work eligibility and game rarity/stats, not the NFT ID. |
| NFT ID (`tokenId`) | `S(J([NETWORK, CHAIN_ID, 'mint', previousHash, height, owner, article.pageId, statsSnapshotTuple]))`. The capture time and page statistics help distinguish repeated mints of the same page. Independent of nonce/proof/signature, avoiding a circular dependency through state commitment. |
Interpret RandomX output and target as **unsigned big-endian** integers; accept `powHash <= target` including equality. This differs from Monero's target convention. The initial/easiest threshold accepts exactly **1/16** of uniformly distributed hashes (expected 16 attempts). No fake or browser PoW is accepted by the production node.
The miner signs `J([NETWORK, 'block-claim', blockID])` using ECDSA P-256/SHA-256, IEEE-P1363 encoding. Address is SHA-256 of canonical SPKI DER public-key bytes. Nodes check public-key/address consistency, signature, body schema, exact parent/height, article, target, key, roots, mint ID, block ID and recomputed native proof. Signatures remain required even though their bytes are excluded from block identity.
## Targets, timestamps and epoch boundaries
- Desired spacing: **15 minutes (900 seconds)**; retarget interval: **10 ancestors**. Heights 1–10 use the initial target; height 11 first retargets. Otherwise inherit the parent target.
- At each retarget, compare the last and first timestamps of the previous 10 blocks: expected span is **9 × 900 = 8,100 seconds**. Clamp elapsed to `floor(8,100/4) = 2,025` through `8,100*4 = 32,400` seconds, multiply the parent target by elapsed/8,100 with integer division, then clamp target to `[1, MAX_TARGET]`. Selection uses work, not height or wall-clock estimates.
- A timestamp must exceed the median of up to **11** ancestors (sorted upper-middle for even counts; genesis time if none) and must be at most **120 seconds** ahead of validation time. Keep operator clocks synchronized.
- Epoch key source height is `floor(max(0, height - 64) / 2048) * 2048`. Zero means `INITIAL_KEY`; otherwise use the ancestor block hash at that source height. Thus heights **1–2111** use the initial key, **2112–4159** use height 2048's hash, and height **4160** switches to height 4096's hash. Forks derive keys from their own ancestors.
## Native NFT transition and commitments
Each block executes its ordered transactions against its parent's NFT state, **then mints one NFT** to the block owner. State entries contain owner, sequence (initially 0), minted height and page ID. Transactions cannot spend this block's new issuance.
An individual transfer carries network, chain ID, `cardHash` (**NFT token ID**, despite the legacy field name), from, to, sequence, public key and signature. Its signed message is `J([NETWORK, 'nft-transfer', chainId, cardHash, from, to, sequence])`. **Transfer ID is SHA-256 of that message, excluding ECDSA signature bytes** to avoid signature-malleability identity changes. Require the current owner, matching public key, a different recipient, exact current sequence, valid signature, and inclusion height minus mint height **>= 2**; increment sequence on application. Replays and duplicate IDs within a block fail.
A trade is one `type: 'trade'` transaction with proposer and counterparty addresses, ordered offered/requested NFT legs (token ID plus current sequence), and one P-256 signature from each party over `J([NETWORK, 'nft-trade', chainId, proposer, counterparty, offeredLegs, requestedLegs])`, where each leg is `[cardHash, sequence]`. Both public keys must derive the respective party addresses. Each side must contain 1–8 distinct NFTs; duplicates across either side are rejected. At inclusion, validation checks **all** selected NFTs against the same pre-trade state, owners, sequences, maturity, and both signatures before writing any changes. If any leg is invalid, the entire trade is rejected; if valid, all offered ownerships move to the counterparty and all requested ownerships to the proposer, with each sequence incremented once. A trade counts as one mempool and block transaction and is committed by one transaction ID. It cannot be partially included or partially replayed. Offers are not reservations; stale offers fail validation.
`transactionsRoot` commits the **ordered transaction IDs** (individual transfer IDs or atomic trade IDs). `stateRoot` commits the complete **post-transaction, post-mint** state: sort token IDs lexicographically; each input leaf is `S(J([id, owner, sequence, mintedHeight, pageId]))`. `merkleRoot` returns `S(NETWORK + ':empty')` for no leaves. For nonempty input, hash each raw 32-byte input leaf with byte prefix `0x00`; hash pairs of raw children with byte prefix `0x01`, duplicating an odd final child at each layer. Finally return `S(J([NETWORK, 'merkle', originalLeafCount, treeRootHex]))`. Count commitment prevents odd-padding ambiguity; leaf/node prefixes separate domains. These commitment semantics, the RandomX version, and big-endian proof convention are pinned in the genesis consensus manifest.
The mempool holds at most **256** ordered transactions (transfers or trade bundles), validating sequentially against the next inclusion height; a template takes the first **64 transaction units**. A 202 response changes **no canonical ownership**. Mint at height `h` is eligible for inclusion at `h+2` (pool admission can happen at tip `h+1`). Confirmations are `tipHeight - mintedHeight + 1`, not a finality guarantee.
## Fork choice, reorgs and storage
Per-block work is `floor(2^256 / (target + 1))`; accumulate from genesis work zero. Keep all valid known-parent branches. Adopt a tip only for **strictly greater cumulative work**; equal-work ties retain the current tip, including across replay of persisted insertion order. A shorter, harder branch can win.
On reorg, reconstruct ownership from the winning branch. Orphan mints disappear from canonical ownership; disconnected transactions are reconsidered before existing pending entries, in order, only if still valid and not already committed. A trade is requeued as one unit only when every leg remains valid; otherwise the complete bundle is discarded. No checkpoints/finality or rollback protection beyond observed work; an isolated node has no peer-backed security.
The [ledger](../server/testnet/ledger.ts) is stored in `data/testnet/ledger.sqlite`, under the `WIKIDECK_DATA_DIR` testnet subdirectory (Docker uses `/app/data`). SQLite WAL mode with full synchronous commits stores each accepted block once in an indexed table and replaces the bounded mempool in the same transaction; writes no longer serialize/rewrite the entire DAG, and there is no 128 MiB/10,000-block ledger cap. A matching v6 legacy `ledger.json` is schema-checked and imported transactionally once; the source JSON is left untouched as a recovery copy. Earlier-protocol ledgers are not compatible and fail closed. Back up the complete stopped-node data directory, including SQLite database/WAL files. Do not copy only `ledger.sqlite` while the node is running.
The in-memory DAG keeps block records and cumulative work, while each block's NFT state is a persistent overlay containing only that block's changes rather than a full state copy. State-root calculation still visits the complete NFT state, and startup still revalidates every stored proof; CPU and startup work therefore grow with chain size. This is materially more storage-efficient, but not yet an archival-scale chain database or a public DoS defense. Blocks remain individually limited to **64 KiB and 64 transaction units**; the mempool remains capped at **256 transaction units**. Mutations are serialized (queue cap 256). HTTP startup exclusively creates a data-directory lock named `testnet-node.lock`. It is removed on normal shutdown, **not automatically after a crash**: stop and verify all owning processes have exited before manual stale-lock removal. Never bypass a live lock or share a writer directory. Back up node data and wallet exports separately; deleting a volume cannot be recovered from a wallet alone.
## Fixed peers and HTTP surface
Peers are **explicit operator-configured URL origins**, up to 16, not discovered from network input. Default: HTTPS/public addresses only, checked and pinned during DNS lookup; no credentials, paths, queries, fragments or redirects. `WIKIDECK_ALLOW_PRIVATE_PEERS=1` permits private/HTTP destinations only by explicit operator choice (used for local Docker tests). No bootnodes, DHT, automatic public deployment or claimed Sybil resistance.
Sync polls every **5 seconds**, checks network/chain ID, imports at most **32** topologically ordered DAG blocks per peer/pass, and validates remote pool entries locally. Accepted blocks and transactions (including complete trade bundles) are gossiped to configured peers; busy-peer gossip is dropped, with polling for catch-up. Responses are capped at 2 MiB with a 10-second deadline. Peer count is current outbound connectivity metadata, not consensus membership or a security score.
| Route | Purpose |
| --- | --- |
| `GET /healthz` | Process health/network, not sync or consensus security. |
| `GET /api/state`, `/api/chain` | Canonical blocks, mined/pending transactions, tip/work and peer count. |
| `GET /api/summary`, `/api/canonical?tip=<hash>&offset=0&limit=16` | Bounded state metadata and current-tip-pinned canonical pages. Full legacy snapshots are limited to 128 blocks and the response-byte budget. See [resource controls](node-resources.md). |
| `GET /api/network` | Selected profile, pinned chain/genesis, 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 single-card transfer admission; 202 means pending. |
| `POST /api/trades` | Validates and admits one signed, atomic multi-card trade bundle; 202 means the whole bundle is pending. |
| `GET /p2p/status`, `/p2p/blocks?offset=0`, `/p2p/mempool` | Manifest/size/tip, 32-block inventory pages, pending transactions. |
| `POST /p2p/block`, `/p2p/transaction` | Same validation as local block/transaction submission; transaction endpoint accepts a transfer or atomic trade. |
POSTs require JSON, enforce supplied browser origins and signatures where applicable; peers without an Origin header are not authenticated by this check. HTTP parsing is capped at 68 KiB; API/P2P rate limits are 300/minute and block/transaction/trade submissions additionally 30/minute. 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.
+45
View File
@@ -0,0 +1,45 @@
# Encrypted local wallet — stage 2
## What changes
The browser no longer creates or returns a plaintext wallet at startup. Open **Wallet backup & recovery** and choose:
- **Create encrypted wallet:** explicitly generate a new P-256 identity and choose a local password of 12–1024 characters.
- **Encrypt existing wallet:** migrate the previous same-origin plaintext record, preserving its exact public key/address. This is required before the old key can sign in the new UI.
- **Unlock wallet:** decrypt an existing local vault in this tab with its local password.
- **Recover and save encrypted wallet:** decrypt a portable backup with its backup password and re-encrypt it under a new local password. Existing storage, even when locked, requires explicit replacement confirmation. Back up that identity first; replacement does not transfer its assets.
The local password and backup password may differ. They have no reset endpoint, are not sent to a server, and are not stored in local/session storage. Forgotten local passwords require a working portable backup and its password. Without either a usable local vault/password or backup/password, access is lost.
## Storage and migration guarantees
Vault records use the existing reviewed backup encryption primitive: PBKDF2-SHA256 (600,000 iterations), random 16-byte salt, AES-256-GCM with random 12-byte IV and authenticated format/network/public-key headers. The local wrapper stores a format/version and random revision plus the encrypted backup. It stores **no plaintext private CryptoKey or password** for newly created/migrated vaults. Public-key metadata remains visible; encryption does not hide wallet identity or on-chain holdings.
The existing testnet/mainnet IndexedDB names remain separate and unchanged to find legacy records safely. Migration reads the legacy key only after the user opts in, encrypts and decrypts to verify identity, then replaces the record in a committed IndexedDB readwrite transaction. No automatic deletion, new identity or network migration occurs. Storage failure leaves the original record intact. The old CryptoKey is never removed before encrypted replacement commits.
Cross-tab readwrite compare-and-swap checks the expected stored revision/record in the same transaction; optional Web Locks additionally serialize storage. A competing create/migration/restore must fail rather than overwrite a newly stored identity. This is application consistency, not protection against a malicious script deliberately editing IndexedDB. Old application versions/tabs must be closed before migration; cached old bundles are not guaranteed to understand a vault record.
Replacing a record does **not** securely erase previous browser storage pages, OS backups, disk snapshots, old downloaded files or keys previously copied by other scripts. If the old identity may have been exposed, encryption is not revocation: a separately planned transfer to a fresh identity is required. This work never transfers assets or silently rotates keys.
## Session and locking
UI components receive a public `Wallet` handle only; private keys remain inside the wallet module. All NFT transfer 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.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" data-theme="mocha">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1e1e2e" />
<meta name="description" content="Experimental, gated Wikideck mainnet candidate: mine, collect, and trade illustrated Wikipedia cards with RandomX. No public bootstrap network or promised monetary value." />
<title>Wikideck — Experimental mainnet candidate.</title>
<link rel="icon" type="image/svg+xml" href="/original-logo.svg" />
<script src="/theme-init.js"></script>
</head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>
+48
View File
@@ -0,0 +1,48 @@
cmake_minimum_required(VERSION 3.24)
project(wikideck_randomx LANGUAGES C CXX)
include(FetchContent)
# Immutable v1.2.1 pin; never follow a branch or a moving tag.
FetchContent_Declare(randomx
GIT_REPOSITORY https://github.com/tevador/RandomX.git
GIT_TAG 102f8acf90a7649ada410de5499a7ec62e49e1da
GIT_SHALLOW FALSE
GIT_SUBMODULES ""
EXCLUDE_FROM_ALL
)
# Upstream still declares CMake 3.5 compatibility; support CMake 4 as well.
set(CMAKE_POLICY_VERSION_MINIMUM 3.5)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "Build static dependencies" FORCE)
FetchContent_MakeAvailable(randomx)
# v1.2.1 has no upstream BUILD_TESTING switch. Exclude its tools explicitly.
set_property(DIRECTORY "${randomx_SOURCE_DIR}" PROPERTY EXCLUDE_FROM_ALL TRUE)
foreach(tool randomx-tests randomx-codegen randomx-benchmark)
set_target_properties(${tool} PROPERTIES EXCLUDE_FROM_ALL TRUE)
endforeach()
# Enforce W^X for the upstream cache JIT as well as our VM JIT.
target_compile_definitions(randomx PRIVATE RANDOMX_FORCE_SECURE)
add_executable(wikideck-randomx randomx-engine.cpp)
target_compile_features(wikideck-randomx PRIVATE cxx_std_17)
set_target_properties(wikideck-randomx PROPERTIES CXX_EXTENSIONS OFF
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}")
foreach(config DEBUG RELEASE RELWITHDEBINFO MINSIZEREL)
set_target_properties(wikideck-randomx PROPERTIES
RUNTIME_OUTPUT_DIRECTORY_${config} "${CMAKE_BINARY_DIR}")
endforeach()
target_include_directories(wikideck-randomx PRIVATE "${randomx_SOURCE_DIR}/src")
target_link_libraries(wikideck-randomx PRIVATE randomx)
if(MSVC)
target_compile_options(wikideck-randomx PRIVATE /W4 /GS)
else()
target_compile_options(wikideck-randomx PRIVATE
-Wall -Wextra -Wpedantic -Wconversion -Wformat=2 -Wformat-security
-fstack-protector-strong)
endif()
option(WIKIDECK_RANDOMX_TESTS "Enable opt-in native known-vector test (256 MiB)" OFF)
if(WIKIDECK_RANDOMX_TESTS)
enable_testing()
add_test(NAME randomx-upstream-vector COMMAND wikideck-randomx --self-test)
set_tests_properties(randomx-upstream-vector PROPERTIES TIMEOUT 120)
endif()
+214
View File
@@ -0,0 +1,214 @@
#include <randomx.h>
#include <algorithm>
#include <array>
#include <charconv>
#include <cstdio>
#include <cstdint>
#include <iostream>
#include <limits>
#include <memory>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>
#ifdef _WIN32
#include <fcntl.h>
#include <io.h>
#endif
namespace {
constexpr std::size_t MaxBlob = 8192;
constexpr std::size_t MaxLine = 16600;
using Hash = std::array<std::uint8_t, RANDOMX_HASH_SIZE>;
[[noreturn]] void invalid() { throw std::runtime_error("invalid command"); }
int nibble(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
invalid();
}
std::vector<std::uint8_t> unhex(std::string_view text, std::size_t min, std::size_t max) {
if (text.size() % 2 != 0 || text.size() / 2 < min || text.size() / 2 > max) invalid();
std::vector<std::uint8_t> bytes(text.size() / 2);
for (std::size_t i = 0; i < bytes.size(); ++i)
bytes[i] = static_cast<std::uint8_t>((nibble(text[2 * i]) << 4) | nibble(text[2 * i + 1]));
return bytes;
}
std::string hex(const Hash& hash) {
constexpr char digits[] = "0123456789abcdef";
std::string out(hash.size() * 2, '0');
for (std::size_t i = 0; i < hash.size(); ++i) {
out[2 * i] = digits[hash[i] >> 4];
out[2 * i + 1] = digits[hash[i] & 15];
}
return out;
}
std::uint64_t decimal(std::string_view text) {
if (text.empty() || text.size() > 20) invalid();
for (char c : text) if (c < '0' || c > '9') invalid();
std::uint64_t value = 0;
const auto result = std::from_chars(text.data(), text.data() + text.size(), value);
if (result.ec != std::errc{} || result.ptr != text.data() + text.size()) invalid();
return value;
}
// Read incrementally into bounded storage; getline(string) alone allows unbounded allocation.
bool readLine(std::string& line) {
line.clear();
char c;
while (std::cin.get(c)) {
if (c == '\n') return true;
if (line.size() >= MaxLine || static_cast<unsigned char>(c) > 127 || c == '\0') invalid();
line.push_back(c);
}
if (!std::cin.eof()) throw std::runtime_error("stdin failure");
if (!line.empty()) throw std::runtime_error("truncated command");
return false;
}
std::vector<std::string_view> tokens(std::string& line) {
if (!line.empty() && line.back() == '\r') line.pop_back();
std::vector<std::string_view> parts;
std::string_view remaining(line);
while (true) {
const auto space = remaining.find(' ');
const auto part = remaining.substr(0, space);
if (part.empty() || parts.size() == 6) invalid();
parts.push_back(part);
if (space == std::string_view::npos) break;
remaining.remove_prefix(space + 1);
}
return parts;
}
class Engine {
using Cache = std::unique_ptr<randomx_cache, decltype(&randomx_release_cache)>;
using Dataset = std::unique_ptr<randomx_dataset, decltype(&randomx_release_dataset)>;
using Vm = std::unique_ptr<randomx_vm, decltype(&randomx_destroy_vm)>;
bool fast_;
randomx_flags flags_ = randomx_get_flags() | RANDOMX_FLAG_SECURE;
// Destruction order: VM, dataset, cache. No borrowed pointer outlives its allocation.
Cache cache_{nullptr, randomx_release_cache};
Dataset dataset_{nullptr, randomx_release_dataset};
Vm vm_{nullptr, randomx_destroy_vm};
std::vector<std::uint8_t> key_;
public:
explicit Engine(bool fast) : fast_(fast) {}
void key(const std::vector<std::uint8_t>& key) {
if (vm_ && key == key_) return;
if (!cache_) {
cache_.reset(randomx_alloc_cache(flags_));
if (!cache_) throw std::runtime_error("RandomX cache allocation failed");
}
// Destroy the old VM before changing memory it references; reuse allocated cache/dataset.
vm_.reset();
randomx_init_cache(cache_.get(), key.data(), key.size());
if (fast_) {
if (!dataset_) dataset_.reset(randomx_alloc_dataset(RANDOMX_FLAG_DEFAULT));
if (!dataset_) throw std::runtime_error("RandomX dataset allocation failed");
randomx_init_dataset(dataset_.get(), cache_.get(), 0, randomx_dataset_item_count());
}
const auto vmFlags = fast_ ? flags_ | RANDOMX_FLAG_FULL_MEM : flags_;
vm_.reset(randomx_create_vm(vmFlags, fast_ ? nullptr : cache_.get(), dataset_.get()));
if (!vm_) throw std::runtime_error("RandomX VM allocation failed");
key_ = key;
}
Hash hash(const std::vector<std::uint8_t>& blob) {
Hash result{};
randomx_calculate_hash(vm_.get(), blob.data(), blob.size(), result.data());
return result;
}
};
void command(Engine& engine, const std::vector<std::string_view>& parts) {
if (parts.size() != 3 && parts.size() != 6) invalid();
const bool mining = parts[0] == "mine";
if ((!mining && parts[0] != "hash") || parts.size() != (mining ? 6U : 3U)) invalid();
const auto key = unhex(parts[1], 32, 32);
auto blob = unhex(parts[2], 1, MaxBlob);
// All validation precedes expensive cache/dataset initialization.
if (!mining) {
engine.key(key);
std::cout << hex(engine.hash(blob)) << std::endl;
return;
}
const auto target = unhex(parts[3], 32, 32);
const auto offsetValue = decimal(parts[4]);
const auto attempts = decimal(parts[5]);
if (blob.size() < 8 || offsetValue > blob.size() - 8 || attempts < 1 || attempts > 10000) invalid();
const auto offset = static_cast<std::size_t>(offsetValue);
std::uint64_t nonce = 0;
for (unsigned i = 0; i < 8; ++i) nonce |= std::uint64_t{blob[offset + i]} << (8 * i);
engine.key(key);
for (std::uint64_t attempt = 0; attempt < attempts; ++attempt) {
for (unsigned i = 0; i < 8; ++i)
blob[offset + i] = static_cast<std::uint8_t>(nonce >> (8 * i));
const auto hash = engine.hash(blob);
// BIG ENDIAN integer comparison (intentionally NOT Monero's endian convention).
if (!std::lexicographical_compare(target.begin(), target.end(), hash.begin(), hash.end())) {
std::cout << "found " << hex(hash) << ' ' << nonce << std::endl;
return;
}
if (nonce == std::numeric_limits<std::uint64_t>::max()) break; // Never wrap/repeat work.
++nonce;
}
std::cout << "exhausted" << std::endl;
}
void selfTest(Engine& engine) {
// Exact upstream v1.2.1 Hash test 1a/2a, including the original 12-byte key.
// This argv-only test does not weaken the protocol's fixed 32-byte key validation.
const std::string key = "test key 000";
const std::string blob = "This is a test";
engine.key(std::vector<std::uint8_t>(key.begin(), key.end()));
if (hex(engine.hash(std::vector<std::uint8_t>(blob.begin(), blob.end()))) !=
"639183aae1bf4c9a35884cb46b09cad9175f04efd7684e7262a0ac1c2f0b4e3f")
throw std::runtime_error("upstream vector mismatch");
std::cerr << "RandomX upstream vector passed\n";
}
} // namespace
int main(int argc, char** argv) {
try {
#ifdef _WIN32
// CRT text-mode Ctrl-Z must not silently truncate an untrusted command stream.
if (_setmode(_fileno(stdin), _O_BINARY) == -1 ||
_setmode(_fileno(stdout), _O_BINARY) == -1)
throw std::runtime_error("stdio binary mode failed");
#endif
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
bool fast = false;
bool test = false;
for (int i = 1; i < argc; ++i) {
const std::string_view arg(argv[i]);
if (arg == "--fast" && !fast) fast = true;
else if (arg == "--self-test" && !test) test = true;
else throw std::runtime_error("usage: wikideck-randomx [--fast] [--self-test]");
}
Engine engine(fast);
if (test) { selfTest(engine); return 0; }
std::string line;
while (readLine(line)) {
command(engine, tokens(line));
if (!std::cout) throw std::runtime_error("stdout failure");
}
return 0;
} catch (const std::exception& error) {
std::cerr << "randomx: " << error.what() << '\n';
return 1;
} catch (...) {
std::cerr << "randomx: native failure\n";
return 1;
}
}
Generated Executable
+3203
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "wikideck",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"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": "node scripts/run-tests.mjs"
},
"dependencies": {
"express": "^5.1.0",
"express-rate-limit": "^8.1.0",
"hash-wasm": "^4.12.0",
"helmet": "^8.1.0",
"lucide-react": "^0.468.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"zod": "^4.1.0"
},
"devDependencies": {
"@types/express": "^5.0.3",
"@types/node": "^24.0.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^5.0.0",
"concurrently": "^9.2.0",
"tsx": "^4.20.0",
"typescript": "~5.9.2",
"vite": "^7.1.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.5 KiB

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

After

Width:  |  Height:  |  Size: 4.9 KiB

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

After

Width:  |  Height:  |  Size: 209 B

+12
View File
@@ -0,0 +1,12 @@
// Run before the app paints. Keep theme names/base colors aligned with src/theme.ts.
(() => {
const bases = { latte: '#eff1f5', frappe: '#303446', macchiato: '#24273a', mocha: '#1e1e2e' };
let theme = 'mocha';
try {
const saved = localStorage.getItem('wikideck-theme');
if (Object.hasOwn(bases, saved)) theme = saved;
} catch { /* Storage can be unavailable in privacy-restricted browsers. */ }
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme === 'latte' ? 'light' : 'dark';
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', bases[theme]);
})();
-5
View File
@@ -1,5 +0,0 @@
cryptography
dotenv
flask
mariadb
wikipedia
+59
View File
@@ -0,0 +1,59 @@
import { DatabaseSync } from 'node:sqlite';
import { mkdtemp, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { performance } from 'node:perf_hooks';
import { CanonicalIndex } from '../server/testnet/canonical-index.ts';
import { CHAIN_ID, GENESIS_HASH, sha256, transactionId } from '../server/testnet/consensus.ts';
import { NETWORK, type TestnetBlock, type NftState, type NftTransfer } from '../shared/testnet.ts';
// Synthetic LOCAL SQL dataset only. Not valid mined blocks; never submitted to Ledger or a peer.
// This measures derived-index costs, not RandomX validation, consensus security or production SLOs.
const text = process.argv[2] ?? '10000';
if (!/^[1-9]\d{0,5}$/.test(text) || Number(text) > 100000) throw new Error('Fixture size must be 1..100000');
const count = Number(text), queries = 1000;
const directory = await mkdtemp(join(tmpdir(), 'wikideck-index-benchmark-'));
const file = join(directory, 'fixture.sqlite'), db = new DatabaseSync(file);
try {
db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON; CREATE TABLE blocks(hash TEXT PRIMARY KEY,block_json TEXT NOT NULL)');
const index = new CanonicalIndex(db), chain: TestnetBlock[] = [], state: NftState = Object.create(null);
index.initialize([], state);
const owners = Array.from({ length: 16 }, (_, i) => sha256(`fixture-owner-${i}`));
const insert = db.prepare('INSERT INTO blocks VALUES(?,?)');
const start = performance.now();
db.exec('BEGIN IMMEDIATE');
try {
for (let height = 1; height <= count; height++) {
const hash = sha256(`fixture-block-${height}`), token = sha256(`fixture-token-${height}`), owner = owners[height % owners.length];
const txs: NftTransfer[] = height > 2 ? [{ network: NETWORK, chainId: CHAIN_ID, cardHash: chain[height - 3].tokenId,
from: state[chain[height - 3].tokenId].owner, to: owner, sequence: 0, publicKey: '', signature: '' }] : [];
const block = { height, hash, tokenId: token, previousHash: chain.at(-1)?.hash ?? GENESIS_HASH, owner,
article: { title: `Synthetic index fixture ${height}`, pageId: height, extract: 'x'.repeat(512) }, transactions: txs } as unknown as TestnetBlock;
state[token] = { owner, sequence: 0, mintedHeight: height, pageId: height };
for (const tx of txs) state[tx.cardHash] = { ...state[tx.cardHash], owner: tx.to, sequence: 1 };
insert.run(hash, JSON.stringify(block)); index.connect(block); chain.push(block);
}
db.exec('COMMIT');
} catch (error) { db.exec('ROLLBACK'); throw error; }
const buildMs = performance.now() - start;
const latency: number[] = [];
for (let i = 0; i < queries; i++) {
const block = chain[(i * 7919) % count], before = performance.now();
const nft = index.nft(block.tokenId, count);
if (nft?.sequence !== state[block.tokenId].sequence || nft.owner !== state[block.tokenId].owner) throw new Error('Fixture ownership mismatch');
index.deck(owners[i % owners.length], 0, 24, count);
if (block.transactions.length && !index.transaction(transactionId(block.transactions[0]))) throw new Error('Fixture transaction missing');
latency.push(performance.now() - before);
}
let cursor = 0, batches = 0;
const consumeStart = performance.now();
do { const page = index.events(cursor, 64); cursor = page.cursor; batches++; } while (cursor < index.head());
const eventReadMs = performance.now() - consumeStart;
const verifyStart = performance.now(); index.initialize(chain, state); const verifyMs = performance.now() - verifyStart;
const bytes = (await stat(file)).size + (await stat(file + '-wal')).size;
latency.sort((a, b) => a - b);
console.log(JSON.stringify({ fixture: 'synthetic-derived-index-only', blocks: count, nfts: count,
transactions: Math.max(0, count - 2), queries, buildMs, lookupGroupP50Ms: latency[499], lookupGroupP95Ms: latency[949],
eventBatches: batches, maxEventsPerBatch: 64, eventReadMs, startupVerifyMs: verifyMs, databaseAndWalBytes: bytes,
warning: 'No PoW, HTTP load, real assets, target hardware or production SLO certification.' }, null, 2));
} finally { db.close(); await rm(directory, { recursive: true, force: true }); }
+48
View File
@@ -0,0 +1,48 @@
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { arch, platform } from 'node:os';
import { RandomX } from '../server/testnet/randomx.ts';
import { MAX_TARGET, sha256 } from '../server/testnet/consensus.ts';
import { expectedHashes, targetForHashrate } from '../server/analysis/calibration.ts';
const args=process.argv.slice(2),known=new Set(['--consent','--fast','--hashes','--network-hps']);
for(let i=0;i<args.length;i++) {
if(!known.has(args[i]))throw new Error('Unknown option');
if(args[i]==='--hashes'||args[i]==='--network-hps')i++;
}
if(!args.includes('--consent'))throw new Error('Explicit --consent required: native RandomX consumes CPU, energy and ~256 MiB light / >2.3 GiB fast. No chain/network access.');
const number=(name:string,fallback:number,min:number,max:number)=>{
const i=args.indexOf(name),text=i<0?String(fallback):args[i+1];
const value=Number(text);
if(!text||!/^\d+$/.test(text)||!Number.isSafeInteger(value)||value<min||value>max)throw new Error(`Invalid ${name}`);
return value;
};
const count=number('--hashes',32,4,256),networkHps=number('--network-hps',1,1,1_000_000_000_000);
const binary=resolve(process.env.RANDOMX_BINARY??`native/build/wikideck-randomx${process.platform==='win32'?'.exe':''}`);
// Fingerprint the exact executable; it must come from the trusted pinned build, never a download URL.
const binarySha256=createHash('sha256').update(await readFile(binary)).digest('hex');
const engine=new RandomX({binary,fast:args.includes('--fast')});
const stop=()=>engine.close();process.once('SIGINT',stop);process.once('SIGTERM',stop);
const deadline=setTimeout(stop,60000);deadline.unref();
try {
const key=sha256('wikideck-offline-benchmark-epoch-a'),otherKey=sha256('wikideck-offline-benchmark-epoch-b');
const blob=Buffer.alloc(40);Buffer.from(sha256('wikideck-offline-benchmark-blob'),'hex').copy(blob);
let start=performance.now();const first=await engine.hash(key,blob);const coldMs=performance.now()-start;
start=performance.now();if(await engine.hash(key,blob)!==first)throw new Error('Repeated reference hash differs');
const repeatMs=performance.now()-start;
const samples:number[]=[];start=performance.now();
for(let i=0;i<count;i++) {
blob.writeBigUInt64LE(BigInt(i+1),32);const t=performance.now();await engine.hash(key,blob);samples.push(performance.now()-t);
}
const warmMs=performance.now()-start,warmHps=count/(warmMs/1000);
start=performance.now();await engine.hash(otherKey,blob);const epochSwitchMs=performance.now()-start;
samples.sort((a,b)=>a-b);
console.log(JSON.stringify({schema:'wikideck-randomx-benchmark-v1',mode:args.includes('--fast')?'fast':'light',
platform:platform(),arch:arch(),node:process.version,binarySha256,hashes:count,coldMs,repeatMs,warmMs,warmHashesPerSecond:warmHps,
p50HashMs:samples[Math.floor(samples.length*.5)],p95HashMs:samples[Math.min(samples.length-1,Math.floor(samples.length*.95))],epochSwitchMs,
currentInitialExpectedHashes:expectedHashes(MAX_TARGET),currentInitialExpectedSecondsAtMeasuredSingleWorker:expectedHashes(MAX_TARGET)/warmHps,
assumedNetworkHashesPerSecond:networkHps,illustrativeTargetForAssumedNetwork:targetForHashrate(networkHps),
note:'Local single-worker IPC-inclusive warm hash benchmark, not a network/miner-throughput estimate. Cold includes initialization; epoch switch is one cache change. Network H/s is an explicit assumption, not extrapolated from this machine.',
consensusChanged:false,productionReady:false},null,2));
} finally {clearTimeout(deadline);engine.close();process.off('SIGINT',stop);process.off('SIGTERM',stop);}
+25
View File
@@ -0,0 +1,25 @@
[CmdletBinding()]
param(
[string]$BuildDirectory = (Join-Path $PSScriptRoot '../native/build'),
[string]$Generator = '',
[ValidateRange(1, 64)][int]$Jobs = 2,
[switch]$Test
)
$ErrorActionPreference = 'Stop'
$SourceDirectory = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../native'))
$BuildDirectory = [System.IO.Path]::GetFullPath($BuildDirectory)
if (-not (Get-Command cmake -ErrorAction SilentlyContinue)) { throw 'CMake 3.24+ is required.' }
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { throw 'Git is required to fetch pinned RandomX.' }
$NativeTests = if ($Test) { 'ON' } else { 'OFF' }
$ConfigureArguments = @('-S', $SourceDirectory, '-B', $BuildDirectory,
'-DCMAKE_BUILD_TYPE=Release', "-DWIKIDECK_RANDOMX_TESTS=$NativeTests")
if ($Generator) { $ConfigureArguments += @('-G', $Generator) }
& cmake @ConfigureArguments
if ($LASTEXITCODE -ne 0) { throw 'RandomX CMake configuration failed.' }
& cmake --build $BuildDirectory --config Release --target wikideck-randomx --parallel $Jobs
if ($LASTEXITCODE -ne 0) { throw 'RandomX native build failed.' }
if ($Test) {
& ctest --test-dir $BuildDirectory -C Release --output-on-failure
if ($LASTEXITCODE -ne 0) { throw 'RandomX known-vector test failed.' }
}
Write-Host "Built native RandomX engine in $BuildDirectory"
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
set -eu
# Usage: sh scripts/build-randomx.sh [--test]; JOBS defaults to a modest two compiler jobs.
tests=OFF
case "${1-}" in
'') ;;
--test) tests=ON; shift ;;
*) printf '%s\n' 'usage: build-randomx.sh [--test]' >&2; exit 2 ;;
esac
[ "$#" -eq 0 ] || { printf '%s\n' 'unexpected argument' >&2; exit 2; }
root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
build=${RANDOMX_BUILD_DIR:-"$root/native/build"}
jobs=${JOBS:-2}
case "$jobs" in ''|*[!0-9]*) printf '%s\n' 'JOBS must be 1..64' >&2; exit 2 ;; esac
[ "$jobs" -ge 1 ] && [ "$jobs" -le 64 ] || { printf '%s\n' 'JOBS must be 1..64' >&2; exit 2; }
command -v cmake >/dev/null 2>&1 || { printf '%s\n' 'CMake 3.24+ is required' >&2; exit 1; }
command -v git >/dev/null 2>&1 || { printf '%s\n' 'Git is required' >&2; exit 1; }
cmake -S "$root/native" -B "$build" -DCMAKE_BUILD_TYPE=Release "-DWIKIDECK_RANDOMX_TESTS=$tests"
cmake --build "$build" --config Release --target wikideck-randomx --parallel "$jobs"
if [ "$tests" = ON ]; then ctest --test-dir "$build" -C Release --output-on-failure; fi
printf 'Built native RandomX engine in %s\n' "$build"
+4
View File
@@ -0,0 +1,4 @@
import { calibrationReport } from '../server/analysis/calibration.ts';
if (process.argv.slice(2).length) throw new Error('No options accepted. This deterministic analysis never selects launch parameters.');
console.log(JSON.stringify(calibrationReport(),null,2));
+20
View File
@@ -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));
+10
View File
@@ -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);
});
+12
View File
@@ -0,0 +1,12 @@
import { createBackup, verifyBackup, restoreBackup } from '../server/recovery.ts';
const [action,...args]=process.argv.slice(2);
if(!['create','verify','restore'].includes(action))throw new Error('Action must be create, verify or restore');
const allowed=new Set(['--source','--destination','--consent']);
for(let i=0;i<args.length;i++){if(!allowed.has(args[i]))throw new Error('Unknown option');if(args[i]!=='--consent')i++;}
const arg=(key:string)=>{const i=args.indexOf(key);return i<0?undefined:args[i+1];};
const source=arg('--source'),destination=arg('--destination');
if(!source||source.startsWith('--'))throw new Error('Explicit --source required');
if(action!=='verify'&&(!args.includes('--consent')||!destination||destination.startsWith('--')))throw new Error('Explicit --consent and new --destination required; stop the node first');
const result=action==='create'?await createBackup(source,destination!):action==='restore'?await restoreBackup(source,destination!):await verifyBackup(source);
console.log(JSON.stringify(result,null,2));
+11
View File
@@ -0,0 +1,11 @@
import { checkOperations } from '../server/operations-check.ts';
const args=process.argv.slice(2);
if(args.length!==2||args[0]!=='--port'||!/^\d{1,5}$/.test(args[1])||Number(args[1])<1||Number(args[1])>65535)throw new Error('Explicit --port 1..65535 required; loopback diagnostics only.');
const response=await fetch(`http://127.0.0.1:${Number(args[1])}/status`,{redirect:'error',signal:AbortSignal.timeout(5000)});
if(!response.ok||!response.body||!/^application\/json(?:;|$)/i.test(response.headers.get('content-type')??''))throw new Error('Local diagnostics unavailable');
const reader=response.body.getReader(),chunks:Uint8Array[]=[];let bytes=0;
try{for(;;){const part=await reader.read();if(part.done)break;bytes+=part.value.byteLength;if(bytes>65536)throw new Error('Diagnostics too large');chunks.push(part.value);}}
finally{await reader.cancel();reader.releaseLock();}
const joined=new Uint8Array(bytes);let offset=0;for(const chunk of chunks){joined.set(chunk,offset);offset+=chunk.length;}
const report=checkOperations(JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(joined)));
console.log(JSON.stringify(report,null,2));if(!report.healthy)process.exitCode=1;
+33
View File
@@ -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<string,number>={};const latencies:number[]=[];
const started=performance.now();
await Promise.all(Array.from({length:concurrency},async()=>{
while(next<count){const i=next++,start=performance.now();
try{
const path=i%3===0?'/api/peers':'/api/summary';
const response=await fetch(new URL(path,endpoint),{redirect:'error',signal:AbortSignal.timeout(5000)});
const reader=response.body?.getReader();let bytes=0;
if(reader)try{for(;;){const part=await reader.read();if(part.done)break;bytes+=part.value.byteLength;if(bytes>2*1024*1024)throw new Error('Response too large');}}finally{await reader.cancel();reader.releaseLock();}
totalBytes+=bytes;statuses[response.status]=(statuses[response.status]??0)+1;
}catch{statuses.error=(statuses.error??0)+1;}
latencies.push(performance.now()-start);
}
}));
latencies.sort((a,b)=>a-b);
const health=await fetch(new URL('/healthz',endpoint),{redirect:'error',signal:AbortSignal.timeout(5000)});
console.log(JSON.stringify({scope:'loopback read-only smoke, not production benchmark',requests:count,concurrency,
elapsedMs:Math.round(performance.now()-started),statuses,totalBytes,
p50Ms:Math.round(latencies[Math.floor(latencies.length*.5)]??0),p95Ms:Math.round(latencies[Math.min(latencies.length-1,Math.floor(latencies.length*.95))]??0),
healthStatus:health.status},null,2));
await health.body?.cancel();
if(statuses.error||health.status!==200)process.exitCode=1;
+18
View File
@@ -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;
+60
View File
@@ -0,0 +1,60 @@
// Explicit opt-in live-network test. Creates disposable wallets and mines three test NFTs.
import assert from 'node:assert/strict';
import { generateKeyPairSync, sign } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
import { RandomX } from '../server/testnet/randomx.ts';
import { address, CHAIN_ID, miningBlob, mintId, sha256 } from '../server/testnet/consensus.ts';
import { blockClaimMessage, nftTransferMessage, NETWORK, testnetOwnerOf, transactionCardIds,
type MiningTemplate, type TestnetSnapshot } from '../shared/testnet.ts';
if(!process.argv.includes('--consent'))throw new Error('This test mines three blocks with a native CPU worker. Pass --consent explicitly. Disposable NFT rewards are not recoverable.');
const nodes=(process.env.WIKIDECK_SMOKE_NODES??'http://127.0.0.1:3101,http://127.0.0.1:3102,http://127.0.0.1:3103').split(',');
for(const endpoint of nodes)if(!/^http:\/\/127\.0\.0\.1:\d+$/.test(endpoint))throw new Error('Smoke test only accepts explicit loopback test nodes');
async function api(node:string,path:string,body?:unknown){
const r=await fetch(node+path,{method:body?'POST':'GET',headers:body?{'Content-Type':'application/json'}:{},
body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(30000),redirect:'error'});
const value=await r.json();if(!r.ok)throw new Error(value.error??`HTTP${r.status}`);return value;
}
const keys=generateKeyPairSync('ec',{namedCurve:'prime256v1'});
const publicKey=keys.publicKey.export({format:'der',type:'spki'}).toString('base64'),owner=address(publicKey);
const receiver=generateKeyPairSync('ec',{namedCurve:'prime256v1'});
const to=address(receiver.publicKey.export({format:'der',type:'spki'}).toString('base64'));
const signed=(m:string)=>sign('sha256',Buffer.from(m),{key:keys.privateKey,dsaEncoding:'ieee-p1363'}).toString('base64');
const engine=new RandomX();
try {
const initial=await api(nodes[0],'/api/state') as TestnetSnapshot;
assert.equal(initial.chainId,CHAIN_ID);
console.log(`Testing ${nodes.length} independent nodes on ${CHAIN_ID}; starting height ${initial.blocks.length}`);
let first='';
for(let count=0;count<3;count++) {
if(count===2){
const tx={network:NETWORK,chainId:CHAIN_ID,cardHash:first,from:owner,to,sequence:0};
await api(nodes[0],'/api/transfer',{...tx,publicKey,signature:signed(nftTransferMessage(tx))});
const pending=await api(nodes[0],'/api/state') as TestnetSnapshot;
assert.equal(testnetOwnerOf(pending,first),owner);
assert.ok(pending.pendingTransfers.some(t=>transactionCardIds(t).includes(first)));
}
const template=await api(nodes[0],'/api/mining/template',{owner,publicKey}) as MiningTemplate;
let found;
for(let batch=0;batch<100;batch++){
found=await engine.mine(template.header.key,miningBlob(template.header),template.header.target,32,32);
if(found)break;template.header.nonce+=32;
}
assert.ok(found,'Native proof not found within smoke budget');
template.header.nonce=found.nonce;
const hash=sha256(miningBlob(template.header)),tokenId=mintId(template.header);
await api(nodes[0],'/api/blocks',{...template.header,hash,tokenId,powHash:found.hash,signature:signed(blockClaimMessage(hash)),transactions:template.transactions});
if(count===0)first=tokenId;
console.log(`Native RandomX block #${template.header.height} accepted: ${hash}`);
}
const canonical=await api(nodes[0],'/api/state') as TestnetSnapshot;
const deadline=Date.now()+60000;
for(;;){
const states=await Promise.all(nodes.map(n=>api(n,'/api/state') as Promise<TestnetSnapshot>));
if(states.every(s=>s.tip===canonical.tip&&s.cumulativeWork===canonical.cumulativeWork&&testnetOwnerOf(s,first)===to)){
console.log(`PASS: ${states.length} independent nodes agreed on work ${canonical.cumulativeWork}, tip, NFT issuance and mined transfer.`);break;
}
if(Date.now()>deadline)throw new Error('Peer convergence timeout');
await delay(500);
}
}finally{engine.close();}
+1
View File
@@ -0,0 +1 @@
// Temporary runtime probe removed after verification.
+66
View File
@@ -0,0 +1,66 @@
import { CONSENSUS, type TestnetBlock } from '../../shared/testnet.ts';
import { CHAIN_ID, GENESIS_TIME, MAX_TARGET, expectedTarget, medianTime, workFor } from '../testnet/consensus.ts';
const SPACE = 1n << 256n;
/** Analysis only. Calls the actual retarget/median helpers but DOES NOT construct valid blocks,
* perform PoW, invoke Ledger, or provide a production fake-PoW selector. */
export type Scenario = 'steady' | 'rapid' | 'slow' | 'stall' | 'median-minimum' | 'future-edge';
export type TracePoint = { height: number; wallTime: number; timestamp: number; target: string;
cumulativeWork: string; expectedHashes: number; nextTarget: string };
export function expectedHashes(target: string): number {
if (!/^[a-f0-9]{64}$/.test(target)) throw new Error('Invalid target');
return Number(SPACE) / Number(BigInt(`0x${target}`) + 1n);
}
export function targetForHashrate(hashesPerSecond: number, seconds = CONSENSUS.targetSeconds): string {
if (!Number.isSafeInteger(hashesPerSecond) || hashesPerSecond < 1 || hashesPerSecond > 1_000_000_000_000 ||
!Number.isSafeInteger(seconds) || seconds < 1 || seconds > 86400) throw new Error('Hashrate/interval outside analysis bounds');
const denominator = BigInt(hashesPerSecond) * BigInt(seconds);
const target = SPACE / denominator - 1n;
const maximum = BigInt(`0x${MAX_TARGET}`);
return (target < 1n ? 1n : target > maximum ? maximum : target).toString(16).padStart(64,'0');
}
export function simulate(scenario: Scenario, count = 60): TracePoint[] {
if (!['steady','rapid','slow','stall','median-minimum','future-edge'].includes(scenario) ||
!Number.isSafeInteger(count) || count < 1 || count > 1000) throw new Error('Invalid bounded simulation');
const history: TestnetBlock[] = [], result: TracePoint[] = [];
let wallTime = GENESIS_TIME, cumulativeWork = 0n;
for (let i = 0; i < count; i++) {
const target = expectedTarget(history);
const spacing = scenario === 'rapid' ? 1 : scenario === 'slow' ? CONSENSUS.targetSeconds * 8
: scenario === 'stall' && i === 20 ? 7 * 86400 : CONSENSUS.targetSeconds;
wallTime += spacing;
const timestamp = scenario === 'median-minimum' ? medianTime(history) + 1
: scenario === 'future-edge' ? wallTime + CONSENSUS.maxFutureSeconds : wallTime;
if (timestamp <= medianTime(history) || timestamp > wallTime + CONSENSUS.maxFutureSeconds) throw new Error('Analysis generated an invalid timestamp');
// Only these fields are read by expectedTarget/medianTime. Never submit these records to a node.
history.push({ createdAt: timestamp, target } as TestnetBlock);
cumulativeWork += workFor(target);
result.push({ height:i+1,wallTime,timestamp,target,cumulativeWork:cumulativeWork.toString(),
expectedHashes:expectedHashes(target),nextTarget:expectedTarget(history) });
}
return result;
}
/** Idealized fixed-difficulty Poisson race: probability of ever catching up from a CURRENT
* known z-block deficit, q<p. NOT probability of double-spend after z confirmations. */
export function idealCatchup(attackerShare: number, deficit: number): number {
if (!Number.isFinite(attackerShare) || attackerShare < 0 || attackerShare > 1 ||
!Number.isSafeInteger(deficit) || deficit < 0 || deficit > 1000) throw new Error('Invalid race assumptions');
if (deficit === 0 || attackerShare >= .5) return 1;
return (attackerShare / (1-attackerShare)) ** deficit;
}
export function calibrationReport() {
const scenarios = Object.fromEntries((['steady','rapid','slow','stall','median-minimum','future-edge'] as const)
.map(name=>[name,simulate(name)]));
const a=scenarios.steady.at(-1)!,b=scenarios.rapid.at(-1)!;
return { schema:'wikideck-consensus-analysis-v1',chainId:CHAIN_ID,consensusChanged:false,
scope:'Deterministic header-math scenarios only; no valid blocks, mining or launch.',
initialExpectedHashes:expectedHashes(MAX_TARGET),targetSeconds:CONSENSUS.targetSeconds,
assumptions:'Spacing is externally imposed, not stochastic mining at constant hashrate. Work comparisons do not imply an attacker can produce that work.',
scenarios,
partitionExample:{steadyHeight:a.height,rapidHeight:b.height,steadyWork:a.cumulativeWork,rapidWork:b.cumulativeWork,
winnerByStrictWork:BigInt(b.cumulativeWork)>BigInt(a.cumulativeWork)?'rapid':'steady',
warning:'Models fork-choice inputs, not peer synchronization, block validity or achievable hashpower.'},
knownDeficitCatchup:[.1,.25,.4,.49].map(share=>({attackerShare:share,probability:idealCatchup(share,10),currentDeficit:10})),
candidateTargets:[1,10,100,1000,10000].map(hps=>({assumedNetworkHashesPerSecond:hps,target:targetForHashrate(hps)})),
decision:'NO-GO: measurement, independent analysis, multi-host rehearsal and explicit operator approval still required.' };
}
+58
View File
@@ -0,0 +1,58 @@
import { createHash, createPublicKey, verify } from 'node:crypto';
import { argon2id } from 'hash-wasm';
import { z } from 'zod';
import { claimMessage, transferMessage, proofMessage, POW, GENESIS, DIFFICULTY, ownerOf,
type Block, type Challenge, type ChainState, type Transfer } from '../shared/protocol.ts';
const hex = z.string().regex(/^[a-f0-9]{64}$/);
const publicKey = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).max(256);
const signature = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).max(128);
export const mineSchema = z.object({ owner: hex, publicKey, signature,
nonce: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), hash: hex,
seed: hex }).strict();
export const transferSchema = z.object({ cardHash: hex, from: hex, to: hex,
sequence: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), publicKey, signature }).strict();
export function address(key: string): string {
return createHash('sha256').update(Buffer.from(key, 'base64')).digest('hex');
}
export function validSignature(key: string, owner: string, message: string, sig: string): boolean {
try {
if (address(key) !== owner) return false;
const imported = createPublicKey({ key: Buffer.from(key, 'base64'), format: 'der', type: 'spki' });
if (imported.asymmetricKeyType !== 'ec' || imported.asymmetricKeyDetails?.namedCurve !== 'prime256v1') return false;
return verify('sha256', Buffer.from(message), { key: imported, dsaEncoding: 'ieee-p1363' }, Buffer.from(sig, 'base64'));
} catch { return false; }
}
export async function powHash(c: Challenge, owner: string, nonce: number): Promise<string> {
return argon2id({ ...POW, password: proofMessage(c, owner, nonce),
salt: Buffer.from(c.seed, 'hex'), outputType: 'hex' });
}
export async function verifyBlock(block: Block, previousHash: string, height: number): Promise<boolean> {
if (block.previousHash !== previousHash || block.height !== height || block.difficulty !== DIFFICULTY) return false;
if (!/^[a-f0-9]{64}$/.test(block.seed) || !/^[a-f0-9]{64}$/.test(block.hash)) return false;
if (!Number.isSafeInteger(block.nonce) || block.nonce < 0 || !Number.isSafeInteger(block.createdAt)) return false;
if (!Number.isSafeInteger(block.article.pageId) || block.article.pageId <= 0) return false;
if (!block.hash.startsWith('0'.repeat(DIFFICULTY))) return false;
if (!validSignature(block.publicKey, block.owner, claimMessage(block, block.owner, block.nonce, block.hash), block.signature)) return false;
return await powHash(block, block.owner, block.nonce) === block.hash;
}
export function validTransfer(state: ChainState, t: Transfer): boolean {
return transferSchema.safeParse(t).success && t.from !== t.to &&
ownerOf(state, t.cardHash) === t.from &&
t.sequence === state.transfers.filter(x => x.cardHash === t.cardHash).length &&
validSignature(t.publicKey, t.from, transferMessage(t), t.signature);
}
export async function verifyState(state: ChainState): Promise<boolean> {
let previous = GENESIS;
for (const [index, block] of state.blocks.entries()) {
if (!await verifyBlock(block, previous, index + 1)) return false;
previous = block.hash;
}
const checked: ChainState = { blocks: state.blocks, transfers: [] };
for (const t of state.transfers) {
if (!validTransfer(checked, t)) return false;
checked.transfers.push(t);
}
return true;
}
+174
View File
@@ -0,0 +1,174 @@
import { z } from 'zod';
import { deriveStats, type PageEnrichment } from '../shared/enrichment.ts';
const MAX_BYTES = 1024 * 1024;
const integer = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
const text = z.string().max(4096);
const pageSchema = z.object({
pageid: integer.positive(), ns: z.literal(0), title: text.min(1),
missing: z.never().optional(), invalid: z.never().optional(),
lastrevid: integer.positive(), length: integer,
description: text.optional(),
pageprops: z.object({ wikibase_item: z.string().regex(/^Q[1-9]\d{0,19}$/).optional() }).optional(),
categories: z.array(z.object({ ns: z.literal(14), title: text.min(1) })).max(50).optional(),
langlinks: z.array(z.object({ lang: z.string().min(1).max(64) })).max(500).optional(),
});
const metadataSchema = z.object({
query: z.object({ pages: z.array(pageSchema).length(1) }),
continue: z.object({ clcontinue: text.optional(), llcontinue: text.optional() }).optional(),
});
const viewsSchema = z.object({ items: z.array(z.object({
timestamp: z.string().regex(/^\d{6}0100$/), views: integer,
project: z.literal('en.wikipedia'), access: z.literal('all-access'),
agent: z.literal('user'), granularity: z.literal('monthly'),
})).max(6) });
type CacheEntry = { expires: number; value: PageEnrichment } | { expires: number; error: Error };
type Options = { fetch?: typeof globalThis.fetch; now?: () => Date; ttlMs?: number; maxEntries?: number };
function monthWindow(now: Date): string[] {
return Array.from({ length: 6 }, (_, index) =>
new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 6 + index, 1)).toISOString().slice(0, 7));
}
/** Fixed-source read-only enrichment; no consensus, persistence, URL or header inputs. */
export class WikipediaEnrichment {
private readonly fetcher: typeof globalThis.fetch;
private readonly now: () => Date;
private readonly ttlMs: number;
private readonly maxEntries: number;
private readonly cache = new Map<number, CacheEntry>();
private readonly pending = new Map<number, Promise<PageEnrichment>>();
private active = 0;
constructor(options: Options = {}) {
this.fetcher = options.fetch ?? globalThis.fetch;
this.now = options.now ?? (() => new Date());
this.ttlMs = options.ttlMs ?? 6 * 60 * 60 * 1000;
this.maxEntries = options.maxEntries ?? 128;
if (!Number.isSafeInteger(this.ttlMs) || this.ttlMs <= 0 ||
!Number.isSafeInteger(this.maxEntries) || this.maxEntries <= 0 || this.maxEntries > 128) {
throw new RangeError('Invalid enrichment cache options');
}
}
async get(pageId: number): Promise<PageEnrichment> {
return this.loadCached(pageId, false);
}
/** Bypass cached values when the result is being pinned into a newly minted block. */
async getFresh(pageId: number): Promise<PageEnrichment> {
return this.loadCached(pageId, true);
}
private async loadCached(pageId: number, fresh: boolean): Promise<PageEnrichment> {
if (!Number.isSafeInteger(pageId) || pageId <= 0) throw new RangeError('Invalid Wikipedia page ID');
const now = this.now();
const cached = this.cache.get(pageId);
if (!fresh && cached && cached.expires > now.getTime() &&
(!('value' in cached) || cached.value.pageviews.latestMonth === monthWindow(now)[5])) {
this.cache.delete(pageId);
this.cache.set(pageId, cached);
if ('error' in cached) throw cached.error;
return structuredClone(cached.value);
}
this.cache.delete(pageId);
const existing = this.pending.get(pageId);
if (existing) return structuredClone(await existing);
// Reject immediately: no unbounded waiting queue. Four active jobs also keep
// the pending-map ceiling of eight conservative (one upstream request/job).
if (this.pending.size >= 8 || this.active >= 4) throw new Error('Wikipedia enrichment busy');
this.active++;
const job = this.load(pageId, now).then(value => {
const ttl = value.pageviews.status === 'available' ? this.ttlMs : Math.min(this.ttlMs, 5 * 60 * 1000);
this.remember(pageId, { value, expires: this.now().getTime() + ttl });
return value;
}, () => {
const error = new Error('Wikipedia page enrichment unavailable');
this.remember(pageId, { error, expires: this.now().getTime() + 30_000 });
throw error;
}).finally(() => { this.active--; this.pending.delete(pageId); });
this.pending.set(pageId, job);
return structuredClone(await job);
}
private remember(pageId: number, entry: CacheEntry): void {
this.cache.delete(pageId);
this.cache.set(pageId, entry);
while (this.cache.size > this.maxEntries) this.cache.delete(this.cache.keys().next().value!);
}
private async json(url: URL): Promise<unknown> {
const signal = AbortSignal.timeout(6000);
const response = await this.fetcher(url, { signal, redirect: 'error',
headers: { Accept: 'application/json', 'User-Agent': 'Wikideck/0.1 (Wikipedia off-chain page enrichment)' } });
const length = response.headers.get('content-length');
if (!response.ok || response.redirected ||
!/^application\/json(?:\s*;|\s*$)/i.test(response.headers.get('content-type') ?? '') ||
(length !== null && (!/^\d+$/.test(length) || Number(length) > MAX_BYTES)) || !response.body) {
await response.body?.cancel();
throw new Error('Invalid upstream response');
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
signal.throwIfAborted();
const { done, value } = await reader.read();
signal.throwIfAborted();
if (done) break;
size += value.byteLength;
if (size > MAX_BYTES) throw new Error('Upstream response too large');
chunks.push(value);
}
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
} finally {
await reader.cancel();
reader.releaseLock();
}
}
private async load(pageId: number, now: Date): Promise<PageEnrichment> {
const url = new URL('https://en.wikipedia.org/w/api.php');
url.search = new URLSearchParams({ action: 'query', format: 'json', formatversion: '2',
pageids: String(pageId), prop: 'info|categories|pageprops|description|langlinks',
clshow: '!hidden', cllimit: '50', lllimit: '500', ppprop: 'wikibase_item' }).toString();
const metadata = metadataSchema.parse(await this.json(url));
const page = metadata.query.pages[0];
if (page.pageid !== pageId) throw new Error('Wikipedia page ID mismatch');
const months = monthWindow(now);
const latestMonth = months[5];
let pageviews: PageEnrichment['pageviews'] = { status: 'unavailable', months: [], latestMonth,
total: null, dailyAverage: null, trendPercent: null };
try {
const title = encodeURIComponent(page.title.replace(/ /g, '_'));
const stamp = (month: string) => `${month.replace('-', '')}0100`;
const endpoint = new URL(`https://wikimedia.org/api/rest_v1/metrics/pageviews/per-article/en.wikipedia.org/all-access/user/${title}/monthly/${stamp(months[0])}/${stamp(latestMonth)}`);
const result = viewsSchema.parse(await this.json(endpoint));
const byMonth = new Map<string, number>();
for (const item of result.items) {
const month = `${item.timestamp.slice(0, 4)}-${item.timestamp.slice(4, 6)}`;
if (!months.includes(month) || byMonth.has(month)) throw new Error('Invalid pageview month');
byMonth.set(month, item.views);
}
if (byMonth.size !== 6) throw new Error('Incomplete pageviews');
const ordered = months.map(month => ({ month, views: byMonth.get(month)! }));
const total = ordered.reduce((sum, item) => sum + item.views, 0);
if (!Number.isSafeInteger(total)) throw new Error('Pageview total overflow');
const latest = ordered[5].views, prior = ordered[4].views;
const days = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0)).getUTCDate();
pageviews = { status: 'available', months: ordered, latestMonth, total,
dailyAverage: latest / days, trendPercent: prior > 0 ? (latest - prior) / prior * 100 : null };
} catch { /* Metadata remains useful; unknown views must not become fake zeroes. */ }
return { pageId, title: page.title, description: page.description ?? null,
wikidataId: page.pageprops?.wikibase_item ?? null, revisionId: page.lastrevid, pageBytes: page.length,
languageCount: page.langlinks?.length ?? 0, languagesTruncated: metadata.continue?.llcontinue !== undefined,
categories: (page.categories ?? []).map(item => item.title.replace(/^Category:/, '')),
categoriesTruncated: metadata.continue?.clcontinue !== undefined, fetchedAt: now.toISOString(), pageviews,
stats: deriveStats(page.length, page.langlinks?.length ?? 0,
pageviews.status === 'available' ? pageviews.months[5].views : null) };
}
}
export const wikipediaEnrichment = new WikipediaEnrichment();
+12
View File
@@ -0,0 +1,12 @@
// A node independently validates RandomX work and follows cumulative-work consensus.
import { assertLaunchAllowed, launchManifest } from './launch.ts';
import { launchPreflight } from './preflight.ts';
if (process.argv.includes('--launch-preflight')) {
console.log(JSON.stringify(launchPreflight(), null, 2));
process.exitCode = 1; // Fail closed: a report with unresolved production blockers is never approval.
} else if (process.argv.includes('--network-manifest')) console.log(JSON.stringify(launchManifest(), null, 2));
else {
assertLaunchAllowed();
await import('./testnet/http.ts');
}
+29
View File
@@ -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');
}
+22
View File
@@ -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.'};
}
+63
View File
@@ -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};
}
+27
View File
@@ -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 };
}
+117
View File
@@ -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<T>(dataDir:string,fn:()=>Promise<T>):Promise<T>{
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<T>(dataDir:string,operation:(source:string)=>Promise<T>):Promise<T>{
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.'};
});
}
+89
View File
@@ -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();
};
}
}
+186
View File
@@ -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;
}
}
+284
View File
@@ -0,0 +1,284 @@
import { createHash, createPublicKey, verify } from 'node:crypto';
import { z } from 'zod';
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 { MAX_TARGET };
// Consensus manifest: an independently running node derives exactly the same genesis.
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}`);
const hex = z.string().regex(/^[a-f0-9]{64}$/);
const key = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).max(256);
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(),
source: z.literal('live'), category: z.string().trim().min(1).max(50) }).strict().superRefine((article, ctx) => {
if (article.url !== `https://en.wikipedia.org/?curid=${article.pageId}`) {
ctx.addIssue({ code: 'custom', message: 'Article URL must identify its English Wikipedia page', path: ['url'] });
}
try {
const image = new URL(article.image);
if (image.protocol !== 'https:' || !['https://upload.wikimedia.org', 'https://thumb.wikimedia.org'].includes(image.origin) ||
image.username || image.password || image.hash) {
ctx.addIssue({ code: 'custom', message: 'Article image must use a Wikimedia HTTPS host', path: ['image'] });
}
} catch { ctx.addIssue({ code: 'custom', message: 'Invalid article image URL', path: ['image'] }); }
});
const monthSchema = z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/);
const statsSnapshotSchema = z.object({ capturedAt: z.string().datetime(), revisionId: integer.positive(),
pageBytes: integer, languageCount: integer, languagesTruncated: z.boolean(),
pageviews: z.object({ status: z.enum(['available', 'unavailable']),
months: z.array(z.object({ month: monthSchema, views: integer }).strict()).max(6),
latestMonth: monthSchema, total: integer.nullable(), dailyAverage: z.number().min(0).nullable(),
trendPercent: z.number().finite().nullable() }).strict(),
stats: z.object({ attention: z.number().int().min(0).max(100).nullable(),
depth: z.number().int().min(0).max(100), reach: z.number().int().min(0).max(100) }).strict() }).strict()
.superRefine((snapshot, ctx) => {
const views = snapshot.pageviews;
if (views.status === 'available') {
if (views.months.length !== 6 || views.months[5]?.month !== views.latestMonth) {
ctx.addIssue({ code: 'custom', message: 'Available pageviews require six months ending at latestMonth', path: ['pageviews', 'months'] });
} else {
const total = views.months.reduce((sum, item) => sum + item.views, 0);
const latest = views.months[5].views;
const prior = views.months[4].views;
const [year, month] = views.latestMonth.split('-').map(Number);
const days = new Date(Date.UTC(year, month, 0)).getUTCDate();
const trend = prior > 0 ? (latest - prior) / prior * 100 : null;
if (!Number.isSafeInteger(total) || views.total !== total || views.dailyAverage !== latest / days ||
views.trendPercent !== trend) {
ctx.addIssue({ code: 'custom', message: 'Pageview summaries do not match the monthly values', path: ['pageviews'] });
}
}
} else if (views.months.length !== 0 || views.total !== null || views.dailyAverage !== null || views.trendPercent !== null) {
ctx.addIssue({ code: 'custom', message: 'Unavailable pageviews must not include invented counts', path: ['pageviews'] });
}
const monthlyViews = views.status === 'available' ? views.months[5]?.views ?? null : null;
const expected = deriveStats(snapshot.pageBytes, snapshot.languageCount, monthlyViews);
if (snapshot.stats.attention !== expected.attention || snapshot.stats.depth !== expected.depth ||
snapshot.stats.reach !== expected.reach) {
ctx.addIssue({ code: 'custom', message: 'Derived statistics do not match the page metrics', path: ['stats'] });
}
});
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(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 {
try {
const bytes = Buffer.from(publicKey, 'base64');
if (bytes.toString('base64') !== publicKey || address(publicKey) !== owner) return false;
const k = createPublicKey({ key: bytes, type: 'spki', format: 'der' });
if (k.asymmetricKeyType !== 'ec' || k.asymmetricKeyDetails?.namedCurve !== 'prime256v1') return false;
if (!k.export({ format: 'der', type: 'spki' }).equals(bytes)) return false;
return verify('sha256', Buffer.from(message), { key: k, dsaEncoding: 'ieee-p1363' }, Buffer.from(sig, 'base64'));
} catch { return false; }
}
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')])));
while (layer.length > 1) {
const next: string[] = [];
for (let i = 0; i < layer.length; i += 2) next.push(sha256(Buffer.concat([Buffer.from([1]),
Buffer.from(layer[i], 'hex'), Buffer.from(layer[i + 1] ?? layer[i], 'hex')])));
layer = next;
}
// 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 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<BlockHeader, 'previousHash' | 'height' | 'owner' | 'article' | 'statsSnapshot'>): string {
return sha256(JSON.stringify([NETWORK, CHAIN_ID, 'mint', h.previousHash, h.height, h.owner,
h.article.pageId, statsSnapshotTuple(h.statsSnapshot)]));
}
export function miningBlob(header: BlockHeader): Buffer {
const nonce = Buffer.alloc(8);
nonce.writeBigUInt64LE(BigInt(header.nonce));
return Buffer.concat([Buffer.from(sha256(headerMessage(header)), 'hex'), nonce]);
}
export function workFor(target: string): bigint { return (1n << 256n) / (BigInt(`0x${target}`) + 1n); }
export function expectedKey(ancestors: TestnetBlock[], height: number): string {
const keyHeight = Math.floor(Math.max(0, height - CONSENSUS.epochDelay) / CONSENSUS.epochBlocks) * CONSENSUS.epochBlocks;
return keyHeight === 0 ? INITIAL_KEY : ancestors[keyHeight - 1].hash;
}
export function expectedTarget(ancestors: TestnetBlock[]): string {
if (!ancestors.length) return MAX_TARGET;
const parent = ancestors.at(-1)!;
if (ancestors.length % CONSENSUS.retargetInterval !== 0) return parent.target;
const start = ancestors[ancestors.length - CONSENSUS.retargetInterval];
const expected = (CONSENSUS.retargetInterval - 1) * CONSENSUS.targetSeconds;
const elapsed = Math.max(Math.floor(expected / 4), Math.min(expected * 4, parent.createdAt - start.createdAt));
const value = BigInt(`0x${parent.target}`) * BigInt(elapsed) / BigInt(expected);
return (value < 1n ? 1n : value > BigInt(`0x${MAX_TARGET}`) ? BigInt(`0x${MAX_TARGET}`) : value).toString(16).padStart(64, '0');
}
export function medianTime(ancestors: TestnetBlock[]): number {
const values = ancestors.slice(-CONSENSUS.medianWindow).map(b => b.createdAt).sort((a,b) => a-b);
return values.length ? values[Math.floor(values.length / 2)] : GENESIS_TIME;
}
export function applyTransfer(state: NftState, tx: NftTransfer, height: number): void {
if (!transferSchema.safeParse(tx).success) throw new Error('Invalid transfer encoding/network');
const nft = 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');
state[tx.cardHash] = { ...nft, owner: tx.to, sequence: tx.sequence + 1 };
}
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<string>();
for (const transaction of txs) {
const id = transactionId(transaction);
if (ids.has(id)) throw new Error('Duplicate transfer');
ids.add(id);
next = applyTransactionOverlay(next, transaction, header.height);
}
const id = mintId(header);
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: 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: 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(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: 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) || (!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(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 };
}
+182
View File
@@ -0,0 +1,182 @@
import express from 'express';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdir, open, unlink } from 'node:fs/promises';
import { z } from 'zod';
import { NETWORK } from '../../shared/testnet.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,`${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;
try { engine = new RandomX(); }
catch(e) { await lock.close();await unlink(lockPath);throw e; }
let ledger: Ledger;
try { ledger = await Ledger.open(dataDir,engine); }
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) {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});});
// 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');
if(req.method==='POST') {
if(!req.is('application/json')){res.status(415).json({error:'JSON required'});return;}
const origin=req.get('origin');
const allowed=new Set(['http://127.0.0.1:5173','http://localhost:5173','http://127.0.0.1:3001','http://localhost:3001',process.env.PUBLIC_ORIGIN]);
if(origin&&!allowed.has(origin)){res.status(403).json({error:'Origin not allowed'});return;}
}
next();
});
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;}
const pageId=Number(req.params.pageId);
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(ledger.peerStatus()));
app.get('/p2p/blocks',(req,res)=>{
const offset=Number(req.query.offset??0);
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.length<ledger.size?offset+blocks.length:null});
});
app.get('/p2p/mempool',(_req,res)=>res.json({transactions:ledger.pendingTransactions()}));
app.get('/api/nfts/:id', (req,res)=>{
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)=>{
const body=z.object({owner:z.string().regex(/^[a-f0-9]{64}$/),publicKey:z.string().max(256)}).strict().parse(req.body);
try {
const article=await wikipediaArticles.random();
const metrics=await wikipediaEnrichment.getFresh(article.pageId);
res.json(ledger.template(body.owner,body.publicKey,article,snapshotStats(metrics)));
} catch {
res.status(503).json({error:'Could not prepare a Wikipedia article and current statistics. Retry shortly.'});
}
});
const workLimit=rateLimit({windowMs:60000,limit:30,standardHeaders:'draft-8',legacyHeaders:false});
app.post(['/api/blocks','/p2p/block'],workLimit,async(req,res)=>{
const block=blockSchema.parse(req.body);
const result=await ledger.submit(block);
res.status(result.accepted?201:200).json(result);
if(result.accepted)void peers.broadcastBlock(block).catch(()=>{});
});
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')));
app.use((err:Error & {status?:number},_req:express.Request,res:express.Response,_next:express.NextFunction)=>{
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.
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 ${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();operations.close();
const timeout=setTimeout(()=>process.exit(1),25000);timeout.unref();
server.close(async()=>{ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
});
server.on('error',async()=>{stopping=true;operations.close();peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
+366
View File
@@ -0,0 +1,366 @@
import { chmod, mkdir, readFile, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { z } from 'zod';
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_PENDING = 256;
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(10_000),
pendingTransfers: z.array(transactionSchema).max(MAX_PENDING) }).strict();
type Node = { block: TestnetBlock; state: NftStateView; work: bigint };
const DATABASE_SCHEMA_VERSION = '1';
/** 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<string, Node>();
private tip = GENESIS_HASH;
private pending: TestnetTransaction[] = [];
private pendingState: NftStateView = {};
private queue: Promise<unknown> = 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 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, nowSeconds: () => number = () => Math.floor(Date.now() / 1000)): Promise<Ledger> {
const directory = join(dir, PROFILE);
await mkdir(directory, { recursive: true, mode: 0o700 });
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 {
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');
}
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;
}
}
get size(): number { return this.nodes.size; }
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;
}
/** Append-only insertion order is topological, including all valid side branches.
* 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 > 1000) {
throw new Error('Invalid inventory range');
}
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 }> {
const input = structuredClone(raw);
return this.serialize(async () => {
const parsed = blockSchema.parse(input);
if (this.nodes.has(parsed.hash)) return { accepted: false, reorg: false };
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 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.
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<string> {
const tx = transferSchema.parse(structuredClone(raw));
return this.submitTransaction(tx);
}
async submitTransaction(raw: unknown): Promise<string> {
const input = structuredClone(raw);
return this.serialize(async () => {
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 nextPendingState = applyTransactionOverlay(this.pendingState, tx, this.height() + 1);
const pending = [...this.pending, tx];
this.persist(undefined, pending);
this.pending = pending;
this.pendingState = nextPendingState;
this.revision++;
return id;
});
}
template(owner: string, publicKey: string, article: Article, statsSnapshot: MintStatsSnapshot): MiningTemplate {
return makeTemplate(this.canonical(), this.tipState(), owner, publicKey,
structuredClone(this.pending.slice(0, CONSENSUS.maxTransactions)), structuredClone(article),
structuredClone(statsSnapshot));
}
snapshot(): TestnetSnapshot {
const blocks = this.canonical();
return structuredClone({ network: NETWORK, chainId: CHAIN_ID, genesis: GENESIS_HASH,
tip: this.tip, cumulativeWork: this.tipWork().toString(), blocks,
transfers: blocks.flatMap(b => b.transactions), pendingTransfers: this.pending,
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(): 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[] = [];
while (hash !== GENESIS_HASH) {
const node = this.nodes.get(hash);
if (!node) throw new Error('Unknown parent');
result.push(node.block);
hash = node.block.previousHash;
}
return result.reverse();
}
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<Node> {
const ancestors = this.branch(raw.previousHash);
const parent = this.nodes.get(raw.previousHash);
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: 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 = transactionId(tx);
if (seen.has(id)) continue;
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, state };
}
private serialize<T>(operation: () => Promise<T>): Promise<T> {
if (this.queued >= 256) return Promise.reject(new Error('Testnet mutation queue full'));
this.queued++;
const result = this.queue.then(operation);
this.queue = result.catch(() => undefined).finally(() => { this.queued--; });
return result;
}
private persist(block: TestnetBlock | undefined, pendingTransfers: TestnetTransaction[],
disconnected: TestnetBlock[] = [], connected: TestnetBlock[] = []): void {
this.db.exec('BEGIN IMMEDIATE');
try {
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;
}
}
}
+96
View File
@@ -0,0 +1,96 @@
import { readFile } from 'node:fs/promises';
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, 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]; };
if (!args.includes('--consent') || !arg('--wallet')) {
console.error('Usage: npm run miner -- --wallet <encrypted-backup.json> --consent [--node http://127.0.0.1:3001] [--fast] [--blocks 1]');
console.error('Native RandomX mining uses CPU, energy and 256 MiB (light) or roughly 2.1 GiB (fast). Ctrl+C stops mining.');
process.exit(1);
}
const endpoint = new URL(arg('--node') ?? 'http://127.0.0.1:3001');
if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.search || endpoint.hash || endpoint.pathname !== '/') throw new Error('Node must be an HTTP(S) origin');
if (endpoint.protocol !== 'https:' && !['localhost','127.0.0.1','[::1]'].includes(endpoint.hostname) && !args.includes('--allow-insecure-node')) throw new Error('Non-loopback HTTP requires --allow-insecure-node');
const limit = Number(arg('--blocks') ?? 1);
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100000) throw new Error('Invalid block limit');
const file = await readFile(resolve(arg('--wallet')!));
if (file.length > 32768) throw new Error('Wallet backup too large');
if (!stdin.isTTY || !stdin.setRawMode) throw new Error('Run the native miner in an interactive terminal to unlock the encrypted wallet.');
console.log('Unlocking locally. The backup and password are never sent to a node.');
async function passwordPrompt(): Promise<string> {
stdout.write('Backup password: ');
stdin.setRawMode(true); stdin.resume(); stdin.setEncoding('utf8');
return new Promise((resolve,reject) => {
let password = '';
const finish = () => { stdin.off('data', handler); stdin.setRawMode(false); stdin.pause(); stdout.write('\n'); };
const handler = (data: string) => {
for (const c of data) {
if (c === '\u0003') { finish(); reject(new Error('Unlock cancelled')); return; }
if (c === '\r' || c === '\n') { finish(); resolve(password); return; }
if (c === '\u007f' || c === '\b') password = password.slice(0,-1);
else if (c >= ' ' && password.length < 1024) password += c;
}
};
stdin.on('data', handler);
});
}
let password = await passwordPrompt();
const wallet = await decryptWallet(JSON.parse(file.toString('utf8')), password);
password = '';
console.log(`RandomX ${args.includes('--fast') ? 'FAST (~2.1 GiB)' : 'LIGHT (256 MiB)'} miner · ${NETWORK}`);
console.log(`Reward address: ${wallet.address}\nNode: ${endpoint.origin}\nMining at most ${limit} blocks. Ctrl+C stops.`);
const engine = new RandomX({ fast: args.includes('--fast') });
let stopping = false;
const stop = () => { stopping = true; engine.close(); };
process.once('SIGINT',stop); process.once('SIGTERM',stop);
async function api(path: string, body?: unknown) {
const response = await fetch(new URL(path,endpoint), { method: body ? 'POST' : 'GET', redirect:'error',
signal: AbortSignal.timeout(30000), headers: body ? {'Content-Type':'application/json'} : {}, body: body ? JSON.stringify(body) : undefined });
const text = await response.text();
if (text.length > 131072) throw new Error('Node response too large');
const data = JSON.parse(text);
if (!response.ok) throw new Error(data.error ?? `Node rejected request (${response.status})`);
return data;
}
try {
let accepted = 0;
while (!stopping && accepted < limit) {
const template = await api('/api/mining/template', { owner:wallet.address, publicKey:wallet.publicKey }) as MiningTemplate;
if (template.header.chainId !== CHAIN_ID || template.header.network !== NETWORK || template.nonceOffset !== 32) throw new Error('Node returned incompatible network');
const header = template.header;
header.nonce = randomInt(0, 2 ** 32);
const started = Date.now(); let attempts = 0;
let solved = false;
while (!stopping && !solved) {
const result = await engine.mine(header.key, miningBlob(header), header.target, 32, 16);
attempts += 16;
if (result) {
header.nonce = result.nonce;
const hash = sha256(miningBlob(header));
const signature = Buffer.from(await crypto.subtle.sign({name:'ECDSA',hash:'SHA-256'},wallet.privateKey,new TextEncoder().encode(blockClaimMessage(hash)))).toString('base64');
const block: TestnetBlock = { ...header, hash, tokenId:mintId(header), powHash:result.hash, signature, transactions:template.transactions };
blockSchema.parse(block);
try { await api('/api/blocks',block); accepted++; console.log(`Accepted block #${header.height} · NFT ${block.tokenId} · ${accepted}/${limit}`); }
catch (e) { console.error((e as Error).message); }
solved = true;
} else {
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 > 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));
}
}
}
} catch(e) { if(!stopping) { console.error((e as Error).message); process.exitCode=1; } }
finally { engine.close(); process.off('SIGINT',stop);process.off('SIGTERM',stop); }
+198
View File
@@ -0,0 +1,198 @@
import { request as httpRequest } from 'node:http';
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 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;
const excluded = new BlockList();
for (const [ip, prefix] of [ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10],
['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24],
['192.0.2.0', 24], ['192.168.0.0', 16], ['192.88.99.0', 24], ['198.18.0.0', 15],
['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 3] ] as const) excluded.addSubnet(ip, prefix, 'ipv4');
const globalV6 = new BlockList();
globalV6.addSubnet('2000::', 3, 'ipv6');
for (const [ip, prefix] of [['2001::', 23], ['2001:db8::', 32], ['2002::', 16],
['3fff::', 20]] as const) excluded.addSubnet(ip, prefix, 'ipv6');
function publicAddress(ip: string): boolean {
const family = isIP(ip);
if (family === 4) return !excluded.check(ip, 'ipv4');
// Only global unicast: also rejects mapped IPv4, local, unspecified and multicast IPv6.
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(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(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/cluster networks, not peer input.
*/
export class PeerNetwork {
private readonly peers: Peer[];
private readonly ledger: Ledger;
private readonly allowPrivate: boolean;
private readonly intervalMs: number;
private timer: ReturnType<typeof setInterval> | undefined;
private closed = false;
private readonly active = new Set<AbortController>();
constructor(options: { ledger: Ledger; urls: string[]; allowPrivate: boolean; intervalMs?: number }) {
this.ledger = options.ledger;
this.allowPrivate = options.allowPrivate === true;
this.intervalMs = options.intervalMs ?? 5000;
if (!Number.isSafeInteger(this.intervalMs) || this.intervalMs < 1000 || this.intervalMs > 3_600_000) {
throw new Error('Peer interval must be 1000..3600000 ms');
}
if (options.urls.length > 16) throw new Error('At most 16 configured peers');
this.peers = [...new Set(options.urls.map(raw => {
if (raw.length > 2048) throw new Error('Peer URL too long');
// Reject forbidden syntax before URL normalization can erase paths or empty delimiters.
if (!/^https?:\/\/[^/?#\\@\s]+\/?$/i.test(raw)) throw new Error('Peer URL must contain only an origin');
const url = new URL(raw);
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash ||
(url.protocol !== 'https:' && !(this.allowPrivate && url.protocol === 'http:'))) {
throw new Error('Peer URL must be an HTTPS origin without credentials/path/query/fragment');
}
const host = url.hostname.replace(/^\[|\]$/g, '');
if (!this.allowPrivate && (host.toLowerCase().replace(/\.$/, '') === 'localhost' ||
host.toLowerCase().replace(/\.$/, '').endsWith('.localhost') ||
(isIP(host) && !publicAddress(host)))) throw new Error('Private peer requires allowPrivate');
return url.origin;
}))].map(url => ({ url, connected: false, offset: 0, busy: false }));
}
start(): void {
if (this.closed || this.timer) return;
const pass = () => { for (const peer of this.peers) void this.run(peer, () => this.sync(peer)); };
pass();
this.timer = setInterval(pass, this.intervalMs);
this.timer.unref();
}
close(): void {
this.closed = true;
if (this.timer) clearInterval(this.timer);
this.timer = undefined;
for (const controller of this.active) controller.abort();
for (const peer of this.peers) peer.connected = false;
this.updateCount();
}
status(): { url: string; connected: boolean; error?: string }[] {
return this.peers.map(({ url, connected, error }) => ({ url, connected, ...(error ? { error } : {}) }));
}
async broadcastBlock(block: TestnetBlock): Promise<void> {
const known = this.ledger.block(blockSchema.parse(block).hash);
if (!known) throw new Error('Cannot broadcast an unaccepted block');
await this.broadcast('/p2p/block', known);
}
async broadcastTransfer(tx: TestnetTransaction): Promise<void> {
return this.broadcastTransaction(tx);
}
async broadcastTransaction(tx: TestnetTransaction): Promise<void> {
const id = transactionId(transactionSchema.parse(tx));
const snapshot = this.ledger.snapshot();
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);
}
private async broadcast(path: string, body: unknown): Promise<void> {
// Drop gossip to busy/disconnected peers instead of building an unbounded retry queue.
await Promise.all(this.peers.filter(p => p.connected).map(p => this.run(p, async () => {
await this.request(p, path, body);
})));
}
private async run(peer: Peer, operation: () => Promise<void>): Promise<void> {
if (this.closed || peer.busy) return;
peer.busy = true;
try {
await operation();
if (!this.closed) { peer.connected = true; delete peer.error; }
} catch (error) {
peer.connected = false;
peer.error = (error instanceof Error ? error.message : 'Peer request failed').replace(/[\r\n\x00-\x1f]/g, ' ').slice(0, 200);
} finally { peer.busy = false; this.updateCount(); }
}
private updateCount(): void { this.ledger.peers = this.peers.filter(p => p.connected).length; }
private async sync(peer: Peer): Promise<void> {
const status = remoteStatus.parse(await this.request(peer, '/p2p/status'));
if (peer.offset > status.size) peer.offset = 0;
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 || !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');
if (!this.ledger.block(block.hash)) await this.ledger.submit(block);
}
peer.offset = end; // At most 32 blocks per peer/pass, never trust a remote cursor.
}
const pool = poolSchema.parse(await this.request(peer, '/p2p/mempool'));
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.submitTransaction(tx); } catch { /* Independently reject; do not relay. */ }
}
}
private async request(peer: Peer, path: string, body?: unknown): Promise<unknown> {
if (this.closed) throw new Error('Peer network closed');
const url = new URL(path, peer.url);
const controller = new AbortController();
this.active.add(controller);
const timeout = setTimeout(() => controller.abort(), 10_000);
const encoded = body === undefined ? undefined : Buffer.from(JSON.stringify(body));
try {
if (encoded && encoded.length > MAX_BODY) throw new Error('Peer request exceeds 2 MiB');
return await new Promise<unknown>((resolve, reject) => {
const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)(url, {
method: encoded ? 'POST' : 'GET', signal: controller.signal, agent: false,
// Supported by net.connect; absent from HTTP's narrower RequestOptions type.
...{ autoSelectFamily: false }, maxHeaderSize: 16 * 1024,
headers: { accept: 'application/json', ...(encoded ? {
'content-type': 'application/json', 'content-length': encoded.length } : {}) },
// Validate every DNS answer and pin the connection to that exact lookup result.
// A separate preflight lookup followed by fetch would allow DNS rebinding.
lookup: (host, _options, callback) => {
void dns.lookup(host, { all: true, verbatim: true }).then(addresses => {
if (!addresses.length || (!this.allowPrivate && addresses.some(a => !publicAddress(a.address)))) {
callback(new Error('Peer DNS resolves to a non-public address'), '', 4);
} else callback(null, addresses[0].address, addresses[0].family);
}, () => callback(new Error('Peer DNS lookup failed'), '', 4));
},
}, response => {
// Node's HTTP client never follows redirects. Reject them, including same-origin.
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
response.destroy(); reject(new Error(`Peer HTTP ${response.statusCode}`)); return;
}
if (!/^application\/json(?:\s*;|$)/i.test(response.headers['content-type'] ?? '') ||
(response.headers['content-encoding'] && response.headers['content-encoding'] !== 'identity')) {
response.destroy(); reject(new Error('Peer must return uncompressed JSON')); return;
}
const chunks: Buffer[] = [];
let length = 0;
response.on('data', (chunk: Buffer) => {
length += chunk.length;
if (length > MAX_BODY) {
response.destroy(new Error('Peer response exceeds 2 MiB')); return;
}
chunks.push(chunk);
});
response.on('error', reject);
response.on('end', () => {
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
catch { reject(new Error('Invalid peer JSON')); }
});
});
request.on('error', reject);
request.end(encoded);
});
} finally { clearTimeout(timeout); this.active.delete(controller); }
}
}
+209
View File
@@ -0,0 +1,209 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { Buffer } from 'node:buffer';
import { accessSync, constants } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
export interface PowEngine {
hash(key: string, blob: Uint8Array): Promise<string>;
close(): void;
}
type Result = { hash: string; nonce: number };
type Request = {
command: string;
parse: (line: string) => unknown;
resolve: (value: unknown) => void;
reject: (error: Error) => void;
};
const TIMEOUT_MS = 120_000;
const MAX_QUEUE = 64;
const MAX_RESPONSE = 128;
const HEX64 = /^[0-9a-fA-F]{64}$/;
function hex64(value: string, name: string): string {
if (typeof value !== 'string' || value.length !== 64 || !HEX64.test(value)) {
throw new TypeError(`${name} must be exactly 64 hexadecimal characters`);
}
return value.toLowerCase();
}
function blobHex(blob: Uint8Array): string {
if (!(blob instanceof Uint8Array) || blob.byteLength < 1 || blob.byteLength > 8192) {
throw new RangeError('blob must contain 1..8192 bytes');
}
return Buffer.from(blob).toString('hex'); // Snapshot: queued work cannot be changed by its caller.
}
/** The production testnet's native CPU RandomX proof engine; never substitutes a web hash. */
export class RandomX implements PowEngine {
private readonly child: ChildProcessWithoutNullStreams;
private readonly queue: Request[] = [];
private active: Request | undefined;
private timer: ReturnType<typeof setTimeout> | undefined;
private shutdownTimer: ReturnType<typeof setTimeout> | undefined;
private failure: Error | undefined;
private output = '';
private stderr = '';
private exited = false;
constructor(options: { binary?: string; fast?: boolean } = {}) {
const defaultBinary = fileURLToPath(new URL(
`../../native/build/wikideck-randomx${process.platform === 'win32' ? '.exe' : ''}`,
import.meta.url,
));
const binary = resolve(options.binary ?? process.env.RANDOMX_BINARY ?? defaultBinary);
// Missing/non-executable binaries fail immediately, not silently on first mining attempt.
accessSync(binary, constants.X_OK);
this.child = spawn(binary, options.fast ? ['--fast'] : [], {
shell: false,
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe'],
});
this.child.on('error', () => this.fail(new Error('RandomX subprocess failed to start')));
this.child.stdin.on('error', () => this.fail(new Error('RandomX stdin failed')));
this.child.stdout.on('error', () => this.fail(new Error('RandomX stdout failed')));
this.child.stderr.on('error', () => this.fail(new Error('RandomX stderr failed')));
this.child.stderr.on('data', (chunk: Buffer) => {
// Always drain stderr, but never accumulate unbounded native diagnostics.
this.stderr = (this.stderr + chunk.subarray(0, 1024).toString('utf8')).slice(-1024);
});
this.child.stdout.on('data', (chunk: Buffer) => this.receive(chunk));
this.child.stdout.on('end', () => {
if (!this.failure) this.fail(new Error('RandomX stdout closed unexpectedly'));
});
this.child.on('exit', (code, signal) => {
this.exited = true;
if (this.shutdownTimer) clearTimeout(this.shutdownTimer);
const detail = this.stderr.replace(/[^\x20-\x7e]/g, ' ').trim();
this.fail(new Error(`RandomX exited (${signal ?? code})${detail ? `: ${detail}` : ''}`));
});
}
async hash(key: string, blob: Uint8Array): Promise<string> {
const command = `hash ${hex64(key, 'key')} ${blobHex(blob)}`;
return this.request(command, (line) => {
if (!/^[0-9a-f]{64}$/.test(line)) throw new Error('Invalid RandomX hash response');
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,
): Promise<Result | null> {
const normalizedKey = hex64(key, 'key');
const encodedBlob = blobHex(blob);
const normalizedTarget = hex64(target, 'target');
const size = encodedBlob.length / 2;
if (!Number.isSafeInteger(nonceOffset) || nonceOffset < 0 || nonceOffset > size - 8) {
throw new RangeError('nonceOffset must leave 8 bytes within blob');
}
if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 10000) {
throw new RangeError('maxAttempts must be in 1..10000');
}
const start = Buffer.from(encodedBlob, 'hex').readBigUInt64LE(nonceOffset);
const last = start + BigInt(maxAttempts - 1);
// The public API returns number, so never silently round a 64-bit native nonce.
if (last > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new RangeError('nonce range must fit Number.MAX_SAFE_INTEGER');
}
const command = `mine ${normalizedKey} ${encodedBlob} ${normalizedTarget} ${nonceOffset} ${maxAttempts}`;
return this.request(command, (line) => {
if (line === 'exhausted') return null;
const match = /^found ([0-9a-f]{64}) (0|[1-9][0-9]{0,19})$/.exec(line);
if (!match) throw new Error('Invalid RandomX mine response');
const nonce = BigInt(match[2]);
if (nonce < start || nonce > last || match[1] > normalizedTarget) {
throw new Error('RandomX result outside requested nonce range/target');
}
return { hash: match[1], nonce: Number(nonce) };
});
}
private request<T>(command: string, parse: (line: string) => T): Promise<T> {
if (this.failure) return Promise.reject(this.failure);
if (this.queue.length + (this.active ? 1 : 0) >= MAX_QUEUE) {
return Promise.reject(new Error('RandomX request queue full'));
}
return new Promise<T>((resolve, reject) => {
this.queue.push({ command, parse, resolve: (value) => resolve(value as T), reject });
this.dispatch();
});
}
private dispatch(): void {
if (this.failure || this.active) return;
const request = this.queue.shift();
if (!request) return;
this.active = request;
this.timer = setTimeout(() => this.fail(new Error('RandomX command timed out after 120s')), TIMEOUT_MS);
this.child.stdin.write(`${request.command}\n`, 'ascii', (error) => {
if (error) this.fail(new Error('RandomX command write failed'));
});
}
private receive(chunk: Buffer): void {
if (this.failure) return;
// Exactly one bounded ASCII response per outstanding request, including CRLF on Windows.
for (const byte of chunk) {
if (byte > 127 || (byte < 32 && byte !== 10 && byte !== 13)) {
this.fail(new Error('Invalid RandomX output encoding'));
return;
}
}
if (this.output.length + chunk.length > MAX_RESPONSE) {
this.fail(new Error('RandomX response too long'));
return;
}
this.output += chunk.toString('ascii');
const newline = this.output.indexOf('\n');
if (newline === -1) return;
const request = this.active;
if (!request || newline !== this.output.length - 1) {
this.fail(new Error('Unsolicited RandomX output'));
return;
}
const line = this.output.slice(0, newline).replace(/\r$/, '');
try {
const result = request.parse(line);
if (this.timer) clearTimeout(this.timer);
this.timer = undefined;
this.output = '';
this.active = undefined;
request.resolve(result);
// Let all current output be checked before allowing another command onto stdin.
queueMicrotask(() => this.dispatch());
} catch (error) {
this.fail(error instanceof Error ? error : new Error('Invalid RandomX response'));
}
}
private fail(error: Error): void {
if (this.failure) return;
this.failure = error;
if (this.timer) clearTimeout(this.timer);
this.timer = undefined;
this.active?.reject(error);
this.active = undefined;
for (const request of this.queue.splice(0)) request.reject(error);
if (!this.exited) this.child.kill('SIGKILL');
}
close(): void {
if (this.failure) return;
const error = new Error('RandomX engine closed');
this.failure = error;
if (this.timer) clearTimeout(this.timer);
this.timer = undefined;
this.active?.reject(error);
this.active = undefined;
for (const request of this.queue.splice(0)) request.reject(error);
// Idle engines exit cleanly on EOF and run RAII destructors. Bound busy shutdown too.
this.child.stdin.end();
if (!this.exited) {
this.shutdownTimer = setTimeout(() => this.child.kill('SIGKILL'), 1000);
this.shutdownTimer.unref();
}
}
}
+99
View File
@@ -0,0 +1,99 @@
import { z } from 'zod';
import type { Article } from '../shared/protocol.ts';
const pageSchema = z.object({
pageid: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), ns: z.literal(0),
title: z.string().trim().min(1).max(200), extract: z.string().max(4096).optional(),
lastrevid: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
thumbnail: z.object({ source: z.string().url().max(1500) }).optional(),
categories: z.array(z.object({ title: z.string().min(1).max(300) })).max(50).optional(),
});
const responseSchema = z.object({ query: z.object({ pages: z.array(z.unknown()).max(10) }) });
const MAX_RESPONSE_BYTES = 512 * 1024;
const MAX_ATTEMPTS = 3;
const imageOrigins = new Set(['https://upload.wikimedia.org', 'https://thumb.wikimedia.org']);
function category(title: string, categories: string[]): string {
const text = `${title} ${categories.join(' ')}`.toLowerCase();
if (/astronom|planet|space|galax|moon|star\b/.test(text)) return 'Space';
if (/animal|plant|species|ocean|biolog|mollusc/.test(text)) return 'Nature';
if (/births|deaths|people|scientist/.test(text)) return 'People';
if (/physic|chem|math|science|technolog/.test(text)) return 'Science';
if (/histor|ancient|archaeolog|empire/.test(text)) return 'History';
return 'Culture';
}
async function readJson(response: Response): Promise<unknown> {
const length = response.headers.get('content-length');
if (!response.ok || response.redirected ||
!/^application\/json(?:\s*;|\s*$)/i.test(response.headers.get('content-type') ?? '') ||
(length !== null && (!/^\d+$/.test(length) || Number(length) > MAX_RESPONSE_BYTES)) || !response.body) {
await response.body?.cancel();
throw new Error('Invalid Wikipedia API response');
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > MAX_RESPONSE_BYTES) throw new Error('Wikipedia response too large');
chunks.push(value);
}
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
} finally {
await reader.cancel();
reader.releaseLock();
}
}
function pictureUrl(value: string | undefined): string | undefined {
if (!value) return undefined;
try {
const url = new URL(value);
return url.protocol === 'https:' && imageOrigins.has(url.origin) && !url.username && !url.password && !url.hash
? url.toString() : undefined;
} catch { return undefined; }
}
export class WikipediaArticleSource {
private readonly fetcher: typeof globalThis.fetch;
private active = 0;
constructor(fetcher: typeof globalThis.fetch = globalThis.fetch) { this.fetcher = fetcher; }
async random(): Promise<Article> {
if (this.active >= 4) throw new Error('Wikipedia article service is busy');
this.active++;
try {
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
try {
const url = new URL('https://en.wikipedia.org/w/api.php');
url.search = new URLSearchParams({ action: 'query', format: 'json', formatversion: '2',
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' } });
const data = responseSchema.parse(await readJson(response));
for (const rawPage of data.query.pages) {
const parsedPage = pageSchema.safeParse(rawPage);
if (!parsedPage.success) continue;
const page = parsedPage.data;
const image = pictureUrl(page.thumbnail?.source);
const extract = page.extract?.trim();
if (!image || !extract || extract.length < 40) continue;
return { pageId: page.pageid, title: page.title, extract: extract.slice(0, 900), image,
url: `https://en.wikipedia.org/?curid=${page.pageid}`, revisionId: page.lastrevid,
source: 'live', category: category(page.title, page.categories?.map(c => c.title) ?? []) };
}
} catch { /* Try another random API response, but keep the number of attempts bounded. */ }
}
throw new Error('Wikipedia did not return an article with a picture after three attempts');
} finally {
this.active--;
}
}
}
export const wikipediaArticles = new WikipediaArticleSource();
+9
View File
@@ -0,0 +1,9 @@
import type { Article } from './protocol.ts';
// Fixed original preview cards for an empty interface; consensus does not mint these.
export const STARTER_ARTICLES: Article[] = [
{ pageId: 22780, title: 'Octopus', extract: 'An octopus is a soft-bodied marine mollusc with eight arms. These remarkable animals use camouflage, problem-solving, and jet propulsion to navigate their world.', category: 'Nature', revisionId: 0, source: 'offline', url: 'https://en.wikipedia.org/?curid=22780', image: 'https://upload.wikimedia.org/wikipedia/commons/5/57/Octopus2.jpg' },
{ pageId: 736, title: 'Albert Einstein', extract: 'Albert Einstein was a theoretical physicist whose work on relativity transformed our understanding of space, time, and gravity. He received the 1921 Nobel Prize in Physics.', category: 'People', revisionId: 0, source: 'offline', url: 'https://en.wikipedia.org/?curid=736', image: 'https://upload.wikimedia.org/wikipedia/commons/2/28/Albert_Einstein_Head_cleaned.jpg' },
{ pageId: 19331, title: 'Moon', extract: 'The Moon is Earth’s natural satellite. Its cratered surface records billions of years of impacts, and its gravitational influence helps drive the ocean tides.', category: 'Space', revisionId: 0, source: 'offline', url: 'https://en.wikipedia.org/?curid=19331', image: 'https://upload.wikimedia.org/wikipedia/commons/e/e1/FullMoon2010.jpg' },
{ pageId: 23053, title: 'Periodic table', extract: 'The periodic table arranges chemical elements by atomic number and recurring properties. Its structure reveals patterns in the building blocks of matter.', category: 'Science', revisionId: 0, source: 'offline', url: 'https://en.wikipedia.org/?curid=23053', image: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Colour_18-col_PT_with_labels.svg/960px-Colour_18-col_PT_with_labels.svg.png' },
];
+16
View File
@@ -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<ChainIdentity> | undefined;
/** Derive the same public manifest as the node from this immutable browser build. */
export function expectedChainIdentity(): Promise<ChainIdentity> {
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;
}
+59
View File
@@ -0,0 +1,59 @@
/** Live, off-chain Wikipedia data. Text fields are plain strings, never HTML. */
export interface PageEnrichment {
pageId: number;
title: string;
description: string | null;
wikidataId: string | null;
revisionId: number;
pageBytes: number;
/** Number of returned interlanguage links (a lower bound when truncated). */
languageCount: number;
languagesTruncated: boolean;
categories: string[];
categoriesTruncated: boolean;
fetchedAt: string;
pageviews: {
status: 'available' | 'unavailable';
/** Six completed UTC months, oldest first, identified as YYYY-MM. */
months: { month: string; views: number }[];
latestMonth: string;
total: number | null;
/** Latest completed month's views divided by its actual UTC day count. */
dailyAverage: number | null;
trendPercent: number | null;
};
stats: { attention: number | null; depth: number; reach: number };
}
/** Immutable numeric Wikipedia metrics committed to a minted block. */
export type MintStatsSnapshot = {
capturedAt: string;
revisionId: number;
pageBytes: number;
languageCount: number;
languagesTruncated: boolean;
pageviews: PageEnrichment['pageviews'];
stats: PageEnrichment['stats'];
};
export function snapshotStats(data: PageEnrichment): MintStatsSnapshot {
return {
capturedAt: new Date().toISOString(),
revisionId: data.revisionId,
pageBytes: data.pageBytes,
languageCount: data.languageCount,
languagesTruncated: data.languagesTruncated,
pageviews: { ...data.pageviews, months: data.pageviews.months.map(item => ({ ...item })) },
stats: { ...data.stats },
};
}
/** Attention uses the latest completed month's views, not the six-month total. */
export function deriveStats(pageBytes: number, languageCount: number, monthlyViews: number | null): PageEnrichment['stats'] {
const score = (value: number, scale: number) => {
if (!Number.isFinite(value) || value < 0) throw new RangeError('Invalid enrichment score input');
return Math.min(100, Math.round(Math.log10(value + 1) / scale * 100));
};
return { attention: monthlyViews === null ? null : score(monthlyViews, 6),
depth: score(pageBytes, 6), reach: score(languageCount, 3) };
}
+41
View File
@@ -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';
+46
View File
@@ -0,0 +1,46 @@
export const POW = { memorySize: 16384, iterations: 2, parallelism: 1, hashLength: 32 } as const;
export const DIFFICULTY = 1; // One leading hex zero: ~16 attempts, intentionally approachable demo difficulty.
export const GENESIS = '0'.repeat(64);
export type Article = {
pageId: number; title: string; extract: string; image?: string; url: string;
revisionId: number; source: 'live' | 'offline'; category: string;
};
export type Challenge = {
height: number; previousHash: string; seed: string; article: Article;
difficulty: number; createdAt: number;
};
export type Block = Challenge & {
hash: string; nonce: number; owner: string; publicKey: string; signature: string;
};
export type Transfer = {
cardHash: string; from: string; to: string; sequence: number;
publicKey: string; signature: string;
};
export type ChainState = { blocks: Block[]; transfers: Transfer[] };
export function proofMessage(c: Challenge, owner: string, nonce: number): string {
// Fixed-order tuple; the complete immutable article snapshot is committed by the proof.
return JSON.stringify(['wikideck:pow:v1', c.height, c.previousHash, c.seed,
c.article.pageId, c.article.revisionId, c.article.title, c.article.extract,
c.article.image ?? '', c.article.url, c.article.source, c.article.category,
c.difficulty, c.createdAt, owner, nonce]);
}
export function claimMessage(c: Challenge, owner: string, nonce: number, hash: string): string {
return JSON.stringify(['wikideck:claim:v1', proofMessage(c, owner, nonce), hash]);
}
export function transferMessage(t: Pick<Transfer, 'cardHash' | 'from' | 'to' | 'sequence'>): string {
return JSON.stringify(['wikideck:transfer:v1', t.cardHash, t.from, t.to, t.sequence]);
}
export function ownerOf(state: ChainState, hash: string): string | undefined {
let owner = state.blocks.find(b => b.hash === hash)?.owner;
for (const t of state.transfers) if (t.cardHash === hash) owner = t.to;
return owner;
}
export function rarity(hash: string): 'Legendary' | 'Epic' | 'Rare' | 'Common' {
const roll = parseInt(hash.slice(-4), 16) % 100;
return roll < 3 ? 'Legendary' : roll < 15 ? 'Epic' : roll < 40 ? 'Rare' : 'Common';
}
export function stats(hash: string) {
return { power: 30 + parseInt(hash.slice(8, 10), 16) % 70,
defense: 30 + parseInt(hash.slice(10, 12), 16) % 70,
knowledge: 30 + parseInt(hash.slice(12, 14), 16) % 70 };
}
+105
View File
@@ -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<TestnetSnapshot> | 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<T>(path: string, signal: AbortSignal): Promise<T> {
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<TestnetSnapshot> {
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<TestnetSummary>('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<CanonicalPage>(`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<TestnetSnapshot> {
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.');
}
}
+96
View File
@@ -0,0 +1,96 @@
import type { Article } from './protocol.ts';
import type { MintStatsSnapshot } from './enrichment.ts';
import { NETWORK } from './network.ts';
export { NETWORK } from './network.ts';
export const CONSENSUS = {
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, 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: 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: TestnetTransaction[];
};
export type MiningTemplate = {
header: BlockHeader; transactions: TestnetTransaction[]; blob: string; nonceOffset: number;
};
export type NftState = Record<string, { owner: string; sequence: number; mintedHeight: number; pageId: number }>;
/** 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: TestnetTransaction[]; pendingTransfers: TestnetTransaction[];
peers: number; algorithm: 'RandomX'; maturity: number;
};
export type TestnetSummary = Omit<TestnetSnapshot, 'blocks' | 'transfers'> & { 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<NftTransfer, 'publicKey' | 'signature'>): string {
return JSON.stringify([NETWORK, 'nft-transfer', t.chainId, t.cardHash, t.from, t.to, t.sequence]);
}
export function nftTradeMessage(t: Pick<NftTrade, 'network' | 'chainId' | 'proposer' | 'counterparty' | 'offered' | 'requested'>): 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];
}
export function statsSnapshotTuple(s: MintStatsSnapshot): unknown[] {
return [s.capturedAt, s.revisionId, s.pageBytes, s.languageCount, s.languagesTruncated,
[s.pageviews.status, s.pageviews.months.map(item => [item.month, item.views]), s.pageviews.latestMonth,
s.pageviews.total, s.pageviews.dailyAverage, s.pageviews.trendPercent],
[s.stats.attention, s.stats.depth, s.stats.reach]];
}
export function headerMessage(h: BlockHeader): string {
return JSON.stringify([NETWORK, h.version, h.chainId, h.height, h.previousHash, h.createdAt,
articleTuple(h.article), statsSnapshotTuple(h.statsSnapshot), h.owner, h.publicKey,
h.target, h.key, h.transactionsRoot, h.stateRoot]);
}
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<TestnetSnapshot, 'blocks' | 'transfers'>, hash: string): string | undefined {
let owner = state.blocks.find(b => b.tokenId === hash)?.owner;
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;
}
+24
View File
@@ -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<typeof tradeOfferSchema>;
+164
View File
@@ -0,0 +1,164 @@
// Browser/Node WebCrypto only: this module never reads storage or sends data.
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();
type WalletBackupBase = {
format: 'wikideck-wallet-backup';
network: typeof NETWORK;
kdf: 'PBKDF2-SHA256';
iterations: 600000;
cipher: 'AES-256-GCM';
salt: string;
iv: string;
publicKey: string;
ciphertext: string;
};
export type WalletBackup = WalletBackupBase & (
| { version: 1; chainId?: never }
| { version: 2; chainId: string }
);
function encode(bytes: ArrayBuffer | Uint8Array<ArrayBuffer>): string {
return btoa(String.fromCharCode(...new Uint8Array(bytes)));
}
function decode(value: unknown, min: number, max: number): Uint8Array<ArrayBuffer> {
if (typeof value !== 'string' || value.length > Math.ceil(max / 3) * 4 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
throw new Error('Invalid backup encoding.');
}
const bytes = Uint8Array.from(atob(value), char => char.charCodeAt(0));
if (bytes.length < min || bytes.length > max || encode(bytes) !== value) {
throw new Error('Invalid backup field size or encoding.');
}
return bytes;
}
function passwordBytes(password: string, minimum: number): Uint8Array<ArrayBuffer> {
if (typeof password !== 'string' || password.length < minimum || password.length > 1024) {
throw new Error(`Password must contain ${minimum}–1024 characters.`);
}
return encoder.encode(password);
}
async function encryptionKey(password: string, salt: Uint8Array<ArrayBuffer>, usage: 'encrypt' | 'decrypt') {
const bytes = passwordBytes(password, usage === 'encrypt' ? 12 : 1);
try {
const material = await crypto.subtle.importKey('raw', bytes, 'PBKDF2', false, ['deriveKey']);
return await crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: ITERATIONS }, material,
{ name: 'AES-GCM', length: 256 }, false, [usage],
);
} finally { bytes.fill(0); }
}
// Fixed-order AAD authenticates every header field, including identity/network.
function additionalData(backup: WalletBackup): Uint8Array<ArrayBuffer> {
return encoder.encode(JSON.stringify([
backup.format, backup.version, backup.network,
...(backup.version === 2 ? [backup.chainId] : []), backup.kdf, backup.iterations,
backup.cipher, backup.salt, backup.iv, backup.publicKey,
]));
}
async function validateBackup(value: unknown): Promise<WalletBackup> {
if (!value || typeof value !== 'object' || Array.isArray(value) ||
![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
throw new Error('Invalid wallet backup.');
}
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.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.
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<string> {
if (!privateKey || privateKey.type !== 'private' || privateKey.algorithm.name !== 'ECDSA' ||
(privateKey.algorithm as EcKeyAlgorithm).namedCurve !== 'P-256' ||
!privateKey.extractable || !privateKey.usages.includes('sign')) {
throw new Error('An exportable P-256 ECDSA signing key is required.');
}
const spki = decode(publicKey, 91, 91);
const verifyingKey = await crypto.subtle.importKey('spki', spki,
{ name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']);
if (encode(await crypto.subtle.exportKey('spki', verifyingKey)) !== publicKey) {
throw new Error('Public key must be canonical SPKI DER.');
}
const challenge = crypto.getRandomValues(new Uint8Array(32));
const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, challenge);
if (!await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, verifyingKey, signature, challenge)) {
throw new Error('Wallet key pair does not match.');
}
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', spki));
return Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('');
}
export async function encryptWallet(privateKey: CryptoKey, publicKey: string, password: string): Promise<WalletBackup> {
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: 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');
const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', privateKey));
try {
backup.ciphertext = encode(await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv, additionalData: additionalData(backup), tagLength: 128 }, key, pkcs8,
));
return validateBackup(backup);
} finally { pkcs8.fill(0); }
}
export async function decryptWallet(backup: unknown, password: string): Promise<{
privateKey: CryptoKey; publicKey: string; address: string;
}> {
const validated = await validateBackup(backup); // Bound work before PBKDF2.
const key = await encryptionKey(password, decode(validated.salt, 16, 16), 'decrypt');
let plaintext: Uint8Array<ArrayBuffer> | undefined;
try {
plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM',
iv: decode(validated.iv, 12, 12), additionalData: additionalData(validated), tagLength: 128,
}, key, decode(validated.ciphertext, 17, 4096)));
const privateKey = await crypto.subtle.importKey('pkcs8', plaintext,
{ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign']);
const address = await validatePair(privateKey, validated.publicKey);
return { privateKey, publicKey: validated.publicKey, address };
} catch {
throw new Error('Cannot unlock backup: incorrect password, damaged backup, or mismatched wallet keys.');
} finally { plaintext?.fill(0); }
}
+9
View File
File diff suppressed because one or more lines are too long
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useRef, useState } from 'react';
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, isNftTrade, nftTransferMessage, testnetOwnerOf, transactionCardIds,
type TestnetBlock, type TestnetSnapshot } from '../shared/testnet.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' | '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<T>(path: string, body?: unknown, expectedStatus?: number): Promise<T> {
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 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 node RPC response. Refresh before trying again.');
return data;
}
// Only canonical, mined transfers determine ownership and the next signature sequence.
function transferProblem(state: TestnetSnapshot, card: CardData, address: string): string {
const mint = card.block;
const block = mint && state.blocks.find(b => b.tokenId === mint.tokenId && b.hash === mint.hash);
if (!block) return 'This NFT is no longer on the canonical chain. Refresh your collection.';
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(tx => transactionCardIds(tx).includes(block.tokenId))) return 'A transfer or trade involving this NFT is already pending mining.';
return '';
}
export default function App() {
const [view, setView] = useState<View>('Discover');
const [wallet, setWallet] = useState<Wallet>();
const [snapshot, setSnapshot] = useState<TestnetSnapshot>();
const [rpcAvailable, setRpcAvailable] = useState(false);
const [error, setError] = useState('');
const [toast, setToast] = useState('');
const [dialog, setDialog] = useState<'guide' | 'wallet' | 'transfer' | null>(null);
const [selected, setSelected] = useState<CardData>();
const [query, setQuery] = useState('');
const [category, setCategory] = useState('All cards');
const [recipient, setRecipient] = useState('');
const [transferBusy, setTransferBusy] = useState(false);
const [transferError, setTransferError] = useState('');
const transferInFlight = useRef(false);
const refreshVersion = useRef(0);
const minerPanel = useRef<HTMLElement>(null);
const stateClient = useRef(new StateClient());
useModalFocus(Boolean(dialog || selected));
const refresh = async () => {
const version = ++refreshVersion.current;
try {
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 is pinned to ${NETWORK} at genesis ${NETWORK_GENESIS_TIME}.`);
if (version === refreshVersion.current) { setSnapshot(data); setRpcAvailable(true); setError(''); }
return data;
} catch (e) {
if (version === refreshVersion.current) { setRpcAvailable(false); setError(message(e)); }
throw e;
}
};
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; unsubscribe(); stopLifecycle(); clearInterval(timer); refreshVersion.current++; };
}, []);
useEffect(() => {
if (toast) { const timer = setTimeout(() => setToast(''), 8000); return () => clearTimeout(timer); }
}, [toast]);
useEffect(() => {
const escape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !transferInFlight.current) { setDialog(null); setSelected(undefined); setTransferError(''); }
};
document.addEventListener('keydown', escape);
return () => document.removeEventListener('keydown', escape);
}, []);
const transfer = async () => {
if (transferInFlight.current || !wallet || !selected?.block || !snapshot) return;
transferInFlight.current = true; setTransferBusy(true); setTransferError('');
try {
const state = await refresh();
if (state.chainId !== snapshot.chainId) throw new Error('The node chain ID changed. Refresh and review this NFT before signing.');
const problem = transferProblem(state, selected, wallet.address);
if (problem) throw new Error(problem);
const to = recipient.trim().toLowerCase();
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 => 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 => !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(() => {});
} catch (e) { setTransferError(message(e)); }
finally { transferInFlight.current = false; setTransferBusy(false); }
};
const owned = snapshot?.blocks.filter(b => wallet && testnetOwnerOf(snapshot, b.tokenId) === wallet.address) ?? [];
const visibleCards = view === 'My collection' ? owned.map(asCard)
: snapshot?.blocks.length ? [...snapshot.blocks].reverse().slice(0, 12).map(asCard) : samples;
const filtered = visibleCards.filter(c => (category === 'All cards' || c.article.category === category) &&
`${c.article.title} ${c.article.extract}`.toLowerCase().includes(query.toLowerCase()));
const selectedMint = selected?.block;
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(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 <backup.json> --consent --node ${window.location.origin}`;
const close = () => { if (!transferInFlight.current) { setDialog(null); setSelected(undefined); setTransferError(''); } };
const openWallet = () => { setSelected(undefined); setDialog('wallet'); };
const goMine = () => {
setView('Discover');
setTimeout(() => minerPanel.current?.scrollIntoView({ behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'center' }), 50);
};
const copyAddress = async () => {
if (!wallet) return;
try { await navigator.clipboard.writeText(wallet.address); setToast('Wallet address copied.'); }
catch { setToast('Clipboard unavailable. Select and copy your receive address below.'); }
};
const updateWallet = (restored: Wallet) => {
setWallet(restored); setSelected(undefined); setRecipient(''); setTransferError('');
setToast('Wallet restored locally. Refreshing confirmed NFT ownership.');
void refresh().catch(() => {});
};
const retry = async () => {
try { setWallet(await getWallet()); await refresh(); }
catch (e) { setError(message(e)); }
};
return <div className="app-shell">
<aside className="sidebar">
<a href="#" className="brand" onClick={e => { e.preventDefault(); setView('Discover'); }}><span className="brand-mark" aria-hidden="true">W<span>✦</span></span>wikideck<span className="brand-dot">.</span></a>
<div className="sidebar-label">YOUR NEXT DISCOVERY</div>
<nav aria-label="Main navigation">{(['Discover', 'My collection', 'Block explorer', 'Trade cards'] as View[]).map((item, i) => {
const Icon = [LayoutDashboard, Layers3, Globe2, ArrowLeftRight][i];
return <button key={item} aria-label={item} aria-current={view === item ? 'page' : undefined} className={`nav-item ${view === item ? 'active' : ''}`} onClick={() => { setView(item); setQuery(''); setCategory('All cards'); }}><Icon size={18} />{item}{item === 'My collection' && <span className="nav-count">{owned.length}</span>}</button>;
})}</nav>
<div className="sidebar-note"><span className="tiny-spark">✦</span><h3>Mine together.<br />Trade your way.</h3><p>Discover illustrated Wikipedia cards, build a deck around what you love, and find your collector community.</p><button onClick={() => setDialog('guide')}>How it works <ArrowUpRight size={15} /></button></div>
<div className="sidebar-bottom"><span className={`network-dot ${!rpcAvailable || !snapshot?.peers ? 'disconnected' : ''}`} /><span>{peerLabel}</span><span className="prototype">{NETWORK_LABEL}</span></div>
<button className="help-link" onClick={() => setDialog('guide')}><CircleHelp size={16} /> The field guide <ArrowUpRight size={14} /></button>
</aside>
<div className="main-shell">
<header className="topbar"><div className="breadcrumbs">Explore <ChevronRight size={13} /><span>{view}</span></div><div className="top-actions"><span className="testnet-brand">RandomX <b>{NETWORK_LABEL}</b></span><ThemePicker /><span className="network-pill"><span className={`network-dot ${!rpcAvailable || !snapshot?.peers ? 'disconnected' : ''}`} />{peerLabel}</span><button className="wallet-button" onClick={openWallet}><span className="wallet-avatar"><WalletIcon size={14} /></span>{wallet ? short(wallet.address) : 'Open wallet'}<ChevronRight size={14} /></button></div></header>
<main>
<div className="testnet-status" role="status"><span><span className={`network-dot ${rpcAvailable ? '' : 'disconnected'}`} /> RPC {rpcAvailable ? 'available' : 'unavailable'}</span><span>{peerLabel}</span><span>{IS_MAINNET?'MAINNET · experimental · unaudited · no value promised':'TESTNET · experimental · no value promised'}</span>{snapshot && !rpcAvailable && <strong>Showing last known state</strong>}</div>
{error && <div className="error-banner" role="alert"><CircleHelp size={17} /><span>{error}</span><button onClick={retry}>Retry</button><button aria-label="Dismiss error" onClick={() => setError('')}><X size={16} /></button></div>}
{view === 'Discover' && <>
<section className="hero">
<div className="hero-copy"><span className="eyebrow"><span /> MINE · COLLECT · TRADE CARDS</span><h1>Mine with your CPU.<br />Collect <span>the world.</span></h1><p>Turn illustrated Wikipedia discoveries into collectible cards. Mine with RandomX, build a collection around what you love, and trade cards directly with other collectors.</p><div className="hero-actions"><button className="button dark" onClick={goMine}><Pickaxe size={17} /> Explore mining <ArrowUpRight size={17} /></button><button className="text-button" onClick={() => setDialog('guide')}>How it works <ArrowRight size={16} /></button></div><div className="hero-foot"><span><Check size={13} /> CPU-friendly RandomX</span><span><Check size={13} /> Direct card-for-card trades</span></div></div>
<div className="hero-visual"><div className="orbit orbit-one" /><div className="orbit orbit-two" /><span className="orbit-spark spark-one">✳</span><span className="orbit-spark spark-two">+</span><div className="floating-label label-top"><Sparkles size={12} /> Preview cards · not owned</div><div className="hero-card back-card"><Card card={samples[2]} onClick={() => setSelected(samples[2])} /></div><div className="hero-card front-card"><Card card={samples[0]} onClick={() => setSelected(samples[0])} /></div><div className="floating-label label-bottom"><Sparkles size={15} /> A world worth collecting <ArrowUpRight size={13} /></div></div>
</section>
<section className="metrics" aria-label="Canonical chain statistics">
<div><span className="metric-icon"><Layers3 size={19} /></span><div><span>Native NFTs minted</span><strong>{snapshot?.blocks.length.toLocaleString() ?? '—'}<small>canonical chain</small></strong></div></div>
<div><span className="metric-icon"><Globe2 size={19} /></span><div><span>Block height</span><strong>{snapshot ? `#${snapshot.blocks.at(-1)?.height ?? 0}` : '—'}<small>current tip</small></strong></div></div>
<div><span className="metric-icon"><WalletIcon size={19} /></span><div><span>Confirmed ownership</span><strong>{wallet && snapshot ? owned.length : '—'}<small>in your wallet</small></strong></div></div>
<div><span className="metric-icon"><Cpu size={19} /></span><div><span>Proof of work</span><strong className="algorithm">{snapshot?.algorithm ?? 'RandomX'}<small>native CPU mining</small></strong></div></div>
</section>
<section className="mining-panel native-mining" ref={minerPanel}>
<div className="mining-description"><div className="section-eyebrow"><span className="idle-dot" /> RANDOMX · DESIGNED FOR GENERAL-PURPOSE CPUS</div><h2>Mining should be for more people.</h2><p>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.</p><div className="mining-meta"><span><Cpu size={13} /> Mine with a CPU</span><span><Shield size={13} /> Independently validating peers</span></div><details className="mining-details"><summary>Hardware, rewards &amp; network details</summary><div className="native-memory"><strong>Light · default</strong><span>256 MiB cache plus scratchpad and process overhead.</span><strong>Fast · --fast</strong><span>About 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.</span></div></details></div>
<div className="miner-controls"><div className="miner-display"><div><span>NEXT WIKIPEDIA ARTICLE</span><strong>{snapshot ? 'Chosen when the miner requests work' : `Waiting for ${NETWORK_LABEL} RPC…`}</strong></div></div><p className="muted">The miner selects a random illustrated English Wikipedia article. The same article can appear again; each card keeps its own mint-time statistics.</p><details className="mining-details"><summary>Want to run a miner? Setup &amp; safety details</summary><ol className="native-steps"><li>Build <code>wikideck-randomx</code> using the repository’s native build instructions.</li><li>Save your encrypted backup and keep its password separate.</li><li>Replace <code>&lt;backup.json&gt;</code> with your local file path (quote paths with spaces), then run:</li></ol><pre className="native-command"><code>{nativeCommand}</code></pre><p className="miner-footnote">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.</p></details><button className="button dark full" onClick={openWallet}><Download size={16} /> Download wallet backup</button><button className="text-button" onClick={() => setDialog('guide')}>Read the {NETWORK_LABEL.toLowerCase()} field guide <ArrowRight size={14} /></button></div>
</section>
<section className="project-pillars" aria-label="Mine, collect, and trade cards">
<article><Pickaxe size={22}/><div className="section-eyebrow">MINE · EARN A CARD</div><h2>Discover the next card.</h2><p>Run the native RandomX miner to compete for blocks. Each accepted block rewards its miner with a distinct illustrated Wikipedia card.</p><button className="text-button" onClick={goMine}>Mining setup <ArrowRight size={14}/></button></article>
<article><Layers3 size={22}/><div className="section-eyebrow">COLLECT · BUILD YOUR DECK</div><h2>Make every collection your own.</h2><p>Browse discoveries on the chain, keep cards in your local wallet, and follow the subjects that interest you.</p><button className="text-button" onClick={()=>setView('My collection')}>Open my collection <ArrowRight size={14}/></button></article>
<article><ArrowLeftRight size={22}/><div className="section-eyebrow">TRADE · PLAYER TO PLAYER</div><h2>Swap cards directly.</h2><p>Prepare a signed offer for another collector and exchange card bundles atomically when the trade is mined. No prices or checkout.</p><button className="text-button" onClick={()=>setView('Trade cards')}>Trade cards <ArrowRight size={14}/></button></article>
</section>
</>}
{view === 'Trade cards' ? <TradeDesk wallet={wallet} snapshot={snapshot} rpcAvailable={rpcAvailable} onNotice={setToast} /> : view !== 'Block explorer' ? <section className="discover-section">
<div className="section-heading"><div><div className="section-eyebrow">{view === 'My collection' ? 'YOUR PERSONAL ENCYCLOPEDIA' : 'AN ENCYCLOPEDIA, REIMAGINED'}</div><h2>{view === 'My collection' ? 'Your cabinet of curiosities' : 'A world of possible discoveries'}<span className="count-badge">{visibleCards.length}</span></h2><p>{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.'}</p></div>{view === 'Discover' && <button className="text-button" onClick={() => setView('My collection')}>My collection <ArrowRight size={16} /></button>}</div>
<div className="filter-bar"><div className="category-tabs">{['All cards', 'Nature', 'Space', 'Science', 'People', 'History', 'Culture'].map(c => <button key={c} className={category === c ? 'selected' : ''} onClick={() => setCategory(c)}>{c}</button>)}</div><label className="search-input"><Search size={15} /><input aria-label="Search cards" placeholder="Find a discovery…" value={query} onChange={e => setQuery(e.target.value)} /></label></div>
{filtered.length ? <div className="card-grid">{filtered.map(c => <div className="card-wrap" key={c.block?.tokenId ?? c.hash}><Card card={c} onClick={() => { setSelected(c); setTransferError(''); }} /><div className="card-list-caption"><span>{c.block ? `Block #${c.block.height}` : 'Example discovery'}</span><span className="caption-dot" />{c.block && snapshot ? short(testnetOwnerOf(snapshot, c.block.tokenId) ?? '') : 'Not owned'}</div>{c.block && snapshot?.pendingTransfers.some(tx => transactionCardIds(tx).includes(c.block!.tokenId)) && <span className="pending-label">Transfer or trade pending mining</span>}</div>)}</div> : <div className="empty-state"><img className="empty-state-logo" src="/original-logo.svg" alt="Wikideck" /><h3>{query || category !== 'All cards' ? 'No discoveries here. Yet.' : 'Your first discovery is waiting.'}</h3><p>{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.'}</p><button className="button dark" onClick={goMine}><Pickaxe size={16} /> Explore native mining</button></div>}
</section> : <section className="explorer-section"><div className="section-eyebrow">FOLLOW THE CANONICAL CHAIN</div><div className="section-heading"><div><h1>Block explorer<span className="count-badge">{snapshot?.blocks.length ?? 0}</span></h1><p>Native NFT mints, RandomX proofs, and mined ownership transfers.</p></div><a className="button outline" href="/api/chain" target="_blank" rel="noreferrer">View full chain JSON <ExternalLink size={15} /></a></div>
<div className="explorer-note"><Shield size={18} /><p>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.</p></div>
<dl className="chain-summary"><div><dt>Cumulative work</dt><dd>{snapshot?.cumulativeWork ?? '—'}</dd></div><div><dt>Peer count</dt><dd>{rpcAvailable ? snapshot?.peers ?? '—' : 'Unknown'} · {peerLabel}</dd></div><div><dt>Chain ID · pinned genesis</dt><dd>{snapshot?.chainId ?? '—'}</dd></div><div><dt>Network</dt><dd>{NETWORK}</dd></div><div><dt>Tip block ID</dt><dd>{snapshot?.tip ?? '—'}</dd></div><div><dt>Mint maturity</dt><dd>{snapshot?.maturity ?? CONSENSUS.coinbaseMaturity} subsequent blocks at inclusion height</dd></div></dl>
{snapshot?.blocks.length ? <div className="table-scroll"><table><thead><tr><th>Block / confirmations</th><th>Discovery</th><th>Block ID / NFT token ID</th><th>Confirmed owner</th><th>RandomX proof / rarity</th></tr></thead><tbody>{[...snapshot.blocks].reverse().map(b => <tr key={b.hash}><td><button className="explorer-card-link" onClick={() => setSelected(asCard(b))}>#{b.height.toString().padStart(4, '0')}</button><small>{snapshot.blocks.length - b.height + 1} confirmations</small></td><td><strong>{b.article.title}</strong><small>Wikipedia #{b.article.pageId}</small></td><td className="mono"><span title={b.hash}>Block {short(b.hash)}</span><a className="nft-link" href={`/api/nfts/${encodeURIComponent(b.tokenId)}`} target="_blank" rel="noreferrer" title={b.tokenId}>NFT {short(b.tokenId)} <ExternalLink size={11} /></a></td><td className="mono" title={testnetOwnerOf(snapshot, b.tokenId)}>{short(testnetOwnerOf(snapshot, b.tokenId) ?? '')}</td><td><span className="proof-caption mono" title={b.powHash}>{short(b.powHash)}</span><span className={`rarity-tag ${rarity(b.powHash).toLowerCase()}`}>{rarity(b.powHash)}</span></td></tr>)}</tbody></table></div> : <div className="empty-state"><img className="empty-state-logo" src="/original-logo.svg" alt="Wikideck" /><h3>A new chain. A blank page.</h3><p>No NFT blocks loaded yet. Mine a block to earn the next Wikipedia card.</p><button className="button dark" onClick={goMine}>Explore RandomX mining <ArrowRight size={16} /></button></div>}
<h2 className="transfer-title">Mined transfers and trades</h2>{snapshot?.transfers.length ? <div className="journal">{snapshot.transfers.map((tx, i) => isNftTrade(tx)
? <div key={`trade-${tx.proposerSignature}`}><ArrowLeftRight size={18}/><span>{tx.offered.length} cards {short(tx.proposer)} ⇄ {tx.requested.length} cards {short(tx.counterparty)}</span><span>Atomic trade · mined #{snapshot.blocks.find(b => b.transactions.some(item => isNftTrade(item) && item.proposerSignature === tx.proposerSignature))?.height ?? '—'}</span><Check size={14} aria-label={`Mined trade ${i + 1}`}/></div>
: <div key={`${tx.cardHash}-${tx.sequence}`}><ArrowDownLeft size={18}/><a href={`/api/nfts/${encodeURIComponent(tx.cardHash)}`} target="_blank" rel="noreferrer" title={tx.cardHash}>{short(tx.cardHash)}</a><span>{short(tx.from)} → {short(tx.to)}</span><span>Sequence {tx.sequence} · mined #{snapshot.blocks.find(b => b.transactions.some(item => !isNftTrade(item) && item.signature === tx.signature))?.height ?? '—'}</span><Check size={14} aria-label={`Mined transfer ${i + 1}`}/></div>)}</div> : <p className="muted">No mined transfers or trades.</p>}
<h2 className="transfer-title">Pending transfers and trades <span className="count-badge">{snapshot?.pendingTransfers.length ?? 0}</span></h2><p className="muted">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.</p><div className="journal">{snapshot?.pendingTransfers.map((tx, i) => isNftTrade(tx)
? <div key={`pending-trade-${tx.proposerSignature}`}><LoaderCircle size={16}/><span>{tx.offered.length} cards {short(tx.proposer)} ⇄ {tx.requested.length} cards {short(tx.counterparty)}</span><span className="pending-label">Atomic trade · awaiting mining</span></div>
: <div key={`${tx.cardHash}-${tx.sequence}`}><LoaderCircle size={16}/><span title={tx.cardHash}>{short(tx.cardHash)}</span><span>{short(tx.from)} → {short(tx.to)}</span><span className="pending-label">Awaiting mining</span></div>)}</div>
</section>}
<footer><a href="https://en.wikipedia.org" target="_blank" rel="noreferrer"><BookOpen size={16} /> Wikipedia pages. Collectible discoveries.</a><span>Mine. Collect. Trade.<span className="footer-star">✦</span></span><p>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.</p></footer>
</main>
</div>
{toast && <div className="toast" role="status"><span><Check size={16} /></span>{toast}<button onClick={() => setToast('')} aria-label="Dismiss notification"><X size={14} /></button></div>}
{(dialog || selected) && <div className="modal-backdrop" onClick={e => { if (e.target === e.currentTarget) close(); }}><section className={`modal ${dialog === 'wallet' ? 'wallet-modal' : selected && !dialog ? 'card-modal' : ''}`} role="dialog" aria-modal="true" aria-label={dialog === 'wallet' ? `Local ${NETWORK_LABEL} wallet` : dialog === 'transfer' ? 'Transfer native NFT' : dialog === 'guide' ? 'How Wikideck works' : 'Card details'}><button className="modal-close" aria-label="Close dialog" onClick={close} disabled={transferBusy}><X size={20} /></button>
{dialog === 'guide' ? <>
<span className="modal-icon"><Sparkles size={24}/></span><div className="section-eyebrow">THE WIKIDECK FIELD GUIDE</div><h2>Mine. Collect. Trade cards.</h2>
<div className="guide-steps">
<div><span>01</span><div><h3>Mine with a CPU. Discover a card.</h3><p>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.</p><details><summary>Mining, rewards &amp; network facts</summary><p>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.</p></details></div></div>
<div><span>02</span><div><h3>Keep your collection in your wallet.</h3><p>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.</p><details><summary>Wallet and ownership basics</summary><p>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.</p></details></div></div>
<div><span>03</span><div><h3>Trade cards directly with another collector.</h3><p>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.</p><details><summary>What happens when I accept?</summary><p>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.</p></details></div></div>
</div>
<div className="info-box"><strong>Collectible snapshots—not ownership of Wikipedia.</strong><p>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.</p></div>
<button className="button dark full" onClick={() => { close(); goMine(); }}>Explore RandomX mining <ArrowRight size={16}/></button>
</>
: dialog === 'wallet' ? <><span className="modal-icon"><WalletIcon size={24} /></span><div className="section-eyebrow">YOUR LOCAL {NETWORK_LABEL} IDENTITY</div><h2>A wallet for your wonders.</h2><p className="muted">No server login. This recoverable wallet controls native NFTs through local signatures. It is separate from the original demo wallet.</p><label className="field-label">YOUR RECEIVE ADDRESS</label><div className="address-box">{wallet?.address ?? 'Wallet unavailable. Enable local browser storage and retry.'}</div><button className="button dark full" onClick={copyAddress} disabled={!wallet}><Copy size={16} /> Copy wallet address</button><div className="wallet-recovery"><WalletPanel wallet={wallet} onRestore={updateWallet} onNotice={setToast} /></div><p className="wallet-count"><Layers3 size={17} /> {owned.length} confirmed native NFTs in your collection</p></>
: dialog === 'transfer' ? <><span className="modal-icon"><ArrowUpRight size={24} /></span><div className="section-eyebrow">PASS ON THE CURIOSITY</div><h2>Send {selected?.article.title}.</h2><p className="muted">Use the recipient’s 64-character wallet receive address on this same network. Signing submits a pending transaction; it does not immediately transfer ownership.</p><label className="field-label">NFT TOKEN ID</label><div className="address-box">{selected?.block?.tokenId}</div><label className="field-label" htmlFor="recipient">RECIPIENT ADDRESS</label><input className="recipient-input" id="recipient" value={recipient} disabled={transferBusy} onChange={e => setRecipient(e.target.value)} placeholder="Paste a 64-character wallet address" autoComplete="off" maxLength={64} /><div className="info-box"><strong>Review before signing.</strong><p>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.</p></div>{(!rpcAvailable || transferDisabledReason) && <p className="transfer-error" role="alert">{!rpcAvailable ? 'RPC unavailable. Reconnect before signing.' : transferDisabledReason}</p>}{transferError && <p className="transfer-error" role="alert">{transferError}</p>}<button className="button dark full" disabled={transferBusy || !rpcAvailable || !!transferDisabledReason || !/^[a-fA-F0-9]{64}$/.test(recipient.trim()) || recipient.trim().toLowerCase() === wallet?.address} onClick={transfer}>{transferBusy ? <LoaderCircle className="spin" size={16} /> : <ArrowUpRight size={16} />}{transferBusy ? 'Signing & submitting…' : 'Sign & submit pending transfer'}</button></>
: selected && <><div className="detail-card"><Card card={selected} /></div><div className="detail-copy"><div className="section-eyebrow">{selected.block ? `MINTED IN BLOCK #${selected.block.height}` : 'EXAMPLE CARD · NOT OWNED'}</div><h2>{selected.article.title}</h2><p>{selected.article.extract}</p><div className="detail-facts"><span>Page ID<strong>#{selected.article.pageId}</strong></span><span>Rarity<strong>{rarity(selected.hash)}</strong></span><span>Content<strong>{selected.article.source === 'live' ? `Pinned revision ${selected.article.revisionId}` : 'Sample preview'}</strong></span></div>{selected.block && <><label className="field-label">NATIVE NFT TOKEN ID</label><div className="address-box">{selected.block.tokenId}</div><label className="field-label">BLOCK ID</label><div className="address-box">{selected.block.hash}</div><label className="field-label">RANDOMX PROOF HASH · GAME STATS</label><div className="address-box">{selected.hash}</div><p className="muted">{confirmations} canonical confirmations{!rpcAvailable ? ' · last known state' : ''}. {selectedBlock ? `Confirmed owner: ${selectedOwner}` : 'This mint is not on the loaded canonical chain.'}</p>{pending && <p className="pending-label">Transfer pending mining · confirmed owner unchanged.</p>}</>}<div className="detail-links"><a href={selected.article.url} target="_blank" rel="noreferrer">Read on Wikipedia <ExternalLink size={14} /></a><a href={imageCredit(selected.article)} target="_blank" rel="noreferrer">Image & license <ExternalLink size={14} /></a>{selected.block && <a href={`/api/nfts/${encodeURIComponent(selected.block.tokenId)}`} target="_blank" rel="noreferrer">NFT metadata <ExternalLink size={14} /></a>}</div><p className="attribution">{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.</p>{selected.block && selectedOwner === wallet?.address && <>{transferDisabledReason && <p className="muted">{transferDisabledReason}</p>}<button className="button dark full" disabled={!rpcAvailable || !!transferDisabledReason} onClick={() => { setRecipient(''); setTransferError(''); setDialog('transfer'); }}>Transfer native NFT <ArrowUpRight size={16} /></button></>}</div></>}
</section></div>}
</div>;
}
+37
View File
@@ -0,0 +1,37 @@
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';
import { useEnrichment } from './useEnrichment.ts';
import { CardInsights, EnrichmentScores, MintStatsScores } from './CardInsights.tsx';
// hash is the RandomX proof hash for game stats; block.tokenId is NFT identity.
export type CardData = { article: Article; hash: string; block?: TestnetBlock };
export const samples: CardData[] = STARTER_ARTICLES.map((article, i) => ({ article,
hash: ['000eca7498a26d1908613726da6e869d7c9c936f4a461cb439ef24672b9a0008',
'000b1a387a067a7d698677965ca1ca7b0eb5e590811431bdb9aad62268180001',
'0008a4fa5893e9ddbd679cbcc7f0938706aa70aab8c8af42a4bc67a42520001',
'0009a4fa5893e9ddbd679cbcc7f0938706aa70aab8c8af42a4bc67a42520020'][i] }));
export function imageCredit(article: Article): string {
if (!article.image) return article.url;
const parts = new URL(article.image).pathname.split('/');
return `https://commons.wikimedia.org/wiki/File:${parts.includes('thumb') ? parts.at(-2)! : parts.at(-1)!}`;
}
export function Card({ card, onClick }: { card: CardData; onClick?: () => void }) {
const { article, hash } = card;
const tier = rarity(hash);
const s = stats(hash);
const {data:enrichment,error}=useEnrichment(article.pageId);
return <><button className={`wiki-card ${tier.toLowerCase()}`} onClick={onClick} tabIndex={onClick ? 0 : -1} aria-label={`View ${article.title} ${tier} card`}>
<div className="card-top"><span><Sparkles size={11} /> {tier}</span><span>W / {String(article.pageId).slice(-4)}</span></div>
<div className={`card-art art-${article.category.toLowerCase()}`}>
{article.image ? <img src={article.image} alt={article.title} loading="lazy" referrerPolicy="no-referrer" onError={e => { e.currentTarget.style.display = 'none'; }} /> : <img className="card-placeholder-logo" src="/original-logo.svg" alt="Wikideck" />}
<span className="card-category">{article.category}</span>
</div>
<div className="card-content"><div className="card-title"><h3>{article.title}</h3><ArrowUpRight size={16} /></div><p>{article.extract}</p>
<div className="card-stats" aria-label="Immutable proof-derived game stats"><span title="Power · proof-derived"><Swords size={12} />{s.power}</span><span title="Defense · proof-derived"><Shield size={12} />{s.defense}</span><span title="Knowledge · proof-derived"><BookOpen size={12} />{s.knowledge}</span></div>
<div className="card-enrichment">{enrichment?<><div className="card-readership"><span>CURRENT WIKIPEDIA</span><span>{enrichment.pageviews.status==='available'?`${new Intl.NumberFormat('en',{notation:'compact',maximumFractionDigits:1}).format(enrichment.pageviews.months.at(-1)!.views)} views / month`:'Views unavailable'}</span></div><EnrichmentScores data={enrichment}/></>:<span className="card-enrichment-status">{error?'Current metadata unavailable':'Loading article insights…'}</span>}{card.block&&<MintStatsScores snapshot={card.block.statsSnapshot} compactView/>}</div>
</div>
<div className="card-bottom"><span>WIKIPEDIA · #{article.pageId}</span><span>{card.block ? `BLOCK ${card.block.height}` : 'PREVIEW'}</span></div>
</button>{!onClick&&<CardInsights pageId={article.pageId} snapshot={card.block?.statsSnapshot}/>}</>;
}
+66
View File
@@ -0,0 +1,66 @@
import { BookOpen, Eye, Globe2, Tags, TrendingUp } from 'lucide-react';
import { useEnrichment } from './useEnrichment.ts';
import type { MintStatsSnapshot, PageEnrichment } from '../shared/enrichment.ts';
import './enrichment.css';
const compact=new Intl.NumberFormat('en',{notation:'compact',maximumFractionDigits:1});
const full=new Intl.NumberFormat('en',{maximumFractionDigits:0});
const month=(value:string)=>new Date(`${value}-01T00:00:00Z`).toLocaleDateString('en',{month:'short',year:'numeric',timeZone:'UTC'});
const score=(value:number|null)=>value===null?'—':`${value}`;
function ScoreStrip({stats,label}: {stats:PageEnrichment['stats'];label:string}){
return <div className="enrichment-scores" aria-label={`${label} Wikipedia-derived scores out of 100`}>
<span title="Attention: logarithmic score from the latest complete month's human page views"><Eye size={12}/><b>{score(stats.attention)}</b><small>Attention</small></span>
<span title="Depth: logarithmic score from article bytes, not a quality judgment"><BookOpen size={12}/><b>{score(stats.depth)}</b><small>Depth</small></span>
<span title="Reach: logarithmic score from interlanguage links"><Globe2 size={12}/><b>{score(stats.reach)}</b><small>Reach</small></span>
</div>;
}
export function EnrichmentScores({data}: {data:PageEnrichment}){
return <ScoreStrip stats={data.stats} label="Current"/>;
}
export function MintStatsScores({snapshot,compactView=false}: {snapshot:MintStatsSnapshot;compactView?:boolean}){
const latest=snapshot.pageviews.months.at(-1);
if(compactView)return <div className="mint-snapshot-compact"><span>AT MINT</span><ScoreStrip stats={snapshot.stats} label="At mint"/><small>{latest?`${compact.format(latest.views)} views · ${snapshot.pageBytes.toLocaleString()} bytes`:'Page views unavailable'} · {new Date(snapshot.capturedAt).toLocaleDateString()}</small></div>;
return <section className="mint-snapshot" aria-label="Wikipedia statistics snapshot at mint">
<div className="insights-heading"><h3>Statistics at mint</h3><span>{new Date(snapshot.capturedAt).toLocaleString()}</span></div>
<ScoreStrip stats={snapshot.stats} label="At mint"/>
<dl className="article-fields">
<div><dt><Eye size={12}/> Monthly views</dt><dd>{latest?full.format(latest.views):'Unavailable'}<small>{month(snapshot.pageviews.latestMonth)} · complete month</small></dd></div>
<div><dt>6-month views</dt><dd>{snapshot.pageviews.total===null?'Unavailable':full.format(snapshot.pageviews.total)}</dd></div>
<div><dt><TrendingUp size={12}/> Monthly change</dt><dd>{snapshot.pageviews.trendPercent===null?'Unavailable':`${snapshot.pageviews.trendPercent>=0?'+':''}${snapshot.pageviews.trendPercent.toFixed(1)}%`}</dd></div>
<div><dt>Daily average</dt><dd>{snapshot.pageviews.dailyAverage===null?'Unavailable':full.format(snapshot.pageviews.dailyAverage)}<small>latest complete month</small></dd></div>
<div><dt>Article size</dt><dd>{full.format(snapshot.pageBytes)} bytes</dd></div>
<div><dt>Languages</dt><dd>{snapshot.languagesTruncated?'≥ ':''}{full.format(snapshot.languageCount)}<small>other-language links</small></dd></div>
<div><dt>Revision at capture</dt><dd><a href={`https://en.wikipedia.org/w/index.php?oldid=${snapshot.revisionId}`} target="_blank" rel="noreferrer">{snapshot.revisionId}</a></dd></div>
</dl>
{snapshot.pageviews.status==='available'?<div className="mint-months" aria-label="Six-month pageview snapshot">{snapshot.pageviews.months.map(item=><span key={item.month}>{month(item.month)}: {full.format(item.views)}</span>)}</div>:<p className="insights-unavailable">Page views were unavailable when this NFT was minted. Missing counts were not recorded as zero.</p>}
</section>;
}
export function CardInsights({pageId,snapshot}:{pageId:number;snapshot?:MintStatsSnapshot}){
const {data,error,retry}=useEnrichment(pageId);
if(!data)return <section className="article-insights" aria-label="Wikipedia insights"><h3>Current Wikipedia statistics</h3><p role="status">{error?'Current metadata is unavailable. The mint-time statistics remain on the NFT.':'Loading current Wikipedia metadata and readership…'}</p>{error&&<button className="text-button" onClick={retry}>Retry live metadata</button>}{snapshot&&<MintStatsScores snapshot={snapshot}/>}</section>;
const views=data.pageviews;
const latest=views.months.at(-1);
const maximum=Math.max(1,...views.months.map(m=>m.views));
const trend=views.trendPercent;
return <section className="article-insights" aria-label="Wikipedia insights">
<div className="insights-heading"><h3>Current Wikipedia statistics</h3><span>LIVE · OFF-CHAIN</span></div>
{data.description&&<p>{data.description}</p>}
<EnrichmentScores data={data}/>
<dl className="article-fields">
<div><dt><Eye size={12}/> Monthly views</dt><dd>{latest?full.format(latest.views):'Unavailable'}<small>{month(views.latestMonth)} · human traffic</small></dd></div>
<div><dt><TrendingUp size={12}/> Monthly change</dt><dd>{trend===null?'Unavailable':`${trend>=0?'+':''}${trend.toFixed(1)}%`}<small>vs. preceding month</small></dd></div>
<div><dt>6-month views</dt><dd>{views.total===null?'Unavailable':full.format(views.total)}</dd></div>
<div><dt>Daily average</dt><dd>{views.dailyAverage===null?'Unavailable':full.format(views.dailyAverage)}<small>latest complete month</small></dd></div>
<div><dt>Article size</dt><dd>{full.format(data.pageBytes)} bytes</dd></div>
<div><dt>Languages</dt><dd>{data.languagesTruncated?'≥ ':''}{full.format(data.languageCount)}<small>other-language links</small></dd></div>
<div><dt>Live revision</dt><dd><a href={`https://en.wikipedia.org/w/index.php?oldid=${data.revisionId}`} target="_blank" rel="noreferrer">{data.revisionId}</a></dd></div>
<div><dt>Wikidata</dt><dd>{data.wikidataId?<a href={`https://www.wikidata.org/wiki/${data.wikidataId}`} target="_blank" rel="noreferrer">{data.wikidataId}</a>:'Not linked'}</dd></div>
</dl>
{views.status==='available'?<figure className="pageview-history"><figcaption>Monthly readership · six complete UTC months</figcaption><div className="pageview-bars">{views.months.map(item=><div key={item.month} title={`${month(item.month)}: ${full.format(item.views)} views`}><span>{compact.format(item.views)}</span><div className="pageview-track"><i style={{height:`${Math.max(1,item.views/maximum*100)}%`}}/></div><small>{month(item.month)}</small></div>)}</div><table className="visually-hidden"><caption>Monthly human page views</caption><tbody>{views.months.map(item=><tr key={item.month}><th>{month(item.month)}</th><td>{item.views}</td></tr>)}</tbody></table></figure>:<p className="insights-unavailable">Page views are unavailable or incomplete. Unknown counts are not treated as zero.</p>}
{snapshot&&<MintStatsScores snapshot={snapshot}/>}
<h4 className="insights-category-heading"><Tags size={13}/> Wikipedia categories{data.categoriesTruncated?' · first 50':''}</h4>
<div className="article-categories">{data.categories.length?data.categories.map(category=><a key={category} href={`https://en.wikipedia.org/wiki/${encodeURIComponent(`Category:${category.replace(/ /g,'_')}`)}`} target="_blank" rel="noreferrer">{category}</a>):<span>No visible categories returned</span>}</div>
<details className="insights-method"><summary>How are these scores calculated?</summary><p>Scores range from 0–100, capped and rounded: Attention = log₁₀(monthly views + 1) ÷ 6 × 100; Depth = log₁₀(article bytes + 1) ÷ 6 × 100; Reach = log₁₀(language links + 1) ÷ 3 × 100. Depth measures size, not accuracy or quality. Scores can change and do not affect proof-derived rarity, NFT ownership, or consensus.</p></details>
<p className="insights-attribution">Retrieved {new Date(data.fetchedAt).toLocaleString()}. Live revision may differ from the minted snapshot. Sources: <a href={`https://en.wikipedia.org/?curid=${pageId}`} target="_blank" rel="noreferrer">Wikipedia contributors</a> (CC BY-SA 4.0) and Wikimedia Analytics (CC0). Views use all-access, user traffic and exclude classified bots. Article moves may affect title-based view history.</p>
</section>;
}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react';
import { Palette } from 'lucide-react';
import { applyTheme, isTheme, resolveTheme, THEMES, THEME_STORAGE_KEY } from './theme.ts';
export function ThemePicker() {
const [theme, setTheme] = useState(() => resolveTheme(document.documentElement.dataset.theme));
useEffect(() => {
const sync = (event: StorageEvent) => {
if (event.key !== THEME_STORAGE_KEY && event.key !== null) return;
const next = resolveTheme(event.newValue);
applyTheme(next);
setTheme(next);
};
window.addEventListener('storage', sync);
return () => window.removeEventListener('storage', sync);
}, []);
const choose = (value: string) => {
// Saved preferences are untrusted: only known palette identifiers may reach the DOM.
if (!isTheme(value)) return;
applyTheme(value);
setTheme(value);
try { localStorage.setItem(THEME_STORAGE_KEY, value); }
catch { /* Theme switching still works for this page when storage is blocked. */ }
};
return <label className="theme-picker" title="Choose your Catppuccin flavor">
<Palette size={15} aria-hidden="true" />
<select aria-label="Catppuccin theme" value={theme} onChange={event => choose(event.target.value)}>
{THEMES.map(option => <option key={option.id} value={option.id}>{option.label}</option>)}
</select>
</label>;
}
+223
View File
@@ -0,0 +1,223 @@
import { useMemo, useState } from 'react';
import { ArrowLeftRight, Check, Clipboard, LoaderCircle, RefreshCw, Send } from 'lucide-react';
import { CONSENSUS, NETWORK, isNftTrade, nftTradeMessage, type NftTrade, type TestnetDeck, type TestnetSnapshot } from '../shared/testnet.ts';
import { tradeOfferSchema, type TradeOffer } from '../shared/trade-offer.ts';
import { sign, type Wallet } from './wallet.ts';
import './trade.css';
import { StateClient } from '../shared/rpc-client.ts';
const stateClient = new StateClient();
type Props = { wallet?: Wallet; snapshot?: TestnetSnapshot; rpcAvailable: boolean; onNotice: (message: string) => void };
type OwnedCard = TestnetDeck['nfts'][number];
const addressPattern = /^[a-f0-9]{64}$/;
const short = (value: string) => `${value.slice(0, 10)}…${value.slice(-6)}`;
async function api<T>(path: string, body?: unknown): Promise<T> {
const response = await fetch(`/api/${path}`, { cache: 'no-store', signal: AbortSignal.timeout(15000),
...(body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) });
const data: unknown = await response.json();
if (!response.ok) throw new Error(data && typeof data === 'object' && 'error' in data && typeof data.error === 'string'
? data.error : 'Trade request failed');
return data as T;
}
async function verifyOfferSignature(offer: TradeOffer): Promise<boolean> {
try {
const bytes = Uint8Array.from(atob(offer.proposerPublicKey), value => value.charCodeAt(0));
const publicKey = await crypto.subtle.importKey('spki', bytes, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']);
const canonical = new Uint8Array(await crypto.subtle.exportKey('spki', publicKey));
const addressBytes = new Uint8Array(await crypto.subtle.digest('SHA-256', canonical));
const address = Array.from(addressBytes, value => value.toString(16).padStart(2, '0')).join('');
if (address !== offer.proposer) return false;
const signature = Uint8Array.from(atob(offer.proposerSignature), value => value.charCodeAt(0));
return await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, publicKey, signature,
new TextEncoder().encode(nftTradeMessage(offer)));
} catch { return false; }
}
function ownershipSequence(state: TestnetSnapshot, tokenId: string): number {
return state.transfers.reduce((count, tx) => count + (
isNftTrade(tx)
? Number(tx.offered.some(leg => leg.cardHash === tokenId) || tx.requested.some(leg => leg.cardHash === tokenId))
: Number(tx.cardHash === tokenId)), 0);
}
function ownsFrom(snapshot: TestnetSnapshot, tokenId: string, owner: string): boolean {
let current = snapshot.blocks.find(block => block.tokenId === tokenId)?.owner;
for (const tx of snapshot.transfers) {
if (isNftTrade(tx)) {
if (tx.offered.some(leg => leg.cardHash === tokenId)) current = tx.counterparty;
if (tx.requested.some(leg => leg.cardHash === tokenId)) current = tx.proposer;
} else if (tx.cardHash === tokenId) current = tx.to;
}
return current === owner;
}
function DeckList({ title, cards, selected, onToggle, disabled }: {
title: string; cards: OwnedCard[]; selected: Set<string>; onToggle: (id: string) => void; disabled?: boolean;
}) {
return <section className="trade-deck-list"><h3>{title}<span>{cards.length}</span></h3>{cards.length ? <div className="trade-card-list">{cards.map(card => {
const id = card.block.tokenId;
return <label className="trade-card-row" key={id}>
<input type="checkbox" checked={selected.has(id)} disabled={disabled} onChange={() => onToggle(id)} />
<img src={card.block.article.image} alt="" loading="lazy" />
<span><strong>{card.block.article.title}</strong><small>#{card.block.height} · {short(id)}{card.confirmations < CONSENSUS.coinbaseMaturity ? ' · immature' : ''}</small></span>
</label>;
})}</div> : <p className="muted">No confirmed cards in this deck.</p>}</section>;
}
export function TradeDesk({ wallet, snapshot, rpcAvailable, onNotice }: Props) {
const [address, setAddress] = useState('');
const [remoteDeck, setRemoteDeck] = useState<TestnetDeck>();
const [offered, setOffered] = useState<Set<string>>(new Set());
const [requested, setRequested] = useState<Set<string>>(new Set());
const [offerText, setOfferText] = useState('');
const [offerError, setOfferError] = useState('');
const [busy, setBusy] = useState(false);
const [loadingDeck, setLoadingDeck] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const parsedOffer = useMemo<TradeOffer | undefined>(() => {
try { return tradeOfferSchema.parse(JSON.parse(offerText)); } catch { return undefined; }
}, [offerText]);
const offerTitle = (id: string) => snapshot?.blocks.find(block => block.tokenId === id)?.article.title ?? short(id);
const myDeck = useMemo<OwnedCard[]>(() => {
if (!wallet || !snapshot) return [];
return snapshot.blocks.filter(block => ownsFrom(snapshot, block.tokenId, wallet.address)).map(block => ({
block, sequence: ownershipSequence(snapshot, block.tokenId),
confirmations: snapshot.blocks.length - block.height + 1,
}));
}, [snapshot, wallet]);
const toggle = (setter: (value: Set<string>) => void, value: Set<string>, id: string) => {
const next = new Set(value);
if (next.has(id)) next.delete(id); else next.add(id);
setter(next);
};
const loadDeck = async () => {
const normalized = address.trim().toLowerCase();
if (!addressPattern.test(normalized)) { setOfferError('Enter a 64-character wallet receive address.'); return; }
if (wallet?.address === normalized) { setOfferError('Enter another player’s address.'); return; }
setLoadingDeck(true); setOfferError(''); setRemoteDeck(undefined); setOffered(new Set()); setRequested(new Set());
try {
const deck = await api<TestnetDeck>(`decks/${normalized}?offset=0&limit=24`);
if (deck.network !== NETWORK || deck.address !== normalized || !Array.isArray(deck.nfts)) throw new Error('The node returned an incompatible deck.');
setRemoteDeck(deck);
onNotice(`Loaded ${deck.nfts.length} of ${deck.total} confirmed cards at chain tip ${short(deck.tip)}.`);
} catch (error) { setOfferError(error instanceof Error ? error.message : 'Could not load that deck.'); }
finally { setLoadingDeck(false); }
};
const loadMore = async () => {
if (!remoteDeck || remoteDeck.next === null || loadingMore) return;
setLoadingMore(true); setOfferError('');
try {
const page = await api<TestnetDeck>(`decks/${remoteDeck.address}?offset=${remoteDeck.next}&limit=${remoteDeck.limit}`);
if (page.chainId !== remoteDeck.chainId || page.tip !== remoteDeck.tip || page.offset !== remoteDeck.next) {
throw new Error('The deck changed while paging. Reload it before continuing.');
}
setRemoteDeck(current => current && current.tip === page.tip
? { ...current, nfts: [...current.nfts, ...page.nfts], next: page.next }
: current);
} catch (error) { setOfferError(error instanceof Error ? error.message : 'Could not load the next deck page.'); }
finally { setLoadingMore(false); }
};
const createOffer = async () => {
if (!wallet || !snapshot || !remoteDeck || offered.size < 1 || requested.size < 1) return;
if (offered.size > CONSENSUS.maxTradeCards || requested.size > CONSENSUS.maxTradeCards) {
setOfferError(`A trade can include at most ${CONSENSUS.maxTradeCards} cards per side.`); return;
}
setBusy(true); setOfferError('');
try {
const fresh = await stateClient.load();
if (fresh.chainId !== snapshot.chainId || remoteDeck.chainId !== fresh.chainId) throw new Error('Chain changed. Refresh and rebuild the trade.');
const offeredLegs = [...offered].map(cardHash => {
const card = myDeck.find(item => item.block.tokenId === cardHash);
if (!card || !ownsFrom(fresh, cardHash, wallet.address)) throw new Error('One of your selected cards is no longer yours.');
if (fresh.blocks.length + 1 - card.block.height < CONSENSUS.coinbaseMaturity) throw new Error(`${card.block.article.title} is not mature enough to trade.`);
if (fresh.pendingTransfers.some(tx => (isNftTrade(tx)
? [...tx.offered, ...tx.requested].some(leg => leg.cardHash === cardHash)
: tx.cardHash === cardHash))) throw new Error(`${card.block.article.title} already has a pending transfer or trade.`);
return { cardHash, sequence: ownershipSequence(fresh, cardHash) };
});
const requestedLegs = [...requested].map(cardHash => {
const card = remoteDeck.nfts.find(item => item.block.tokenId === cardHash);
if (!card || !ownsFrom(fresh, cardHash, remoteDeck.address)) throw new Error('The other player no longer owns one of the selected cards.');
if (fresh.blocks.length + 1 - card.block.height < CONSENSUS.coinbaseMaturity) throw new Error(`${card.block.article.title} is not mature enough to trade.`);
if (fresh.pendingTransfers.some(tx => (isNftTrade(tx)
? [...tx.offered, ...tx.requested].some(leg => leg.cardHash === cardHash)
: tx.cardHash === cardHash))) throw new Error(`${card.block.article.title} already has a pending transfer or trade.`);
return { cardHash, sequence: ownershipSequence(fresh, cardHash) };
});
const terms = { network: NETWORK, chainId: fresh.chainId, proposer: wallet.address,
counterparty: remoteDeck.address, offered: offeredLegs, requested: requestedLegs };
const signatureText = await sign(wallet,nftTradeMessage(terms));
const offer = { format: 'wikideck-trade-offer' as const, version: 1 as const, ...terms,
proposerPublicKey: wallet.publicKey, proposerSignature: signatureText };
setOfferText(JSON.stringify(offer, null, 2));
onNotice('Signed offer prepared. Share its JSON with the other player; nothing has been submitted yet.');
} catch (error) { setOfferError(error instanceof Error ? error.message : 'Could not create trade offer.'); }
finally { setBusy(false); }
};
const acceptOffer = async () => {
if (!wallet || !snapshot || busy) return;
setBusy(true); setOfferError('');
try {
if (offerText.length > 16_384) throw new Error('Trade offer JSON is too large.');
const offer: TradeOffer = tradeOfferSchema.parse(JSON.parse(offerText));
if (offer.network !== NETWORK || offer.chainId !== snapshot.chainId) throw new Error('Offer is for another network or chain.');
if (offer.counterparty !== wallet.address) throw new Error('This offer is addressed to a different wallet.');
if (!await verifyOfferSignature(offer)) throw new Error('The proposer signature is invalid; do not accept this offer.');
const fresh = await stateClient.load();
if (fresh.chainId !== offer.chainId) throw new Error('The chain changed. Reload and review the offer again.');
for (const leg of offer.offered) {
const block = fresh.blocks.find(item => item.tokenId === leg.cardHash);
if (!block || !ownsFrom(fresh, leg.cardHash, offer.proposer) || ownershipSequence(fresh, leg.cardHash) !== leg.sequence ||
fresh.blocks.length + 1 - block.height < CONSENSUS.coinbaseMaturity) throw new Error('An offered card is no longer owned, current, or mature.');
}
for (const leg of offer.requested) {
const block = fresh.blocks.find(item => item.tokenId === leg.cardHash);
if (!block || !ownsFrom(fresh, leg.cardHash, wallet.address) || ownershipSequence(fresh, leg.cardHash) !== leg.sequence ||
fresh.blocks.length + 1 - block.height < CONSENSUS.coinbaseMaturity) throw new Error('One of your requested cards is no longer owned, current, or mature.');
}
const counterpartySignature = await sign(wallet,nftTradeMessage(offer));
const accepted: NftTrade = { type: 'trade', network: offer.network, chainId: offer.chainId,
proposer: offer.proposer, counterparty: offer.counterparty, offered: offer.offered, requested: offer.requested,
proposerPublicKey: offer.proposerPublicKey, proposerSignature: offer.proposerSignature,
counterpartyPublicKey: wallet.publicKey,
counterpartySignature };
const result = await api<{ status: string; message: string }>('trades', accepted);
onNotice(result.message);
setOfferText(''); setRemoteDeck(undefined); setOffered(new Set()); setRequested(new Set());
} catch (error) { setOfferError(error instanceof Error ? error.message : 'Could not accept this trade offer.'); }
finally { setBusy(false); }
};
const copyOffer = async () => {
try { await navigator.clipboard.writeText(offerText); onNotice('Signed trade offer copied. Share it privately with the other player.'); }
catch { onNotice('Clipboard unavailable. Select and copy the offer JSON.'); }
};
const mineEligible = myDeck.filter(card => card.confirmations >= CONSENSUS.coinbaseMaturity &&
!snapshot?.pendingTransfers.some(tx => (isNftTrade(tx)
? [...tx.offered, ...tx.requested].some(leg => leg.cardHash === card.block.tokenId)
: tx.cardHash === card.block.tokenId)));
const otherEligible = (remoteDeck?.nfts ?? []).filter(card => card.confirmations >= CONSENSUS.coinbaseMaturity &&
!snapshot?.pendingTransfers.some(tx => (isNftTrade(tx)
? [...tx.offered, ...tx.requested].some(leg => leg.cardHash === card.block.tokenId)
: tx.cardHash === card.block.tokenId)));
return <section className="trade-desk">
<header className="trade-heading"><div><div className="section-eyebrow">PLAYER-TO-PLAYER · THE WHOLE SWAP OR NONE</div><h1>Build a card-for-card trade</h1><p>Choose cards from each collection, agree on the swap, and both sign. The network settles the entire bundle together—never a partial trade.</p></div><span className="trade-limit">Up to {CONSENSUS.maxTradeCards} cards per side</span></header>
{!wallet && <p className="trade-warning">Open or restore your wallet to build and accept trade offers.</p>}
{!rpcAvailable && <p className="trade-warning">Node is unavailable. Reconnect before creating or accepting a trade.</p>}
<section className="trade-lookup"><label htmlFor="player-address">OTHER PLAYER’S WALLET ADDRESS</label><div><input id="player-address" value={address} onChange={event => setAddress(event.target.value)} placeholder="Paste a 64-character wallet address" maxLength={64} autoComplete="off"/><button className="button dark" onClick={loadDeck} disabled={loadingDeck || !rpcAvailable}>{loadingDeck?<LoaderCircle className="spin" size={16}/>:<RefreshCw size={16}/>}View collection</button></div><p>Wallet holdings are public on this chain. Looking up an address reveals its confirmed cards at the node’s current chain tip.</p></section>
<div className="trade-columns">
<DeckList title="Your confirmed cards · offered" cards={mineEligible} selected={offered} onToggle={id => toggle(setOffered, offered, id)} disabled={!wallet || busy}/>
<div className="trade-arrow"><ArrowLeftRight size={24}/><span>Swap</span></div>
<DeckList title={remoteDeck?`Player ${short(remoteDeck.address)} · requested`:'Other player’s deck'} cards={otherEligible} selected={requested} onToggle={id => toggle(setRequested, requested, id)} disabled={busy || !remoteDeck}/>
</div>
<div className="trade-actions"><button className="button dark" onClick={createOffer} disabled={!wallet || !rpcAvailable || !remoteDeck || !offered.size || !requested.size || busy}><Check size={16}/>Sign offer</button><span>{offered.size} offered · {requested.size} requested</span></div>
<section className="trade-import"><div className="section-eyebrow">SHARE AN OFFER</div><p>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.</p><textarea aria-label="Trade offer JSON" value={offerText} onChange={event => setOfferText(event.target.value)} placeholder="Create an offer above, or paste a trade offer from another player." spellCheck={false}/>{parsedOffer&&<div className="trade-review"><strong>Review before signing</strong><p>{short(parsedOffer.proposer)} offers {parsedOffer.offered.map(leg=>offerTitle(leg.cardHash)).join(', ')} to {short(parsedOffer.counterparty)} for {parsedOffer.requested.map(leg=>offerTitle(leg.cardHash)).join(', ')}.</p><small>The node checks both signatures. All listed cards move together or none do.</small></div>}{offerError&&<p className="trade-error" role="alert">{offerError}</p>}<div className="trade-actions"><button className="button outline" onClick={copyOffer} disabled={!offerText}><Clipboard size={16}/>Copy offer</button><button className="button dark" onClick={acceptOffer} disabled={!wallet || !rpcAvailable || !parsedOffer || busy}>{busy?<LoaderCircle className="spin" size={16}/>:<Send size={16}/>}Accept and submit trade</button></div>
{remoteDeck&&<div className="trade-tip">Deck tip: <code>{short(remoteDeck.tip)}</code> · showing {remoteDeck.nfts.length} of {remoteDeck.total}; ownership and maturity re-checked when signing.{remoteDeck.next!==null&&<button className="text-button" onClick={loadMore} disabled={loadingMore}>{loadingMore?<LoaderCircle className="spin" size={14}/>:null}Load more cards</button>}</div>}
</section>
</section>;
}
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useId, useRef, useState, type FormEvent } from 'react';
import { exportWallet, restoreWallet, protectWallet, unlockWallet, lockWallet, walletStatus, subscribeWallet,
type WalletStatus, type Wallet } from './wallet.ts';
import { NETWORK } from '../shared/network.ts';
type Props = {
wallet: Wallet | undefined;
onRestore: (wallet: Wallet) => void;
onNotice: (message: string) => void;
};
export function WalletPanel({ wallet, onRestore, onNotice }: Props) {
const id = useId();
const [password, setPassword] = useState('');
const [confirmation, setConfirmation] = useState('');
const [importPassword, setImportPassword] = useState('');
const [file, setFile] = useState<File>();
const [replace, setReplace] = useState(false);
const [busy, setBusy] = useState(false);
const [status,setStatus] = useState<WalletStatus>();
const [localPassword,setLocalPassword] = useState('');
const [localConfirmation,setLocalConfirmation] = useState('');
const [error,setError] = useState('');
const inProgress = useRef(false);
const fileInput = useRef<HTMLInputElement>(null);
useEffect(()=>{
let active=true;
const load=()=>{void walletStatus().then(s=>{if(active)setStatus(s);}).catch(()=>{if(active)setError('Wallet storage unavailable.');});};
load();const unsubscribe=subscribeWallet(()=>{
setPassword('');setConfirmation('');setImportPassword('');setLocalPassword('');setLocalConfirmation('');load();
});
const clear=()=>{if(document.visibilityState!=='visible'){setPassword('');setConfirmation('');setImportPassword('');setLocalPassword('');setLocalConfirmation('');}};
document.addEventListener('visibilitychange',clear);
return()=>{active=false;unsubscribe();document.removeEventListener('visibilitychange',clear);};
},[]);
async function open(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); if(inProgress.current||!status)return;
if(status!=='locked'&&(localPassword.length<12||localPassword!==localConfirmation)) {setError('Choose at least 12 characters and confirm the local password.');return;}
inProgress.current=true;setBusy(true);setError('');
try {
const opened= status==='locked'?await unlockWallet(localPassword):await protectWallet(localPassword);
onRestore(opened);onNotice('Wallet unlocked in this tab. Locking or leaving the tab clears its signing session.');
} catch(e) {setError(e instanceof Error?e.message:'Could not unlock wallet.');}
finally {setLocalPassword('');setLocalConfirmation('');inProgress.current=false;setBusy(false);setStatus(await walletStatus());}
}
async function download(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (inProgress.current || !wallet) return;
if (password.length < 12 || password.length > 1024 || password !== confirmation) {
onNotice('Use a password of 12–1024 characters and enter it identically twice.');
return;
}
inProgress.current = true;
setBusy(true);
try {
const backup = await exportWallet(wallet, password);
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = `${NETWORK}-wallet-${wallet.address}.json`;
document.body.append(link);
try { link.click(); }
finally {
link.remove();
// Let the browser start consuming the download before revocation.
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
onNotice('Encrypted backup download requested. Keep the file and password safe; test recovery before relying on it.');
} catch { onNotice('Could not export the wallet. Check browser support and try again.'); }
finally {
setPassword('');
setConfirmation('');
inProgress.current = false;
setBusy(false);
}
}
async function recover(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (inProgress.current) return;
if (!file || file.size === 0 || file.size > 32 * 1024) {
onNotice('Choose a nonempty wallet backup JSON file no larger than 32 KB.');
return;
}
if (status !== 'empty' && !replace) {
onNotice('Confirm replacement of the active wallet first. Back it up before continuing.');
return;
}
inProgress.current = true;
setBusy(true);
try {
if(localPassword.length<12||localPassword!==localConfirmation)throw new Error('Choose and confirm a local encryption password of at least 12 characters.');
// Size checked before reading; the shared decoder validates all JSON fields.
const backup: unknown = JSON.parse(await file.text());
const recovered = await restoreWallet(backup, importPassword, localPassword, replace);
onRestore(recovered);
onNotice('Wallet recovered into encrypted local storage. Other tabs are locked; keep the backup safe.');
setFile(undefined);
setReplace(false);
if (fileInput.current) fileInput.current.value = '';
} catch {
onNotice('Recovery failed. Check the password, backup format and network, and browser storage. The backup must be intact.');
} finally {
setImportPassword('');
setLocalPassword('');setLocalConfirmation('');
inProgress.current = false;
setBusy(false);
}
}
return <section aria-labelledby={`${id}-title`} aria-busy={busy}>
<h2 id={`${id}-title`}>Wallet backup &amp; recovery</h2>
<p>Local P-256 wallet for {NETWORK}. Status: <strong>{status??'checking storage'}</strong>.</p>
<p>New and migrated keys are encrypted in IndexedDB. Unlocking keeps an exportable signing key only in this tab’s memory.
Lock manually, after five idle minutes, after four hours, or automatically when the tab is hidden.
Encryption does not protect an unlocked page from malicious same-origin scripts or extensions. Clearing site data without a backup loses access.</p>
{status==='legacy'&&<p role="alert"><strong>Existing unencrypted wallet detected.</strong> Encrypt the same identity below; migration never generates a replacement key. The original record is replaced only after encrypted storage commits. Existing disk copies/backups may still contain the old plaintext key.</p>}
{error&&<p role="alert" className="transfer-error">{error}</p>}
{wallet?<button type="button" onClick={()=>{lockWallet();setPassword('');setConfirmation('');setImportPassword('');setLocalPassword('');setLocalConfirmation('');}} disabled={busy}>Lock wallet now</button>:<form onSubmit={open}><fieldset disabled={busy||!status}>
<legend>{status==='locked'?'Unlock encrypted local wallet':status==='legacy'?'Encrypt existing wallet':'Create encrypted wallet'}</legend>
<label htmlFor={`${id}-local-password`}>Local wallet password {status!=='locked'?'(at least 12 characters)':''}</label>
<input id={`${id}-local-password`} type="password" autoComplete={status==='locked'?'current-password':'new-password'} required maxLength={1024} minLength={status==='locked'?1:12} value={localPassword} onChange={e=>setLocalPassword(e.target.value)}/>
{status!=='locked'&&<><label htmlFor={`${id}-local-confirm`}>Confirm local password</label><input id={`${id}-local-confirm`} type="password" autoComplete="new-password" required minLength={12} maxLength={1024} value={localConfirmation} onChange={e=>setLocalConfirmation(e.target.value)}/></>}
<button type="submit">{status==='locked'?'Unlock wallet':status==='legacy'?'Encrypt existing wallet':'Create encrypted wallet'}</button>
</fieldset></form>}
<p>Backups are bound to this exact chain ID; a backup for a different genesis cannot be restored here. Encryption and recovery happen locally. No private key, backup file, or password is transmitted to a server
by this panel. Public addresses, public keys and signatures may be shared during normal wallet use.</p>
<p><strong>Forgotten backup passwords cannot be reset or recovered.</strong> Keep the encrypted file and a strong,
unique password safe and separate. Anyone with both can control this wallet. Downloading does not guarantee the file was saved.</p>
<form onSubmit={download}>
<fieldset disabled={busy || !wallet}>
<legend>Download encrypted backup</legend>
<label htmlFor={`${id}-password`}>Backup password (at least 12 characters)</label>
<input id={`${id}-password`} type="password" autoComplete="new-password" minLength={12} maxLength={1024}
required value={password} onChange={event => setPassword(event.target.value)} />
<label htmlFor={`${id}-confirm`}>Confirm backup password</label>
<input id={`${id}-confirm`} type="password" autoComplete="new-password" minLength={12} maxLength={1024}
required value={confirmation} onChange={event => setConfirmation(event.target.value)} />
<button type="submit">Download encrypted backup</button>
</fieldset>
</form>
<form onSubmit={recover}>
<fieldset disabled={busy}>
<legend>Recover from a local backup</legend>
<label htmlFor={`${id}-file`}>Backup JSON file (maximum 32 KB)</label>
<input ref={fileInput} id={`${id}-file`} type="file" accept=".json,application/json" required
onChange={event => { setFile(event.target.files?.[0]); setReplace(false); }} />
<label htmlFor={`${id}-import-password`}>Backup password</label>
<input id={`${id}-import-password`} type="password" autoComplete="off" required maxLength={1024}
value={importPassword} onChange={event => setImportPassword(event.target.value)} />
<label htmlFor={`${id}-recover-local`}>New local encryption password (at least 12 characters)</label>
<input id={`${id}-recover-local`} type="password" autoComplete="new-password" minLength={12} maxLength={1024} required value={localPassword} onChange={e=>setLocalPassword(e.target.value)}/>
<label htmlFor={`${id}-recover-confirm`}>Confirm new local password</label>
<input id={`${id}-recover-confirm`} type="password" autoComplete="new-password" minLength={12} maxLength={1024} required value={localConfirmation} onChange={e=>setLocalConfirmation(e.target.value)}/>
{status!=='empty' && <label>
<input type="checkbox" required checked={replace} onChange={event => setReplace(event.target.checked)} />
I have backed up my active wallet and confirm replacing it. Recovery does not transfer its assets.
</label>}
<button type="submit" disabled={!status||!file || (status!=='empty' && !replace)}>Recover and save encrypted wallet</button>
</fieldset>
</form>
</section>;
}
export default WalletPanel;
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+3
View File
@@ -0,0 +1,3 @@
.card-modal{grid-template-areas:"card detail" "insights insights"}.card-modal .detail-card{display:contents}.card-modal .detail-card>.wiki-card{grid-area:card;align-self:start}.card-modal .detail-copy{grid-area:detail;min-width:0}.card-modal .article-insights{grid-area:insights;margin-top:0}.card-modal .article-fields{grid-template-columns:repeat(4,minmax(0,1fr))}@media(max-width:560px){.card-modal .detail-card>.wiki-card{margin-bottom:24px}.card-modal .article-insights{margin:24px 0}.card-modal .article-fields{grid-template-columns:repeat(2,minmax(0,1fr))}}
.card-enrichment{border-top:1px solid var(--theme-border);margin-top:12px;padding-top:10px;min-height:68px}.card-readership{display:flex;justify-content:space-between;gap:6px;font-size:8px;color:var(--ctp-subtext0)}.card-readership>span:first-child{font-size:7px;letter-spacing:.7px;color:var(--ctp-teal)}.enrichment-scores{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:10px}.enrichment-scores>span{display:grid;grid-template-columns:auto auto;justify-content:center;align-items:center;gap:3px 5px;font-size:11px;color:var(--ctp-lavender)}.enrichment-scores b{font-weight:600;color:var(--ctp-text)}.enrichment-scores small{grid-column:1/-1;text-align:center;font-size:8px;color:var(--ctp-subtext0)}.card-enrichment-status{color:var(--ctp-subtext0);font-size:9px}.hero-card .card-enrichment{display:none}.article-insights{margin-top:24px;padding:20px;background:var(--ctp-mantle);border:1px solid var(--ctp-surface1);border-radius:10px;text-align:left;pointer-events:auto}.article-insights h3{font-size:18px;letter-spacing:-.3px;font-weight:550;color:var(--ctp-text)}.article-insights p{font-size:11px;line-height:1.85;color:var(--ctp-subtext0);margin-top:12px}.insights-heading{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:9px}.insights-heading>span{font-size:8px;letter-spacing:1px;color:var(--ctp-teal)}.article-insights .enrichment-scores{padding:16px 0;border-bottom:1px solid var(--theme-border)}.article-insights .enrichment-scores b{font-size:21px}.article-insights .enrichment-scores small{font-size:10px}.article-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px 14px;margin:20px 0}.article-fields dt{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--ctp-subtext0)}.article-fields dd{margin:6px 0 0;font-size:14px;color:var(--ctp-text);overflow-wrap:anywhere}.article-fields dd small{display:block;font-size:9px;color:var(--ctp-subtext0);margin-top:4px}.article-insights a{color:var(--ctp-blue);text-decoration:underline;text-underline-offset:3px}.pageview-history{margin:22px 0}.pageview-history figcaption{font-size:10px;color:var(--ctp-subtext1);margin-bottom:14px}.pageview-bars{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:7px}.pageview-bars>div{text-align:center;min-width:0}.pageview-bars span{font-size:8px;color:var(--ctp-subtext1)}.pageview-track{height:80px;display:flex;align-items:flex-end;margin-top:5px;background:var(--ctp-base);border-radius:4px;overflow:hidden}.pageview-track i{display:block;width:100%;background:var(--ctp-mauve);border-radius:3px 3px 0 0}.pageview-bars small{display:block;margin-top:7px;font-size:8px;color:var(--ctp-subtext0);line-height:1.6}.insights-category-heading{display:flex;align-items:center;gap:6px;color:var(--ctp-subtext1);font-size:11px;margin:20px 0 12px}.article-categories{display:flex;flex-wrap:wrap;gap:6px;max-height:170px;overflow:auto}.article-categories a,.article-categories>span{font-size:9px;line-height:1.6;border-radius:4px;background:var(--ctp-surface0);padding:4px 7px;text-decoration:none;color:var(--ctp-subtext1);overflow-wrap:anywhere}.article-categories a:hover{color:var(--ctp-mauve)}.insights-method{margin-top:20px;color:var(--ctp-lavender);font-size:10px;line-height:1.8}.insights-method summary{cursor:pointer}.article-insights p.insights-attribution{font-size:9px;overflow-wrap:anywhere}.visually-hidden{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.card-modal .detail-card{align-self:start}.card-modal .detail-copy{align-self:start}.card-modal .detail-card .wiki-card{pointer-events:none}@media(max-width:560px){.card-readership{flex-direction:column;font-size:8px;gap:4px}.enrichment-scores{gap:4px}.enrichment-scores>span{font-size:10px}.enrichment-scores small{font-size:7px}.article-insights{padding:16px}.card-modal .detail-card{max-width:none}.card-modal .detail-card>.wiki-card{max-width:230px;margin-inline:auto}}
.mint-snapshot-compact{margin-top:8px;padding-top:7px;border-top:1px dashed var(--theme-border)}.mint-snapshot-compact>span{display:block;text-align:center;font-size:7px;letter-spacing:.7px;color:var(--ctp-peach)}.mint-snapshot-compact .enrichment-scores{margin-top:4px}.mint-snapshot-compact .enrichment-scores>span{font-size:9px}.mint-snapshot-compact .enrichment-scores small{font-size:7px}.mint-snapshot-compact>small{display:block;margin-top:4px;text-align:center;font-size:7px;color:var(--ctp-subtext0)}.mint-snapshot{margin:24px 0;padding:18px;border:1px dashed var(--ctp-surface2);border-radius:8px;background:var(--ctp-base)}.mint-snapshot .insights-heading h3{font-size:15px}.mint-snapshot .insights-heading>span{font-size:9px;color:var(--ctp-subtext0)}.mint-snapshot .enrichment-scores{padding-bottom:12px;border-bottom:1px solid var(--theme-border)}.mint-months{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;margin-top:14px;font-size:9px;color:var(--ctp-subtext1)}.mint-months span{padding:6px;border-radius:4px;background:var(--ctp-mantle)}
+1
View File
@@ -0,0 +1 @@
:root{font-family:'Segoe UI',Arial,sans-serif;color:#242825;background:#f8f9f6;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-size:14px}*{box-sizing:border-box}body{margin:0;min-width:360px}button,input,select{font:inherit}button,a,input,select{-webkit-tap-highlight-color:transparent}button{cursor:pointer;color:inherit;border:0}button:disabled{cursor:not-allowed;opacity:.45}a{color:inherit;text-decoration:none}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #83a94c;outline-offset:4px}h1,h2,h3,p{margin:0}svg{flex-shrink:0}::selection{background:#d3edb0}button{transition:background .18s,transform .18s,box-shadow .18s}button:not(:disabled):active{transform:translateY(1px)}
+7
View File
@@ -0,0 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import './themes.css';
ReactDOM.createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);
+27
View File
@@ -0,0 +1,27 @@
/// <reference lib="webworker" />
import { argon2id } from 'hash-wasm';
import { POW, proofMessage, type Challenge } from '../shared/protocol.ts';
self.onmessage = async (event: MessageEvent<{ challenge: Challenge; owner: string; duty: number }>) => {
const { challenge, owner, duty } = event.data;
try {
const salt = new Uint8Array(challenge.seed.match(/.{2}/g)!.map(x => parseInt(x, 16)));
let nonce = crypto.getRandomValues(new Uint32Array(1))[0];
let attempts = 0;
const start = performance.now();
for (;;) {
const tick = performance.now();
const hash = await argon2id({ ...POW, password: proofMessage(challenge, owner, nonce), salt, outputType: 'hex' });
attempts++;
self.postMessage({ type: 'progress', attempts, hash, rate: attempts / ((performance.now() - start) / 1000) });
if (hash.startsWith('0'.repeat(challenge.difficulty))) {
self.postMessage({ type: 'found', hash, nonce });
return;
}
nonce++;
await new Promise(resolve => setTimeout(resolve, Math.max(20, (performance.now() - tick) * (1 / duty - 1))));
}
} catch (error) {
self.postMessage({ type: 'error', message: error instanceof Error ? error.message : 'Mining worker failed' });
}
};
+67
View File
@@ -0,0 +1,67 @@
/* Testnet-only layout and forms. All colors follow the active Catppuccin flavor. */
.testnet-brand { display: inline-flex; gap: 7px; align-items: center; white-space: nowrap; font-size: 11px; color: var(--ctp-subtext1); }
.testnet-brand b { padding: 4px 6px; border: 1px solid var(--ctp-surface1); border-radius: 5px; font-size: 9px; letter-spacing: .8px; color: var(--theme-accent); background: var(--theme-accent-soft); }
.testnet-status { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 20px; margin-bottom: 20px; font-size: 11px; line-height: 1.6; color: var(--ctp-subtext0); }
.testnet-status > span:first-child { display: inline-flex; align-items: center; gap: 8px; }
.testnet-status strong, .pending-label { color: var(--ctp-peach); }
.pending-label { display: inline-block; font-size: 11px; line-height: 1.6; margin-top: 6px; }
.native-mining { align-items: start; }
.native-mining > *, .native-mining .miner-display > div { min-width: 0; }
.native-mining .miner-display strong { max-width: none; white-space: normal; line-height: 1.6; }
.native-mining .muted { font-size: 12px; line-height: 1.8; margin-top: 8px; }
.native-memory { display: grid; gap: 7px; margin-top: 22px; padding: 16px; border: 1px solid var(--theme-border); border-radius: 9px; background: var(--ctp-base); font-size: 12px; line-height: 1.7; }
.native-memory strong { color: var(--ctp-lavender); }
.native-memory span { color: var(--ctp-subtext0); }
.native-memory strong:not(:first-child) { margin-top: 6px; }
.mining-details { margin-top: 18px; color: var(--ctp-subtext0); font-size: 12px; line-height: 1.8; }
.mining-details > summary, .guide-steps details > summary { width: fit-content; color: var(--theme-accent); cursor: pointer; font-weight: 600; text-underline-offset: 3px; }
.guide-steps details { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--theme-border); border-radius: 8px; background: var(--ctp-mantle); }
.guide-steps details p { margin: 8px 0 0; }
.mining-details > summary:focus-visible, .guide-steps details > summary:focus-visible { outline: 2px solid var(--theme-accent); outline-offset: 4px; border-radius: 2px; }
.native-steps { padding-left: 20px; margin: 18px 0; color: var(--ctp-subtext1); font-size: 12px; line-height: 1.8; }
.native-steps li + li { margin-top: 8px; }
.native-command { margin: 14px 0; padding: 16px; border: 1px solid var(--ctp-surface1); border-radius: 8px; background: var(--ctp-crust); color: var(--ctp-green); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; line-height: 1.9; user-select: text; }
.native-mining .miner-footnote { display: block; margin: 14px 0 20px; font-size: 11px; line-height: 1.9; overflow-wrap: anywhere; }
.native-mining .text-button { margin-top: 12px; }
.chain-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 22px 0; border: 1px solid var(--theme-border); border-radius: 10px; overflow: hidden; background: var(--theme-border); }
.chain-summary > div { padding: 16px 20px; background: var(--ctp-mantle); }
.chain-summary dt { color: var(--ctp-subtext0); font-size: 11px; margin-bottom: 8px; }
.chain-summary dd { margin: 0; font-family: ui-monospace, monospace; font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; color: var(--ctp-text); }
.explorer-card-link { padding: 8px 0; background: transparent; color: var(--theme-accent); font: inherit; text-decoration: underline; text-underline-offset: 3px; }
.nft-link { display: flex; align-items: center; gap: 6px; margin-top: 6px; color: var(--ctp-blue); }
.proof-caption { display: block; margin-bottom: 6px; font-size: 11px; }
.wallet-modal { width: min(680px, 100%); }
.wallet-recovery { margin-top: 28px; border-top: 1px solid var(--theme-border); padding-top: 24px; }
.wallet-recovery h2 { font-size: 22px; }
.wallet-recovery p { margin: 12px 0; color: var(--ctp-subtext0); font-size: 12px; line-height: 1.9; }
.wallet-recovery p strong { color: var(--ctp-peach); }
.wallet-recovery fieldset { display: grid; min-width: 0; gap: 10px; padding: 18px; margin: 20px 0; border: 1px solid var(--ctp-surface1); border-radius: 10px; background: var(--ctp-mantle); }
.wallet-recovery legend { padding: 0 8px; font-size: 13px; color: var(--ctp-lavender); font-weight: 600; }
.wallet-recovery label { color: var(--ctp-subtext1); font-size: 12px; line-height: 1.8; }
.wallet-recovery input:not([type='checkbox']) { display: block; width: 100%; min-width: 0; border: 1px solid var(--ctp-surface1); border-radius: 6px; padding: 10px 12px; background: var(--ctp-base); color: var(--ctp-text); font: inherit; font-size: 13px; }
.wallet-recovery input[type='checkbox'] { margin-right: 8px; accent-color: var(--theme-accent); }
.wallet-recovery input[type='file'] { padding: 9px; font-size: 11px; }
.wallet-recovery input::file-selector-button { padding: 6px 8px; margin-right: 10px; border: 0; border-radius: 4px; background: var(--ctp-surface0); color: var(--ctp-text); }
.wallet-recovery button { min-height: 42px; margin-top: 8px; padding: 10px 16px; border: 0; border-radius: 6px; background: var(--theme-accent); color: var(--theme-accent-ink); font: inherit; font-size: 12px; font-weight: 600; }
.wallet-recovery button:hover:not(:disabled) { filter: brightness(1.08); }
.wallet-recovery button:disabled { opacity: .5; cursor: not-allowed; }
.wallet-recovery input:focus-visible, .wallet-recovery button:focus-visible, .explorer-card-link:focus-visible { outline: 2px solid var(--theme-accent); outline-offset: 3px; }
.detail-copy .muted, .detail-copy .address-box { overflow-wrap: anywhere; }
.detail-links { flex-wrap: wrap; row-gap: 10px; }
.toast { max-height: 40vh; overflow-y: auto; }
@media (max-width: 1100px) {
.testnet-brand { font-size: 10px; }
.top-actions { gap: 8px; }
}
@media (max-width: 760px) {
.testnet-status { font-size: 10px; gap: 8px 14px; }
.chain-summary { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 560px) {
.testnet-brand { flex-direction: column; gap: 3px; font-size: 9px; }
.testnet-brand b { font-size: 8px; padding: 2px 4px; }
.native-command { font-size: 11px; padding: 12px; }
.native-mining .miner-footnote { font-size: 11px; }
.wallet-recovery fieldset { padding: 14px; }
.wallet-recovery h2 { font-size: 20px; }
}
+21
View File
@@ -0,0 +1,21 @@
export const THEMES = [
{ id: 'mocha', label: 'Mocha', base: '#1e1e2e' },
{ id: 'macchiato', label: 'Macchiato', base: '#24273a' },
{ id: 'frappe', label: 'Frappé', base: '#303446' },
{ id: 'latte', label: 'Latte', base: '#eff1f5' },
] as const;
export type Theme = typeof THEMES[number]['id'];
export const THEME_STORAGE_KEY = 'wikideck-theme';
export const DEFAULT_THEME: Theme = 'mocha';
export function isTheme(value: unknown): value is Theme {
return THEMES.some(theme => theme.id === value);
}
export function resolveTheme(value: unknown): Theme {
return isTheme(value) ? value : DEFAULT_THEME;
}
export function applyTheme(theme: Theme) {
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme === 'latte' ? 'light' : 'dark';
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', THEMES.find(t => t.id === theme)!.base);
}
+348
View File
@@ -0,0 +1,348 @@
/* Catppuccin official palette: https://catppuccin.com/palette/
Color roles live here; App.css remains responsible for component layout.
Mocha is also the CSS fallback before the preference bootstrap runs. */
:root,
:root[data-theme='mocha'] {
color-scheme: dark;
--ctp-rosewater: #f5e0dc;
--ctp-flamingo: #f2cdcd;
--ctp-pink: #f5c2e7;
--ctp-mauve: #cba6f7;
--ctp-red: #f38ba8;
--ctp-maroon: #eba0ac;
--ctp-peach: #fab387;
--ctp-yellow: #f9e2af;
--ctp-green: #a6e3a1;
--ctp-teal: #94e2d5;
--ctp-sky: #89dceb;
--ctp-sapphire: #74c7ec;
--ctp-blue: #89b4fa;
--ctp-lavender: #b4befe;
--ctp-text: #cdd6f4;
--ctp-subtext1: #bac2de;
--ctp-subtext0: #a6adc8;
--ctp-overlay2: #9399b2;
--ctp-overlay1: #7f849c;
--ctp-overlay0: #6c7086;
--ctp-surface2: #585b70;
--ctp-surface1: #45475a;
--ctp-surface0: #313244;
--ctp-base: #1e1e2e;
--ctp-mantle: #181825;
--ctp-crust: #11111b;
}
:root[data-theme='macchiato'] {
color-scheme: dark;
--ctp-rosewater: #f4dbd6;
--ctp-flamingo: #f0c6c6;
--ctp-pink: #f5bde6;
--ctp-mauve: #c6a0f6;
--ctp-red: #ed8796;
--ctp-maroon: #ee99a0;
--ctp-peach: #f5a97f;
--ctp-yellow: #eed49f;
--ctp-green: #a6da95;
--ctp-teal: #8bd5ca;
--ctp-sky: #91d7e3;
--ctp-sapphire: #7dc4e4;
--ctp-blue: #8aadf4;
--ctp-lavender: #b7bdf8;
--ctp-text: #cad3f5;
--ctp-subtext1: #b8c0e0;
--ctp-subtext0: #a5adcb;
--ctp-overlay2: #939ab7;
--ctp-overlay1: #8087a2;
--ctp-overlay0: #6e738d;
--ctp-surface2: #5b6078;
--ctp-surface1: #494d64;
--ctp-surface0: #363a4f;
--ctp-base: #24273a;
--ctp-mantle: #1e2030;
--ctp-crust: #181926;
}
:root[data-theme='frappe'] {
color-scheme: dark;
--ctp-rosewater: #f2d5cf;
--ctp-flamingo: #eebebe;
--ctp-pink: #f4b8e4;
--ctp-mauve: #ca9ee6;
--ctp-red: #e78284;
--ctp-maroon: #ea999c;
--ctp-peach: #ef9f76;
--ctp-yellow: #e5c890;
--ctp-green: #a6d189;
--ctp-teal: #81c8be;
--ctp-sky: #99d1db;
--ctp-sapphire: #85c1dc;
--ctp-blue: #8caaee;
--ctp-lavender: #babbf1;
--ctp-text: #c6d0f5;
--ctp-subtext1: #b5bfe2;
--ctp-subtext0: #a5adce;
--ctp-overlay2: #949cbb;
--ctp-overlay1: #838ba7;
--ctp-overlay0: #737994;
--ctp-surface2: #626880;
--ctp-surface1: #51576d;
--ctp-surface0: #414559;
--ctp-base: #303446;
--ctp-mantle: #292c3c;
--ctp-crust: #232634;
}
:root[data-theme='latte'] {
color-scheme: light;
--ctp-rosewater: #dc8a78;
--ctp-flamingo: #dd7878;
--ctp-pink: #ea76cb;
--ctp-mauve: #8839ef;
--ctp-red: #d20f39;
--ctp-maroon: #e64553;
--ctp-peach: #fe640b;
--ctp-yellow: #df8e1d;
--ctp-green: #40a02b;
--ctp-teal: #179299;
--ctp-sky: #04a5e5;
--ctp-sapphire: #209fb5;
--ctp-blue: #1e66f5;
--ctp-lavender: #7287fd;
--ctp-text: #4c4f69;
--ctp-subtext1: #5c5f77;
--ctp-subtext0: #6c6f85;
--ctp-overlay2: #7c7f93;
--ctp-overlay1: #8c8fa1;
--ctp-overlay0: #9ca0b0;
--ctp-surface2: #acb0be;
--ctp-surface1: #bcc0cc;
--ctp-surface0: #ccd0da;
--ctp-base: #eff1f5;
--ctp-mantle: #e6e9ef;
--ctp-crust: #dce0e8;
}
:root {
--theme-accent: var(--ctp-mauve);
--theme-accent-ink: var(--ctp-crust);
--theme-accent-soft: color-mix(in srgb, var(--theme-accent) 13%, var(--ctp-base));
--theme-success-soft: color-mix(in srgb, var(--ctp-green) 12%, var(--ctp-base));
--theme-error-soft: color-mix(in srgb, var(--ctp-red) 10%, var(--ctp-base));
--theme-border: var(--ctp-surface0);
--theme-shadow: color-mix(in srgb, var(--ctp-crust) 45%, transparent);
--theme-scrim: color-mix(in srgb, var(--ctp-crust) 76%, transparent);
/* Labels on photos must remain readable independently of the chosen flavor. */
--theme-photo-ink: #cdd6f4;
--theme-photo-scrim: #11111bbf;
--theme-photo-border: #cdd6f44d;
background: var(--ctp-base);
color: var(--ctp-text);
accent-color: var(--theme-accent);
scrollbar-color: var(--ctp-surface2) var(--ctp-base);
}
:root[data-theme='latte'] {
--theme-accent-ink: var(--ctp-base);
--theme-shadow: color-mix(in srgb, var(--ctp-text) 16%, transparent);
--theme-scrim: color-mix(in srgb, var(--ctp-text) 58%, transparent);
}
body { background: var(--ctp-base); color: var(--ctp-text); }
::selection { background: var(--theme-accent); color: var(--theme-accent-ink); }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible {
outline-color: var(--theme-accent);
}
/* Navigation and flavor selector */
.sidebar { background: var(--ctp-crust); color: var(--ctp-text); border-color: var(--theme-border); }
.brand-dot, .tiny-spark { color: var(--theme-accent); }
.brand-mark { background: var(--theme-accent); color: var(--theme-accent-ink); }
.sidebar-label, .help-link { color: var(--ctp-subtext0); }
.nav-item { color: var(--ctp-subtext1); }
.nav-item:hover { background: var(--ctp-surface0); }
.nav-item.active { color: var(--theme-accent); background: var(--theme-accent-soft); }
.nav-count { border-color: var(--ctp-surface1); }
.sidebar-note { border-color: var(--theme-border); background: linear-gradient(135deg, var(--ctp-surface0), var(--ctp-mantle)); }
.sidebar-note p { color: var(--ctp-subtext0); }
.sidebar-note button { color: var(--theme-accent); }
.sidebar-bottom { color: var(--ctp-subtext1); border-color: var(--theme-border); }
.network-dot { background: var(--ctp-green); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ctp-green) 10%, transparent); }
.disconnected { background: var(--ctp-peach); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ctp-peach) 10%, transparent); }
.prototype { color: var(--ctp-green); background: var(--theme-success-soft); border-color: var(--ctp-surface1); }
.topbar { background: var(--ctp-mantle); border-color: var(--theme-border); }
.breadcrumbs, .network-pill { color: var(--ctp-subtext0); }
.breadcrumbs span { color: var(--ctp-text); }
.wallet-button { background: var(--ctp-base); color: var(--ctp-text); border-color: var(--ctp-surface1); }
.wallet-avatar { background: var(--theme-accent-soft); color: var(--theme-accent); }
.wallet-button > svg { color: var(--ctp-subtext0); }
.theme-picker {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 6px;
color: var(--theme-accent);
}
.theme-picker select {
border: 0;
border-radius: 2px;
background: var(--ctp-base);
color: var(--ctp-text);
font-size: 11px;
cursor: pointer;
min-height: 22px;
max-width: 110px;
}
.theme-picker:focus-within { border-color: var(--theme-accent); }
.theme-picker option { background: var(--ctp-base); color: var(--ctp-text); }
/* Hero and actions */
.hero { background: linear-gradient(125deg, var(--ctp-mantle), var(--theme-accent-soft)); border-color: var(--theme-border); }
.eyebrow { color: var(--ctp-subtext1); }
.eyebrow > span { background: var(--theme-accent); }
.hero h1 { color: var(--ctp-text); }
.hero h1 > span { color: var(--theme-accent); }
.hero-copy > p, .hero-foot { color: var(--ctp-subtext0); }
.button.dark { background: var(--theme-accent); color: var(--theme-accent-ink); }
.button.dark:hover { background: color-mix(in srgb, var(--theme-accent) 80%, var(--ctp-text)); }
.text-button { color: var(--ctp-subtext1); }
.text-button:hover { color: var(--theme-accent); }
.orbit { border-color: color-mix(in srgb, var(--theme-accent) 20%, transparent); }
.orbit-spark { color: var(--ctp-lavender); }
.hero-card .wiki-card { box-shadow: 0 12px 25px var(--theme-shadow); }
.floating-label { background: var(--ctp-surface0); color: var(--ctp-subtext1); border-color: var(--ctp-surface1); box-shadow: 0 3px 12px var(--theme-shadow); }
.label-bottom svg:first-child { color: var(--ctp-yellow); }
/* Network metrics and opt-in miner */
.metrics > div { border-color: var(--theme-border); }
.metric-icon { background: var(--ctp-surface0); color: var(--ctp-lavender); }
.metrics > div > div > span, .metrics small { color: var(--ctp-subtext0); }
.metric-icon.leaf { color: var(--ctp-green); background: var(--theme-success-soft); }
.mining-panel { background: var(--ctp-mantle); border-color: var(--theme-border); }
.section-eyebrow, .mining-description p, .mining-meta, .miner-display span { color: var(--ctp-subtext0); }
.idle-dot { background: var(--ctp-overlay1); }
.miner-controls { border-color: var(--theme-border); }
.signal-bars i { background: var(--ctp-surface2); }
.signal-bars i:nth-child(3n) { background: var(--ctp-mauve); }
.signal-bars.running i { background: var(--ctp-green); }
.intensity, .consent { color: var(--ctp-subtext1); }
.intensity select { background: var(--ctp-base); color: var(--ctp-text); border-color: var(--ctp-surface1); }
.consent input { accent-color: var(--ctp-green); }
.lime { background: var(--ctp-green); color: var(--theme-accent-ink); }
.lime:hover { background: color-mix(in srgb, var(--ctp-green) 80%, var(--ctp-text)); }
.miner-footnote { color: var(--ctp-subtext0); }
.mining-stop { background: var(--theme-error-soft); color: var(--ctp-red); border-color: var(--ctp-surface1); }
.hash-line { color: var(--ctp-green); background: var(--ctp-base); }
/* Collection filters and encyclopedia cards */
.section-heading p { color: var(--ctp-subtext0); }
.section-heading > .text-button { color: var(--theme-accent); }
.count-badge { color: var(--ctp-subtext1); border-color: var(--ctp-surface1); }
.filter-bar { border-color: var(--theme-border); }
.category-tabs button { color: var(--ctp-subtext0); }
.category-tabs button:hover { background: var(--ctp-surface0); }
.category-tabs button.selected { color: var(--theme-accent); background: var(--theme-accent-soft); }
.search-input { background: var(--ctp-mantle); color: var(--ctp-subtext0); border-color: var(--ctp-surface1); }
.search-input input { color: var(--ctp-text); }
.search-input input::placeholder { color: var(--ctp-subtext0); }
.wiki-card { --rarity-color: var(--ctp-green); background: var(--ctp-mantle); color: var(--ctp-text); border-color: var(--ctp-surface1); box-shadow: 0 3px 4px var(--theme-shadow); }
.wiki-card.legendary { --rarity-color: var(--ctp-yellow); border-color: color-mix(in srgb, var(--ctp-yellow) 55%, var(--ctp-surface0)); }
.wiki-card.epic { --rarity-color: var(--ctp-mauve); }
.wiki-card.rare { --rarity-color: var(--ctp-blue); }
.wiki-card:hover { border-color: var(--rarity-color); box-shadow: 0 9px 20px var(--theme-shadow); }
.wiki-card .card-top { color: var(--rarity-color); background: color-mix(in srgb, var(--rarity-color) 12%, var(--ctp-mantle)); }
.card-top > span:last-child { color: var(--ctp-subtext0); }
.card-art { color: var(--ctp-teal); background: radial-gradient(ellipse at top right, var(--ctp-surface1), var(--ctp-crust)); }
.art-space { background: var(--ctp-crust); }
.art-people { background: var(--ctp-surface0); }
.art-science { background: var(--ctp-base); }
.art-nature { background: color-mix(in srgb, var(--ctp-teal) 15%, var(--ctp-crust)); }
.card-art::after { background: linear-gradient(transparent 70%, var(--theme-photo-scrim)); }
.card-category { color: var(--theme-photo-ink); background: var(--theme-photo-scrim); border-color: var(--theme-photo-border); }
.card-title > svg { color: var(--ctp-subtext0); }
.card-content > p { color: var(--ctp-subtext0); }
.card-stats { color: var(--ctp-subtext1); border-color: var(--theme-border); }
.card-stats svg { color: var(--ctp-lavender); }
.card-bottom { background: var(--ctp-crust); color: var(--ctp-subtext0); border-color: var(--theme-border); }
.card-list-caption, .card-list-caption > span:first-child { color: var(--ctp-subtext0); }
.caption-dot { background: var(--ctp-green); }
.empty-state { background: var(--ctp-mantle); color: var(--ctp-subtext0); border-color: var(--ctp-surface1); }
.empty-state > svg { color: var(--ctp-lavender); }
.empty-state h3 { color: var(--ctp-text); }
/* Explorer, notices, and footer */
.outline { background: var(--ctp-mantle); color: var(--theme-accent); border-color: var(--ctp-surface1); }
.explorer-note { background: var(--theme-accent-soft); color: var(--ctp-subtext1); border-color: var(--ctp-surface1); }
.table-scroll { border-color: var(--theme-border); }
table { background: var(--ctp-mantle); }
th { color: var(--ctp-subtext1); background: var(--ctp-surface0); }
td { color: var(--ctp-subtext1); border-color: var(--theme-border); }
td strong { color: var(--ctp-text); }
td small { color: var(--ctp-subtext0); }
tbody tr:hover { background: var(--ctp-surface0); }
.rarity-tag { color: var(--ctp-green); background: var(--theme-success-soft); }
.rarity-tag.legendary { color: var(--ctp-yellow); background: color-mix(in srgb, var(--ctp-yellow) 12%, var(--ctp-base)); }
.rarity-tag.epic { color: var(--ctp-mauve); background: var(--theme-accent-soft); }
.rarity-tag.rare { color: var(--ctp-blue); background: color-mix(in srgb, var(--ctp-blue) 12%, var(--ctp-base)); }
.journal > div { color: var(--ctp-subtext1); background: var(--ctp-mantle); border-color: var(--theme-border); }
.muted { color: var(--ctp-subtext0); }
.error-banner { background: var(--theme-error-soft); color: var(--ctp-red); border-color: color-mix(in srgb, var(--ctp-red) 35%, var(--ctp-surface0)); }
.error-banner button { color: var(--ctp-red); }
.transfer-error { color: var(--ctp-red); background: var(--theme-error-soft); }
footer { color: var(--ctp-subtext0); border-color: var(--theme-border); }
footer > a > svg, .footer-star { color: var(--ctp-lavender); }
footer > p { color: var(--ctp-subtext0); }
.toast { background: var(--ctp-surface0); color: var(--ctp-text); border-color: var(--ctp-surface2); box-shadow: 0 6px 35px var(--theme-shadow); }
.toast > span { color: var(--ctp-green); background: var(--theme-success-soft); }
.toast > button { color: var(--ctp-subtext1); }
/* Wallet, guide, transfers, and card details */
.modal-backdrop { background: var(--theme-scrim); }
.modal { background: var(--ctp-base); color: var(--ctp-text); border-color: var(--ctp-surface1); box-shadow: 0 20px 70px var(--theme-shadow); }
.modal-close { background: var(--ctp-surface0); color: var(--ctp-subtext1); }
.modal-icon { background: var(--theme-accent-soft); color: var(--theme-accent); }
.guide-steps > div > span { color: var(--ctp-lavender); }
.guide-steps p { color: var(--ctp-subtext0); }
.info-box { background: var(--ctp-mantle); border-color: var(--theme-border); }
.info-box strong { color: var(--ctp-lavender); }
.info-box p { color: var(--ctp-subtext0); }
.field-label { color: var(--ctp-subtext0); }
.address-box { color: var(--ctp-subtext1); background: var(--ctp-mantle); border-color: var(--ctp-surface1); }
.wallet-count { color: var(--ctp-lavender); }
.recipient-input { color: var(--ctp-text); background: var(--ctp-mantle); border-color: var(--ctp-surface1); }
.recipient-input::placeholder { color: var(--ctp-subtext0); }
.detail-copy > p { color: var(--ctp-subtext1); }
.detail-facts { color: var(--ctp-subtext0); border-color: var(--theme-border); }
.detail-facts strong { color: var(--ctp-text); }
.detail-links a { color: var(--ctp-blue); }
.detail-copy p.attribution { color: var(--ctp-subtext0); }
/* Latte's green/yellow accents need darker ink for small labels and buttons. */
:root[data-theme='latte'] .lime {
color: var(--ctp-text);
background: color-mix(in srgb, var(--ctp-green) 18%, var(--ctp-base));
}
:root[data-theme='latte'] .lime:hover {
background: color-mix(in srgb, var(--ctp-green) 26%, var(--ctp-base));
}
:root[data-theme='latte'] .wiki-card .card-top {
color: color-mix(in srgb, var(--rarity-color) 45%, var(--ctp-text));
}
:root[data-theme='latte'] .rarity-tag {
color: var(--ctp-text);
}
@media (max-width: 1000px) {
.top-actions { gap: 10px; }
.theme-picker { padding: 6px 8px; gap: 6px; }
.theme-picker select { font-size: 10px; }
.top-actions .network-pill { display: none; }
}
@media (max-width: 560px) {
.topbar .breadcrumbs { display: none; }
.topbar .top-actions { width: 100%; justify-content: space-between; }
.theme-picker { min-height: 36px; }
.theme-picker select { font-size: 12px; }
}
Executable
+3
View File
@@ -0,0 +1,3 @@
.trade-desk{max-width:1180px;margin:0 auto}.trade-heading{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;margin:24px 0 28px}.trade-heading h1{font-size:32px;letter-spacing:-1px;margin:8px 0}.trade-heading p,.trade-lookup p,.trade-import>p{max-width:760px;color:var(--ctp-subtext0);font-size:12px;line-height:1.8}.trade-limit{white-space:nowrap;padding:9px 12px;border:1px solid var(--theme-border);border-radius:8px;color:var(--ctp-lavender);font-size:10px}.trade-warning,.trade-error{padding:12px 14px;border:1px solid #e78284;color:var(--ctp-red);background:color-mix(in srgb,var(--ctp-red) 9%,var(--ctp-base));border-radius:7px;font-size:12px}.trade-error{margin:12px 0}.trade-lookup,.trade-import{padding:18px 20px;margin:18px 0 24px;border:1px solid var(--theme-border);background:var(--ctp-mantle);border-radius:10px}.trade-lookup>label{display:block;margin-bottom:8px;color:var(--ctp-subtext1);font-size:9px;letter-spacing:1px}.trade-lookup>div{display:flex;gap:10px}.trade-lookup input,.trade-import textarea{min-width:0;width:100%;padding:12px;border:1px solid var(--ctp-surface1);border-radius:7px;background:var(--ctp-base);color:var(--ctp-text);font:inherit;font-size:12px}.trade-lookup .button,.trade-actions .button{display:inline-flex;align-items:center;justify-content:center;gap:8px;white-space:nowrap}.trade-lookup p{margin:9px 0 0;font-size:10px}.trade-columns{display:grid;grid-template-columns:minmax(0,1fr) 44px minmax(0,1fr);gap:16px;align-items:start}.trade-deck-list{min-width:0;padding:16px;border:1px solid var(--theme-border);border-radius:10px;background:var(--ctp-mantle)}.trade-deck-list h3{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:0 0 12px;font-size:13px;color:var(--ctp-text)}.trade-deck-list h3 span{display:grid;place-items:center;min-width:22px;height:22px;border-radius:6px;background:var(--ctp-surface0);font-size:10px}.trade-card-list{display:grid;gap:7px;max-height:440px;overflow:auto}.trade-card-row{display:grid;grid-template-columns:16px 46px minmax(0,1fr);align-items:center;gap:10px;min-height:58px;padding:6px 8px;border:1px solid transparent;border-radius:7px;background:var(--ctp-base);cursor:pointer}.trade-card-row:has(input:checked){border-color:var(--theme-accent);background:var(--theme-accent-soft)}.trade-card-row input{accent-color:var(--theme-accent)}.trade-card-row img{width:46px;height:42px;object-fit:cover;border-radius:5px;background:var(--ctp-surface0)}.trade-card-row span{min-width:0}.trade-card-row strong,.trade-card-row small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trade-card-row strong{font-size:10px;color:var(--ctp-text)}.trade-card-row small{margin-top:4px;font-size:8px;color:var(--ctp-subtext0)}.trade-arrow{display:grid;justify-items:center;gap:5px;margin-top:80px;color:var(--ctp-peach)}.trade-arrow span{font-size:8px;letter-spacing:.6px;text-transform:uppercase}.trade-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:16px 0}.trade-actions>span{color:var(--ctp-subtext0);font-size:10px}.trade-import textarea{display:block;min-height:150px;margin-top:12px;resize:vertical;font-family:ui-monospace,monospace;font-size:10px}.trade-review{margin-top:12px;padding:12px;border:1px solid var(--theme-border);border-radius:7px;background:var(--ctp-base);font-size:11px}.trade-review strong{color:var(--ctp-peach)}.trade-review p{margin:7px 0;color:var(--ctp-text);line-height:1.7}.trade-review small{color:var(--ctp-subtext0);line-height:1.6}.trade-tip{display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:var(--ctp-subtext0);font-size:9px}.trade-tip code{color:var(--ctp-text)}.trade-tip .text-button{font-size:10px}.trade-lookup input:focus-visible,.trade-import textarea:focus-visible,.trade-card-row input:focus-visible{outline:2px solid var(--theme-accent);outline-offset:2px}
@media(max-width:900px){.trade-columns{grid-template-columns:minmax(0,1fr)}.trade-arrow{display:flex;justify-content:center;align-items:center;margin:0;transform:rotate(90deg)}.trade-arrow span{display:none}}
@media(max-width:560px){.trade-heading{display:block}.trade-heading h1{font-size:26px}.trade-limit{display:inline-block;margin-top:8px}.trade-lookup,.trade-import{padding:14px}.trade-lookup>div{display:grid}.trade-lookup .button{width:100%}.trade-card-list{max-height:360px}.trade-actions .button{flex:1;font-size:10px}.trade-review{font-size:10px}}
+28
View File
@@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';
import type { PageEnrichment } from '../shared/enrichment.ts';
// One request per article shared by hero, collection and detail views. No wallet data is sent.
const cache = new Map<number, { expires: number; job: Promise<PageEnrichment> }>();
function load(pageId: number): Promise<PageEnrichment> {
const existing=cache.get(pageId);
if(existing && existing.expires>Date.now())return existing.job;
const job=(async()=>{
const response=await fetch(`/api/wikipedia/${pageId}`,{signal:AbortSignal.timeout(15000),cache:'no-store'});
if(!response.ok)throw new Error('Live Wikipedia data unavailable');
return await response.json() as PageEnrichment;
})();
cache.set(pageId,{expires:Date.now()+5*60*1000,job});
if(cache.size>128)cache.delete(cache.keys().next().value!);
return job;
}
export function useEnrichment(pageId: number) {
const [data,setData]=useState<PageEnrichment>();
const [error,setError]=useState(false);
const [attempt,setAttempt]=useState(0);
useEffect(()=>{
let active=true;setData(undefined);setError(false);
load(pageId).then(value=>{if(active)setData(value);},()=>{if(active)setError(true);});
return()=>{active=false;};
},[pageId,attempt]);
return {data,error,retry:()=>{cache.delete(pageId);setAttempt(n=>n+1);}};
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect } from 'react';
export function useModalFocus(open: boolean) {
useEffect(() => {
if (!open) return;
const previous = document.activeElement as HTMLElement | null;
const modal = document.querySelector<HTMLElement>('[role="dialog"]');
if (!modal) return;
const background = Array.from(document.querySelectorAll<HTMLElement>('.sidebar, .main-shell'));
background.forEach(el => { el.inert = true; });
const oldOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const focusable = () => Array.from(modal.querySelectorAll<HTMLElement>('a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), [tabindex="0"]')).filter(el => el.getClientRects().length > 0);
focusable()[0]?.focus();
const trap = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return;
const elements = focusable();
const first = elements[0];
const last = elements.at(-1);
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
};
modal.addEventListener('keydown', trap);
return () => {
modal.removeEventListener('keydown', trap);
background.forEach(el => { el.inert = false; });
document.body.style.overflow = oldOverflow;
if (previous?.isConnected) previous.focus();
};
}, [open]);
}
+217
View File
@@ -0,0 +1,217 @@
import { decryptWallet, encryptWallet, type WalletBackup } from '../shared/wallet-backup.ts';
import { expectedChainIdentity } from '../shared/chain-identity.ts';
export type Wallet = { publicKey: string; address: string };
type Keys = Awaited<ReturnType<typeof decryptWallet>>;
type Vault = { format: 'wikideck-local-vault'; version: 1; revision: string; backup: WalletBackup };
type Stored = Vault | Keys;
export type WalletStatus = 'empty' | 'legacy' | 'locked' | 'unlocked';
export const WALLET_IDLE_MS = 5 * 60_000;
const ABSOLUTE_MS = 4 * 60 * 60_000;
let session: { keys: Keys; handle: Wallet; revision: string; opened: number; touched: number } | undefined;
let epoch = 0;
const listeners = new Set<() => void>();
const isVault = (value: Stored): value is Vault => 'format' in value && value.format === 'wikideck-local-vault';
let databaseNamePromise: Promise<string> | undefined;
function databaseName(): Promise<string> {
if (!databaseNamePromise) databaseNamePromise = expectedChainIdentity().then(({ chainId }) => `wikideck-wallet-${chainId}-v2`);
return databaseNamePromise;
}
const encode = (bytes: ArrayBuffer) => btoa(String.fromCharCode(...new Uint8Array(bytes)));
async function openDatabase(): Promise<IDBDatabase> {
const name = await databaseName();
return new Promise((resolve, reject) => {
const request = indexedDB.open(name, 1);
let blocked = false;
request.onupgradeneeded = () => request.result.createObjectStore('keys');
request.onsuccess = () => {
if (blocked) { request.result.close(); return; }
request.result.onversionchange = () => request.result.close();
resolve(request.result);
};
request.onblocked = () => {
blocked = true;
reject(new Error('Close other Wikideck tabs to enable wallet storage, then retry.'));
};
request.onerror = () => reject(request.error ?? new Error('Enable browser storage to use your local wallet.'));
});
}
// Resolve only on transaction commit, not a request's earlier success event.
function identity(db: IDBDatabase, replacement?: Vault, expected?: Stored): Promise<Stored | undefined> {
return new Promise((resolve, reject) => {
const tx = db.transaction('keys', replacement ? 'readwrite' : 'readonly');
const store = tx.objectStore('keys');
let result: Stored | undefined;
let conflict: Error | undefined;
const request = store.get('identity');
request.onsuccess = () => {
try {
result = request.result as Stored | undefined;
if (replacement) {
if (!same(result, expected)) { conflict = new Error('Wallet changed in another tab. Reload and review before replacing.'); tx.abort(); return; }
store.put(replacement, 'identity');
result = replacement;
}
} catch (error) {
tx.abort();
reject(error);
}
};
tx.oncomplete = () => resolve(result);
tx.onabort = () => reject(conflict ?? tx.error ?? new DOMException('Wallet transaction aborted.', 'AbortError'));
tx.onerror = () => reject(tx.error ?? new Error('Could not save the wallet.'));
});
}
// Serialize this tab even without Web Locks. Atomic create-only transactions
// prevent first-use races across tabs without navigator.locks as well.
let queue: Promise<unknown> = Promise.resolve();
function withStorage<T>(operation: (db: IDBDatabase) => Promise<T>): Promise<T> {
const run = async () => {
for (let attempt = 0; ; attempt++) {
let db: IDBDatabase | undefined;
try {
db = await openDatabase();
return await operation(db);
} catch (error) {
if (attempt >= 2 || !(error instanceof DOMException) ||
!['AbortError', 'UnknownError', 'InvalidStateError'].includes(error.name)) throw error;
} finally { db?.close(); }
}
};
const result = queue.then(async (): Promise<T> => {
const name = await databaseName();
if (typeof navigator !== 'undefined' && navigator.locks) {
return await navigator.locks.request(`${name}:identity`, run);
}
return await run();
});
queue = result.catch(() => undefined);
return result;
}
function same(a: Stored | undefined, b: Stored | undefined): boolean {
if (!a || !b) return a === b;
return isVault(a) && isVault(b) ? JSON.stringify(a) === JSON.stringify(b)
: !isVault(a) && !isVault(b) && a.publicKey === b.publicKey && a.address === b.address;
}
const read = () => withStorage(db => identity(db));
let channel: BroadcastChannel | undefined;
let channelGeneration = 0;
function notify() { for (const fn of listeners) fn(); }
export function lockWallet(broadcast = true): void {
epoch++; session = undefined;
if (broadcast) channel?.postMessage('lock');
notify();
}
function validSession() {
if (session && (Date.now() - session.touched >= WALLET_IDLE_MS || Date.now() - session.opened >= ABSOLUTE_MS)) lockWallet();
return session;
}
export function touchWallet(): void {
const current = validSession(); if (current) current.touched = Date.now();
}
export function subscribeWallet(fn: () => void): () => void {
listeners.add(fn); return () => { listeners.delete(fn); };
}
export function watchWalletLifecycle(): () => void {
const generation = ++channelGeneration;
const activity = (e: Event) => { if (e.isTrusted) touchWallet(); };
const visibility = () => { if (document.visibilityState !== 'visible') lockWallet(); };
const hide = () => lockWallet();
if (typeof BroadcastChannel !== 'undefined') {
void databaseName().then(name => {
if (generation !== channelGeneration) return;
channel = new BroadcastChannel(`${name}:lock`);
channel.onmessage = () => lockWallet(false);
}).catch(() => {});
}
document.addEventListener('pointerdown',activity); document.addEventListener('keydown',activity);
document.addEventListener('visibilitychange',visibility); window.addEventListener('pagehide',hide);
const timer = setInterval(() => { validSession(); },1000);
return () => {
clearInterval(timer); document.removeEventListener('pointerdown',activity); document.removeEventListener('keydown',activity);
document.removeEventListener('visibilitychange',visibility); window.removeEventListener('pagehide',hide);
channelGeneration++; channel?.close(); channel = undefined; lockWallet(false);
};
}
export async function walletStatus(): Promise<WalletStatus> {
if (await getWallet()) return 'unlocked';
const value = await read(); return value ? isVault(value) ? 'locked' : 'legacy' : 'empty';
}
export async function getWallet(): Promise<Wallet | undefined> {
const current = validSession(); if (!current) return undefined;
const saved = await read();
if (session !== current) return undefined; // An old async read must not lock a newer unlock session.
if (!saved || !isVault(saved) || saved.revision !== current.revision) { lockWallet(); return undefined; }
return validSession() === current ? current.handle : undefined;
}
function requireSession(wallet: Wallet) {
const current = validSession();
if (!current || current.handle !== wallet) throw new Error('Wallet is locked or changed. Unlock and review again.');
return current;
}
async function install(keys: Keys, backup: WalletBackup, expected: Stored | undefined, token: number): Promise<Wallet> {
if (token !== epoch) throw new Error('Wallet operation cancelled by locking.');
const vault: Vault = { format:'wikideck-local-vault', version:1, revision:crypto.randomUUID(), backup };
// CAS inside a single readwrite transaction protects tabs even without Web Locks.
await withStorage(db => identity(db,vault,expected));
if (token !== epoch) throw new Error('Wallet saved encrypted but remains locked. Unlock to continue.');
channel?.postMessage('lock');
const handle = Object.freeze({address:keys.address,publicKey:keys.publicKey});
session = {keys,handle,revision:vault.revision,opened:Date.now(),touched:Date.now()}; notify();
return handle;
}
export async function protectWallet(password: string): Promise<Wallet> {
const token = ++epoch; session = undefined; notify();
const existing = await read();
if (existing && isVault(existing)) throw new Error('An encrypted wallet already exists. Unlock or explicitly recover a backup.');
let keys: Keys;
if (existing) keys = existing;
else {
const pair = await crypto.subtle.generateKey({name:'ECDSA',namedCurve:'P-256'},true,['sign','verify']);
const spki = await crypto.subtle.exportKey('spki',pair.publicKey);
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256',spki));
keys = {privateKey:pair.privateKey,publicKey:encode(spki),address:Array.from(digest,b=>b.toString(16).padStart(2,'0')).join('')};
}
const backup = await encryptWallet(keys.privateKey,keys.publicKey,password);
const verified = await decryptWallet(backup,password);
if (existing && verified.address !== existing.address) throw new Error('Legacy wallet identity mismatch; original record preserved.');
return install(verified,backup,existing,token);
}
export async function unlockWallet(password: string): Promise<Wallet> {
const token = ++epoch; session = undefined; notify();
const saved = await read();
if (!saved || !isVault(saved) || saved.version !== 1) throw new Error('Create or encrypt the local wallet first.');
const keys = await decryptWallet(saved.backup,password);
const latest = await read();
if (token !== epoch || !same(latest,saved)) throw new Error('Wallet changed or was locked while unlocking.');
const handle = Object.freeze({address:keys.address,publicKey:keys.publicKey});
session = {keys,handle,revision:saved.revision,opened:Date.now(),touched:Date.now()}; notify();
return handle;
}
export async function sign(wallet: Wallet, message: string): Promise<string> {
const current = requireSession(wallet), token = epoch;
if (!await getWallet() || token !== epoch) throw new Error('Wallet locked or storage changed before signing.');
const signature = encode(await crypto.subtle.sign({name:'ECDSA',hash:'SHA-256'},current.keys.privateKey,new TextEncoder().encode(message)));
if (!await getWallet() || token !== epoch || requireSession(wallet) !== current) throw new Error('Wallet locked during signing. Signature discarded.');
return signature;
}
export async function exportWallet(wallet: Wallet, password: string): Promise<WalletBackup> {
const current = requireSession(wallet), token = epoch;
if (!await getWallet()) throw new Error('Wallet storage changed.');
const backup = await encryptWallet(current.keys.privateKey,current.keys.publicKey,password);
if (!await getWallet() || token !== epoch || requireSession(wallet) !== current) throw new Error('Wallet locked during export.');
return backup;
}
export async function restoreWallet(backup: unknown, password: string, localPassword: string, replace = false): Promise<Wallet> {
const token = ++epoch; session = undefined; notify();
const existing = await read();
if (existing && !replace) throw new Error('Explicit replacement confirmation required for the stored wallet.');
const keys = await decryptWallet(backup,password);
const encrypted = await encryptWallet(keys.privateKey,keys.publicKey,localPassword);
await decryptWallet(encrypted,localPassword);
return install(keys,encrypted,existing,token);
}
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
docker compose up --build -d
docker compose ps
docker compose logs -f wikideck
-261
View File
@@ -1,261 +0,0 @@
import json
import os
import requests
import sys
import unittest
import uuid
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'wikideck')))
from Mine.Block import Block
from Mine.Transaction import Transaction
WIKIDECK_URL = os.getenv('WIKIDECK_URL', 'http://localhost:8080/')
class TestMine(unittest.TestCase):
def test_mine_block_send_card_mine_another_block(self):
# Create two users
privateKeyA = rsa.generate_private_key(
public_exponent=65537,
key_size=4096
)
privateKeyB = rsa.generate_private_key(
public_exponent=65537,
key_size=4096
)
# Mine a block with privateKeyA
response = requests.get(f"{WIKIDECK_URL}/blocks")
self.assertEqual(response.status_code, 200)
newBlockA = Block(
data = response.json()
)
newBlockA.mine(privateKeyA)
response = requests.post(f"{WIKIDECK_URL}/blocks", json=newBlockA.as_dict())
self.assertEqual(response.status_code, 200)
# Send card from newBlockA to privateKeyB
newTransaction = Transaction(
cardId = newBlockA.card.cardId,
sender = privateKeyA.public_key(),
receiver = privateKeyB.public_key(),
authorPrivateKey = privateKeyA
)
response = requests.post(f"{WIKIDECK_URL}/transactions", json=newTransaction.as_dict())
self.assertEqual(response.status_code, 200)
# Mine a block with privateKeyB
response = requests.get(f"{WIKIDECK_URL}/blocks")
self.assertEqual(response.status_code, 200)
newBlockB = Block(
data = response.json()
)
newBlockB.mine(privateKeyB)
self.assertEqual(newBlockA.blockHash.hexdigest(), newBlockB.previousHash)
self.assertTrue(newBlockA.timestamp < newBlockB.timestamp)
self.assertTrue(newBlockB.height == newBlockA.height + 1)
response = requests.post(f"{WIKIDECK_URL}/blocks", json=newBlockB.as_dict())
self.assertEqual(response.status_code, 200)
# Validate decks
response = requests.get(f"{WIKIDECK_URL}/cards", params={
'publicKey': privateKeyA.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode('utf-8')
})
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 0)
response = requests.get(f"{WIKIDECK_URL}/cards", params={
'publicKey': privateKeyB.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode('utf-8')
})
self.assertEqual(response.json()[0]['cardId'], str(newBlockA.card.cardId))
self.assertEqual(response.json()[0]['pageId'], newBlockA.card.pageId)
self.assertEqual(response.json()[1]['cardId'], str(newBlockB.card.cardId))
self.assertEqual(response.json()[1]['pageId'], newBlockB.card.pageId)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 2)
def test_invalid_block_modified_transaction(self):
# Create two users
privateKeyA = rsa.generate_private_key(
public_exponent=65537,
key_size=4096
)
privateKeyB = rsa.generate_private_key(
public_exponent=65537,
key_size=4096
)
# Mine a block with privateKeyA
response = requests.get(f"{WIKIDECK_URL}/blocks")
self.assertEqual(response.status_code, 200)
newBlockA = Block(
data = response.json()
)
newBlockA.mine(privateKeyA)
response = requests.post(f"{WIKIDECK_URL}/blocks", json=newBlockA.as_dict())
self.assertEqual(response.status_code, 200)
# Send card from newBlockA to privateKeyB
newTransaction = Transaction(
cardId = newBlockA.card.cardId,
sender = privateKeyA.public_key(),
receiver = privateKeyB.public_key(),
authorPrivateKey = privateKeyA
)
response = requests.post(f"{WIKIDECK_URL}/transactions", json=newTransaction.as_dict())
self.assertEqual(response.status_code, 200)
# Mine a block with privateKeyB
response = requests.get(f"{WIKIDECK_URL}/blocks")
self.assertEqual(response.status_code, 200)
newBlockB = Block(
data = response.json()
)
# Modify transactions
newBlockB.transactions[0].sender = privateKeyB.public_key()
newBlockB.transactions[0].receiver = privateKeyA.public_key()
newBlockB.mine(privateKeyB)
self.assertEqual(newBlockA.blockHash.hexdigest(), newBlockB.previousHash)
self.assertTrue(newBlockA.timestamp < newBlockB.timestamp)
self.assertTrue(newBlockB.height == newBlockA.height + 1)
response = requests.post(f"{WIKIDECK_URL}/blocks", json=newBlockB.as_dict())
self.assertEqual(response.status_code, 400)
# Validate decks
response = requests.get(f"{WIKIDECK_URL}/cards", params={
'publicKey': privateKeyA.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode('utf-8')
})
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
self.assertEqual(response.json()[0]['cardId'], str(newBlockA.card.cardId))
self.assertEqual(response.json()[0]['pageId'], newBlockA.card.pageId)
response = requests.get(f"{WIKIDECK_URL}/cards", params={
'publicKey': privateKeyB.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode('utf-8')
})
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 0)
# Mine a block with privateKeyB
response = requests.get(f"{WIKIDECK_URL}/blocks")
self.assertEqual(response.status_code, 200)
newBlockB = Block(
data = response.json()
)
newBlockB.mine(privateKeyB)
self.assertEqual(newBlockA.blockHash.hexdigest(), newBlockB.previousHash)
self.assertTrue(newBlockA.timestamp < newBlockB.timestamp)
self.assertTrue(newBlockB.height == newBlockA.height + 1)
response = requests.post(f"{WIKIDECK_URL}/blocks", json=newBlockB.as_dict())
self.assertEqual(response.status_code, 200)
# Validate decks
response = requests.get(f"{WIKIDECK_URL}/cards", params={
'publicKey': privateKeyA.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode('utf-8')
})
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 0)
response = requests.get(f"{WIKIDECK_URL}/cards", params={
'publicKey': privateKeyB.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode('utf-8')
})
self.assertEqual(response.json()[0]['cardId'], str(newBlockA.card.cardId))
self.assertEqual(response.json()[0]['pageId'], newBlockA.card.pageId)
self.assertEqual(response.json()[1]['cardId'], str(newBlockB.card.cardId))
self.assertEqual(response.json()[1]['pageId'], newBlockB.card.pageId)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 2)
def test_invalid_transaction(self):
# Create three users
privateKeyA = rsa.generate_private_key(
public_exponent=65537,
key_size=4096
)
privateKeyB = rsa.generate_private_key(
public_exponent=65537,
key_size=4096
)
privateKeyC = rsa.generate_private_key(
public_exponent=65537,
key_size=4096
)
# Mine a block with privateKeyA
response = requests.get(f"{WIKIDECK_URL}/blocks")
self.assertEqual(response.status_code, 200)
newBlockA = Block(
data = response.json()
)
newBlockA.mine(privateKeyA)
response = requests.post(f"{WIKIDECK_URL}/blocks", json=newBlockA.as_dict())
self.assertEqual(response.status_code, 200)
# Send card from newBlockA to privateKeyB
newTransaction = Transaction(
cardId = newBlockA.card.cardId,
sender = privateKeyA.public_key(),
receiver = privateKeyB.public_key(),
authorPrivateKey = privateKeyA
)
# Invalidate transaction
newTransaction.cardId = uuid.uuid4()
response = requests.post(f"{WIKIDECK_URL}/transactions", json=newTransaction.as_dict())
self.assertEqual(response.status_code, 400)
newTransaction.cardId = newBlockA.card.cardId
newTransaction.sender = privateKeyC.public_key()
response = requests.post(f"{WIKIDECK_URL}/transactions", json=newTransaction.as_dict())
self.assertEqual(response.status_code, 400)
newTransaction.sender = privateKeyA.public_key()
newTransaction.receiver = privateKeyC.public_key()
response = requests.post(f"{WIKIDECK_URL}/transactions", json=newTransaction.as_dict())
self.assertEqual(response.status_code, 400)
newTransaction.receiver = privateKeyB.public_key()
newTransaction.sign(privateKeyC)
response = requests.post(f"{WIKIDECK_URL}/transactions", json=newTransaction.as_dict())
self.assertEqual(response.status_code, 400)
newTransaction.sign(privateKeyA)
response = requests.post(f"{WIKIDECK_URL}/transactions", json=newTransaction.as_dict())
self.assertEqual(response.status_code, 200)
# Mine a block with privateKeyB
response = requests.get(f"{WIKIDECK_URL}/blocks")
self.assertEqual(response.status_code, 200)
newBlockB = Block(
data = response.json()
)
newBlockB.mine(privateKeyB)
self.assertEqual(newBlockA.blockHash.hexdigest(), newBlockB.previousHash)
self.assertTrue(newBlockA.timestamp < newBlockB.timestamp)
self.assertTrue(newBlockB.height == newBlockA.height + 1)
response = requests.post(f"{WIKIDECK_URL}/blocks", json=newBlockB.as_dict())
self.assertEqual(response.status_code, 200)
# Validate decks
response = requests.get(f"{WIKIDECK_URL}/cards", params={
'publicKey': privateKeyA.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode('utf-8')
})
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 0)
response = requests.get(f"{WIKIDECK_URL}/cards", params={
'publicKey': privateKeyB.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode('utf-8')
})
self.assertEqual(response.json()[0]['cardId'], str(newBlockA.card.cardId))
self.assertEqual(response.json()[0]['pageId'], newBlockA.card.pageId)
self.assertEqual(response.json()[1]['cardId'], str(newBlockB.card.cardId))
self.assertEqual(response.json()[1]['pageId'], newBlockB.card.pageId)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 2)
if __name__ == '__main__':
unittest.main()
View File

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