More cards

This commit is contained in:
2026-10-19 18:52:49 -04:00
parent 716c9612cc
commit 0b9061372b
26 changed files with 549 additions and 201 deletions
+54 -67
View File
@@ -1,12 +1,12 @@
# Wikideck
**Mine knowledge. Collect the extraordinary.** An independent, experimental **RandomX testnet** with native NFTs, independently validating nodes, and recoverable P-256 wallets. Built with React, TypeScript, Express, Web Crypto, and a pinned native RandomX engine.
Wikideck is an experimental, self-hosted RandomX testnet where each mined block creates a native NFT from a random English Wikipedia article. A page may be minted more than once; each NFT commits its own article and mint-time statistics snapshot, including a Wikimedia-hosted picture. It includes a React interface, a validating node, local P-256 wallets, transfers, and current off-chain Wikipedia statistics.
**Not a public mainnet.** No public bootnodes or deployed public network are supplied. Test assets have no promised value; launch with no funds. This is not Monero mining, an Ethereum/ERC-721 NFT, an ASIC-proof claim, or an audited security product. Old browser-mined demo assets and wallets are separate: there is no migration.
**This is a local test network, not a public mainnet or an investment product.** No public bootnodes or hosted network are provided. Test NFTs have no promised value. Do not use funds or valuable assets. This project is not affiliated with Wikipedia or the Wikimedia Foundation.
## Quick start: three local validators
## Run the three-node testnet (Docker)
Install Docker Desktop with Linux containers and Compose v2. From the repository root:
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
@@ -14,106 +14,93 @@ docker compose -f compose.testnet.yaml ps
docker compose -f compose.testnet.yaml logs -f node-a
```
Open **http://127.0.0.1:3101** (node-a); node-b and node-c are at **3102** and **3103**. Each independently validates blocks and has its own named volume: `testnet-a`, `testnet-b`, `testnet-c` (Compose prefixes actual volume names). **No miner starts automatically.** A healthy node is not proof of connected peers; inspect the UI peer count or `/api/peers`.
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 with `docker compose -f compose.testnet.yaml down`; restart with the first command. Volumes survive ordinary shutdown and rebuilds. **Adding `-v` deletes all three ledgers.** Stop nodes before making consistent volume backups; never share one writable data directory between nodes. Back up wallets separately.
Stop the nodes with:
The default [compose.yaml](compose.yaml) instead runs one node at **http://127.0.0.1:3001**, with a different `wikideck-data` volume; it is not the three-node setup. Existing project-local demo data is excluded from the image and is not imported. Current nodes write a separate testnet ledger, not the old demo chain.
```sh
docker compose -f compose.testnet.yaml down
```
## Host build: Windows or Linux
The volumes persist across stops and rebuilds. **Do not add `-v` unless you intend to delete all three ledgers.** Back up wallets separately. Protocol v4 uses a new chain identity; earlier v2/v3 ledgers and encrypted backups are not compatible. To keep old Docker data intact while trying v4, use a separate Compose project name, for example `docker compose -p wikideck-v4 -f compose.testnet.yaml up --build -d`, and use the same `-p wikideck-v4` for subsequent Compose commands. The default `docker compose up --build -d` uses [compose.yaml](compose.yaml): it starts one isolated node on port 3001 with a different volume.
Use **Node.js 24 LTS**, npm, Git, CMake **3.24+**, and a compatible C/C++17 toolchain. Initial native configuration fetches pinned RandomX over HTTPS. On Windows, put CMake, Git, the MinGW compiler, make, and compiler runtime DLLs on PATH; use a consistent 64-bit toolchain. From a terminal in the repository root:
For an existing host install, keep the old data directory intact and set `WIKIDECK_DATA_DIR` to a new empty directory before starting protocol v4. The node intentionally fails closed on an old ledger; it does not reset or migrate it.
```powershell
## 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 -- -G "MinGW Makefiles"
npm run native:configure
npm run native:build
npm run test:native
npm run build
npm start
```
Open **http://127.0.0.1:3001**. In PowerShell, use `npm.cmd` in place of `npm` if execution policy blocks the npm PowerShell shim. Do not bypass a managed policy. The optional [Windows script](scripts/build-randomx.ps1) also requires permission to run: `./scripts/build-randomx.ps1 -Generator 'MinGW Makefiles' -Test`. The npm commands above invoke CMake directly rather than that script. Do not switch generators inside an existing native build directory; use a fresh build directory when changing toolchains.
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.
On Linux, install the distribution's C/C++ build tools, CMake and Git; run `npm install`, `sh scripts/build-randomx.sh --test`, `npm run build`, then `npm start`. MSVC requires a developer terminal with C++/MASM tooling; omit the MinGW generator and review upstream PowerShell build requirements. See [native build, memory, and line-protocol details](docs/randomx-native.md). The current node and CLI use that engine, with no fake production fallback.
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.
For development, `npm run dev` starts the loopback API on **3001** and Vite on **5173**; open **http://127.0.0.1:5173**. Build the native engine first. Never expose Vite publicly. Stop conflicting servers before reusing a port.
## Wallets and backups
## Wallet backup and recovery
The browser creates a local, recoverable P-256 wallet. Open the wallet panel and use **Wallet backup & recovery** to download an encrypted backup. Choose a unique password of at least 12 characters, keep the file outside the repository, and store the password separately. Test recovery in another browser profile before relying on the backup.
1. Open the wallet button, then **Wallet backup & recovery**. The browser creates an **exportable ECDSA P-256** key in IndexedDB; its address is SHA-256 of the canonical SPKI public key.
2. Download the encrypted backup with a strong, unique password (12–1024 characters). Confirm the download actually saved; store the file **outside the repository/Docker build context**, separately from its password. Do not commit either.
3. Test recovery in another browser profile: choose the backup, enter its password, confirm replacement of any active wallet, and recover. Check the receive address matches. Back up the replaced wallet first; reload other open tabs before signing.
The password encrypts the downloaded backup; it does **not** encrypt the key stored in browser IndexedDB. Same-origin scripts and access to the browser profile may expose that key. Clearing site data without a usable backup loses access. Wallets are scoped to an origin (scheme, hostname, and port), so a backup must be restored at another origin to use the same identity there. A restored wallet controls the same address; it does not move assets between chains.
Portable backups use **AES-256-GCM** with authenticated headers, a fresh 16-byte salt and 12-byte IV, and **PBKDF2-SHA256, 600,000 iterations**. Export/import happens locally; this is control of a signing key, **not a server login**. No node endpoint accepts private keys, backups, or passwords. Public keys, addresses and signatures are shared normally.
The current testnet wallet is separate from any wallet used by an older demo. There is no automatic migration. See [consensus, wallets, and operational limits](docs/testnet-consensus.md) and the in-app backup warnings for more detail.
**IndexedDB itself is not password-encrypted.** Same-origin scripts/XSS or browser-profile access may use or export the key. A backup password protects the portable file, not an unlocked browser or miner. Anyone with file and password controls the wallet. Lost passwords cannot be reset; clearing site data without a usable backup loses access.
## Mine only by explicit choice
Use a consistent browser **scheme + hostname + port**. localhost, 127.0.0.1, 5173, 3001 and 3101–3103 are different origins with separate wallet storage. Restore the backup at another origin to use the same identity; recovery does not move assets between chains. The UI uses same-origin RPC (Vite proxies it in development), **trusts that node's reported state**, and is not an SPV or independently verifying browser client.
## Mine explicitly, outside the browser
After building the native engine, use the browser's encrypted backup in an **interactive terminal**:
Mining uses the native CPU RandomX engine. The browser page never mines or launches a native program. First build the engine, download an encrypted wallet backup, then run the CLI from an interactive terminal and enter the password at its hidden prompt:
```sh
npm run miner -- --wallet "path/to/encrypted-backup.json" --consent --node http://127.0.0.1:3101 --blocks 3
```
Replace the quoted path with your saved file. Enter the backup password at the hidden local prompt: **never put passwords in environment variables or command-line arguments**. The backup/password are not sent to the node. `--consent` is required; defaults are one worker, light mode, one block, and node port 3001. Add `--fast` only after budgeting memory. **Ctrl+C stops mining; closing the browser does not.** The page never mines or launches native programs.
Use port 3001 for the default single host/Docker node, or 3101–3103 for the three-node Docker testnet. The command above uses node-a. The miner defaults to one worker, light mode, and one block; `--blocks 3` requests up to three accepted blocks. Press **Ctrl+C** to stop it. Do not put the backup password in a command, environment variable, or repository file. The backup and password are decrypted locally and are not sent to the node.
- Light mode: **256 MiB cache**, about 2 MiB scratchpad, plus process overhead.
- Fast mode: about **2080 MiB dataset plus the retained 256 MiB cache**, scratchpad and overhead: budget **over 2.3 GiB per miner process**. Each validating node also needs its own light-mode cache. CPU, heat, energy and initialization costs are real; the Compose nodes' 1 GiB limits are not fast-miner budgets.
- Use HTTPS for remote miner endpoints. The CLI rejects non-loopback HTTP unless explicitly overridden; do not weaken this for public connections.
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.
The Docker runtime image includes the native engine and can run the TypeScript miner directly with Node 24; it has no runtime tsx/development dependencies. Container mining is manual, needs an interactive TTY, a read-only mounted backup, and a container-reachable node URL. A host-run CLI as above avoids Windows host-networking pitfalls. Do not bake wallets into an image or start mining as a node service.
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).
## Collect, transfer, and inspect
## What the app shows
Each canonical block mints one **native NFT**; the same article may be minted repeatedly with different token IDs. Preview cards are not owned. Card details and the explorer distinguish NFT token ID, block ID and RandomX proof hash. Rarity/stats are proof-derived game values, not Wikipedia facts or a fair economic lottery.
- **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.
- 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.
Use another profile or a second wallet to receive a transfer. Signing submits it to the mempool: **HTTP 202 is pending, not changed ownership**. Ownership changes only after inclusion in the canonical chain. A mint at height `h` can first transfer in block `h+2`; the pool may accept it at tip `h+1` for next-block inclusion. Confirmations count the mint block itself. Reorganizations can remove mints, reverse ownership and change confirmations; there is no fixed finality or undo for a wrong address.
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.
Metadata is a **fixed four-article catalog of original starter summaries**, not a live Wikipedia API feed. The catalog is genesis-bound; expanding it currently requires a new genesis/chain. Page IDs agree across catalog and state. See [consensus, identifiers, peer RPC and limits](docs/testnet-consensus.md).
## Tests and development checks
### Live Wikipedia card insights
```sh
npm test
npm run build
npm run test:native
```
Cards also display **optional off-chain enrichment**, separate from the minted snapshot: human-readable description, visible Wikipedia categories (up to 50), article size in bytes, other-language links (up to 500, labeled as a lower bound if truncated), Wikidata ID, current revision, and six complete UTC months of Wikimedia page views. The details show the latest monthly count, daily average, six-month total, month-over-month change, and a readership chart. Counts use English Wikipedia, all access methods, and Analytics' `user` traffic classification; classified automated traffic is excluded. Article renames can affect title-based history. Missing/incomplete API data is shown as unavailable, never invented zeroes.
`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.
Three **live scores** range from 0–100, rounded and capped: **Attention** = log₁₀(latest complete month's views + 1) / 6 × 100; **Depth** = log₁₀(article bytes + 1) / 6 × 100; **Reach** = log₁₀(language links + 1) / 3 × 100. Depth is a size proxy, not article quality. These scores may change; the original proof-derived Power/Defense/Knowledge, rarity, NFT ownership, and consensus do not. Live revision and retrieval time are shown explicitly, with Wikipedia/Wikimedia attribution.
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:
`GET /api/wikipedia/:pageId` only enriches current catalog IDs. The node contacts fixed Wikimedia HTTPS hosts with no redirects, bounded time/response sizes, four concurrent jobs, per-article request deduplication, and a bounded cache (up to six hours; shorter for partial failures). API outages do not block mining, validation, or transfers. No live enrichment is written into blocks, NFT state, or the genesis manifest; existing chains remain compatible. Rebuild the Docker image to serve this feature from containers.
```sh
node --experimental-strip-types scripts/testnet-smoke.ts --consent
```
## Connect your own independent nodes
This mines three real blocks using disposable identities. It changes the testnet, and is not run automatically.
Operators must provision their own nodes, separate storage and connectivity; multiple containers on one machine are not independent security domains. Configure `WIKIDECK_PEERS` as a comma-separated list of fixed peer **origins**, for example `https://peer-a.example.org,https://peer-b.example.org` after replacing these placeholders with actual operator-controlled endpoints. Default policy requires HTTPS and public IP/DNS answers; credentials, paths, queries, fragments and redirects are rejected. At most 16 peers; no bootnodes, DHT, discovery or peer-list expansion.
## Network and safety limits
`WIKIDECK_ALLOW_PRIVATE_PEERS=1` explicitly permits private addresses and HTTP; the three-node Compose file uses this only for its private Docker test network. Do not carry that exception into public peer configuration. Peer URLs are operator settings, never accepted from remote peers.
Nodes use only explicitly configured peers; there is no discovery service or public bootstrap network. The peer, storage, and HTTP configuration is documented in [docs/testnet-consensus.md](docs/testnet-consensus.md). The implementation is experimental: it has no fees or economic security, and remains vulnerable to resource exhaustion, isolation/eclipsing, censorship, majority-hashpower attacks, and reorganizations. Storage limits are stop limits rather than a scalable production design. HTTPS, container hardening, and passing tests do not constitute a security audit.
For a remote experiment, choose your own public domain and HTTPS origin, reverse proxy the intended node port, and set `PUBLIC_ORIGIN` to that exact frontend origin. Set `HOST` deliberately if not loopback, firewall backend ports and configure peers on each node. This describes required operator work, **not an already-deployed or production-ready public network**; HTTPS and origin checks do not provide consensus/economic security or authentication.
Before considering any broader deployment, the protocol and cryptography would need independent audits, adversarial network testing, hardened abuse controls, and tested recovery procedures. Do not expose a public node or use valuable assets on this experimental network.
| Setting | Current behavior |
| --- | --- |
| `HOST`, `PORT` | Host defaults: 127.0.0.1, 3001; image binds 0.0.0.0:3001 inside container. |
| `WIKIDECK_DATA_DIR` | Defaults to project data; image uses /app/data; testnet ledger lives in its testnet subdirectory. |
| `RANDOMX_BINARY` | Optional trusted executable override; otherwise host native build output. Image sets its packaged binary. |
| `PUBLIC_ORIGIN` | Additional exact allowed POST origin; not a browser RPC node selector or login. |
| `WIKIDECK_PEERS`, `WIKIDECK_ALLOW_PRIVATE_PEERS` | Explicit outbound allowlist and opt-in private/HTTP permission; empty peers means isolated. |
| `WIKIDECK_PORT` | Default Compose host-port interpolation only; does not change fixed 3101–3103 testnet mappings. |
## Attribution
## Validation and operational limits
`npm run build` type-checks client/server and builds the UI. `npm test` uses a **test-only fake PoW** for fast fork/ledger tests, plus real native HTTP integration **when the executable exists** (otherwise that test skips). Production has no fake selector/fallback. `npm run test:native` runs the upstream known vector after native configuration with tests enabled; the Docker build also builds RandomX and runs CTest. These checks do not certify consensus safety or a successful deployment on every platform.
For an explicitly opted-in live check against the three local Docker nodes, run `node --experimental-strip-types scripts/testnet-smoke.ts --consent` after building the host native engine. This mines **three real testnet blocks** with disposable, non-recoverable test identities, submits and mines an NFT transfer, and asserts all nodes agree on work, tip, and ownership. It changes the test network; it is never run automatically. Docker restart durability has also been exercised on the three-node setup. Fork-choice/reorg tests use the labeled fast test engine; the HTTP and live-container tests use actual RandomX.
Storage is deliberately bounded: **128 MiB ledger, 10,000 total DAG blocks including forks, 256 pending transfers**. Every DAG node retains a complete NFT state; memory can grow quadratically despite disk caps. Whole-ledger atomic rewrites and full startup revalidation are unsuitable for a long-lived public chain. Corrupt/mismatched data fails closed rather than resetting. The HTTP process exclusively creates a data-directory lock; after a crash, **stop/verify all owning processes are gone before manually removing the stale lock**. There is no automatic stale-lock recovery.
The image runs nonroot; Compose uses loopback publishing, separate volumes, read-only roots, dropped capabilities, no-new-privileges and resource limits. There are no admin mint keys; ownership requires valid mint/transfer signatures, and private keys stay local. These measures are not a security audit. No fees or economic security exist; spam/DoS, eclipse and majority-hashpower (51%) attacks, censorship, rollback and reorgs remain material risks. Obtain independent protocol/cryptographic audits, adversarial network testing, hardened storage/abuse controls and recovery procedures before any broader launch. **No funds or valuable assets.**
Run `npm audit`, rebuild regularly, and scan the actual container/base OS and native dependencies for CVEs. Zero npm advisories is **not** an OS-image/native-code vulnerability guarantee; known-vector success is not an audit.
## Appearance and attribution
Official [Catppuccin](https://catppuccin.com/palette/) flavors: **Mocha** (default), **Macchiato**, **Frappé**, and light **Latte**. The top-bar selector saves the flavor locally, restores it before paint, and synchronizes same-origin tabs. With blocked storage it still switches for the page; themes never change wallets or chain state.
Independent experiment, not affiliated with Wikipedia or the Wikimedia Foundation. Current starter summaries are original, explicitly labeled, and link real articles. Wikipedia contributor text, where reused, is [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/); Commons images have file-specific licenses and credits—open **Image & license** before reuse. Native NFT ownership grants no copyright or exclusive rights to article text/images. Linked Wikipedia content may be unsuitable for some audiences; no age filtering is provided.
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.
+18 -20
View File
@@ -1,9 +1,22 @@
# Native RandomX engine
This is the CPU proof engine used by the Wikideck testnet node and explicit opt-in
native miner. Browser mining has been removed from the active application. The
Docker image builds this pinned implementation and runs its known-vector test.
There is no GPU path, simulated hash, or production fallback hash.
native miner. Mining runs only when the operator starts the native CLI; the browser
does not mine or launch native programs. The Docker image builds this pinned
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
@@ -47,8 +60,8 @@ must permit the script; do not bypass a managed policy. POSIX customization:
CMake environment or a manual configuration. Don't switch generators in an
existing build directory.
All generators place the executable directly under [native/build](../native/build)
(with `.exe` on Windows), including Release builds with multi-config generators.
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.
@@ -177,18 +190,3 @@ 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.
## Validation record
Verified on Windows x64 with CMake 3.26.4, MinGW GCC 13.1.0 and Node 24.22.0:
- Release native build with two compiler jobs; opt-in CTest upstream vector passed.
- Production TypeScript/application build passed.
- Ad-hoc actual-native light-mode checks passed: FIFO hashes, same-key reuse,
key changes and restoration, mining success/exhaustion, target equality and
big-endian comparison, LE8 nonce handling, 8192-byte blob, strict invalid-input
rejection, native uint64 nonce output, missing binary, queue bounds, crash
rejection, idempotent close, and stdin EOF.
- Both build scripts passed syntax checks.
Fast-mode allocation/runtime and Linux/macOS/MSVC builds were not run. The memory
and initialization cost of the full dataset remains an explicit opt-in.
+28 -12
View File
@@ -2,24 +2,39 @@
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).
## Network, genesis and catalog
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.
- Network: `wikideck-randomx-testnet-2`, block version **2**. Genesis has no blocks, NFTs or work; first mined height is 1. No privileged issuance/admin mint key.
## 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-4`, block version **4**. Genesis has no blocks, NFTs or work; first mined height is 1. No privileged issuance/admin mint key. Version 4 has a new chain identity; prior-version ledger files and wallet backups are not compatible.
- `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, STARTER_ARTICLES.map(articleTuple)]))`, using the constants object's insertion order in the implementation.
- `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.
- The [catalog](../shared/catalog.ts) contains four fixed **original summaries**: Albert Einstein (736), Moon (19331), Octopus (22780), Periodic table (23053). No live Wikipedia request determines consensus metadata. `articleTuple = [pageId, revisionId, title, extract, image-or-empty-string, url, source, category]`.
- The next catalog index is the big-endian integer `S(CHAIN_ID + ':article:' + previousHash)` modulo 4. Validation requires the exact selected tuple. NFT state stores that article's same `pageId`. Changing the catalog/constants currently changes genesis: coordinate a new chain, not a live catalog expansion or demo-asset migration.
- [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), owner, publicKey, target, key, transactionsRoot, stateRoot])`. 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.
`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]))`. Independent of nonce/proof/signature, avoiding a circular dependency through state commitment. |
| 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.
@@ -48,7 +63,7 @@ Per-block work is `floor(2^256 / (target + 1))`; accumulate from genesis work ze
On reorg, reconstruct ownership from the winning branch. Orphan mints disappear from canonical ownership; disconnected transfers are reconsidered before existing pending entries, in order, only if still valid and not already committed. Conflicting, immature or orphan-issuance transfers are discarded. No checkpoints/finality or rollback protection beyond observed work; an isolated node has no peer-backed security.
The [ledger](../server/testnet/ledger.ts) stores the entire DAG and pending pool in [the testnet ledger](../data/testnet/ledger.json), under `WIKIDECK_DATA_DIR`'s testnet subdirectory. Defaults use project data; Docker uses /app/data. Disk caps: **128 MiB, 10,000 total blocks including side branches**; blocks: **64 KiB and 64 transfers**. Each in-memory DAG node stores a full NFT-state snapshot: potentially **quadratic memory growth**, plus full-file rewrite cost. These are experimental stop limits, not scalable storage or a public DoS defense.
The [ledger](../server/testnet/ledger.ts) stores the entire DAG and pending pool in `data/testnet/ledger.json`, under the `WIKIDECK_DATA_DIR` testnet subdirectory. The file is created when the node first writes ledger data. Defaults use project data; Docker uses `/app/data`. Disk caps: **128 MiB, 10,000 total blocks including side branches**; blocks: **64 KiB and 64 transfers**. Each in-memory DAG node stores a full NFT-state snapshot: potentially **quadratic memory growth**, plus full-file rewrite cost. These are experimental stop limits, not scalable storage or a public DoS defense.
Mutations are serialized (queue cap 256); persist via a synchronized temporary file and atomic rename before exposing new state. Restart independently revalidates every saved proof and transaction; malformed, oversized or mismatched files fail closed without automatic reset. HTTP startup exclusively creates a data-directory lock named testnet-node.lock. It is removed on normal shutdown, **not automatically after a crash**: stop and verify all owning processes have exited before manual stale-lock removal. Never bypass a live lock or share a writer directory. Back up stopped-node volumes/files and wallet exports separately; destructive volume reset cannot recover assets from a wallet alone.
@@ -61,10 +76,11 @@ Sync polls every **5 seconds**, checks network/chain ID, imports at most **32**
| Route | Purpose |
| --- | --- |
| `GET /healthz` | Process health/network, not sync or consensus security. |
| `GET /api/state`, `/api/chain` | Canonical blocks, mined/pending transfers, tip/work and next article. |
| `GET /api/nfts/:id`, `/api/peers` | Canonical NFT owner/sequence/confirmations/metadata; configured peer status. |
| `GET /api/wikipedia/:pageId` | Optional cached live enrichment for catalog IDs: categories, views, size, languages and display-only scores. Never a consensus input. |
| `POST /api/mining/template`, `/api/blocks` | Public miner identity to template; independently validated signed block submission. |
| `GET /api/state`, `/api/chain` | Canonical blocks, mined/pending transfers, tip/work and peer count. |
| `GET /api/nfts/:id`, `/api/peers` | Canonical NFT owner/sequence/confirmations, article and at-mint statistics; configured peer status. |
| `GET /api/wikipedia/:pageId` | Cached current enrichment for positive English Wikipedia page IDs: categories, views, size, languages and display-only scores. Never a consensus input. |
| `POST /api/mining/template` | Rate-limited public miner identity; fetches a random article with a picture plus fresh at-mint statistics. Returns 503 when either cannot be fetched. |
| `POST /api/blocks` | Independently validates a signed block, proof, article snapshot, required Wikimedia image URL, and statistics snapshot consistency. Repeated page IDs are accepted. |
| `POST /api/transfer` | Signed transfer admission; 202 means pending. |
| `GET /p2p/status`, `/p2p/blocks?offset=0`, `/p2p/mempool` | Manifest/size/tip, 32-block inventory pages, pending transactions. |
| `POST /p2p/block`, `/p2p/transaction` | Same validation as local block/transfer submission. |
+1 -1
View File
@@ -11,7 +11,7 @@ const nodes=(process.env.WIKIDECK_SMOKE_NODES??'http://127.0.0.1:3101,http://127
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(15000),redirect:'error'});
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'});
+10 -1
View File
@@ -53,10 +53,19 @@ export class WikipediaEnrichment {
}
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 (cached && cached.expires > now.getTime() &&
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);
+66 -19
View File
@@ -1,16 +1,16 @@
import { createHash, createPublicKey, verify } from 'node:crypto';
import { z } from 'zod';
import { STARTER_ARTICLES } from '../../shared/catalog.ts';
import { NETWORK, CONSENSUS, articleTuple, headerMessage, blockClaimMessage, nftTransferMessage,
import { NETWORK, CONSENSUS, headerMessage, blockClaimMessage, nftTransferMessage, statsSnapshotTuple,
type BlockHeader, type TestnetBlock, type NftTransfer, type NftState, 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';
export const sha256 = (data: string | Uint8Array) => createHash('sha256').update(data).digest('hex');
export const MAX_TARGET = `0fff${'f'.repeat(60)}`;
// Consensus manifest: an independently running node derives exactly the same genesis.
export const GENESIS_TIME = 1_792_411_200;
export const CHAIN_ID = sha256(JSON.stringify([NETWORK, CONSENSUS, GENESIS_TIME, MAX_TARGET,
STARTER_ARTICLES.map(articleTuple)]));
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}$/);
@@ -19,11 +19,59 @@ 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 articleSchema = z.object({ pageId: integer, title: z.string().max(200), extract: z.string().max(1000),
image: z.string().max(1000).optional(), url: z.string().max(500), revisionId: integer,
source: z.enum(['live', 'offline']), category: z.string().max(50) }).strict();
export const blockSchema = z.object({ version: z.literal(2), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID),
height: integer, previousHash: hex, createdAt: integer, article: articleSchema, owner: hex,
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(4), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID),
height: integer, previousHash: hex, createdAt: integer, article: articleSchema, statsSnapshot: statsSnapshotSchema, owner: hex,
publicKey: key, nonce: integer, target: hex, key: hex, transactionsRoot: hex, stateRoot: hex,
hash: hex, tokenId: hex, powHash: hex, signature, transactions: z.array(transferSchema).max(CONSENSUS.maxTransactions) }).strict();
@@ -59,8 +107,9 @@ export function stateRoot(state: NftState): string {
const n = state[id]; return sha256(JSON.stringify([id, n.owner, n.sequence, n.mintedHeight, n.pageId]));
}));
}
export function mintId(h: Pick<BlockHeader, 'previousHash' | 'height' | 'owner' | 'article'>): string {
return sha256(JSON.stringify([NETWORK, CHAIN_ID, 'mint', h.previousHash, h.height, h.owner, h.article.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);
@@ -68,10 +117,6 @@ export function miningBlob(header: BlockHeader): Buffer {
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 selectArticle(previousHash: string) {
const index = Number(BigInt(`0x${sha256(`${CHAIN_ID}:article:${previousHash}`)}`) % BigInt(STARTER_ARTICLES.length));
return { ...STARTER_ARTICLES[index] };
}
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;
@@ -112,12 +157,15 @@ export function transition(parent: NftState, header: BlockHeader, txs: NftTransf
return next;
}
export function makeTemplate(ancestors: TestnetBlock[], state: NftState, owner: string,
publicKey: string, transactions: NftTransfer[], now = Math.floor(Date.now()/1000)): MiningTemplate {
publicKey: string, transactions: NftTransfer[], 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: 2, network: NETWORK, chainId: CHAIN_ID,
const header: BlockHeader = { version: 4, network: NETWORK, chainId: CHAIN_ID,
height: ancestors.length + 1, previousHash, createdAt: Math.max(now, medianTime(ancestors) + 1),
article: selectArticle(previousHash), owner, publicKey, nonce: 0, target: expectedTarget(ancestors),
article: parsedArticle, statsSnapshot: parsedStats, owner, publicKey, nonce: 0, target: expectedTarget(ancestors),
key: expectedKey(ancestors, ancestors.length + 1), transactionsRoot: merkleRoot(transactions.map(transferId)), stateRoot: '' };
header.stateRoot = stateRoot(transition(state, header, transactions));
return { header, transactions, blob: miningBlob(header).toString('hex'), nonceOffset: 32 };
@@ -129,7 +177,6 @@ export async function validateBlock(raw: unknown, ancestors: TestnetBlock[], par
if (b.height !== ancestors.length + 1 || b.previousHash !== (ancestors.at(-1)?.hash ?? GENESIS_HASH)) throw new Error('Invalid parent/height');
if (b.createdAt <= medianTime(ancestors) || b.createdAt > now + CONSENSUS.maxFutureSeconds) throw new Error('Invalid block timestamp');
if (b.target !== expectedTarget(ancestors) || b.key !== expectedKey(ancestors, b.height)) throw new Error('Invalid difficulty or RandomX epoch key');
if (JSON.stringify(articleTuple(b.article)) !== JSON.stringify(articleTuple(selectArticle(b.previousHash)))) throw new Error('Article is not in the consensus catalog');
if (b.transactionsRoot !== merkleRoot(b.transactions.map(transferId))) throw new Error('Invalid transaction root');
const next = transition(parentState, b, b.transactions);
if (b.stateRoot !== stateRoot(next) || b.tokenId !== mintId(b)) throw new Error('Invalid NFT state commitment');
+13 -7
View File
@@ -11,7 +11,8 @@ import { RandomX } from './randomx.ts';
import { Ledger } from './ledger.ts';
import { PeerNetwork } from './peers.ts';
import { wikipediaEnrichment } from '../enrichment.ts';
import { STARTER_ARTICLES } from '../../shared/catalog.ts';
import { snapshotStats } from '../../shared/enrichment.ts';
import { wikipediaArticles } from '../wiki.ts';
const root = fileURLToPath(new URL('../../', import.meta.url));
const dataDir = process.env.WIKIDECK_DATA_DIR ?? resolve(root,'data');
@@ -56,9 +57,6 @@ app.get('/api/peers',(_req,res)=>res.json({peers:peers.status()}));
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);
if(!STARTER_ARTICLES.some(a=>a.pageId===pageId)){
res.status(404).json({error:'Page is not in the current consensus catalog'});return;
}
try {res.json(await wikipediaEnrichment.get(pageId));}
catch {res.status(503).json({error:'Wikipedia enrichment is temporarily unavailable. NFT data is unaffected.'});}
});
@@ -76,11 +74,19 @@ app.get('/api/nfts/:id', (req,res)=>{
let owner=block.owner,sequence=0;
for(const tx of s.transfers)if(tx.cardHash===block.tokenId){owner=tx.to;sequence++;}
res.json({network:NETWORK,chainId:CHAIN_ID,tokenId:block.tokenId,owner,sequence,blockHash:block.hash,
confirmations:s.blocks.length-block.height+1,article:block.article,stateRoot:s.blocks.at(-1)?.stateRoot});
confirmations:s.blocks.length-block.height+1,article:block.article,statsSnapshot:block.statsSnapshot,
stateRoot:s.blocks.at(-1)?.stateRoot});
});
app.post('/api/mining/template', (req,res)=>{
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);
res.json(ledger.template(body.owner,body.publicKey));
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)=>{
+7 -4
View File
@@ -5,9 +5,11 @@ import { setTimeout as delay } from 'node:timers/promises';
import { z } from 'zod';
import { NETWORK, CONSENSUS, type TestnetBlock, type NftTransfer, type NftState,
type MiningTemplate, type TestnetSnapshot } from '../../shared/testnet.ts';
import { CHAIN_ID, GENESIS_HASH, applyTransfer, blockSchema, makeTemplate, selectArticle,
import { CHAIN_ID, GENESIS_HASH, applyTransfer, blockSchema, makeTemplate,
transferId, 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';
const MAX_BLOCKS = 10_000;
const MAX_PENDING = 256;
@@ -135,16 +137,17 @@ export class Ledger {
});
}
template(owner: string, publicKey: string): MiningTemplate {
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(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,
nextArticle: selectArticle(this.tip), peers: this.peers, algorithm: 'RandomX',
peers: this.peers, algorithm: 'RandomX',
maturity: CONSENSUS.coinbaseMaturity });
}
+1 -1
View File
@@ -51,7 +51,7 @@ 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(15000), headers: body ? {'Content-Type':'application/json'} : {}, body: body ? JSON.stringify(body) : undefined });
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);
+81 -26
View File
@@ -1,13 +1,17 @@
import { z } from 'zod';
import type { Article } from '../shared/protocol.ts';
import { STARTER_ARTICLES } from '../shared/catalog.ts';
const pageSchema = z.object({
pageid: z.number().int().positive(), title: z.string(), extract: z.string().default(''),
lastrevid: z.number().int().nonnegative().default(0),
thumbnail: z.object({ source: z.string().url() }).optional(),
categories: z.array(z.object({ title: z.string() })).optional(),
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();
@@ -18,27 +22,78 @@ function category(title: string, categories: string[]): string {
if (/histor|ancient|archaeolog|empire/.test(text)) return 'History';
return 'Culture';
}
export async function wikipediaArticle(): Promise<Article> {
if (process.env.WIKIDECK_OFFLINE === '1') return { ...STARTER_ARTICLES[Math.floor(Math.random() * STARTER_ARTICLES.length)] };
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 {
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: '6', prop: 'extracts|pageimages|info|categories',
exintro: '1', explaintext: '1', exchars: '700', piprop: 'thumbnail', pithumbsize: '900', cllimit: '8' }).toString();
const response = await fetch(url, { signal: AbortSignal.timeout(6000), redirect: 'error',
headers: { 'User-Agent': 'Wikideck/0.1 (local educational card-game prototype)', Accept: 'application/json' } });
if (!response.ok) throw new Error('Wikipedia unavailable');
const raw = await response.text();
if (raw.length > 1_000_000) throw new Error('Wikipedia response too large');
const result = z.object({ query: z.object({ pages: z.array(pageSchema) }) }).parse(JSON.parse(raw));
const page = result.query.pages.find(p => p.extract.length > 120 && p.thumbnail) ?? result.query.pages.find(p => p.extract.length > 120);
if (!page) throw new Error('No suitable article');
const image = page.thumbnail?.source;
return { pageId: page.pageid, title: page.title, extract: page.extract.slice(0, 700),
image: image && ['https://upload.wikimedia.org', 'https://thumb.wikimedia.org'].includes(new URL(image).origin) ? image : undefined,
url: `https://en.wikipedia.org/?curid=${page.pageid}`, revisionId: page.lastrevid,
source: 'live', category: category(page.title, page.categories?.map(c => c.title) ?? []) };
} catch {
return { ...STARTER_ARTICLES[Math.floor(Math.random() * STARTER_ARTICLES.length)] };
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();
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Article } from './protocol.ts';
// Original summaries and verified English Wikipedia page IDs. Not simulated live responses.
// 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' },
+23
View File
@@ -25,6 +25,29 @@ export interface PageEnrichment {
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) => {
+15 -6
View File
@@ -1,19 +1,21 @@
import type { Article } from './protocol.ts';
import type { MintStatsSnapshot } from './enrichment.ts';
export const NETWORK = 'wikideck-randomx-testnet-2' as const;
export const NETWORK = 'wikideck-randomx-testnet-4' as const;
export const CONSENSUS = {
version: 2, targetSeconds: 30, retargetInterval: 10, medianWindow: 11,
version: 4, targetSeconds: 30, retargetInterval: 10, medianWindow: 11,
commitments: 'sha256-domain-separated-counted-merkle-v1', randomx: '1.2.1', powEndian: 'big',
maxFutureSeconds: 120, epochBlocks: 2048, epochDelay: 64,
maxTransactions: 64, coinbaseMaturity: 2, maxBlockBytes: 64 * 1024,
articleSource: 'wikipedia-random-with-image-and-stats-v1',
} as const;
export type NftTransfer = {
network: typeof NETWORK; chainId: string; cardHash: string; from: string; to: string;
sequence: number; publicKey: string; signature: string;
};
export type BlockHeader = {
version: 2; network: typeof NETWORK; chainId: string; height: number;
previousHash: string; createdAt: number; article: Article;
version: 4; 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;
};
@@ -27,7 +29,7 @@ export type NftState = Record<string, { owner: string; sequence: number; mintedH
export type TestnetSnapshot = {
network: typeof NETWORK; chainId: string; genesis: string; tip: string; cumulativeWork: string;
blocks: TestnetBlock[]; transfers: NftTransfer[]; pendingTransfers: NftTransfer[];
nextArticle: Article; peers: number; algorithm: 'RandomX'; maturity: number;
peers: number; algorithm: 'RandomX'; maturity: 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]);
@@ -35,9 +37,16 @@ export function nftTransferMessage(t: Omit<NftTransfer, 'publicKey' | 'signature
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), h.owner, h.publicKey, h.target, h.key, h.transactionsRoot, h.stateRoot]);
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]);
+2 -2
View File
@@ -1,5 +1,5 @@
// Browser/Node WebCrypto only: this module never reads storage or sends data.
const NETWORK = 'wikideck-randomx-testnet-2';
const NETWORK = 'wikideck-randomx-testnet-4';
const ITERATIONS = 600_000;
const MAX_BYTES = 32 * 1024;
const encoder = new TextEncoder();
@@ -7,7 +7,7 @@ const encoder = new TextEncoder();
export type WalletBackup = {
format: 'wikideck-wallet-backup';
version: 1;
network: 'wikideck-randomx-testnet-2';
network: 'wikideck-randomx-testnet-4';
kdf: 'PBKDF2-SHA256';
iterations: 600000;
cipher: 'AES-256-GCM';
+7 -7
View File
@@ -63,7 +63,7 @@ export default function App() {
try {
const data = await request<TestnetSnapshot>('state');
if (data.network !== NETWORK || data.algorithm !== 'RandomX' || typeof data.chainId !== 'string' ||
!Array.isArray(data.blocks) || !Array.isArray(data.transfers) || !Array.isArray(data.pendingTransfers) || !data.nextArticle)
!Array.isArray(data.blocks) || !Array.isArray(data.transfers) || !Array.isArray(data.pendingTransfers))
throw new Error('Incompatible node. This interface requires the Wikideck RandomX testnet API.');
if (version === refreshVersion.current) { setSnapshot(data); setRpcAvailable(true); setError(''); }
return data;
@@ -168,7 +168,7 @@ export default function App() {
{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 /> KNOWLEDGE, COLLECTED ON ITS OWN CHAIN.</span><h1>Mine knowledge.<br />Collect the <span>extraordinary.</span></h1><p>Four curated article snapshots. A chain of new discoveries. Mine native NFTs with an opt-in RandomX miner, and keep the keys to your collection.</p><div className="hero-actions"><button className="button dark" onClick={goMine}><Pickaxe size={17} /> Explore native 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} /> Independent testnet</span><span><Check size={13} /> No browser mining</span></div></div>
<div className="hero-copy"><span className="eyebrow"><span /> KNOWLEDGE, COLLECTED ON ITS OWN CHAIN.</span><h1>Mine knowledge.<br />Collect the <span>extraordinary.</span></h1><p>Random Wikipedia articles, each with a picture. A chain of new discoveries. Mine native NFTs with an opt-in RandomX miner, and keep the keys to your collection.</p><div className="hero-actions"><button className="button dark" onClick={goMine}><Pickaxe size={17} /> Explore native 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} /> Independent testnet</span><span><Check size={13} /> No browser mining</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">
@@ -179,11 +179,11 @@ export default function App() {
</section>
<section className="mining-panel native-mining" ref={minerPanel}>
<div className="mining-description"><div className="section-eyebrow"><span className="idle-dot" /> EXPLICIT OPT-IN · OUTSIDE YOUR BROWSER</div><h2>Native RandomX mining</h2><p>This page never runs mining code or launches a native program. Build the trusted native binary, download an encrypted wallet backup, then start the miner yourself in an interactive terminal.</p><div className="mining-meta"><span><Cpu size={13} /> One native worker</span><span><Shield size={13} /> Independent validating peers</span></div><div className="native-memory"><strong>Light · default</strong><span>256 MiB cache + scratchpad and overhead.</span><strong>Fast · explicit --fast</strong><span>~2 GiB dataset plus cache; budget over 2.3 GiB per process. Higher CPU, energy and memory use. RandomX is not a promise of ASIC-proof mining.</span></div></div>
<div className="miner-controls"><div className="miner-display"><div><span>NEXT CONSENSUS ARTICLE</span><strong>{snapshot?.nextArticle.title ?? 'Waiting for testnet RPC…'}</strong></div></div><p className="muted">Fixed four-article snapshot catalog, not a live random Wikipedia feed.</p><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}. Same-origin RPC, including the development proxy. Use HTTPS for remote nodes. Password entry stays local; never put it in the command. Defaults to one worker and at most one block. Ctrl+C stops the native miner; closing this page does not.</p><button className="button dark full" onClick={openWallet}><Download size={16} /> Download wallet backup</button><button className="text-button" onClick={() => setDialog('guide')}>Read the testnet field guide <ArrowRight size={14} /></button></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 testnet RPC…'}</strong></div></div><p className="muted">Each mining template requests a random English Wikipedia article with a Wikimedia picture. Pages may repeat; every minted card carries its own statistics snapshot.</p><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}. Same-origin RPC, including the development proxy. Use HTTPS for remote nodes. Password entry stays local; never put it in the command. Defaults to one worker and at most one block. Ctrl+C stops the native miner; closing this page does not.</p><button className="button dark full" onClick={openWallet}><Download size={16} /> Download wallet backup</button><button className="text-button" onClick={() => setDialog('guide')}>Read the testnet field guide <ArrowRight size={14} /></button></div>
</section>
</>}
{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 testnet chain.' : 'Preview examples from the fixed catalog. These are not owned NFTs.'}</p></div>{view === 'Discover' && <button className="text-button" onClick={() => setView('My collection')}>My collection <ArrowRight size={16} /></button>}</div>
<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 testnet 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(t => t.cardHash === c.block!.tokenId) && <span className="pending-label">Transfer pending mining</span>}</div>)}</div> : <div className="empty-state"><Layers3 size={38} /><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>
@@ -193,15 +193,15 @@ export default function App() {
<h2 className="transfer-title">Mined ownership transfers</h2>{snapshot?.transfers.length ? <div className="journal">{snapshot.transfers.map((t, i) => <div key={`${t.cardHash}-${t.sequence}`}><ArrowDownLeft size={18} /><a href={`/api/nfts/${encodeURIComponent(t.cardHash)}`} target="_blank" rel="noreferrer" title={t.cardHash}>{short(t.cardHash)}</a><span>{short(t.from)} → {short(t.to)}</span><span>Sequence {t.sequence} · mined #{snapshot.blocks.find(b => b.transactions.some(tx => tx.signature === t.signature))?.height ?? '—'}</span><Check size={14} aria-label={`Mined transfer ${i + 1}`} /></div>)}</div> : <p className="muted">No mined transfers. Ownership changes only when a transfer is included in a canonical block.</p>}
<h2 className="transfer-title">Pending transfers <span className="count-badge">{snapshot?.pendingTransfers.length ?? 0}</span></h2><p className="muted">Mempool only · not confirmed ownership. Pending transactions may be dropped or invalidated by a reorganization.</p><div className="journal">{snapshot?.pendingTransfers.map(t => <div key={`${t.cardHash}-${t.sequence}`}><LoaderCircle size={16} /><span title={t.cardHash}>{short(t.cardHash)}</span><span>{short(t.from)} → {short(t.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} /> Built on the shoulders of Wikipedia.</a><span>Knowledge, not financial promises.<span className="footer-star">✦</span></span><p>Independent experiment, not affiliated with Wikimedia. Fixed snapshot catalog; attribution and image licenses on each card. Native NFTs are not ERC-721 tokens, fiat, or rights to article content. No promised monetary value.</p></footer>
<footer><a href="https://en.wikipedia.org" target="_blank" rel="noreferrer"><BookOpen size={16} /> Built on the shoulders of Wikipedia.</a><span>Knowledge, not financial promises.<span className="footer-star">✦</span></span><p>Independent experiment, not affiliated with Wikimedia. Mined article snapshots are selected from Wikipedia; attribution and image licenses appear on each card. Native NFTs are not ERC-721 tokens, fiat, or rights to article content. No promised monetary value.</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 testnet 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 TESTNET FIELD GUIDE</div><h2>A universe in every card.</h2><div className="guide-steps"><div><span>01</span><div><h3>Mine natively. Only by choice.</h3><p>No browser mining or automatic native execution. Build the trusted RandomX binary and run the miner yourself with an encrypted wallet backup and explicit --consent. One worker defaults to light mode (256 MiB cache plus overhead); --fast adds a ~2 GiB dataset. Mining uses CPU and energy, may warm your device, and is not guaranteed ASIC-proof. Ctrl+C stops it.</p></div></div><div><span>02</span><div><h3>A small catalog. A sovereign chain.</h3><p>Consensus uses four fixed article snapshots, not live random articles. The genesis and chain ID pin this catalog; expanding it currently requires a genesis reset and a new chain. Independent peers validate RandomX blocks, native NFT mints, and signed transactions, choosing the cumulative-work canonical chain. This testnet is unbootstrapped: there is no public mainnet, and an isolated node has no peer-backed network security.</p></div></div><div><span>03</span><div><h3>Keep your keys. Confirm your transfers.</h3><p>Your P-256 wallet is a local signing identity, not a server login. The new testnet wallet is separate from the original demo wallet. Download a password-encrypted backup and restore it locally to control the same NFTs. A submitted transfer is pending, not final ownership. Mints require two subsequent blocks at inclusion height; reorganizations can change confirmations and ownership.</p></div></div></div><div className="info-box"><strong>Native NFTs, not content ownership.</strong><p>Token IDs identify native assets on this chain, not ERC-721 tokens on another network. RandomX proof hashes determine game rarity and stats; block IDs identify blocks. An NFT does not grant copyright or other intellectual-property rights to Wikipedia text or images. Follow their licenses and attribution. No fiat, financial return, or monetary value is promised.</p></div><button className="button dark full" onClick={() => { close(); goMine(); }}>Explore native mining <ArrowRight size={16} /></button></>
{dialog === 'guide' ? <><span className="modal-icon"><Sparkles size={24} /></span><div className="section-eyebrow">THE TESTNET FIELD GUIDE</div><h2>A universe in every card.</h2><div className="guide-steps"><div><span>01</span><div><h3>Mine natively. Only by choice.</h3><p>No browser mining or automatic native execution. Build the trusted RandomX binary and run the miner yourself with an encrypted wallet backup and explicit --consent. One worker defaults to light mode (256 MiB cache plus overhead); --fast adds a ~2 GiB dataset. Mining uses CPU and energy, may warm your device, and is not guaranteed ASIC-proof. Ctrl+C stops it.</p></div></div><div><span>02</span><div><h3>A page, a snapshot. A sovereign chain.</h3><p>Each mining template asks Wikipedia for a random English article with a picture. A page can appear on multiple NFTs; each block commits its own article and mint-time statistics snapshot. Validators verify the snapshot's structure and commitments without contacting Wikimedia during consensus validation. Independent peers validate RandomX work, native NFT mints, and signed transactions, choosing the cumulative-work canonical chain. This testnet is unbootstrapped: there is no public mainnet, and an isolated node has no peer-backed network security.</p></div></div><div><span>03</span><div><h3>Keep your keys. Confirm your transfers.</h3><p>Your P-256 wallet is a local signing identity, not a server login. The new testnet wallet is separate from the original demo wallet. Download a password-encrypted backup and restore it locally to control the same NFTs. A submitted transfer is pending, not final ownership. Mints require two subsequent blocks at inclusion height; reorganizations can change confirmations and ownership.</p></div></div></div><div className="info-box"><strong>Native NFTs, not content ownership.</strong><p>Token IDs identify native assets on this chain, not ERC-721 tokens on another network. RandomX proof hashes determine game rarity and stats; block IDs identify blocks. An NFT does not grant copyright or other intellectual-property rights to Wikipedia text or images. Follow their licenses and attribution. No fiat, financial return, or monetary value is promised.</p></div><button className="button dark full" onClick={() => { close(); goMine(); }}>Explore native mining <ArrowRight size={16} /></button></>
: dialog === 'wallet' ? <><span className="modal-icon"><WalletIcon size={24} /></span><div className="section-eyebrow">YOUR LOCAL TESTNET 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 testnet wallet address on this same chain. 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}` : 'Fixed starter snapshot'}</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></>}
: 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>;
}
+3 -3
View File
@@ -3,7 +3,7 @@ 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 } from './CardInsights.tsx';
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 };
@@ -30,8 +30,8 @@ export function Card({ card, onClick }: { card: CardData; onClick?: () => void }
</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>LIVE 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?'Live metadata unavailable':'Loading article insights…'}</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}/>}</>;
</button>{!onClick&&<CardInsights pageId={article.pageId} snapshot={card.block?.statsSnapshot}/>}</>;
}
+31 -9
View File
@@ -1,28 +1,49 @@
import { BookOpen, Eye, Globe2, Tags, TrendingUp } from 'lucide-react';
import { useEnrichment } from './useEnrichment.ts';
import type { PageEnrichment } from '../shared/enrichment.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}`;
export function EnrichmentScores({data}: {data:PageEnrichment}){
return <div className="enrichment-scores" aria-label="Live 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(data.stats.attention)}</b><small>Attention</small></span>
<span title="Depth: logarithmic score from article bytes, not a quality judgment"><BookOpen size={12}/><b>{score(data.stats.depth)}</b><small>Depth</small></span>
<span title="Reach: logarithmic score from interlanguage links"><Globe2 size={12}/><b>{score(data.stats.reach)}</b><small>Reach</small></span>
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 CardInsights({pageId}:{pageId:number}){
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>Wikipedia insights</h3><p role="status">{error?'Live metadata is unavailable. The NFT and its original game stats are unchanged.':'Loading Wikipedia metadata and monthly readership…'}</p>{error&&<button className="text-button" onClick={retry}>Retry live metadata</button>}</section>;
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>Wikipedia insights</h3><span>LIVE · OFF-CHAIN</span></div>
<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">
@@ -36,6 +57,7 @@ export function CardInsights({pageId}:{pageId:number}){
<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>
+1 -1
View File
@@ -83,7 +83,7 @@ export function WalletPanel({ wallet, onRestore, onNotice }: Props) {
return <section aria-labelledby={`${id}-title`} aria-busy={busy}>
<h2 id={`${id}-title`}>Wallet backup &amp; recovery</h2>
<p>This is a recoverable P-256 wallet for wikideck-randomx-testnet-2, separate from your old demo wallet.</p>
<p>This is a recoverable P-256 wallet for wikideck-randomx-testnet-4, separate from your old demo wallet.</p>
<p>The exportable private key is stored locally in this browser’s IndexedDB, not encrypted by this password.
Scripts running on this origin or someone with access to your browser profile may access it.
Clearing site data without a backup loses access.</p>
+1
View File
@@ -1,2 +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)}
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
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
+14 -5
View File
@@ -15,6 +15,7 @@ import { NETWORK, CONSENSUS, blockClaimMessage, nftTransferMessage,
type MiningTemplate, type NftTransfer, type TestnetBlock, type TestnetSnapshot } from '../shared/testnet.ts';
const root = fileURLToPath(new URL('../', import.meta.url));
const wikipediaMock = resolve(root, 'tests', 'mock-wikipedia.mjs');
const binary = resolve(process.env.RANDOMX_BINARY ?? join(root, 'native', 'build',
`wikideck-randomx${process.platform === 'win32' ? '.exe' : ''}`));
@@ -74,7 +75,7 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
async function start(dataDir: string, peer?: string): Promise<RunningNode> {
t.signal.throwIfAborted();
const child = spawn(process.execPath, ['--experimental-strip-types', 'server/index.ts'], {
const child = spawn(process.execPath, ['--import', wikipediaMock, '--experimental-strip-types', 'server/index.ts'], {
cwd: root, windowsHide: true, shell: false,
env: { ...process.env, HOST: '127.0.0.1', PORT: '0', NODE_ENV: 'production',
WIKIDECK_DATA_DIR: dataDir, RANDOMX_BINARY: binary,
@@ -116,7 +117,7 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
headers: Record<string, string> = {}): Promise<Response> {
const response = await fetch(`${base}${path}`, {
method: body === undefined ? 'GET' : 'POST', redirect: 'error',
signal: AbortSignal.any([t.signal, AbortSignal.timeout(15_000)]),
signal: AbortSignal.any([t.signal, AbortSignal.timeout(30_000)]),
headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
@@ -183,7 +184,7 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
assert.equal(health.headers.has('ratelimit'), false);
await (await request(first.base, '/api/wikipedia/0', 400)).text();
await (await request(first.base, '/api/wikipedia/not-a-page', 400)).text();
await (await request(first.base, '/api/wikipedia/9999999999', 404)).text();
await (await request(first.base, '/api/wikipedia/9999999999', 503)).text();
const initial = await snapshot(first.base);
assert.equal(initial.network, NETWORK);
assert.equal(initial.chainId, CHAIN_ID);
@@ -214,6 +215,12 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
const block1 = await mine(first.base, alice);
assert.equal(block1.height, 1);
assert.equal(block1.article.source, 'live');
assert.ok(block1.article.image);
assert.ok(['https://upload.wikimedia.org', 'https://thumb.wikimedia.org'].includes(new URL(block1.article.image).origin));
assert.equal(block1.statsSnapshot.pageviews.status, 'available');
assert.equal(block1.statsSnapshot.pageviews.total, 600);
assert.equal(block1.statsSnapshot.revisionId, block1.article.pageId + 1);
await submit(first.base, block1);
const unsigned = { network: NETWORK, chainId: CHAIN_ID, cardHash: block1.tokenId,
from: alice.owner, to: bob.owner, sequence: 0 };
@@ -280,7 +287,8 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
assert.match(replay.error, /owner\/sequence/i);
assert.deepEqual(await json(node.base, `/api/nfts/${block1.tokenId}`), {
network: NETWORK, chainId: CHAIN_ID, tokenId: block1.tokenId, owner: bob.owner, sequence: 1,
blockHash: block1.hash, confirmations: 3, article: block1.article, stateRoot: block3.stateRoot,
blockHash: block1.hash, confirmations: 3, article: block1.article,
statsSnapshot: block1.statsSnapshot, stateRoot: block3.stateRoot,
});
const inventory = await json<{ blocks: TestnetBlock[]; next: null }>(node.base, '/p2p/blocks?offset=0');
assert.deepEqual(inventory, { blocks: canonical.blocks, next: null });
@@ -308,7 +316,8 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
assert.deepEqual(restored, { ...canonical, peers: 0 });
assert.deepEqual(await json(first.base, `/api/nfts/${block1.tokenId}`), {
network: NETWORK, chainId: CHAIN_ID, tokenId: block1.tokenId, owner: bob.owner, sequence: 1,
blockHash: block1.hash, confirmations: 3, article: block1.article, stateRoot: block3.stateRoot,
blockHash: block1.hash, confirmations: 3, article: block1.article,
statsSnapshot: block1.statsSnapshot, stateRoot: block3.stateRoot,
});
assert.deepEqual(await json(first.base, '/api/blocks', 200, block3), { accepted: false, reorg: false });
await (await request(first.base, '/api/transfer', 400, transfer)).text();
+9
View File
@@ -186,6 +186,15 @@ test('six-hour TTL, deduplication and mutation isolation', async () => {
assert.equal(calls.length, 4);
});
test('fresh enrichment bypasses the display cache for mint-time snapshots', async () => {
const { service, calls } = fixture();
await service.get(736);
const pinned = await service.getFresh(736);
assert.equal(calls.length, 4); // Each lookup makes one metadata and one pageview request.
assert.equal(pinned.pageId, 736);
assert.equal(pinned.pageviews.status, 'available');
});
test('LRU bound and configured TTL are enforced', async () => {
let now = JANUARY.getTime();
const { service, calls } = fixture({ maxEntries: 2, ttlMs: 100, now: () => new Date(now) });
+40
View File
@@ -0,0 +1,40 @@
// Deterministic upstream responses for the isolated HTTP integration test only.
let nextPageId = 700_000;
const nativeFetch = globalThis.fetch.bind(globalThis);
const json = (value) => new Response(JSON.stringify(value), {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
globalThis.fetch = async (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.origin === 'https://en.wikipedia.org' && url.pathname === '/w/api.php') {
if (url.searchParams.get('generator') === 'random') {
const pageid = nextPageId++;
return json({ query: { pages: [{ pageid, ns: 0, title: `Test article ${pageid}`,
extract: `A deterministic mock Wikipedia extract for article ${pageid}, long enough to display on a card.`,
lastrevid: pageid + 1, thumbnail: { source: `https://upload.wikimedia.org/wikipedia/commons/${pageid}.jpg` },
categories: [{ title: 'Category:Science' }] }] } });
}
const pageid = Number(url.searchParams.get('pageids'));
if (pageid === 9_999_999_999) {
return json({ query: { pages: [{ pageid, ns: 0, title: 'Missing test page', missing: true }] } });
}
return json({ query: { pages: [{ pageid, ns: 0, title: `Test article ${pageid}`,
lastrevid: pageid + 1, length: 12_345, description: 'A mock article for integration testing.',
categories: [{ ns: 14, title: 'Category:Science' }], langlinks: [{ lang: 'fr', title: 'Article test' }] }] } });
}
if (url.origin === 'https://wikimedia.org' && url.pathname.includes('/metrics/pageviews/')) {
const parts = url.pathname.split('/');
const start = parts.at(-2);
const end = parts.at(-1);
const from = new Date(Date.UTC(Number(start.slice(0, 4)), Number(start.slice(4, 6)) - 1, 1));
const through = new Date(Date.UTC(Number(end.slice(0, 4)), Number(end.slice(4, 6)) - 1, 1));
const items = [];
for (const cursor = from; cursor <= through; cursor.setUTCMonth(cursor.getUTCMonth() + 1)) {
const timestamp = `${cursor.getUTCFullYear()}${String(cursor.getUTCMonth() + 1).padStart(2, '0')}0100`;
items.push({ timestamp, views: 100, project: 'en.wikipedia', access: 'all-access', agent: 'user', granularity: 'monthly' });
}
return json({ items });
}
return nativeFetch(input, init);
};
+35 -8
View File
@@ -13,6 +13,7 @@ import { CHAIN_ID, GENESIS_HASH, GENESIS_TIME, MAX_TARGET, address, makeTemplate
mintId, sha256, stateRoot, transferId, transition, workFor } from '../server/testnet/consensus.ts';
import { NETWORK, CONSENSUS, blockClaimMessage, nftTransferMessage, testnetOwnerOf,
type MiningTemplate, type NftState, type NftTransfer, type TestnetBlock } from '../shared/testnet.ts';
import { deriveStats } from '../shared/enrichment.ts';
import type { PowEngine } from '../server/testnet/randomx.ts';
// Deliberately test-only: production has no fake PoW selector or fallback.
@@ -48,10 +49,23 @@ async function mine(template: MiningTemplate, miner: Identity = alice): Promise<
}
type Branch = { blocks: TestnetBlock[]; state: NftState };
function branch(): Branch { return { blocks: [], state: {} }; }
function articleFor(_ancestors: TestnetBlock[]) {
const pageId = 50_000;
return { pageId, title: `Test article ${pageId}`, extract: `A deterministic test article snapshot for Wikipedia page ${pageId}.`,
image: 'https://upload.wikimedia.org/wikipedia/commons/test-image.jpg',
url: `https://en.wikipedia.org/?curid=${pageId}`, revisionId: 1, source: 'live' as const, category: 'Culture' };
}
function statsSnapshot() {
const pageBytes = 2048, languageCount = 3;
return { capturedAt: '2026-10-19T00:00:00.000Z', revisionId: 1, pageBytes, languageCount,
languagesTruncated: false, pageviews: { status: 'unavailable' as const, months: [], latestMonth: '2026-09',
total: null, dailyAverage: null, trendPercent: null }, stats: deriveStats(pageBytes, languageCount, null) };
}
async function extend(chain: Branch, miner: Identity = alice, transactions: NftTransfer[] = [],
spacing = 30): Promise<TestnetBlock> {
const now = GENESIS_TIME + (chain.blocks.length + 1) * spacing;
const block = await mine(makeTemplate(chain.blocks, chain.state, miner.owner, miner.publicKey, transactions, now), miner);
const block = await mine(makeTemplate(chain.blocks, chain.state, miner.owner, miner.publicKey,
transactions, articleFor(chain.blocks), statsSnapshot(), now), miner);
chain.state = transition(chain.state, block, transactions);
chain.blocks.push(block);
return block;
@@ -87,7 +101,7 @@ test('testnet genesis is empty, manifest-bound, isolated and defensively copied'
await assert.rejects(readFile(join(dir, 'chain.json')), /ENOENT/);
assert.equal(JSON.parse(await readFile(file, 'utf8')).version, 1);
if (process.platform !== 'win32') assert.equal((await stat(file)).mode & 0o777, 0o600);
const candidate = await mine(ledger.template(alice.owner, alice.publicKey));
const candidate = await mine(ledger.template(alice.owner, alice.publicKey, articleFor([]), statsSnapshot()));
assert.deepEqual(await ledger.submit(candidate), { accepted: true, reorg: false });
candidate.article.title = 'caller mutation';
const copy = ledger.block(candidate.hash)!;
@@ -156,7 +170,8 @@ test('NFT maturity, ordered mempool, block commitments and replay protection', a
assert.equal(testnetOwnerOf(ledger.snapshot(), minted.tokenId), alice.owner); // Unconfirmed is not ownership.
const restarted = await Ledger.open(dir, new FakePow());
assert.deepEqual(restarted.snapshot().pendingTransfers, [tx, tx2]);
const block = await mine(restarted.template(alice.owner, alice.publicKey));
const block = await mine(restarted.template(alice.owner, alice.publicKey,
articleFor(restarted.snapshot().blocks), statsSnapshot()));
assert.deepEqual(block.transactions, [tx, tx2]);
await restarted.submit(block);
assert.equal(testnetOwnerOf(restarted.snapshot(), minted.tokenId), carol.owner);
@@ -189,7 +204,8 @@ test('reorg rolls back NFT ownership and requeues only still-valid disconnected
assert.deepEqual(snapshot.pendingTransfers, [tx, tx2]); // Orphan issuance is discarded.
assert.ok(ledger.block(committed.hash));
assert.deepEqual((await Ledger.open(dir, new FakePow())).snapshot(), snapshot);
const next = await mine(ledger.template(bob.owner, bob.publicKey), bob);
const next = await mine(ledger.template(bob.owner, bob.publicKey,
articleFor(ledger.snapshot().blocks), statsSnapshot()), bob);
await ledger.submit(next);
assert.equal(testnetOwnerOf(ledger.snapshot(), minted.tokenId), carol.owner);
});
@@ -210,11 +226,17 @@ test('reorg removes mempool conflicts and does not requeue already committed tra
assert.equal(testnetOwnerOf(ledger.snapshot(), minted.tokenId), carol.owner);
});
test('invalid parent, manifest, proof, catalog, transaction/state commitments leave ledger unchanged', async t => {
test('invalid parent, manifest, proof, article/stat snapshots, and commitments leave ledger unchanged', async t => {
const { ledger } = await fixture(t);
const valid = await extend(branch());
assert.notEqual(mintId(valid), mintId({ ...valid,
statsSnapshot: { ...valid.statsSnapshot, capturedAt: '2026-10-19T00:00:01.000Z' } }));
for (const changed of [ { previousHash: 'aa'.repeat(32) }, { chainId: 'bb'.repeat(32) },
{ network: 'another-network' }, { article: { ...valid.article, extract: 'tampered catalog' } },
{ network: 'another-network' }, { article: { ...valid.article, extract: 'tampered snapshot' } },
{ article: { ...valid.article, image: 'https://evil.example/image.jpg' } },
{ article: { ...valid.article, image: undefined } },
{ statsSnapshot: { ...valid.statsSnapshot, capturedAt: '2026-10-19T00:00:01.000Z' } },
{ statsSnapshot: { ...valid.statsSnapshot, stats: { ...valid.statsSnapshot.stats, depth: 0 } } },
{ stateRoot: '00'.repeat(32) }, { transactionsRoot: '00'.repeat(32) },
{ tokenId: '00'.repeat(32) }, { powHash: '00'.repeat(32) }, { nonce: valid.nonce + 1 },
{ signature: bob.sign(blockClaimMessage(valid.hash)) } ]) {
@@ -223,7 +245,11 @@ test('invalid parent, manifest, proof, catalog, transaction/state commitments le
}
await assert.rejects(ledger.submitTransfer({ ...transfer('aa'.repeat(32)), chainId: 'bb'.repeat(32) }));
await ledger.submit(valid); // Queue recovers from failed validations.
assert.equal(ledger.size, 1);
const repeated = await mine(ledger.template(alice.owner, alice.publicKey, valid.article, valid.statsSnapshot));
assert.equal(repeated.article.pageId, valid.article.pageId);
assert.notEqual(repeated.tokenId, valid.tokenId);
await ledger.submit(repeated);
assert.equal(ledger.size, 2);
});
test('startup rechecks every proof, rejects tampering and mismatched manifests without resetting disk', async t => {
@@ -286,7 +312,8 @@ test('mempool is ordered, capped at 256, and templates include at most 64 withou
await Promise.all(transactions.map(tx => ledger.submitTransfer(tx)));
assert.deepEqual(ledger.snapshot().pendingTransfers, transactions);
await assert.rejects(ledger.submitTransfer(transfer(minted.tokenId, alice, bob, 256)), /256/);
const template = ledger.template(alice.owner, alice.publicKey);
const template = ledger.template(alice.owner, alice.publicKey,
articleFor(ledger.snapshot().blocks), statsSnapshot());
assert.deepEqual(template.transactions, transactions.slice(0, 64));
await assert.rejects(ledger.submitTransfer(transfer(mintId(template.header))), /256/);
const block = await mine(template);
+1 -1
View File
@@ -54,7 +54,7 @@ test('Node WebCrypto backup roundtrip preserves SPKI SHA256 identity and signing
key: createPublicKey({ key: spki, format: 'der', type: 'spki' }), dsaEncoding: 'ieee-p1363',
}, Buffer.from(signature, 'base64')), true);
assert.equal(backup.iterations, 600000);
assert.equal(backup.network, 'wikideck-randomx-testnet-2');
assert.equal(backup.network, 'wikideck-randomx-testnet-4');
assert.equal(Buffer.from(backup.salt, 'base64').length, 16);
assert.equal(Buffer.from(backup.iv, 'base64').length, 12);
assert.ok(Buffer.byteLength(JSON.stringify(backup)) < 32 * 1024);
+82
View File
@@ -0,0 +1,82 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { WikipediaArticleSource } from '../server/wiki.ts';
function page(pageid: number, patch: Record<string, unknown> = {}) {
return { pageid, ns: 0, title: `Article ${pageid}`, extract: `An encyclopedia article with enough explanatory text for a useful collectible card. ${pageid}.`,
lastrevid: pageid + 100, thumbnail: { source: `https://upload.wikimedia.org/wikipedia/commons/${pageid}.jpg` },
categories: [{ title: 'Category:Science' }], ...patch };
}
function json(value: unknown, headers: Record<string, string> = {}) {
return new Response(JSON.stringify(value), { headers: { 'Content-Type': 'application/json', ...headers } });
}
function mockFetch(handler: (url: URL, init: RequestInit) => Response | Promise<Response>) {
const calls: { url: URL; init: RequestInit }[] = [];
const fetcher: typeof fetch = async (input, init) => {
const url = new URL(String(input));
calls.push({ url, init: init! });
return handler(url, init!);
};
return { fetcher, calls };
}
test('random article lookup retries batches with no pictures and selects a pictured page', async () => {
let attempt = 0;
const { fetcher, calls } = mockFetch(() => {
attempt++;
return attempt === 1
? json({ query: { pages: [page(10, { thumbnail: undefined }), page(11, { thumbnail: undefined })] } })
: json({ query: { pages: [page(12)] } });
});
const article = await new WikipediaArticleSource(fetcher).random();
assert.equal(attempt, 2);
assert.equal(article.pageId, 12);
assert.equal(article.source, 'live');
assert.equal(article.image, 'https://upload.wikimedia.org/wikipedia/commons/12.jpg');
assert.equal(article.url, 'https://en.wikipedia.org/?curid=12');
assert.equal(calls.length, 2);
assert.equal(calls[0].url.origin, 'https://en.wikipedia.org');
assert.equal(calls[0].url.searchParams.get('generator'), 'random');
assert.equal(calls[0].url.searchParams.get('grnlimit'), '10');
assert.equal(calls[0].url.searchParams.get('piprop'), 'thumbnail');
assert.equal(calls[0].init.redirect, 'error');
});
test('only HTTPS Wikimedia pictures are eligible and random lookup has a bounded retry count', async () => {
const { fetcher, calls } = mockFetch(() => json({ query: { pages: [
page(20, { thumbnail: { source: 'https://evil.example/tracker.jpg' } }),
page(21, { thumbnail: { source: 'http://upload.wikimedia.org/image.jpg' } }),
] } }));
await assert.rejects(new WikipediaArticleSource(fetcher).random(), /three attempts/);
assert.equal(calls.length, 3);
});
test('random article fetch rejects unsafe response types, redirects, and oversized payloads without fallback', async () => {
const failures = [
() => new Response('{}', { headers: { 'Content-Type': 'text/html' } }),
() => new Response(null, { status: 302, headers: { Location: 'https://evil.example' } }),
() => new Response(' '.repeat(512 * 1024 + 1), { headers: { 'Content-Type': 'application/json' } }),
() => { throw new Error('offline'); },
];
for (const fail of failures) {
const { fetcher, calls } = mockFetch(() => fail());
await assert.rejects(new WikipediaArticleSource(fetcher).random());
assert.equal(calls.length, 3);
}
});
test('bad page records are skipped while later valid candidates are considered', async () => {
const { fetcher } = mockFetch(() => json({ query: { pages: [
page(30, { pageid: Number.MAX_SAFE_INTEGER + 1 }), page(31, { extract: 'short' }), page(32),
] } }));
const article = await new WikipediaArticleSource(fetcher).random();
assert.equal(article.pageId, 32);
});
test('a previously selected Wikipedia page may be selected again', async () => {
const { fetcher } = mockFetch(() => json({ query: { pages: [page(40)] } }));
const source = new WikipediaArticleSource(fetcher);
const first = await source.random();
const second = await source.random();
assert.equal(first.pageId, second.pageId);
});