This commit is contained in:
2026-10-20 03:14:08 -04:00
parent b1d2b5c10e
commit 9850fd8554
28 changed files with 1256 additions and 26 deletions
+7 -1
View File
@@ -1,2 +1,8 @@
// A node independently validates RandomX work and follows cumulative-work consensus.
import './testnet/http.ts';
import { assertLaunchAllowed, launchManifest } from './launch.ts';
if (process.argv.includes('--network-manifest')) console.log(JSON.stringify(launchManifest(), null, 2));
else {
assertLaunchAllowed();
await import('./testnet/http.ts');
}
+26
View File
@@ -0,0 +1,26 @@
import { IS_MAINNET, NETWORK, PROFILE } from '../shared/network.ts';
import { CHAIN_ID, GENESIS_HASH, GENESIS_TIME } from './testnet/consensus.ts';
export function launchManifest() {
return { profile: PROFILE, network: NETWORK, chainId: CHAIN_ID, genesis: GENESIS_HASH,
genesisTime: GENESIS_TIME, launchStatus: IS_MAINNET ? 'candidate-gated' : 'testnet',
paymentMode: 'mock', audited: false };
}
/** Operator attestation is an accident-prevention gate, NOT proof of a security audit. */
export function assertLaunchAllowed(env: NodeJS.ProcessEnv = process.env, now = Date.now()) {
if (!IS_MAINNET) return;
if (env.WIKIDECK_MAINNET_LAUNCH !== CHAIN_ID || env.WIKIDECK_MAINNET_REVIEW_ACK !== 'independent-review-complete') {
throw new Error(`Mainnet candidate is launch-gated. Review the launch checklist; explicit chain approval required: ${CHAIN_ID}`);
}
if (now < GENESIS_TIME * 1000) throw new Error('Mainnet candidate genesis time has not arrived; this is not an announced launch date');
const origin = new URL(env.PUBLIC_ORIGIN ?? 'http://invalid');
if (origin.protocol !== 'https:' || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash) throw new Error('Mainnet requires an HTTPS PUBLIC_ORIGIN');
const peers = (env.WIKIDECK_PEERS ?? '').split(',').map(s => s.trim()).filter(Boolean);
if (new Set(peers).size < 2) throw new Error('Mainnet launch requires at least two explicitly configured independent HTTPS peers');
for (const text of peers) {
const peer = new URL(text);
if (peer.protocol !== 'https:' || peer.origin === origin.origin || peer.username || peer.password || peer.pathname !== '/' || peer.search || peer.hash) throw new Error('Invalid mainnet peer origin');
}
if (new Set(peers.map(p => new URL(p).origin)).size < 2 || env.WIKIDECK_ALLOW_PRIVATE_PEERS === '1') throw new Error('Mainnet requires distinct public peer origins; private peer bypass is disabled');
if ((env.WIKIDECK_PAYMENT_MODE ?? 'mock') !== 'mock') throw new Error('Live XMR payments remain disabled pending a separate settlement review');
}
+22
View File
@@ -0,0 +1,22 @@
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
/** No live payment adapter is reachable in this release. Never return a payable mock address. */
export function paymentMode(env: NodeJS.ProcessEnv = process.env): 'mock' {
const mode = env.WIKIDECK_PAYMENT_MODE ?? 'mock';
if (mode !== 'mock') throw new Error('Real payments are launch-gated. Only WIKIDECK_PAYMENT_MODE=mock is supported until production settlement review.');
return mode;
}
export function xmrString(atomic: bigint): string {
return `${atomic / 1_000_000_000_000n}.${(atomic % 1_000_000_000_000n).toString().padStart(12, '0')}`;
}
/** Deterministic simulation ONLY. Round gross up and both independent seller fees down. */
export function mockQuote(priceCents: number, feeBps: number, usdCentsPerXmr: number) {
const rate = BigInt(usdCentsPerXmr);
const gross = (BigInt(priceCents) * 1_000_000_000_000n + rate - 1n) / rate;
const fee = gross * BigInt(feeBps) / 10_000n;
const additionalFee = gross * BigInt(ADDITIONAL_MARKET_FEE.bps) / 10_000n;
return { invoice: { provider: 'mock' as const, amountXmr: xmrString(gross), address: null,
label: 'SIMULATION ONLY — no Monero payment address. Never send real XMR.' },
feeXmr: xmrString(fee), sellerNetXmr: xmrString(gross - fee - additionalFee),
additionalFee: { ...ADDITIONAL_MARKET_FEE, amountXmr: xmrString(additionalFee), payout: 'not-started' as const } };
}
+262
View File
@@ -0,0 +1,262 @@
import { DatabaseSync } from 'node:sqlite';
import { randomUUID } from 'node:crypto';
import { mkdir, chmod } from 'node:fs/promises';
import { join } from 'node:path';
import { NETWORK, CONSENSUS, nftTransferMessage, testnetOwnerOf, transactionCardIds,
type TestnetSnapshot, type NftTransfer } from '../../shared/testnet.ts';
import { ADDITIONAL_MARKET_FEE, marketCommandSchema, marketMessage, type MarketCommand, type MarketListing,
type MarketOrder, type MarketInfo, type MarketActivity } from '../../shared/market.ts';
import { CHAIN_ID, transactionId, transferSchema, verifySignature } from '../testnet/consensus.ts';
import { mockQuote, paymentMode } from './payments.ts';
type Chain = { snapshot(): TestnetSnapshot; submitTransfer(raw: unknown): Promise<string> };
type Config = { feeBps: number; confirmations: number; usdCentsPerXmr: number };
export function marketConfig(env: NodeJS.ProcessEnv = process.env): Config {
paymentMode(env);
const integer = (name: string, fallback: number, min: number, max: number) => {
const text = env[name] ?? String(fallback);
if (!/^\d+$/.test(text)) throw new Error(`Invalid ${name}`);
const n = Number(text);
if (!Number.isSafeInteger(n) || n < min || n > max) throw new Error(`Invalid ${name}`);
return n;
};
return { feeBps: integer('WIKIDECK_MARKET_FEE_BPS', 250, 0, 5000),
confirmations: integer('WIKIDECK_MARKET_CONFIRMATIONS', 10, 2, 1000),
usdCentsPerXmr: integer('WIKIDECK_MOCK_USD_CENTS_PER_XMR', 15000, 1, 100_000_000) };
}
const PAYMENT_WINDOW = 30 * 60_000;
const DELIVERY_WINDOW = 48 * 60 * 60_000;
/** Node-local marketplace, NOT consensus escrow. Its SQLite journal is backed up with the ledger. */
export class Market {
private queue: Promise<unknown> = Promise.resolve();
private queued = 0;
private readonly db: DatabaseSync;
private readonly chain: Chain;
readonly config: Config;
readonly id: string;
private readonly now: () => number;
private constructor(db: DatabaseSync, chain: Chain, config: Config, id: string, now: () => number) {
this.db = db; this.chain = chain; this.config = config; this.id = id; this.now = now;
}
static async open(directory: string, chain: Chain, config = marketConfig(), now = Date.now): Promise<Market> {
const folder = join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet');
await mkdir(folder, { recursive: true, mode: 0o700 });
const file = join(folder, 'market.sqlite');
const db = new DatabaseSync(file);
try {
db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000;
CREATE TABLE IF NOT EXISTS market_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS listings(id TEXT PRIMARY KEY, card TEXT NOT NULL, status TEXT NOT NULL, data TEXT NOT NULL);
CREATE UNIQUE INDEX IF NOT EXISTS one_active_listing ON listings(card) WHERE status IN ('open','reserved');
CREATE TABLE IF NOT EXISTS orders(id TEXT PRIMARY KEY, data TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS receipts(id TEXT PRIMARY KEY, digest TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);`);
const meta = db.prepare('SELECT value FROM market_meta WHERE key=?').get('identity') as { value: string } | undefined;
let id: string;
if (meta) {
const identity = JSON.parse(meta.value);
if (identity.network !== NETWORK || identity.chainId !== CHAIN_ID || identity.version !== 1 ||
typeof identity.id !== 'string') throw new Error('Marketplace network/schema mismatch');
id = identity.id;
} else {
id = randomUUID();
db.prepare('INSERT INTO market_meta VALUES(?,?)').run('identity', JSON.stringify({ version: 1, network: NETWORK, chainId: CHAIN_ID, id }));
}
if (process.platform !== 'win32') await chmod(file, 0o600);
return new Market(db, chain, config, id, now);
} catch (error) { db.close(); throw error; }
}
close() { this.db.close(); }
info(): MarketInfo {
return { network: NETWORK, chainId: CHAIN_ID, marketId: this.id, paymentMode: 'mock', feeBps: this.config.feeBps,
additionalFee: { ...ADDITIONAL_MARKET_FEE },
deliveryConfirmations: this.config.confirmations, mockUsdPerXmr: (this.config.usdCentsPerXmr / 100).toFixed(2), serverTime: this.now() };
}
private rows<T>(table: 'listings' | 'orders'): T[] {
return (this.db.prepare(`SELECT data FROM ${table} ORDER BY rowid DESC LIMIT 10000`).all() as { data: string }[])
.map(row => JSON.parse(row.data) as T);
}
private listing(id: string): MarketListing {
const row = this.db.prepare('SELECT data FROM listings WHERE id=?').get(id) as { data: string } | undefined;
if (!row) throw new Error('Listing not found');
return JSON.parse(row.data);
}
private order(id: string): MarketOrder {
const row = this.db.prepare('SELECT data FROM orders WHERE id=?').get(id) as { data: string } | undefined;
if (!row) throw new Error('Order not found');
return JSON.parse(row.data);
}
private saveListing(l: MarketListing) {
this.db.prepare('INSERT INTO listings VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data')
.run(l.id, l.cardHash, l.status, JSON.stringify(l));
}
private saveOrder(o: MarketOrder) {
this.db.prepare('INSERT INTO orders VALUES(?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data').run(o.id, JSON.stringify(o));
}
private atomic<T>(fn: () => T): T {
this.db.exec('BEGIN IMMEDIATE');
try { const result = fn(); this.db.exec('COMMIT'); return result; }
catch (error) { this.db.exec('ROLLBACK'); throw error; }
}
private card(snapshot: TestnetSnapshot, hash: string, owner: string, sequence: number): boolean {
const block = snapshot.blocks.find(b => b.tokenId === hash);
return !!block && testnetOwnerOf(snapshot, hash) === owner &&
snapshot.transfers.filter(tx => transactionCardIds(tx).includes(hash)).length === sequence &&
snapshot.blocks.length + 1 - block.height >= CONSENSUS.coinbaseMaturity;
}
/** Reconcile from canonical transaction identity, never from a mempool acknowledgement. */
private reconcile() {
const s = this.chain.snapshot(), now = this.now();
this.atomic(() => {
for (const o of this.rows<MarketOrder>('orders')) {
const l = this.listing(o.listingId);
if (o.status === 'refunded' || o.status === 'expired') continue;
const delivery = o.transferId && s.blocks.find(b => b.transactions.some(tx => transactionId(tx) === o.transferId));
const confirmations = delivery ? s.blocks.length - delivery.height + 1 : 0;
o.deliveryConfirmations = confirmations;
if (o.status === 'complete' || o.status === 'review') {
if (!delivery || confirmations < o.requiredConfirmations) {
o.status = 'review'; o.payout = 'review';
if (o.additionalFee) o.additionalFee.payout = 'review';
}
this.saveOrder(o); continue; // Never automatically pay a second time after a reorganization.
}
if (delivery) {
o.status = 'delivery-pending';
if (confirmations >= o.requiredConfirmations) {
o.status = 'complete'; o.payout = 'mock-paid'; l.status = 'sold';
if (o.additionalFee) o.additionalFee.payout = 'mock-paid';
}
} else if (o.status === 'awaiting-payment' && now >= o.payBy) {
o.status = 'expired'; l.status = l.kind === 'fixed' ? 'open' : 'expired';
} else if (o.status !== 'awaiting-payment') {
const pending = s.pendingTransfers.some(tx => transactionId(tx) === o.transferId);
const conflict = s.pendingTransfers.some(tx => transactionCardIds(tx).includes(o.cardHash) && transactionId(tx) !== o.transferId);
if (!this.card(s, o.cardHash, o.seller, o.sequence) || conflict || (now >= o.deliverBy && !pending)) o.status = 'refund-required';
else if (o.status === 'delivery-pending' && !pending) o.status = 'paid';
}
this.saveOrder(o); this.saveListing(l);
}
for (const l of this.rows<MarketListing>('listings')) {
if (l.status !== 'open') continue;
if (l.additionalFee?.bps !== ADDITIONAL_MARKET_FEE.bps || l.additionalFee?.address !== ADDITIONAL_MARKET_FEE.address ||
!this.card(s, l.cardHash, l.seller, l.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(l.cardHash))) l.status = 'invalid';
else if (l.kind === 'auction' && now >= l.endsAt! + PAYMENT_WINDOW) l.status = 'expired';
else if (l.kind === 'auction' && now >= l.endsAt! && !l.highestBid) l.status = 'expired';
this.saveListing(l);
}
this.db.prepare('DELETE FROM receipts WHERE expires < ?').run(now - 86400_000);
});
}
browse(offset = 0): { listings: MarketListing[]; next: number | null; info: MarketInfo } {
if (!Number.isSafeInteger(offset) || offset < 0 || offset > 10000) throw new Error('Invalid marketplace page');
// Reconciliation must not interleave with delivery submission while its chain await is outstanding.
if (!this.queued) this.reconcile();
const all = this.rows<MarketListing>('listings').filter(l => l.status === 'open' || l.status === 'reserved');
return { listings: all.slice(offset, offset + 24), next: offset + 24 < all.length ? offset + 24 : null, info: this.info() };
}
execute(raw: unknown): Promise<MarketActivity | MarketListing | MarketOrder> {
const cmd = marketCommandSchema.parse(raw);
if (this.queued >= 64) return Promise.reject(new Error('Marketplace busy; retry shortly'));
this.queued++;
const result = this.queue.then(async () => {
try { return await this.run(cmd); }
finally { this.queued--; }
});
this.queue = result.catch(() => undefined);
return result;
}
private async run(c: MarketCommand): Promise<MarketActivity | MarketListing | MarketOrder> {
const now = this.now();
if (c.chainId !== CHAIN_ID || c.marketId !== this.id || c.expiresAt < now || c.expiresAt > now + 5 * 60_000 ||
!verifySignature(c.publicKey, c.actor, marketMessage(c), c.signature)) throw new Error('Invalid or expired marketplace signature');
const key = `${c.actor}:${c.nonce}`, digest = marketMessage(c);
const old = this.db.prepare('SELECT digest,data FROM receipts WHERE id=?').get(key) as { digest: string; data: string } | undefined;
if (old) {
if (old.digest !== digest) throw new Error('Nonce already used for different terms');
return JSON.parse(old.data);
}
this.reconcile();
const a = c.action;
if (a.type === 'activity') return { orders: this.rows<MarketOrder>('orders').filter(o => o.seller === c.actor || o.buyer === c.actor),
listings: this.rows<MarketListing>('listings').filter(l => l.seller === c.actor) };
if (a.type === 'deliver') {
const o = this.order(a.orderId), tx = transferSchema.parse(a.transfer);
if (o.seller !== c.actor || !['paid', 'delivery-pending'].includes(o.status)) throw new Error('Only the paid order seller can deliver');
if (tx.cardHash !== o.cardHash || tx.sequence !== o.sequence || tx.from !== o.seller || tx.to !== o.buyer ||
!verifySignature(tx.publicKey, tx.from, nftTransferMessage(tx), tx.signature)) throw new Error('Delivery does not match this sale');
// Persist intent before the ledger await. A crash/restart reconciles by deterministic tx ID.
o.transfer = tx; o.transferId = transactionId(tx); o.status = 'delivery-pending'; this.saveOrder(o);
await this.chain.submitTransfer(tx);
return this.receipt(key, digest, c.expiresAt, o);
}
return this.atomic(() => {
const result = this.mutate(c);
return this.receipt(key, digest, c.expiresAt, result);
});
}
private receipt<T>(key: string, digest: string, expires: number, value: T): T {
this.db.prepare('INSERT INTO receipts VALUES(?,?,?,?)').run(key, digest, expires, JSON.stringify(value));
return value;
}
private mutate(c: MarketCommand): MarketListing | MarketOrder {
const a = c.action, now = this.now(), s = this.chain.snapshot();
if (a.type === 'list') {
if (a.feeBps !== this.config.feeBps) throw new Error('Market fee changed; review and sign again');
if (!this.card(s, a.cardHash, c.actor, a.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(a.cardHash))) throw new Error('Card is missing, stale, immature, or already pending');
if (a.kind === 'fixed' ? a.endsAt !== null : a.endsAt === null || a.endsAt < now + 60_000 || a.endsAt > now + 30 * 86400_000) throw new Error('Auction duration must be 1 minute to 30 days; fixed listings have no end');
if (this.rows<MarketListing>('listings').filter(l => l.seller === c.actor && ['open','reserved'].includes(l.status)).length >= 100) throw new Error('Limit: 100 active listings per seller');
if ((this.db.prepare('SELECT COUNT(*) AS n FROM listings').get() as { n: number }).n >= 10000) throw new Error('Marketplace journal limit reached; operator archival required');
if (this.db.prepare("SELECT 1 FROM listings WHERE card=? AND status IN ('open','reserved')").get(a.cardHash)) throw new Error('Card already listed');
const mint = s.blocks.find(b => b.tokenId === a.cardHash)!;
const l: MarketListing = { id: randomUUID(), cardHash: a.cardHash, sequence: a.sequence, seller: c.actor,
title: mint.article.title, image: mint.article.image!, kind: a.kind, priceCents: a.priceCents,
feeBps: a.feeBps, additionalFee: { ...ADDITIONAL_MARKET_FEE },
createdAt: now, endsAt: a.endsAt, status: 'open', highestBid: null };
this.saveListing(l); return l;
}
if (a.type === 'bid' || a.type === 'buy' || a.type === 'cancel') {
const l = this.listing(a.listingId);
if (l.status !== 'open') throw new Error('Listing is no longer available');
if (a.type === 'cancel') {
if (l.seller !== c.actor || l.highestBid) throw new Error('Only the seller can cancel, and auctions with bids cannot be cancelled');
l.status = 'cancelled'; this.saveListing(l); return l;
}
if (l.seller === c.actor) throw new Error('Cannot buy or bid on your own card');
if (a.type === 'bid') {
if (l.kind !== 'auction' || now >= l.endsAt!) throw new Error('Auction is not accepting bids');
const minimum = l.highestBid ? l.highestBid.priceCents + 1 : l.priceCents;
if (a.priceCents < minimum) throw new Error('Bid must meet the start price and exceed the current bid by at least $0.01');
l.highestBid = { bidder: c.actor, priceCents: a.priceCents }; this.saveListing(l); return l;
}
const price = l.kind === 'fixed' ? l.priceCents : l.highestBid?.priceCents;
if (l.kind === 'auction' && (now < l.endsAt! || now >= l.endsAt! + PAYMENT_WINDOW || l.highestBid?.bidder !== c.actor)) throw new Error('Only the auction winner can check out within 30 minutes of closing');
if (price !== a.priceCents || l.feeBps !== a.feeBps) throw new Error('Sale terms changed; review and sign again');
if (!this.card(s, l.cardHash, l.seller, l.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(l.cardHash))) throw new Error('Card is no longer available');
if ((this.db.prepare('SELECT COUNT(*) AS n FROM orders').get() as { n: number }).n >= 10000) throw new Error('Marketplace order limit reached; operator archival required');
const payBy = l.kind === 'auction' ? l.endsAt! + PAYMENT_WINDOW : now + PAYMENT_WINDOW;
const o: MarketOrder = { id: randomUUID(), listingId: l.id, seller: l.seller, buyer: c.actor,
cardHash: l.cardHash, sequence: l.sequence, priceCents: price!, feeBps: l.feeBps, createdAt: now, payBy,
deliverBy: payBy + DELIVERY_WINDOW, status: 'awaiting-payment',
...mockQuote(price!, l.feeBps, this.config.usdCentsPerXmr), payout: 'not-started', deliveryConfirmations: 0,
requiredConfirmations: this.config.confirmations };
l.status = 'reserved'; this.saveListing(l); this.saveOrder(o); return o;
}
if (a.type === 'mock-pay' || a.type === 'refund') {
const o = this.order(a.orderId);
if (o.buyer !== c.actor) throw new Error('Only the order buyer can perform this action');
if (a.type === 'mock-pay') {
if (o.status !== 'awaiting-payment' || now >= o.payBy) throw new Error('Invoice is not payable');
if (!this.card(s, o.cardHash, o.seller, o.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(o.cardHash))) throw new Error('Seller card changed; do not pay');
o.status = 'paid'; o.deliverBy = now + DELIVERY_WINDOW;
} else {
if (o.status !== 'refund-required' || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(o.cardHash)) ||
s.blocks.some(b => b.transactions.some(tx => transactionId(tx) === o.transferId))) throw new Error('Refund requires failed delivery and no pending/canonical sale transfer');
o.status = 'refunded'; const l = this.listing(o.listingId); l.status = 'cancelled'; this.saveListing(l);
}
this.saveOrder(o); return o;
}
throw new Error('Unsupported market action');
}
}
+2 -1
View File
@@ -6,11 +6,12 @@ import { NETWORK, CONSENSUS, headerMessage, blockClaimMessage, nftTransferMessag
import type { Article } from '../../shared/protocol.ts';
import { deriveStats, type MintStatsSnapshot } from '../../shared/enrichment.ts';
import type { PowEngine } from './randomx.ts';
import { NETWORK_GENESIS_TIME } from '../../shared/network.ts';
export const sha256 = (data: string | Uint8Array) => createHash('sha256').update(data).digest('hex');
export const MAX_TARGET = `0fff${'f'.repeat(60)}`;
// Consensus manifest: an independently running node derives exactly the same genesis.
export const GENESIS_TIME = 1_792_411_200;
export const GENESIS_TIME = NETWORK_GENESIS_TIME;
export const CHAIN_ID = sha256(JSON.stringify([NETWORK, CONSENSUS, GENESIS_TIME, MAX_TARGET]));
export const GENESIS_HASH = sha256(`${NETWORK}:genesis:${CHAIN_ID}`);
export const INITIAL_KEY = sha256(`${NETWORK}:initial-key:${CHAIN_ID}`);
+31 -5
View File
@@ -14,12 +14,18 @@ import { PeerNetwork } from './peers.ts';
import { wikipediaEnrichment } from '../enrichment.ts';
import { snapshotStats } from '../../shared/enrichment.ts';
import { wikipediaArticles } from '../wiki.ts';
import { Market, marketConfig } from '../market/store.ts';
import { marketCommandSchema } from '../../shared/market.ts';
import { IS_MAINNET, PROFILE } from '../../shared/network.ts';
import { assertLaunchAllowed, launchManifest } from '../launch.ts';
const root = fileURLToPath(new URL('../../', import.meta.url));
assertLaunchAllowed();
const paymentConfig = marketConfig(); // Fail before opening node resources if live payments are requested.
const dataDir = process.env.WIKIDECK_DATA_DIR ?? resolve(root,'data');
await mkdir(dataDir,{recursive:true});
// Never permit two processes to rewrite the same ledger. A stale lock requires operator review.
const lockPath = resolve(dataDir,'testnet-node.lock');
const lockPath = resolve(dataDir,`${PROFILE}-node.lock`);
const lock = await open(lockPath,'wx',0o600).catch(()=>{throw new Error('Data directory locked. Stop its other node; remove stale testnet-node.lock only after verifying no node is running.');});
await lock.writeFile(String(process.pid));
let engine: RandomX;
@@ -32,6 +38,9 @@ let peers: PeerNetwork;
try { peers = new PeerNetwork({ ledger, urls:(process.env.WIKIDECK_PEERS ?? '').split(',').map(s=>s.trim()).filter(Boolean),
allowPrivate:process.env.WIKIDECK_ALLOW_PRIVATE_PEERS === '1' }); }
catch(e) {ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
let market: Market;
try { market = await Market.open(dataDir, ledger, paymentConfig); }
catch(e) {peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
const app = express();
app.disable('x-powered-by');
app.use(helmet({contentSecurityPolicy:{directives:{defaultSrc:["'self'"],scriptSrc:["'self'"],
@@ -52,8 +61,22 @@ app.use(['/api','/p2p'],(req,res,next)=>{
next();
});
app.get('/api/state',(_req,res)=>res.json(ledger.snapshot()));
app.get('/api/network',(_req,res)=>res.json(launchManifest()));
app.get('/api/chain',(_req,res)=>res.json(ledger.snapshot()));
app.get('/api/peers',(_req,res)=>res.json({peers:peers.status()}));
app.get('/api/market', (req,res)=>res.json(market.browse(Number(req.query.offset ?? 0))));
app.post('/api/market/command',rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}),async(req,res)=>{
const command = marketCommandSchema.parse(req.body);
try {
const result = await market.execute(command);
res.json(result);
if(command.action.type==='deliver')void peers.broadcastTransfer(command.action.transfer).catch(()=>{});
} catch(error) {
// Marketplace storage/payment internals must never leak through the generic node error handler.
if(error instanceof Error && !/SQLITE|constraint|database/i.test(error.message))res.status(400).json({error:error.message.slice(0,200)});
else res.status(409).json({error:'Marketplace state conflict. Refresh and retry.'});
}
});
app.get('/api/decks/:address', rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}), (req,res)=>{
if(typeof req.params.address!=='string'||!/^[a-f0-9]{64}$/.test(req.params.address)){
res.status(400).json({error:'Invalid wallet address'});return;
@@ -135,13 +158,16 @@ app.use((err:Error & {status?:number},_req:express.Request,res:express.Response,
});
const port=Number(process.env.PORT??3001),host=process.env.HOST??'127.0.0.1';
const server=app.listen(port,host,()=>{
const a=server.address();console.log(`Wikideck RandomX testnet: http://${host}:${typeof a==='object'&&a?a.port:port} · chain ${CHAIN_ID} · ${ledger.snapshot().blocks.length} blocks`);
const a=server.address();console.log(`Wikideck RandomX ${IS_MAINNET?'mainnet candidate':'testnet'}: http://${host}:${typeof a==='object'&&a?a.port:port} · chain ${CHAIN_ID} · ${ledger.snapshot().blocks.length} blocks`);
peers.start();
});
// Advance simulated payout/expiry state even when no browser is open. No real funds are sent.
const marketTimer=setInterval(()=>{try {market.browse();}catch{console.error('Marketplace reconciliation failed; operator review required.');}},15000);
marketTimer.unref();
let stopping=false;
for(const signal of ['SIGTERM','SIGINT']as const)process.once(signal,()=>{
if(stopping)return;stopping=true;peers.close();
if(stopping)return;stopping=true;peers.close();clearInterval(marketTimer);
const timeout=setTimeout(()=>process.exit(1),25000);timeout.unref();
server.close(async()=>{ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
server.close(async()=>{market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
});
server.on('error',async()=>{peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
server.on('error',async()=>{clearInterval(marketTimer);peers.close();market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
+2 -1
View File
@@ -11,6 +11,7 @@ import { CHAIN_ID, GENESIS_HASH, applyTransactionOverlay, blockSchema, makeTempl
import type { PowEngine } from './randomx.ts';
import type { Article } from '../../shared/protocol.ts';
import type { MintStatsSnapshot } from '../../shared/enrichment.ts';
import { PROFILE } from '../../shared/network.ts';
const MAX_PENDING = 256;
const MAX_DECK_PAGE = 24;
@@ -41,7 +42,7 @@ export class Ledger {
private constructor(file: string, db: DatabaseSync, pow: PowEngine) { this.file = file; this.db = db; this.pow = pow; }
static async open(dir: string, pow: PowEngine): Promise<Ledger> {
const directory = join(dir, 'testnet');
const directory = join(dir, PROFILE);
await mkdir(directory, { recursive: true, mode: 0o700 });
const file = join(directory, 'ledger.sqlite');
let databaseExists = false;
+3
View File
@@ -6,6 +6,9 @@ import { decryptWallet } from '../../shared/wallet-backup.ts';
import { blockClaimMessage, CONSENSUS, NETWORK, type MiningTemplate, type TestnetBlock } from '../../shared/testnet.ts';
import { CHAIN_ID, sha256, miningBlob, mintId, blockSchema } from './consensus.ts';
import { RandomX } from './randomx.ts';
import { assertLaunchAllowed } from '../launch.ts';
assertLaunchAllowed();
const args = process.argv.slice(2);
const arg = (key: string) => { const i = args.indexOf(key); return i < 0 ? undefined : args[i+1]; };