V2.1 (#21)
Working version Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
+80
-22
@@ -6,19 +6,29 @@ 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 { CHAIN_ID, blockSchema, tradeSchema, transferSchema, transactionSchema } from './consensus.ts';
|
||||
import { RandomX } from './randomx.ts';
|
||||
import { Ledger } from './ledger.ts';
|
||||
import { PeerNetwork } from './peers.ts';
|
||||
import { wikipediaEnrichment } from '../enrichment.ts';
|
||||
import { snapshotStats } from '../../shared/enrichment.ts';
|
||||
import { wikipediaArticles } from '../wiki.ts';
|
||||
import { IS_MAINNET, PROFILE } from '../../shared/network.ts';
|
||||
import { assertLaunchAllowed, launchManifest } from '../launch.ts';
|
||||
import { resourceConfig, RpcBudget, RpcError } from '../resources.ts';
|
||||
import { trustedProxies, operationsPort, startOperations } from '../operations.ts';
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
assertLaunchAllowed();
|
||||
const limits = resourceConfig();
|
||||
const proxyTrust=trustedProxies();
|
||||
const opsPort=operationsPort();
|
||||
const apiBudget = new RpcBudget(limits);
|
||||
const peerBudget = new RpcBudget({ ...limits, requestsPerSecond: 40, writesPerSecond: 10, active: 16 });
|
||||
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;
|
||||
@@ -30,15 +40,18 @@ catch(e) {engine.close();await lock.close();await unlink(lockPath);throw e;}
|
||||
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) {engine.close();await lock.close();await unlink(lockPath);throw e;}
|
||||
catch(e) {ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
if(proxyTrust.length)app.set('trust proxy',proxyTrust);
|
||||
app.use(helmet({contentSecurityPolicy:{directives:{defaultSrc:["'self'"],scriptSrc:["'self'"],
|
||||
imgSrc:["'self'",'data:','https://upload.wikimedia.org','https://thumb.wikimedia.org'],styleSrc:["'self'","'unsafe-inline'"],
|
||||
connectSrc:["'self'"],upgradeInsecureRequests:null}},crossOriginEmbedderPolicy:false,
|
||||
strictTransportSecurity:process.env.NODE_ENV==='production'}));
|
||||
app.get('/healthz',(_req,res)=>{res.setHeader('Cache-Control','no-store');res.json({status:'ok',network:NETWORK});});
|
||||
app.use(express.json({limit:'68kb',strict:true}));
|
||||
// Admission comes before body parsing and signature/schema work. Disable compressed request inflation.
|
||||
app.use('/api',apiBudget.middleware());
|
||||
app.use('/p2p',peerBudget.middleware());
|
||||
app.use(['/api','/p2p'],rateLimit({windowMs:60000,limit:300,standardHeaders:'draft-8',legacyHeaders:false}));
|
||||
app.use(['/api','/p2p'],(req,res,next)=>{
|
||||
res.setHeader('Cache-Control','no-store');
|
||||
@@ -50,9 +63,31 @@ app.use(['/api','/p2p'],(req,res,next)=>{
|
||||
}
|
||||
next();
|
||||
});
|
||||
app.get('/api/state',(_req,res)=>res.json(ledger.snapshot()));
|
||||
app.get('/api/chain',(_req,res)=>res.json(ledger.snapshot()));
|
||||
app.use(['/api','/p2p'],express.json({limit:'68kb',strict:true,inflate:false}));
|
||||
app.get('/api/state',(_req,res)=>res.json(ledger.legacySnapshot(limits.legacyBlocks)));
|
||||
app.get('/api/summary',(_req,res)=>res.json(ledger.summary()));
|
||||
app.get('/api/canonical',(req,res)=>{
|
||||
const q=z.object({tip:z.string().regex(/^[a-f0-9]{64}$/),offset:z.string().regex(/^\d{1,12}$/).optional(),
|
||||
limit:z.string().regex(/^\d{1,2}$/).optional()}).strict().parse(req.query);
|
||||
res.json(ledger.canonicalPage(q.tip,Number(q.offset??0),Number(q.limit??16)));
|
||||
});
|
||||
app.get('/api/network',(_req,res)=>res.json(launchManifest()));
|
||||
app.get('/api/chain',(_req,res)=>res.json(ledger.legacySnapshot(limits.legacyBlocks)));
|
||||
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');
|
||||
if(req.query.tip!==undefined && req.query.tip!==ledger.summary().tip){
|
||||
res.status(409).json({error:'Canonical tip changed; restart deck pagination.'});return;
|
||||
}
|
||||
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;}
|
||||
@@ -60,22 +95,24 @@ app.get('/api/wikipedia/:pageId', rateLimit({windowMs:60000,limit:60,standardHea
|
||||
try {res.json(await wikipediaEnrichment.get(pageId));}
|
||||
catch {res.status(503).json({error:'Wikipedia enrichment is temporarily unavailable. NFT data is unaffected.'});}
|
||||
});
|
||||
app.get('/p2p/status',(_req,res)=>res.json({network:NETWORK,chainId:CHAIN_ID,size:ledger.size,tip:ledger.snapshot().tip}));
|
||||
app.get('/p2p/status',(_req,res)=>res.json(ledger.peerStatus()));
|
||||
app.get('/p2p/blocks',(req,res)=>{
|
||||
const offset=Number(req.query.offset??0);
|
||||
if(!Number.isSafeInteger(offset)||offset<0||offset>10000){res.status(400).json({error:'Invalid offset'});return;}
|
||||
if(!Number.isSafeInteger(offset)||offset<0){res.status(400).json({error:'Invalid offset'});return;}
|
||||
const blocks=ledger.inventory(offset,32);
|
||||
res.json({blocks,next:offset+blocks.length<ledger.size?offset+blocks.length:null});
|
||||
});
|
||||
app.get('/p2p/mempool',(_req,res)=>res.json({transactions:ledger.snapshot().pendingTransfers}));
|
||||
app.get('/p2p/mempool',(_req,res)=>res.json({transactions:ledger.pendingTransactions()}));
|
||||
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++;}
|
||||
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});
|
||||
if(typeof req.params.id!=='string'||!/^[a-f0-9]{64}$/.test(req.params.id)){
|
||||
res.status(400).json({error:'Invalid NFT identifier'});return;
|
||||
}
|
||||
const nft=ledger.nft(req.params.id);
|
||||
if(!nft){res.status(404).json({error:'NFT not found on the canonical chain'});return;}
|
||||
const block=nft.block;
|
||||
res.json({network:NETWORK,chainId:CHAIN_ID,tokenId:block.tokenId,owner:nft.owner,sequence:nft.sequence,blockHash:block.hash,
|
||||
confirmations:nft.confirmations,article:block.article,statsSnapshot:block.statsSnapshot,
|
||||
stateRoot:ledger.block(ledger.summary().tip)?.stateRoot});
|
||||
});
|
||||
const templateLimit=rateLimit({windowMs:60000,limit:20,standardHeaders:'draft-8',legacyHeaders:false});
|
||||
app.post('/api/mining/template', templateLimit, async (req,res)=>{
|
||||
@@ -95,12 +132,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')));
|
||||
@@ -108,17 +156,27 @@ app.use((err:Error & {status?:number},_req:express.Request,res:express.Response,
|
||||
const input=err instanceof z.ZodError||err instanceof SyntaxError;
|
||||
const message=input?'Invalid request encoding':err.message;
|
||||
// No keys/passwords are accepted by any node endpoint.
|
||||
res.status(err.status===413?413:400).json({error:message.slice(0,200)});
|
||||
if(err instanceof RpcError&&err.status===503)res.setHeader('Retry-After','1');
|
||||
res.status(err instanceof RpcError?err.status:err.status===413?413:err.status===415?415:400).json({error:message.slice(0,200)});
|
||||
});
|
||||
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();
|
||||
});
|
||||
const operations=startOperations(opsPort,()=>({network:NETWORK,
|
||||
ledger:ledger.diagnostics(),native:engine.diagnostics(),
|
||||
peers:{configured:peers.status().length,connected:peers.status().filter(p=>p.connected).length},
|
||||
rpc:{api:apiBudget.status(),p2p:peerBudget.status()}}),resolve(dataDir,PROFILE));
|
||||
server.maxConnections=256;
|
||||
server.headersTimeout=10000;
|
||||
server.requestTimeout=15000;
|
||||
server.keepAliveTimeout=5000;
|
||||
server.setTimeout(30000,socket=>socket.destroy());
|
||||
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();operations.close();
|
||||
const timeout=setTimeout(()=>process.exit(1),25000);timeout.unref();
|
||||
server.close(async()=>{engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
|
||||
server.close(async()=>{ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);});
|
||||
});
|
||||
server.on('error',async()=>{peers.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
|
||||
server.on('error',async()=>{stopping=true;operations.close();peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);});
|
||||
|
||||
Reference in New Issue
Block a user