This commit is contained in:
2026-10-20 03:14:08 -04:00
parent b1d2b5c10e
commit 9850fd8554
28 changed files with 1256 additions and 26 deletions
+8
View File
@@ -12,8 +12,16 @@ WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG WIKIDECK_NETWORK=testnet
ENV VITE_WIKIDECK_NETWORK=$WIKIDECK_NETWORK
RUN npm run build
FROM build AS test
RUN apk add --no-cache libstdc++
COPY --from=randomx-build /native/build/wikideck-randomx /app/native/wikideck-randomx
ENV RANDOMX_BINARY=/app/native/wikideck-randomx
CMD ["npm", "test"]
FROM node:24-alpine AS production-deps
WORKDIR /app
COPY package.json package-lock.json ./
+9 -2
View File
@@ -1,6 +1,6 @@
# Wikideck
Wikideck is an experimental, self-hosted RandomX testnet where each mined block creates a native NFT from a random English Wikipedia article. A page may be minted more than once; each NFT commits its own article and mint-time statistics snapshot, including a Wikimedia-hosted picture. It includes a React interface, a validating node, local P-256 wallets, transfers, and current off-chain Wikipedia statistics.
Wikideck is an experimental, self-hosted RandomX network where each mined block creates a native NFT from a random English Wikipedia article. A page may be minted more than once; each NFT commits its own article and mint-time statistics snapshot, including a Wikimedia-hosted picture. It includes a React interface, a validating node, local P-256 wallets, transfers, atomic trades, a mock-payment marketplace, and current off-chain Wikipedia statistics. The default network is testnet; a separate mainnet candidate is implemented but launch-gated.
**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.
@@ -73,11 +73,16 @@ Light mode needs a 256 MiB cache plus scratchpad and process overhead. `--fast`
- **My collection** shows NFTs owned by the active local wallet on the connected node's canonical chain.
- **Block explorer** distinguishes block IDs, NFT token IDs, and RandomX proof hashes. A submitted transfer is pending until a block confirms it. Reorganizations can change canonical ownership and confirmations.
- **Trade decks** lets you look up another public wallet by receive address, select up to eight cards on each side, prepare a signed offer, and accept another player's offer. Offers are shared as JSON for the other player to review; they are not submitted until accepted. The two parties sign the same card list, and validators apply the whole bundle atomically or reject it entirely. Offers are not reservations: before acceptance the app re-checks ownership, maturity, sequences, and pending activity; stale offers must be recreated.
- **Marketplace** supports persistent fixed-price listings, timed auctions, USD prices and mock XMR checkout. Seller fees include configurable hosting (default 2.5%) plus a hardcoded additional 1% to a fixed XMR recipient (3.5% total by default). Buyers simulate payment, sellers sign real card delivery, and simulated seller-net/additional-fee payouts happen automatically after canonical delivery confirmations. **Never send real XMR: no payable checkout address or live processor is enabled.** Listings/orders are local to the selected marketplace node, not gossiped; listing does not lock a card on-chain. See [marketplace setup, fee recipient, rules and SHKeeper boundary](docs/marketplace.md).
- Each minted card shows current Wikipedia statistics alongside its immutable at-mint snapshot: pageviews, article size, language links, revision, and derived scores. Current values may change; at-mint values are committed to the block. Missing pageview data stays unavailable, not zero.
- The appearance selector offers Catppuccin Mocha, Macchiato, Frappé, and Latte themes.
The browser interface trusts the connected node's reported state. It does not independently verify the chain or provide an SPV client. Transfers are irreversible once confirmed; check the recipient address carefully.
## Mainnet candidate · launch gated
A separate mainnet profile has its own network/genesis identity, ledger and market directories, browser wallet, backup checks, and opt-in [Compose definition](compose.mainnet.yaml). It preserves the existing testnet chain and does not migrate testnet cards. **No production network has been launched or certified by this work.** Startup requires explicit chain-specific approval, an operator review attestation, the candidate genesis time, HTTPS origins and multiple configured peers. Real payments remain blocked even then. See [the candidate launch gates and production checklist](docs/mainnet-launch.md).
## Tests and development checks
```sh
@@ -88,6 +93,8 @@ npm run test:native
`npm test` runs the application test suite, including fast test-only proof tests. `npm run test:native` runs the real upstream RandomX known-vector test and requires a native build configured with tests enabled; the documented `npm run native:configure` command enables them. The Docker build also builds RandomX and runs its native test.
The Dockerfile also provides a **test** target containing Node 24, development dependencies, the tests, and the compiled Linux-native RandomX binary. Build that target and run the resulting image to exercise the full native HTTP suite without relying on a host-platform binary. TypeScript compilation enforces erasable syntax because the production runtime strips types directly.
To explicitly test propagation and transfer behavior across the three local Docker nodes, first build the host native engine and start the three-node testnet, then run:
```sh
@@ -98,7 +105,7 @@ This mines three real blocks using disposable identities. It changes the testnet
## Network and safety limits
Nodes use only explicitly configured peers; there is no discovery service or public bootstrap network. The peer, storage, and HTTP configuration is documented in [docs/testnet-consensus.md](docs/testnet-consensus.md). The implementation is experimental: it has no fees or economic security, and remains vulnerable to resource exhaustion, isolation/eclipsing, censorship, majority-hashpower attacks, and reorganizations. 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.
Nodes use only explicitly configured peers; there is no discovery service or public bootstrap network. The peer, storage, and HTTP configuration is documented in [docs/testnet-consensus.md](docs/testnet-consensus.md). The implementation is experimental: it has no consensus transaction fees or established economic security (market fees are off-chain simulations), and remains vulnerable to resource exhaustion, isolation/eclipsing, censorship, majority-hashpower attacks, and reorganizations. The ledger now uses transactional SQLite/WAL storage and sparse per-block NFT-state deltas, but nodes still retain DAG block records in memory, rebuild state roots over the full NFT set, and revalidate all proofs at startup. It is not yet an archival-scale production design. HTTPS, container hardening, and passing tests do not constitute a security audit.
Before considering any broader deployment, the protocol and cryptography would need independent audits, adversarial network testing, hardened abuse controls, and tested recovery procedures. Do not expose a public node or use valuable assets on this experimental network.
+38
View File
@@ -0,0 +1,38 @@
# Gated candidate profile, NOT an announcement of a production-ready or launched network.
# Keep payments mocked. Review docs/mainnet-launch.md before attempting activation.
services:
mainnet:
profiles: [mainnet]
build:
context: .
target: runtime
args:
WIKIDECK_NETWORK: mainnet
ports:
- "127.0.0.1:${WIKIDECK_MAINNET_PORT:-3201}:3001"
environment:
WIKIDECK_NETWORK: mainnet
WIKIDECK_PAYMENT_MODE: mock
PUBLIC_ORIGIN: "${WIKIDECK_MAINNET_ORIGIN:-}"
WIKIDECK_PEERS: "${WIKIDECK_MAINNET_PEERS:-}"
WIKIDECK_MAINNET_LAUNCH: "${WIKIDECK_MAINNET_LAUNCH:-}"
WIKIDECK_MAINNET_REVIEW_ACK: "${WIKIDECK_MAINNET_REVIEW_ACK:-}"
WIKIDECK_ALLOW_PRIVATE_PEERS: "0"
WIKIDECK_MARKET_FEE_BPS: "${WIKIDECK_MARKET_FEE_BPS:-250}"
WIKIDECK_MARKET_CONFIRMATIONS: "${WIKIDECK_MARKET_CONFIRMATIONS:-10}"
volumes:
- mainnet-candidate-data:/app/data
init: true
restart: "no"
read_only: true
tmpfs:
- /tmp:size=32m,mode=1777,noexec,nosuid
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
pids_limit: 128
mem_limit: 1g
cpus: 1.0
stop_grace_period: 30s
volumes:
mainnet-candidate-data:
+9
View File
@@ -1,10 +1,17 @@
# Three independent validators, separate volumes, no miner auto-starts.
x-market-environment: &market-environment
WIKIDECK_NETWORK: testnet
WIKIDECK_PAYMENT_MODE: mock
WIKIDECK_MARKET_FEE_BPS: "${WIKIDECK_MARKET_FEE_BPS:-250}"
WIKIDECK_MARKET_CONFIRMATIONS: "${WIKIDECK_MARKET_CONFIRMATIONS:-10}"
WIKIDECK_MOCK_USD_CENTS_PER_XMR: "${WIKIDECK_MOCK_USD_CENTS_PER_XMR:-15000}"
services:
node-a:
image: wikideck:testnet
build: { context: ., target: runtime }
ports: ["127.0.0.1:3101:3001"]
environment:
<<: *market-environment
PUBLIC_ORIGIN: http://127.0.0.1:3101
WIKIDECK_PEERS: http://node-b:3001,http://node-c:3001
WIKIDECK_ALLOW_PRIVATE_PEERS: "1"
@@ -23,6 +30,7 @@ services:
build: { context: ., target: runtime }
ports: ["127.0.0.1:3102:3001"]
environment:
<<: *market-environment
PUBLIC_ORIGIN: http://127.0.0.1:3102
WIKIDECK_PEERS: http://node-a:3001,http://node-c:3001
WIKIDECK_ALLOW_PRIVATE_PEERS: "1"
@@ -41,6 +49,7 @@ services:
build: { context: ., target: runtime }
ports: ["127.0.0.1:3103:3001"]
environment:
<<: *market-environment
PUBLIC_ORIGIN: http://127.0.0.1:3103
WIKIDECK_PEERS: http://node-a:3001,http://node-b:3001
WIKIDECK_ALLOW_PRIVATE_PEERS: "1"
+5
View File
@@ -7,9 +7,14 @@ services:
# Keep the prototype private; listen on all interfaces only inside the container.
- "127.0.0.1:${WIKIDECK_PORT:-3001}:3001"
environment:
WIKIDECK_NETWORK: testnet
PUBLIC_ORIGIN: "${PUBLIC_ORIGIN:-http://127.0.0.1:${WIKIDECK_PORT:-3001}}"
WIKIDECK_PEERS: "${WIKIDECK_PEERS:-}"
WIKIDECK_ALLOW_PRIVATE_PEERS: "${WIKIDECK_ALLOW_PRIVATE_PEERS:-0}"
WIKIDECK_PAYMENT_MODE: mock
WIKIDECK_MARKET_FEE_BPS: "${WIKIDECK_MARKET_FEE_BPS:-250}"
WIKIDECK_MARKET_CONFIRMATIONS: "${WIKIDECK_MARKET_CONFIRMATIONS:-10}"
WIKIDECK_MOCK_USD_CENTS_PER_XMR: "${WIKIDECK_MOCK_USD_CENTS_PER_XMR:-15000}"
volumes:
- wikideck-data:/app/data
init: true
+55
View File
@@ -0,0 +1,55 @@
# Gated mainnet candidate
This repository now has a **separate mainnet candidate profile**, not an audited or launched production network. Shipping code does not establish independent miners, economic security, an operational public network, or a safe real-money market. **Payments remain mocked on every profile.**
## What is implemented
| Property | Testnet default | Mainnet candidate |
| --- | --- | --- |
| Selection | `WIKIDECK_NETWORK=testnet` (or unset) | `WIKIDECK_NETWORK=mainnet` |
| Network ID | `wikideck-randomx-testnet-6` | `wikideck-randomx-mainnet-1` |
| Protocol | Existing version 6 | Version 6 under a distinct chain identity |
| Genesis timestamp | Unchanged | 2027-01-01 00:00 UTC, **candidate only, not a promised launch** |
| Ledger/market subdirectory | testnet | mainnet |
| Browser wallet | Existing testnet store, unchanged | Separate mainnet store |
| Payment mode | Mock only | Mock only |
Both profiles use real RandomX validation, cumulative-work branch selection, the 15-minute target, mint maturity, signed NFT transfers, atomic card trades, and SQLite persistence. The mainnet candidate has no premine, token migration, hidden checkpoint, or fake-PoW option. Changing the network identifier and candidate genesis derives distinct chain/genesis IDs, RandomX initial keys, signature domains, and consensus manifest checks. Existing testnet values and data remain unchanged.
**Testnet cards and backups do not migrate.** Browser storage is separate, backup network headers are authenticated and checked, peers reject mismatched manifests, and transactions are checked against the active chain. The selected profile is pinned at startup/browser build, never silently adopted from an RPC response.
## Inspect without launching
The node entry point accepts `--network-manifest`. With `WIKIDECK_NETWORK=mainnet` set in the process environment, this prints the candidate network/chain/genesis/timestamp and exits **before** opening databases, starting a RandomX engine, listening, or contacting peers. It does not require activation credentials or approval and does not launch anything.
Browser builds select `VITE_WIKIDECK_NETWORK=mainnet`; server/miner processes select `WIKIDECK_NETWORK=mainnet`. These must match. Docker's `WIKIDECK_NETWORK` **build argument** sets the browser profile; a runtime variable cannot alter an already compiled UI. A mismatched UI rejects the node network instead of signing for it. The mainnet Compose definition supplies both the build and runtime selections.
## Explicit launch gates
[compose.mainnet.yaml](../compose.mainnet.yaml) is an opt-in Compose profile, uses a separate named volume, binds only loopback for an external HTTPS reverse proxy, and does **not** auto-restart a failed candidate. It has no default peers, approval, or public origin. It is not started by the default Compose definitions. **No mainnet nodes have been started by this change.**
Starting the candidate node or native miner requires all of:
1. `WIKIDECK_MAINNET_LAUNCH` equals the exact candidate chain ID printed by the manifest command.
2. `WIKIDECK_MAINNET_REVIEW_ACK=independent-review-complete` records the operator's review attestation. This is **not cryptographic proof of an audit**, and the manifest still honestly reports `audited: false`.
3. The candidate genesis timestamp has arrived. If review changes the timestamp or consensus manifest before launch, the chain ID also changes and the old approval is invalidated. Freeze and publish the final manifest only after review.
4. An HTTPS `PUBLIC_ORIGIN`, at least two distinct configured HTTPS peer origins, and no private-peer bypass. Configuration count cannot prove the peers are online, independent, or sufficiently distributed; verify that operationally. Existing peer DNS/origin controls still apply.
5. `WIKIDECK_PAYMENT_MODE=mock`. Live modes fail closed independently of launch approval.
Unknown profile names fail closed. A direct import of the HTTP entry point is also guarded. Library-level manifest/consensus/storage inspection remains possible for tests and review; activation gates are accident prevention, not a security boundary against someone who edits the source.
## Required before an actual production launch
- **Independent review:** protocol/cryptography, signatures and wallet lifecycle, RandomX integration, difficulty/time/retarget attacks, state commitments, fork choice, network message validation and denial-of-service limits. Publish findings, fixes and a versioned final manifest.
- **Security model:** quantify hashpower, chain-rewrite cost and majority attacks. The inherited easy initial target and ten-block retarget are **not production-calibrated**. Choose reviewed launch difficulty/retarget behavior and test start-up/stall/recovery scenarios. Independent mining participation and sufficient hashpower are not provided by running multiple containers on one host.
- **Adversarial rehearsal:** sustained multi-host simulations, partitions/eclipses, competing branches, deep reorganizations, clock skew, bad peers, load/mempool saturation, corrupted state and restart recovery. Test the candidate rules with no valuable assets.
- **Operations:** independently administered public HTTPS nodes and miners across failure domains, pinned release artifacts and dependencies, firewall/reverse proxy/rate-limit policies, bounded storage and sync strategy, metrics, alerts, rotation and emergency-response procedures.
- **Recovery:** verified offline wallet recovery, consistent ledger/market snapshots, full replay/revalidation, documented incident and fork handling. No silent ledger reset or reuse of testnet data.
- **Launch governance:** select the actual genesis date, freeze the reviewed identity/configuration, publish binaries and configuration hashes, agree peer/miner readiness and upgrade policy, and explicitly authorize launch. No operator should attest review merely to bypass the gates.
- **Economics and legal review:** no monetary value or security is promised. NFT rights do not imply article/image copyright ownership. A real-money custodial market requires a separate risk, custody and payment review.
## Still not production-ready
This remains experimental software. Nodes retain the DAG and overlays in memory, rebuild state roots over the full NFT set, and revalidate proofs at startup. The HTTP UI trusts its connected node rather than independently validating the chain. There is no automatic peer discovery, established bootstrap infrastructure, formal finality, independent audit, built-in anti-shill mechanism or production abuse system. The marketplace is node-local, not consensus escrow. Mainnet selection does not remove any of these limitations.
See [marketplace sandbox and future SHKeeper boundary](marketplace.md) for the mock settlement model and the additional work required before any real XMR can be accepted or paid out.
+86
View File
@@ -0,0 +1,86 @@
# Marketplace sandbox
Open **Marketplace** in the interface. Prices are **USD**; checkout amounts, fees, refunds, and automatic seller payouts are simulated in **XMR**. There is no payable address, payment processor connection, or movement of real Monero in this release. The displayed exchange rate is a deterministic simulation fixture, not a market quote. **Signing delivery transfers the actual card on the selected chain**, even though payment is mocked.
## Try a sale
1. Mine enough blocks to own a mature NFT. Open Marketplace → **Sell a card**.
2. Choose a card, a fixed price or timed auction, and review the seller fee before signing.
3. In a different browser profile/wallet on the **same marketplace node**, buy the fixed-price listing, or bid and wait for the auction to close.
4. Open **Your activity** as the buyer, review the order, and choose **Simulate XMR payment**.
5. Return as the seller, review the buyer/card/proceeds, and **Sign and confirm** delivery. This creates an ordinary signed NFT transfer; mine it into the canonical chain.
6. After the order's required confirmation depth, reconciliation records automatic **mock-paid** seller and additional-fee payouts. The configured market fee is retained in the simulated accounting. No operator payout click is needed.
Wallet backup/recovery remains local; never send private keys or backup passwords to the node. USD listings do not turn the NFT wallet into a Monero wallet.
## Sale rules
- **Fixed price:** stays listed until sold, cancelled, or invalidated by changing ownership/sequence. Checkout exclusively reserves it for 30 minutes; an unpaid reservation expires and the listing reopens if still valid.
- **Auction:** one minute to 30 days. Bids must meet the start price and exceed the highest bid by at least one cent. Bids are signed but **unfunded**. Sellers cannot bid on their own auction or cancel after its first bid. This does not prevent alternate-wallet shill bidding or nonpayment.
- No bids are accepted at or after the deadline. If there are bids, the listing remains available only for winner checkout until **30 minutes after auction close**, not 30 minutes after checkout. With no bids it expires at closing. An unpaid winner causes expiry; there is no automatic runner-up sale. The seller may relist.
- Seller delivery is due **48 hours after mock payment**. Failed delivery can enter **refund-required**. The buyer can simulate a full refund only if the sale transfer is neither pending nor canonical. Pending/late/ambiguous delivery requires review, not an automatic refund that might also release the card.
- Listings are **not consensus escrow**: they do not lock cards on-chain. A seller can transfer or trade elsewhere; availability is rechecked when listing, reserving, paying, and reconciling delivery. Payment and NFT delivery are **not an atomic cross-chain exchange**.
- Fee basis points are fixed when the seller lists and explicitly reviewed at checkout. The XMR quote, fee, net, and delivery-confirmation requirement are pinned to the order. Changing configuration does not reprice an existing order.
- Settlement matches the **exact canonical transfer identity**, not merely the buyer's current holdings. Unconfirmed submission never counts as delivery. A buyer may transfer onward after delivery without confusing its identity.
- If a reorganization affects an already settled order, it enters **review** permanently and does not auto-pay again. The operator must investigate; this sandbox has no administrative recovery/payment override endpoint.
## Configuration
| Variable | Default | Meaning |
| --- | --- | --- |
| `WIKIDECK_PAYMENT_MODE` | `mock` | Only accepted value. Other values fail startup, including on the mainnet candidate. |
| `WIKIDECK_MARKET_FEE_BPS` | `250` | Seller fee: 250 basis points = 2.5%; integer 0–5000. |
| `WIKIDECK_MARKET_CONFIRMATIONS` | `10` | Canonical delivery confirmations before simulated payout; integer 2–1000. No finite depth guarantees PoW finality. |
| `WIKIDECK_MOCK_USD_CENTS_PER_XMR` | `15000` | Simulation-only conversion fixture: $150/XMR. Not a live price feed. |
The single-node and three-node testnet Compose configurations pass these settings. Choose one market host and use its origin consistently. Node marketplaces have different identities and journals: **listing, bid, checkout, and order state are not gossiped**. NFT transfers/blocks are. Buyers and sellers must use the same market host.
Money is stored as integer USD cents and calculated in integer Monero atomic units (12 decimal places). Gross XMR is rounded up to an atomic unit; each fee is rounded down separately; seller net is exactly gross minus both fees. Maximum listing/bid price is $1,000,000.00.
## Fixed additional 1% fee
Every new marketplace sale (fixed price or winning auction) includes a **hardcoded additional 1% seller fee**, on top of the configurable hosting fee. Both fees are calculated independently on the gross sale proceeds, not compounded. At the default 2.5% hosting fee the combined deduction is **3.5%**. Setting the hosting fee to zero does **not** disable the additional 1%.
The fixed public XMR payout recipient is:
44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv
This is a payout destination, **not a buyer checkout address**. Do not send real XMR to it to pay for a sandbox order. No real transfer is submitted; the journal only records a simulated fee allocation and payout after canonical NFT delivery. Both seller and additional-fee payouts enter review if a settled delivery is reorganized. Refunded or expired sales do not pay either recipient. The fee applies to marketplace sales, not free NFT transfers, card-for-card trades, bids or listing creation, which have no XMR sale proceeds.
The additional fee is rounded down to an atomic unit separately from the hosting fee. Seller net is exactly gross minus **both** fees. For example, a 0.1 XMR sale with 2.5% hosting allocates 0.0025 XMR hosting fee, 0.001 XMR additional fee, and 0.0965 XMR seller net. Rounding below one atomic unit produces zero rather than increasing the charge above 1%.
The recipient, rate and amount are snapshotted into each new order and disclosed in the UI. The rate and recipient are bound to listing/checkout signatures using marketplace command version 2. Neither is environment-configurable or overridable in a request. **Existing reserved/paid/completed orders are not retroactively repriced.** Old open listings without the additional fee are invalidated and must be relisted with fresh seller authorization; their old bids do not carry over. Old version-1 signed requests are rejected; refresh the browser before signing after an upgrade.
## API and storage
- `GET /api/market?offset=0` returns up to 24 public listings, pagination, server clock, fee policy, and market identity.
- `POST /api/market/command` accepts a strict, signed command. Actions: `list`, `bid`, `buy`, `cancel`, `mock-pay`, `deliver`, `refund`, `activity`.
- Every command binds the network, chain ID, **market instance ID**, actor/public key, UUID nonce, short expiry, action, and all terms. Private activity requires an actor signature and includes only orders involving that actor. Signed requests expire within five minutes; keep clocks synchronized.
- Nonce receipts are persisted and replay returns the same result. A different command cannot reuse that nonce. Refresh activity to obtain the latest order state rather than interpreting an old receipt as current state.
- Public listings reveal seller/highest bidder wallet addresses and card holdings. Orders reveal counterparties/amounts only to their participants, but the **market operator and its database backups can see all orders**. This is not private Monero trading.
- The marketplace journal is stored alongside the ledger in the selected network subdirectory, uses SQLite/WAL and full synchronous commits, and binds its metadata to the active chain. Mutations are serialized. There is one active local listing per card, at most 100 active listings per seller, and 10,000 total listings/orders per journal. Reaching a journal cap fails closed; operator archival work is required, not deleting active records.
- HTTP rate limits and bounded mutation queues apply. Read/reconciliation work still scans the bounded journal and chain; this is not a high-volume production market.
## Recovery and operations
Back up the ledger, marketplace SQLite databases and their WAL state consistently, plus the independent wallet backups. Prefer a clean node shutdown before copying the data directory; do not copy a live SQLite file without SQLite's backup facilities. Do not delete the journal to clear a stuck order: this loses reservations, invoice identities and idempotency history.
The service writes delivery intent before submitting to the ledger. A crash between these steps is reconciled by deterministic transfer ID; the seller can resubmit the same delivery if still valid. Background reconciliation runs every 15 seconds, and marketplace commands reconcile before mutation. A closed server does not send payouts or advance its local journal until restarted; deadline enforcement still uses absolute timestamps.
For **review**, preserve backups and inspect canonical blocks, pending transfers, signed order terms and transfer ID. Do not clear a payout marker, auto-refund, or blindly resubmit a payout. This release intentionally has no real-fund recovery controls.
## SHKeeper production boundary
The intended future live processor is **SHKeeper**, using the operator-provided instance at https://shkeeper.eom.dev/. **It is not contacted or configured by this sandbox; there is no live adapter in this release.** Do not supply payment credentials yet. No secret belongs in browser build variables, source, or version-controlled Compose files.
According to [SHKeeper's API documentation](https://shkeeper.io/api/), creating an XMR invoice uses `POST /api/v1/XMR/payment_request` with a USD amount and an API key, which naturally supports USD listings with an XMR checkout quote. Seller payouts use a separate Basic-authenticated XMR payout endpoint. The merchant wallet receives the gross amount; paying the seller net means the operator temporarily **custodies funds** and retains the market fee. Network fees and any processor fees need an explicit, disclosed policy before enabling this path.
Live settlement needs a separate implementation and review, **after** a reviewed production network exists:
- Authoritative server-to-server verification of invoice identity, XMR currency, address, amount, confirmation policy and settled status. Never trust an unsigned callback's `paid` field; treat callbacks only as bounded reconciliation hints.
- Stored quote expiry, payment deadlines and explicit rules for late, partial, duplicate and overpayments, seller refusal/non-delivery, and refunds.
- Validated seller XMR payout destinations, server-only secret storage, fixed HTTPS processor origin, no redirects, bounded timeouts/responses, and least-privilege credentials.
- Durable payout intents and task reconciliation for both seller net and the fixed 1% recipient, including partial success across these separate payouts. The documented payout create endpoint does not expose a merchant idempotency key; a timeout or crash after submission is **ambiguous** and must not trigger blind retry/double payout.
- Reorg holds/reserves, operator reconciliation tools, tested backup/restore, dispute/refund procedures, and applicable custody/payment obligations. Confirmation depth alone does not make a small PoW network safe for real money.
There is deliberately **no environment switch that bypasses these missing protections**.
+5 -1
View File
@@ -79,6 +79,8 @@ Sync polls every **5 seconds**, checks network/chain ID, imports at most **32**
| --- | --- |
| `GET /healthz` | Process health/network, not sync or consensus security. |
| `GET /api/state`, `/api/chain` | Canonical blocks, mined/pending transactions, tip/work and peer count. |
| `GET /api/network` | Selected profile, pinned chain/genesis, candidate launch status and mock-payment boundary. |
| `GET /api/market?offset=0`, `POST /api/market/command` | Node-local mock-payment marketplace; strict signed listing, bid, checkout, delivery and private activity commands. Not consensus escrow. See [marketplace operations](marketplace.md). |
| `GET /api/nfts/:id`, `/api/peers` | Canonical NFT owner/sequence/confirmations, article and at-mint statistics; configured peer status. |
| `GET /api/decks/:address?offset=0&limit=24` | Public canonical deck for a wallet address at one tip; paged, maximum 24 cards per response. This reveals public address holdings. |
| `GET /api/wikipedia/:pageId` | Cached current enrichment for positive English Wikipedia page IDs: categories, views, size, languages and display-only scores. Never a consensus input. |
@@ -89,4 +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 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. Marketplace commands additionally allow 60/minute. There are no consensus fees, peer authentication or economic spam deterrents; market fees are off-chain simulations only. Same-origin browser RPC trusts the node; it does not independently verify these commitments. Deploy no funds: majority-hashpower, eclipse, censorship, reorg and resource-exhaustion risks require independent audits and adversarial testing before any broader launch.
Testnet storage and chain identities above remain unchanged. The [gated mainnet candidate](mainnet-launch.md) selects separate chain/signature domains, storage, lock and wallet identities; it is not a running or certified production network and still rejects real payment modes.
+7 -1
View File
@@ -1,2 +1,8 @@
// A node independently validates RandomX work and follows cumulative-work consensus.
import './testnet/http.ts';
import { assertLaunchAllowed, launchManifest } from './launch.ts';
if (process.argv.includes('--network-manifest')) console.log(JSON.stringify(launchManifest(), null, 2));
else {
assertLaunchAllowed();
await import('./testnet/http.ts');
}
+26
View File
@@ -0,0 +1,26 @@
import { IS_MAINNET, NETWORK, PROFILE } from '../shared/network.ts';
import { CHAIN_ID, GENESIS_HASH, GENESIS_TIME } from './testnet/consensus.ts';
export function launchManifest() {
return { profile: PROFILE, network: NETWORK, chainId: CHAIN_ID, genesis: GENESIS_HASH,
genesisTime: GENESIS_TIME, launchStatus: IS_MAINNET ? 'candidate-gated' : 'testnet',
paymentMode: 'mock', audited: false };
}
/** Operator attestation is an accident-prevention gate, NOT proof of a security audit. */
export function assertLaunchAllowed(env: NodeJS.ProcessEnv = process.env, now = Date.now()) {
if (!IS_MAINNET) return;
if (env.WIKIDECK_MAINNET_LAUNCH !== CHAIN_ID || env.WIKIDECK_MAINNET_REVIEW_ACK !== 'independent-review-complete') {
throw new Error(`Mainnet candidate is launch-gated. Review the launch checklist; explicit chain approval required: ${CHAIN_ID}`);
}
if (now < GENESIS_TIME * 1000) throw new Error('Mainnet candidate genesis time has not arrived; this is not an announced launch date');
const origin = new URL(env.PUBLIC_ORIGIN ?? 'http://invalid');
if (origin.protocol !== 'https:' || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash) throw new Error('Mainnet requires an HTTPS PUBLIC_ORIGIN');
const peers = (env.WIKIDECK_PEERS ?? '').split(',').map(s => s.trim()).filter(Boolean);
if (new Set(peers).size < 2) throw new Error('Mainnet launch requires at least two explicitly configured independent HTTPS peers');
for (const text of peers) {
const peer = new URL(text);
if (peer.protocol !== 'https:' || peer.origin === origin.origin || peer.username || peer.password || peer.pathname !== '/' || peer.search || peer.hash) throw new Error('Invalid mainnet peer origin');
}
if (new Set(peers.map(p => new URL(p).origin)).size < 2 || env.WIKIDECK_ALLOW_PRIVATE_PEERS === '1') throw new Error('Mainnet requires distinct public peer origins; private peer bypass is disabled');
if ((env.WIKIDECK_PAYMENT_MODE ?? 'mock') !== 'mock') throw new Error('Live XMR payments remain disabled pending a separate settlement review');
}
+22
View File
@@ -0,0 +1,22 @@
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
/** No live payment adapter is reachable in this release. Never return a payable mock address. */
export function paymentMode(env: NodeJS.ProcessEnv = process.env): 'mock' {
const mode = env.WIKIDECK_PAYMENT_MODE ?? 'mock';
if (mode !== 'mock') throw new Error('Real payments are launch-gated. Only WIKIDECK_PAYMENT_MODE=mock is supported until production settlement review.');
return mode;
}
export function xmrString(atomic: bigint): string {
return `${atomic / 1_000_000_000_000n}.${(atomic % 1_000_000_000_000n).toString().padStart(12, '0')}`;
}
/** Deterministic simulation ONLY. Round gross up and both independent seller fees down. */
export function mockQuote(priceCents: number, feeBps: number, usdCentsPerXmr: number) {
const rate = BigInt(usdCentsPerXmr);
const gross = (BigInt(priceCents) * 1_000_000_000_000n + rate - 1n) / rate;
const fee = gross * BigInt(feeBps) / 10_000n;
const additionalFee = gross * BigInt(ADDITIONAL_MARKET_FEE.bps) / 10_000n;
return { invoice: { provider: 'mock' as const, amountXmr: xmrString(gross), address: null,
label: 'SIMULATION ONLY — no Monero payment address. Never send real XMR.' },
feeXmr: xmrString(fee), sellerNetXmr: xmrString(gross - fee - additionalFee),
additionalFee: { ...ADDITIONAL_MARKET_FEE, amountXmr: xmrString(additionalFee), payout: 'not-started' as const } };
}
+262
View File
@@ -0,0 +1,262 @@
import { DatabaseSync } from 'node:sqlite';
import { randomUUID } from 'node:crypto';
import { mkdir, chmod } from 'node:fs/promises';
import { join } from 'node:path';
import { NETWORK, CONSENSUS, nftTransferMessage, testnetOwnerOf, transactionCardIds,
type TestnetSnapshot, type NftTransfer } from '../../shared/testnet.ts';
import { ADDITIONAL_MARKET_FEE, marketCommandSchema, marketMessage, type MarketCommand, type MarketListing,
type MarketOrder, type MarketInfo, type MarketActivity } from '../../shared/market.ts';
import { CHAIN_ID, transactionId, transferSchema, verifySignature } from '../testnet/consensus.ts';
import { mockQuote, paymentMode } from './payments.ts';
type Chain = { snapshot(): TestnetSnapshot; submitTransfer(raw: unknown): Promise<string> };
type Config = { feeBps: number; confirmations: number; usdCentsPerXmr: number };
export function marketConfig(env: NodeJS.ProcessEnv = process.env): Config {
paymentMode(env);
const integer = (name: string, fallback: number, min: number, max: number) => {
const text = env[name] ?? String(fallback);
if (!/^\d+$/.test(text)) throw new Error(`Invalid ${name}`);
const n = Number(text);
if (!Number.isSafeInteger(n) || n < min || n > max) throw new Error(`Invalid ${name}`);
return n;
};
return { feeBps: integer('WIKIDECK_MARKET_FEE_BPS', 250, 0, 5000),
confirmations: integer('WIKIDECK_MARKET_CONFIRMATIONS', 10, 2, 1000),
usdCentsPerXmr: integer('WIKIDECK_MOCK_USD_CENTS_PER_XMR', 15000, 1, 100_000_000) };
}
const PAYMENT_WINDOW = 30 * 60_000;
const DELIVERY_WINDOW = 48 * 60 * 60_000;
/** Node-local marketplace, NOT consensus escrow. Its SQLite journal is backed up with the ledger. */
export class Market {
private queue: Promise<unknown> = Promise.resolve();
private queued = 0;
private readonly db: DatabaseSync;
private readonly chain: Chain;
readonly config: Config;
readonly id: string;
private readonly now: () => number;
private constructor(db: DatabaseSync, chain: Chain, config: Config, id: string, now: () => number) {
this.db = db; this.chain = chain; this.config = config; this.id = id; this.now = now;
}
static async open(directory: string, chain: Chain, config = marketConfig(), now = Date.now): Promise<Market> {
const folder = join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet');
await mkdir(folder, { recursive: true, mode: 0o700 });
const file = join(folder, 'market.sqlite');
const db = new DatabaseSync(file);
try {
db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000;
CREATE TABLE IF NOT EXISTS market_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS listings(id TEXT PRIMARY KEY, card TEXT NOT NULL, status TEXT NOT NULL, data TEXT NOT NULL);
CREATE UNIQUE INDEX IF NOT EXISTS one_active_listing ON listings(card) WHERE status IN ('open','reserved');
CREATE TABLE IF NOT EXISTS orders(id TEXT PRIMARY KEY, data TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS receipts(id TEXT PRIMARY KEY, digest TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);`);
const meta = db.prepare('SELECT value FROM market_meta WHERE key=?').get('identity') as { value: string } | undefined;
let id: string;
if (meta) {
const identity = JSON.parse(meta.value);
if (identity.network !== NETWORK || identity.chainId !== CHAIN_ID || identity.version !== 1 ||
typeof identity.id !== 'string') throw new Error('Marketplace network/schema mismatch');
id = identity.id;
} else {
id = randomUUID();
db.prepare('INSERT INTO market_meta VALUES(?,?)').run('identity', JSON.stringify({ version: 1, network: NETWORK, chainId: CHAIN_ID, id }));
}
if (process.platform !== 'win32') await chmod(file, 0o600);
return new Market(db, chain, config, id, now);
} catch (error) { db.close(); throw error; }
}
close() { this.db.close(); }
info(): MarketInfo {
return { network: NETWORK, chainId: CHAIN_ID, marketId: this.id, paymentMode: 'mock', feeBps: this.config.feeBps,
additionalFee: { ...ADDITIONAL_MARKET_FEE },
deliveryConfirmations: this.config.confirmations, mockUsdPerXmr: (this.config.usdCentsPerXmr / 100).toFixed(2), serverTime: this.now() };
}
private rows<T>(table: 'listings' | 'orders'): T[] {
return (this.db.prepare(`SELECT data FROM ${table} ORDER BY rowid DESC LIMIT 10000`).all() as { data: string }[])
.map(row => JSON.parse(row.data) as T);
}
private listing(id: string): MarketListing {
const row = this.db.prepare('SELECT data FROM listings WHERE id=?').get(id) as { data: string } | undefined;
if (!row) throw new Error('Listing not found');
return JSON.parse(row.data);
}
private order(id: string): MarketOrder {
const row = this.db.prepare('SELECT data FROM orders WHERE id=?').get(id) as { data: string } | undefined;
if (!row) throw new Error('Order not found');
return JSON.parse(row.data);
}
private saveListing(l: MarketListing) {
this.db.prepare('INSERT INTO listings VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data')
.run(l.id, l.cardHash, l.status, JSON.stringify(l));
}
private saveOrder(o: MarketOrder) {
this.db.prepare('INSERT INTO orders VALUES(?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data').run(o.id, JSON.stringify(o));
}
private atomic<T>(fn: () => T): T {
this.db.exec('BEGIN IMMEDIATE');
try { const result = fn(); this.db.exec('COMMIT'); return result; }
catch (error) { this.db.exec('ROLLBACK'); throw error; }
}
private card(snapshot: TestnetSnapshot, hash: string, owner: string, sequence: number): boolean {
const block = snapshot.blocks.find(b => b.tokenId === hash);
return !!block && testnetOwnerOf(snapshot, hash) === owner &&
snapshot.transfers.filter(tx => transactionCardIds(tx).includes(hash)).length === sequence &&
snapshot.blocks.length + 1 - block.height >= CONSENSUS.coinbaseMaturity;
}
/** Reconcile from canonical transaction identity, never from a mempool acknowledgement. */
private reconcile() {
const s = this.chain.snapshot(), now = this.now();
this.atomic(() => {
for (const o of this.rows<MarketOrder>('orders')) {
const l = this.listing(o.listingId);
if (o.status === 'refunded' || o.status === 'expired') continue;
const delivery = o.transferId && s.blocks.find(b => b.transactions.some(tx => transactionId(tx) === o.transferId));
const confirmations = delivery ? s.blocks.length - delivery.height + 1 : 0;
o.deliveryConfirmations = confirmations;
if (o.status === 'complete' || o.status === 'review') {
if (!delivery || confirmations < o.requiredConfirmations) {
o.status = 'review'; o.payout = 'review';
if (o.additionalFee) o.additionalFee.payout = 'review';
}
this.saveOrder(o); continue; // Never automatically pay a second time after a reorganization.
}
if (delivery) {
o.status = 'delivery-pending';
if (confirmations >= o.requiredConfirmations) {
o.status = 'complete'; o.payout = 'mock-paid'; l.status = 'sold';
if (o.additionalFee) o.additionalFee.payout = 'mock-paid';
}
} else if (o.status === 'awaiting-payment' && now >= o.payBy) {
o.status = 'expired'; l.status = l.kind === 'fixed' ? 'open' : 'expired';
} else if (o.status !== 'awaiting-payment') {
const pending = s.pendingTransfers.some(tx => transactionId(tx) === o.transferId);
const conflict = s.pendingTransfers.some(tx => transactionCardIds(tx).includes(o.cardHash) && transactionId(tx) !== o.transferId);
if (!this.card(s, o.cardHash, o.seller, o.sequence) || conflict || (now >= o.deliverBy && !pending)) o.status = 'refund-required';
else if (o.status === 'delivery-pending' && !pending) o.status = 'paid';
}
this.saveOrder(o); this.saveListing(l);
}
for (const l of this.rows<MarketListing>('listings')) {
if (l.status !== 'open') continue;
if (l.additionalFee?.bps !== ADDITIONAL_MARKET_FEE.bps || l.additionalFee?.address !== ADDITIONAL_MARKET_FEE.address ||
!this.card(s, l.cardHash, l.seller, l.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(l.cardHash))) l.status = 'invalid';
else if (l.kind === 'auction' && now >= l.endsAt! + PAYMENT_WINDOW) l.status = 'expired';
else if (l.kind === 'auction' && now >= l.endsAt! && !l.highestBid) l.status = 'expired';
this.saveListing(l);
}
this.db.prepare('DELETE FROM receipts WHERE expires < ?').run(now - 86400_000);
});
}
browse(offset = 0): { listings: MarketListing[]; next: number | null; info: MarketInfo } {
if (!Number.isSafeInteger(offset) || offset < 0 || offset > 10000) throw new Error('Invalid marketplace page');
// Reconciliation must not interleave with delivery submission while its chain await is outstanding.
if (!this.queued) this.reconcile();
const all = this.rows<MarketListing>('listings').filter(l => l.status === 'open' || l.status === 'reserved');
return { listings: all.slice(offset, offset + 24), next: offset + 24 < all.length ? offset + 24 : null, info: this.info() };
}
execute(raw: unknown): Promise<MarketActivity | MarketListing | MarketOrder> {
const cmd = marketCommandSchema.parse(raw);
if (this.queued >= 64) return Promise.reject(new Error('Marketplace busy; retry shortly'));
this.queued++;
const result = this.queue.then(async () => {
try { return await this.run(cmd); }
finally { this.queued--; }
});
this.queue = result.catch(() => undefined);
return result;
}
private async run(c: MarketCommand): Promise<MarketActivity | MarketListing | MarketOrder> {
const now = this.now();
if (c.chainId !== CHAIN_ID || c.marketId !== this.id || c.expiresAt < now || c.expiresAt > now + 5 * 60_000 ||
!verifySignature(c.publicKey, c.actor, marketMessage(c), c.signature)) throw new Error('Invalid or expired marketplace signature');
const key = `${c.actor}:${c.nonce}`, digest = marketMessage(c);
const old = this.db.prepare('SELECT digest,data FROM receipts WHERE id=?').get(key) as { digest: string; data: string } | undefined;
if (old) {
if (old.digest !== digest) throw new Error('Nonce already used for different terms');
return JSON.parse(old.data);
}
this.reconcile();
const a = c.action;
if (a.type === 'activity') return { orders: this.rows<MarketOrder>('orders').filter(o => o.seller === c.actor || o.buyer === c.actor),
listings: this.rows<MarketListing>('listings').filter(l => l.seller === c.actor) };
if (a.type === 'deliver') {
const o = this.order(a.orderId), tx = transferSchema.parse(a.transfer);
if (o.seller !== c.actor || !['paid', 'delivery-pending'].includes(o.status)) throw new Error('Only the paid order seller can deliver');
if (tx.cardHash !== o.cardHash || tx.sequence !== o.sequence || tx.from !== o.seller || tx.to !== o.buyer ||
!verifySignature(tx.publicKey, tx.from, nftTransferMessage(tx), tx.signature)) throw new Error('Delivery does not match this sale');
// Persist intent before the ledger await. A crash/restart reconciles by deterministic tx ID.
o.transfer = tx; o.transferId = transactionId(tx); o.status = 'delivery-pending'; this.saveOrder(o);
await this.chain.submitTransfer(tx);
return this.receipt(key, digest, c.expiresAt, o);
}
return this.atomic(() => {
const result = this.mutate(c);
return this.receipt(key, digest, c.expiresAt, result);
});
}
private receipt<T>(key: string, digest: string, expires: number, value: T): T {
this.db.prepare('INSERT INTO receipts VALUES(?,?,?,?)').run(key, digest, expires, JSON.stringify(value));
return value;
}
private mutate(c: MarketCommand): MarketListing | MarketOrder {
const a = c.action, now = this.now(), s = this.chain.snapshot();
if (a.type === 'list') {
if (a.feeBps !== this.config.feeBps) throw new Error('Market fee changed; review and sign again');
if (!this.card(s, a.cardHash, c.actor, a.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(a.cardHash))) throw new Error('Card is missing, stale, immature, or already pending');
if (a.kind === 'fixed' ? a.endsAt !== null : a.endsAt === null || a.endsAt < now + 60_000 || a.endsAt > now + 30 * 86400_000) throw new Error('Auction duration must be 1 minute to 30 days; fixed listings have no end');
if (this.rows<MarketListing>('listings').filter(l => l.seller === c.actor && ['open','reserved'].includes(l.status)).length >= 100) throw new Error('Limit: 100 active listings per seller');
if ((this.db.prepare('SELECT COUNT(*) AS n FROM listings').get() as { n: number }).n >= 10000) throw new Error('Marketplace journal limit reached; operator archival required');
if (this.db.prepare("SELECT 1 FROM listings WHERE card=? AND status IN ('open','reserved')").get(a.cardHash)) throw new Error('Card already listed');
const mint = s.blocks.find(b => b.tokenId === a.cardHash)!;
const l: MarketListing = { id: randomUUID(), cardHash: a.cardHash, sequence: a.sequence, seller: c.actor,
title: mint.article.title, image: mint.article.image!, kind: a.kind, priceCents: a.priceCents,
feeBps: a.feeBps, additionalFee: { ...ADDITIONAL_MARKET_FEE },
createdAt: now, endsAt: a.endsAt, status: 'open', highestBid: null };
this.saveListing(l); return l;
}
if (a.type === 'bid' || a.type === 'buy' || a.type === 'cancel') {
const l = this.listing(a.listingId);
if (l.status !== 'open') throw new Error('Listing is no longer available');
if (a.type === 'cancel') {
if (l.seller !== c.actor || l.highestBid) throw new Error('Only the seller can cancel, and auctions with bids cannot be cancelled');
l.status = 'cancelled'; this.saveListing(l); return l;
}
if (l.seller === c.actor) throw new Error('Cannot buy or bid on your own card');
if (a.type === 'bid') {
if (l.kind !== 'auction' || now >= l.endsAt!) throw new Error('Auction is not accepting bids');
const minimum = l.highestBid ? l.highestBid.priceCents + 1 : l.priceCents;
if (a.priceCents < minimum) throw new Error('Bid must meet the start price and exceed the current bid by at least $0.01');
l.highestBid = { bidder: c.actor, priceCents: a.priceCents }; this.saveListing(l); return l;
}
const price = l.kind === 'fixed' ? l.priceCents : l.highestBid?.priceCents;
if (l.kind === 'auction' && (now < l.endsAt! || now >= l.endsAt! + PAYMENT_WINDOW || l.highestBid?.bidder !== c.actor)) throw new Error('Only the auction winner can check out within 30 minutes of closing');
if (price !== a.priceCents || l.feeBps !== a.feeBps) throw new Error('Sale terms changed; review and sign again');
if (!this.card(s, l.cardHash, l.seller, l.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(l.cardHash))) throw new Error('Card is no longer available');
if ((this.db.prepare('SELECT COUNT(*) AS n FROM orders').get() as { n: number }).n >= 10000) throw new Error('Marketplace order limit reached; operator archival required');
const payBy = l.kind === 'auction' ? l.endsAt! + PAYMENT_WINDOW : now + PAYMENT_WINDOW;
const o: MarketOrder = { id: randomUUID(), listingId: l.id, seller: l.seller, buyer: c.actor,
cardHash: l.cardHash, sequence: l.sequence, priceCents: price!, feeBps: l.feeBps, createdAt: now, payBy,
deliverBy: payBy + DELIVERY_WINDOW, status: 'awaiting-payment',
...mockQuote(price!, l.feeBps, this.config.usdCentsPerXmr), payout: 'not-started', deliveryConfirmations: 0,
requiredConfirmations: this.config.confirmations };
l.status = 'reserved'; this.saveListing(l); this.saveOrder(o); return o;
}
if (a.type === 'mock-pay' || a.type === 'refund') {
const o = this.order(a.orderId);
if (o.buyer !== c.actor) throw new Error('Only the order buyer can perform this action');
if (a.type === 'mock-pay') {
if (o.status !== 'awaiting-payment' || now >= o.payBy) throw new Error('Invoice is not payable');
if (!this.card(s, o.cardHash, o.seller, o.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(o.cardHash))) throw new Error('Seller card changed; do not pay');
o.status = 'paid'; o.deliverBy = now + DELIVERY_WINDOW;
} else {
if (o.status !== 'refund-required' || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(o.cardHash)) ||
s.blocks.some(b => b.transactions.some(tx => transactionId(tx) === o.transferId))) throw new Error('Refund requires failed delivery and no pending/canonical sale transfer');
o.status = 'refunded'; const l = this.listing(o.listingId); l.status = 'cancelled'; this.saveListing(l);
}
this.saveOrder(o); return o;
}
throw new Error('Unsupported market action');
}
}
+2 -1
View File
@@ -6,11 +6,12 @@ import { NETWORK, CONSENSUS, headerMessage, blockClaimMessage, nftTransferMessag
import type { Article } from '../../shared/protocol.ts';
import { deriveStats, type MintStatsSnapshot } from '../../shared/enrichment.ts';
import type { PowEngine } from './randomx.ts';
import { NETWORK_GENESIS_TIME } from '../../shared/network.ts';
export const sha256 = (data: string | Uint8Array) => createHash('sha256').update(data).digest('hex');
export const MAX_TARGET = `0fff${'f'.repeat(60)}`;
// Consensus manifest: an independently running node derives exactly the same genesis.
export const GENESIS_TIME = 1_792_411_200;
export const GENESIS_TIME = NETWORK_GENESIS_TIME;
export const CHAIN_ID = sha256(JSON.stringify([NETWORK, CONSENSUS, GENESIS_TIME, MAX_TARGET]));
export const GENESIS_HASH = sha256(`${NETWORK}:genesis:${CHAIN_ID}`);
export const INITIAL_KEY = sha256(`${NETWORK}:initial-key:${CHAIN_ID}`);
+31 -5
View File
@@ -14,12 +14,18 @@ import { PeerNetwork } from './peers.ts';
import { wikipediaEnrichment } from '../enrichment.ts';
import { snapshotStats } from '../../shared/enrichment.ts';
import { wikipediaArticles } from '../wiki.ts';
import { Market, marketConfig } from '../market/store.ts';
import { marketCommandSchema } from '../../shared/market.ts';
import { IS_MAINNET, PROFILE } from '../../shared/network.ts';
import { assertLaunchAllowed, launchManifest } from '../launch.ts';
const root = fileURLToPath(new URL('../../', import.meta.url));
assertLaunchAllowed();
const paymentConfig = marketConfig(); // Fail before opening node resources if live payments are requested.
const dataDir = process.env.WIKIDECK_DATA_DIR ?? resolve(root,'data');
await mkdir(dataDir,{recursive:true});
// Never permit two processes to rewrite the same ledger. A stale lock requires operator review.
const lockPath = resolve(dataDir,'testnet-node.lock');
const lockPath = resolve(dataDir,`${PROFILE}-node.lock`);
const lock = await open(lockPath,'wx',0o600).catch(()=>{throw new Error('Data directory locked. Stop its other node; remove stale testnet-node.lock only after verifying no node is running.');});
await lock.writeFile(String(process.pid));
let engine: RandomX;
@@ -32,6 +38,9 @@ let peers: PeerNetwork;
try { peers = new PeerNetwork({ ledger, urls:(process.env.WIKIDECK_PEERS ?? '').split(',').map(s=>s.trim()).filter(Boolean),
allowPrivate:process.env.WIKIDECK_ALLOW_PRIVATE_PEERS === '1' }); }
catch(e) {ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
let market: Market;
try { market = await Market.open(dataDir, ledger, paymentConfig); }
catch(e) {peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
const app = express();
app.disable('x-powered-by');
app.use(helmet({contentSecurityPolicy:{directives:{defaultSrc:["'self'"],scriptSrc:["'self'"],
@@ -52,8 +61,22 @@ app.use(['/api','/p2p'],(req,res,next)=>{
next();
});
app.get('/api/state',(_req,res)=>res.json(ledger.snapshot()));
app.get('/api/network',(_req,res)=>res.json(launchManifest()));
app.get('/api/chain',(_req,res)=>res.json(ledger.snapshot()));
app.get('/api/peers',(_req,res)=>res.json({peers:peers.status()}));
app.get('/api/market', (req,res)=>res.json(market.browse(Number(req.query.offset ?? 0))));
app.post('/api/market/command',rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}),async(req,res)=>{
const command = marketCommandSchema.parse(req.body);
try {
const result = await market.execute(command);
res.json(result);
if(command.action.type==='deliver')void peers.broadcastTransfer(command.action.transfer).catch(()=>{});
} catch(error) {
// Marketplace storage/payment internals must never leak through the generic node error handler.
if(error instanceof Error && !/SQLITE|constraint|database/i.test(error.message))res.status(400).json({error:error.message.slice(0,200)});
else res.status(409).json({error:'Marketplace state conflict. Refresh and retry.'});
}
});
app.get('/api/decks/:address', rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}), (req,res)=>{
if(typeof req.params.address!=='string'||!/^[a-f0-9]{64}$/.test(req.params.address)){
res.status(400).json({error:'Invalid wallet address'});return;
@@ -135,13 +158,16 @@ app.use((err:Error & {status?:number},_req:express.Request,res:express.Response,
});
const port=Number(process.env.PORT??3001),host=process.env.HOST??'127.0.0.1';
const server=app.listen(port,host,()=>{
const a=server.address();console.log(`Wikideck RandomX testnet: http://${host}:${typeof a==='object'&&a?a.port:port} · chain ${CHAIN_ID} · ${ledger.snapshot().blocks.length} blocks`);
const a=server.address();console.log(`Wikideck RandomX ${IS_MAINNET?'mainnet candidate':'testnet'}: http://${host}:${typeof a==='object'&&a?a.port:port} · chain ${CHAIN_ID} · ${ledger.snapshot().blocks.length} blocks`);
peers.start();
});
// Advance simulated payout/expiry state even when no browser is open. No real funds are sent.
const marketTimer=setInterval(()=>{try {market.browse();}catch{console.error('Marketplace reconciliation failed; operator review required.');}},15000);
marketTimer.unref();
let stopping=false;
for(const signal of ['SIGTERM','SIGINT']as const)process.once(signal,()=>{
if(stopping)return;stopping=true;peers.close();
if(stopping)return;stopping=true;peers.close();clearInterval(marketTimer);
const timeout=setTimeout(()=>process.exit(1),25000);timeout.unref();
server.close(async()=>{ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
server.close(async()=>{market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
});
server.on('error',async()=>{peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
server.on('error',async()=>{clearInterval(marketTimer);peers.close();market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
+2 -1
View File
@@ -11,6 +11,7 @@ import { CHAIN_ID, GENESIS_HASH, applyTransactionOverlay, blockSchema, makeTempl
import type { PowEngine } from './randomx.ts';
import type { Article } from '../../shared/protocol.ts';
import type { MintStatsSnapshot } from '../../shared/enrichment.ts';
import { PROFILE } from '../../shared/network.ts';
const MAX_PENDING = 256;
const MAX_DECK_PAGE = 24;
@@ -41,7 +42,7 @@ export class Ledger {
private constructor(file: string, db: DatabaseSync, pow: PowEngine) { this.file = file; this.db = db; this.pow = pow; }
static async open(dir: string, pow: PowEngine): Promise<Ledger> {
const directory = join(dir, 'testnet');
const directory = join(dir, PROFILE);
await mkdir(directory, { recursive: true, mode: 0o700 });
const file = join(directory, 'ledger.sqlite');
let databaseExists = false;
+3
View File
@@ -6,6 +6,9 @@ import { decryptWallet } from '../../shared/wallet-backup.ts';
import { blockClaimMessage, CONSENSUS, NETWORK, type MiningTemplate, type TestnetBlock } from '../../shared/testnet.ts';
import { CHAIN_ID, sha256, miningBlob, mintId, blockSchema } from './consensus.ts';
import { RandomX } from './randomx.ts';
import { assertLaunchAllowed } from '../launch.ts';
assertLaunchAllowed();
const args = process.argv.slice(2);
const arg = (key: string) => { const i = args.indexOf(key); return i < 0 ? undefined : args[i+1]; };
+72
View File
@@ -0,0 +1,72 @@
import { z } from 'zod';
import { NETWORK, type NftTransfer } from './testnet.ts';
export const usdCents = z.number().int().min(1).max(100_000_000);
/** Public payout destination, not a secret or a buyer invoice address. Not environment-configurable. */
export const ADDITIONAL_MARKET_FEE = Object.freeze({ bps: 100,
address: '44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv' });
export type AdditionalFeePolicy = { bps: number; address: string };
const id = z.string().uuid();
const hex = z.string().regex(/^[a-f0-9]{64}$/);
export const marketActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('list'), cardHash: hex, sequence: z.number().int().nonnegative(),
kind: z.enum(['fixed', 'auction']), priceCents: usdCents, endsAt: z.number().int().nonnegative().nullable(),
feeBps: z.number().int().min(0).max(5000) }).strict(),
z.object({ type: z.literal('bid'), listingId: id, priceCents: usdCents }).strict(),
z.object({ type: z.literal('buy'), listingId: id, priceCents: usdCents, feeBps: z.number().int().min(0).max(5000) }).strict(),
z.object({ type: z.literal('cancel'), listingId: id }).strict(),
z.object({ type: z.literal('mock-pay'), orderId: id }).strict(),
z.object({ type: z.literal('refund'), orderId: id }).strict(),
// Delivery is an ordinary chain-validated transfer, not a market signature substitute.
z.object({ type: z.literal('deliver'), orderId: id, transfer: z.object({ network: z.literal(NETWORK),
chainId: hex, cardHash: hex, from: hex, to: hex, sequence: z.number().int().nonnegative(),
publicKey: z.string().max(256), signature: z.string().max(128) }).strict() }).strict(),
z.object({ type: z.literal('activity') }).strict(),
]);
export type MarketAction = z.infer<typeof marketActionSchema>;
export const marketCommandSchema = z.object({ network: z.literal(NETWORK), chainId: hex, marketId: id,
actor: hex, publicKey: z.string().max(256), nonce: id, expiresAt: z.number().int().nonnegative(),
action: marketActionSchema, signature: z.string().max(128) }).strict();
export type MarketCommand = z.infer<typeof marketCommandSchema>;
export function marketMessage(command: Omit<MarketCommand, 'signature'>): string {
const a = command.action;
const terms = a.type === 'list' ? [a.cardHash, a.sequence, a.kind, a.priceCents, a.endsAt, a.feeBps]
: a.type === 'bid' ? [a.listingId, a.priceCents]
: a.type === 'buy' ? [a.listingId, a.priceCents, a.feeBps]
: a.type === 'cancel' ? [a.listingId]
: a.type === 'activity' ? []
: a.type === 'deliver' ? [a.orderId, a.transfer.network, a.transfer.chainId, a.transfer.cardHash,
a.transfer.from, a.transfer.to, a.transfer.sequence, a.transfer.publicKey, a.transfer.signature]
: [a.orderId];
// Bind the non-configurable fee and recipient to sale authorization, not just UI disclosure.
const feeTerms = a.type === 'list' || a.type === 'buy' ? [ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address] : [];
return JSON.stringify([NETWORK, 'market-command-v2', command.chainId, command.marketId, command.actor,
command.publicKey, command.nonce, command.expiresAt, a.type, terms, feeTerms]);
}
export type MarketListing = {
id: string; cardHash: string; sequence: number; seller: string; title: string; image: string;
kind: 'fixed' | 'auction'; priceCents: number; feeBps: number; createdAt: number; endsAt: number | null;
status: 'open' | 'reserved' | 'sold' | 'cancelled' | 'expired' | 'invalid';
highestBid: { bidder: string; priceCents: number } | null;
additionalFee?: AdditionalFeePolicy; // Absent on legacy listings; relisting requires fresh fee authorization.
};
export type MarketOrder = {
id: string; listingId: string; seller: string; buyer: string; cardHash: string; sequence: number;
priceCents: number; feeBps: number; createdAt: number; payBy: number; deliverBy: number;
status: 'awaiting-payment' | 'paid' | 'delivery-pending' | 'complete' | 'expired' | 'refund-required' | 'refunded' | 'review';
invoice: { provider: 'mock'; amountXmr: string; address: null; label: string };
feeXmr: string; sellerNetXmr: string; payout: 'not-started' | 'mock-paid' | 'review';
transfer?: NftTransfer; transferId?: string; deliveryConfirmations: number; requiredConfirmations: number;
additionalFee?: AdditionalFeePolicy & { amountXmr: string; payout: 'not-started' | 'mock-paid' | 'review' };
};
export type MarketInfo = { network: string; chainId: string; marketId: string; paymentMode: 'mock';
feeBps: number; additionalFee: AdditionalFeePolicy; deliveryConfirmations: number; mockUsdPerXmr: string; serverTime: number };
export type MarketActivity = { orders: MarketOrder[]; listings: MarketListing[] };
export function formatUsd(cents: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(cents / 100);
}
export function parseUsd(text: string): number {
if (!/^(0|[1-9]\d{0,6})(\.\d{1,2})?$/.test(text)) throw new Error('Enter a USD amount with at most two decimal places.');
const [whole, fraction = ''] = text.split('.');
return usdCents.parse(Number(whole) * 100 + Number(fraction.padEnd(2, '0')));
}
+13
View File
@@ -0,0 +1,13 @@
/** Selection is fixed at server startup / browser build, never learned from an untrusted RPC. */
export function networkProfile(value: string | undefined): 'testnet' | 'mainnet' {
const profile = value ?? 'testnet';
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
? 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';
+2 -1
View File
@@ -1,7 +1,8 @@
import type { Article } from './protocol.ts';
import type { MintStatsSnapshot } from './enrichment.ts';
export const NETWORK = 'wikideck-randomx-testnet-6' as const;
import { NETWORK } from './network.ts';
export { NETWORK } from './network.ts';
export const CONSENSUS = {
version: 6, targetSeconds: 15 * 60, retargetInterval: 10, medianWindow: 11,
commitments: 'sha256-domain-separated-counted-merkle-v1', randomx: '1.2.1', powEndian: 'big',
+2 -2
View File
@@ -1,5 +1,5 @@
// Browser/Node WebCrypto only: this module never reads storage or sends data.
const NETWORK = 'wikideck-randomx-testnet-6';
import { NETWORK } from './network.ts';
const ITERATIONS = 600_000;
const MAX_BYTES = 32 * 1024;
const encoder = new TextEncoder();
@@ -7,7 +7,7 @@ const encoder = new TextEncoder();
export type WalletBackup = {
format: 'wikideck-wallet-backup';
version: 1;
network: 'wikideck-randomx-testnet-6';
network: typeof NETWORK;
kdf: 'PBKDF2-SHA256';
iterations: 600000;
cipher: 'AES-256-GCM';
+10 -8
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { ArrowDownLeft, ArrowLeftRight, ArrowRight, ArrowUpRight, BookOpen, Check, ChevronRight, CircleHelp,
Copy, Cpu, Download, ExternalLink, Globe2, Layers3, LayoutDashboard, LoaderCircle,
Pickaxe, Search, Shield, Sparkles, Wallet as WalletIcon, X } from 'lucide-react';
Pickaxe, Search, Shield, ShoppingBag, Sparkles, Wallet as WalletIcon, X } from 'lucide-react';
import { rarity } from '../shared/protocol.ts';
import { CONSENSUS, NETWORK, isNftTrade, nftTransferMessage, testnetOwnerOf, transactionCardIds,
type TestnetBlock, type TestnetSnapshot } from '../shared/testnet.ts';
@@ -11,10 +11,12 @@ import { WalletPanel } from './WalletPanel.tsx';
import { useModalFocus } from './useModalFocus.ts';
import { ThemePicker } from './ThemePicker.tsx';
import { TradeDesk } from './TradeDesk.tsx';
import { Marketplace } from './Marketplace.tsx';
import { IS_MAINNET, NETWORK_LABEL } from '../shared/network.ts';
import './App.css';
import './testnet.css';
type View = 'Discover' | 'My collection' | 'Block explorer' | 'Trade decks';
type View = 'Discover' | 'My collection' | 'Block explorer' | 'Trade decks' | 'Marketplace';
const short = (s: string) => s ? `${s.slice(0, 6)}…${s.slice(-4)}` : '—';
const asCard = (block: TestnetBlock): CardData => ({ article: block.article, hash: block.powHash, block });
const message = (error: unknown) => error instanceof Error ? error.message : 'Request failed. Please try again.';
@@ -154,18 +156,18 @@ export default function App() {
<aside className="sidebar">
<a href="#" className="brand" onClick={e => { e.preventDefault(); setView('Discover'); }}><img className="brand-logo" src="/Wikideck.png" alt="" />wikideck</a>
<div className="sidebar-label">YOUR NEXT DISCOVERY</div>
<nav aria-label="Main navigation">{(['Discover', 'My collection', 'Block explorer', 'Trade decks'] as View[]).map((item, i) => {
const Icon = [LayoutDashboard, Layers3, Globe2, ArrowLeftRight][i];
<nav aria-label="Main navigation">{(['Discover', 'My collection', 'Block explorer', 'Trade decks', 'Marketplace'] as View[]).map((item, i) => {
const Icon = [LayoutDashboard, Layers3, Globe2, ArrowLeftRight, ShoppingBag][i];
return <button key={item} aria-label={item} aria-current={view === item ? 'page' : undefined} className={`nav-item ${view === item ? 'active' : ''}`} onClick={() => { setView(item); setQuery(''); setCategory('All cards'); }}><Icon size={18} />{item}{item === 'My collection' && <span className="nav-count">{owned.length}</span>}</button>;
})}</nav>
<div className="sidebar-note"><span className="tiny-spark">✦</span><h3>A little curiosity.<br />An independent chain.</h3><p>Collect knowledge as native NFTs on a sovereign RandomX testnet.</p><button onClick={() => setDialog('guide')}>How it works <ArrowUpRight size={15} /></button></div>
<div className="sidebar-bottom"><span className={`network-dot ${!rpcAvailable || !snapshot?.peers ? 'disconnected' : ''}`} /><span>{peerLabel}</span><span className="prototype">TESTNET</span></div>
<div className="sidebar-bottom"><span className={`network-dot ${!rpcAvailable || !snapshot?.peers ? 'disconnected' : ''}`} /><span>{peerLabel}</span><span className="prototype">{NETWORK_LABEL}</span></div>
<button className="help-link" onClick={() => setDialog('guide')}><CircleHelp size={16} /> The field guide <ArrowUpRight size={14} /></button>
</aside>
<div className="main-shell">
<header className="topbar"><div className="breadcrumbs">Explore <ChevronRight size={13} /><span>{view}</span></div><div className="top-actions"><span className="testnet-brand">RandomX <b>TESTNET</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>
<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>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?'Gated mainnet candidate · unaudited · mock payments only':'Unbootstrapped testnet · no public mainnet'}</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">
@@ -183,7 +185,7 @@ export default function App() {
<div className="miner-controls"><div className="miner-display"><div><span>NEXT WIKIPEDIA ARTICLE</span><strong>{snapshot ? 'Chosen when the miner requests work' : 'Waiting for testnet RPC…'}</strong></div></div><p className="muted">Each mining template requests a random English Wikipedia article with a Wikimedia picture. Pages may repeat; every minted card carries its own statistics snapshot.</p><ol className="native-steps"><li>Build <code>wikideck-randomx</code> using the repository’s native build instructions.</li><li>Save your encrypted backup and keep its password separate.</li><li>Replace <code>&lt;backup.json&gt;</code> with your local file path (quote paths with spaces), then run:</li></ol><pre className="native-command"><code>{nativeCommand}</code></pre><p className="miner-footnote">Node: {window.location.origin}. Same-origin RPC, including the development proxy. Use HTTPS for remote nodes. Password entry stays local; never put it in the command. Defaults to one worker and at most one block. Ctrl+C stops the native miner; closing this page does not.</p><button className="button dark full" onClick={openWallet}><Download size={16} /> Download wallet backup</button><button className="text-button" onClick={() => setDialog('guide')}>Read the testnet field guide <ArrowRight size={14} /></button></div>
</section>
</>}
{view === 'Trade decks' ? <TradeDesk wallet={wallet} snapshot={snapshot} rpcAvailable={rpcAvailable} onNotice={setToast} /> : view !== 'Block explorer' ? <section className="discover-section">
{view === 'Marketplace' ? <Marketplace wallet={wallet} snapshot={snapshot} rpcAvailable={rpcAvailable} onNotice={setToast} /> : view === 'Trade decks' ? <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="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="/Wikideck.png" 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>}
+172
View File
@@ -0,0 +1,172 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, Clock3, Gavel, LoaderCircle, RefreshCw, ShoppingBag, Tag } from 'lucide-react';
import { NETWORK, CONSENSUS, nftTransferMessage, testnetOwnerOf, transactionCardIds, type TestnetSnapshot } from '../shared/testnet.ts';
import { ADDITIONAL_MARKET_FEE, formatUsd, parseUsd, marketMessage, type MarketAction, type MarketInfo, type MarketListing,
type MarketOrder, type MarketActivity } from '../shared/market.ts';
import { sign, type Wallet } from './wallet.ts';
import { imageCredit } from './Card.tsx';
import './market.css';
type Props = { wallet?: Wallet; snapshot?: TestnetSnapshot; rpcAvailable: boolean; onNotice(message: string): void };
type Page = { listings: MarketListing[]; next: number | null; info: MarketInfo };
const short = (v: string) => `${v.slice(0, 8)}…${v.slice(-5)}`;
const feeDisclosure = (marketBps: number) => `Market fee: ${marketBps / 100}% plus a fixed 1% additional fee (${(marketBps + ADDITIONAL_MARKET_FEE.bps) / 100}% total), both deducted from seller proceeds. Additional fee recipient: ${ADDITIONAL_MARKET_FEE.address}. Payouts are simulated only.`;
async function api<T>(body?: unknown, offset = 0): Promise<T> {
const response = await fetch(body ? '/api/market/command' : `/api/market?offset=${offset}`, {
cache: 'no-store', signal: AbortSignal.timeout(15000),
...(body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}),
});
if (!response.headers.get('content-type')?.includes('application/json')) throw new Error('Marketplace is unavailable on this node.');
const data = await response.json();
if (!response.ok) throw new Error(typeof data.error === 'string' ? data.error : 'Marketplace request failed.');
return data;
}
async function command<T>(wallet: Wallet, info: MarketInfo, action: MarketAction): Promise<T> {
const envelope = { network: NETWORK, chainId: info.chainId, marketId: info.marketId, actor: wallet.address,
publicKey: wallet.publicKey, nonce: crypto.randomUUID(), expiresAt: Date.now() + 4 * 60_000, action };
return api<T>({ ...envelope, signature: await sign(wallet, marketMessage(envelope)) });
}
function deadline(time: number, now: number) {
if (time <= now) return 'Closed';
const minutes = Math.ceil((time - now) / 60000);
return minutes >= 1440 ? `${Math.ceil(minutes / 1440)}d remaining` : minutes >= 60 ? `${Math.ceil(minutes / 60)}h remaining` : `${minutes}m remaining`;
}
export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props) {
const [page, setPage] = useState<Page>();
const [activity, setActivity] = useState<MarketActivity>({ listings: [], orders: [] });
const [tab, setTab] = useState<'browse' | 'sell' | 'orders'>('browse');
const [filter, setFilter] = useState<'all' | 'fixed' | 'auction'>('all');
const [cardHash, setCardHash] = useState('');
const [kind, setKind] = useState<'fixed' | 'auction'>('fixed');
const [price, setPrice] = useState('5.00');
const [hours, setHours] = useState('24');
const [bids, setBids] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [review, setReview] = useState<{ action: MarketAction; text: string }>();
const [now, setNow] = useState(Date.now());
const version = useRef(0);
const inFlight = useRef(false);
const reviewPanel = useRef<HTMLElement>(null);
useEffect(() => {
if (!review) return;
reviewPanel.current?.focus({ preventScroll: true });
reviewPanel.current?.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
}, [review]);
const refresh = useCallback(async () => {
const current = version.current;
const data = await api<Page>();
if (data.info.network !== NETWORK || data.info.paymentMode !== 'mock' ||
data.info.additionalFee?.bps !== ADDITIONAL_MARKET_FEE.bps || data.info.additionalFee?.address !== ADDITIONAL_MARKET_FEE.address ||
(snapshot && data.info.chainId !== snapshot.chainId)) throw new Error('Incompatible marketplace network, payment mode, or fee policy.');
const mine = wallet ? await command<MarketActivity>(wallet, data.info, { type: 'activity' }) : { listings: [], orders: [] };
if (current !== version.current) return;
setPage(data); setActivity(mine); setNow(Date.now());
}, [wallet, snapshot?.chainId]);
useEffect(() => {
version.current++; setPage(undefined); setActivity({ listings: [], orders: [] }); setReview(undefined);
let active = true;
const load = () => { void refresh().catch(e => { if (active) setError(e instanceof Error ? e.message : 'Could not load market.'); }); };
if (rpcAvailable) load();
const timer = setInterval(() => { setNow(Date.now()); if (rpcAvailable && !inFlight.current) load(); }, 15000);
return () => { active = false; version.current++; clearInterval(timer); };
}, [refresh, rpcAvailable]);
const prepare = (action: MarketAction, text: string) => { setError(''); setReview({ action, text }); };
const submit = async () => {
if (!wallet || !page || !review || inFlight.current || !rpcAvailable) return;
const currentVersion = version.current;
inFlight.current = true; setBusy(true); setError('');
try {
const action = review.action.type === 'deliver' ? { ...review.action, transfer: { ...review.action.transfer,
signature: await sign(wallet, nftTransferMessage(review.action.transfer)) } } : review.action;
await command(wallet, page.info, action);
if (currentVersion !== version.current) return;
const type = review.action.type;
setReview(undefined);
if (type === 'buy' || type === 'mock-pay' || type === 'deliver' || type === 'refund') setTab('orders');
onNotice(type === 'deliver' ? 'Delivery submitted. Simulated seller payout waits for canonical confirmations.' : 'Marketplace updated. All XMR payments and payouts are simulations.');
await refresh();
} catch (e) { setError(e instanceof Error ? e.message : 'Marketplace action failed.'); }
finally { inFlight.current = false; setBusy(false); }
};
const listed = new Set(activity.listings.filter(l => l.status === 'open' || l.status === 'reserved').map(l => l.cardHash));
const owned = snapshot?.blocks.filter(b => wallet && testnetOwnerOf(snapshot, b.tokenId) === wallet.address &&
snapshot.blocks.length + 1 - b.height >= CONSENSUS.coinbaseMaturity && !listed.has(b.tokenId) &&
!snapshot.pendingTransfers.some(tx => transactionCardIds(tx).includes(b.tokenId))) ?? [];
const create = () => {
if (!snapshot || !page || !owned.some(b => b.tokenId === cardHash)) { setError('Select an eligible card first.'); return; }
try {
const priceCents = parseUsd(price), duration = Number(hours);
if (kind === 'auction' && (!Number.isFinite(duration) || duration < 1 / 60 || duration > 720)) throw new Error('Choose an auction duration from one minute to 30 days.');
prepare({ type: 'list', cardHash, sequence: snapshot.transfers.filter(tx => transactionCardIds(tx).includes(cardHash)).length,
kind, priceCents, endsAt: kind === 'auction' ? Date.now() + Math.round(duration * 3600_000) : null, feeBps: page.info.feeBps },
`List ${owned.find(b => b.tokenId === cardHash)!.article.title} ${kind === 'fixed' ? 'for' : 'with a starting bid of'} ${formatUsd(priceCents)}. ${feeDisclosure(page.info.feeBps)} ${kind === 'auction' ? `Auction lasts ${duration} hours. You cannot cancel after the first bid.` : 'The listing stays open until sold or cancelled.'} Listing does not lock the card on-chain.`);
} catch (e) { setError(e instanceof Error ? e.message : 'Invalid price.'); }
};
const deliver = async (o: MarketOrder) => {
if (!wallet || !page) return;
try {
const tx = { network: NETWORK, chainId: page.info.chainId, cardHash: o.cardHash,
from: wallet.address, to: o.buyer, sequence: o.sequence };
const transfer = { ...tx, publicKey: wallet.publicKey, signature: '' }; // Sign only after explicit review confirmation.
prepare({ type: 'deliver', orderId: o.id, transfer },
`Transfer card ${short(o.cardHash)} permanently to ${short(o.buyer)}. Only mock payment has been received. This signature submits a real NFT transfer on the selected network. Simulated net proceeds: ${o.sellerNetXmr} XMR, after ${o.feeXmr} XMR market fee${o.additionalFee ? ` and ${o.additionalFee.amountXmr} XMR additional fee (${o.additionalFee.bps / 100}%) to ${o.additionalFee.address}` : ' (legacy order: no additional fee)'}.`);
} catch { setError('Could not prepare delivery signature.'); }
};
const loadMore = async () => {
if (!page || page.next === null) return;
try {
const next = await api<Page>(undefined, page.next);
if (next.info.marketId !== page.info.marketId || next.info.chainId !== page.info.chainId) throw new Error('Market changed. Refresh first.');
setPage(current => current ? { ...next, listings: [...new Map([...current.listings, ...next.listings].map(l => [l.id, l])).values()] } : next);
} catch (e) { setError(e instanceof Error ? e.message : 'Could not load listings.'); }
};
const disabled = busy || !wallet || !rpcAvailable || !page;
return <section className="marketplace">
<header className="market-hero"><div><div className="section-eyebrow">THE COLLECTOR’S EXCHANGE</div><h1>A new home for<br/><span>extraordinary finds.</span></h1><p>Discover a fixed-price favorite or join a timed auction. Prices in USD, checkout in XMR. Your keys always sign card delivery.</p></div><div className="market-symbol"><ShoppingBag size={52}/><span>KNOWLEDGE CHANGES HANDS</span></div></header>
<div className="market-simulation" role="note"><ShieldNote/><div><strong>Mock payments only · never send real XMR</strong><p>Quotes, payment confirmations, refunds, fees, and automatic seller payouts are simulated. Card delivery is a real on-chain transfer. There is no payable Monero address. Listings are local to this marketplace node, not shared by peers.</p></div><span className="market-mode">SANDBOX</span></div>
<div className="market-toolbar"><div className="market-tabs" role="group" aria-label="Marketplace section">{(['browse','sell','orders'] as const).map(t=><button key={t} className={tab===t?'selected':''} onClick={()=>setTab(t)}>{t==='browse'?'Explore market':t==='sell'?'Sell a card':'Your activity'}</button>)}</div><button className="text-button" onClick={()=>{setError('');void refresh().catch(e=>setError(String(e.message)));}} disabled={busy||!rpcAvailable}><RefreshCw size={14}/>Refresh</button></div>
{page&&<><div className="market-meta"><span>Market fee <strong>{page.info.feeBps/100}% + 1% additional</strong> · {(page.info.feeBps+ADDITIONAL_MARKET_FEE.bps)/100}% total deducted from seller</span><span>Delivery <strong>{page.info.deliveryConfirmations} confirmations</strong> before mock payout</span><span>Simulation rate <strong>${page.info.mockUsdPerXmr}/XMR</strong> · not a live quote</span></div><p className="market-fee-recipient">Fixed 1% fee recipient · simulated payout only, not a checkout address: <span>{ADDITIONAL_MARKET_FEE.address}</span></p></>}
{!rpcAvailable&&<p className="market-error" role="alert">Reconnect to the node before using the marketplace.</p>}
{!wallet&&<p className="muted">Open your local wallet to list cards, bid, and check out.</p>}
{error&&<p className="market-error" role="alert">{error}</p>}
{review&&<section className="market-review" aria-label="Review marketplace signature" ref={reviewPanel} tabIndex={-1}><div className="section-eyebrow">REVIEW BEFORE SIGNING</div><p>{review.text}</p><div className="market-actions"><button className="button dark" onClick={submit} disabled={!!disabled}>{busy?<LoaderCircle size={16} className="spin"/>:<Check size={16}/>}Sign and confirm</button><button className="button outline" onClick={()=>setReview(undefined)} disabled={busy}>Back</button></div></section>}
{tab==='browse'&&<><div className="market-filter" role="group" aria-label="Listing type">{(['all','fixed','auction'] as const).map(f=><button key={f} onClick={()=>setFilter(f)} className={filter===f?'selected':''}>{f==='all'?'All discoveries':f==='fixed'?'Buy now':'Auctions'}</button>)}</div><div className="market-grid">{page?.listings.filter(l=>filter==='all'||l.kind===filter).map(l=>{
const ended=l.kind==='auction'&&now>=l.endsAt!, mine=l.seller===wallet?.address;
const salePrice=l.highestBid?.priceCents??l.priceCents;
const article=snapshot?.blocks.find(b=>b.tokenId===l.cardHash)?.article;
return <article className="market-card" key={l.id}><div className="market-art"><img src={l.image} alt="" loading="lazy"/><span>{l.kind==='auction'?<Gavel size={12}/>:<Tag size={12}/>} {l.kind==='auction'?'Timed auction':'Fixed price'}</span></div><div className="market-card-body"><small>BY {short(l.seller)} {mine?'· YOU':''}</small><h2>{l.title}</h2><div className="market-price"><strong>{formatUsd(salePrice)}</strong><span>{l.kind==='fixed'?'XMR at checkout':l.highestBid?'Highest bid':'Starting bid'}</span></div>{l.kind==='auction'&&<p className="market-countdown"><Clock3 size={13}/>{deadline(l.endsAt!,now)}{ended&&l.highestBid?` · winner ${short(l.highestBid.bidder)}`:''}</p>}
{l.status==='reserved'?<p className="market-reserved">Reserved · awaiting settlement</p>:mine?<button className="button outline full" disabled={!!disabled||!!l.highestBid} onClick={()=>prepare({type:'cancel',listingId:l.id},`Cancel the listing for ${l.title}?`)}>Cancel listing</button>:l.kind==='fixed'||ended?<button className="button dark full" disabled={!!disabled||(ended&&l.highestBid?.bidder!==wallet?.address)} onClick={()=>prepare({type:'buy',listingId:l.id,priceCents:salePrice,feeBps:l.feeBps},`Reserve ${l.title} for ${formatUsd(salePrice)} with simulated XMR checkout. ${feeDisclosure(l.feeBps)}. Complete mock payment within 30 minutes ${ended?'of auction closing':'of reservation'}.`)}><ShoppingBag size={14}/>{ended?'Winner checkout':'Buy now'}</button>:<div className="market-bid"><label className="sr-only" htmlFor={`bid-${l.id}`}>Your USD bid for {l.title}</label><input id={`bid-${l.id}`} inputMode="decimal" value={bids[l.id]??''} placeholder={formatUsd(l.highestBid?salePrice+1:salePrice)} onChange={e=>setBids(v=>({...v,[l.id]:e.target.value}))}/><button className="button dark" disabled={!!disabled} onClick={()=>{try{const priceCents=parseUsd(bids[l.id]??'');prepare({type:'bid',listingId:l.id,priceCents},`Bid ${formatUsd(priceCents)} on ${l.title}. If you win, check out within 30 minutes of closing. Bids are signed but not funded; all payment is simulated.`);}catch(e){setError(e instanceof Error?e.message:'Invalid bid');}}}><Gavel size={14}/>Bid</button></div>}
<p className="market-card-foot">{l.feeBps/100}% market + {(l.additionalFee?.bps ?? 0)/100}% additional seller fee · NFT {short(l.cardHash)}</p>{article&&<p className="market-card-foot"><a href={article.url} target="_blank" rel="noreferrer">Wikipedia</a> · <a href={imageCredit(article)} target="_blank" rel="noreferrer">Image & license</a> · <a href={`/api/nfts/${l.cardHash}`} target="_blank" rel="noreferrer">NFT metadata</a></p>}</div></article>;
})}</div>{!page?.listings.length&&<div className="market-empty"><ShoppingBag size={30}/><h2>A discovery worth sharing?</h2><p>The market is waiting for its first listing. Offer a card from your confirmed collection.</p><button className="button dark" onClick={()=>setTab('sell')}>List your first card</button></div>}{page?.next!==null&&page?.next!==undefined&&<button className="button outline" onClick={loadMore}>Load more listings</button>}</>}
{tab==='sell'&&<section className="market-sell"><div><div className="section-eyebrow">MAKE ROOM FOR THE NEXT DISCOVERY</div><h2>Put a card on the market.</h2><p>Keep a set price open indefinitely, or let collectors bid until the clock runs out. You retain custody of the card and must return to sign delivery after mock payment.</p><p>A listing does not lock a card in consensus. Transfers elsewhere can invalidate it. No cancellation after an auction’s first bid; the winner gets 30 minutes to pay. Seller delivery deadline is 48 hours after mock payment.</p></div><form onSubmit={e=>{e.preventDefault();create();}}><label htmlFor="market-card">YOUR MATURE, UNLISTED CARD</label><select id="market-card" value={cardHash} onChange={e=>setCardHash(e.target.value)} required><option value="">Choose a card…</option>{owned.map(b=><option key={b.tokenId} value={b.tokenId}>{b.article.title} · {short(b.tokenId)}</option>)}</select><label htmlFor="market-kind">SALE FORMAT</label><select id="market-kind" value={kind} onChange={e=>setKind(e.target.value as typeof kind)}><option value="fixed">Fixed price · no expiry</option><option value="auction">Timed auction</option></select><label htmlFor="market-price">{kind==='fixed'?'PRICE':'STARTING BID'} · USD</label><input id="market-price" inputMode="decimal" value={price} onChange={e=>setPrice(e.target.value)} required/>{kind==='auction'&&<><label htmlFor="market-hours">AUCTION LENGTH · HOURS</label><input id="market-hours" type="number" min="0.016667" max="720" step="any" value={hours} onChange={e=>setHours(e.target.value)} required/></>}<p className="muted">Fees: {page?(page.info.feeBps/100)+'% market + 1% additional':'—'} · both subtracted from seller proceeds, never added to the buyer price.</p><button className="button dark" type="submit" disabled={!!disabled||!owned.length}><Tag size={15}/>Review listing</button></form></section>}
{tab==='orders'&&<section className="market-activity">
<h2>Orders & settlement</h2>
<p className="muted">Private order details require your wallet signature. No private keys leave this browser. Simulated payouts are automatic only after confirmed card delivery.</p>
{activity.orders.length===0&&<p>No orders yet. Explore the market to find your next discovery.</p>}
{activity.orders.map(o=>{
const buyer=o.buyer===wallet?.address;
return <article className="market-order" key={o.id}>
<header><div><strong>{snapshot?.blocks.find(b=>b.tokenId===o.cardHash)?.article.title??short(o.cardHash)}</strong><small>{buyer?'Buying from':'Selling to'} {short(buyer?o.seller:o.buyer)} · {short(o.id)}</small></div><span className="market-state">{o.status.replaceAll('-',' ')}</span></header>
<dl>
<div><dt>Sale</dt><dd>{formatUsd(o.priceCents)}</dd></div>
<div><dt>Mock checkout</dt><dd>{o.invoice.amountXmr} XMR</dd></div>
<div><dt>Market fee ({o.feeBps/100}%)</dt><dd>{o.feeXmr} XMR</dd></div>
{o.additionalFee && <div><dt>Additional fee ({o.additionalFee.bps/100}%) · simulated</dt><dd>{o.additionalFee.amountXmr} XMR · {o.additionalFee.payout}<span className="market-fee-recipient">Recipient: {o.additionalFee.address}</span></dd></div>}
<div><dt>Seller net · simulated</dt><dd>{o.sellerNetXmr} XMR</dd></div>
</dl>
<p className="muted">{o.status==='awaiting-payment'?`Pay by ${new Date(o.payBy).toLocaleString()}`:o.status==='paid'?`Seller signs delivery by ${new Date(o.deliverBy).toLocaleString()}`:`Delivery: ${o.deliveryConfirmations}/${o.requiredConfirmations} confirmations · payout: ${o.payout}`}</p>
<div className="market-actions">
{buyer&&o.status==='awaiting-payment'&&<button className="button dark" disabled={!!disabled} onClick={()=>prepare({type:'mock-pay',orderId:o.id},`Simulate receipt of ${o.invoice.amountXmr} XMR for order ${short(o.id)}. This sends no money and is not a real payment confirmation.`)}>Simulate XMR payment</button>}
{!buyer&&(o.status==='paid'||o.status==='delivery-pending')&&<button className="button dark" disabled={!!disabled} onClick={()=>void deliver(o)}>Review & sign card delivery</button>}
{buyer&&o.status==='refund-required'&&<button className="button outline" disabled={!!disabled} onClick={()=>prepare({type:'refund',orderId:o.id},'Simulate a full buyer refund after failed delivery. No real funds will move.')}>Simulate full refund</button>}
</div>
{o.status==='review'&&<p className="market-error">A chain reorganization affected settled delivery. Operator review required; no second payout is submitted.</p>}
</article>;
})}
<h2>Your listing history</h2>{activity.listings.map(l=><div className="market-history" key={l.id}><strong>{l.title}</strong><span>{formatUsd(l.highestBid?.priceCents??l.priceCents)}</span><span>{l.status}</span></div>)}
</section>}
</section>;
}
function ShieldNote() { return <span className="market-note-icon"><Clock3 size={20}/></span>; }
Executable
+3
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -1,7 +1,8 @@
import { decryptWallet, encryptWallet, type WalletBackup } from '../shared/wallet-backup.ts';
import { IS_MAINNET } from '../shared/network.ts';
export type Wallet = { privateKey: CryptoKey; publicKey: string; address: string };
const DATABASE = 'wikideck-testnet-wallet-v2';
const DATABASE = IS_MAINNET ? 'wikideck-mainnet-wallet-v1' : 'wikideck-testnet-wallet-v2';
const LOCK = `${DATABASE}:identity`;
const encode = (bytes: ArrayBuffer) => btoa(String.fromCharCode(...new Uint8Array(bytes)));
+32 -2
View File
@@ -6,7 +6,7 @@ import { constants, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { generateKeyPairSync, sign } from 'node:crypto';
import { generateKeyPairSync, randomUUID, sign } from 'node:crypto';
import { DatabaseSync } from 'node:sqlite';
import { setTimeout as delay } from 'node:timers/promises';
import { RandomX } from '../server/testnet/randomx.ts';
@@ -14,6 +14,8 @@ import { address, CHAIN_ID, GENESIS_HASH, MAX_TARGET, miningBlob, mintId, sha256
transferId, workFor } from '../server/testnet/consensus.ts';
import { NETWORK, CONSENSUS, blockClaimMessage, nftTransferMessage,
type MiningTemplate, type NftTransfer, type TestnetBlock, type TestnetSnapshot } from '../shared/testnet.ts';
import { marketMessage, type MarketAction, type MarketInfo, type MarketListing, type MarketOrder,
type MarketActivity } from '../shared/market.ts';
const root = fileURLToPath(new URL('../', import.meta.url));
const wikipediaMock = resolve(root, 'tests', 'mock-wikipedia.mjs');
@@ -81,7 +83,8 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
env: { ...process.env, HOST: '127.0.0.1', PORT: '0', NODE_ENV: 'production',
WIKIDECK_DATA_DIR: dataDir, RANDOMX_BINARY: binary,
WIKIDECK_PEERS: peer ?? '', WIKIDECK_ALLOW_PRIVATE_PEERS: peer ? '1' : '0',
PUBLIC_ORIGIN: 'http://127.0.0.1:3001' },
PUBLIC_ORIGIN: 'http://127.0.0.1:3001', WIKIDECK_PAYMENT_MODE: 'mock',
WIKIDECK_MARKET_FEE_BPS: '250', WIKIDECK_MARKET_CONFIRMATIONS: '2' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
@@ -131,6 +134,11 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
return (await request(base, path, expected, body)).json() as Promise<T>;
}
const snapshot = (base: string) => json<TestnetSnapshot>(base, '/api/chain');
async function marketCommand<T>(base: string, info: MarketInfo, who: ReturnType<typeof wallet>, action: MarketAction) {
const terms = { network: NETWORK, chainId: CHAIN_ID, marketId: info.marketId, actor: who.owner,
publicKey: who.publicKey, nonce: randomUUID(), expiresAt: Date.now() + 60000, action };
return json<T>(base, '/api/market/command', 200, { ...terms, signature: who.signed(marketMessage(terms)) });
}
async function waitFor<T>(label: string, read: () => Promise<T>, predicate: (value: T) => boolean): Promise<T> {
const until = Date.now() + 25_000;
let value: T;
@@ -248,6 +256,19 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
assert.equal(aliceDeck.tip, block2.hash);
assert.equal(aliceDeck.total, 2);
assert.deepEqual(aliceDeck.nfts.map(item => item.block.tokenId), [block1.tokenId, block2.tokenId]);
const market = await json<{ info: MarketInfo; listings: MarketListing[] }>(first.base, '/api/market');
assert.equal(market.info.paymentMode, 'mock');
assert.deepEqual(market.listings, []);
await (await request(first.base, '/api/market/command', 400, {})).text();
await (await request(first.base, '/api/market/command', 403, {}, { Origin: 'https://evil.example' })).text();
const sale = await marketCommand<MarketListing>(first.base, market.info, alice, { type: 'list',
cardHash: block1.tokenId, sequence: 0, kind: 'fixed', priceCents: 1500, endsAt: null, feeBps: 250 });
assert.equal((await json<{ listings: MarketListing[] }>(first.base, '/api/market')).listings[0].id, sale.id);
const order = await marketCommand<MarketOrder>(first.base, market.info, bob, { type: 'buy', listingId: sale.id,
priceCents: 1500, feeBps: 250 });
assert.equal(order.invoice.address, null);
assert.equal(order.invoice.amountXmr, '0.100000000000');
await marketCommand(first.base, market.info, bob, { type: 'mock-pay', orderId: order.id });
await (await request(first.base, `/api/decks/${'z'.repeat(64)}`, 400)).text();
// A truly independent process reconstructs the canonical chain over HTTP and checks real work.
@@ -262,6 +283,8 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
const badTransfer = await json<{ error: string }>(second.base, '/api/transfer', 400,
{ ...transfer, signature: tamperSignature(transfer.signature) });
assert.match(badTransfer.error, /signature/i);
await marketCommand(first.base, market.info, alice, { type: 'deliver', orderId: order.id, transfer });
assert.equal((await marketCommand<MarketActivity>(first.base, market.info, bob, { type: 'activity' })).orders[0].status, 'delivery-pending');
const pending = { id: transferId(transfer), status: 'pending',
message: 'Ownership changes only when this transaction is mined.' };
assert.deepEqual(await json(second.base, '/api/transfer', 202, transfer), pending);
@@ -299,6 +322,10 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
assert.deepEqual(canonical.blocks, [block1, block2, block3]);
assert.deepEqual(canonical.transfers, [transfer]);
assert.deepEqual(canonical.pendingTransfers, []);
const settling = await marketCommand<MarketActivity>(first.base, market.info, bob, { type: 'activity' });
assert.equal(settling.orders[0].deliveryConfirmations, 1);
assert.equal(settling.orders[0].payout, 'not-started');
assert.equal((await json<{ listings: MarketListing[] }>(second.base, '/api/market')).listings.length, 0);
assert.equal(canonical.cumulativeWork, (3n * workFor(MAX_TARGET)).toString());
for (const node of [first, second]) {
const replay = await json<{ error: string }>(node.base, '/api/transfer', 400, transfer);
@@ -331,6 +358,9 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
}
first = await start(firstDir); // Ledger.open revalidates every persisted block using native RandomX.
const restored = await snapshot(first.base);
const restoredMarket = await json<{ info: MarketInfo }>(first.base, '/api/market');
assert.equal(restoredMarket.info.marketId, market.info.marketId);
assert.equal((await marketCommand<MarketActivity>(first.base, market.info, bob, { type: 'activity' })).orders[0].id, order.id);
assert.deepEqual(restored, { ...canonical, peers: 0 });
assert.deepEqual(await json(first.base, `/api/nfts/${block1.tokenId}`), {
network: NETWORK, chainId: CHAIN_ID, tokenId: block1.tokenId, owner: bob.owner, sequence: 1,
+290
View File
@@ -0,0 +1,290 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { generateKeyPairSync, randomUUID, sign } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { DatabaseSync } from 'node:sqlite';
import { Market, marketConfig } from '../server/market/store.ts';
import { mockQuote, paymentMode } from '../server/market/payments.ts';
import { ADDITIONAL_MARKET_FEE, marketMessage, parseUsd, type MarketAction, type MarketListing, type MarketOrder,
type MarketActivity } from '../shared/market.ts';
import { NETWORK, nftTransferMessage, type NftTransfer, type TestnetSnapshot, type TestnetBlock } from '../shared/testnet.ts';
import { address, CHAIN_ID, GENESIS_HASH, transferId, verifySignature } from '../server/testnet/consensus.ts';
function identity() {
const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
return { publicKey, actor: address(publicKey), signed: (text: string) => sign('sha256', Buffer.from(text),
{ key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') };
}
const alice = identity(), bob = identity(), carol = identity();
// Unit-test chain boundary. Production always uses Ledger + reference RandomX; no selectable fake PoW.
function chainFixture() {
const mint = { tokenId: 'a'.repeat(64), height: 1, owner: alice.actor,
article: { title: 'A collector’s discovery', image: 'https://upload.wikimedia.org/test.jpg' }, transactions: [] } as unknown as TestnetBlock;
const state: TestnetSnapshot = { network: NETWORK, chainId: CHAIN_ID, genesis: GENESIS_HASH,
tip: 'b'.repeat(64), blocks: [mint, { ...mint, tokenId: 'c'.repeat(64), height: 2 }],
transfers: [], pendingTransfers: [], peers: 0, algorithm: 'RandomX', maturity: 2, cumulativeWork: '2' };
return { state, snapshot: () => structuredClone(state), submitTransfer: async (raw: unknown) => {
const tx = raw as NftTransfer;
assert.ok(verifySignature(tx.publicKey, tx.from, nftTransferMessage(tx), tx.signature));
const id = transferId(tx);
if (!state.pendingTransfers.some(t => transferId(t as NftTransfer) === id)) state.pendingTransfers.push(tx);
return id;
} };
}
async function fixture(t: { after(fn: () => Promise<void>): void }) {
const dir = await mkdtemp(join(tmpdir(), 'wikideck-market-'));
const chain = chainFixture();
let time = 1_800_000_000_000;
let market = await Market.open(dir, chain, { feeBps: 250, confirmations: 2, usdCentsPerXmr: 15000 }, () => time);
t.after(async () => { market.close(); await rm(dir, { recursive: true, force: true }); });
const envelope = (who: typeof alice, action: MarketAction) => {
const terms = { network: NETWORK, chainId: CHAIN_ID, marketId: market.id, actor: who.actor,
publicKey: who.publicKey, nonce: randomUUID(), expiresAt: time + 60000, action };
return { ...terms, signature: who.signed(marketMessage(terms)) };
};
const run = <T = MarketListing | MarketOrder>(who: typeof alice, action: MarketAction) => market.execute(envelope(who, action)) as Promise<T>;
const list = (kind: 'fixed' | 'auction' = 'fixed') => run<MarketListing>(alice, { type: 'list', cardHash: chain.state.blocks[0].tokenId,
sequence: 0, kind, priceCents: 1500, endsAt: kind === 'auction' ? time + 60000 : null, feeBps: 250 });
const buy = (l: MarketListing, who = bob, priceCents = l.priceCents) => run<MarketOrder>(who,
{ type: 'buy', listingId: l.id, priceCents, feeBps: l.feeBps });
const orders = async (who = bob) => (await run<MarketActivity>(who, { type: 'activity' })).orders;
const delivery = (o: MarketOrder, to = bob) => {
const terms = { network: NETWORK, chainId: CHAIN_ID, cardHash: o.cardHash, from: alice.actor, to: to.actor, sequence: o.sequence };
return { ...terms, publicKey: alice.publicKey, signature: alice.signed(nftTransferMessage(terms)) };
};
const confirm = (tx: NftTransfer) => {
const height = chain.state.blocks.length + 1;
chain.state.blocks.push({ ...chain.state.blocks[0], height, transactions: [tx] });
chain.state.transfers.push(tx); chain.state.pendingTransfers = [];
};
return { dir, chain, envelope, run, list, buy, orders, delivery, confirm,
get market() { return market; }, get now() { return time; }, advance: (ms: number) => { time += ms; },
restart: async () => { market.close(); market = await Market.open(dir, chain,
{ feeBps: 500, confirmations: 5, usdCentsPerXmr: 20000 }, () => time); } };
}
test('USD and mock XMR accounting use bounded integer amounts and exact fee splitting', () => {
assert.equal(parseUsd('15.01'), 1501); assert.equal(parseUsd('0.01'), 1);
for (const invalid of ['0', '-1', '1e3', '1.001', ' 1', 'NaN', '1000001', '01.00']) assert.throws(() => parseUsd(invalid));
const quote = mockQuote(1500, 250, 15000);
assert.equal(quote.invoice.amountXmr, '0.100000000000');
assert.equal(quote.feeXmr, '0.002500000000');
assert.equal(quote.sellerNetXmr, '0.096500000000');
assert.deepEqual(quote.additionalFee, { bps: 100,
address: '44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv',
amountXmr: '0.001000000000', payout: 'not-started' });
assert.equal(quote.invoice.address, null);
assert.equal(mockQuote(1, 0, 3).invoice.amountXmr, '0.333333333334');
assert.equal(paymentMode({}), 'mock');
for (const mode of ['live','shkeeper','testnet','']) assert.throws(() => paymentMode({ WIKIDECK_PAYMENT_MODE: mode }));
for (const value of ['-1','5001','250.5','x','']) assert.throws(() => marketConfig({ WIKIDECK_MARKET_FEE_BPS: value }));
assert.equal(marketConfig({ WIKIDECK_MARKET_FEE_BPS: '0' }).feeBps, 0);
const noHostingFee = mockQuote(1500, 0, 15000);
assert.equal(noHostingFee.additionalFee.amountXmr, '0.001000000000');
assert.equal(noHostingFee.sellerNetXmr, '0.099000000000');
const atomic = (text: string) => BigInt(text.replace('.', ''));
for (const cents of [1, 19, 1500, 100_000_000]) for (const bps of [0, 250, 5000]) {
const q = mockQuote(cents, bps, 19999);
const gross = atomic(q.invoice.amountXmr);
assert.equal(atomic(q.additionalFee.amountXmr), gross * 100n / 10000n);
assert.equal(atomic(q.feeXmr) + atomic(q.additionalFee.amountXmr) + atomic(q.sellerNetXmr), gross);
assert.ok(atomic(q.sellerNetXmr) >= 0n);
}
});
test('market commands bind actor, instance, network, expiry, and every listing term', async t => {
const f = await fixture(t);
const original = f.envelope(alice, { type: 'list', cardHash: f.chain.state.blocks[0].tokenId, sequence: 0,
kind: 'fixed', priceCents: 1500, endsAt: null, feeBps: 250 });
for (const bad of [
{ ...original, actor: bob.actor }, { ...original, marketId: randomUUID() },
{ ...original, chainId: '0'.repeat(64) }, { ...original, expiresAt: f.now - 1 },
{ ...original, expiresAt: f.now + 600_000 },
{ ...original, action: { ...original.action, priceCents: 1 } },
]) await assert.rejects(async () => f.market.execute(bad), /signature/);
await assert.rejects(async () => f.market.execute({ ...original, network: 'wrong-network' }));
const first = await f.market.execute(original);
const message = JSON.parse(marketMessage(original));
assert.equal(message[1], 'market-command-v2');
assert.deepEqual(message.at(-1), [100, ADDITIONAL_MARKET_FEE.address]);
message[message.length - 1] = [0, ADDITIONAL_MARKET_FEE.address];
await assert.rejects(async () => f.market.execute({ ...original, signature: alice.signed(JSON.stringify(message)) }), /signature/);
assert.deepEqual(await f.market.execute(original), first);
const changed = { ...original, action: { ...original.action, priceCents: 1600 } };
await assert.rejects(async () => f.market.execute({ ...changed, signature: alice.signed(marketMessage(changed)) }), /Nonce/);
assert.equal(f.market.browse().listings.length, 1);
});
test('listings reject unknown, immature, stale, foreign, duplicate, and pending cards', async t => {
const f = await fixture(t);
const base = { type: 'list' as const, cardHash: f.chain.state.blocks[0].tokenId, sequence: 0,
kind: 'fixed' as const, priceCents: 1500, endsAt: null, feeBps: 250 };
await assert.rejects(f.run(bob, base), /Card/);
await assert.rejects(f.run(alice, { ...base, cardHash: '0'.repeat(64) }), /Card/);
await assert.rejects(f.run(alice, { ...base, sequence: 1 }), /Card/);
await assert.rejects(f.run(alice, { ...base, feeBps: 0 }), /fee changed/);
f.chain.state.blocks.pop();
await assert.rejects(f.run(alice, base), /Card/);
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 2 });
f.chain.state.pendingTransfers.push(f.delivery({ cardHash: base.cardHash, sequence: 0 } as MarketOrder));
await assert.rejects(f.run(alice, base), /pending/);
f.chain.state.pendingTransfers = [];
const listing = await f.list();
await assert.rejects(f.list(), /already listed/);
await f.restart();
assert.equal(f.market.browse().listings[0].id, listing.id);
await f.run(alice, { type: 'cancel', listingId: listing.id });
assert.equal(f.market.browse().listings.length, 0);
});
test('fixed-price reservations are exclusive, idempotent, private and expire without charging', async t => {
const f = await fixture(t), listing = await f.list();
await assert.rejects(f.buy(listing, alice), /own card/);
const request = f.envelope(bob, { type: 'buy', listingId: listing.id, priceCents: 1500, feeBps: 250 });
const results = await Promise.allSettled([f.market.execute(request), f.buy(listing, carol)]);
assert.equal(results[0].status, 'fulfilled'); assert.equal(results[1].status, 'rejected');
const order = (results[0] as PromiseFulfilledResult<MarketOrder>).value;
assert.deepEqual(await f.market.execute(request), order);
assert.equal((await f.orders(carol)).length, 0);
assert.equal((await f.orders(alice)).length, 1);
await assert.rejects(f.run(carol, { type: 'mock-pay', orderId: order.id }), /Only the order buyer/);
await assert.rejects(f.run(alice, { type: 'cancel', listingId: listing.id }), /available/);
f.advance(30 * 60000);
assert.equal((await f.orders())[0].status, 'expired');
assert.equal(f.market.browse().listings[0].status, 'open');
await f.buy(listing, carol);
});
test('auction bids are signed, increasing, deadline-bound, uncancellable, and winner-only', async t => {
const f = await fixture(t), l = await f.list('auction');
await assert.rejects(f.buy(l), /winner/);
await assert.rejects(f.run(alice, { type: 'bid', listingId: l.id, priceCents: 1500 }), /own card/);
await assert.rejects(f.run(bob, { type: 'bid', listingId: l.id, priceCents: 1499 }), /Bid/);
await f.run(bob, { type: 'bid', listingId: l.id, priceCents: 1500 });
await assert.rejects(f.run(carol, { type: 'bid', listingId: l.id, priceCents: 1500 }), /Bid/);
await assert.rejects(f.run(alice, { type: 'cancel', listingId: l.id }), /cannot be cancelled/);
await f.run(carol, { type: 'bid', listingId: l.id, priceCents: 1600 });
f.advance(60000);
await assert.rejects(f.run(bob, { type: 'bid', listingId: l.id, priceCents: 1700 }), /not accepting/);
await assert.rejects(f.buy(l, bob, 1600), /winner/);
await assert.rejects(f.buy(l, carol, 1500), /terms changed/);
const order = await f.buy(l, carol, 1600);
assert.equal(order.priceCents, 1600); assert.equal(order.payBy, f.now + 30 * 60000);
assert.equal(order.additionalFee?.bps, 100);
assert.equal(order.additionalFee?.address, ADDITIONAL_MARKET_FEE.address);
});
test('auctions without bids or unpaid winners expire without a silent runner-up sale', async t => {
const f = await fixture(t), l = await f.list('auction');
f.advance(60000); assert.equal(f.market.browse().listings.length, 0);
const next = await f.list('auction');
await f.run(bob, { type: 'bid', listingId: next.id, priceCents: 1500 });
f.advance(31 * 60000);
assert.equal(f.market.browse().listings.length, 0);
await assert.rejects(f.buy(next), /available/);
});
test('mock payment cannot release a card: seller signature, exact buyer, and canonical depth required', async t => {
const f = await fixture(t), l = await f.list(), o = await f.buy(l);
await assert.rejects(f.run(alice, { type: 'deliver', orderId: o.id, transfer: f.delivery(o) }), /paid order seller/);
await f.run(bob, { type: 'mock-pay', orderId: o.id });
await assert.rejects(f.run(bob, { type: 'deliver', orderId: o.id, transfer: f.delivery(o) }), /paid order seller/);
await assert.rejects(f.run(alice, { type: 'deliver', orderId: o.id, transfer: f.delivery(o, carol) }), /match this sale/);
const transfer = f.delivery(o);
await f.run(alice, { type: 'deliver', orderId: o.id, transfer });
assert.equal((await f.orders())[0].payout, 'not-started');
assert.equal((await f.orders())[0].additionalFee?.payout, 'not-started');
f.confirm(transfer);
assert.equal((await f.orders())[0].status, 'delivery-pending');
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] });
const completed = (await f.orders())[0];
assert.equal(completed.status, 'complete'); assert.equal(completed.payout, 'mock-paid');
assert.equal(completed.feeXmr, '0.002500000000'); assert.equal(completed.sellerNetXmr, '0.096500000000');
assert.equal(completed.additionalFee?.amountXmr, '0.001000000000');
assert.equal(completed.additionalFee?.address, ADDITIONAL_MARKET_FEE.address);
assert.equal(completed.additionalFee?.payout, 'mock-paid');
assert.equal(f.market.browse().listings.length, 0);
});
test('settlement policy and quote survive restart/config changes; reorg never triggers duplicate payout', async t => {
const f = await fixture(t), l = await f.list(), o = await f.buy(l);
await f.run(bob, { type: 'mock-pay', orderId: o.id });
const tx = f.delivery(o);
await f.run(alice, { type: 'deliver', orderId: o.id, transfer: tx });
f.confirm(tx);
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] });
assert.equal((await f.orders())[0].status, 'complete');
const id = f.market.id;
await f.restart(); assert.equal(f.market.id, id);
const restored = (await f.orders())[0];
assert.equal(restored.status, 'complete'); assert.equal(restored.requiredConfirmations, 2);
assert.equal(restored.invoice.amountXmr, o.invoice.amountXmr);
assert.equal(restored.feeBps, 250);
assert.equal(restored.additionalFee?.address, ADDITIONAL_MARKET_FEE.address);
assert.equal(restored.additionalFee?.amountXmr, '0.001000000000');
assert.equal(restored.additionalFee?.payout, 'mock-paid');
f.chain.state.blocks.splice(2); f.chain.state.transfers = [];
assert.equal((await f.orders())[0].status, 'review');
f.confirm(tx); f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] });
assert.equal((await f.orders())[0].status, 'review');
assert.equal((await f.orders())[0].payout, 'review');
assert.equal((await f.orders())[0].additionalFee?.payout, 'review');
});
test('failed seller delivery offers a full mock refund, never a seller payout', async t => {
const f = await fixture(t), l = await f.list(), o = await f.buy(l);
await f.run(bob, { type: 'mock-pay', orderId: o.id });
await assert.rejects(f.run(bob, { type: 'refund', orderId: o.id }), /Refund requires/);
f.advance(48 * 3600_000);
assert.equal((await f.orders())[0].status, 'refund-required');
await f.run(bob, { type: 'refund', orderId: o.id });
assert.equal((await f.orders())[0].status, 'refunded');
assert.equal((await f.orders())[0].payout, 'not-started');
assert.equal((await f.orders())[0].additionalFee?.payout, 'not-started');
assert.equal(f.market.browse().listings.length, 0);
});
test('stale listings are removed and changed cards cannot be paid for', async t => {
const f = await fixture(t), l = await f.list(), o = await f.buy(l);
f.chain.state.transfers.push(f.delivery(o, carol));
await assert.rejects(f.run(bob, { type: 'mock-pay', orderId: o.id }), /Seller card changed/);
f.advance(30 * 60000);
await f.orders(); assert.equal(f.market.browse().listings.length, 0);
});
test('old open listings require fresh seller fee authorization and cannot be bought', async t => {
const f = await fixture(t), l = await f.list();
const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
try {
delete l.additionalFee;
db.prepare('UPDATE listings SET data=? WHERE id=?').run(JSON.stringify(l), l.id);
} finally { db.close(); }
assert.equal(f.market.browse().listings.length, 0);
await assert.rejects(f.buy(l), /available/);
assert.equal((await f.list()).additionalFee?.bps, 100);
});
test('existing reserved orders retain their agreed net; the additional fee is never retroactive', async t => {
const f = await fixture(t), l = await f.list(), order = await f.buy(l);
const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
try {
delete l.additionalFee;
delete order.additionalFee;
order.sellerNetXmr = '0.097500000000';
db.prepare('UPDATE listings SET data=? WHERE id=?').run(JSON.stringify({ ...l, status: 'reserved' }), l.id);
db.prepare('UPDATE orders SET data=? WHERE id=?').run(JSON.stringify(order), order.id);
} finally { db.close(); }
await f.restart();
await f.run(bob, { type: 'mock-pay', orderId: order.id });
const tx = f.delivery(order);
await f.run(alice, { type: 'deliver', orderId: order.id, transfer: tx });
f.confirm(tx);
for (let i = 0; i < 4; i++) f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4 + i, transactions: [] });
const restored = (await f.orders())[0];
assert.equal(restored.status, 'complete');
assert.equal(restored.additionalFee, undefined);
assert.equal(restored.sellerNetXmr, '0.097500000000');
});
+87
View File
@@ -0,0 +1,87 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { NETWORK, CONSENSUS } from '../shared/testnet.ts';
import { networkProfile } from '../shared/network.ts';
import { CHAIN_ID, GENESIS_TIME, GENESIS_HASH, MAX_TARGET } from '../server/testnet/consensus.ts';
import { paymentMode } from '../server/market/payments.ts';
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,
}).trim();
}
test('default testnet v6 manifest is unchanged and unknown network profiles fail closed', () => {
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.throws(() => networkProfile('production')); assert.throws(() => networkProfile(''));
});
test('mainnet candidate has distinct deterministic genesis, signatures and strict transaction schemas', () => {
const output = JSON.parse(script(`
import { NETWORK, nftTransferMessage } from './shared/testnet.ts';
import { CHAIN_ID, GENESIS_HASH, transferSchema } from './server/testnet/consensus.ts';
console.log(JSON.stringify({ NETWORK, CHAIN_ID, GENESIS_HASH,
message:nftTransferMessage({chainId:CHAIN_ID,cardHash:'a'.repeat(64),from:'b'.repeat(64),to:'c'.repeat(64),sequence:0}),
acceptsTestnet:transferSchema.safeParse({network:'wikideck-randomx-testnet-6',chainId:${JSON.stringify(CHAIN_ID)},
cardHash:'a'.repeat(64),from:'b'.repeat(64),to:'c'.repeat(64),sequence:0,publicKey:'AA==',signature:'A'.repeat(86)+'=='}).success }));
`));
assert.equal(output.NETWORK, 'wikideck-randomx-mainnet-1');
assert.notEqual(output.CHAIN_ID, CHAIN_ID); assert.notEqual(output.GENESIS_HASH, GENESIS_HASH);
assert.match(output.message, /wikideck-randomx-mainnet-1/); assert.equal(output.acceptsTestnet, false);
});
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 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' }));
assert.equal(manifest.profile, 'mainnet'); assert.equal(manifest.paymentMode, 'mock'); assert.equal(manifest.audited, false);
assert.equal(manifest.launchStatus, 'candidate-gated');
});
test('mainnet gates require genesis time, chain-specific approval, HTTPS, independent peers, and mock payment', () => {
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',
PUBLIC_ORIGIN:'https://market.example',WIKIDECK_PEERS:'https://peer-a.example,https://peer-b.example'};
const results=[];
for(const [changes,now] of [[{},0],[{WIKIDECK_MAINNET_LAUNCH:'wrong'},Infinity],
[{PUBLIC_ORIGIN:'http://market.example'},Infinity],[{WIKIDECK_PEERS:''},Infinity],
[{WIKIDECK_PEERS:'https://peer-a.example,https://peer-a.example/'},Infinity],
[{WIKIDECK_ALLOW_PRIVATE_PEERS:'1'},Infinity],[{WIKIDECK_PAYMENT_MODE:'shkeeper'},Infinity],[{},Infinity]]) {
try { assertLaunchAllowed({...env,...changes},now); results.push(true); } catch { results.push(false); }
}
console.log(JSON.stringify(results));
`));
assert.deepEqual(result, [false,false,false,false,false,false,false,true]);
assert.throws(() => paymentMode({ WIKIDECK_NETWORK: 'mainnet', WIKIDECK_PAYMENT_MODE: 'shkeeper' }));
});
test('mainnet ledger and market files use isolated folders; testnet backup headers are rejected', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wikideck-network-'));
try {
const result = JSON.parse(script(`
import { Ledger } from './server/testnet/ledger.ts';
import { Market } from './server/market/store.ts';
import { decryptWallet } from './shared/wallet-backup.ts';
import { existsSync } from 'node:fs'; import { join } from 'node:path';
const ledger=await Ledger.open(process.env.PROFILE_TEST_DIR,{hash:async()=>{throw Error('unused')},close(){}});
const market=await Market.open(process.env.PROFILE_TEST_DIR,ledger);
let rejected=false;
try {await decryptWallet({format:'wikideck-wallet-backup',version:1,network:'wikideck-randomx-testnet-6',
kdf:'PBKDF2-SHA256',iterations:600000,cipher:'AES-256-GCM',salt:'',iv:'',publicKey:'',ciphertext:''},'password');}
catch(e){rejected=/network/.test(e.message);}
console.log(JSON.stringify({ledger:existsSync(join(process.env.PROFILE_TEST_DIR,'mainnet','ledger.sqlite')),
market:existsSync(join(process.env.PROFILE_TEST_DIR,'mainnet','market.sqlite')),
testnet:existsSync(join(process.env.PROFILE_TEST_DIR,'testnet')),rejected}));
market.close();ledger.close();
`, { PROFILE_TEST_DIR: dir }));
assert.deepEqual(result, { ledger: true, market: true, testnet: false, rejected: true });
} finally { await rm(dir, { recursive: true, force: true }); }
});
+1 -1
View File
@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx",
"strict": true, "skipLibCheck": true, "esModuleInterop": true,
"strict": true, "skipLibCheck": true, "esModuleInterop": true, "erasableSyntaxOnly": true,
"allowImportingTsExtensions": true, "resolveJsonModule": true,
"noEmit": true, "types": ["node", "vite/client"]
},