diff --git a/README.md b/README.md index 326d7a6..67adc6b 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ Stop the nodes with: docker compose -f compose.testnet.yaml down ``` -The target block interval is **15 minutes**. The volumes persist across stops and rebuilds. **Do not add `-v` unless you intend to delete all three ledgers.** Back up wallets separately. Protocol v5 uses a new chain identity; earlier v2/v3/v4 ledgers and encrypted backups are not compatible. To keep old Docker data intact while trying v5, use a separate Compose project name, for example `docker compose -p wikideck-v5 -f compose.testnet.yaml up --build -d`, and use the same `-p wikideck-v5` for subsequent Compose commands. The default `docker compose up --build -d` uses [compose.yaml](compose.yaml): it starts one isolated node on port 3001 with a different volume. +The target block interval is **15 minutes**. The volumes persist across stops and rebuilds. **Do not add `-v` unless you intend to delete all three ledgers.** Back up wallets separately. Protocol v6 adds atomic multi-card trades and a new chain identity; earlier v2–v5 ledgers and encrypted backups are not compatible. To keep old Docker data intact while trying v6, use a separate Compose project name, for example `docker compose -p wikideck-v6 -f compose.testnet.yaml up --build -d`, and use the same `-p wikideck-v6` for subsequent Compose commands. The default `docker compose up --build -d` uses [compose.yaml](compose.yaml): it starts one isolated node on port 3001 with a different volume. -For an existing host install, keep the old data directory intact and set `WIKIDECK_DATA_DIR` to a new empty directory before starting protocol v5. The node intentionally fails closed on an old-protocol ledger; it does not reset or migrate it. An existing v5 JSON ledger is imported into SQLite on first startup and left in place as a recovery copy. +For an existing host install, keep the old data directory intact and set `WIKIDECK_DATA_DIR` to a new empty directory before starting protocol v6. The node intentionally fails closed on an old-protocol ledger; it does not reset or migrate it. A v6 JSON ledger is imported into SQLite on first startup and left in place as a recovery copy. ## Run on your host @@ -72,6 +72,7 @@ Light mode needs a 256 MiB cache plus scratchpad and process overhead. `--fast` - **Discover** shows preview examples until the node has mined blocks; previews are not owned NFTs. Minted cards can reference the same page, but each carries its own captured stats snapshot. - **My collection** shows NFTs owned by the active local wallet on the connected node's canonical chain. - **Block explorer** distinguishes block IDs, NFT token IDs, and RandomX proof hashes. A submitted transfer is pending until a block confirms it. Reorganizations can change canonical ownership and confirmations. +- **Trade 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. - 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. diff --git a/docs/testnet-consensus.md b/docs/testnet-consensus.md index 754dcec..7b31bb3 100644 --- a/docs/testnet-consensus.md +++ b/docs/testnet-consensus.md @@ -17,7 +17,7 @@ implementation-specific, and changing them can create a different chain. ## Network, genesis and article selection -- Network: `wikideck-randomx-testnet-5`, block version **5**. Genesis has no blocks, NFTs or work; first mined height is 1. No privileged issuance/admin mint key. Version 5 has a new chain identity; prior-version ledger files and wallet backups are not compatible. +- Network: `wikideck-randomx-testnet-6`, block version **6**. Genesis has no blocks, NFTs or work; first mined height is 1. No privileged issuance/admin mint key. Version 6 adds atomic NFT trade bundles and has a new chain identity; prior-version ledger files and wallet backups are not compatible. - `GENESIS_TIME = 1792411200` (Unix seconds). Let `S(x)` mean lowercase hex SHA-256 of UTF-8 `x`, and `J(x)` mean compact JavaScript `JSON.stringify(x)`; field order below is consensus-critical. - `MAX_TARGET` is `0fff` followed by 60 `f` digits. `CHAIN_ID = S(J([NETWORK, CONSENSUS, GENESIS_TIME, MAX_TARGET]))`, using the constants object's insertion order in the implementation. `CONSENSUS` identifies the random Wikipedia source and mint-statistics snapshot format. - `GENESIS_HASH = S(NETWORK + ':genesis:' + CHAIN_ID)`; `INITIAL_KEY = S(NETWORK + ':initial-key:' + CHAIN_ID)`. Nodes independently derive the same manifest and reject other network/chain IDs. @@ -49,40 +49,44 @@ The miner signs `J([NETWORK, 'block-claim', blockID])` using ECDSA P-256/SHA-256 ## Native NFT transition and commitments -Each block executes its ordered transfers against its parent's NFT state, **then mints one NFT** to the block owner. State entries contain owner, sequence (initially 0), minted height and page ID. Transfers cannot spend this block's new issuance. +Each block executes its ordered transactions against its parent's NFT state, **then mints one NFT** to the block owner. State entries contain owner, sequence (initially 0), minted height and page ID. Transactions cannot spend this block's new issuance. -A transfer carries network, chain ID, `cardHash` (**NFT token ID**, despite the legacy field name), from, to, sequence, public key and signature. Its signed message is `J([NETWORK, 'nft-transfer', chainId, cardHash, from, to, sequence])`. **Transfer ID is SHA-256 of that message, excluding ECDSA signature bytes** to avoid signature-malleability identity changes. Require the current owner, matching public key, a different recipient, exact current sequence, valid signature, and inclusion height minus mint height **>= 2**; increment sequence on application. Replays and duplicate IDs within a block fail. +An individual transfer carries network, chain ID, `cardHash` (**NFT token ID**, despite the legacy field name), from, to, sequence, public key and signature. Its signed message is `J([NETWORK, 'nft-transfer', chainId, cardHash, from, to, sequence])`. **Transfer ID is SHA-256 of that message, excluding ECDSA signature bytes** to avoid signature-malleability identity changes. Require the current owner, matching public key, a different recipient, exact current sequence, valid signature, and inclusion height minus mint height **>= 2**; increment sequence on application. Replays and duplicate IDs within a block fail. -`transactionsRoot` commits the **ordered transfer IDs**. `stateRoot` commits the complete **post-transfer, post-mint** state: sort token IDs lexicographically; each input leaf is `S(J([id, owner, sequence, mintedHeight, pageId]))`. `merkleRoot` returns `S(NETWORK + ':empty')` for no leaves. For nonempty input, hash each raw 32-byte input leaf with byte prefix `0x00`; hash pairs of raw children with byte prefix `0x01`, duplicating an odd final child at each layer. Finally return `S(J([NETWORK, 'merkle', originalLeafCount, treeRootHex]))`. Count commitment prevents odd-padding ambiguity; leaf/node prefixes separate domains. These commitment semantics, the RandomX version, and big-endian proof convention are pinned in the genesis consensus manifest. +A trade is one `type: 'trade'` transaction with proposer and counterparty addresses, ordered offered/requested NFT legs (token ID plus current sequence), and one P-256 signature from each party over `J([NETWORK, 'nft-trade', chainId, proposer, counterparty, offeredLegs, requestedLegs])`, where each leg is `[cardHash, sequence]`. Both public keys must derive the respective party addresses. Each side must contain 1–8 distinct NFTs; duplicates across either side are rejected. At inclusion, validation checks **all** selected NFTs against the same pre-trade state, owners, sequences, maturity, and both signatures before writing any changes. If any leg is invalid, the entire trade is rejected; if valid, all offered ownerships move to the counterparty and all requested ownerships to the proposer, with each sequence incremented once. A trade counts as one mempool and block transaction and is committed by one transaction ID. It cannot be partially included or partially replayed. Offers are not reservations; stale offers fail validation. -The mempool holds at most **256** ordered transfers, validating sequentially against the next inclusion height; a template takes the first **64**. The protocol can accept chained transfers of the same NFT in order; the browser conservatively blocks a second pending transfer for a card. A 202 response changes **no canonical ownership**. Mint at height `h` is eligible for inclusion at `h+2` (pool admission can happen at tip `h+1`). Confirmations are `tipHeight - mintedHeight + 1`, not a finality guarantee. +`transactionsRoot` commits the **ordered transaction IDs** (individual transfer IDs or atomic trade IDs). `stateRoot` commits the complete **post-transaction, post-mint** state: sort token IDs lexicographically; each input leaf is `S(J([id, owner, sequence, mintedHeight, pageId]))`. `merkleRoot` returns `S(NETWORK + ':empty')` for no leaves. For nonempty input, hash each raw 32-byte input leaf with byte prefix `0x00`; hash pairs of raw children with byte prefix `0x01`, duplicating an odd final child at each layer. Finally return `S(J([NETWORK, 'merkle', originalLeafCount, treeRootHex]))`. Count commitment prevents odd-padding ambiguity; leaf/node prefixes separate domains. These commitment semantics, the RandomX version, and big-endian proof convention are pinned in the genesis consensus manifest. + +The mempool holds at most **256** ordered transactions (transfers or trade bundles), validating sequentially against the next inclusion height; a template takes the first **64 transaction units**. A 202 response changes **no canonical ownership**. Mint at height `h` is eligible for inclusion at `h+2` (pool admission can happen at tip `h+1`). Confirmations are `tipHeight - mintedHeight + 1`, not a finality guarantee. ## Fork choice, reorgs and storage Per-block work is `floor(2^256 / (target + 1))`; accumulate from genesis work zero. Keep all valid known-parent branches. Adopt a tip only for **strictly greater cumulative work**; equal-work ties retain the current tip, including across replay of persisted insertion order. A shorter, harder branch can win. -On reorg, reconstruct ownership from the winning branch. Orphan mints disappear from canonical ownership; disconnected transfers are reconsidered before existing pending entries, in order, only if still valid and not already committed. Conflicting, immature or orphan-issuance transfers are discarded. No checkpoints/finality or rollback protection beyond observed work; an isolated node has no peer-backed security. +On reorg, reconstruct ownership from the winning branch. Orphan mints disappear from canonical ownership; disconnected transactions are reconsidered before existing pending entries, in order, only if still valid and not already committed. A trade is requeued as one unit only when every leg remains valid; otherwise the complete bundle is discarded. No checkpoints/finality or rollback protection beyond observed work; an isolated node has no peer-backed security. -The [ledger](../server/testnet/ledger.ts) is stored in `data/testnet/ledger.sqlite`, under the `WIKIDECK_DATA_DIR` testnet subdirectory (Docker uses `/app/data`). SQLite WAL mode with full synchronous commits stores each accepted block once in an indexed table and replaces the bounded mempool in the same transaction; writes no longer serialize/rewrite the entire DAG, and there is no 128 MiB/10,000-block ledger cap. Existing v5 `ledger.json` files are validated and imported transactionally once; the source JSON is left untouched as a recovery copy. Back up the complete stopped-node data directory, including SQLite database/WAL files. Do not copy only `ledger.sqlite` while the node is running. +The [ledger](../server/testnet/ledger.ts) is stored in `data/testnet/ledger.sqlite`, under the `WIKIDECK_DATA_DIR` testnet subdirectory (Docker uses `/app/data`). SQLite WAL mode with full synchronous commits stores each accepted block once in an indexed table and replaces the bounded mempool in the same transaction; writes no longer serialize/rewrite the entire DAG, and there is no 128 MiB/10,000-block ledger cap. A matching v6 legacy `ledger.json` is schema-checked and imported transactionally once; the source JSON is left untouched as a recovery copy. Earlier-protocol ledgers are not compatible and fail closed. Back up the complete stopped-node data directory, including SQLite database/WAL files. Do not copy only `ledger.sqlite` while the node is running. -The in-memory DAG keeps block records and cumulative work, while each block's NFT state is a persistent overlay containing only that block's changes rather than a full state copy. State-root calculation still visits the complete NFT state, and startup still revalidates every stored proof; CPU and startup work therefore grow with chain size. This is materially more storage-efficient, but not yet an archival-scale chain database or a public DoS defense. Blocks remain individually limited to **64 KiB and 64 transfers**; the mempool remains capped at **256 transfers**. Mutations are serialized (queue cap 256). HTTP startup exclusively creates a data-directory lock named `testnet-node.lock`. It is removed on normal shutdown, **not automatically after a crash**: stop and verify all owning processes have exited before manual stale-lock removal. Never bypass a live lock or share a writer directory. Back up node data and wallet exports separately; deleting a volume cannot be recovered from a wallet alone. +The in-memory DAG keeps block records and cumulative work, while each block's NFT state is a persistent overlay containing only that block's changes rather than a full state copy. State-root calculation still visits the complete NFT state, and startup still revalidates every stored proof; CPU and startup work therefore grow with chain size. This is materially more storage-efficient, but not yet an archival-scale chain database or a public DoS defense. Blocks remain individually limited to **64 KiB and 64 transaction units**; the mempool remains capped at **256 transaction units**. Mutations are serialized (queue cap 256). HTTP startup exclusively creates a data-directory lock named `testnet-node.lock`. It is removed on normal shutdown, **not automatically after a crash**: stop and verify all owning processes have exited before manual stale-lock removal. Never bypass a live lock or share a writer directory. Back up node data and wallet exports separately; deleting a volume cannot be recovered from a wallet alone. ## Fixed peers and HTTP surface Peers are **explicit operator-configured URL origins**, up to 16, not discovered from network input. Default: HTTPS/public addresses only, checked and pinned during DNS lookup; no credentials, paths, queries, fragments or redirects. `WIKIDECK_ALLOW_PRIVATE_PEERS=1` permits private/HTTP destinations only by explicit operator choice (used for local Docker tests). No bootnodes, DHT, automatic public deployment or claimed Sybil resistance. -Sync polls every **5 seconds**, checks network/chain ID, imports at most **32** topologically ordered DAG blocks per peer/pass, and validates remote pool entries locally. Accepted blocks/transfers are gossiped to available configured peers; busy-peer gossip is dropped, with polling for catch-up. Responses are capped at 2 MiB with a 10-second deadline. Peer count is current outbound connectivity metadata, not consensus membership or a security score. +Sync polls every **5 seconds**, checks network/chain ID, imports at most **32** topologically ordered DAG blocks per peer/pass, and validates remote pool entries locally. Accepted blocks and transactions (including complete trade bundles) are gossiped to configured peers; busy-peer gossip is dropped, with polling for catch-up. Responses are capped at 2 MiB with a 10-second deadline. Peer count is current outbound connectivity metadata, not consensus membership or a security score. | Route | Purpose | | --- | --- | | `GET /healthz` | Process health/network, not sync or consensus security. | -| `GET /api/state`, `/api/chain` | Canonical blocks, mined/pending transfers, tip/work and peer count. | +| `GET /api/state`, `/api/chain` | Canonical blocks, mined/pending transactions, tip/work and peer count. | | `GET /api/nfts/:id`, `/api/peers` | Canonical NFT owner/sequence/confirmations, article and at-mint statistics; configured peer status. | +| `GET /api/decks/:address?offset=0&limit=24` | Public canonical deck for a wallet address at one tip; paged, maximum 24 cards per response. This reveals public address holdings. | | `GET /api/wikipedia/:pageId` | Cached current enrichment for positive English Wikipedia page IDs: categories, views, size, languages and display-only scores. Never a consensus input. | | `POST /api/mining/template` | Rate-limited public miner identity; fetches a random article with a picture plus fresh at-mint statistics. Returns 503 when either cannot be fetched. | | `POST /api/blocks` | Independently validates a signed block, proof, article snapshot, required Wikimedia image URL, and statistics snapshot consistency. Repeated page IDs are accepted. | -| `POST /api/transfer` | Signed transfer admission; 202 means pending. | +| `POST /api/transfer` | Signed single-card transfer admission; 202 means pending. | +| `POST /api/trades` | Validates and admits one signed, atomic multi-card trade bundle; 202 means the whole bundle is pending. | | `GET /p2p/status`, `/p2p/blocks?offset=0`, `/p2p/mempool` | Manifest/size/tip, 32-block inventory pages, pending transactions. | -| `POST /p2p/block`, `/p2p/transaction` | Same validation as local block/transfer submission. | +| `POST /p2p/block`, `/p2p/transaction` | Same validation as local block/transaction submission; transaction endpoint accepts a transfer or atomic trade. | -POSTs require JSON, enforce supplied browser origins and signatures where applicable; peers without an Origin header are not authenticated by this check. HTTP parsing is capped at 68 KiB; API/P2P rate limits are 300/minute and block/transfer POSTs additionally 30/minute. There are no fees, peer authentication or economic spam deterrents. Same-origin browser RPC trusts the node; it does not independently verify these commitments. Deploy no funds: majority-hashpower, eclipse, censorship, reorg and resource-exhaustion risks require independent audits and adversarial testing before any broader launch. +POSTs require JSON, enforce supplied browser origins and signatures where applicable; peers without an Origin header are not authenticated by this check. HTTP parsing is capped at 68 KiB; API/P2P rate limits are 300/minute and block/transaction/trade submissions additionally 30/minute. There are no 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. diff --git a/scripts/testnet-smoke.ts b/scripts/testnet-smoke.ts index 72b6753..311e002 100644 --- a/scripts/testnet-smoke.ts +++ b/scripts/testnet-smoke.ts @@ -4,7 +4,8 @@ import { generateKeyPairSync, sign } from 'node:crypto'; import { setTimeout as delay } from 'node:timers/promises'; import { RandomX } from '../server/testnet/randomx.ts'; import { address, CHAIN_ID, miningBlob, mintId, sha256 } from '../server/testnet/consensus.ts'; -import { blockClaimMessage, nftTransferMessage, NETWORK, testnetOwnerOf, type MiningTemplate, type TestnetSnapshot } from '../shared/testnet.ts'; +import { blockClaimMessage, nftTransferMessage, NETWORK, testnetOwnerOf, transactionCardIds, + type MiningTemplate, type TestnetSnapshot } from '../shared/testnet.ts'; if(!process.argv.includes('--consent'))throw new Error('This test mines three blocks with a native CPU worker. Pass --consent explicitly. Disposable NFT rewards are not recoverable.'); const nodes=(process.env.WIKIDECK_SMOKE_NODES??'http://127.0.0.1:3101,http://127.0.0.1:3102,http://127.0.0.1:3103').split(','); @@ -31,7 +32,7 @@ try { await api(nodes[0],'/api/transfer',{...tx,publicKey,signature:signed(nftTransferMessage(tx))}); const pending=await api(nodes[0],'/api/state') as TestnetSnapshot; assert.equal(testnetOwnerOf(pending,first),owner); - assert.ok(pending.pendingTransfers.some(t=>t.cardHash===first)); + assert.ok(pending.pendingTransfers.some(t=>transactionCardIds(t).includes(first))); } const template=await api(nodes[0],'/api/mining/template',{owner,publicKey}) as MiningTemplate; let found; diff --git a/server/testnet/consensus.ts b/server/testnet/consensus.ts index 69db9a4..dc9c8f6 100644 --- a/server/testnet/consensus.ts +++ b/server/testnet/consensus.ts @@ -1,8 +1,8 @@ import { createHash, createPublicKey, verify } from 'node:crypto'; import { z } from 'zod'; -import { NETWORK, CONSENSUS, headerMessage, blockClaimMessage, nftTransferMessage, statsSnapshotTuple, +import { NETWORK, CONSENSUS, headerMessage, blockClaimMessage, nftTransferMessage, nftTradeMessage, statsSnapshotTuple, type BlockHeader, type TestnetBlock, type NftTransfer, type NftState, type NftStateLayer, - type NftStateView, type MiningTemplate } from '../../shared/testnet.ts'; + isNftTrade, type NftStateView, type NftTrade, type TestnetTransaction, type MiningTemplate } from '../../shared/testnet.ts'; import type { Article } from '../../shared/protocol.ts'; import { deriveStats, type MintStatsSnapshot } from '../../shared/enrichment.ts'; import type { PowEngine } from './randomx.ts'; @@ -20,6 +20,25 @@ const signature = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).length(88); const integer = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); export const transferSchema = z.object({ network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID), cardHash: hex, from: hex, to: hex, sequence: integer, publicKey: key, signature }).strict(); +const tradeLegSchema = z.object({ cardHash: hex, sequence: integer }).strict(); +export const tradeSchema = z.object({ type: z.literal('trade'), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID), + proposer: hex, counterparty: hex, offered: z.array(tradeLegSchema).min(1).max(CONSENSUS.maxTradeCards), + requested: z.array(tradeLegSchema).min(1).max(CONSENSUS.maxTradeCards), + proposerPublicKey: key, proposerSignature: signature, counterpartyPublicKey: key, + counterpartySignature: signature }).strict().superRefine((trade, ctx) => { + const offered = new Set(trade.offered.map(leg => leg.cardHash)); + const requested = new Set(trade.requested.map(leg => leg.cardHash)); + if (trade.proposer === trade.counterparty) ctx.addIssue({ code: 'custom', message: 'Trade parties must differ' }); + if (offered.size !== trade.offered.length || requested.size !== trade.requested.length || + trade.requested.some(leg => offered.has(leg.cardHash))) { + ctx.addIssue({ code: 'custom', message: 'Trade cards must be unique and cannot appear on both sides' }); + } + if (!verifySignature(trade.proposerPublicKey, trade.proposer, nftTradeMessage(trade), trade.proposerSignature) || + !verifySignature(trade.counterpartyPublicKey, trade.counterparty, nftTradeMessage(trade), trade.counterpartySignature)) { + ctx.addIssue({ code: 'custom', message: 'Trade requires valid signatures from both parties' }); + } + }); +export const transactionSchema = z.union([transferSchema, tradeSchema]); const articleSchema = z.object({ pageId: integer.positive(), title: z.string().trim().min(1).max(200), extract: z.string().trim().min(1).max(1000), image: z.string().url().max(1500), url: z.string().url().max(500), revisionId: integer.positive(), @@ -71,10 +90,10 @@ const statsSnapshotSchema = z.object({ capturedAt: z.string().datetime(), revisi ctx.addIssue({ code: 'custom', message: 'Derived statistics do not match the page metrics', path: ['stats'] }); } }); -export const blockSchema = z.object({ version: z.literal(5), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID), +export const blockSchema = z.object({ version: z.literal(6), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID), height: integer, previousHash: hex, createdAt: integer, article: articleSchema, statsSnapshot: statsSnapshotSchema, owner: hex, publicKey: key, nonce: integer, target: hex, key: hex, transactionsRoot: hex, stateRoot: hex, - hash: hex, tokenId: hex, powHash: hex, signature, transactions: z.array(transferSchema).max(CONSENSUS.maxTransactions) }).strict(); + hash: hex, tokenId: hex, powHash: hex, signature, transactions: z.array(transactionSchema).max(CONSENSUS.maxTransactions) }).strict(); export function address(publicKey: string): string { return sha256(Buffer.from(publicKey, 'base64')); } export function verifySignature(publicKey: string, owner: string, message: string, sig: string): boolean { @@ -91,6 +110,10 @@ export function transferId(t: NftTransfer): string { // Exclude malleable ECDSA signature bytes from transaction identity. return sha256(nftTransferMessage(t)); } +export function transactionId(transaction: TestnetTransaction): string { + if (isNftTrade(transaction)) return sha256(nftTradeMessage(transaction)); + return transferId(transaction); +} export function merkleRoot(leaves: string[]): string { if (!leaves.length) return sha256(`${NETWORK}:empty`); let layer = leaves.map(leaf => sha256(Buffer.concat([Buffer.from([0]), Buffer.from(leaf,'hex')]))); @@ -179,14 +202,42 @@ export function applyTransferOverlay(state: NftStateView, tx: NftTransfer, heigh changes[tx.cardHash] = { ...nft, owner: tx.to, sequence: tx.sequence + 1 }; return { kind: 'layer', parent: state, changes }; } -export function transition(parent: NftStateView, header: BlockHeader, txs: NftTransfer[]): NftStateLayer { +export function applyTradeOverlay(state: NftStateView, trade: NftTrade, height: number): NftStateLayer { + const parsed = tradeSchema.parse(trade); + const allLegs = [...parsed.offered, ...parsed.requested]; + const current = allLegs.map(leg => stateEntry(state, leg.cardHash)); + if (current.some((entry, index) => !entry || entry.sequence !== allLegs[index].sequence || + height - entry.mintedHeight < CONSENSUS.coinbaseMaturity)) { + throw new Error('Trade contains missing, stale, or immature NFTs'); + } + if (current.slice(0, parsed.offered.length).some(entry => entry!.owner !== parsed.proposer) || + current.slice(parsed.offered.length).some(entry => entry!.owner !== parsed.counterparty)) { + throw new Error('Trade NFT owners do not match the signed parties'); + } + const changes: NftState = Object.create(null) as NftState; + parsed.offered.forEach((leg, index) => { + const nft = current[index]!; + changes[leg.cardHash] = { ...nft, owner: parsed.counterparty, sequence: nft.sequence + 1 }; + }); + parsed.requested.forEach((leg, index) => { + const nft = current[parsed.offered.length + index]!; + changes[leg.cardHash] = { ...nft, owner: parsed.proposer, sequence: nft.sequence + 1 }; + }); + return { kind: 'layer', parent: state, changes }; +} +export function applyTransactionOverlay(state: NftStateView, transaction: TestnetTransaction, height: number): NftStateLayer { + return isNftTrade(transaction) + ? applyTradeOverlay(state, transaction, height) + : applyTransferOverlay(state, transaction, height); +} +export function transition(parent: NftStateView, header: BlockHeader, txs: TestnetTransaction[]): NftStateLayer { let next = parent; const ids = new Set(); - for (const tx of txs) { - const id = transferId(tx); + for (const transaction of txs) { + const id = transactionId(transaction); if (ids.has(id)) throw new Error('Duplicate transfer'); ids.add(id); - next = applyTransferOverlay(next, tx, header.height); + next = applyTransactionOverlay(next, transaction, header.height); } const id = mintId(header); if (stateEntry(next, id)) throw new Error('Duplicate NFT issuance'); @@ -195,16 +246,16 @@ export function transition(parent: NftStateView, header: BlockHeader, txs: NftTr return { kind: 'layer', parent: next, changes }; } export function makeTemplate(ancestors: TestnetBlock[], state: NftStateView, owner: string, - publicKey: string, transactions: NftTransfer[], article: Article, statsSnapshot: MintStatsSnapshot, + publicKey: string, transactions: TestnetTransaction[], article: Article, statsSnapshot: MintStatsSnapshot, now = Math.floor(Date.now()/1000)): MiningTemplate { if (!hex.safeParse(owner).success || address(publicKey) !== owner) throw new Error('Invalid miner identity'); const parsedArticle = articleSchema.parse(article); const parsedStats = statsSnapshotSchema.parse(statsSnapshot); const previousHash = ancestors.at(-1)?.hash ?? GENESIS_HASH; - const header: BlockHeader = { version: 5, network: NETWORK, chainId: CHAIN_ID, + const header: BlockHeader = { version: 6, network: NETWORK, chainId: CHAIN_ID, height: ancestors.length + 1, previousHash, createdAt: Math.max(now, medianTime(ancestors) + 1), article: parsedArticle, statsSnapshot: parsedStats, owner, publicKey, nonce: 0, target: expectedTarget(ancestors), - key: expectedKey(ancestors, ancestors.length + 1), transactionsRoot: merkleRoot(transactions.map(transferId)), stateRoot: '' }; + key: expectedKey(ancestors, ancestors.length + 1), transactionsRoot: merkleRoot(transactions.map(transactionId)), stateRoot: '' }; header.stateRoot = stateRoot(transition(state, header, transactions)); return { header, transactions, blob: miningBlob(header).toString('hex'), nonceOffset: 32 }; } @@ -215,7 +266,7 @@ export async function validateBlock(raw: unknown, ancestors: TestnetBlock[], par if (b.height !== ancestors.length + 1 || b.previousHash !== (ancestors.at(-1)?.hash ?? GENESIS_HASH)) throw new Error('Invalid parent/height'); if (b.createdAt <= medianTime(ancestors) || b.createdAt > now + CONSENSUS.maxFutureSeconds) throw new Error('Invalid block timestamp'); if (b.target !== expectedTarget(ancestors) || b.key !== expectedKey(ancestors, b.height)) throw new Error('Invalid difficulty or RandomX epoch key'); - if (b.transactionsRoot !== merkleRoot(b.transactions.map(transferId))) throw new Error('Invalid transaction root'); + if (b.transactionsRoot !== merkleRoot(b.transactions.map(transactionId))) throw new Error('Invalid transaction root'); const next = transition(parentState, b, b.transactions); if (b.stateRoot !== stateRoot(next) || b.tokenId !== mintId(b)) throw new Error('Invalid NFT state commitment'); const blob = miningBlob(b); diff --git a/server/testnet/http.ts b/server/testnet/http.ts index c3d42eb..85090a9 100644 --- a/server/testnet/http.ts +++ b/server/testnet/http.ts @@ -6,7 +6,8 @@ import { fileURLToPath } from 'node:url'; import { mkdir, open, unlink } from 'node:fs/promises'; import { z } from 'zod'; import { NETWORK } from '../../shared/testnet.ts'; -import { CHAIN_ID, blockSchema, transferSchema } from './consensus.ts'; +import { testnetOwnerOf, transactionCardIds } from '../../shared/testnet.ts'; +import { CHAIN_ID, blockSchema, tradeSchema, transferSchema, transactionSchema } from './consensus.ts'; import { RandomX } from './randomx.ts'; import { Ledger } from './ledger.ts'; import { PeerNetwork } from './peers.ts'; @@ -53,6 +54,17 @@ app.use(['/api','/p2p'],(req,res,next)=>{ app.get('/api/state',(_req,res)=>res.json(ledger.snapshot())); app.get('/api/chain',(_req,res)=>res.json(ledger.snapshot())); app.get('/api/peers',(_req,res)=>res.json({peers:peers.status()})); +app.get('/api/decks/:address', rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}), (req,res)=>{ + if(typeof req.params.address!=='string'||!/^[a-f0-9]{64}$/.test(req.params.address)){ + res.status(400).json({error:'Invalid wallet address'});return; + } + const offset=Number(req.query.offset??0),limit=Number(req.query.limit??24); + if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(limit)||limit<1||limit>24){ + res.status(400).json({error:'Invalid deck page'});return; + } + res.setHeader('Cache-Control','no-store'); + res.json(ledger.deck(req.params.address,offset,limit)); +}); // Optional live data is intentionally not used by genesis, block validation or NFT ownership. app.get('/api/wikipedia/:pageId', rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}), async(req,res)=>{ if(typeof req.params.pageId!=='string'||!/^[1-9]\d{0,9}$/.test(req.params.pageId)){res.status(400).json({error:'Invalid Wikipedia page ID'});return;} @@ -71,8 +83,8 @@ app.get('/p2p/mempool',(_req,res)=>res.json({transactions:ledger.snapshot().pend app.get('/api/nfts/:id', (req,res)=>{ const s=ledger.snapshot();const block=s.blocks.find(b=>b.tokenId===req.params.id); if(!block){res.status(404).json({error:'NFT not found on the canonical chain'});return;} - let owner=block.owner,sequence=0; - for(const tx of s.transfers)if(tx.cardHash===block.tokenId){owner=tx.to;sequence++;} + const owner=testnetOwnerOf(s,block.tokenId)??block.owner; + const sequence=s.transfers.filter(tx=>transactionCardIds(tx).includes(block.tokenId)).length; res.json({network:NETWORK,chainId:CHAIN_ID,tokenId:block.tokenId,owner,sequence,blockHash:block.hash, confirmations:s.blocks.length-block.height+1,article:block.article,statsSnapshot:block.statsSnapshot, stateRoot:s.blocks.at(-1)?.stateRoot}); @@ -95,12 +107,23 @@ app.post(['/api/blocks','/p2p/block'],workLimit,async(req,res)=>{ res.status(result.accepted?201:200).json(result); if(result.accepted)void peers.broadcastBlock(block).catch(()=>{}); }); -app.post(['/api/transfer','/p2p/transaction'],workLimit,async(req,res)=>{ +app.post('/api/transfer',workLimit,async(req,res)=>{ const tx=transferSchema.parse(req.body); const id=await ledger.submitTransfer(tx); res.status(202).json({id,status:'pending',message:'Ownership changes only when this transaction is mined.'}); void peers.broadcastTransfer(tx).catch(()=>{}); }); +app.post('/api/trades',workLimit,async(req,res)=>{ + const trade=tradeSchema.parse(req.body); + const id=await ledger.submitTransaction(trade); + res.status(202).json({id,status:'pending',message:'The complete multi-card trade is pending; ownership changes atomically when it is mined.'}); + void peers.broadcastTransaction(trade).catch(()=>{}); +}); +app.post('/p2p/transaction',workLimit,async(req,res)=>{ + const transaction=transactionSchema.parse(req.body); + const id=await ledger.submitTransaction(transaction); + res.status(202).json({id,status:'pending'}); +}); app.use(['/api','/p2p'],(_req,res)=>{res.status(404).json({error:'Unknown testnet endpoint'});}); app.use(express.static(resolve(root,'dist'))); app.get('/{*path}',(_req,res)=>res.sendFile(resolve(root,'dist/index.html'))); diff --git a/server/testnet/ledger.ts b/server/testnet/ledger.ts index c8f85d8..e98322d 100644 --- a/server/testnet/ledger.ts +++ b/server/testnet/ledger.ts @@ -2,19 +2,22 @@ import { chmod, mkdir, readFile, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { z } from 'zod'; -import { NETWORK, CONSENSUS, type TestnetBlock, type NftTransfer, - type NftStateView, type MiningTemplate, type TestnetSnapshot } from '../../shared/testnet.ts'; -import { CHAIN_ID, GENESIS_HASH, applyTransferOverlay, blockSchema, makeTemplate, - transferId, transferSchema, validateBlock, workFor } from './consensus.ts'; +import { NETWORK, CONSENSUS, type TestnetBlock, + type NftState, type NftStateView, type TestnetTransaction, type MiningTemplate, + type TestnetDeck, type TestnetSnapshot } from '../../shared/testnet.ts'; +import { CHAIN_ID, GENESIS_HASH, applyTransactionOverlay, blockSchema, makeTemplate, + applyTransferOverlay, materializeState, transactionId, transactionSchema, transferSchema, + validateBlock, workFor } from './consensus.ts'; import type { PowEngine } from './randomx.ts'; import type { Article } from '../../shared/protocol.ts'; import type { MintStatsSnapshot } from '../../shared/enrichment.ts'; const MAX_PENDING = 256; +const MAX_DECK_PAGE = 24; const MAX_LEGACY_FILE = 128 * 1024 * 1024; const diskSchema = z.object({ version: z.literal(1), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID), blocks: z.array(blockSchema).max(10_000), - pendingTransfers: z.array(transferSchema).max(MAX_PENDING) }).strict(); + pendingTransfers: z.array(transactionSchema).max(MAX_PENDING) }).strict(); type Node = { block: TestnetBlock; state: NftStateView; work: bigint }; const DATABASE_SCHEMA_VERSION = '1'; @@ -24,7 +27,7 @@ const DATABASE_SCHEMA_VERSION = '1'; export class Ledger { private readonly nodes = new Map(); private tip = GENESIS_HASH; - private pending: NftTransfer[] = []; + private pending: TestnetTransaction[] = []; private pendingState: NftStateView = {}; private queue: Promise = Promise.resolve(); private queued = 0; @@ -107,7 +110,7 @@ export class Ledger { const insertBlock = db.prepare('INSERT INTO blocks(hash, previous_hash, block_json) VALUES(?, ?, ?)'); for (const block of oldDisk.blocks) insertBlock.run(block.hash, block.previousHash, JSON.stringify(block)); const insertPending = db.prepare('INSERT INTO pending_transfers(position, id, transaction_json) VALUES(?, ?, ?)'); - oldDisk.pendingTransfers.forEach((tx, index) => insertPending.run(index, transferId(tx), JSON.stringify(tx))); + oldDisk.pendingTransfers.forEach((tx, index) => insertPending.run(index, transactionId(tx), JSON.stringify(tx))); } insertMeta.run('legacy_json_migrated', '1'); db.exec('COMMIT'); @@ -123,12 +126,12 @@ export class Ledger { if (node.work > ledger.tipWork()) ledger.tip = node.block.hash; } let state = ledger.tipState(); - const seen = new Set(ledger.canonical().flatMap(b => b.transactions.map(transferId))); + const seen = new Set(ledger.canonical().flatMap(b => b.transactions.map(transactionId))); for (const row of db.prepare('SELECT id, transaction_json FROM pending_transfers ORDER BY position').iterate() as Iterable<{ id: string; transaction_json: string }>) { - const tx = transferSchema.parse(JSON.parse(row.transaction_json)); - const id = transferId(tx); + const tx = transactionSchema.parse(JSON.parse(row.transaction_json)); + const id = transactionId(tx); if (row.id !== id || seen.has(id)) throw new Error('Duplicate or mismatched persisted transfer'); - state = applyTransferOverlay(state, tx, ledger.height() + 1); + state = applyTransactionOverlay(state, tx, ledger.height() + 1); seen.add(id); ledger.pending.push(tx); } @@ -191,13 +194,18 @@ export class Ledger { } async submitTransfer(raw: unknown): Promise { + const tx = transferSchema.parse(structuredClone(raw)); + return this.submitTransaction(tx); + } + + async submitTransaction(raw: unknown): Promise { const input = structuredClone(raw); return this.serialize(async () => { - const tx = transferSchema.parse(input); - const id = transferId(tx); - if (this.pending.some(t => transferId(t) === id)) return id; + const tx = transactionSchema.parse(input); + const id = transactionId(tx); + if (this.pending.some(t => transactionId(t) === id)) return id; if (this.pending.length >= MAX_PENDING) throw new Error('Testnet mempool limit: 256 transfers'); - const nextPendingState = applyTransferOverlay(this.pendingState, tx, this.height() + 1); + const nextPendingState = applyTransactionOverlay(this.pendingState, tx, this.height() + 1); const pending = [...this.pending, tx]; await this.persist(undefined, pending); this.pending = pending; @@ -219,6 +227,23 @@ export class Ledger { peers: this.peers, algorithm: 'RandomX', maturity: CONSENSUS.coinbaseMaturity }); } + deck(address: string, offset = 0, limit = MAX_DECK_PAGE): TestnetDeck { + if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > MAX_DECK_PAGE) { + throw new Error('Invalid deck page'); + } + const blocks = this.canonical(); + const byToken = new Map(blocks.map(block => [block.tokenId, block])); + const state = materializeState(this.tipState()); + const owned = Object.entries(state).filter(([, nft]) => nft.owner === address) + .flatMap(([tokenId, nft]) => { + const block = byToken.get(tokenId); + return block ? [{ block, sequence: nft.sequence, confirmations: blocks.length - block.height + 1 }] : []; + }).sort((a, b) => a.block.height - b.block.height || a.block.tokenId.localeCompare(b.block.tokenId)); + const nfts = owned.slice(offset, offset + limit); + const next = offset + nfts.length < owned.length ? offset + nfts.length : null; + return structuredClone({ network: NETWORK, chainId: CHAIN_ID, address, tip: this.tip, + offset, limit, total: owned.length, next, nfts }); + } private height(): number { return this.nodes.get(this.tip)?.block.height ?? 0; } private tipState(): NftStateView { return this.nodes.get(this.tip)?.state ?? {}; } @@ -240,16 +265,16 @@ export class Ledger { const { block, state } = await validateBlock(raw, ancestors, parent?.state ?? {}, this.pow); return { block, state, work: (parent?.work ?? 0n) + workFor(block.target) }; } - private filterPending(parent: NftStateView, chain: TestnetBlock[], candidates: NftTransfer[]): - { pending: NftTransfer[]; state: NftStateView } { + private filterPending(parent: NftStateView, chain: TestnetBlock[], candidates: TestnetTransaction[]): + { pending: TestnetTransaction[]; state: NftStateView } { let state = parent; - const seen = new Set(chain.flatMap(b => b.transactions.map(transferId))); - const pending: NftTransfer[] = []; + const seen = new Set(chain.flatMap(b => b.transactions.map(transactionId))); + const pending: TestnetTransaction[] = []; for (const tx of candidates) { if (pending.length === MAX_PENDING) break; - const id = transferId(tx); + const id = transactionId(tx); if (seen.has(id)) continue; - try { state = applyTransferOverlay(state, tx, chain.length + 1); } + try { state = applyTransactionOverlay(state, tx, chain.length + 1); } catch { continue; } // Disconnected issuance, immature coins and conflicts are not replayable. seen.add(id); pending.push(tx); } @@ -262,14 +287,14 @@ export class Ledger { this.queue = result.catch(() => undefined).finally(() => { this.queued--; }); return result; } - private async persist(block: TestnetBlock | undefined, pendingTransfers: NftTransfer[]): Promise { + private async persist(block: TestnetBlock | undefined, pendingTransfers: TestnetTransaction[]): Promise { this.db.exec('BEGIN IMMEDIATE'); try { if (block) this.db.prepare('INSERT INTO blocks(hash, previous_hash, block_json) VALUES(?, ?, ?)') .run(block.hash, block.previousHash, JSON.stringify(block)); this.db.exec('DELETE FROM pending_transfers'); const insertPending = this.db.prepare('INSERT INTO pending_transfers(position, id, transaction_json) VALUES(?, ?, ?)'); - pendingTransfers.forEach((tx, index) => insertPending.run(index, transferId(tx), JSON.stringify(tx))); + pendingTransfers.forEach((tx, index) => insertPending.run(index, transactionId(tx), JSON.stringify(tx))); this.db.exec('COMMIT'); } catch (error) { this.db.exec('ROLLBACK'); diff --git a/server/testnet/peers.ts b/server/testnet/peers.ts index 20cfb09..181e027 100644 --- a/server/testnet/peers.ts +++ b/server/testnet/peers.ts @@ -3,8 +3,8 @@ import { request as httpsRequest } from 'node:https'; import dns from 'node:dns/promises'; import { BlockList, isIP } from 'node:net'; import { z } from 'zod'; -import { NETWORK, type TestnetBlock, type NftTransfer } from '../../shared/testnet.ts'; -import { CHAIN_ID, blockSchema, transferId, transferSchema } from './consensus.ts'; +import { NETWORK, type TestnetBlock, type TestnetTransaction } from '../../shared/testnet.ts'; +import { CHAIN_ID, blockSchema, transactionId, transactionSchema } from './consensus.ts'; import type { Ledger } from './ledger.ts'; const MAX_BODY = 2 * 1024 * 1024; @@ -27,7 +27,7 @@ const remoteStatus = z.object({ network: z.literal(NETWORK), chainId: z.literal( size: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), tip: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict(); const pageSchema = z.object({ blocks: z.array(blockSchema).max(32), next: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable() }).strict(); -const poolSchema = z.object({ transactions: z.array(transferSchema).max(256) }).strict(); +const poolSchema = z.object({ transactions: z.array(transactionSchema).max(256) }).strict(); type Peer = { url: string; connected: boolean; error?: string; offset: number; busy: boolean }; /** Outbound-only fixed allowlist. HTTP routes are deliberately left to the caller. @@ -90,10 +90,13 @@ export class PeerNetwork { if (!known) throw new Error('Cannot broadcast an unaccepted block'); await this.broadcast('/p2p/block', known); } - async broadcastTransfer(tx: NftTransfer): Promise { - const id = transferId(transferSchema.parse(tx)); + async broadcastTransfer(tx: TestnetTransaction): Promise { + return this.broadcastTransaction(tx); + } + async broadcastTransaction(tx: TestnetTransaction): Promise { + const id = transactionId(transactionSchema.parse(tx)); const snapshot = this.ledger.snapshot(); - const known = [...snapshot.pendingTransfers, ...snapshot.transfers].find(t => transferId(t) === id); + const known = [...snapshot.pendingTransfers, ...snapshot.transfers].find(t => transactionId(t) === id); if (!known) throw new Error('Cannot broadcast an unaccepted transfer'); await this.broadcast('/p2p/transaction', known); } @@ -134,7 +137,7 @@ export class PeerNetwork { for (const tx of pool.transactions) { if (this.closed) throw new Error('Peer network closed'); // Valid remote transactions may be immature/conflicting on our current tip. - try { await this.ledger.submitTransfer(tx); } catch { /* Independently reject; do not relay. */ } + try { await this.ledger.submitTransaction(tx); } catch { /* Independently reject; do not relay. */ } } } diff --git a/shared/testnet.ts b/shared/testnet.ts index dbfb234..0827313 100644 --- a/shared/testnet.ts +++ b/shared/testnet.ts @@ -1,29 +1,40 @@ import type { Article } from './protocol.ts'; import type { MintStatsSnapshot } from './enrichment.ts'; -export const NETWORK = 'wikideck-randomx-testnet-5' as const; +export const NETWORK = 'wikideck-randomx-testnet-6' as const; export const CONSENSUS = { - version: 5, targetSeconds: 15 * 60, retargetInterval: 10, medianWindow: 11, + version: 6, targetSeconds: 15 * 60, retargetInterval: 10, medianWindow: 11, commitments: 'sha256-domain-separated-counted-merkle-v1', randomx: '1.2.1', powEndian: 'big', maxFutureSeconds: 120, epochBlocks: 2048, epochDelay: 64, - maxTransactions: 64, coinbaseMaturity: 2, maxBlockBytes: 64 * 1024, - articleSource: 'wikipedia-random-with-image-and-stats-v1', + maxTransactions: 64, maxTradeCards: 8, coinbaseMaturity: 2, maxBlockBytes: 64 * 1024, + articleSource: 'wikipedia-random-with-image-and-stats-v1', atomicTrades: 'signed-two-party-bundle-v1', } as const; export type NftTransfer = { network: typeof NETWORK; chainId: string; cardHash: string; from: string; to: string; sequence: number; publicKey: string; signature: string; }; +export type TradeLeg = { cardHash: string; sequence: number }; +export type NftTrade = { + type: 'trade'; network: typeof NETWORK; chainId: string; proposer: string; counterparty: string; + offered: TradeLeg[]; requested: TradeLeg[]; + proposerPublicKey: string; proposerSignature: string; + counterpartyPublicKey: string; counterpartySignature: string; +}; +export type TestnetTransaction = NftTransfer | NftTrade; +export function isNftTrade(transaction: TestnetTransaction): transaction is NftTrade { + return (transaction as NftTrade).type === 'trade'; +} export type BlockHeader = { - version: 5; network: typeof NETWORK; chainId: string; height: number; + version: 6; network: typeof NETWORK; chainId: string; height: number; previousHash: string; createdAt: number; article: Article; statsSnapshot: MintStatsSnapshot; owner: string; publicKey: string; nonce: number; target: string; key: string; transactionsRoot: string; stateRoot: string; }; export type TestnetBlock = BlockHeader & { - hash: string; tokenId: string; powHash: string; signature: string; transactions: NftTransfer[]; + hash: string; tokenId: string; powHash: string; signature: string; transactions: TestnetTransaction[]; }; export type MiningTemplate = { - header: BlockHeader; transactions: NftTransfer[]; blob: string; nonceOffset: number; + header: BlockHeader; transactions: TestnetTransaction[]; blob: string; nonceOffset: number; }; export type NftState = Record; /** Persistent overlay: a block stores only NFT entries it changed, sharing its parent's state. */ @@ -31,12 +42,21 @@ export type NftStateLayer = { kind: 'layer'; parent: NftStateView; changes: NftS export type NftStateView = NftState | NftStateLayer; export type TestnetSnapshot = { network: typeof NETWORK; chainId: string; genesis: string; tip: string; cumulativeWork: string; - blocks: TestnetBlock[]; transfers: NftTransfer[]; pendingTransfers: NftTransfer[]; + blocks: TestnetBlock[]; transfers: TestnetTransaction[]; pendingTransfers: TestnetTransaction[]; peers: number; algorithm: 'RandomX'; maturity: number; }; +export type TestnetDeck = { + network: typeof NETWORK; chainId: string; address: string; tip: string; + offset: number; limit: number; total: number; next: number | null; + nfts: { block: TestnetBlock; sequence: number; confirmations: number }[]; +}; export function nftTransferMessage(t: Omit): string { return JSON.stringify([NETWORK, 'nft-transfer', t.chainId, t.cardHash, t.from, t.to, t.sequence]); } +export function nftTradeMessage(t: Pick): string { + return JSON.stringify([NETWORK, 'nft-trade', t.chainId, t.proposer, t.counterparty, + t.offered.map(leg => [leg.cardHash, leg.sequence]), t.requested.map(leg => [leg.cardHash, leg.sequence])]); +} export function articleTuple(a: Article): unknown[] { return [a.pageId, a.revisionId, a.title, a.extract, a.image ?? '', a.url, a.source, a.category]; } @@ -54,8 +74,18 @@ export function headerMessage(h: BlockHeader): string { export function blockClaimMessage(hash: string): string { return JSON.stringify([NETWORK, 'block-claim', hash]); } +export function transactionCardIds(transaction: TestnetTransaction): string[] { + return isNftTrade(transaction) + ? [...transaction.offered, ...transaction.requested].map(leg => leg.cardHash) + : [transaction.cardHash]; +} export function testnetOwnerOf(state: Pick, hash: string): string | undefined { let owner = state.blocks.find(b => b.tokenId === hash)?.owner; - for (const t of state.transfers) if (t.cardHash === hash) owner = t.to; + for (const tx of state.transfers) { + if (isNftTrade(tx)) { + if (tx.offered.some(leg => leg.cardHash === hash)) owner = tx.counterparty; + if (tx.requested.some(leg => leg.cardHash === hash)) owner = tx.proposer; + } else if (tx.cardHash === hash) owner = tx.to; + } return owner; } diff --git a/shared/trade-offer.ts b/shared/trade-offer.ts new file mode 100755 index 0000000..6317ade --- /dev/null +++ b/shared/trade-offer.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { NETWORK, CONSENSUS } from './testnet.ts'; + +const hex = z.string().regex(/^[a-f0-9]{64}$/); +const signature = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).length(88); +const leg = z.object({ cardHash: hex, sequence: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER) }).strict(); + +export const tradeOfferSchema = z.object({ + format: z.literal('wikideck-trade-offer'), + version: z.literal(1), + network: z.literal(NETWORK), + chainId: hex, + proposer: hex, + counterparty: hex, + offered: z.array(leg).min(1).max(CONSENSUS.maxTradeCards), + requested: z.array(leg).min(1).max(CONSENSUS.maxTradeCards), + proposerPublicKey: z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).max(256), + proposerSignature: signature, +}).strict().superRefine((offer, ctx) => { + if (offer.proposer === offer.counterparty) ctx.addIssue({ code: 'custom', message: 'Trade parties must differ' }); + const ids = [...offer.offered, ...offer.requested].map(item => item.cardHash); + if (new Set(ids).size !== ids.length) ctx.addIssue({ code: 'custom', message: 'Each NFT may appear only once in a trade' }); +}); +export type TradeOffer = z.infer; diff --git a/shared/wallet-backup.ts b/shared/wallet-backup.ts index fa1a88f..1ea617a 100644 --- a/shared/wallet-backup.ts +++ b/shared/wallet-backup.ts @@ -1,5 +1,5 @@ // Browser/Node WebCrypto only: this module never reads storage or sends data. -const NETWORK = 'wikideck-randomx-testnet-5'; +const NETWORK = 'wikideck-randomx-testnet-6'; 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-5'; + network: 'wikideck-randomx-testnet-6'; kdf: 'PBKDF2-SHA256'; iterations: 600000; cipher: 'AES-256-GCM'; diff --git a/src/App.tsx b/src/App.tsx index 1e99144..8536e7d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,19 +1,20 @@ import { useEffect, useRef, useState } from 'react'; -import { ArrowDownLeft, ArrowRight, ArrowUpRight, BookOpen, Check, ChevronRight, CircleHelp, +import { ArrowDownLeft, ArrowLeftRight, ArrowRight, ArrowUpRight, BookOpen, Check, ChevronRight, CircleHelp, Copy, Cpu, Download, ExternalLink, Globe2, Layers3, LayoutDashboard, LoaderCircle, Pickaxe, Search, Shield, Sparkles, Wallet as WalletIcon, X } from 'lucide-react'; import { rarity } from '../shared/protocol.ts'; -import { CONSENSUS, NETWORK, nftTransferMessage, testnetOwnerOf, +import { CONSENSUS, NETWORK, isNftTrade, nftTransferMessage, testnetOwnerOf, transactionCardIds, type TestnetBlock, type TestnetSnapshot } from '../shared/testnet.ts'; import { getWallet, sign, type Wallet } from './wallet.ts'; import { Card, samples, imageCredit, type CardData } from './Card.tsx'; import { WalletPanel } from './WalletPanel.tsx'; import { useModalFocus } from './useModalFocus.ts'; import { ThemePicker } from './ThemePicker.tsx'; +import { TradeDesk } from './TradeDesk.tsx'; import './App.css'; import './testnet.css'; -type View = 'Discover' | 'My collection' | 'Block explorer'; +type View = 'Discover' | 'My collection' | 'Block explorer' | 'Trade decks'; 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.'; @@ -35,7 +36,7 @@ function transferProblem(state: TestnetSnapshot, card: CardData, address: string if (testnetOwnerOf(state, block.tokenId) !== address) return 'This wallet is not the confirmed owner.'; if (state.blocks.length + 1 - block.height < CONSENSUS.coinbaseMaturity) return 'Immature NFT: transfers require two subsequent blocks at the next inclusion height.'; - if (state.pendingTransfers.some(t => t.cardHash === block.tokenId)) return 'A transfer for this NFT is already pending mining.'; + if (state.pendingTransfers.some(tx => transactionCardIds(tx).includes(block.tokenId))) return 'A transfer or trade involving this NFT is already pending mining.'; return ''; } @@ -102,13 +103,13 @@ export default function App() { if (!/^[a-f0-9]{64}$/.test(to) || to === wallet.address) throw new Error('Enter another wallet’s 64-character receive address.'); const tokenId = selected.block.tokenId; const t = { network: NETWORK, chainId: state.chainId, cardHash: tokenId, - from: wallet.address, to, sequence: state.transfers.filter(tx => tx.cardHash === tokenId).length }; + from: wallet.address, to, sequence: state.transfers.filter(tx => transactionCardIds(tx).includes(tokenId)).length }; const tx = { ...t, publicKey: wallet.publicKey, signature: await sign(wallet, nftTransferMessage(t)) }; await request('transfer', tx, 202); // A 202 is a mempool acknowledgement, NOT a change of confirmed ownership. refreshVersion.current++; setSnapshot(current => current && current.chainId === state.chainId ? { ...current, - pendingTransfers: [...current.pendingTransfers.filter(p => p.cardHash !== tx.cardHash), tx] } : current); + pendingTransfers: [...current.pendingTransfers.filter(p => !transactionCardIds(p).includes(tx.cardHash)), tx] } : current); setDialog(null); setRecipient(''); setToast('Transfer pending. You remain the confirmed owner until it is mined into the canonical chain.'); void refresh().catch(() => {}); @@ -124,7 +125,7 @@ export default function App() { const selectedBlock = selectedMint && snapshot?.blocks.find(b => b.tokenId === selectedMint.tokenId && b.hash === selectedMint.hash); const confirmations = selectedBlock && snapshot ? snapshot.blocks.length - selectedBlock.height + 1 : 0; const selectedOwner = selectedBlock && snapshot ? testnetOwnerOf(snapshot, selectedBlock.tokenId) : undefined; - const pending = !!selectedBlock && !!snapshot?.pendingTransfers.some(t => t.cardHash === selectedBlock.tokenId); + const pending = !!selectedBlock && !!snapshot?.pendingTransfers.some(tx => transactionCardIds(tx).includes(selectedBlock.tokenId)); const transferDisabledReason = selected && wallet && snapshot ? transferProblem(snapshot, selected, wallet.address) : 'Wallet and node state are required.'; const peerLabel = !rpcAvailable ? 'Peers unknown' : snapshot?.peers ? `${snapshot.peers} peers` : 'Isolated node'; const nativeCommand = `npm run miner -- --wallet --consent --node ${window.location.origin}`; @@ -153,8 +154,8 @@ export default function App() {