Trades
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
+17
-13
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
+63
-12
@@ -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<string>();
|
||||
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);
|
||||
|
||||
+27
-4
@@ -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')));
|
||||
|
||||
+48
-23
@@ -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<string, Node>();
|
||||
private tip = GENESIS_HASH;
|
||||
private pending: NftTransfer[] = [];
|
||||
private pending: TestnetTransaction[] = [];
|
||||
private pendingState: NftStateView = {};
|
||||
private queue: Promise<unknown> = 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<string> {
|
||||
const tx = transferSchema.parse(structuredClone(raw));
|
||||
return this.submitTransaction(tx);
|
||||
}
|
||||
|
||||
async submitTransaction(raw: unknown): Promise<string> {
|
||||
const input = structuredClone(raw);
|
||||
return this.serialize(async () => {
|
||||
const tx = 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<void> {
|
||||
private async persist(block: TestnetBlock | undefined, pendingTransfers: TestnetTransaction[]): Promise<void> {
|
||||
this.db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
if (block) this.db.prepare('INSERT INTO blocks(hash, previous_hash, block_json) VALUES(?, ?, ?)')
|
||||
.run(block.hash, block.previousHash, JSON.stringify(block));
|
||||
this.db.exec('DELETE FROM pending_transfers');
|
||||
const insertPending = this.db.prepare('INSERT INTO pending_transfers(position, id, transaction_json) VALUES(?, ?, ?)');
|
||||
pendingTransfers.forEach((tx, index) => insertPending.run(index, 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');
|
||||
|
||||
+10
-7
@@ -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<void> {
|
||||
const id = transferId(transferSchema.parse(tx));
|
||||
async broadcastTransfer(tx: TestnetTransaction): Promise<void> {
|
||||
return this.broadcastTransaction(tx);
|
||||
}
|
||||
async broadcastTransaction(tx: TestnetTransaction): Promise<void> {
|
||||
const id = transactionId(transactionSchema.parse(tx));
|
||||
const snapshot = this.ledger.snapshot();
|
||||
const known = [...snapshot.pendingTransfers, ...snapshot.transfers].find(t => 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. */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-9
@@ -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<string, { owner: string; sequence: number; mintedHeight: number; pageId: number }>;
|
||||
/** 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<NftTransfer, 'publicKey' | 'signature'>): string {
|
||||
return JSON.stringify([NETWORK, 'nft-transfer', t.chainId, t.cardHash, t.from, t.to, t.sequence]);
|
||||
}
|
||||
export function nftTradeMessage(t: Pick<NftTrade, 'network' | 'chainId' | 'proposer' | 'counterparty' | 'offered' | 'requested'>): string {
|
||||
return JSON.stringify([NETWORK, 'nft-trade', t.chainId, t.proposer, t.counterparty,
|
||||
t.offered.map(leg => [leg.cardHash, leg.sequence]), t.requested.map(leg => [leg.cardHash, leg.sequence])]);
|
||||
}
|
||||
export function articleTuple(a: Article): unknown[] {
|
||||
return [a.pageId, a.revisionId, a.title, a.extract, a.image ?? '', a.url, a.source, a.category];
|
||||
}
|
||||
@@ -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<TestnetSnapshot, 'blocks' | 'transfers'>, 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;
|
||||
}
|
||||
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
import { NETWORK, CONSENSUS } from './testnet.ts';
|
||||
|
||||
const hex = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const signature = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).length(88);
|
||||
const leg = z.object({ cardHash: hex, sequence: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER) }).strict();
|
||||
|
||||
export const tradeOfferSchema = z.object({
|
||||
format: z.literal('wikideck-trade-offer'),
|
||||
version: z.literal(1),
|
||||
network: z.literal(NETWORK),
|
||||
chainId: hex,
|
||||
proposer: hex,
|
||||
counterparty: hex,
|
||||
offered: z.array(leg).min(1).max(CONSENSUS.maxTradeCards),
|
||||
requested: z.array(leg).min(1).max(CONSENSUS.maxTradeCards),
|
||||
proposerPublicKey: z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).max(256),
|
||||
proposerSignature: signature,
|
||||
}).strict().superRefine((offer, ctx) => {
|
||||
if (offer.proposer === offer.counterparty) ctx.addIssue({ code: 'custom', message: 'Trade parties must differ' });
|
||||
const ids = [...offer.offered, ...offer.requested].map(item => item.cardHash);
|
||||
if (new Set(ids).size !== ids.length) ctx.addIssue({ code: 'custom', message: 'Each NFT may appear only once in a trade' });
|
||||
});
|
||||
export type TradeOffer = z.infer<typeof tradeOfferSchema>;
|
||||
@@ -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';
|
||||
|
||||
+19
-14
@@ -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 <backup.json> --consent --node ${window.location.origin}`;
|
||||
@@ -153,8 +154,8 @@ 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'] as View[]).map((item, i) => {
|
||||
const Icon = [LayoutDashboard, Layers3, Globe2][i];
|
||||
<nav aria-label="Main navigation">{(['Discover', 'My collection', 'Block explorer', 'Trade decks'] as View[]).map((item, i) => {
|
||||
const Icon = [LayoutDashboard, Layers3, Globe2, ArrowLeftRight][i];
|
||||
return <button key={item} aria-label={item} aria-current={view === item ? 'page' : undefined} className={`nav-item ${view === item ? 'active' : ''}`} onClick={() => { setView(item); setQuery(''); setCategory('All cards'); }}><Icon size={18} />{item}{item === 'My collection' && <span className="nav-count">{owned.length}</span>}</button>;
|
||||
})}</nav>
|
||||
<div className="sidebar-note"><span className="tiny-spark">✦</span><h3>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>
|
||||
@@ -182,23 +183,27 @@ 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><backup.json></code> with your local file path (quote paths with spaces), then run:</li></ol><pre className="native-command"><code>{nativeCommand}</code></pre><p className="miner-footnote">Node: {window.location.origin}. Same-origin RPC, including the development proxy. Use HTTPS for remote nodes. Password entry stays local; never put it in the command. Defaults to one worker and at most one block. Ctrl+C stops the native miner; closing this page does not.</p><button className="button dark full" onClick={openWallet}><Download size={16} /> Download wallet backup</button><button className="text-button" onClick={() => setDialog('guide')}>Read the testnet field guide <ArrowRight size={14} /></button></div>
|
||||
</section>
|
||||
</>}
|
||||
{view !== 'Block explorer' ? <section className="discover-section">
|
||||
{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(t => t.cardHash === c.block!.tokenId) && <span className="pending-label">Transfer 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>}
|
||||
{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>}
|
||||
</section> : <section className="explorer-section"><div className="section-eyebrow">FOLLOW THE CANONICAL CHAIN</div><div className="section-heading"><div><h1>Block explorer<span className="count-badge">{snapshot?.blocks.length ?? 0}</span></h1><p>Native NFT mints, RandomX proofs, and mined ownership transfers.</p></div><a className="button outline" href="/api/chain" target="_blank" rel="noreferrer">View full chain JSON <ExternalLink size={15} /></a></div>
|
||||
<div className="explorer-note"><Shield size={18} /><p>Sovereign testnet with independently validating peers and cumulative-work chain selection. This network is unbootstrapped, not a public mainnet. Zero peers means an isolated node, not a connected network. Canonical ownership can change during a reorganization.</p></div>
|
||||
<dl className="chain-summary"><div><dt>Cumulative work</dt><dd>{snapshot?.cumulativeWork ?? '—'}</dd></div><div><dt>Peer count</dt><dd>{rpcAvailable ? snapshot?.peers ?? '—' : 'Unknown'} · {peerLabel}</dd></div><div><dt>Chain ID · pinned genesis</dt><dd>{snapshot?.chainId ?? '—'}</dd></div><div><dt>Network</dt><dd>{NETWORK}</dd></div><div><dt>Tip block ID</dt><dd>{snapshot?.tip ?? '—'}</dd></div><div><dt>Mint maturity</dt><dd>{snapshot?.maturity ?? CONSENSUS.coinbaseMaturity} subsequent blocks at inclusion height</dd></div></dl>
|
||||
{snapshot?.blocks.length ? <div className="table-scroll"><table><thead><tr><th>Block / confirmations</th><th>Discovery</th><th>Block ID / NFT token ID</th><th>Confirmed owner</th><th>RandomX proof / rarity</th></tr></thead><tbody>{[...snapshot.blocks].reverse().map(b => <tr key={b.hash}><td><button className="explorer-card-link" onClick={() => setSelected(asCard(b))}>#{b.height.toString().padStart(4, '0')}</button><small>{snapshot.blocks.length - b.height + 1} confirmations</small></td><td><strong>{b.article.title}</strong><small>Wikipedia #{b.article.pageId}</small></td><td className="mono"><span title={b.hash}>Block {short(b.hash)}</span><a className="nft-link" href={`/api/nfts/${encodeURIComponent(b.tokenId)}`} target="_blank" rel="noreferrer" title={b.tokenId}>NFT {short(b.tokenId)} <ExternalLink size={11} /></a></td><td className="mono" title={testnetOwnerOf(snapshot, b.tokenId)}>{short(testnetOwnerOf(snapshot, b.tokenId) ?? '')}</td><td><span className="proof-caption mono" title={b.powHash}>{short(b.powHash)}</span><span className={`rarity-tag ${rarity(b.powHash).toLowerCase()}`}>{rarity(b.powHash)}</span></td></tr>)}</tbody></table></div> : <div className="empty-state"><img className="empty-state-logo" src="/Wikideck.png" alt="Wikideck" /><h3>A new chain. A blank page.</h3><p>No canonical NFT blocks loaded. Native mining is an explicit, separate step.</p><button className="button dark" onClick={goMine}>Explore native mining <ArrowRight size={16} /></button></div>}
|
||||
<h2 className="transfer-title">Mined ownership transfers</h2>{snapshot?.transfers.length ? <div className="journal">{snapshot.transfers.map((t, i) => <div key={`${t.cardHash}-${t.sequence}`}><ArrowDownLeft size={18} /><a href={`/api/nfts/${encodeURIComponent(t.cardHash)}`} target="_blank" rel="noreferrer" title={t.cardHash}>{short(t.cardHash)}</a><span>{short(t.from)} → {short(t.to)}</span><span>Sequence {t.sequence} · mined #{snapshot.blocks.find(b => b.transactions.some(tx => tx.signature === t.signature))?.height ?? '—'}</span><Check size={14} aria-label={`Mined transfer ${i + 1}`} /></div>)}</div> : <p className="muted">No mined transfers. Ownership changes only when a transfer is included in a canonical block.</p>}
|
||||
<h2 className="transfer-title">Pending transfers <span className="count-badge">{snapshot?.pendingTransfers.length ?? 0}</span></h2><p className="muted">Mempool only · not confirmed ownership. Pending transactions may be dropped or invalidated by a reorganization.</p><div className="journal">{snapshot?.pendingTransfers.map(t => <div key={`${t.cardHash}-${t.sequence}`}><LoaderCircle size={16} /><span title={t.cardHash}>{short(t.cardHash)}</span><span>{short(t.from)} → {short(t.to)}</span><span className="pending-label">Awaiting mining</span></div>)}</div>
|
||||
<h2 className="transfer-title">Mined transfers and trades</h2>{snapshot?.transfers.length ? <div className="journal">{snapshot.transfers.map((tx, i) => isNftTrade(tx)
|
||||
? <div key={`trade-${tx.proposerSignature}`}><ArrowLeftRight size={18}/><span>{tx.offered.length} cards {short(tx.proposer)} ⇄ {tx.requested.length} cards {short(tx.counterparty)}</span><span>Atomic trade · mined #{snapshot.blocks.find(b => b.transactions.some(item => isNftTrade(item) && item.proposerSignature === tx.proposerSignature))?.height ?? '—'}</span><Check size={14} aria-label={`Mined trade ${i + 1}`}/></div>
|
||||
: <div key={`${tx.cardHash}-${tx.sequence}`}><ArrowDownLeft size={18}/><a href={`/api/nfts/${encodeURIComponent(tx.cardHash)}`} target="_blank" rel="noreferrer" title={tx.cardHash}>{short(tx.cardHash)}</a><span>{short(tx.from)} → {short(tx.to)}</span><span>Sequence {tx.sequence} · mined #{snapshot.blocks.find(b => b.transactions.some(item => !isNftTrade(item) && item.signature === tx.signature))?.height ?? '—'}</span><Check size={14} aria-label={`Mined transfer ${i + 1}`}/></div>)}</div> : <p className="muted">No mined transfers or trades.</p>}
|
||||
<h2 className="transfer-title">Pending transfers and trades <span className="count-badge">{snapshot?.pendingTransfers.length ?? 0}</span></h2><p className="muted">Mempool only · not confirmed ownership. A trade bundle is included in full or not at all; pending transactions may be dropped or invalidated by a reorganization.</p><div className="journal">{snapshot?.pendingTransfers.map((tx, i) => isNftTrade(tx)
|
||||
? <div key={`pending-trade-${tx.proposerSignature}`}><LoaderCircle size={16}/><span>{tx.offered.length} cards {short(tx.proposer)} ⇄ {tx.requested.length} cards {short(tx.counterparty)}</span><span className="pending-label">Atomic trade · awaiting mining</span></div>
|
||||
: <div key={`${tx.cardHash}-${tx.sequence}`}><LoaderCircle size={16}/><span title={tx.cardHash}>{short(tx.cardHash)}</span><span>{short(tx.from)} → {short(tx.to)}</span><span className="pending-label">Awaiting mining</span></div>)}</div>
|
||||
</section>}
|
||||
<footer><a href="https://en.wikipedia.org" target="_blank" rel="noreferrer"><BookOpen size={16} /> Built on the shoulders of Wikipedia.</a><span>Knowledge, not financial promises.<span className="footer-star">✦</span></span><p>Independent experiment, not affiliated with Wikimedia. Mined article snapshots are selected from Wikipedia; attribution and image licenses appear on each card. Native NFTs are not ERC-721 tokens, fiat, or rights to article content. No promised monetary value.</p></footer>
|
||||
</main>
|
||||
</div>
|
||||
{toast && <div className="toast" role="status"><span><Check size={16} /></span>{toast}<button onClick={() => setToast('')} aria-label="Dismiss notification"><X size={14} /></button></div>}
|
||||
{(dialog || selected) && <div className="modal-backdrop" onClick={e => { if (e.target === e.currentTarget) close(); }}><section className={`modal ${dialog === 'wallet' ? 'wallet-modal' : selected && !dialog ? 'card-modal' : ''}`} role="dialog" aria-modal="true" aria-label={dialog === 'wallet' ? 'Local testnet wallet' : dialog === 'transfer' ? 'Transfer native NFT' : dialog === 'guide' ? 'How Wikideck works' : 'Card details'}><button className="modal-close" aria-label="Close dialog" onClick={close} disabled={transferBusy}><X size={20} /></button>
|
||||
{dialog === 'guide' ? <><span className="modal-icon"><Sparkles size={24} /></span><div className="section-eyebrow">THE TESTNET FIELD GUIDE</div><h2>A universe in every card.</h2><div className="guide-steps"><div><span>01</span><div><h3>Mine natively. Only by choice.</h3><p>No browser mining or automatic native execution. Build the trusted RandomX binary and run the miner yourself with an encrypted wallet backup and explicit --consent. One worker defaults to light mode (256 MiB cache plus overhead); --fast adds a ~2 GiB dataset. Mining uses CPU and energy, may warm your device, and is not guaranteed ASIC-proof. Ctrl+C stops it.</p></div></div><div><span>02</span><div><h3>A page, a snapshot. A sovereign chain.</h3><p>Each mining template asks Wikipedia for a random English article with a picture. A page can appear on multiple NFTs; each block commits its own article and mint-time statistics snapshot. Validators verify the snapshot's structure and commitments without contacting Wikimedia during consensus validation. Independent peers validate RandomX work, native NFT mints, and signed transactions, choosing the cumulative-work canonical chain. This testnet is unbootstrapped: there is no public mainnet, and an isolated node has no peer-backed network security.</p></div></div><div><span>03</span><div><h3>Keep your keys. Confirm your transfers.</h3><p>Your P-256 wallet is a local signing identity, not a server login. The new testnet wallet is separate from the original demo wallet. Download a password-encrypted backup and restore it locally to control the same NFTs. A submitted transfer is pending, not final ownership. Mints require two subsequent blocks at inclusion height; reorganizations can change confirmations and ownership.</p></div></div></div><div className="info-box"><strong>Native NFTs, not content ownership.</strong><p>Token IDs identify native assets on this chain, not ERC-721 tokens on another network. RandomX proof hashes determine game rarity and stats; block IDs identify blocks. An NFT does not grant copyright or other intellectual-property rights to Wikipedia text or images. Follow their licenses and attribution. No fiat, financial return, or monetary value is promised.</p></div><button className="button dark full" onClick={() => { close(); goMine(); }}>Explore native mining <ArrowRight size={16} /></button></>
|
||||
{dialog === 'guide' ? <><span className="modal-icon"><Sparkles size={24} /></span><div className="section-eyebrow">THE TESTNET FIELD GUIDE</div><h2>A universe in every card.</h2><div className="guide-steps"><div><span>01</span><div><h3>Mine natively. Only by choice.</h3><p>No browser mining or automatic native execution. Build the trusted RandomX binary and run the miner yourself with an encrypted wallet backup and explicit --consent. One worker defaults to light mode (256 MiB cache plus overhead); --fast adds a ~2 GiB dataset. Mining uses CPU and energy, may warm your device, and is not guaranteed ASIC-proof. Ctrl+C stops it.</p></div></div><div><span>02</span><div><h3>A page, a snapshot. A sovereign chain.</h3><p>Each mining template asks Wikipedia for a random English article with a picture. A page can appear on multiple NFTs; each block commits its own article and mint-time statistics snapshot. Validators verify the snapshot's structure and commitments without contacting Wikimedia during consensus validation. Independent peers validate RandomX work, native NFT mints, and signed transactions, choosing the cumulative-work canonical chain. This testnet is unbootstrapped: there is no public mainnet, and an isolated node has no peer-backed network security.</p></div></div><div><span>03</span><div><h3>Keep your keys. Trade as a team.</h3><p>Your P-256 wallet is a local signing identity, not a server login. Download a password-encrypted backup and restore it locally to control your NFTs. Use Trade decks to review a public wallet’s confirmed holdings and prepare a signed multi-card offer. The other player signs before submission; all cards exchange together when the single trade transaction is mined. Offers do not reserve cards and can go stale. A pending transfer or trade does not change confirmed ownership. Mints require two subsequent blocks at inclusion height; reorganizations can change confirmations and ownership.</p></div></div></div><div className="info-box"><strong>Native NFTs, not content ownership.</strong><p>Token IDs identify native assets on this chain, not ERC-721 tokens on another network. RandomX proof hashes determine game rarity and stats; block IDs identify blocks. An NFT does not grant copyright or other intellectual-property rights to Wikipedia text or images. Follow their licenses and attribution. No fiat, financial return, or monetary value is promised.</p></div><button className="button dark full" onClick={() => { close(); goMine(); }}>Explore native mining <ArrowRight size={16} /></button></>
|
||||
: dialog === 'wallet' ? <><span className="modal-icon"><WalletIcon size={24} /></span><div className="section-eyebrow">YOUR LOCAL TESTNET IDENTITY</div><h2>A wallet for your wonders.</h2><p className="muted">No server login. This recoverable wallet controls native NFTs through local signatures. It is separate from the original demo wallet.</p><label className="field-label">YOUR RECEIVE ADDRESS</label><div className="address-box">{wallet?.address ?? 'Wallet unavailable. Enable local browser storage and retry.'}</div><button className="button dark full" onClick={copyAddress} disabled={!wallet}><Copy size={16} /> Copy wallet address</button><div className="wallet-recovery"><WalletPanel wallet={wallet} onRestore={updateWallet} onNotice={setToast} /></div><p className="wallet-count"><Layers3 size={17} /> {owned.length} confirmed native NFTs in your collection</p></>
|
||||
: dialog === 'transfer' ? <><span className="modal-icon"><ArrowUpRight size={24} /></span><div className="section-eyebrow">PASS ON THE CURIOSITY</div><h2>Send {selected?.article.title}.</h2><p className="muted">Use the recipient’s 64-character testnet wallet address on this same chain. Signing submits a pending transaction; it does not immediately transfer ownership.</p><label className="field-label">NFT TOKEN ID</label><div className="address-box">{selected?.block?.tokenId}</div><label className="field-label" htmlFor="recipient">RECIPIENT ADDRESS</label><input className="recipient-input" id="recipient" value={recipient} disabled={transferBusy} onChange={e => setRecipient(e.target.value)} placeholder="Paste a 64-character wallet address" autoComplete="off" maxLength={64} /><div className="info-box"><strong>Review before signing.</strong><p>Transfers take effect only when mined into the canonical chain. Once confirmed, there is no undo button or recovery for a wrong address. Mints require two subsequent blocks at inclusion height; only one pending transfer per NFT is allowed.</p></div>{(!rpcAvailable || transferDisabledReason) && <p className="transfer-error" role="alert">{!rpcAvailable ? 'RPC unavailable. Reconnect before signing.' : transferDisabledReason}</p>}{transferError && <p className="transfer-error" role="alert">{transferError}</p>}<button className="button dark full" disabled={transferBusy || !rpcAvailable || !!transferDisabledReason || !/^[a-fA-F0-9]{64}$/.test(recipient.trim()) || recipient.trim().toLowerCase() === wallet?.address} onClick={transfer}>{transferBusy ? <LoaderCircle className="spin" size={16} /> : <ArrowUpRight size={16} />}{transferBusy ? 'Signing & submitting…' : 'Sign & submit pending transfer'}</button></>
|
||||
: selected && <><div className="detail-card"><Card card={selected} /></div><div className="detail-copy"><div className="section-eyebrow">{selected.block ? `MINTED IN BLOCK #${selected.block.height}` : 'EXAMPLE CARD · NOT OWNED'}</div><h2>{selected.article.title}</h2><p>{selected.article.extract}</p><div className="detail-facts"><span>Page ID<strong>#{selected.article.pageId}</strong></span><span>Rarity<strong>{rarity(selected.hash)}</strong></span><span>Content<strong>{selected.article.source === 'live' ? `Pinned revision ${selected.article.revisionId}` : 'Sample preview'}</strong></span></div>{selected.block && <><label className="field-label">NATIVE NFT TOKEN ID</label><div className="address-box">{selected.block.tokenId}</div><label className="field-label">BLOCK ID</label><div className="address-box">{selected.block.hash}</div><label className="field-label">RANDOMX PROOF HASH · GAME STATS</label><div className="address-box">{selected.hash}</div><p className="muted">{confirmations} canonical confirmations{!rpcAvailable ? ' · last known state' : ''}. {selectedBlock ? `Confirmed owner: ${selectedOwner}` : 'This mint is not on the loaded canonical chain.'}</p>{pending && <p className="pending-label">Transfer pending mining · confirmed owner unchanged.</p>}</>}<div className="detail-links"><a href={selected.article.url} target="_blank" rel="noreferrer">Read on Wikipedia <ExternalLink size={14} /></a><a href={imageCredit(selected.article)} target="_blank" rel="noreferrer">Image & license <ExternalLink size={14} /></a>{selected.block && <a href={`/api/nfts/${encodeURIComponent(selected.block.tokenId)}`} target="_blank" rel="noreferrer">NFT metadata <ExternalLink size={14} /></a>}</div><p className="attribution">{selected.article.source === 'live' ? 'Pinned text by Wikipedia contributors, CC BY-SA 4.0. Excerpt shortened for card display.' : 'Original fixed starter summary, linked to the real Wikipedia article; no live revision captured.'} Stats are game values, not encyclopedia facts. NFT ownership grants no rights to article text or images.</p>{selected.block && selectedOwner === wallet?.address && <>{transferDisabledReason && <p className="muted">{transferDisabledReason}</p>}<button className="button dark full" disabled={!rpcAvailable || !!transferDisabledReason} onClick={() => { setRecipient(''); setTransferError(''); setDialog('transfer'); }}>Transfer native NFT <ArrowUpRight size={16} /></button></>}</div></>}
|
||||
|
||||
Executable
+224
@@ -0,0 +1,224 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ArrowLeftRight, Check, Clipboard, LoaderCircle, RefreshCw, Send } from 'lucide-react';
|
||||
import { CONSENSUS, NETWORK, isNftTrade, nftTradeMessage, type NftTrade, type TestnetDeck, type TestnetSnapshot } from '../shared/testnet.ts';
|
||||
import { tradeOfferSchema, type TradeOffer } from '../shared/trade-offer.ts';
|
||||
import type { Wallet } from './wallet.ts';
|
||||
import './trade.css';
|
||||
|
||||
type Props = { wallet?: Wallet; snapshot?: TestnetSnapshot; rpcAvailable: boolean; onNotice: (message: string) => void };
|
||||
type OwnedCard = TestnetDeck['nfts'][number];
|
||||
const addressPattern = /^[a-f0-9]{64}$/;
|
||||
const short = (value: string) => `${value.slice(0, 10)}…${value.slice(-6)}`;
|
||||
async function api<T>(path: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(`/api/${path}`, { cache: 'no-store', signal: AbortSignal.timeout(15000),
|
||||
...(body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) });
|
||||
const data: unknown = await response.json();
|
||||
if (!response.ok) throw new Error(data && typeof data === 'object' && 'error' in data && typeof data.error === 'string'
|
||||
? data.error : 'Trade request failed');
|
||||
return data as T;
|
||||
}
|
||||
async function verifyOfferSignature(offer: TradeOffer): Promise<boolean> {
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(offer.proposerPublicKey), value => value.charCodeAt(0));
|
||||
const publicKey = await crypto.subtle.importKey('spki', bytes, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']);
|
||||
const canonical = new Uint8Array(await crypto.subtle.exportKey('spki', publicKey));
|
||||
const addressBytes = new Uint8Array(await crypto.subtle.digest('SHA-256', canonical));
|
||||
const address = Array.from(addressBytes, value => value.toString(16).padStart(2, '0')).join('');
|
||||
if (address !== offer.proposer) return false;
|
||||
const signature = Uint8Array.from(atob(offer.proposerSignature), value => value.charCodeAt(0));
|
||||
return await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, publicKey, signature,
|
||||
new TextEncoder().encode(nftTradeMessage(offer)));
|
||||
} catch { return false; }
|
||||
}
|
||||
function ownershipSequence(state: TestnetSnapshot, tokenId: string): number {
|
||||
return state.transfers.reduce((count, tx) => count + (
|
||||
isNftTrade(tx)
|
||||
? Number(tx.offered.some(leg => leg.cardHash === tokenId) || tx.requested.some(leg => leg.cardHash === tokenId))
|
||||
: Number(tx.cardHash === tokenId)), 0);
|
||||
}
|
||||
function ownsFrom(snapshot: TestnetSnapshot, tokenId: string, owner: string): boolean {
|
||||
let current = snapshot.blocks.find(block => block.tokenId === tokenId)?.owner;
|
||||
for (const tx of snapshot.transfers) {
|
||||
if (isNftTrade(tx)) {
|
||||
if (tx.offered.some(leg => leg.cardHash === tokenId)) current = tx.counterparty;
|
||||
if (tx.requested.some(leg => leg.cardHash === tokenId)) current = tx.proposer;
|
||||
} else if (tx.cardHash === tokenId) current = tx.to;
|
||||
}
|
||||
return current === owner;
|
||||
}
|
||||
function DeckList({ title, cards, selected, onToggle, disabled }: {
|
||||
title: string; cards: OwnedCard[]; selected: Set<string>; onToggle: (id: string) => void; disabled?: boolean;
|
||||
}) {
|
||||
return <section className="trade-deck-list"><h3>{title}<span>{cards.length}</span></h3>{cards.length ? <div className="trade-card-list">{cards.map(card => {
|
||||
const id = card.block.tokenId;
|
||||
return <label className="trade-card-row" key={id}>
|
||||
<input type="checkbox" checked={selected.has(id)} disabled={disabled} onChange={() => onToggle(id)} />
|
||||
<img src={card.block.article.image} alt="" loading="lazy" />
|
||||
<span><strong>{card.block.article.title}</strong><small>#{card.block.height} · {short(id)}{card.confirmations < CONSENSUS.coinbaseMaturity ? ' · immature' : ''}</small></span>
|
||||
</label>;
|
||||
})}</div> : <p className="muted">No confirmed cards in this deck.</p>}</section>;
|
||||
}
|
||||
|
||||
export function TradeDesk({ wallet, snapshot, rpcAvailable, onNotice }: Props) {
|
||||
const [address, setAddress] = useState('');
|
||||
const [remoteDeck, setRemoteDeck] = useState<TestnetDeck>();
|
||||
const [offered, setOffered] = useState<Set<string>>(new Set());
|
||||
const [requested, setRequested] = useState<Set<string>>(new Set());
|
||||
const [offerText, setOfferText] = useState('');
|
||||
const [offerError, setOfferError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loadingDeck, setLoadingDeck] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const parsedOffer = useMemo<TradeOffer | undefined>(() => {
|
||||
try { return tradeOfferSchema.parse(JSON.parse(offerText)); } catch { return undefined; }
|
||||
}, [offerText]);
|
||||
const offerTitle = (id: string) => snapshot?.blocks.find(block => block.tokenId === id)?.article.title ?? short(id);
|
||||
|
||||
const myDeck = useMemo<OwnedCard[]>(() => {
|
||||
if (!wallet || !snapshot) return [];
|
||||
return snapshot.blocks.filter(block => ownsFrom(snapshot, block.tokenId, wallet.address)).map(block => ({
|
||||
block, sequence: ownershipSequence(snapshot, block.tokenId),
|
||||
confirmations: snapshot.blocks.length - block.height + 1,
|
||||
}));
|
||||
}, [snapshot, wallet]);
|
||||
|
||||
const toggle = (setter: (value: Set<string>) => void, value: Set<string>, id: string) => {
|
||||
const next = new Set(value);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
setter(next);
|
||||
};
|
||||
|
||||
const loadDeck = async () => {
|
||||
const normalized = address.trim().toLowerCase();
|
||||
if (!addressPattern.test(normalized)) { setOfferError('Enter a 64-character wallet receive address.'); return; }
|
||||
if (wallet?.address === normalized) { setOfferError('Enter another player’s address.'); return; }
|
||||
setLoadingDeck(true); setOfferError(''); setRemoteDeck(undefined); setOffered(new Set()); setRequested(new Set());
|
||||
try {
|
||||
const deck = await api<TestnetDeck>(`decks/${normalized}?offset=0&limit=24`);
|
||||
if (deck.network !== NETWORK || deck.address !== normalized || !Array.isArray(deck.nfts)) throw new Error('The node returned an incompatible deck.');
|
||||
setRemoteDeck(deck);
|
||||
onNotice(`Loaded ${deck.nfts.length} of ${deck.total} confirmed cards at chain tip ${short(deck.tip)}.`);
|
||||
} catch (error) { setOfferError(error instanceof Error ? error.message : 'Could not load that deck.'); }
|
||||
finally { setLoadingDeck(false); }
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!remoteDeck || remoteDeck.next === null || loadingMore) return;
|
||||
setLoadingMore(true); setOfferError('');
|
||||
try {
|
||||
const page = await api<TestnetDeck>(`decks/${remoteDeck.address}?offset=${remoteDeck.next}&limit=${remoteDeck.limit}`);
|
||||
if (page.chainId !== remoteDeck.chainId || page.tip !== remoteDeck.tip || page.offset !== remoteDeck.next) {
|
||||
throw new Error('The deck changed while paging. Reload it before continuing.');
|
||||
}
|
||||
setRemoteDeck(current => current && current.tip === page.tip
|
||||
? { ...current, nfts: [...current.nfts, ...page.nfts], next: page.next }
|
||||
: current);
|
||||
} catch (error) { setOfferError(error instanceof Error ? error.message : 'Could not load the next deck page.'); }
|
||||
finally { setLoadingMore(false); }
|
||||
};
|
||||
|
||||
const createOffer = async () => {
|
||||
if (!wallet || !snapshot || !remoteDeck || offered.size < 1 || requested.size < 1) return;
|
||||
if (offered.size > CONSENSUS.maxTradeCards || requested.size > CONSENSUS.maxTradeCards) {
|
||||
setOfferError(`A trade can include at most ${CONSENSUS.maxTradeCards} cards per side.`); return;
|
||||
}
|
||||
setBusy(true); setOfferError('');
|
||||
try {
|
||||
const fresh = await api<TestnetSnapshot>('state');
|
||||
if (fresh.chainId !== snapshot.chainId || remoteDeck.chainId !== fresh.chainId) throw new Error('Chain changed. Refresh and rebuild the trade.');
|
||||
const offeredLegs = [...offered].map(cardHash => {
|
||||
const card = myDeck.find(item => item.block.tokenId === cardHash);
|
||||
if (!card || !ownsFrom(fresh, cardHash, wallet.address)) throw new Error('One of your selected cards is no longer yours.');
|
||||
if (fresh.blocks.length + 1 - card.block.height < CONSENSUS.coinbaseMaturity) throw new Error(`${card.block.article.title} is not mature enough to trade.`);
|
||||
if (fresh.pendingTransfers.some(tx => (isNftTrade(tx)
|
||||
? [...tx.offered, ...tx.requested].some(leg => leg.cardHash === cardHash)
|
||||
: tx.cardHash === cardHash))) throw new Error(`${card.block.article.title} already has a pending transfer or trade.`);
|
||||
return { cardHash, sequence: ownershipSequence(fresh, cardHash) };
|
||||
});
|
||||
const requestedLegs = [...requested].map(cardHash => {
|
||||
const card = remoteDeck.nfts.find(item => item.block.tokenId === cardHash);
|
||||
if (!card || !ownsFrom(fresh, cardHash, remoteDeck.address)) throw new Error('The other player no longer owns one of the selected cards.');
|
||||
if (fresh.blocks.length + 1 - card.block.height < CONSENSUS.coinbaseMaturity) throw new Error(`${card.block.article.title} is not mature enough to trade.`);
|
||||
if (fresh.pendingTransfers.some(tx => (isNftTrade(tx)
|
||||
? [...tx.offered, ...tx.requested].some(leg => leg.cardHash === cardHash)
|
||||
: tx.cardHash === cardHash))) throw new Error(`${card.block.article.title} already has a pending transfer or trade.`);
|
||||
return { cardHash, sequence: ownershipSequence(fresh, cardHash) };
|
||||
});
|
||||
const terms = { network: NETWORK, chainId: fresh.chainId, proposer: wallet.address,
|
||||
counterparty: remoteDeck.address, offered: offeredLegs, requested: requestedLegs };
|
||||
const proposerSignature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, wallet.privateKey,
|
||||
new TextEncoder().encode(nftTradeMessage(terms)));
|
||||
const signatureText = btoa(String.fromCharCode(...new Uint8Array(proposerSignature)));
|
||||
const offer = { format: 'wikideck-trade-offer' as const, version: 1 as const, ...terms,
|
||||
proposerPublicKey: wallet.publicKey, proposerSignature: signatureText };
|
||||
setOfferText(JSON.stringify(offer, null, 2));
|
||||
onNotice('Signed offer prepared. Share its JSON with the other player; nothing has been submitted yet.');
|
||||
} catch (error) { setOfferError(error instanceof Error ? error.message : 'Could not create trade offer.'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const acceptOffer = async () => {
|
||||
if (!wallet || !snapshot || busy) return;
|
||||
setBusy(true); setOfferError('');
|
||||
try {
|
||||
if (offerText.length > 16_384) throw new Error('Trade offer JSON is too large.');
|
||||
const offer: TradeOffer = tradeOfferSchema.parse(JSON.parse(offerText));
|
||||
if (offer.network !== NETWORK || offer.chainId !== snapshot.chainId) throw new Error('Offer is for another network or chain.');
|
||||
if (offer.counterparty !== wallet.address) throw new Error('This offer is addressed to a different wallet.');
|
||||
if (!await verifyOfferSignature(offer)) throw new Error('The proposer signature is invalid; do not accept this offer.');
|
||||
const fresh = await api<TestnetSnapshot>('state');
|
||||
if (fresh.chainId !== offer.chainId) throw new Error('The chain changed. Reload and review the offer again.');
|
||||
for (const leg of offer.offered) {
|
||||
const block = fresh.blocks.find(item => item.tokenId === leg.cardHash);
|
||||
if (!block || !ownsFrom(fresh, leg.cardHash, offer.proposer) || ownershipSequence(fresh, leg.cardHash) !== leg.sequence ||
|
||||
fresh.blocks.length + 1 - block.height < CONSENSUS.coinbaseMaturity) throw new Error('An offered card is no longer owned, current, or mature.');
|
||||
}
|
||||
for (const leg of offer.requested) {
|
||||
const block = fresh.blocks.find(item => item.tokenId === leg.cardHash);
|
||||
if (!block || !ownsFrom(fresh, leg.cardHash, wallet.address) || ownershipSequence(fresh, leg.cardHash) !== leg.sequence ||
|
||||
fresh.blocks.length + 1 - block.height < CONSENSUS.coinbaseMaturity) throw new Error('One of your requested cards is no longer owned, current, or mature.');
|
||||
}
|
||||
const counterpartySignature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, wallet.privateKey,
|
||||
new TextEncoder().encode(nftTradeMessage(offer)));
|
||||
const accepted: NftTrade = { type: 'trade', network: offer.network, chainId: offer.chainId,
|
||||
proposer: offer.proposer, counterparty: offer.counterparty, offered: offer.offered, requested: offer.requested,
|
||||
proposerPublicKey: offer.proposerPublicKey, proposerSignature: offer.proposerSignature,
|
||||
counterpartyPublicKey: wallet.publicKey,
|
||||
counterpartySignature: btoa(String.fromCharCode(...new Uint8Array(counterpartySignature))) };
|
||||
const result = await api<{ status: string; message: string }>('trades', accepted);
|
||||
onNotice(result.message);
|
||||
setOfferText(''); setRemoteDeck(undefined); setOffered(new Set()); setRequested(new Set());
|
||||
} catch (error) { setOfferError(error instanceof Error ? error.message : 'Could not accept this trade offer.'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const copyOffer = async () => {
|
||||
try { await navigator.clipboard.writeText(offerText); onNotice('Signed trade offer copied. Share it privately with the other player.'); }
|
||||
catch { onNotice('Clipboard unavailable. Select and copy the offer JSON.'); }
|
||||
};
|
||||
|
||||
const mineEligible = myDeck.filter(card => card.confirmations >= CONSENSUS.coinbaseMaturity &&
|
||||
!snapshot?.pendingTransfers.some(tx => (isNftTrade(tx)
|
||||
? [...tx.offered, ...tx.requested].some(leg => leg.cardHash === card.block.tokenId)
|
||||
: tx.cardHash === card.block.tokenId)));
|
||||
const otherEligible = (remoteDeck?.nfts ?? []).filter(card => card.confirmations >= CONSENSUS.coinbaseMaturity &&
|
||||
!snapshot?.pendingTransfers.some(tx => (isNftTrade(tx)
|
||||
? [...tx.offered, ...tx.requested].some(leg => leg.cardHash === card.block.tokenId)
|
||||
: tx.cardHash === card.block.tokenId)));
|
||||
|
||||
return <section className="trade-desk">
|
||||
<header className="trade-heading"><div><div className="section-eyebrow">PLAYER-TO-PLAYER · ATOMIC SETTLEMENT</div><h1>Trade decks</h1><p>Browse a public wallet deck, choose multiple cards on both sides, then exchange a signed offer. The complete trade settles as one indivisible on-chain transaction.</p></div><span className="trade-limit">Up to {CONSENSUS.maxTradeCards} cards per side</span></header>
|
||||
{!wallet && <p className="trade-warning">Open or restore your wallet to build and accept trade offers.</p>}
|
||||
{!rpcAvailable && <p className="trade-warning">Node is unavailable. Reconnect before creating or accepting a trade.</p>}
|
||||
<section className="trade-lookup"><label htmlFor="player-address">PLAYER RECEIVE ADDRESS</label><div><input id="player-address" value={address} onChange={event => setAddress(event.target.value)} placeholder="Paste a 64-character wallet address" maxLength={64} autoComplete="off"/><button className="button dark" onClick={loadDeck} disabled={loadingDeck || !rpcAvailable}>{loadingDeck?<LoaderCircle className="spin" size={16}/>:<RefreshCw size={16}/>}Load deck</button></div><p>Decks are public canonical ownership at the node’s reported chain tip. Viewing an address can reveal its holdings.</p></section>
|
||||
<div className="trade-columns">
|
||||
<DeckList title="Your confirmed cards · offered" cards={mineEligible} selected={offered} onToggle={id => toggle(setOffered, offered, id)} disabled={!wallet || busy}/>
|
||||
<div className="trade-arrow"><ArrowLeftRight size={24}/><span>Swap</span></div>
|
||||
<DeckList title={remoteDeck?`Player ${short(remoteDeck.address)} · requested`:'Other player’s deck'} cards={otherEligible} selected={requested} onToggle={id => toggle(setRequested, requested, id)} disabled={busy || !remoteDeck}/>
|
||||
</div>
|
||||
<div className="trade-actions"><button className="button dark" onClick={createOffer} disabled={!wallet || !rpcAvailable || !remoteDeck || !offered.size || !requested.size || busy}><Check size={16}/>Sign offer</button><span>{offered.size} offered · {requested.size} requested</span></div>
|
||||
<section className="trade-import"><div className="section-eyebrow">OFFER JSON · COPY OR PASTE</div><p>Only the proposing wallet signs when creating an offer. It is not active until the addressed player reviews and accepts it. Acceptance signs all requested cards and submits one atomic trade bundle; stale offers are rejected.</p><textarea aria-label="Trade offer JSON" value={offerText} onChange={event => setOfferText(event.target.value)} placeholder="Create an offer above, or paste a trade offer from another player." spellCheck={false}/>{parsedOffer&&<div className="trade-review"><strong>Review before signing</strong><p>{short(parsedOffer.proposer)} offers {parsedOffer.offered.map(leg=>offerTitle(leg.cardHash)).join(', ')} to {short(parsedOffer.counterparty)} for {parsedOffer.requested.map(leg=>offerTitle(leg.cardHash)).join(', ')}.</p><small>Offer signatures are checked by the node. Acceptance transfers all listed cards together or none.</small></div>}{offerError&&<p className="trade-error" role="alert">{offerError}</p>}<div className="trade-actions"><button className="button outline" onClick={copyOffer} disabled={!offerText}><Clipboard size={16}/>Copy offer</button><button className="button dark" onClick={acceptOffer} disabled={!wallet || !rpcAvailable || !parsedOffer || busy}>{busy?<LoaderCircle className="spin" size={16}/>:<Send size={16}/>}Accept and submit atomic trade</button></div>
|
||||
{remoteDeck&&<div className="trade-tip">Deck tip: <code>{short(remoteDeck.tip)}</code> · showing {remoteDeck.nfts.length} of {remoteDeck.total}; ownership and maturity re-checked when signing.{remoteDeck.next!==null&&<button className="text-button" onClick={loadMore} disabled={loadingMore}>{loadingMore?<LoaderCircle className="spin" size={14}/>:null}Load more cards</button>}</div>}
|
||||
</section>
|
||||
</section>;
|
||||
}
|
||||
+1
-1
@@ -83,7 +83,7 @@ export function WalletPanel({ wallet, onRestore, onNotice }: Props) {
|
||||
|
||||
return <section aria-labelledby={`${id}-title`} aria-busy={busy}>
|
||||
<h2 id={`${id}-title`}>Wallet backup & recovery</h2>
|
||||
<p>This is a recoverable P-256 wallet for wikideck-randomx-testnet-5, separate from your old demo wallet.</p>
|
||||
<p>This is a recoverable P-256 wallet for wikideck-randomx-testnet-6, separate from your old demo wallet.</p>
|
||||
<p>The exportable private key is stored locally in this browser’s IndexedDB, not encrypted by this password.
|
||||
Scripts running on this origin or someone with access to your browser profile may access it.
|
||||
Clearing site data without a backup loses access.</p>
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
.trade-desk{max-width:1180px;margin:0 auto}.trade-heading{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;margin:24px 0 28px}.trade-heading h1{font-size:32px;letter-spacing:-1px;margin:8px 0}.trade-heading p,.trade-lookup p,.trade-import>p{max-width:760px;color:var(--ctp-subtext0);font-size:12px;line-height:1.8}.trade-limit{white-space:nowrap;padding:9px 12px;border:1px solid var(--theme-border);border-radius:8px;color:var(--ctp-lavender);font-size:10px}.trade-warning,.trade-error{padding:12px 14px;border:1px solid #e78284;color:var(--ctp-red);background:color-mix(in srgb,var(--ctp-red) 9%,var(--ctp-base));border-radius:7px;font-size:12px}.trade-error{margin:12px 0}.trade-lookup,.trade-import{padding:18px 20px;margin:18px 0 24px;border:1px solid var(--theme-border);background:var(--ctp-mantle);border-radius:10px}.trade-lookup>label{display:block;margin-bottom:8px;color:var(--ctp-subtext1);font-size:9px;letter-spacing:1px}.trade-lookup>div{display:flex;gap:10px}.trade-lookup input,.trade-import textarea{min-width:0;width:100%;padding:12px;border:1px solid var(--ctp-surface1);border-radius:7px;background:var(--ctp-base);color:var(--ctp-text);font:inherit;font-size:12px}.trade-lookup .button,.trade-actions .button{display:inline-flex;align-items:center;justify-content:center;gap:8px;white-space:nowrap}.trade-lookup p{margin:9px 0 0;font-size:10px}.trade-columns{display:grid;grid-template-columns:minmax(0,1fr) 44px minmax(0,1fr);gap:16px;align-items:start}.trade-deck-list{min-width:0;padding:16px;border:1px solid var(--theme-border);border-radius:10px;background:var(--ctp-mantle)}.trade-deck-list h3{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:0 0 12px;font-size:13px;color:var(--ctp-text)}.trade-deck-list h3 span{display:grid;place-items:center;min-width:22px;height:22px;border-radius:6px;background:var(--ctp-surface0);font-size:10px}.trade-card-list{display:grid;gap:7px;max-height:440px;overflow:auto}.trade-card-row{display:grid;grid-template-columns:16px 46px minmax(0,1fr);align-items:center;gap:10px;min-height:58px;padding:6px 8px;border:1px solid transparent;border-radius:7px;background:var(--ctp-base);cursor:pointer}.trade-card-row:has(input:checked){border-color:var(--theme-accent);background:var(--theme-accent-soft)}.trade-card-row input{accent-color:var(--theme-accent)}.trade-card-row img{width:46px;height:42px;object-fit:cover;border-radius:5px;background:var(--ctp-surface0)}.trade-card-row span{min-width:0}.trade-card-row strong,.trade-card-row small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trade-card-row strong{font-size:10px;color:var(--ctp-text)}.trade-card-row small{margin-top:4px;font-size:8px;color:var(--ctp-subtext0)}.trade-arrow{display:grid;justify-items:center;gap:5px;margin-top:80px;color:var(--ctp-peach)}.trade-arrow span{font-size:8px;letter-spacing:.6px;text-transform:uppercase}.trade-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:16px 0}.trade-actions>span{color:var(--ctp-subtext0);font-size:10px}.trade-import textarea{display:block;min-height:150px;margin-top:12px;resize:vertical;font-family:ui-monospace,monospace;font-size:10px}.trade-review{margin-top:12px;padding:12px;border:1px solid var(--theme-border);border-radius:7px;background:var(--ctp-base);font-size:11px}.trade-review strong{color:var(--ctp-peach)}.trade-review p{margin:7px 0;color:var(--ctp-text);line-height:1.7}.trade-review small{color:var(--ctp-subtext0);line-height:1.6}.trade-tip{display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:var(--ctp-subtext0);font-size:9px}.trade-tip code{color:var(--ctp-text)}.trade-tip .text-button{font-size:10px}.trade-lookup input:focus-visible,.trade-import textarea:focus-visible,.trade-card-row input:focus-visible{outline:2px solid var(--theme-accent);outline-offset:2px}
|
||||
@media(max-width:900px){.trade-columns{grid-template-columns:minmax(0,1fr)}.trade-arrow{display:flex;justify-content:center;align-items:center;margin:0;transform:rotate(90deg)}.trade-arrow span{display:none}}
|
||||
@media(max-width:560px){.trade-heading{display:block}.trade-heading h1{font-size:26px}.trade-limit{display:inline-block;margin-top:8px}.trade-lookup,.trade-import{padding:14px}.trade-lookup>div{display:grid}.trade-lookup .button{width:100%}.trade-card-list{max-height:360px}.trade-actions .button{flex:1;font-size:10px}.trade-review{font-size:10px}}
|
||||
@@ -208,6 +208,8 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
|
||||
await (await request(first.base, '/api/mining/template', 400, {})).text();
|
||||
await (await request(first.base, '/api/blocks', 400, {})).text();
|
||||
await (await request(first.base, '/api/transfer', 400, {})).text();
|
||||
await (await request(first.base, '/api/trades', 400, {})).text();
|
||||
await (await request(first.base, `/api/decks/${'x'.repeat(64)}`, 400)).text();
|
||||
await (await request(first.base, '/api/mining/template', 403, {}, { Origin: 'https://evil.example' })).text();
|
||||
await (await request(first.base, '/api/mining/template', 415, {}, { 'Content-Type': 'text/plain' })).text();
|
||||
const malformed = await fetch(`${first.base}/api/blocks`, { method: 'POST',
|
||||
@@ -240,6 +242,13 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
|
||||
const block2 = await mine(first.base, alice);
|
||||
assert.equal(block2.height, 2);
|
||||
await submit(first.base, block2);
|
||||
const aliceDeck = await json<{ address: string; tip: string; total: number; nfts: { block: TestnetBlock }[] }>(
|
||||
first.base, `/api/decks/${alice.owner}`);
|
||||
assert.equal(aliceDeck.address, alice.owner);
|
||||
assert.equal(aliceDeck.tip, block2.hash);
|
||||
assert.equal(aliceDeck.total, 2);
|
||||
assert.deepEqual(aliceDeck.nfts.map(item => item.block.tokenId), [block1.tokenId, block2.tokenId]);
|
||||
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.
|
||||
const second = await start(secondDir, first.base);
|
||||
|
||||
@@ -13,8 +13,8 @@ import { PeerNetwork } from '../server/testnet/peers.ts';
|
||||
import { CHAIN_ID, GENESIS_HASH, GENESIS_TIME, MAX_TARGET, address, makeTemplate, miningBlob,
|
||||
mintId, sha256, stateRoot, transferId, transition, workFor } from '../server/testnet/consensus.ts';
|
||||
import { NETWORK, CONSENSUS, blockClaimMessage, nftTransferMessage, testnetOwnerOf,
|
||||
type MiningTemplate, type NftState, type NftStateLayer, type NftStateView,
|
||||
type NftTransfer, type TestnetBlock } from '../shared/testnet.ts';
|
||||
nftTradeMessage, type MiningTemplate, type NftState, type NftStateLayer, type NftStateView,
|
||||
type NftTrade, type NftTransfer, type TestnetBlock } from '../shared/testnet.ts';
|
||||
import { deriveStats } from '../shared/enrichment.ts';
|
||||
import type { PowEngine } from '../server/testnet/randomx.ts';
|
||||
|
||||
@@ -77,6 +77,15 @@ function transfer(cardHash: string, from: Identity = alice, to: Identity = bob,
|
||||
sequence, publicKey: from.publicKey };
|
||||
return { ...tx, signature: from.sign(nftTransferMessage(tx)) };
|
||||
}
|
||||
function trade(offered: TestnetBlock[], requested: TestnetBlock[], proposer: Identity = alice,
|
||||
counterparty: Identity = bob): NftTrade {
|
||||
const terms = { network: NETWORK, chainId: CHAIN_ID, proposer: proposer.owner, counterparty: counterparty.owner,
|
||||
offered: offered.map(block => ({ cardHash: block.tokenId, sequence: 0 })),
|
||||
requested: requested.map(block => ({ cardHash: block.tokenId, sequence: 0 })) };
|
||||
const message = nftTradeMessage(terms);
|
||||
return { type: 'trade', ...terms, proposerPublicKey: proposer.publicKey, proposerSignature: proposer.sign(message),
|
||||
counterpartyPublicKey: counterparty.publicKey, counterpartySignature: counterparty.sign(message) };
|
||||
}
|
||||
async function fixture(t: { after: (fn: () => Promise<void>) => void }) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wikideck-testnet-'));
|
||||
const engine = new FakePow();
|
||||
@@ -192,6 +201,51 @@ test('NFT maturity, ordered mempool, block commitments and replay protection', a
|
||||
await assert.rejects(restarted.submitTransfer(transfer(minted.tokenId, carol, alice, 0)), /owner\/sequence/);
|
||||
});
|
||||
|
||||
test('signed multi-card trade is validated, mempooled, mined, and applied atomically', async t => {
|
||||
const { ledger } = await fixture(t);
|
||||
const chain = branch();
|
||||
const aliceFirst = await extend(chain, alice); await ledger.submit(aliceFirst);
|
||||
const bobFirst = await extend(chain, bob); await ledger.submit(bobFirst);
|
||||
const aliceSecond = await extend(chain, alice); await ledger.submit(aliceSecond);
|
||||
const bobSecond = await extend(chain, bob); await ledger.submit(bobSecond);
|
||||
await ledger.submit(await extend(chain, alice)); // All four NFTs are mature at the next inclusion height.
|
||||
|
||||
const offered = [aliceFirst, aliceSecond], requested = [bobFirst, bobSecond];
|
||||
const invalid = trade(offered, requested);
|
||||
const badTerms = { ...invalid, requested: invalid.requested.map((leg, index) =>
|
||||
index === 1 ? { ...leg, sequence: 1 } : leg) };
|
||||
const badMessage = nftTradeMessage(badTerms);
|
||||
const badSequence = { ...badTerms, proposerSignature: alice.sign(badMessage), counterpartySignature: bob.sign(badMessage) };
|
||||
const before = ledger.snapshot();
|
||||
await assert.rejects(ledger.submitTransaction(badSequence), /missing, stale, or immature/);
|
||||
const badSignature = { ...invalid, proposerSignature: bob.sign(nftTradeMessage(invalid)) };
|
||||
await assert.rejects(ledger.submitTransaction(badSignature), /signature/i);
|
||||
assert.deepEqual(ledger.snapshot().pendingTransfers, []);
|
||||
for (const block of offered) assert.equal(testnetOwnerOf(before, block.tokenId), alice.owner);
|
||||
for (const block of requested) assert.equal(testnetOwnerOf(before, block.tokenId), bob.owner);
|
||||
|
||||
const bundle = trade(offered, requested);
|
||||
await ledger.submitTransaction(bundle);
|
||||
assert.deepEqual(ledger.snapshot().pendingTransfers, [bundle]);
|
||||
const template = ledger.template(alice.owner, alice.publicKey, articleFor(ledger.snapshot().blocks), statsSnapshot());
|
||||
assert.deepEqual(template.transactions, [bundle]);
|
||||
await ledger.submit(await mine(template));
|
||||
const after = ledger.snapshot();
|
||||
assert.equal(after.pendingTransfers.length, 0);
|
||||
for (const block of offered) assert.equal(testnetOwnerOf(after, block.tokenId), bob.owner);
|
||||
for (const block of requested) assert.equal(testnetOwnerOf(after, block.tokenId), alice.owner);
|
||||
assert.deepEqual(after.transfers.at(-1), bundle);
|
||||
const deck = ledger.deck(alice.owner, 0, 2);
|
||||
assert.equal(deck.total, after.blocks.filter(block => testnetOwnerOf(after, block.tokenId) === alice.owner).length);
|
||||
assert.equal(deck.nfts.length, 2);
|
||||
assert.equal(deck.next, 2);
|
||||
assert.deepEqual(ledger.deck(alice.owner, deck.next!, 2).nfts.map(item => item.block.tokenId).sort(),
|
||||
ledger.deck(alice.owner).nfts.slice(2).map(item => item.block.tokenId).sort());
|
||||
assert.deepEqual(ledger.deck(alice.owner).nfts.map(item => item.block.tokenId).sort(),
|
||||
after.blocks.filter(block => testnetOwnerOf(after, block.tokenId) === alice.owner)
|
||||
.map(block => block.tokenId).sort());
|
||||
});
|
||||
|
||||
test('reorg rolls back NFT ownership and requeues only still-valid disconnected transfers in order', async t => {
|
||||
const { ledger, dir } = await fixture(t);
|
||||
const original = branch();
|
||||
|
||||
@@ -54,7 +54,7 @@ test('Node WebCrypto backup roundtrip preserves SPKI SHA256 identity and signing
|
||||
key: createPublicKey({ key: spki, format: 'der', type: 'spki' }), dsaEncoding: 'ieee-p1363',
|
||||
}, Buffer.from(signature, 'base64')), true);
|
||||
assert.equal(backup.iterations, 600000);
|
||||
assert.equal(backup.network, 'wikideck-randomx-testnet-5');
|
||||
assert.equal(backup.network, 'wikideck-randomx-testnet-6');
|
||||
assert.equal(Buffer.from(backup.salt, 'base64').length, 16);
|
||||
assert.equal(Buffer.from(backup.iv, 'base64').length, 12);
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(backup)) < 32 * 1024);
|
||||
|
||||
Reference in New Issue
Block a user