Mainnet
This commit is contained in:
+15
-2
@@ -12,12 +12,19 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
ARG WIKIDECK_NETWORK=testnet
|
||||
ARG WIKIDECK_NETWORK=mainnet
|
||||
ARG WIKIDECK_GENESIS_TIME=1798761600
|
||||
ENV VITE_WIKIDECK_NETWORK=$WIKIDECK_NETWORK
|
||||
ENV VITE_WIKIDECK_GENESIS_TIME=$WIKIDECK_GENESIS_TIME
|
||||
RUN npm run build
|
||||
|
||||
FROM build AS test
|
||||
RUN apk add --no-cache libstdc++
|
||||
ENV WIKIDECK_NETWORK=testnet \
|
||||
WIKIDECK_GENESIS_TIME=1792411200 \
|
||||
VITE_WIKIDECK_NETWORK=testnet \
|
||||
VITE_WIKIDECK_GENESIS_TIME=1792411200
|
||||
RUN npm run build
|
||||
COPY --from=randomx-build /native/build/wikideck-randomx /app/native/wikideck-randomx
|
||||
ENV RANDOMX_BINARY=/app/native/wikideck-randomx
|
||||
CMD ["npm", "test"]
|
||||
@@ -29,11 +36,17 @@ RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
|
||||
|
||||
FROM node:24-alpine AS runtime
|
||||
RUN apk add --no-cache libstdc++
|
||||
ARG WIKIDECK_NETWORK=mainnet
|
||||
ARG WIKIDECK_GENESIS_TIME=1798761600
|
||||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=3001 \
|
||||
WIKIDECK_DATA_DIR=/app/data \
|
||||
RANDOMX_BINARY=/app/native/wikideck-randomx
|
||||
RANDOMX_BINARY=/app/native/wikideck-randomx \
|
||||
WIKIDECK_NETWORK=$WIKIDECK_NETWORK \
|
||||
WIKIDECK_GENESIS_TIME=$WIKIDECK_GENESIS_TIME \
|
||||
WIKIDECK_IMAGE_NETWORK=$WIKIDECK_NETWORK \
|
||||
WIKIDECK_IMAGE_GENESIS_TIME=$WIKIDECK_GENESIS_TIME
|
||||
WORKDIR /app
|
||||
COPY --from=production-deps /app/node_modules ./node_modules
|
||||
COPY package.json ./
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
Wikideck is an experimental blockchain game about **mining, collecting, and trading illustrated Wikipedia cards**. Mine a block with the native RandomX CPU miner to earn a card, build a collection in a local wallet, and trade cards directly with another player.
|
||||
|
||||
**Production readiness:** see the [card-only production plan](docs/card-only-production-plan.md). A public beta and a valuable mainnet launch have separate release gates; neither is approved by a successful local build.
|
||||
**Mainnet-first, still NO-GO:** the default image, runtime profile, and Compose service are mainnet, but the network is experimental, unaudited, and not launched. A local build is not production approval. See the [card-only production plan](docs/card-only-production-plan.md) and [mainnet launch gates](docs/mainnet-launch.md).
|
||||
|
||||
Mining uses **RandomX, the proof-of-work algorithm also used by Monero**, designed to keep general-purpose consumer CPUs competitive. Wikideck is an independent blockchain, not part of Monero or its mining/security network. Hardware, total network hashpower and difficulty determine results; no particular mining reward rate or profitability is guaranteed.
|
||||
|
||||
**This is a local test network, not a public mainnet or an investment product.** No public bootnodes or hosted network are provided. Test NFTs have no promised value. Do not use funds or valuable assets. This project is not affiliated with Wikipedia or the Wikimedia Foundation.
|
||||
**Mainnet is a gated candidate, not an operating public network or an investment product.** There are no hosted nodes, public bootnodes, or automatic peer discovery. No external audit is planned; the operator accepts that residual risk. Cards have no promised monetary value. Do not use funds or valuable assets. This project is not affiliated with Wikipedia or the Wikimedia Foundation.
|
||||
|
||||
## Mine, collect, and trade cards
|
||||
|
||||
@@ -19,13 +19,13 @@ Mining uses **RandomX, the proof-of-work algorithm also used by Monero**, design
|
||||
|
||||
## What's available now
|
||||
|
||||
| Available in the testnet | Not included |
|
||||
| Available in the mainnet-first codebase | Not included |
|
||||
| --- | --- |
|
||||
| RandomX mining and illustrated Wikipedia card rewards | Playable card games |
|
||||
| Encrypted local wallets, collections, transfers, and atomic card trades | Production network launch |
|
||||
| Local single-node or optional three-node testnet | — |
|
||||
| Mainnet candidate by default; testnet only as an explicit local development fixture | Public bootstrap network or production readiness |
|
||||
|
||||
## Quick start: one local node (Docker)
|
||||
## Default: mainnet candidate (Docker)
|
||||
|
||||
Install Docker Desktop with Linux containers and Docker Compose v2. From the repository root:
|
||||
|
||||
@@ -34,9 +34,11 @@ docker compose up --build -d
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Open **http://127.0.0.1:3001**. The first run builds the node and native RandomX engine. The node does not mine automatically. Open the wallet panel to create a local wallet and encrypted backup, then follow [Mine Wikipedia cards with RandomX](#mine-wikipedia-cards-with-randomx) to run the miner. Stop the node with `docker compose down`; the ledger volume remains. **Do not add `-v` unless you intend to delete that data.**
|
||||
The default Compose service is mainnet-first and binds only to **http://127.0.0.1:3201** behind a separately configured HTTPS edge. It intentionally exits until the exact chain approval, explicit unaudited-risk acceptance, genesis time, HTTPS origin, and at least two valid public HTTPS peers are configured. Do not set these to placeholders just to make the process start. See [mainnet launch gates](docs/mainnet-launch.md). The node does not mine automatically. Stop it with `docker compose down`; the ledger volume remains. **Do not add `-v` unless you intend to delete that data.**
|
||||
|
||||
## Optional: three-node testnet (Docker)
|
||||
For isolated development, use the explicitly testnet-only [three-node fixture](#explicit-testnet-development-fixture-docker). `npm run dev` also pins both the API and Vite browser build to that testnet fixture; it does not silently change a production build.
|
||||
|
||||
## Explicit testnet development fixture (Docker)
|
||||
|
||||
Install Docker Desktop with Linux containers and Docker Compose v2. From the repository root, start three connected nodes:
|
||||
|
||||
@@ -56,7 +58,7 @@ docker compose -f compose.testnet.yaml down
|
||||
|
||||
The target block interval is **15 minutes**. The volumes persist across stops and rebuilds. **Do not add `-v` unless you intend to delete all three ledgers.** Back up wallets separately. Protocol v6 adds atomic multi-card trades and a new chain identity; earlier v2–v5 ledgers and encrypted backups are not compatible. To keep old Docker data intact while trying v6, use a separate Compose project name, for example `docker compose -p wikideck-v6 -f compose.testnet.yaml up --build -d`, and use the same `-p wikideck-v6` for subsequent Compose commands. The default `docker compose up --build -d` uses [compose.yaml](compose.yaml): it starts one isolated node on port 3001 with a different volume.
|
||||
|
||||
For an existing host install, keep the old data directory intact and set `WIKIDECK_DATA_DIR` to a new empty directory before starting protocol v6. The node intentionally fails closed on an old-protocol ledger; it does not reset or migrate it. A v6 JSON ledger is imported into SQLite on first startup and left in place as a recovery copy.
|
||||
For an existing host install, keep the old data directory intact and set `WIKIDECK_DATA_DIR` to a new empty directory before starting protocol v6. The node intentionally fails closed on an old-protocol ledger; it does not reset or migrate it. A v6 JSON ledger is imported into SQLite on first startup and left in place as a recovery copy. `npm start` defaults to the gated mainnet identity; use `npm run dev` only for local testnet development.
|
||||
|
||||
## Run on your host
|
||||
|
||||
@@ -81,19 +83,19 @@ The node listens at **http://127.0.0.1:3001** by default. For development, `npm
|
||||
|
||||
Open the wallet panel to explicitly create an encrypted local P-256 wallet, unlock an existing vault, or encrypt a legacy plaintext wallet without changing its identity. Nothing is silently generated or unlocked at startup. Choose a strong local password of at least 12 characters. **Wallet backup & recovery** downloads a separately password-encrypted portable backup; keep the file outside the repository and its password separately, and test recovery in another browser profile.
|
||||
|
||||
New/migrated local vaults encrypt the key in IndexedDB; the unlocked key remains exportable in tab memory for signing and backup. Manual locking, five-minute inactivity, a four-hour absolute lifetime, reload, or hiding/leaving the tab ends the session. Same-origin compromise can still capture passwords or abuse an unlocked signer. Forgotten local passwords cannot be reset: recover from a usable backup. Legacy disk copies may retain old plaintext keys even after migration. Wallets are scoped to an origin (scheme, hostname, and port), so a backup must be restored at another origin to use the same identity there. Recovery does not move assets between chains. See [encrypted wallet lifecycle and remaining risks](docs/wallet-security.md).
|
||||
New/migrated local vaults encrypt the key in an IndexedDB database scoped to the exact chain ID; v2 encrypted backups authenticate that same chain ID. Original fixed-testnet v1 backups remain recoverable only on that original testnet. The unlocked key remains exportable in tab memory for signing and backup. Manual locking, five-minute inactivity, a four-hour absolute lifetime, reload, or hiding/leaving the tab ends the session. Same-origin compromise can still capture passwords or abuse an unlocked signer. Forgotten local passwords cannot be reset: recover from a usable backup. Legacy disk copies may retain old plaintext keys even after migration. Wallets are scoped to an origin (scheme, hostname, and port), so a backup must be restored at another origin to use the same identity there. Recovery does not move assets between chains. See [encrypted wallet lifecycle and remaining risks](docs/wallet-security.md).
|
||||
|
||||
The current testnet wallet is separate from any wallet used by an older demo. There is no automatic migration. See [consensus, wallets, and operational limits](docs/testnet-consensus.md) and the in-app backup warnings for more detail.
|
||||
Each genesis has a separate browser wallet store, and backups cannot cross chain identities. Testnet remains only as an explicit development fixture; there is no automatic migration from older demos. See [consensus, wallets, and operational limits](docs/testnet-consensus.md) and the in-app backup warnings for more detail.
|
||||
|
||||
## Mine Wikipedia cards with RandomX
|
||||
|
||||
Mining uses the native CPU RandomX engine. First build the engine, download an encrypted wallet backup, then run the CLI from an interactive terminal and enter the password at its hidden prompt:
|
||||
|
||||
```sh
|
||||
npm run miner -- --wallet "path/to/encrypted-backup.json" --consent --node http://127.0.0.1:3101 --blocks 3
|
||||
npm run miner:testnet -- --wallet "path/to/encrypted-backup.json" --consent --node http://127.0.0.1:3101 --blocks 3
|
||||
```
|
||||
|
||||
Use port 3001 for the default single host/Docker node, or 3101–3103 for the three-node Docker testnet. The command above uses node-a. The miner defaults to one worker, light mode, and one block; `--blocks 3` requests up to three accepted blocks. Press **Ctrl+C** to stop it. Do not put the backup password in a command, environment variable, or repository file. The backup and password are decrypted locally and are not sent to the node.
|
||||
The example is intentionally testnet-only and uses node-a; the dedicated command pins that profile and its fixed genesis. For mainnet, use `npm run miner` with a chain-bound mainnet backup and the configured HTTPS node origin after satisfying the mainnet launch gates. The miner defaults to one worker, light mode, and one block; `--blocks 3` requests up to three accepted blocks. Press **Ctrl+C** to stop it. Do not put the backup password in a command, environment variable, or repository file. The backup and password are decrypted locally and are not sent to the node.
|
||||
|
||||
Each template request asks English Wikipedia for up to ten random pages and retries at most three times to find one with an image and readable extract. The same page may be selected again. The node also captures page size, language-link count, revision, pageviews, and derived scores for the mint-time snapshot. If Wikipedia cannot supply the article or its metadata, template creation returns an error; there is no fixed-article fallback. Validators check and commit the included snapshot without contacting Wikipedia during consensus validation.
|
||||
|
||||
@@ -112,9 +114,9 @@ The browser interface trusts the connected node's reported state. It does not in
|
||||
|
||||
## Mainnet candidate · launch gated
|
||||
|
||||
**Production status: NO-GO pending remediation and independent review.** See the [security review](docs/security-review.md) and [operator launch checklist](docs/operator-launch.md). A read-only `launch:preflight` script reports blockers and local gate status without launching or granting approval.
|
||||
**Production status: NO-GO pending network, capacity, deployment, and recovery evidence.** External audits are not planned; the operator accepts the residual risk, and the candidate must not be described as audited or certified. See the [security review](docs/security-review.md) and [operator launch checklist](docs/operator-launch.md). A read-only `launch:preflight` script reports blockers and local gate status without launching or granting approval.
|
||||
|
||||
A separate mainnet profile has its own network/genesis identity, ledger directory, browser wallet, backup checks, and opt-in [Compose definition](compose.mainnet.yaml). It preserves the existing testnet chain and does not migrate testnet cards. **No production network has been launched or certified by this work.** Startup requires explicit chain-specific approval, an operator review attestation, the candidate genesis time, HTTPS origins and multiple configured peers. See [the candidate launch gates and production checklist](docs/mainnet-launch.md).
|
||||
The mainnet profile is the default and has a deployment-pinned network/genesis identity, separate ledger directory, chain-ID-scoped browser wallet, and chain-bound backups. The alternate [Compose definition](compose.mainnet.yaml) is also explicitly mainnet. Testnet is available only through the dedicated development/test harness. **No production network has been launched or certified by this work.** Startup requires explicit chain-specific approval, explicit unaudited-risk acceptance, the chosen genesis time in the built image, HTTPS origin, and multiple configured public peers. See [the mainnet launch gates and production checklist](docs/mainnet-launch.md).
|
||||
|
||||
## Tests and development checks
|
||||
|
||||
@@ -146,7 +148,7 @@ This mines three real blocks using disposable identities. It changes the testnet
|
||||
|
||||
Nodes use only explicitly configured peers; there is no discovery service or public bootstrap network. The peer, storage, and HTTP configuration is documented in [docs/testnet-consensus.md](docs/testnet-consensus.md). The implementation is experimental: it has no established economic security and remains vulnerable to resource exhaustion, isolation/eclipsing, censorship, majority-hashpower attacks, and reorganizations. The ledger now uses transactional SQLite/WAL storage and sparse per-block NFT-state deltas, but nodes still retain DAG block records in memory, rebuild state roots over the full NFT set, and revalidate all proofs at startup. It is not yet an archival-scale production design. HTTPS, container hardening, and passing tests do not constitute a security audit.
|
||||
|
||||
Before considering any broader deployment, the protocol and cryptography would need independent audits, adversarial network testing, hardened abuse controls, and tested recovery procedures. Do not expose a public node or use valuable assets on this experimental network.
|
||||
Before considering any broader deployment, complete adversarial network testing, hardened abuse controls, and recovery procedures. External protocol/cryptography audits are not planned; this leaves accepted residual risk. Do not expose a public node or use valuable assets on this experimental network.
|
||||
|
||||
## Attribution
|
||||
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
# Review docs/mainnet-launch.md before attempting activation.
|
||||
services:
|
||||
mainnet:
|
||||
profiles: [mainnet]
|
||||
build:
|
||||
context: .
|
||||
target: runtime
|
||||
args:
|
||||
WIKIDECK_NETWORK: mainnet
|
||||
WIKIDECK_GENESIS_TIME: "${WIKIDECK_GENESIS_TIME:-1798761600}"
|
||||
ports:
|
||||
- "127.0.0.1:${WIKIDECK_MAINNET_PORT:-3201}:3001"
|
||||
environment:
|
||||
WIKIDECK_NETWORK: mainnet
|
||||
WIKIDECK_GENESIS_TIME: "${WIKIDECK_GENESIS_TIME:-1798761600}"
|
||||
PUBLIC_ORIGIN: "${WIKIDECK_MAINNET_ORIGIN:-}"
|
||||
WIKIDECK_PEERS: "${WIKIDECK_MAINNET_PEERS:-}"
|
||||
WIKIDECK_MAINNET_LAUNCH: "${WIKIDECK_MAINNET_LAUNCH:-}"
|
||||
WIKIDECK_MAINNET_REVIEW_ACK: "${WIKIDECK_MAINNET_REVIEW_ACK:-}"
|
||||
WIKIDECK_MAINNET_RISK_ACK: "${WIKIDECK_MAINNET_RISK_ACK:-}"
|
||||
WIKIDECK_ALLOW_PRIVATE_PEERS: "0"
|
||||
# Empty by default; set only the actual edge-to-container source IP/CIDR after topology review.
|
||||
WIKIDECK_TRUSTED_PROXIES: "${WIKIDECK_TRUSTED_PROXIES:-}"
|
||||
|
||||
+12
-3
@@ -4,7 +4,10 @@ x-node-environment: &node-environment
|
||||
services:
|
||||
node-a:
|
||||
image: wikideck:testnet
|
||||
build: { context: ., target: runtime }
|
||||
build:
|
||||
context: .
|
||||
target: runtime
|
||||
args: { WIKIDECK_NETWORK: testnet, WIKIDECK_GENESIS_TIME: "1792411200" }
|
||||
ports: ["127.0.0.1:3101:3001"]
|
||||
environment:
|
||||
<<: *node-environment
|
||||
@@ -23,7 +26,10 @@ services:
|
||||
stop_grace_period: 30s
|
||||
node-b:
|
||||
image: wikideck:testnet
|
||||
build: { context: ., target: runtime }
|
||||
build:
|
||||
context: .
|
||||
target: runtime
|
||||
args: { WIKIDECK_NETWORK: testnet, WIKIDECK_GENESIS_TIME: "1792411200" }
|
||||
ports: ["127.0.0.1:3102:3001"]
|
||||
environment:
|
||||
<<: *node-environment
|
||||
@@ -42,7 +48,10 @@ services:
|
||||
stop_grace_period: 30s
|
||||
node-c:
|
||||
image: wikideck:testnet
|
||||
build: { context: ., target: runtime }
|
||||
build:
|
||||
context: .
|
||||
target: runtime
|
||||
args: { WIKIDECK_NETWORK: testnet, WIKIDECK_GENESIS_TIME: "1792411200" }
|
||||
ports: ["127.0.0.1:3103:3001"]
|
||||
environment:
|
||||
<<: *node-environment
|
||||
|
||||
+16
-9
@@ -3,18 +3,25 @@ services:
|
||||
build:
|
||||
context: .
|
||||
target: runtime
|
||||
args:
|
||||
WIKIDECK_NETWORK: mainnet
|
||||
WIKIDECK_GENESIS_TIME: "${WIKIDECK_GENESIS_TIME:-1798761600}"
|
||||
ports:
|
||||
# Keep the prototype private; listen on all interfaces only inside the container.
|
||||
- "127.0.0.1:${WIKIDECK_PORT:-3001}:3001"
|
||||
# Loopback only; terminate reviewed HTTPS at the operator's edge.
|
||||
- "127.0.0.1:${WIKIDECK_MAINNET_PORT:-3201}:3001"
|
||||
environment:
|
||||
WIKIDECK_NETWORK: testnet
|
||||
PUBLIC_ORIGIN: "${PUBLIC_ORIGIN:-http://127.0.0.1:${WIKIDECK_PORT:-3001}}"
|
||||
WIKIDECK_PEERS: "${WIKIDECK_PEERS:-}"
|
||||
WIKIDECK_ALLOW_PRIVATE_PEERS: "${WIKIDECK_ALLOW_PRIVATE_PEERS:-0}"
|
||||
WIKIDECK_NETWORK: mainnet
|
||||
WIKIDECK_GENESIS_TIME: "${WIKIDECK_GENESIS_TIME:-1798761600}"
|
||||
PUBLIC_ORIGIN: "${WIKIDECK_MAINNET_ORIGIN:-}"
|
||||
WIKIDECK_PEERS: "${WIKIDECK_MAINNET_PEERS:-}"
|
||||
WIKIDECK_MAINNET_LAUNCH: "${WIKIDECK_MAINNET_LAUNCH:-}"
|
||||
WIKIDECK_MAINNET_RISK_ACK: "${WIKIDECK_MAINNET_RISK_ACK:-}"
|
||||
WIKIDECK_ALLOW_PRIVATE_PEERS: "0"
|
||||
WIKIDECK_TRUSTED_PROXIES: "${WIKIDECK_TRUSTED_PROXIES:-}"
|
||||
volumes:
|
||||
- wikideck-data:/app/data
|
||||
- wikideck-mainnet-data:/app/data
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
restart: "no"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=32m,mode=1777,noexec,nosuid
|
||||
@@ -28,4 +35,4 @@ services:
|
||||
stop_grace_period: 30s
|
||||
|
||||
volumes:
|
||||
wikideck-data:
|
||||
wikideck-mainnet-data:
|
||||
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
# Kubernetes mainnet preparation
|
||||
|
||||
This directory intentionally contains no apply-ready manifests yet. The code is mainnet-first and supports a genesis frozen into the browser bundle and runtime image, but the current launch gate requires deployment facts that were not supplied. Placeholder origins, approval IDs, TLS secrets, or storage settings would not produce a working or safe deployment.
|
||||
|
||||
## Required topology for this implementation
|
||||
|
||||
- Build and publish one immutable image with `WIKIDECK_NETWORK=mainnet` and one chosen `WIKIDECK_GENESIS_TIME`. Give every peer and browser bundle that same chain identity. A runtime-only genesis override is rejected by the image manifest check.
|
||||
- Give each node a stable, unique public HTTPS origin. TLS must terminate at an operator-managed ingress/reverse proxy; the application listener itself is HTTP on port 3001.
|
||||
- Configure at least two other distinct public HTTPS peer origins per node. The current mainnet peer client rejects private IPs and private DNS answers. Kubernetes-only ClusterIP/headless DNS peers are not accepted; never enable `WIKIDECK_ALLOW_PRIVATE_PEERS=1` to bypass the mainnet launch gate.
|
||||
- Route each public peer hostname deterministically to exactly one node. A shared load-balanced hostname is not a stable peer identity. Separate per-peer Deployments/Services/Ingress routes are the simplest current shape; each node must exclude its own origin from its peer list.
|
||||
- Give every node an exclusive persistent volume mounted at `/app/data`. Never share a ledger volume between pods. Agree the storage class, capacity, backup/restore procedure, resource limits, failure domains, and upgrade/reorg policy before enabling the launch approval.
|
||||
- Configure the exact manifest `CHAIN_ID` as `WIKIDECK_MAINNET_LAUNCH`, and explicitly set `WIKIDECK_MAINNET_RISK_ACK=operator-accepts-unaudited-risk`. The latter accepts unaudited residual risk; it is not an audit or readiness check.
|
||||
|
||||
## Pod hardening baseline
|
||||
|
||||
Use a dedicated namespace and least-privilege service account (or disable token automount if no Kubernetes API access is needed), non-root execution matching the image's `node` user, read-only root filesystem, dropped Linux capabilities, `allowPrivilegeEscalation: false`, `seccompProfile: RuntimeDefault`, and a bounded writable `/tmp` volume. Use resource requests and limits based on measured RandomX/replay/sync behavior, not guesses. Readiness/liveness should use `/healthz`; allow a startup grace period for ledger replay and native engine initialization. Restrict ingress to the reviewed edge and egress to DNS, the configured public peers, and required Wikimedia endpoints. Add NetworkPolicy, TLS, image provenance/signature enforcement, monitoring, and tested encrypted backups through the cluster's established platform controls.
|
||||
|
||||
Do not make the public origin or configured peers imply that peers are independent or that the network is secure. This software has no external audit, public bootstrap service, peer discovery, or demonstrated production capacity.
|
||||
|
||||
## Values needed before manifests can be finalized
|
||||
|
||||
Choose the genesis timestamp and image registry/digest; provide the namespace, ingress class and TLS/certificate mechanism, public DNS name per node, peer topology, storage class/size, resource budget, network-policy egress model, backup destination/retention, and desired availability/recovery behavior. Then calculate and publish the exact manifest and chain approval. Until those values and the peer topology are available, this checklist is the safe preparation artifact rather than a misleading runnable YAML template.
|
||||
@@ -8,7 +8,7 @@ Wikideck's current product scope is **RandomX mining, collecting cards, and dire
|
||||
|
||||
The nearest reasonable release target is a **publicly hosted, non-monetary testnet beta**. It must be labeled experimental and valueless. It is not a mainnet launch, a promise of ownership permanence, an investment product, or an independently secured network. Mining consumes compute, electricity, and time; no reward rate or profitability is promised.
|
||||
|
||||
Mainnet or valuable-card use is a separate, higher bar. It remains **NO-GO** until consensus, wallet, network, capacity, deployment, and independent-review gates below have evidence and named sign-off. The mainnet candidate's launch date and approval flags do not substitute for that evidence.
|
||||
Mainnet or valuable-card use is a separate, higher bar. It remains **NO-GO** until consensus, wallet, network, capacity, deployment, and operator risk-acceptance gates below have evidence and named sign-off. The operator has chosen not to commission external audits; this is a conscious acceptance of additional residual risk, not evidence that the protocol or implementation is secure.
|
||||
|
||||
## Current starting point
|
||||
|
||||
@@ -17,7 +17,7 @@ Mainnet or valuable-card use is a separate, higher bar. It remains **NO-GO** unt
|
||||
- Existing runtime volumes and historical backup packages may contain local financial journals or external database bindings. Removing source code does not delete or resolve those records; preserve them separately and do not claim new ledger-only backups include them.
|
||||
- The current network has no public bootnodes or automatic peer discovery. Peers must be configured by operators; containers on one host do not provide independent network security.
|
||||
- The inherited initial proof-of-work target accepts one in sixteen hashes, and the ten-block retarget is not production-calibrated. Nodes retain the DAG in memory, rebuild state roots across the NFT set, and revalidate stored proofs at startup.
|
||||
- Browser-wallet encryption is implemented, but same-origin code can abuse an unlocked wallet; old plaintext copies, cross-browser recovery, and independent wallet review remain concerns.
|
||||
- Browser-wallet encryption is implemented, but same-origin code can abuse an unlocked wallet; old plaintext copies and cross-browser recovery remain concerns.
|
||||
|
||||
See the [mainnet launch limitations](mainnet-launch.md), [consensus reference](testnet-consensus.md), [wallet limitations](wallet-security.md), and [security review](security-review.md). Existing passing tests and local rehearsals are useful evidence, not production certification.
|
||||
|
||||
@@ -33,22 +33,38 @@ See the [mainnet launch limitations](mainnet-launch.md), [consensus reference](t
|
||||
|
||||
**Exit evidence:** scope owner approval is still required. Source/deployment removals are complete; API regression checks cover removed routes while preserving card trades; backup v2 and legacy-data refusal are covered; old paired backups remain portable. No active product docs promise sales or payments.
|
||||
|
||||
### Gate 1 — independent protocol and application review
|
||||
### Gate 1 — internal protocol and application review · complete by operator decision
|
||||
|
||||
- Commission independent review of consensus serialization, signatures, state roots, fork choice, timestamp/retarget logic, peer message validation, RandomX integration/native boundary, and wallet signing/recovery.
|
||||
- Test competing branches, deep reorgs, clock skew, eclipse/partition behavior, malformed traffic, mempool/resource exhaustion, and crash/restart recovery on disposable networks.
|
||||
- Fix critical/high findings and add regression evidence. Publish the exact reviewed protocol manifest and residual risks; do not set review acknowledgements merely to pass startup gates.
|
||||
**Operator decision (2026-10-28):** no external audit or independent review will be commissioned for this release. The product will proceed using internal code review, regression tests, local adversarial exercises, and explicit operator risk acceptance. This does not certify the software or establish that undiscovered critical defects are absent.
|
||||
|
||||
**Exit evidence:** review against the exact release candidate, issue dispositions, regression tests, and named reviewer sign-off. An internal code review or test count is not independent assurance.
|
||||
Internal source-assisted review covered consensus serialization/validation, signatures, state commitments, fork choice, timestamps/retarget, peer URL/DNS/pagination/response handling, RandomX native boundary, wallet signing/recovery documentation and tests.
|
||||
|
||||
### Gate 2 — establish a credible network
|
||||
Internal findings and dispositions:
|
||||
|
||||
- Measure expected and adversarial hashpower on intended hardware; quantify rewrite costs and concentration risk. Choose and review initial difficulty, retarget, timestamp, and stall-recovery behavior before freezing a mainnet identity.
|
||||
- Design the bootstrap and peer-operations model: independently administered nodes, supported peer discovery or a documented trusted bootstrap list, upgrade coordination, and abuse/isolation response.
|
||||
- Demonstrate actual connectivity and independent mining across failure domains. Peer URL count alone is not evidence that peers are online, independent, or secure.
|
||||
- **Clock rollback could strand accepted SQLite history.** Startup replay applied the live future-skew admission bound to every persisted block. A block accepted at the allowed future edge could fail replay if the clock later moved backward. Startup now replays previously accepted SQLite history without reapplying that live-time bound. New blocks imported from legacy JSON retain a pinned import-time clock and imported-block cutoff; their future timestamps are checked on import and on later replays, so an untrusted legacy file cannot seed arbitrarily future-dated history. Regression tests cover both rollback recovery and repeated rejection of future-dated legacy data.
|
||||
- **Invalid work reached full NFT-state materialization.** Block validation computed the state transition/root before checking the claimed and actual RandomX proof. Cheap commitment/signature/claimed-target checks now precede native proof verification, and the complete state transition/root is computed only after the native proof matches. A regression uses an instrumented state to assert that a wrong claimed proof is rejected before NFT-state enumeration.
|
||||
- Added a deterministic 30-block versus 16-block competing-fork regression: equal cumulative work keeps the current tip, and a strictly higher-work branch then causes a 30-block reorganization while preserving both DAG branches. This is a local FakePow consensus-state test, not a multi-host security rehearsal.
|
||||
|
||||
Current validation evidence: the Docker test image and TypeScript/Vite build succeeded; the complete offline suite passed **95/95** with the native RandomX HTTP integration; the packaged native engine's upstream known-vector self-test passed. Tests cover signatures/transaction replay, atomic trades, commitments, retarget/key boundaries, equal-work fork choice, reorg/requeue, malformed schemas, peer DNS/manifest/pagination/response rejection, mempool and RPC bounds, wallet backup crypto, and restart/revalidation. These checks are the selected assurance approach; they are not a security certification.
|
||||
|
||||
**Residual risks carried forward:** no external auditor or independent reviewer will sign off. The candidate is not frozen. Multi-host eclipse/partition and clock-skew rehearsals, hostile populated-state load/reorg tests, and broad browser-wallet/XSS/recovery exercises remain open engineering work for Gates 2–5. The operator accepts proceeding without external audit; do not describe the result as audited or independently certified.
|
||||
|
||||
**Exit evidence:** internal findings fixed with regression coverage; current build, offline suite and native known-vector results recorded; operator accepts the unaudited-risk boundary. Gate 1 is **complete under this explicitly reduced scope**. This completion is not external assurance, and its remaining multi-host/load/wallet exercises continue under the following gates.
|
||||
|
||||
### Gate 2 — establish a credible network · in progress
|
||||
|
||||
- **Mainnet candidate inspection (2026-10-28, read-only):** `wikideck-randomx-mainnet-1`, candidate chain ID `3ef36901f49d57127ebb0bf43625c7334e9b8ef817fd31953761723b5474ef6c`, genesis `72be7cdac8e625562bc8a49a82a406f41b96a6fde285c9961dceafdbaca8cd1d`, candidate genesis time 2027-01-01 00:00 UTC. The genesis time has not arrived; this is a candidate manifest, not an announced launch. No production node was started and no approval or data volume was created.
|
||||
- **Repeat benchmark (2026-10-28):** same executable and container limits, 32 hashes measured 25.11 H/s warm (p50 39.13 ms, p95 48.18 ms), 883 ms cold initialization, and 642 ms for a key/cache switch. These two short samples are not a confidence interval and do not establish normal host variability. Both are verification-path hashes, not miner throughput, dedicated-host measurements, aggregate network hashpower, or a security budget. Do not extrapolate them to choose consensus parameters.
|
||||
- The first bounded sample in the same container was 23.35 H/s warm (p50 41.09 ms, p95 55.81 ms), 796 ms cold initialization, and 594 ms for a key/cache switch. Together these two observations show run-to-run variation only; they are not intended-host miner measurements.
|
||||
- **Bootstrap policy for the first beta:** use a fixed, operator-curated HTTPS peer allowlist supplied as `WIKIDECK_PEERS`; do not add DHT, automatic discovery, or peers learned from untrusted network messages. Treat configured peers as trust/bootstrap contacts only; each node independently validates every block and transaction. The current protocol provides no peer identity or independent-operation proof beyond the configured URL and TLS connection.
|
||||
- **Local rehearsal status:** the complete offline suite's native RandomX HTTP integration syncs two separately started node processes over loopback, gossips a mined transfer, and verifies restart/replay. This is local functional evidence only. A disposable three-node Compose attempt on 2026-10-28 could not allocate a Docker bridge subnet because the configured address pools were exhausted; Compose failed before creating services or volumes. No multi-host/failure-domain rehearsal has been performed.
|
||||
- **Mainnet peer configuration remains empty/unverified:** the repository has no operator-supplied public HTTPS origins, ownership/failure-domain evidence, or exercised TLS endpoints for the two required mainnet peers. The compose profile only forwards operator-provided peers and origin; placeholders are not launch infrastructure.
|
||||
- For a multi-node rehearsal, use at least three separately administered nodes across distinct failure domains and network providers; record operator, host/region/provider, address/origin, chain ID, software digest, availability window, and which node supplies each bootstrap connection. Three containers or aliases on one host do not count as independent.
|
||||
- Measure expected and adversarial hashpower on intended hardware; quantify rewrite costs and concentration risk. Decide whether this experimental beta retains the current easy target or whether a future mainnet identity needs different initial difficulty, retarget, timestamp, and stall-recovery rules. Do not alter a launched chain's consensus.
|
||||
- Rehearse bootstrap with an empty node, peer loss/recovery, partition/rejoin, conflicting branches, stale/malicious configured peer, and independent mining. Record the observed tips, cumulative work, sync lag, and recovery time. Peer URL count alone is not evidence that peers are online or independent.
|
||||
- State the finality/reorganization limits honestly. There is no formal finality today; a confirmation count is not an absolute guarantee.
|
||||
|
||||
**Exit evidence:** reviewed launch manifest/genesis for mainnet (or explicit testnet-only beta decision), multi-host partition/reorg/stall rehearsal, measured hashpower assumptions, and an operator-owned peer/upgrade plan.
|
||||
**Exit evidence:** explicit testnet-only beta decision (or separately approved mainnet candidate), documented bootstrap peer allowlist and ownership/failure domains, repeated intended-hardware hashrate data with variance, multi-host partition/reorg/stall rehearsal, a written risk/attack-cost estimate, and an operator-owned peer/upgrade/incident plan. Internal sign-off only; no external audit is planned.
|
||||
|
||||
### Gate 3 — demonstrate capacity and recovery limits
|
||||
|
||||
@@ -62,11 +78,11 @@ See the [mainnet launch limitations](mainnet-launch.md), [consensus reference](t
|
||||
### Gate 4 — wallet, trades, and user protection
|
||||
|
||||
- Test create, lock, unlock, backup, restore in a fresh browser/profile, and recovery after app/browser upgrade across the supported browser matrix. Test signed transfer/trade review, stale offers, pending state, and reorg outcomes.
|
||||
- Review same-origin script compromise, extensions, password entry, old plaintext-wallet migration, downloaded backup handling, and key-loss support. Decide whether production needs hardware/isolated signing; document residual risks if it does not.
|
||||
- Perform an internal threat review of same-origin script compromise, extensions, password entry, old plaintext-wallet migration, downloaded backup handling, and key-loss support. Decide whether the beta accepts browser-only signing; document residual risks rather than implying external wallet assurance.
|
||||
- Publish experimental/no-value disclosures, mining resource risks, irreversible-transfer warnings, privacy/data-retention terms, Wikipedia text/image attribution and license handling, support contact, abuse-report path, and stop conditions.
|
||||
- Select and publish an appropriate project software license before public distribution. A Wikideck license does not replace Wikipedia or dependency licenses.
|
||||
|
||||
**Exit evidence:** independent wallet review, verified recovery drill, tested trade flows, reviewed participant disclosures, public support/abuse contacts, and license decision.
|
||||
**Exit evidence:** internal wallet threat review, verified recovery drill, tested trade flows, reviewed participant disclosures, public support/abuse contacts, and license decision.
|
||||
|
||||
### Gate 5 — operate and release safely
|
||||
|
||||
@@ -86,7 +102,7 @@ May be considered only after all applicable gates pass, the node remains on the
|
||||
|
||||
### Mainnet or valuable cards
|
||||
|
||||
Requires all gates plus independent consensus/economic-security review, calibrated and frozen consensus parameters, a credible independent peer/miner topology, reviewed wallet protections, sustained capacity and adversarial evidence, and explicit governance/upgrade and incident procedures. Mainnet activation is a separate authorization. Until then, retain **NO-GO**.
|
||||
Requires all gates plus measured and operator-accepted consensus/economic risks, calibrated and frozen consensus parameters, a credible peer/miner topology, reviewed wallet protections, sustained capacity and adversarial evidence, and explicit governance/upgrade and incident procedures. External audit is intentionally not a requirement under the operator's decision, so residual risk remains higher and must be explicitly accepted before any mainnet activation. Until then, retain **NO-GO**.
|
||||
|
||||
### Markets and real money
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Consensus calibration and adversarial analysis — stage 3
|
||||
|
||||
**Decision remains NO-GO.** This stage supplies reproducible offline tools and bounded measurements, not new launch parameters, an independent audit or an operational network. Consensus, initial target, genesis, and chain identity are unchanged.
|
||||
**Decision remains NO-GO.** This stage supplies reproducible offline tools and bounded measurements, not new launch parameters or an operational network. Consensus, initial target, genesis, and chain identity are unchanged.
|
||||
|
||||
## Tools and safety boundaries
|
||||
|
||||
@@ -56,7 +56,7 @@ Before production calibration can be approved:
|
||||
- Benchmark the exact final native/image build on multiple intended independent miner/validator hosts; record CPU model, operating mode, effective quotas, competing load, cold/warm/cache-switch distributions and batch-miner throughput. Use repeated runs and documented confidence/variance, not this single sample.
|
||||
- Establish credible aggregate honest hashrate, launch ramp/surge/drop ranges and attack/rewrite cost assumptions; independently review target choice and the inherited ten-block/clamped endpoint retarget, including gaps across windows and timestamp attacks.
|
||||
- Add stochastic mining/partition/clock/adaptive-hashpower models and sustained multi-host rehearsals with valid reference proofs, full state commitments, mempool/reorg persistence, networking and realistic resource pressure.
|
||||
- Freeze reviewed rules and actual genesis date with a new identity where required. Publish the manifest/artifact hashes and obtain independent review and explicit operator sign-off. Do not patch an active chain's rules in place.
|
||||
- Freeze the selected rules and actual genesis date with a new identity where required. Publish the manifest/artifact hashes and record internal findings plus explicit operator risk acceptance. Do not patch an active chain's rules in place.
|
||||
- Document reorganization and confirmation limitations honestly; no finite confirmation count is absolute finality on this unbootstrapped PoW chain.
|
||||
|
||||
See [security review](security-review.md), [launch evidence register](operator-launch.md) and the read-only preflight. The calibration blocker remains open.
|
||||
|
||||
+22
-17
@@ -1,50 +1,55 @@
|
||||
# Gated mainnet candidate
|
||||
# Mainnet-first, gated candidate
|
||||
|
||||
This repository has a **separate mainnet candidate profile**, not an audited or launched production network. Shipping code does not establish independent miners, economic security, or an operational public network. Market and payment features have been removed from the card-only product.
|
||||
The repository defaults to a **mainnet candidate**, not an audited or launched production network. Shipping code does not establish independent miners, economic security, or an operational public network. Market and payment features have been removed from the card-only product.
|
||||
|
||||
**Current production decision: NO-GO.** See the [2026-10-20 security review](security-review.md) and [operator evidence register and launch procedure](operator-launch.md). The read-only `launch:preflight` package script (or node entry-point `--launch-preflight`) reports local gates and unresolved blockers without starting a node or setting approvals. It deliberately exits nonzero while production readiness is blocked.
|
||||
|
||||
## What is implemented
|
||||
|
||||
| Property | Testnet default | Mainnet candidate |
|
||||
| Property | Mainnet-first default | Explicit development fixture |
|
||||
| --- | --- | --- |
|
||||
| Selection | `WIKIDECK_NETWORK=testnet` (or unset) | `WIKIDECK_NETWORK=mainnet` |
|
||||
| Network ID | `wikideck-randomx-testnet-6` | `wikideck-randomx-mainnet-1` |
|
||||
| Protocol | Existing version 6 | Version 6 under a distinct chain identity |
|
||||
| Genesis timestamp | Unchanged | 2027-01-01 00:00 UTC, **candidate only, not a promised launch** |
|
||||
| Ledger subdirectory | testnet | mainnet |
|
||||
| Browser wallet | Existing testnet store, unchanged | Separate mainnet store |
|
||||
| Selection | `WIKIDECK_NETWORK=mainnet` (or unset) | `WIKIDECK_NETWORK=testnet` |
|
||||
| Network ID | `wikideck-randomx-mainnet-1` | `wikideck-randomx-testnet-6` |
|
||||
| Protocol | Version 6 | Existing fixed local testnet version 6 |
|
||||
| Genesis timestamp | 2027-01-01 00:00 UTC by default; deployment may choose another timestamp before building/freezing | Fixed at 2026-10-19 00:00 UTC |
|
||||
| Ledger directory | mainnet | testnet |
|
||||
| Browser wallet | IndexedDB name derived from exact chain ID | Separate chain identity |
|
||||
| New backup | v2 header authenticates exact chain ID | Same v2 format; legacy v1 is accepted only on the original testnet |
|
||||
|
||||
Both profiles use real RandomX validation, cumulative-work branch selection, the 15-minute target, mint maturity, signed NFT transfers, atomic card trades, and SQLite persistence. The mainnet candidate has no premine, token migration, hidden checkpoint, or fake-PoW option. Changing the network identifier and candidate genesis derives distinct chain/genesis IDs, RandomX initial keys, signature domains, and consensus manifest checks. Existing testnet values and data remain unchanged.
|
||||
|
||||
**Testnet cards and backups do not migrate.** Browser storage is separate, backup network headers are authenticated and checked, peers reject mismatched manifests, and transactions are checked against the active chain. The selected profile is pinned at startup/browser build, never silently adopted from an RPC response.
|
||||
**Cards and backups do not migrate across chain identities.** Browser storage and backup headers are bound to the exact chain ID; peers reject mismatched manifests, and transactions are checked against the active chain. The selected profile and genesis are pinned at startup/browser build, never silently adopted from an RPC response. The original fixed-testnet v1 backups remain supported only on that testnet.
|
||||
|
||||
## Inspect without launching
|
||||
|
||||
The node entry point accepts `--network-manifest`. With `WIKIDECK_NETWORK=mainnet` set in the process environment, this prints the candidate network/chain/genesis/timestamp and exits **before** opening databases, starting a RandomX engine, listening, or contacting peers. It does not require activation credentials or approval and does not launch anything.
|
||||
|
||||
Browser builds select `VITE_WIKIDECK_NETWORK=mainnet`; server/miner processes select `WIKIDECK_NETWORK=mainnet`. These must match. Docker's `WIKIDECK_NETWORK` **build argument** sets the browser profile; a runtime variable cannot alter an already compiled UI. A mismatched UI rejects the node network instead of signing for it. The mainnet Compose definition supplies both the build and runtime selections.
|
||||
Browser builds select `VITE_WIKIDECK_NETWORK=mainnet`; server/miner processes select `WIKIDECK_NETWORK=mainnet`. These must match. `WIKIDECK_GENESIS_TIME` is also part of the chain identity. Docker's network/genesis **build arguments** pin the browser and immutable image manifest; a runtime variable cannot alter an already compiled UI. To choose another genesis, set it for the image build and runtime together, publish the resulting manifest, then use that immutable image and exact timestamp for every node and browser. A runtime-only genesis override fails closed. A mismatched UI rejects the node rather than signing for it.
|
||||
|
||||
## Explicit launch gates
|
||||
|
||||
[compose.mainnet.yaml](../compose.mainnet.yaml) is an opt-in Compose profile, uses a separate named volume, binds only loopback for an external HTTPS reverse proxy, and does **not** auto-restart a failed candidate. It has no default peers, approval, or public origin. It is not started by the default Compose definitions. **No mainnet nodes have been started by this change.**
|
||||
[compose.yaml](../compose.yaml) and [compose.mainnet.yaml](../compose.mainnet.yaml) are mainnet-first definitions, use separate named volumes, bind only loopback for an external HTTPS reverse proxy, and do **not** auto-restart a failed candidate. They have no default peers, approval, or public origin. The default service therefore exits until the operator supplies real deployment values. The testnet Docker fixture is separate and explicit. **This code change does not establish or certify a public mainnet.**
|
||||
|
||||
Starting the candidate node or native miner requires all of:
|
||||
|
||||
1. `WIKIDECK_MAINNET_LAUNCH` equals the exact candidate chain ID printed by the manifest command.
|
||||
2. `WIKIDECK_MAINNET_REVIEW_ACK=independent-review-complete` records the operator's review attestation. This is **not cryptographic proof of an audit**, and the manifest still honestly reports `audited: false`.
|
||||
3. The candidate genesis timestamp has arrived. If review changes the timestamp or consensus manifest before launch, the chain ID also changes and the old approval is invalidated. Freeze and publish the final manifest only after review.
|
||||
2. `WIKIDECK_MAINNET_RISK_ACK=operator-accepts-unaudited-risk` records the operator's explicit acceptance that no external audit is planned and residual risks remain. This is not a safety certification; the manifest honestly reports `audited: false`.
|
||||
3. The candidate genesis timestamp has arrived. If the operator changes the timestamp or consensus manifest before launch, the chain ID also changes and the old approval is invalidated. Freeze and publish the final manifest before launch.
|
||||
4. An HTTPS `PUBLIC_ORIGIN`, at least two distinct configured HTTPS peer origins, and no private-peer bypass. Configuration count cannot prove the peers are online, independent, or sufficiently distributed; verify that operationally. Existing peer DNS/origin controls still apply.
|
||||
Unknown profile names fail closed. A direct import of the HTTP entry point is also guarded. Library-level manifest/consensus/storage inspection remains possible for tests and review; activation gates are accident prevention, not a security boundary against someone who edits the source.
|
||||
|
||||
For Kubernetes, the current peer implementation requires HTTPS origins that resolve to public addresses; it rejects private cluster DNS/IPs for mainnet. The node serves HTTP internally and needs a separately configured TLS ingress/reverse proxy. Every node needs a stable, unique public HTTPS origin and at least two distinct peer origins. A default ClusterIP/headless Service alone cannot satisfy these rules, and a load-balanced ingress shared by several pods is not a stable per-node peer identity. Do not set the private-peer bypass to work around this; use public per-node HTTPS endpoints or first review a separate authenticated private-peering design that preserves the public independence gate. No cluster-specific manifests are included because ingress class, TLS/certificate management, storage class/size, resource capacity, and stable peer DNS were not provided.
|
||||
|
||||
See the [Kubernetes mainnet preparation checklist](../deploy/kubernetes/README.md) for the deployment contract and the values needed before manifests can be finalized.
|
||||
Unknown profile names fail closed. A direct import of the HTTP entry point is also guarded. Library-level manifest/consensus/storage inspection remains possible for tests and review; activation gates are accident prevention and operator risk acceptance, not a security boundary against someone who edits the source.
|
||||
|
||||
## Required before an actual production launch
|
||||
|
||||
- **Independent review:** protocol/cryptography, signatures and wallet lifecycle, RandomX integration, difficulty/time/retarget attacks, state commitments, fork choice, network message validation and denial-of-service limits. Publish findings, fixes and a versioned final manifest.
|
||||
- **Internal readiness review:** track code findings and regression evidence in-repository. No external audit is planned; the operator accepts the additional risk and must not describe the candidate as audited or certified.
|
||||
- **Security model:** quantify hashpower, chain-rewrite cost and majority attacks. The inherited easy initial target and ten-block retarget are **not production-calibrated**. Choose reviewed launch difficulty/retarget behavior and test start-up/stall/recovery scenarios. Independent mining participation and sufficient hashpower are not provided by running multiple containers on one host.
|
||||
- **Adversarial rehearsal:** sustained multi-host simulations, partitions/eclipses, competing branches, deep reorganizations, clock skew, bad peers, load/mempool saturation, corrupted state and restart recovery. Test the candidate rules with no valuable assets.
|
||||
- **Operations:** independently administered public HTTPS nodes and miners across failure domains, pinned release artifacts and dependencies, firewall/reverse proxy/rate-limit policies, bounded storage and sync strategy, metrics, alerts, rotation and emergency-response procedures.
|
||||
- **Recovery:** verified offline wallet recovery, consistent ledger snapshots, full replay/revalidation, documented incident and fork handling. No silent ledger reset or reuse of testnet data.
|
||||
- **Launch governance:** select the actual genesis date, freeze the reviewed identity/configuration, publish binaries and configuration hashes, agree peer/miner readiness and upgrade policy, and explicitly authorize launch. No operator should attest review merely to bypass the gates.
|
||||
- **Launch governance:** select the actual genesis date, freeze the identity/configuration, publish binaries and configuration hashes, agree peer/miner readiness and upgrade policy, and explicitly record the operator's risk acceptance. Do not treat the flag as proof of safety or peer readiness.
|
||||
- **User and legal review:** no monetary value or security is promised. NFT rights do not imply article/image copyright ownership. Publish experimental-use, privacy, support, and abuse-report terms appropriate to the release.
|
||||
|
||||
## Still not production-ready
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
The [card-only production readiness plan](card-only-production-plan.md) defines the product scope and release gates. The supported product is mining, collecting, and direct card-for-card trades. Market, custody, and payment source has been removed. **Current decision: HOLD / NO-GO for mainnet, valuable assets, or public exposure until the relevant evidence below is reviewed.**
|
||||
|
||||
A successful build, local container startup, peer URL count, genesis timestamp, or configured approval flag is not production evidence. The read-only launch preflight reports local blockers; it is not an independent audit or approval.
|
||||
A successful build, local container startup, peer URL count, genesis timestamp, or configured approval flag is not proof of network security. The read-only launch preflight reports local blockers; it does not certify the release. The operator has chosen not to commission external audits and accepts the corresponding residual risk.
|
||||
|
||||
## Safe inspection
|
||||
|
||||
Use Node.js 24 and a clean, reviewed checkout. For mainnet assessment, select `WIKIDECK_NETWORK=mainnet` and run `launch:preflight`. Use `--network-manifest` for chain identity inspection. These commands do not start a node, create a ledger, start RandomX, or contact peers.
|
||||
|
||||
Mainnet startup is gated by a chain-specific approval, a review acknowledgement, the candidate genesis time, HTTPS origin, and multiple configured HTTPS peers. These are accident-prevention checks only; they do not prove that review occurred, peers are online/independent, or a network is secure. Keep deployment and release permissions restricted.
|
||||
Mainnet startup is gated by a chain-specific approval, explicit unaudited-risk acceptance, the candidate genesis time, HTTPS origin, and multiple configured HTTPS peers. These are accident-prevention checks only; they do not prove peers are online/independent or that a network is secure. Keep deployment and release permissions restricted.
|
||||
|
||||
## Evidence required before a public non-monetary testnet beta
|
||||
|
||||
@@ -26,7 +26,7 @@ If these conditions cannot be verified on a self-hosted site, do not expose it p
|
||||
|
||||
## Mainnet and valuable-card blockers
|
||||
|
||||
Mainnet or valuable-card use requires the beta evidence plus independent protocol/application review, measured and reviewed consensus parameters, adversarial multi-host partition/reorg/hashpower testing, a credible bootstrap and independent-peer/miner plan, production capacity evidence, and explicit governance/upgrade and incident procedures. The current initial target is deliberately easy and the retarget is not production-calibrated. Nodes retain the DAG in memory and replay proofs at startup. There is no formal finality or automatic peer discovery. Keep mainnet **NO-GO** until these gaps are independently reviewed and signed off.
|
||||
Mainnet or valuable-card use requires the beta evidence plus internally reviewed and measured consensus parameters, adversarial multi-host partition/reorg/hashpower testing, a credible bootstrap and independent-peer/miner plan, production capacity evidence, and explicit governance/upgrade and incident procedures. External audit is not planned; the operator must record acceptance of this extra risk. The current initial target is deliberately easy and the retarget is not production-calibrated. Nodes retain the DAG in memory and replay proofs at startup. There is no formal finality or automatic peer discovery. Keep mainnet **NO-GO** until the remaining network and operational evidence is recorded and the operator explicitly accepts the residual risks.
|
||||
|
||||
## Hold-point register
|
||||
|
||||
@@ -34,7 +34,7 @@ Record owner, exact release/manifest, evidence location and hash, test/rehearsal
|
||||
|
||||
| Hold point | Required evidence | Current status |
|
||||
| --- | --- | --- |
|
||||
| Independent protocol/application review | Review against the exact release; critical/high findings fixed and retested | Missing |
|
||||
| Internal protocol/application review | Findings documented against the release; fixes have regression coverage; operator records unaudited-risk acceptance | Internal review and regression tests recorded; no external audit planned |
|
||||
| Consensus launch design | Final manifest; measured starting target/hashpower; timestamp, retarget, partition, and reorg analysis | Missing |
|
||||
| Node availability | Realistic long-chain and hostile-traffic capacity report with explicit limits | Missing |
|
||||
| Production wallet | Same-origin threat model, user confirmation, supported-browser lifecycle and offline recovery drill | Unverified |
|
||||
@@ -47,13 +47,13 @@ Record owner, exact release/manifest, evidence location and hash, test/rehearsal
|
||||
|
||||
## Freeze and authorize
|
||||
|
||||
1. Fix critical findings in a separately reviewed release; do not change consensus rules under a launched chain ID.
|
||||
1. Fix known critical code findings in a new release and add regression coverage; do not change consensus rules under a launched chain ID. This is internal engineering, not external audit.
|
||||
2. Freeze the reviewed genesis/manifest, exact build/runtime profile, image digest, peer origins, and artifact hashes.
|
||||
3. Rehearse initial sync, independent mining, partitions/reconnects, invalid traffic, disk pressure, restart, and isolated full restore. Do not delete production data or weaken rules to pass a drill.
|
||||
4. Verify DNS/TLS, proxy forwarded-IP handling, no direct backend access, permissions, resource capacity, and monitoring on intended hosts. `/healthz` is liveness only.
|
||||
5. Record a go/no-go with operator/reviewer names, evidence, known residual risks, change window, recovery objectives, and stop conditions in the controlled operations system.
|
||||
5. Record a go/no-go with operator name, evidence, known residual risks, change window, recovery objectives, and stop conditions in the controlled operations system.
|
||||
|
||||
Do not set review acknowledgements merely to bypass gates. This internal code work is not the independent review required for launch.
|
||||
Do not set the risk acknowledgement merely to bypass gates. It records acceptance of known risks; it does not attest that an audit occurred or make the system secure.
|
||||
|
||||
## Stop conditions
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Decision: NO-GO for public production or valuable assets
|
||||
|
||||
This is an internal source-assisted readiness review, not an independent security audit, penetration test, formal consensus proof, or production certification. The card-only product consists of RandomX mining, wallets, collections, transfers, and direct atomic card trades. Market, custody, and payment components have been removed from the supported source tree; no real-money feature is part of this release.
|
||||
This is an internal source-assisted readiness review, not a penetration test, formal consensus proof, or production certification. The operator has chosen not to commission external audits and accepts that residual risk. The card-only product consists of RandomX mining, wallets, collections, transfers, and direct atomic card trades. Market, custody, and payment components have been removed from the supported source tree; no real-money feature is part of this release.
|
||||
|
||||
A limited, non-monetary testnet beta may be considered only after the release-specific operational, wallet, capacity, recovery, and review gates in the [card-only production plan](card-only-production-plan.md) are met. Mainnet or valuable-card use remains **NO-GO**.
|
||||
|
||||
@@ -10,14 +10,14 @@ A limited, non-monetary testnet beta may be considered only after the release-sp
|
||||
|
||||
| Finding | Risk | Required disposition |
|
||||
| --- | --- | --- |
|
||||
| Consensus/economic security | Current initial proof-of-work target is deliberately easy and the ten-block retarget is not production-calibrated. Hashpower concentration, timestamp/retarget manipulation, eclipse, censorship, and reorganization costs have not received independent review. RandomX use does not inherit Monero's security. | Independent consensus/economic review; representative hashpower measurements; adversarial multi-host clock, partition, stall, fork, and reorg simulations; freeze reviewed launch parameters and chain identity. |
|
||||
| Consensus/economic security | Current initial proof-of-work target is deliberately easy and the ten-block retarget is not production-calibrated. Hashpower concentration, timestamp/retarget manipulation, eclipse, censorship, and reorganization costs have not been independently measured. RandomX use does not inherit Monero's security. | Measure representative hashpower; run multi-host clock, partition, stall, fork, and reorg simulations; document operator-accepted risk and freeze parameters/identity before launch. |
|
||||
| Node availability and scale | The DAG remains in memory, state-root work visits the NFT set, and startup revalidates stored proofs. Work, RAM, disk, and recovery time grow with history. | Repeated long-chain and hostile-traffic tests on intended hardware; define and validate latency, resource, storage, and recovery limits; establish an archival/capacity strategy. |
|
||||
| Wallet/key protection | Encrypted local storage helps while locked, but a compromised same-origin page, extension, browser, or OS can abuse an unlocked signer. Old plaintext copies and cross-browser recovery require careful handling. | Independent wallet review; supported-browser migration/lock/sign/backup/restore drills; document residual compromise and key-loss risks. Decide whether isolated/hardware signing is required. |
|
||||
| Deployment and operations | The real TLS/proxy/firewall topology, peer/miner independence, off-host recovery, alert delivery, and on-call response have not been verified. A liveness response is not readiness. | Verify intended hosts and external edge; test alert routes and incident procedures; encrypt off-host backups and complete an isolated restore/replay with measured recovery objectives. |
|
||||
| Release supply chain | A successful build or dependency advisory check does not establish artifact integrity or native/container safety. | Freeze base/dependency/native versions and image digest; produce an SBOM, scans, build provenance/signature, and restricted release process for the exact artifact. |
|
||||
| Governance and user terms | No production operator/reviewer sign-off, public support/abuse path, or published project software license is established. | Name accountable decision makers; publish license, support, privacy, experimental/no-value disclosures, abuse reporting, upgrade policy, and stop conditions. |
|
||||
|
||||
These are readiness gaps, not claims that each item is an exploited vulnerability. Local tests and rehearsals are useful but do not close independent-review or intended-host evidence requirements.
|
||||
These are readiness gaps, not claims that each item is an exploited vulnerability. Local tests and rehearsals are useful, but they do not establish security or verify the intended production host.
|
||||
|
||||
## Implemented groundwork (not certification)
|
||||
|
||||
@@ -28,4 +28,4 @@ These are readiness gaps, not claims that each item is an exploited vulnerabilit
|
||||
|
||||
## Review evidence and limits
|
||||
|
||||
The exact release candidate must be rebuilt and fully retested after scope cleanup. No claims are made here about the user's production host, public peer topology, Internet-facing penetration tests, independent native-code review, alert delivery, off-host recovery, or mainnet security. Do not expose a public node or use valuable assets based solely on a successful test suite or container startup.
|
||||
The exact release candidate must be rebuilt and fully retested after scope cleanup. No claims are made here about the user's production host, public peer topology, Internet-facing testing, native-code assurance, alert delivery, off-host recovery, or mainnet security. External audits are not planned; retain the NO-GO for mainnet/valuable assets until the remaining network, deployment, and recovery evidence is complete and the operator records informed risk acceptance. A successful test suite or container startup alone is insufficient.
|
||||
|
||||
@@ -91,6 +91,6 @@ Sync polls every **5 seconds**, checks network/chain ID, imports at most **32**
|
||||
| `GET /p2p/status`, `/p2p/blocks?offset=0`, `/p2p/mempool` | Manifest/size/tip, 32-block inventory pages, pending transactions. |
|
||||
| `POST /p2p/block`, `/p2p/transaction` | Same validation as local block/transaction submission; transaction endpoint accepts a transfer or atomic trade. |
|
||||
|
||||
POSTs require JSON, enforce supplied browser origins and signatures where applicable; peers without an Origin header are not authenticated by this check. HTTP parsing is capped at 68 KiB; API/P2P rate limits are 300/minute and block/transaction/trade submissions additionally 30/minute. There are no consensus fees, peer authentication, or economic spam deterrents. Same-origin browser RPC trusts the node; it does not independently verify these commitments. Deploy no funds: majority-hashpower, eclipse, censorship, reorg and resource-exhaustion risks require independent audits and adversarial testing before any broader launch.
|
||||
POSTs require JSON, enforce supplied browser origins and signatures where applicable; peers without an Origin header are not authenticated by this check. HTTP parsing is capped at 68 KiB; API/P2P rate limits are 300/minute and block/transaction/trade submissions additionally 30/minute. There are no consensus fees, peer authentication, or economic spam deterrents. Same-origin browser RPC trusts the node; it does not independently verify these commitments. Deploy no valuable assets: majority-hashpower, eclipse, censorship, reorg and resource-exhaustion risks remain and are not externally audited under the operator's current policy.
|
||||
|
||||
Testnet storage and chain identities above remain unchanged. The [gated mainnet candidate](mainnet-launch.md) selects separate chain/signature domains, storage, lock and wallet identities; it is not a running or certified production network.
|
||||
|
||||
@@ -42,4 +42,4 @@ Node/WebCrypto storage-boundary tests cover encrypted record-only persistence, e
|
||||
|
||||
Real browser smoke checks on a disposable loopback origin passed create → lock → unlock and reload → locked. IndexedDB inspection confirmed the vault wrapper/ciphertext and absence of a top-level private key/password without retrieving private material. Tests use ephemeral test credentials only. Final Node 24 TypeScript/Vite build passed; the complete suite passed **86 tests, 0 failed, 0 skipped**, including real RandomX HTTP integration and expanded wallet storage/lifecycle assertions.
|
||||
|
||||
**Production wallet review remains open.** At-rest encryption reduces profile-theft exposure when locked but cannot protect an unlocked page or password entry from XSS, a malicious release, privileged extensions or a compromised browser/OS. The in-memory key intentionally remains extractable to support existing backup export. This is not hardware-isolated signing, a browser-extension wallet, or an independent audit. Multi-browser/background/cross-tab races and destructive crash/restore drills need broader coverage; no independent assurance or formal crypto verification is claimed. Do not use valuable assets merely because local encryption exists.
|
||||
**Wallet risks remain accepted but unresolved.** At-rest encryption reduces profile-theft exposure when locked but cannot protect an unlocked page or password entry from XSS, a malicious release, privileged extensions or a compromised browser/OS. The in-memory key intentionally remains extractable to support existing backup export. This is not hardware-isolated signing or a browser-extension wallet. External wallet audits are not planned; multi-browser/background/cross-tab races and destructive crash/restore drills remain internal engineering work. Do not use valuable assets merely because local encryption exists.
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1e1e2e" />
|
||||
<meta name="description" content="Mine Wikipedia NFT cards with RandomX. Build collections, trade cards, and explore peer-hosted cryptocurrency markets. TCGs planned; current checkout is simulated." />
|
||||
<title>Wikideck — Mine knowledge.</title>
|
||||
<meta name="description" content="Experimental, gated Wikideck mainnet candidate: mine, collect, and trade illustrated Wikipedia cards with RandomX. No public bootstrap network or promised monetary value." />
|
||||
<title>Wikideck — Experimental mainnet candidate.</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/original-logo.svg" />
|
||||
<script src="/theme-init.js"></script>
|
||||
</head>
|
||||
|
||||
+3
-2
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -n api,web \"tsx watch server/index.ts\" \"vite --host 127.0.0.1\"",
|
||||
"dev": "node scripts/dev-testnet.mjs",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"start": "tsx server/index.ts",
|
||||
"launch:preflight": "node --experimental-strip-types server/index.ts --launch-preflight",
|
||||
@@ -15,10 +15,11 @@
|
||||
"node:backup": "node --experimental-strip-types scripts/node-backup.ts",
|
||||
"ops:check": "node --experimental-strip-types scripts/ops-check.ts",
|
||||
"miner": "tsx server/testnet/miner.ts",
|
||||
"miner:testnet": "node scripts/miner-testnet.mjs",
|
||||
"native:configure": "cmake -S native -B native/build -DCMAKE_BUILD_TYPE=Release -DWIKIDECK_RANDOMX_TESTS=ON",
|
||||
"native:build": "cmake --build native/build --config Release --parallel 2",
|
||||
"test:native": "ctest --test-dir native/build -C Release --output-on-failure",
|
||||
"test": "tsx --test tests/*.test.ts"
|
||||
"test": "node scripts/run-tests.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const env = { ...process.env, WIKIDECK_NETWORK: 'testnet', WIKIDECK_GENESIS_TIME: '1792411200',
|
||||
VITE_WIKIDECK_NETWORK: 'testnet', VITE_WIKIDECK_GENESIS_TIME: '1792411200' };
|
||||
const children = [
|
||||
spawn(process.execPath, ['--import', 'tsx', 'server/index.ts'], { env, stdio: 'inherit', shell: false }),
|
||||
spawn(process.execPath, ['node_modules/vite/bin/vite.js', '--host', '127.0.0.1'], { env, stdio: 'inherit', shell: false }),
|
||||
];
|
||||
let stopping = false;
|
||||
const stop = code => {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
for (const child of children) if (child.exitCode === null) child.kill('SIGTERM');
|
||||
process.exitCode = code;
|
||||
};
|
||||
for (const child of children) {
|
||||
child.once('error', () => stop(1));
|
||||
child.once('exit', code => stop(code ?? 1));
|
||||
}
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => stop(0));
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const env = { ...process.env, WIKIDECK_NETWORK: 'testnet', WIKIDECK_GENESIS_TIME: '1792411200' };
|
||||
const child = spawn(process.execPath, ['--import', 'tsx', 'server/testnet/miner.ts', ...process.argv.slice(2)], {
|
||||
env, stdio: 'inherit', shell: false,
|
||||
});
|
||||
child.once('error', () => { process.exitCode = 1; });
|
||||
child.once('exit', (code, signal) => {
|
||||
process.exitCode = code ?? (signal ? 1 : 0);
|
||||
});
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const testDirectory = fileURLToPath(new URL('../tests/', import.meta.url));
|
||||
const files = readdirSync(testDirectory)
|
||||
.filter(name => name.endsWith('.test.ts'))
|
||||
.sort()
|
||||
.map(name => join(testDirectory, name));
|
||||
if (!files.length) throw new Error('No test files found.');
|
||||
|
||||
const result = spawnSync(process.execPath, ['--import', 'tsx', '--test', ...files], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, WIKIDECK_NETWORK: 'testnet', WIKIDECK_GENESIS_TIME: '1792411200' },
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
process.exitCode = result.status ?? 1;
|
||||
+3
-3
@@ -5,11 +5,11 @@ export function launchManifest() {
|
||||
return { profile: PROFILE, network: NETWORK, chainId: CHAIN_ID, genesis: GENESIS_HASH,
|
||||
genesisTime: GENESIS_TIME, launchStatus: IS_MAINNET ? 'candidate-gated' : 'testnet', audited: false };
|
||||
}
|
||||
/** Operator attestation is an accident-prevention gate, NOT proof of a security audit. */
|
||||
/** Operator risk acceptance is an accident-prevention gate, NOT evidence of external review. */
|
||||
export function assertLaunchAllowed(env: NodeJS.ProcessEnv = process.env, now = Date.now()) {
|
||||
if (!IS_MAINNET) return;
|
||||
if (env.WIKIDECK_MAINNET_LAUNCH !== CHAIN_ID || env.WIKIDECK_MAINNET_REVIEW_ACK !== 'independent-review-complete') {
|
||||
throw new Error(`Mainnet candidate is launch-gated. Review the launch checklist; explicit chain approval required: ${CHAIN_ID}`);
|
||||
if (env.WIKIDECK_MAINNET_LAUNCH !== CHAIN_ID || env.WIKIDECK_MAINNET_RISK_ACK !== 'operator-accepts-unaudited-risk') {
|
||||
throw new Error(`Mainnet candidate is launch-gated. Explicit chain approval and operator risk acceptance required: ${CHAIN_ID}`);
|
||||
}
|
||||
if (now < GENESIS_TIME * 1000) throw new Error('Mainnet candidate genesis time has not arrived; this is not an announced launch date');
|
||||
const origin = new URL(env.PUBLIC_ORIGIN ?? 'http://invalid');
|
||||
|
||||
+3
-3
@@ -17,9 +17,9 @@ export function launchPreflight(env: NodeJS.ProcessEnv = process.env, now = Date
|
||||
}
|
||||
checks.push(
|
||||
{ id: 'public-node-resource-controls', status: 'blocked', detail: 'Global RPC budgets, bounded pages, and browser history controls exist. In-memory DAG growth and production-scale load/capacity evidence remain unresolved.' },
|
||||
{ id: 'consensus-launch-calibration', status: 'blocked', detail: 'Bounded offline native benchmarks and deterministic retarget/timestamp analysis exist. Representative network hashrate, stochastic/multi-host adversarial evidence, reviewed launch rules/genesis, and independent economic-security review remain missing.' },
|
||||
{ id: 'wallet-key-protection', status: 'blocked', detail: 'Encrypted vault storage, explicit migration/unlock, and lock-aware signing exist. Legacy plaintext copies, unlocked-page compromise, migration/lifecycle coverage, and independent production wallet/recovery review remain unresolved.' },
|
||||
{ id: 'release-security-review', status: 'unverified', detail: 'Independent consensus/application review and release-specific dependency/native/container scans require external evidence; an operator environment flag is not evidence.' },
|
||||
{ id: 'consensus-launch-calibration', status: 'blocked', detail: 'Bounded offline native benchmarks and deterministic retarget/timestamp analysis exist. Representative network hashrate, multi-host adversarial evidence, and the operator-frozen launch rules/genesis remain missing; external audit is not planned under the current policy.' },
|
||||
{ id: 'wallet-key-protection', status: 'blocked', detail: 'Encrypted vault storage, explicit migration/unlock, and lock-aware signing exist. Legacy plaintext copies, unlocked-page compromise, supported-browser lifecycle coverage, and wallet recovery drills remain unresolved; the operator accepts that no external wallet audit is planned.' },
|
||||
{ id: 'release-risk-acceptance', status: 'unverified', detail: 'External audit is not planned by operator choice. Record explicit acceptance of unaudited protocol/wallet risk and complete release-specific dependency/native/container scans.' },
|
||||
{ id: 'deployment-rehearsal', status: 'unverified', detail: 'Explicit proxy trust, private diagnostics/checker, and stopped-node ledger backup/restore with local native replay tests exist. Actual HTTPS/proxy topology, independent hosts/miners, alert delivery, off-host recovery, and on-call procedures remain unverified.' },
|
||||
);
|
||||
return { ...launchManifest(), assessedAt: new Date(now).toISOString(), productionReady: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash, createPublicKey, verify } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import { NETWORK, CONSENSUS, headerMessage, blockClaimMessage, nftTransferMessage, nftTradeMessage, statsSnapshotTuple,
|
||||
import { NETWORK, CONSENSUS, MAX_TARGET, headerMessage, blockClaimMessage, nftTransferMessage, nftTradeMessage, statsSnapshotTuple,
|
||||
type BlockHeader, type TestnetBlock, type NftTransfer, type NftState, type NftStateLayer,
|
||||
isNftTrade, type NftStateView, type NftTrade, type TestnetTransaction, type MiningTemplate } from '../../shared/testnet.ts';
|
||||
import type { Article } from '../../shared/protocol.ts';
|
||||
@@ -9,7 +9,7 @@ import type { PowEngine } from './randomx.ts';
|
||||
import { NETWORK_GENESIS_TIME } from '../../shared/network.ts';
|
||||
|
||||
export const sha256 = (data: string | Uint8Array) => createHash('sha256').update(data).digest('hex');
|
||||
export const MAX_TARGET = `0fff${'f'.repeat(60)}`;
|
||||
export { MAX_TARGET };
|
||||
// Consensus manifest: an independently running node derives exactly the same genesis.
|
||||
export const GENESIS_TIME = NETWORK_GENESIS_TIME;
|
||||
export const CHAIN_ID = sha256(JSON.stringify([NETWORK, CONSENSUS, GENESIS_TIME, MAX_TARGET]));
|
||||
@@ -261,19 +261,24 @@ export function makeTemplate(ancestors: TestnetBlock[], state: NftStateView, own
|
||||
return { header, transactions, blob: miningBlob(header).toString('hex'), nonceOffset: 32 };
|
||||
}
|
||||
export async function validateBlock(raw: unknown, ancestors: TestnetBlock[], parentState: NftStateView,
|
||||
pow: PowEngine, now = Math.floor(Date.now()/1000)): Promise<{block:TestnetBlock;state:NftStateView}> {
|
||||
pow: PowEngine, now = Math.floor(Date.now()/1000), allowHistoricalFutureTimestamp = false): Promise<{block:TestnetBlock;state:NftStateView}> {
|
||||
if (Buffer.byteLength(JSON.stringify(raw)) > CONSENSUS.maxBlockBytes) throw new Error('Block size limit exceeded');
|
||||
const b = blockSchema.parse(raw);
|
||||
if (b.height !== ancestors.length + 1 || b.previousHash !== (ancestors.at(-1)?.hash ?? GENESIS_HASH)) throw new Error('Invalid parent/height');
|
||||
if (b.createdAt <= medianTime(ancestors) || b.createdAt > now + CONSENSUS.maxFutureSeconds) throw new Error('Invalid block timestamp');
|
||||
if (b.createdAt <= medianTime(ancestors) || (!allowHistoricalFutureTimestamp && b.createdAt > now + CONSENSUS.maxFutureSeconds)) {
|
||||
throw new Error('Invalid block timestamp');
|
||||
}
|
||||
if (b.target !== expectedTarget(ancestors) || b.key !== expectedKey(ancestors, b.height)) throw new Error('Invalid difficulty or RandomX epoch key');
|
||||
if (b.transactionsRoot !== merkleRoot(b.transactions.map(transactionId))) throw new Error('Invalid transaction root');
|
||||
const next = transition(parentState, b, b.transactions);
|
||||
if (b.stateRoot !== stateRoot(next) || b.tokenId !== mintId(b)) throw new Error('Invalid NFT state commitment');
|
||||
if (b.tokenId !== mintId(b)) throw new Error('Invalid NFT state commitment');
|
||||
const blob = miningBlob(b);
|
||||
if (b.hash !== sha256(blob)) throw new Error('Invalid block ID');
|
||||
if (!verifySignature(b.publicKey, b.owner, blockClaimMessage(b.hash), b.signature)) throw new Error('Invalid miner signature');
|
||||
if (BigInt(`0x${b.powHash}`) > BigInt(`0x${b.target}`)) throw new Error('Insufficient work');
|
||||
if (await pow.hash(b.key, blob) !== b.powHash) throw new Error('Invalid RandomX proof');
|
||||
// Verify native work before materializing/sorting the complete NFT state. A malformed
|
||||
// low-work block must not force O(total NFTs) commitment work on the request path.
|
||||
const next = transition(parentState, b, b.transactions);
|
||||
if (b.stateRoot !== stateRoot(next)) throw new Error('Invalid NFT state commitment');
|
||||
return { block: b, state: next };
|
||||
}
|
||||
|
||||
@@ -44,12 +44,13 @@ export class Ledger {
|
||||
private closed = false;
|
||||
private revision = 0;
|
||||
private reorgs = 0;
|
||||
private readonly nowSeconds: () => number;
|
||||
private canonicalCache: { tip: string; blocks: TestnetBlock[] } | undefined;
|
||||
private constructor(file: string, db: DatabaseSync, pow: PowEngine) {
|
||||
this.file = file; this.db = db; this.pow = pow; this.index = new CanonicalIndex(db);
|
||||
private constructor(file: string, db: DatabaseSync, pow: PowEngine, nowSeconds: () => number) {
|
||||
this.file = file; this.db = db; this.pow = pow; this.nowSeconds = nowSeconds; this.index = new CanonicalIndex(db);
|
||||
}
|
||||
|
||||
static async open(dir: string, pow: PowEngine): Promise<Ledger> {
|
||||
static async open(dir: string, pow: PowEngine, nowSeconds: () => number = () => Math.floor(Date.now() / 1000)): Promise<Ledger> {
|
||||
const directory = join(dir, PROFILE);
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const file = join(directory, 'ledger.sqlite');
|
||||
@@ -120,17 +121,36 @@ export class Ledger {
|
||||
for (const block of oldDisk.blocks) insertBlock.run(block.hash, block.previousHash, JSON.stringify(block));
|
||||
const insertPending = db.prepare('INSERT INTO pending_transfers(position, id, transaction_json) VALUES(?, ?, ?)');
|
||||
oldDisk.pendingTransfers.forEach((tx, index) => insertPending.run(index, transactionId(tx), JSON.stringify(tx)));
|
||||
insertMeta.run('legacy_json_imported_at', String(nowSeconds()));
|
||||
insertMeta.run('legacy_json_block_count', String(oldDisk.blocks.length));
|
||||
}
|
||||
insertMeta.run('legacy_json_migrated', '1');
|
||||
db.exec('COMMIT');
|
||||
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
||||
}
|
||||
|
||||
const ledger = new Ledger(file, db, pow);
|
||||
for (const row of db.prepare('SELECT block_json FROM blocks ORDER BY sequence').iterate() as Iterable<{ block_json: string }>) {
|
||||
const ledger = new Ledger(file, db, pow, nowSeconds);
|
||||
const importedAtText = (db.prepare('SELECT value FROM metadata WHERE key=?').get('legacy_json_imported_at') as { value: string } | undefined)?.value;
|
||||
const importedCountText = (db.prepare('SELECT value FROM metadata WHERE key=?').get('legacy_json_block_count') as { value: string } | undefined)?.value;
|
||||
const legacyImportedAt = importedAtText !== undefined && /^(0|[1-9]\d{0,10})$/.test(importedAtText)
|
||||
? Number(importedAtText) : undefined;
|
||||
const legacyImportedCount = importedCountText !== undefined && /^(0|[1-9]\d{0,9})$/.test(importedCountText)
|
||||
? Number(importedCountText) : undefined;
|
||||
if ((importedAtText !== undefined && (legacyImportedAt === undefined || !Number.isSafeInteger(legacyImportedAt))) ||
|
||||
(importedCountText !== undefined && (legacyImportedCount === undefined || !Number.isSafeInteger(legacyImportedCount))) ||
|
||||
(legacyImportedAt === undefined) !== (legacyImportedCount === undefined)) {
|
||||
throw new Error('Legacy ledger import timestamp metadata is invalid');
|
||||
}
|
||||
for (const row of db.prepare('SELECT sequence, block_json FROM blocks ORDER BY sequence').iterate() as Iterable<{ sequence: number; block_json: string }>) {
|
||||
const raw = blockSchema.parse(JSON.parse(row.block_json));
|
||||
if (ledger.nodes.has(raw.hash)) throw new Error('Duplicate persisted block');
|
||||
const node = await ledger.validate(raw);
|
||||
const importedLegacyBlock = legacyImportedCount !== undefined && row.sequence <= legacyImportedCount;
|
||||
// Historical SQLite blocks already passed admission; wall-clock rollback must not
|
||||
// strand them. Imported JSON blocks retain their original admission clock so an
|
||||
// untrusted legacy file cannot inject arbitrarily future-dated history.
|
||||
const node = importedLegacyBlock
|
||||
? await ledger.validate(raw, false, legacyImportedAt!)
|
||||
: await ledger.validate(raw, true);
|
||||
ledger.nodes.set(node.block.hash, node);
|
||||
if (node.work > ledger.tipWork()) ledger.tip = node.block.hash;
|
||||
}
|
||||
@@ -297,10 +317,11 @@ export class Ledger {
|
||||
if (this.canonicalCache?.tip !== this.tip) this.canonicalCache = { tip: this.tip, blocks: this.branch(this.tip) };
|
||||
return this.canonicalCache.blocks; // Internal immutable block references only; public APIs clone their slice.
|
||||
}
|
||||
private async validate(raw: TestnetBlock): Promise<Node> {
|
||||
private async validate(raw: TestnetBlock, historicalReplay = false, now = this.nowSeconds()): Promise<Node> {
|
||||
const ancestors = this.branch(raw.previousHash);
|
||||
const parent = this.nodes.get(raw.previousHash);
|
||||
const { block, state } = await validateBlock(raw, ancestors, parent?.state ?? {}, this.pow);
|
||||
const { block, state } = await validateBlock(raw, ancestors, parent?.state ?? {}, this.pow,
|
||||
now, historicalReplay);
|
||||
return { block, state, work: (parent?.work ?? 0n) + workFor(block.target) };
|
||||
}
|
||||
private filterPending(parent: NftStateView, chain: TestnetBlock[], candidates: TestnetTransaction[]):
|
||||
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
import { CONSENSUS, MAX_TARGET, NETWORK } from './testnet.ts';
|
||||
import { NETWORK_GENESIS_TIME } from './network.ts';
|
||||
|
||||
export type ChainIdentity = { chainId: string; genesis: string };
|
||||
let identity: Promise<ChainIdentity> | undefined;
|
||||
|
||||
/** Derive the same public manifest as the node from this immutable browser build. */
|
||||
export function expectedChainIdentity(): Promise<ChainIdentity> {
|
||||
if (!identity) identity = (async () => {
|
||||
const digestHex = async (value: string) => Array.from(new Uint8Array(await crypto.subtle.digest(
|
||||
'SHA-256', new TextEncoder().encode(value))), byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
const chainId = await digestHex(JSON.stringify([NETWORK, CONSENSUS, NETWORK_GENESIS_TIME, MAX_TARGET]));
|
||||
return { chainId, genesis: await digestHex(`${NETWORK}:genesis:${chainId}`) };
|
||||
})();
|
||||
return identity;
|
||||
}
|
||||
+30
-5
@@ -1,16 +1,41 @@
|
||||
/** Selection is fixed at server startup / browser build, never learned from an untrusted RPC. */
|
||||
export type NetworkProfile = 'testnet' | 'mainnet';
|
||||
export function networkProfile(value: string | undefined): NetworkProfile {
|
||||
const profile = value ?? 'testnet';
|
||||
const profile = value ?? 'mainnet';
|
||||
if (profile !== 'testnet' && profile !== 'mainnet') {
|
||||
throw new Error('WIKIDECK_NETWORK must be testnet or mainnet');
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
export const PROFILE = networkProfile(typeof process !== 'undefined' && process.versions?.node
|
||||
const isNodeRuntime = typeof process !== 'undefined' && !!process.versions?.node;
|
||||
export const PROFILE = networkProfile(isNodeRuntime
|
||||
? process.env.WIKIDECK_NETWORK : import.meta.env?.VITE_WIKIDECK_NETWORK);
|
||||
export const IS_MAINNET = PROFILE === 'mainnet';
|
||||
export const NETWORK = IS_MAINNET ? 'wikideck-randomx-mainnet-1' as const : 'wikideck-randomx-testnet-6' as const;
|
||||
// Candidate mainnet genesis: 2027-01-01 00:00 UTC. NOT a commitment to a launch date.
|
||||
export const NETWORK_GENESIS_TIME = IS_MAINNET ? 1_798_761_600 : 1_792_411_200;
|
||||
export const NETWORK_LABEL = IS_MAINNET ? 'MAINNET CANDIDATE' : 'TESTNET';
|
||||
const TESTNET_GENESIS_TIME = 1_792_411_200;
|
||||
const DEFAULT_MAINNET_GENESIS_TIME = 1_798_761_600; // 2027-01-01 UTC candidate; deployment must freeze one value.
|
||||
function genesisTime(raw: string | undefined, profile: NetworkProfile): number {
|
||||
const value = raw ?? String(profile === 'mainnet' ? DEFAULT_MAINNET_GENESIS_TIME : TESTNET_GENESIS_TIME);
|
||||
if (!/^(0|[1-9]\d{8,10})$/.test(value)) throw new Error('WIKIDECK_GENESIS_TIME must be a canonical Unix-seconds integer');
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1_700_000_000 || parsed > 4_102_444_800) {
|
||||
throw new Error('WIKIDECK_GENESIS_TIME is outside the supported range');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
export const NETWORK_GENESIS_TIME = genesisTime(isNodeRuntime
|
||||
? process.env.WIKIDECK_GENESIS_TIME : import.meta.env?.VITE_WIKIDECK_GENESIS_TIME, PROFILE);
|
||||
if (isNodeRuntime) {
|
||||
const imageProfile = process.env.WIKIDECK_IMAGE_NETWORK;
|
||||
const imageGenesisTime = process.env.WIKIDECK_IMAGE_GENESIS_TIME;
|
||||
if (imageProfile !== undefined && imageProfile !== PROFILE) {
|
||||
throw new Error('Runtime network profile does not match the immutable image manifest');
|
||||
}
|
||||
if (imageGenesisTime !== undefined && Number(imageGenesisTime) !== NETWORK_GENESIS_TIME) {
|
||||
throw new Error('Runtime genesis time does not match the immutable image manifest');
|
||||
}
|
||||
if (imageGenesisTime !== undefined && !/^(0|[1-9]\d{8,10})$/.test(imageGenesisTime)) {
|
||||
throw new Error('Image genesis time manifest is malformed');
|
||||
}
|
||||
}
|
||||
export const NETWORK_LABEL = IS_MAINNET ? 'MAINNET' : 'TESTNET';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NETWORK, type CanonicalPage, type TestnetBlock, type TestnetSnapshot, type TestnetSummary } from './testnet.ts';
|
||||
import { expectedChainIdentity } from './chain-identity.ts';
|
||||
|
||||
const MAX_PAGE_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_HISTORY_BYTES = 64 * 1024 * 1024;
|
||||
@@ -45,7 +46,9 @@ export class StateClient {
|
||||
}
|
||||
private async summary(signal: AbortSignal) {
|
||||
const s = await this.json<TestnetSummary>('summary', signal);
|
||||
if (s.network !== NETWORK || s.algorithm !== 'RandomX' || !hex.test(s.chainId) || !hex.test(s.tip) || !hex.test(s.genesis) ||
|
||||
const expected = await expectedChainIdentity();
|
||||
if (s.network !== NETWORK || s.chainId !== expected.chainId || s.genesis !== expected.genesis ||
|
||||
s.algorithm !== 'RandomX' || !hex.test(s.chainId) || !hex.test(s.tip) || !hex.test(s.genesis) ||
|
||||
!Number.isSafeInteger(s.height) || s.height < 0 || !Array.isArray(s.pendingTransfers) || s.pendingTransfers.length > 256 ||
|
||||
!/^\d{1,256}$/.test(s.cumulativeWork)) throw new Error('Incompatible node summary.');
|
||||
if (s.height > MAX_CLIENT_BLOCKS) throw new Error('This node exceeds the browser history limit (10,000 blocks). A paged explorer/wallet is required; no partial ownership state will be shown.');
|
||||
|
||||
@@ -10,6 +10,7 @@ export const CONSENSUS = {
|
||||
maxTransactions: 64, maxTradeCards: 8, coinbaseMaturity: 2, maxBlockBytes: 64 * 1024,
|
||||
articleSource: 'wikipedia-random-with-image-and-stats-v1', atomicTrades: 'signed-two-party-bundle-v1',
|
||||
} as const;
|
||||
export const MAX_TARGET = `0fff${'f'.repeat(60)}`;
|
||||
export type NftTransfer = {
|
||||
network: typeof NETWORK; chainId: string; cardHash: string; from: string; to: string;
|
||||
sequence: number; publicKey: string; signature: string;
|
||||
|
||||
+26
-9
@@ -1,12 +1,12 @@
|
||||
// Browser/Node WebCrypto only: this module never reads storage or sends data.
|
||||
import { NETWORK } from './network.ts';
|
||||
import { IS_MAINNET, NETWORK, NETWORK_GENESIS_TIME } from './network.ts';
|
||||
import { expectedChainIdentity } from './chain-identity.ts';
|
||||
const ITERATIONS = 600_000;
|
||||
const MAX_BYTES = 32 * 1024;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export type WalletBackup = {
|
||||
type WalletBackupBase = {
|
||||
format: 'wikideck-wallet-backup';
|
||||
version: 1;
|
||||
network: typeof NETWORK;
|
||||
kdf: 'PBKDF2-SHA256';
|
||||
iterations: 600000;
|
||||
@@ -16,6 +16,10 @@ export type WalletBackup = {
|
||||
publicKey: string;
|
||||
ciphertext: string;
|
||||
};
|
||||
export type WalletBackup = WalletBackupBase & (
|
||||
| { version: 1; chainId?: never }
|
||||
| { version: 2; chainId: string }
|
||||
);
|
||||
|
||||
function encode(bytes: ArrayBuffer | Uint8Array<ArrayBuffer>): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(bytes)));
|
||||
@@ -54,27 +58,38 @@ async function encryptionKey(password: string, salt: Uint8Array<ArrayBuffer>, us
|
||||
// Fixed-order AAD authenticates every header field, including identity/network.
|
||||
function additionalData(backup: WalletBackup): Uint8Array<ArrayBuffer> {
|
||||
return encoder.encode(JSON.stringify([
|
||||
backup.format, backup.version, backup.network, backup.kdf, backup.iterations,
|
||||
backup.format, backup.version, backup.network,
|
||||
...(backup.version === 2 ? [backup.chainId] : []), backup.kdf, backup.iterations,
|
||||
backup.cipher, backup.salt, backup.iv, backup.publicKey,
|
||||
]));
|
||||
}
|
||||
|
||||
function validateBackup(value: unknown): WalletBackup {
|
||||
async function validateBackup(value: unknown): Promise<WalletBackup> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
||||
![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
|
||||
throw new Error('Invalid wallet backup.');
|
||||
}
|
||||
const names = ['format', 'version', 'network', 'kdf', 'iterations', 'cipher', 'salt', 'iv', 'publicKey', 'ciphertext'];
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
const version = descriptors.version && Object.hasOwn(descriptors.version, 'value') ? descriptors.version.value : undefined;
|
||||
if (version !== 1 && version !== 2) throw new Error('Unsupported wallet backup format, network, or cryptography.');
|
||||
const names = ['format', 'version', 'network', ...(version === 2 ? ['chainId'] : []),
|
||||
'kdf', 'iterations', 'cipher', 'salt', 'iv', 'publicKey', 'ciphertext'];
|
||||
if (Reflect.ownKeys(value).length !== names.length || names.some(name =>
|
||||
!Object.hasOwn(descriptors, name) || !Object.hasOwn(descriptors[name], 'value'))) {
|
||||
throw new Error('Invalid wallet backup fields.');
|
||||
}
|
||||
const backup = value as WalletBackup;
|
||||
if (backup.format !== 'wikideck-wallet-backup' || backup.version !== 1 || backup.network !== NETWORK ||
|
||||
if (backup.format !== 'wikideck-wallet-backup' || backup.network !== NETWORK ||
|
||||
backup.kdf !== 'PBKDF2-SHA256' || backup.iterations !== ITERATIONS || backup.cipher !== 'AES-256-GCM') {
|
||||
throw new Error('Unsupported wallet backup format, network, or cryptography.');
|
||||
}
|
||||
if (backup.version === 2) {
|
||||
const expected = await expectedChainIdentity();
|
||||
if (backup.chainId !== expected.chainId) throw new Error('Wallet backup is for a different chain.');
|
||||
} else if (IS_MAINNET || NETWORK_GENESIS_TIME !== 1_792_411_200) {
|
||||
// Legacy v1 headers had no chain identity, so accept them only on the original testnet.
|
||||
throw new Error('Legacy wallet backups are supported only on the original testnet.');
|
||||
}
|
||||
decode(backup.salt, 16, 16);
|
||||
decode(backup.iv, 12, 12);
|
||||
decode(backup.publicKey, 91, 91);
|
||||
@@ -82,6 +97,7 @@ function validateBackup(value: unknown): WalletBackup {
|
||||
if (encoder.encode(JSON.stringify(backup)).length > MAX_BYTES) throw new Error('Backup exceeds 32 KB.');
|
||||
// Snapshot primitives so callers cannot change headers during awaits.
|
||||
return { format: backup.format, version: backup.version, network: backup.network,
|
||||
...(backup.version === 2 ? { chainId: backup.chainId } : {}),
|
||||
kdf: backup.kdf, iterations: backup.iterations, cipher: backup.cipher,
|
||||
salt: backup.salt, iv: backup.iv, publicKey: backup.publicKey, ciphertext: backup.ciphertext };
|
||||
}
|
||||
@@ -110,9 +126,10 @@ async function validatePair(privateKey: CryptoKey, publicKey: string): Promise<s
|
||||
export async function encryptWallet(privateKey: CryptoKey, publicKey: string, password: string): Promise<WalletBackup> {
|
||||
passwordBytes(password, 12).fill(0);
|
||||
await validatePair(privateKey, publicKey);
|
||||
const { chainId } = await expectedChainIdentity();
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const backup: WalletBackup = { format: 'wikideck-wallet-backup', version: 1, network: NETWORK,
|
||||
const backup: WalletBackup = { format: 'wikideck-wallet-backup', version: 2, network: NETWORK, chainId,
|
||||
kdf: 'PBKDF2-SHA256', iterations: ITERATIONS, cipher: 'AES-256-GCM',
|
||||
salt: encode(salt), iv: encode(iv), publicKey, ciphertext: '' };
|
||||
const key = await encryptionKey(password, salt, 'encrypt');
|
||||
@@ -128,7 +145,7 @@ export async function encryptWallet(privateKey: CryptoKey, publicKey: string, pa
|
||||
export async function decryptWallet(backup: unknown, password: string): Promise<{
|
||||
privateKey: CryptoKey; publicKey: string; address: string;
|
||||
}> {
|
||||
const validated = validateBackup(backup); // Bound work before PBKDF2.
|
||||
const validated = await validateBackup(backup); // Bound work before PBKDF2.
|
||||
const key = await encryptionKey(password, decode(validated.salt, 16, 16), 'decrypt');
|
||||
let plaintext: Uint8Array<ArrayBuffer> | undefined;
|
||||
try {
|
||||
|
||||
+13
-13
@@ -11,7 +11,7 @@ import { WalletPanel } from './WalletPanel.tsx';
|
||||
import { useModalFocus } from './useModalFocus.ts';
|
||||
import { ThemePicker } from './ThemePicker.tsx';
|
||||
import { TradeDesk } from './TradeDesk.tsx';
|
||||
import { IS_MAINNET, NETWORK_LABEL } from '../shared/network.ts';
|
||||
import { IS_MAINNET, NETWORK_GENESIS_TIME, NETWORK_LABEL } from '../shared/network.ts';
|
||||
import { StateClient } from '../shared/rpc-client.ts';
|
||||
import './App.css';
|
||||
import './testnet.css';
|
||||
@@ -23,10 +23,10 @@ const message = (error: unknown) => error instanceof Error ? error.message : 'Re
|
||||
async function request<T>(path: string, body?: unknown, expectedStatus?: number): Promise<T> {
|
||||
const res = await fetch(`/api/${path}`, { cache: 'no-store', signal: AbortSignal.timeout(15000),
|
||||
...(body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) });
|
||||
if (!res.headers.get('content-type')?.includes('application/json')) throw new Error('The testnet RPC is unavailable. Start the node and web services.');
|
||||
if (!res.headers.get('content-type')?.includes('application/json')) throw new Error('The Wikideck node RPC is unavailable. Start the node and web services.');
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(typeof data.error === 'string' ? data.error : 'Request failed');
|
||||
if (expectedStatus !== undefined && res.status !== expectedStatus) throw new Error('Unexpected testnet RPC response. Refresh before trying again.');
|
||||
if (expectedStatus !== undefined && res.status !== expectedStatus) throw new Error('Unexpected node RPC response. Refresh before trying again.');
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function App() {
|
||||
const data = await stateClient.current.load();
|
||||
if (data.network !== NETWORK || data.algorithm !== 'RandomX' || typeof data.chainId !== 'string' ||
|
||||
!Array.isArray(data.blocks) || !Array.isArray(data.transfers) || !Array.isArray(data.pendingTransfers))
|
||||
throw new Error('Incompatible node. This interface requires the Wikideck RandomX testnet API.');
|
||||
throw new Error(`Incompatible node. This interface is pinned to ${NETWORK} at genesis ${NETWORK_GENESIS_TIME}.`);
|
||||
if (version === refreshVersion.current) { setSnapshot(data); setRpcAvailable(true); setError(''); }
|
||||
return data;
|
||||
} catch (e) {
|
||||
@@ -172,7 +172,7 @@ export default function App() {
|
||||
<div className="main-shell">
|
||||
<header className="topbar"><div className="breadcrumbs">Explore <ChevronRight size={13} /><span>{view}</span></div><div className="top-actions"><span className="testnet-brand">RandomX <b>{NETWORK_LABEL}</b></span><ThemePicker /><span className="network-pill"><span className={`network-dot ${!rpcAvailable || !snapshot?.peers ? 'disconnected' : ''}`} />{peerLabel}</span><button className="wallet-button" onClick={openWallet}><span className="wallet-avatar"><WalletIcon size={14} /></span>{wallet ? short(wallet.address) : 'Open wallet'}<ChevronRight size={14} /></button></div></header>
|
||||
<main>
|
||||
<div className="testnet-status" role="status"><span><span className={`network-dot ${rpcAvailable ? '' : 'disconnected'}`} /> RPC {rpcAvailable ? 'available' : 'unavailable'}</span><span>{peerLabel}</span><span>{IS_MAINNET?'Gated mainnet candidate · unaudited':'Unbootstrapped testnet · no public mainnet'}</span>{snapshot && !rpcAvailable && <strong>Showing last known state</strong>}</div>
|
||||
<div className="testnet-status" role="status"><span><span className={`network-dot ${rpcAvailable ? '' : 'disconnected'}`} /> RPC {rpcAvailable ? 'available' : 'unavailable'}</span><span>{peerLabel}</span><span>{IS_MAINNET?'MAINNET · experimental · unaudited · no value promised':'TESTNET · experimental · no value promised'}</span>{snapshot && !rpcAvailable && <strong>Showing last known state</strong>}</div>
|
||||
{error && <div className="error-banner" role="alert"><CircleHelp size={17} /><span>{error}</span><button onClick={retry}>Retry</button><button aria-label="Dismiss error" onClick={() => setError('')}><X size={16} /></button></div>}
|
||||
{view === 'Discover' && <>
|
||||
<section className="hero">
|
||||
@@ -187,7 +187,7 @@ 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" /> RANDOMX · DESIGNED FOR GENERAL-PURPOSE CPUS</div><h2>Mining should be for more people.</h2><p>Wikideck and Monero use RandomX, an algorithm designed to resist specialized ASICs and keep general-purpose CPUs competitive. That can make mining more accessible—not make every machine equally powerful or promise rewards. Each accepted Wikideck block earns its miner a distinct illustrated Wikipedia card. Wikideck is its own independent network.</p><div className="mining-meta"><span><Cpu size={13} /> Mine with a CPU</span><span><Shield size={13} /> Independently validating peers</span></div><details className="mining-details"><summary>Hardware, rewards & network details</summary><div className="native-memory"><strong>Light · default</strong><span>256 MiB cache plus scratchpad and process overhead.</span><strong>Fast · --fast</strong><span>About 2 GiB dataset plus cache; budget over 2.3 GiB per process. Mining uses CPU, memory, electricity and time. ASIC resistance is a design goal, not a guarantee; results depend on hardware and network difficulty. Wikideck does not share Monero’s network, hashpower or security.</span></div></details></div>
|
||||
<div className="miner-controls"><div className="miner-display"><div><span>NEXT WIKIPEDIA ARTICLE</span><strong>{snapshot ? 'Chosen when the miner requests work' : 'Waiting for testnet RPC…'}</strong></div></div><p className="muted">The miner selects a random illustrated English Wikipedia article. The same article can appear again; each card keeps its own mint-time statistics.</p><details className="mining-details"><summary>Want to run a miner? Setup & safety details</summary><ol className="native-steps"><li>Build <code>wikideck-randomx</code> using the repository’s native build instructions.</li><li>Save your encrypted backup and keep its password separate.</li><li>Replace <code><backup.json></code> with your local file path (quote paths with spaces), then run:</li></ol><pre className="native-command"><code>{nativeCommand}</code></pre><p className="miner-footnote">Node: {window.location.origin}. The command connects to this node; use HTTPS for remote nodes. Password entry stays local—never put it in the command. It defaults to one worker and at most one block. Ctrl+C stops the native miner; closing this page does not.</p></details><button className="button dark full" onClick={openWallet}><Download size={16} /> Download wallet backup</button><button className="text-button" onClick={() => setDialog('guide')}>Read the 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 ${NETWORK_LABEL} RPC…`}</strong></div></div><p className="muted">The miner selects a random illustrated English Wikipedia article. The same article can appear again; each card keeps its own mint-time statistics.</p><details className="mining-details"><summary>Want to run a miner? Setup & safety details</summary><ol className="native-steps"><li>Build <code>wikideck-randomx</code> using the repository’s native build instructions.</li><li>Save your encrypted backup and keep its password separate.</li><li>Replace <code><backup.json></code> with your local file path (quote paths with spaces), then run:</li></ol><pre className="native-command"><code>{nativeCommand}</code></pre><p className="miner-footnote">Node: {window.location.origin}. The command connects to this node; use HTTPS for remote nodes. Password entry stays local—never put it in the command. It defaults to one worker and at most one block. Ctrl+C stops the native miner; closing this page does not.</p></details><button className="button dark full" onClick={openWallet}><Download size={16} /> Download wallet backup</button><button className="text-button" onClick={() => setDialog('guide')}>Read the {NETWORK_LABEL.toLowerCase()} field guide <ArrowRight size={14} /></button></div>
|
||||
</section>
|
||||
<section className="project-pillars" aria-label="Mine, collect, and trade cards">
|
||||
<article><Pickaxe size={22}/><div className="section-eyebrow">MINE · EARN A CARD</div><h2>Discover the next card.</h2><p>Run the native RandomX miner to compete for blocks. Each accepted block rewards its miner with a distinct illustrated Wikipedia card.</p><button className="text-button" onClick={goMine}>Mining setup <ArrowRight size={14}/></button></article>
|
||||
@@ -196,11 +196,11 @@ export default function App() {
|
||||
</section>
|
||||
</>}
|
||||
{view === 'Trade cards' ? <TradeDesk wallet={wallet} snapshot={snapshot} rpcAvailable={rpcAvailable} onNotice={setToast} /> : view !== 'Block explorer' ? <section className="discover-section">
|
||||
<div className="section-heading"><div><div className="section-eyebrow">{view === 'My collection' ? 'YOUR PERSONAL ENCYCLOPEDIA' : 'AN ENCYCLOPEDIA, REIMAGINED'}</div><h2>{view === 'My collection' ? 'Your cabinet of curiosities' : 'A world of possible discoveries'}<span className="count-badge">{visibleCards.length}</span></h2><p>{view === 'My collection' ? 'Confirmed native NFT ownership. Pending transfers stay here until mined.' : snapshot?.blocks.length ? 'Recent native NFTs on this node’s canonical 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="section-heading"><div><div className="section-eyebrow">{view === 'My collection' ? 'YOUR PERSONAL ENCYCLOPEDIA' : 'AN ENCYCLOPEDIA, REIMAGINED'}</div><h2>{view === 'My collection' ? 'Your cabinet of curiosities' : 'A world of possible discoveries'}<span className="count-badge">{visibleCards.length}</span></h2><p>{view === 'My collection' ? 'Confirmed native NFT ownership. Pending transfers stay here until mined.' : snapshot?.blocks.length ? 'Recent native NFTs on this node’s canonical chain.' : 'Sample previews only; mined NFTs use random Wikipedia pages with pictures. The same page may be minted more than once.'}</p></div>{view === 'Discover' && <button className="text-button" onClick={() => setView('My collection')}>My collection <ArrowRight size={16} /></button>}</div>
|
||||
<div className="filter-bar"><div className="category-tabs">{['All cards', 'Nature', 'Space', 'Science', 'People', 'History', 'Culture'].map(c => <button key={c} className={category === c ? 'selected' : ''} onClick={() => setCategory(c)}>{c}</button>)}</div><label className="search-input"><Search size={15} /><input aria-label="Search cards" placeholder="Find a discovery…" value={query} onChange={e => setQuery(e.target.value)} /></label></div>
|
||||
{filtered.length ? <div className="card-grid">{filtered.map(c => <div className="card-wrap" key={c.block?.tokenId ?? c.hash}><Card card={c} onClick={() => { setSelected(c); setTransferError(''); }} /><div className="card-list-caption"><span>{c.block ? `Block #${c.block.height}` : 'Example discovery'}</span><span className="caption-dot" />{c.block && snapshot ? short(testnetOwnerOf(snapshot, c.block.tokenId) ?? '') : 'Not owned'}</div>{c.block && snapshot?.pendingTransfers.some(tx => transactionCardIds(tx).includes(c.block!.tokenId)) && <span className="pending-label">Transfer or trade pending mining</span>}</div>)}</div> : <div className="empty-state"><img className="empty-state-logo" src="/original-logo.svg" alt="Wikideck" /><h3>{query || category !== 'All cards' ? 'No discoveries here. Yet.' : 'Your first discovery is waiting.'}</h3><p>{query || category !== 'All cards' ? 'Try another search or explore a different category.' : 'Use the native miner or receive a mined transfer on this chain. Restore a backup to access an existing wallet.'}</p><button className="button dark" onClick={goMine}><Pickaxe size={16} /> Explore native mining</button></div>}
|
||||
</section> : <section className="explorer-section"><div className="section-eyebrow">FOLLOW THE CANONICAL CHAIN</div><div className="section-heading"><div><h1>Block explorer<span className="count-badge">{snapshot?.blocks.length ?? 0}</span></h1><p>Native NFT mints, RandomX proofs, and mined ownership transfers.</p></div><a className="button outline" href="/api/chain" target="_blank" rel="noreferrer">View full chain JSON <ExternalLink size={15} /></a></div>
|
||||
<div className="explorer-note"><Shield size={18} /><p>Sovereign testnet with independently validating peers and cumulative-work chain selection. This network is unbootstrapped, not a public mainnet. Zero peers means an isolated node, not a connected network. Canonical ownership can change during a reorganization.</p></div>
|
||||
<div className="explorer-note"><Shield size={18} /><p>Sovereign network with independently validating peers and cumulative-work chain selection. This network is experimental and may be unbootstrapped. Zero peers means an isolated node, not a connected network. Canonical ownership can change during a reorganization.</p></div>
|
||||
<dl className="chain-summary"><div><dt>Cumulative work</dt><dd>{snapshot?.cumulativeWork ?? '—'}</dd></div><div><dt>Peer count</dt><dd>{rpcAvailable ? snapshot?.peers ?? '—' : 'Unknown'} · {peerLabel}</dd></div><div><dt>Chain ID · pinned genesis</dt><dd>{snapshot?.chainId ?? '—'}</dd></div><div><dt>Network</dt><dd>{NETWORK}</dd></div><div><dt>Tip block ID</dt><dd>{snapshot?.tip ?? '—'}</dd></div><div><dt>Mint maturity</dt><dd>{snapshot?.maturity ?? CONSENSUS.coinbaseMaturity} subsequent blocks at inclusion height</dd></div></dl>
|
||||
{snapshot?.blocks.length ? <div className="table-scroll"><table><thead><tr><th>Block / confirmations</th><th>Discovery</th><th>Block ID / NFT token ID</th><th>Confirmed owner</th><th>RandomX proof / rarity</th></tr></thead><tbody>{[...snapshot.blocks].reverse().map(b => <tr key={b.hash}><td><button className="explorer-card-link" onClick={() => setSelected(asCard(b))}>#{b.height.toString().padStart(4, '0')}</button><small>{snapshot.blocks.length - b.height + 1} confirmations</small></td><td><strong>{b.article.title}</strong><small>Wikipedia #{b.article.pageId}</small></td><td className="mono"><span title={b.hash}>Block {short(b.hash)}</span><a className="nft-link" href={`/api/nfts/${encodeURIComponent(b.tokenId)}`} target="_blank" rel="noreferrer" title={b.tokenId}>NFT {short(b.tokenId)} <ExternalLink size={11} /></a></td><td className="mono" title={testnetOwnerOf(snapshot, b.tokenId)}>{short(testnetOwnerOf(snapshot, b.tokenId) ?? '')}</td><td><span className="proof-caption mono" title={b.powHash}>{short(b.powHash)}</span><span className={`rarity-tag ${rarity(b.powHash).toLowerCase()}`}>{rarity(b.powHash)}</span></td></tr>)}</tbody></table></div> : <div className="empty-state"><img className="empty-state-logo" src="/original-logo.svg" alt="Wikideck" /><h3>A new chain. A blank page.</h3><p>No NFT blocks loaded yet. Mine a block to earn the next Wikipedia card.</p><button className="button dark" onClick={goMine}>Explore RandomX mining <ArrowRight size={16} /></button></div>}
|
||||
<h2 className="transfer-title">Mined transfers and trades</h2>{snapshot?.transfers.length ? <div className="journal">{snapshot.transfers.map((tx, i) => isNftTrade(tx)
|
||||
@@ -210,11 +210,11 @@ export default function App() {
|
||||
? <div key={`pending-trade-${tx.proposerSignature}`}><LoaderCircle size={16}/><span>{tx.offered.length} cards {short(tx.proposer)} ⇄ {tx.requested.length} cards {short(tx.counterparty)}</span><span className="pending-label">Atomic trade · awaiting mining</span></div>
|
||||
: <div key={`${tx.cardHash}-${tx.sequence}`}><LoaderCircle size={16}/><span title={tx.cardHash}>{short(tx.cardHash)}</span><span>{short(tx.from)} → {short(tx.to)}</span><span className="pending-label">Awaiting mining</span></div>)}</div>
|
||||
</section>}
|
||||
<footer><a href="https://en.wikipedia.org" target="_blank" rel="noreferrer"><BookOpen size={16} /> Wikipedia pages. Collectible discoveries.</a><span>Mine. Collect. Trade.<span className="footer-star">✦</span></span><p>Independent project, not affiliated with Wikimedia or Monero. NFT rewards represent Wikipedia article snapshots, not ownership of Wikipedia pages or their copyright. Attribution and image licenses appear on each card. Current network: experimental testnet with no promised monetary value.</p></footer>
|
||||
<footer><a href="https://en.wikipedia.org" target="_blank" rel="noreferrer"><BookOpen size={16} /> Wikipedia pages. Collectible discoveries.</a><span>Mine. Collect. Trade.<span className="footer-star">✦</span></span><p>Independent project, not affiliated with Wikimedia or Monero. NFT rewards represent Wikipedia article snapshots, not ownership of Wikipedia pages or their copyright. Attribution and image licenses appear on each card. Current network: {NETWORK_LABEL}; experimental, with no promised monetary value.</p></footer>
|
||||
</main>
|
||||
</div>
|
||||
{toast && <div className="toast" role="status"><span><Check size={16} /></span>{toast}<button onClick={() => setToast('')} aria-label="Dismiss notification"><X size={14} /></button></div>}
|
||||
{(dialog || selected) && <div className="modal-backdrop" onClick={e => { if (e.target === e.currentTarget) close(); }}><section className={`modal ${dialog === 'wallet' ? 'wallet-modal' : selected && !dialog ? 'card-modal' : ''}`} role="dialog" aria-modal="true" aria-label={dialog === 'wallet' ? 'Local 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 || selected) && <div className="modal-backdrop" onClick={e => { if (e.target === e.currentTarget) close(); }}><section className={`modal ${dialog === 'wallet' ? 'wallet-modal' : selected && !dialog ? 'card-modal' : ''}`} role="dialog" aria-modal="true" aria-label={dialog === 'wallet' ? `Local ${NETWORK_LABEL} wallet` : dialog === 'transfer' ? 'Transfer native NFT' : dialog === 'guide' ? 'How Wikideck works' : 'Card details'}><button className="modal-close" aria-label="Close dialog" onClick={close} disabled={transferBusy}><X size={20} /></button>
|
||||
{dialog === 'guide' ? <>
|
||||
<span className="modal-icon"><Sparkles size={24}/></span><div className="section-eyebrow">THE WIKIDECK FIELD GUIDE</div><h2>Mine. Collect. Trade cards.</h2>
|
||||
<div className="guide-steps">
|
||||
@@ -222,11 +222,11 @@ export default function App() {
|
||||
<div><span>02</span><div><h3>Keep your collection in your wallet.</h3><p>Your encrypted local wallet controls your cards. Restore a backup to use the same identity in another browser profile. Confirmed ownership follows the canonical chain.</p><details><summary>Wallet and ownership basics</summary><p>Transfers remain pending until confirmed, and a chain reorganization can change confirmed ownership. Looking up a public wallet address reveals its confirmed holdings. Keep your encrypted backup and password separately.</p></details></div></div>
|
||||
<div><span>03</span><div><h3>Trade cards directly with another collector.</h3><p>Both players sign the same card list; the complete swap is applied together when mined or rejected. Offers are shared between players, not sold or submitted as listings.</p><details><summary>What happens when I accept?</summary><p>The app rechecks ownership, maturity, sequences, and pending activity before submitting. A trade stays pending until mined; re-check the recipient and cards before signing.</p></details></div></div>
|
||||
</div>
|
||||
<div className="info-box"><strong>Collectible snapshots—not ownership of Wikipedia.</strong><p>Cards do not grant rights to Wikipedia text or images. Check each card’s attribution and licenses. This is an experimental testnet; cards have no promised monetary value.</p></div>
|
||||
<div className="info-box"><strong>Collectible snapshots—not ownership of Wikipedia.</strong><p>Cards do not grant rights to Wikipedia text or images. Check each card’s attribution and licenses. This is an experimental {NETWORK_LABEL.toLowerCase()} network; cards have no promised monetary value.</p></div>
|
||||
<button className="button dark full" onClick={() => { close(); goMine(); }}>Explore RandomX mining <ArrowRight size={16}/></button>
|
||||
</>
|
||||
: dialog === 'wallet' ? <><span className="modal-icon"><WalletIcon size={24} /></span><div className="section-eyebrow">YOUR LOCAL 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></>
|
||||
: dialog === 'wallet' ? <><span className="modal-icon"><WalletIcon size={24} /></span><div className="section-eyebrow">YOUR LOCAL {NETWORK_LABEL} IDENTITY</div><h2>A wallet for your wonders.</h2><p className="muted">No server login. This recoverable wallet controls native NFTs through local signatures. It is separate from the original demo wallet.</p><label className="field-label">YOUR RECEIVE ADDRESS</label><div className="address-box">{wallet?.address ?? 'Wallet unavailable. Enable local browser storage and retry.'}</div><button className="button dark full" onClick={copyAddress} disabled={!wallet}><Copy size={16} /> Copy wallet address</button><div className="wallet-recovery"><WalletPanel wallet={wallet} onRestore={updateWallet} onNotice={setToast} /></div><p className="wallet-count"><Layers3 size={17} /> {owned.length} confirmed native NFTs in your collection</p></>
|
||||
: dialog === 'transfer' ? <><span className="modal-icon"><ArrowUpRight size={24} /></span><div className="section-eyebrow">PASS ON THE CURIOSITY</div><h2>Send {selected?.article.title}.</h2><p className="muted">Use the recipient’s 64-character wallet receive address on this same network. Signing submits a pending transaction; it does not immediately transfer ownership.</p><label className="field-label">NFT TOKEN ID</label><div className="address-box">{selected?.block?.tokenId}</div><label className="field-label" htmlFor="recipient">RECIPIENT ADDRESS</label><input className="recipient-input" id="recipient" value={recipient} disabled={transferBusy} onChange={e => setRecipient(e.target.value)} placeholder="Paste a 64-character wallet address" autoComplete="off" maxLength={64} /><div className="info-box"><strong>Review before signing.</strong><p>Transfers take effect only when mined into the canonical chain. Once confirmed, there is no undo button or recovery for a wrong address. Mints require two subsequent blocks at inclusion height; only one pending transfer per NFT is allowed.</p></div>{(!rpcAvailable || transferDisabledReason) && <p className="transfer-error" role="alert">{!rpcAvailable ? 'RPC unavailable. Reconnect before signing.' : transferDisabledReason}</p>}{transferError && <p className="transfer-error" role="alert">{transferError}</p>}<button className="button dark full" disabled={transferBusy || !rpcAvailable || !!transferDisabledReason || !/^[a-fA-F0-9]{64}$/.test(recipient.trim()) || recipient.trim().toLowerCase() === wallet?.address} onClick={transfer}>{transferBusy ? <LoaderCircle className="spin" size={16} /> : <ArrowUpRight size={16} />}{transferBusy ? 'Signing & submitting…' : 'Sign & submit pending transfer'}</button></>
|
||||
: selected && <><div className="detail-card"><Card card={selected} /></div><div className="detail-copy"><div className="section-eyebrow">{selected.block ? `MINTED IN BLOCK #${selected.block.height}` : 'EXAMPLE CARD · NOT OWNED'}</div><h2>{selected.article.title}</h2><p>{selected.article.extract}</p><div className="detail-facts"><span>Page ID<strong>#{selected.article.pageId}</strong></span><span>Rarity<strong>{rarity(selected.hash)}</strong></span><span>Content<strong>{selected.article.source === 'live' ? `Pinned revision ${selected.article.revisionId}` : 'Sample preview'}</strong></span></div>{selected.block && <><label className="field-label">NATIVE NFT TOKEN ID</label><div className="address-box">{selected.block.tokenId}</div><label className="field-label">BLOCK ID</label><div className="address-box">{selected.block.hash}</div><label className="field-label">RANDOMX PROOF HASH · GAME STATS</label><div className="address-box">{selected.hash}</div><p className="muted">{confirmations} canonical confirmations{!rpcAvailable ? ' · last known state' : ''}. {selectedBlock ? `Confirmed owner: ${selectedOwner}` : 'This mint is not on the loaded canonical chain.'}</p>{pending && <p className="pending-label">Transfer pending mining · confirmed owner unchanged.</p>}</>}<div className="detail-links"><a href={selected.article.url} target="_blank" rel="noreferrer">Read on Wikipedia <ExternalLink size={14} /></a><a href={imageCredit(selected.article)} target="_blank" rel="noreferrer">Image & license <ExternalLink size={14} /></a>{selected.block && <a href={`/api/nfts/${encodeURIComponent(selected.block.tokenId)}`} target="_blank" rel="noreferrer">NFT metadata <ExternalLink size={14} /></a>}</div><p className="attribution">{selected.article.source === 'live' ? 'Pinned text by Wikipedia contributors, CC BY-SA 4.0. Excerpt shortened for card display.' : 'Original fixed starter summary, linked to the real Wikipedia article; no live revision captured.'} Stats are game values, not encyclopedia facts. NFT ownership grants no rights to article text or images.</p>{selected.block && selectedOwner === wallet?.address && <>{transferDisabledReason && <p className="muted">{transferDisabledReason}</p>}<button className="button dark full" disabled={!rpcAvailable || !!transferDisabledReason} onClick={() => { setRecipient(''); setTransferError(''); setDialog('transfer'); }}>Transfer native NFT <ArrowUpRight size={16} /></button></>}</div></>}
|
||||
</section></div>}
|
||||
</div>;
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ export function WalletPanel({ wallet, onRestore, onNotice }: Props) {
|
||||
{status!=='locked'&&<><label htmlFor={`${id}-local-confirm`}>Confirm local password</label><input id={`${id}-local-confirm`} type="password" autoComplete="new-password" required minLength={12} maxLength={1024} value={localConfirmation} onChange={e=>setLocalConfirmation(e.target.value)}/></>}
|
||||
<button type="submit">{status==='locked'?'Unlock wallet':status==='legacy'?'Encrypt existing wallet':'Create encrypted wallet'}</button>
|
||||
</fieldset></form>}
|
||||
<p>Backup encryption and recovery happen locally. No private key, backup file, or password is transmitted to a server
|
||||
<p>Backups are bound to this exact chain ID; a backup for a different genesis cannot be restored here. Encryption and recovery happen locally. No private key, backup file, or password is transmitted to a server
|
||||
by this panel. Public addresses, public keys and signatures may be shared during normal wallet use.</p>
|
||||
<p><strong>Forgotten backup passwords cannot be reset or recovered.</strong> Keep the encrypted file and a strong,
|
||||
unique password safe and separate. Anyone with both can control this wallet. Downloading does not guarantee the file was saved.</p>
|
||||
|
||||
+19
-9
@@ -1,5 +1,5 @@
|
||||
import { decryptWallet, encryptWallet, type WalletBackup } from '../shared/wallet-backup.ts';
|
||||
import { IS_MAINNET } from '../shared/network.ts';
|
||||
import { expectedChainIdentity } from '../shared/chain-identity.ts';
|
||||
|
||||
export type Wallet = { publicKey: string; address: string };
|
||||
type Keys = Awaited<ReturnType<typeof decryptWallet>>;
|
||||
@@ -12,13 +12,17 @@ let session: { keys: Keys; handle: Wallet; revision: string; opened: number; tou
|
||||
let epoch = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
const isVault = (value: Stored): value is Vault => 'format' in value && value.format === 'wikideck-local-vault';
|
||||
const DATABASE = IS_MAINNET ? 'wikideck-mainnet-wallet-v1' : 'wikideck-testnet-wallet-v2';
|
||||
const LOCK = `${DATABASE}:identity`;
|
||||
let databaseNamePromise: Promise<string> | undefined;
|
||||
function databaseName(): Promise<string> {
|
||||
if (!databaseNamePromise) databaseNamePromise = expectedChainIdentity().then(({ chainId }) => `wikideck-wallet-${chainId}-v2`);
|
||||
return databaseNamePromise;
|
||||
}
|
||||
const encode = (bytes: ArrayBuffer) => btoa(String.fromCharCode(...new Uint8Array(bytes)));
|
||||
|
||||
function openDatabase(): Promise<IDBDatabase> {
|
||||
async function openDatabase(): Promise<IDBDatabase> {
|
||||
const name = await databaseName();
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DATABASE, 1);
|
||||
const request = indexedDB.open(name, 1);
|
||||
let blocked = false;
|
||||
request.onupgradeneeded = () => request.result.createObjectStore('keys');
|
||||
request.onsuccess = () => {
|
||||
@@ -78,8 +82,9 @@ function withStorage<T>(operation: (db: IDBDatabase) => Promise<T>): Promise<T>
|
||||
}
|
||||
};
|
||||
const result = queue.then(async (): Promise<T> => {
|
||||
const name = await databaseName();
|
||||
if (typeof navigator !== 'undefined' && navigator.locks) {
|
||||
return await navigator.locks.request(LOCK, run);
|
||||
return await navigator.locks.request(`${name}:identity`, run);
|
||||
}
|
||||
return await run();
|
||||
});
|
||||
@@ -94,6 +99,7 @@ function same(a: Stored | undefined, b: Stored | undefined): boolean {
|
||||
}
|
||||
const read = () => withStorage(db => identity(db));
|
||||
let channel: BroadcastChannel | undefined;
|
||||
let channelGeneration = 0;
|
||||
function notify() { for (const fn of listeners) fn(); }
|
||||
export function lockWallet(broadcast = true): void {
|
||||
epoch++; session = undefined;
|
||||
@@ -111,12 +117,16 @@ export function subscribeWallet(fn: () => void): () => void {
|
||||
listeners.add(fn); return () => { listeners.delete(fn); };
|
||||
}
|
||||
export function watchWalletLifecycle(): () => void {
|
||||
const generation = ++channelGeneration;
|
||||
const activity = (e: Event) => { if (e.isTrusted) touchWallet(); };
|
||||
const visibility = () => { if (document.visibilityState !== 'visible') lockWallet(); };
|
||||
const hide = () => lockWallet();
|
||||
if (typeof BroadcastChannel !== 'undefined') {
|
||||
channel = new BroadcastChannel(`${DATABASE}:lock`);
|
||||
channel.onmessage = () => lockWallet(false);
|
||||
void databaseName().then(name => {
|
||||
if (generation !== channelGeneration) return;
|
||||
channel = new BroadcastChannel(`${name}:lock`);
|
||||
channel.onmessage = () => lockWallet(false);
|
||||
}).catch(() => {});
|
||||
}
|
||||
document.addEventListener('pointerdown',activity); document.addEventListener('keydown',activity);
|
||||
document.addEventListener('visibilitychange',visibility); window.addEventListener('pagehide',hide);
|
||||
@@ -124,7 +134,7 @@ export function watchWalletLifecycle(): () => void {
|
||||
return () => {
|
||||
clearInterval(timer); document.removeEventListener('pointerdown',activity); document.removeEventListener('keydown',activity);
|
||||
document.removeEventListener('visibilitychange',visibility); window.removeEventListener('pagehide',hide);
|
||||
channel?.close(); channel = undefined; lockWallet(false);
|
||||
channelGeneration++; channel?.close(); channel = undefined; lockWallet(false);
|
||||
};
|
||||
}
|
||||
export async function walletStatus(): Promise<WalletStatus> {
|
||||
|
||||
@@ -1,5 +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
|
||||
docker compose up --build -d
|
||||
docker compose ps
|
||||
docker compose logs -f wikideck
|
||||
|
||||
+10
-8
@@ -11,15 +11,16 @@ import { CHAIN_ID, GENESIS_TIME, GENESIS_HASH, MAX_TARGET } from '../server/test
|
||||
|
||||
function script(code: string, env: Record<string,string> = {}) {
|
||||
return execFileSync(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], {
|
||||
env: { ...process.env, WIKIDECK_NETWORK: 'mainnet', ...env }, encoding: 'utf8', timeout: 30000,
|
||||
env: { ...process.env, WIKIDECK_NETWORK: 'mainnet', WIKIDECK_GENESIS_TIME: '1798761600', ...env }, encoding: 'utf8', timeout: 30000,
|
||||
}).trim();
|
||||
}
|
||||
test('default testnet v6 manifest is unchanged and unknown network profiles fail closed', () => {
|
||||
test('default profile is mainnet while testnet remains an explicit isolated profile', () => {
|
||||
const hash = (text: string) => createHash('sha256').update(text).digest('hex');
|
||||
assert.equal(NETWORK, 'wikideck-randomx-testnet-6');
|
||||
assert.equal(GENESIS_TIME, 1_792_411_200);
|
||||
assert.equal(CHAIN_ID, hash(JSON.stringify(['wikideck-randomx-testnet-6', CONSENSUS, 1_792_411_200, MAX_TARGET])));
|
||||
assert.equal(networkProfile(undefined), 'testnet');
|
||||
assert.equal(networkProfile(undefined), 'mainnet');
|
||||
assert.equal(networkProfile('testnet'), 'testnet');
|
||||
assert.throws(() => networkProfile('production')); assert.throws(() => networkProfile(''));
|
||||
});
|
||||
test('mainnet candidate has distinct deterministic genesis, signatures and strict transaction schemas', () => {
|
||||
@@ -37,7 +38,7 @@ test('mainnet candidate has distinct deterministic genesis, signatures and stric
|
||||
});
|
||||
test('mainnet startup is blocked before resources open, while manifest inspection needs no launch approval', () => {
|
||||
const args = ['--experimental-strip-types', 'server/index.ts'];
|
||||
const env = { ...process.env, WIKIDECK_NETWORK: 'mainnet', WIKIDECK_MAINNET_LAUNCH: '', WIKIDECK_MAINNET_REVIEW_ACK: '' };
|
||||
const env = { ...process.env, WIKIDECK_NETWORK: 'mainnet', WIKIDECK_GENESIS_TIME: '1798761600', WIKIDECK_MAINNET_LAUNCH: '', WIKIDECK_MAINNET_RISK_ACK: '' };
|
||||
const result = spawnSync(process.execPath, args, { env, encoding: 'utf8', timeout: 30000 });
|
||||
assert.notEqual(result.status, 0); assert.match(result.stderr, /launch-gated/);
|
||||
const manifest = JSON.parse(execFileSync(process.execPath, [...args, '--network-manifest'], { env, encoding: 'utf8' }));
|
||||
@@ -48,10 +49,11 @@ test('mainnet gates require genesis time, chain-specific approval, HTTPS, and in
|
||||
const result = JSON.parse(script(`
|
||||
import { assertLaunchAllowed } from './server/launch.ts';
|
||||
import { CHAIN_ID, GENESIS_TIME } from './server/testnet/consensus.ts';
|
||||
const env={WIKIDECK_MAINNET_LAUNCH:CHAIN_ID,WIKIDECK_MAINNET_REVIEW_ACK:'independent-review-complete',
|
||||
const env={WIKIDECK_MAINNET_LAUNCH:CHAIN_ID,WIKIDECK_MAINNET_RISK_ACK:'operator-accepts-unaudited-risk',
|
||||
PUBLIC_ORIGIN:'https://wikideck.example',WIKIDECK_PEERS:'https://peer-a.example,https://peer-b.example'};
|
||||
const results=[];
|
||||
for(const [changes,now] of [[{},0],[{WIKIDECK_MAINNET_LAUNCH:'wrong'},Infinity],
|
||||
[{WIKIDECK_MAINNET_RISK_ACK:'not-accepted'},Infinity],
|
||||
[{PUBLIC_ORIGIN:'http://wikideck.example'},Infinity],[{WIKIDECK_PEERS:''},Infinity],
|
||||
[{WIKIDECK_PEERS:'https://peer-a.example,https://peer-a.example/'},Infinity],
|
||||
[{WIKIDECK_ALLOW_PRIVATE_PEERS:'1'},Infinity],[{},Infinity]]) {
|
||||
@@ -59,7 +61,7 @@ test('mainnet gates require genesis time, chain-specific approval, HTTPS, and in
|
||||
}
|
||||
console.log(JSON.stringify(results));
|
||||
`));
|
||||
assert.deepEqual(result, [false,false,false,false,false,false,true]);
|
||||
assert.deepEqual(result, [false,false,false,false,false,false,false,true]);
|
||||
});
|
||||
test('mainnet ledger uses its isolated folder; testnet backup headers are rejected', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wikideck-network-'));
|
||||
@@ -89,8 +91,8 @@ test('operator preflight is read-only, fails closed, and does not disclose crede
|
||||
const dataDir = join(dir, 'must-not-be-created');
|
||||
const secret = 'test-only-sensitive-value-must-not-appear';
|
||||
const result = spawnSync(process.execPath, ['--experimental-strip-types', 'server/index.ts', '--launch-preflight'], {
|
||||
env: { ...process.env, WIKIDECK_NETWORK: 'mainnet', WIKIDECK_DATA_DIR: dataDir,
|
||||
WIKIDECK_MAINNET_LAUNCH: '', WIKIDECK_MAINNET_REVIEW_ACK: '', PAYMENT_PROVIDER_API_KEY: secret },
|
||||
env: { ...process.env, WIKIDECK_NETWORK: 'mainnet', WIKIDECK_GENESIS_TIME: '1798761600', WIKIDECK_DATA_DIR: dataDir,
|
||||
WIKIDECK_MAINNET_LAUNCH: '', WIKIDECK_MAINNET_RISK_ACK: '', PAYMENT_PROVIDER_API_KEY: secret },
|
||||
encoding: 'utf8', timeout: 30000,
|
||||
});
|
||||
assert.equal(result.status, 1);
|
||||
|
||||
@@ -2,8 +2,9 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { StateClient } from '../shared/rpc-client.ts';
|
||||
import { NETWORK, type TestnetBlock } from '../shared/testnet.ts';
|
||||
import { expectedChainIdentity } from '../shared/chain-identity.ts';
|
||||
|
||||
const genesis='0'.repeat(64),chainId='f'.repeat(64);
|
||||
const { genesis, chainId } = await expectedChainIdentity();
|
||||
const block=(height:number,previousHash:string):TestnetBlock=>({ network:NETWORK,chainId,height,
|
||||
hash:height.toString(16).padStart(64,'0'),previousHash,transactions:[] }) as unknown as TestnetBlock;
|
||||
test('default browser-style fetch receives the global receiver, not StateClient',async()=>{
|
||||
@@ -18,6 +19,12 @@ test('default browser-style fetch receives the global receiver, not StateClient'
|
||||
try{const state=await new StateClient().load();assert.equal(state.tip,genesis);assert.equal(calls,2);}
|
||||
finally{globalThis.fetch=original;}
|
||||
});
|
||||
test('browser rejects a node with the same network label but a different chain identity', async () => {
|
||||
const client = new StateClient((async () => Response.json({ network: NETWORK, chainId: 'f'.repeat(64),
|
||||
genesis: '0'.repeat(64), tip: '0'.repeat(64), height: 0, revision: 0,
|
||||
cumulativeWork: '0', pendingTransfers: [], peers: 0, algorithm: 'RandomX', maturity: 2 })) as typeof fetch);
|
||||
await assert.rejects(client.load(), /Incompatible node summary/);
|
||||
});
|
||||
function fixture(count:number){
|
||||
const blocks:TestnetBlock[]=[];for(let i=0;i<count;i++)blocks.push(block(i+1,blocks.at(-1)?.hash??genesis));
|
||||
const calls:string[]=[];
|
||||
|
||||
@@ -11,7 +11,7 @@ import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { Ledger } from '../server/testnet/ledger.ts';
|
||||
import { PeerNetwork } from '../server/testnet/peers.ts';
|
||||
import { CHAIN_ID, GENESIS_HASH, GENESIS_TIME, MAX_TARGET, address, makeTemplate, miningBlob,
|
||||
mintId, sha256, stateRoot, transferId, transition, workFor } from '../server/testnet/consensus.ts';
|
||||
mintId, sha256, stateRoot, transferId, transition, validateBlock, workFor } from '../server/testnet/consensus.ts';
|
||||
import { NETWORK, CONSENSUS, blockClaimMessage, nftTransferMessage, testnetOwnerOf, transactionCardIds,
|
||||
nftTradeMessage, type MiningTemplate, type NftState, type NftStateLayer, type NftStateView,
|
||||
type NftTrade, type NftTransfer, type TestnetBlock } from '../shared/testnet.ts';
|
||||
@@ -152,6 +152,51 @@ test('timestamp attack boundaries reject at median and beyond future limit befor
|
||||
assert.equal(engine.calls,calls);
|
||||
});
|
||||
|
||||
test('historical replay survives a local clock rollback after accepting the future-skew boundary',async t=>{
|
||||
const dir=await mkdtemp(join(tmpdir(),'wikideck-clock-replay-'));
|
||||
const admissionTime=GENESIS_TIME+CONSENSUS.targetSeconds*20;
|
||||
const powEngine=new FakePow();
|
||||
const ledger=await Ledger.open(dir,powEngine,()=>admissionTime);
|
||||
const template=makeTemplate([],{},alice.owner,alice.publicKey,[],articleFor([]),statsSnapshot(),
|
||||
admissionTime+CONSENSUS.maxFutureSeconds);
|
||||
const block=await mine(template);
|
||||
assert.equal(block.createdAt,admissionTime+CONSENSUS.maxFutureSeconds);
|
||||
assert.deepEqual(await ledger.submit(block),{accepted:true,reorg:false});
|
||||
const accepted=ledger.snapshot();ledger.close();
|
||||
const restarted=await Ledger.open(dir,new FakePow(),()=>admissionTime-CONSENSUS.maxFutureSeconds-1);
|
||||
t.after(async()=>{restarted.close();await rm(dir,{recursive:true,force:true});});
|
||||
assert.deepEqual(restarted.snapshot(),accepted);
|
||||
});
|
||||
|
||||
test('legacy JSON import cannot seed future-dated blocks, even after restart',async t=>{
|
||||
const dir=await mkdtemp(join(tmpdir(),'wikideck-future-legacy-'));
|
||||
const admissionTime=GENESIS_TIME+CONSENSUS.targetSeconds*20;
|
||||
const networkDir=join(dir,'testnet');await mkdir(networkDir,{recursive:true});
|
||||
const template=makeTemplate([],{},alice.owner,alice.publicKey,[],articleFor([]),statsSnapshot(),
|
||||
admissionTime+CONSENSUS.maxFutureSeconds+1);
|
||||
const futureBlock=await mine(template);
|
||||
await writeFile(join(networkDir,'ledger.json'),JSON.stringify({version:1,network:NETWORK,chainId:CHAIN_ID,
|
||||
blocks:[futureBlock],pendingTransfers:[]}));
|
||||
for(let attempt=0;attempt<2;attempt++) {
|
||||
await assert.rejects(Ledger.open(dir,new FakePow(),()=>admissionTime),/timestamp/);
|
||||
}
|
||||
t.after(async()=>{await rm(dir,{recursive:true,force:true});});
|
||||
});
|
||||
|
||||
test('invalid claimed RandomX proof is rejected before full NFT-state enumeration',async()=>{
|
||||
const template=makeTemplate([],{},alice.owner,alice.publicKey,[],articleFor([]),statsSnapshot());
|
||||
const badHeader={...template.header,stateRoot:'f'.repeat(64)};
|
||||
const blob=miningBlob(badHeader),hash=sha256(blob);
|
||||
const candidate:TestnetBlock={...badHeader,hash,tokenId:mintId(badHeader),powHash:'0'.repeat(64),
|
||||
signature:alice.sign(blockClaimMessage(hash)),transactions:[]};
|
||||
let enumerations=0,hashCalls=0;
|
||||
const parentState=new Proxy(Object.create(null) as NftState,{ownKeys(target){enumerations++;return Reflect.ownKeys(target);}});
|
||||
const verifier:PowEngine={async hash(){hashCalls++;return 'f'.repeat(64);},close(){}};
|
||||
await assert.rejects(validateBlock(candidate,[],parentState,verifier),/Invalid RandomX proof/);
|
||||
assert.equal(hashCalls,1);
|
||||
assert.equal(enumerations,0,'invalid work must be rejected before materializing the full NFT state');
|
||||
});
|
||||
|
||||
test('DAG retains equal-work branches, serializes duplicates and selects strictly greater cumulative work', async t => {
|
||||
const { ledger, dir } = await fixture(t);
|
||||
t.mock.method(Date, 'now', () => (GENESIS_TIME + CONSENSUS.targetSeconds * 20) * 1000);
|
||||
@@ -182,6 +227,25 @@ test('DAG retains equal-work branches, serializes duplicates and selects strictl
|
||||
assert.deepEqual(restart.allBlocks, stored);
|
||||
});
|
||||
|
||||
test('a deeper competing fork disconnects the old chain only after cumulative work wins',async t=>{
|
||||
const {ledger}=await fixture(t);const original=branch(),competitor=branch();
|
||||
t.mock.method(Date,'now',()=>(GENESIS_TIME+CONSENSUS.targetSeconds*100)*1000);
|
||||
for(let i=0;i<30;i++)await ledger.submit(await extend(original,alice,[],CONSENSUS.targetSeconds));
|
||||
assert.equal(ledger.snapshot().tip,original.blocks.at(-1)!.hash);
|
||||
for(let i=0;i<15;i++){
|
||||
const result=await ledger.submit(await extend(competitor,bob,[],1));
|
||||
assert.equal(result.reorg,false);
|
||||
assert.equal(ledger.snapshot().tip,original.blocks.at(-1)!.hash);
|
||||
}
|
||||
assert.equal(competitor.blocks.at(-1)!.height,15);
|
||||
assert.notEqual(competitor.blocks.at(-1)!.target,MAX_TARGET);
|
||||
const winning=await ledger.submit(await extend(competitor,bob,[],1));
|
||||
assert.deepEqual(winning,{accepted:true,reorg:true});
|
||||
assert.ok(ledger.snapshot().cumulativeWork===competitor.blocks.reduce((sum,block)=>sum+workFor(block.target),0n).toString());
|
||||
assert.deepEqual(ledger.snapshot().blocks,competitor.blocks);
|
||||
assert.equal(ledger.size,46,'both branches remain available in the DAG');
|
||||
});
|
||||
|
||||
test('equal-work ties preserve the current tip across restart and only extension triggers reorg', async t => {
|
||||
const { dir, ledger } = await fixture(t);
|
||||
const a = branch(), b = branch();
|
||||
@@ -473,7 +537,7 @@ test('SQLite startup rechecks every proof and fails closed on database corruptio
|
||||
tampered.prepare('UPDATE blocks SET block_json = ? WHERE hash = ?')
|
||||
.run(JSON.stringify({ ...originalBlock, stateRoot: '00'.repeat(32) }), row.hash);
|
||||
tampered.close();
|
||||
await assert.rejects(Ledger.open(dir, new FakePow()), /state commitment/);
|
||||
await assert.rejects(Ledger.open(dir, new FakePow()), /block ID/);
|
||||
const restoreTampered = new DatabaseSync(file);
|
||||
restoreTampered.prepare('UPDATE blocks SET block_json = ? WHERE hash = ?').run(row.block_json, row.hash);
|
||||
restoreTampered.prepare("UPDATE metadata SET value = 'wrong' WHERE key = 'network'").run();
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync,
|
||||
randomBytes, sign as nativeSign, verify as nativeVerify, webcrypto } from 'node:crypto';
|
||||
import { encryptWallet, decryptWallet, type WalletBackup } from '../shared/wallet-backup.ts';
|
||||
import { expectedChainIdentity } from '../shared/chain-identity.ts';
|
||||
import { getWallet, exportWallet, restoreWallet, protectWallet, unlockWallet, walletStatus, lockWallet, sign, touchWallet, WALLET_IDLE_MS } from '../src/wallet.ts';
|
||||
|
||||
const subtle = webcrypto.subtle as SubtleCrypto;
|
||||
@@ -31,6 +32,7 @@ async function seal(pkcs8: ArrayBuffer, header: WalletBackup): Promise<WalletBac
|
||||
const key = await subtle.deriveKey({ name: 'PBKDF2', hash: 'SHA-256', iterations: 600000,
|
||||
salt: Buffer.from(header.salt, 'base64') }, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
|
||||
const aad = new TextEncoder().encode(JSON.stringify([header.format, header.version, header.network,
|
||||
...(header.version === 2 ? [header.chainId] : []),
|
||||
header.kdf, header.iterations, header.cipher, header.salt, header.iv, header.publicKey]));
|
||||
return { ...header, ciphertext: encode(await subtle.encrypt({ name: 'AES-GCM', tagLength: 128,
|
||||
iv: Buffer.from(header.iv, 'base64'), additionalData: aad }, key, pkcs8)) };
|
||||
@@ -54,7 +56,9 @@ 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-6');
|
||||
assert.equal(backup.version, 2);
|
||||
assert.equal(backup.network, 'wikideck-randomx-testnet-6');
|
||||
assert.equal(backup.chainId, (await expectedChainIdentity()).chainId);
|
||||
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);
|
||||
@@ -64,6 +68,17 @@ test('Node WebCrypto backup roundtrip preserves SPKI SHA256 identity and signing
|
||||
assert.notEqual(again.ciphertext, backup.ciphertext);
|
||||
});
|
||||
|
||||
test('chain-bound v2 rejects a different chain and original testnet v1 backups remain recoverable', async () => {
|
||||
const { backup, privateKey } = await fixture;
|
||||
assert.ok(backup.version === 2);
|
||||
await assert.rejects(decryptWallet({ ...backup, chainId: '0'.repeat(64) }, password), /different chain/);
|
||||
const { chainId: _chainId, ...legacyFields } = backup;
|
||||
const legacy: WalletBackup = { ...legacyFields, version: 1, ciphertext: '' };
|
||||
const legacyBackup = await seal(await subtle.exportKey('pkcs8', privateKey), legacy);
|
||||
assert.equal((await decryptWallet(legacyBackup, password)).address,
|
||||
(await decryptWallet(backup, password)).address);
|
||||
});
|
||||
|
||||
test('native Node miner PKCS8 imports into WebCrypto without changing address or signatures', async () => {
|
||||
const native = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const pkcs8 = native.privateKey.export({ format: 'der', type: 'pkcs8' });
|
||||
@@ -117,7 +132,7 @@ test('strict format, bounded fields, canonical base64, and fixed KDF work reject
|
||||
await assert.rejects(decryptWallet({ ...backup, iterations }, password), /Unsupported/);
|
||||
}
|
||||
const invalid: unknown[] = [null, undefined, [], 'x'.repeat(32769), {},
|
||||
{ ...backup, extra: 'unexpected' }, { ...backup, version: 2 },
|
||||
{ ...backup, extra: 'unexpected' }, { ...backup, version: 3 },
|
||||
{ ...backup, network: 'wikideck-demo' }, { ...backup, format: 'other' },
|
||||
{ ...backup, kdf: 'scrypt' }, { ...backup, cipher: 'AES-CBC' },
|
||||
{ ...backup, salt: 'AA==' }, { ...backup, iv: null },
|
||||
@@ -152,9 +167,10 @@ test('wallet persistence commits before restore resolves, isolates demo DB, retr
|
||||
let commits = 0;
|
||||
let beforeWrite: (()=>void)|undefined;
|
||||
const opened: string[] = [];
|
||||
const dbName = `wikideck-wallet-${(await expectedChainIdentity()).chainId}-v2`;
|
||||
const installNavigator = (locks: boolean) => Object.defineProperty(globalThis, 'navigator', { configurable: true,
|
||||
value: locks ? { locks: { request: async (name: string, callback: () => Promise<unknown>) => {
|
||||
assert.equal(name, 'wikideck-testnet-wallet-v2:identity'); lockCalls++; return callback();
|
||||
assert.equal(name, `${dbName}:identity`); lockCalls++; return callback();
|
||||
} } } : {} });
|
||||
Object.defineProperty(globalThis, 'indexedDB', { configurable: true, value: {
|
||||
open(name: string) {
|
||||
@@ -239,7 +255,7 @@ test('wallet persistence commits before restore resolves, isolates demo DB, retr
|
||||
assert.equal(recovered.address, expected.address);
|
||||
assert.equal((await getWallet())?.address, expected.address);
|
||||
assert.ok(lockCalls >= 2);
|
||||
assert.ok(opened.every(name => name === 'wikideck-testnet-wallet-v2'));
|
||||
assert.ok(opened.every(name => name === dbName));
|
||||
permanentFailure = true;
|
||||
const preserved=JSON.stringify(stored);
|
||||
await assert.rejects(restoreWallet(exported,password,password,true), { name: 'QuotaExceededError' });
|
||||
|
||||
Reference in New Issue
Block a user