Improvements

This commit is contained in:
2026-10-20 04:07:37 -04:00
parent 0cc5032857
commit 3cee89d023
20 changed files with 511 additions and 23 deletions
+29 -11
View File
@@ -9,7 +9,7 @@ import { ADDITIONAL_MARKET_FEE, marketCommandSchema, marketMessage, type MarketC
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 Chain = { snapshot(): TestnetSnapshot; summary?(): { tip: string; revision: number }; submitTransfer(raw: unknown): Promise<string> };
type Config = { feeBps: number; confirmations: number; usdCentsPerXmr: number };
export function marketConfig(env: NodeJS.ProcessEnv = process.env): Config {
paymentMode(env);
@@ -31,6 +31,7 @@ const DELIVERY_WINDOW = 48 * 60 * 60_000;
export class Market {
private queue: Promise<unknown> = Promise.resolve();
private queued = 0;
private reconciled: { tip: string; revision: number; at: number } | undefined;
private readonly db: DatabaseSync;
private readonly chain: Chain;
readonly config: Config;
@@ -52,6 +53,10 @@ export class Market {
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);`);
db.exec(`CREATE INDEX IF NOT EXISTS listings_by_status ON listings(status);
CREATE INDEX IF NOT EXISTS listings_by_seller ON listings(json_extract(data,'$.seller'));
CREATE INDEX IF NOT EXISTS orders_by_seller ON orders(json_extract(data,'$.seller'));
CREATE INDEX IF NOT EXISTS orders_by_buyer ON orders(json_extract(data,'$.buyer'));`);
const meta = db.prepare('SELECT value FROM market_meta WHERE key=?').get('identity') as { value: string } | undefined;
let id: string;
if (meta) {
@@ -88,11 +93,11 @@ export class Market {
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')
this.db.prepare('INSERT INTO listings VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data WHERE listings.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));
this.db.prepare('INSERT INTO orders VALUES(?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data WHERE orders.data<>excluded.data').run(o.id, JSON.stringify(o));
}
private atomic<T>(fn: () => T): T {
this.db.exec('BEGIN IMMEDIATE');
@@ -114,12 +119,15 @@ export class Market {
/** Reconcile from canonical transaction identity, never from a mempool acknowledgement. */
private reconcile() {
const s = this.chain.snapshot(), now = this.now();
const deliveries = new Map<string, TestnetSnapshot['blocks'][number]>();
for (const b of s.blocks) for (const tx of b.transactions) deliveries.set(transactionId(tx), b);
const pendingIds = new Set(s.pendingTransfers.map(transactionId));
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 deliveryId = this.deliveryId(o);
const delivery = s.blocks.find(b => b.transactions.some(tx => transactionId(tx) === deliveryId));
const delivery = deliveries.get(deliveryId);
const confirmations = delivery ? s.blocks.length - delivery.height + 1 : 0;
o.deliveryConfirmations = confirmations;
if (o.status === 'complete' || o.status === 'review') {
@@ -143,7 +151,7 @@ export class Market {
} 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) === deliveryId);
const pending = pendingIds.has(deliveryId);
const conflict = s.pendingTransfers.some(tx => transactionCardIds(tx).includes(o.cardHash) && transactionId(tx) !== deliveryId);
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';
@@ -160,13 +168,18 @@ export class Market {
}
this.db.prepare('DELETE FROM receipts WHERE expires < ?').run(now - 86400_000);
});
const summary = this.chain.summary?.();
if (summary) this.reconciled = { ...summary, at: now };
}
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() };
const summary = this.chain.summary?.();
const last = this.reconciled;
if (!this.queued && (!summary || !last || summary.tip !== last.tip || summary.revision !== last.revision || this.now() - last.at >= 1000)) this.reconcile();
const rows = this.db.prepare("SELECT data FROM listings WHERE status IN ('open','reserved') ORDER BY rowid DESC LIMIT 25 OFFSET ?")
.all(offset) as { data: string }[];
return { listings: rows.slice(0,24).map(r=>JSON.parse(r.data)), next: rows.length>24?offset+24:null, info: this.info() };
}
execute(raw: unknown): Promise<MarketActivity | MarketListing | MarketOrder> {
const cmd = marketCommandSchema.parse(raw);
@@ -189,10 +202,15 @@ export class Market {
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) };
const summary = this.chain.summary?.(), last = this.reconciled;
if (a.type !== 'activity' || !summary || !last || summary.tip !== last.tip || summary.revision !== last.revision ||
this.now() - last.at >= 1000) this.reconcile();
if (a.type === 'activity') return {
orders: (this.db.prepare("SELECT data FROM orders WHERE json_extract(data,'$.seller')=? OR json_extract(data,'$.buyer')=? ORDER BY rowid DESC")
.all(c.actor,c.actor) as { data: string }[]).map(r=>JSON.parse(r.data)),
listings: (this.db.prepare("SELECT data FROM listings WHERE json_extract(data,'$.seller')=? ORDER BY rowid DESC")
.all(c.actor) as { data: string }[]).map(r=>JSON.parse(r.data)) };
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');
+1 -1
View File
@@ -24,7 +24,7 @@ export function launchPreflight(env: NodeJS.ProcessEnv = process.env, now = Date
}
}
checks.push(
{ id: 'public-node-resource-controls', status: 'blocked', detail: 'Full-chain RPC, in-memory DAG and synchronous journal scans need a reviewed capacity/abuse strategy and load-test evidence.' },
{ id: 'public-node-resource-controls', status: 'blocked', detail: 'Stage-1 global RPC budgets, bounded pages/client history and read caching exist. In-memory DAG growth, full-history UI/reconciliation and production-scale load/capacity evidence remain unresolved.' },
{ id: 'consensus-launch-calibration', status: 'blocked', detail: 'Initial RandomX difficulty, retarget behavior, final genesis and economic security have not been calibrated and independently reviewed for production.' },
{ id: 'wallet-key-protection', status: 'blocked', detail: 'Browser signing keys remain extractable and persist unencrypted in IndexedDB; production wallet policy and recovery review are required.' },
{ id: 'live-market-settlement', status: 'blocked', detail: 'No live SHKeeper adapter or reviewed payout/refund/reorg system exists. Mock payment cannot constitute real payment for valuable cards.' },
+89
View File
@@ -0,0 +1,89 @@
import type { RequestHandler } from 'express';
export class RpcError extends Error {
readonly status: number;
constructor(status: number, message: string) { super(message); this.status = status; }
}
export type ResourceConfig = { requestsPerSecond: number; writesPerSecond: number; active: number;
responseBytes: number; bytesPerSecond: number; legacyBlocks: number };
export function resourceConfig(env: NodeJS.ProcessEnv = process.env): ResourceConfig {
const number = (name: string, fallback: number, min: number, max: number) => {
const text = env[name] ?? String(fallback), n = Number(text);
if (!/^\d+$/.test(text) || !Number.isSafeInteger(n) || n < min || n > max) throw new Error(`Invalid ${name}`);
return n;
};
return { requestsPerSecond: number('WIKIDECK_RPC_REQUESTS_PER_SECOND', 120, 1, 1000),
writesPerSecond: number('WIKIDECK_RPC_WRITES_PER_SECOND', 10, 1, 100),
active: number('WIKIDECK_RPC_MAX_ACTIVE', 32, 1, 256),
responseBytes: number('WIKIDECK_RPC_RESPONSE_BYTES', 2 * 1024 * 1024, 128 * 1024, 2 * 1024 * 1024),
bytesPerSecond: number('WIKIDECK_RPC_BYTES_PER_SECOND', 8 * 1024 * 1024, 2 * 1024 * 1024, 128 * 1024 * 1024),
legacyBlocks: number('WIKIDECK_RPC_LEGACY_BLOCKS', 128, 0, 128) };
}
/** Bounded, constant-storage global budgets. No per-client map or retry queue.
* Peer traffic has its own lane so a saturated browser lane cannot consume its tokens. */
export class RpcBudget {
private tokens: number;
private writeTokens: number;
private byteTokens: number;
private updated: number;
private active = 0;
private rejected = 0;
readonly config: ResourceConfig;
constructor(config: ResourceConfig, privateClock: () => number = () => performance.now()) {
this.config = config;
this.clock = privateClock;
this.tokens = config.requestsPerSecond; this.writeTokens = config.writesPerSecond;
this.byteTokens = config.bytesPerSecond; this.updated = this.clock();
}
private readonly clock: () => number;
private refill() {
const now = this.clock(), elapsed = Math.max(0, now - this.updated) / 1000;
this.updated = now;
this.tokens = Math.min(this.config.requestsPerSecond, this.tokens + elapsed * this.config.requestsPerSecond);
this.writeTokens = Math.min(this.config.writesPerSecond, this.writeTokens + elapsed * this.config.writesPerSecond);
this.byteTokens = Math.min(this.config.bytesPerSecond, this.byteTokens + elapsed * this.config.bytesPerSecond);
}
acquire(write: boolean): () => void {
this.refill();
if (this.active >= this.config.active || this.tokens < 1 || (write && this.writeTokens < 1)) {
this.rejected++; throw new RpcError(503, 'Node RPC admission budget exhausted; retry later.');
}
this.tokens--; if (write) this.writeTokens--;
this.active++;
let released = false;
return () => { if (!released) { this.active--; released = true; } };
}
encode(value: unknown): string {
const encoded = JSON.stringify(value), bytes = Buffer.byteLength(encoded);
if (bytes > this.config.responseBytes) {
this.rejected++; throw new RpcError(413, 'RPC response limit exceeded; use smaller paged requests.');
}
this.refill();
if (bytes > this.byteTokens) { this.rejected++; throw new RpcError(503, 'Node RPC response-byte budget exhausted; retry later.'); }
this.byteTokens -= bytes;
return encoded;
}
status() { return { active: this.active, rejected: this.rejected }; }
middleware(): RequestHandler {
return (req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
let release: () => void;
try { release = this.acquire(req.method !== 'GET' && req.method !== 'HEAD'); }
catch (error) {
res.setHeader('Retry-After', '1');
res.status(503).json({ error: (error as Error).message }); return;
}
res.once('finish', release); res.once('close', release);
// Express.json normally serializes before send; intercept here so no oversized payload is sent.
const originalJson = res.json.bind(res);
res.json = (value: unknown) => {
try {
const encoded = this.encode(value);
return res.type('application/json').send(encoded);
} catch (error) { res.json = originalJson; throw error; }
};
next();
};
}
}
+26 -6
View File
@@ -18,10 +18,14 @@ 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';
import { resourceConfig, RpcBudget, RpcError } from '../resources.ts';
const root = fileURLToPath(new URL('../../', import.meta.url));
assertLaunchAllowed();
const paymentConfig = marketConfig(); // Fail before opening node resources if live payments are requested.
const limits = resourceConfig();
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.
@@ -48,7 +52,9 @@ app.use(helmet({contentSecurityPolicy:{directives:{defaultSrc:["'self'"],scriptS
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');
@@ -60,9 +66,16 @@ app.use(['/api','/p2p'],(req,res,next)=>{
}
next();
});
app.get('/api/state',(_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.snapshot()));
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/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)=>{
@@ -72,6 +85,7 @@ app.post('/api/market/command',rateLimit({windowMs:60000,limit:60,standardHeader
res.json(result);
if(command.action.type==='deliver')void peers.broadcastTransfer(command.action.transfer).catch(()=>{});
} catch(error) {
if(error instanceof RpcError)throw 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.'});
@@ -95,14 +109,14 @@ 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){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;}
@@ -154,13 +168,19 @@ 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 ${IS_MAINNET?'mainnet candidate':'testnet'}: http://${host}:${typeof a==='object'&&a?a.port:port} · chain ${CHAIN_ID} · ${ledger.snapshot().blocks.length} blocks`);
peers.start();
});
server.maxConnections=256;
server.headersTimeout=10000;
server.requestTimeout=15000;
server.keepAliveTimeout=5000;
server.setTimeout(30000,socket=>socket.destroy());
// 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();
+31 -2
View File
@@ -4,7 +4,7 @@ import { DatabaseSync } from 'node:sqlite';
import { z } from 'zod';
import { NETWORK, CONSENSUS, type TestnetBlock,
type NftState, type NftStateView, type TestnetTransaction, type MiningTemplate,
type TestnetDeck, type TestnetSnapshot } from '../../shared/testnet.ts';
type TestnetDeck, type TestnetSnapshot, type TestnetSummary, type CanonicalPage } from '../../shared/testnet.ts';
import { CHAIN_ID, GENESIS_HASH, applyTransactionOverlay, blockSchema, makeTemplate,
applyTransferOverlay, materializeState, transactionId, transactionSchema, transferSchema,
validateBlock, workFor } from './consensus.ts';
@@ -12,6 +12,7 @@ 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';
import { RpcError } from '../resources.ts';
const MAX_PENDING = 256;
const MAX_DECK_PAGE = 24;
@@ -39,6 +40,8 @@ export class Ledger {
private readonly db: DatabaseSync;
private readonly pow: PowEngine;
private closed = false;
private revision = 0;
private canonicalCache: { tip: string; blocks: TestnetBlock[] } | undefined;
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> {
@@ -190,6 +193,7 @@ export class Ledger {
if (wins) this.tip = node.block.hash;
this.pending = pending;
if (filtered) this.pendingState = filtered.state;
this.revision++;
return { accepted: true, reorg: disconnected.length > 0 };
});
}
@@ -211,6 +215,7 @@ export class Ledger {
await this.persist(undefined, pending);
this.pending = pending;
this.pendingState = nextPendingState;
this.revision++;
return id;
});
}
@@ -228,6 +233,27 @@ export class Ledger {
peers: this.peers, algorithm: 'RandomX',
maturity: CONSENSUS.coinbaseMaturity });
}
/** Small metadata/mempool response. No full-chain traversal or clone. */
summary(): TestnetSummary {
return { network: NETWORK, chainId: CHAIN_ID, genesis: GENESIS_HASH, tip: this.tip,
height: this.height(), revision: this.revision, cumulativeWork: this.tipWork().toString(),
pendingTransfers: structuredClone(this.pending), peers: this.peers, algorithm: 'RandomX', maturity: CONSENSUS.coinbaseMaturity };
}
peerStatus() { return { network: NETWORK, chainId: CHAIN_ID, size: this.size, tip: this.tip }; }
pendingTransactions(): TestnetTransaction[] { return structuredClone(this.pending); }
legacySnapshot(maxBlocks: number): TestnetSnapshot {
// Reject before any full-chain copy/serialization. Never truncate ownership history silently.
if (this.height() > maxBlocks) throw new RpcError(413, 'Full snapshot disabled at this height; use /api/summary and /api/canonical pages.');
return this.snapshot();
}
canonicalPage(tip: string, offset: number, limit = 16): CanonicalPage {
if (tip !== this.tip) throw new RpcError(409, 'Canonical tip changed; restart paged loading.');
if (!Number.isSafeInteger(offset) || offset < 0 || offset > this.height() ||
!Number.isSafeInteger(limit) || limit < 1 || limit > 16) throw new RpcError(400, 'Invalid canonical page');
const blocks = structuredClone(this.canonical().slice(offset, offset + limit));
return { network: NETWORK, chainId: CHAIN_ID, tip: this.tip, height: this.height(), offset, blocks,
next: offset + blocks.length < this.height() ? offset + blocks.length : null };
}
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');
@@ -259,7 +285,10 @@ export class Ledger {
}
return result.reverse();
}
private canonical(): TestnetBlock[] { return this.branch(this.tip); }
private canonical(): TestnetBlock[] {
if (this.canonicalCache?.tip !== this.tip) this.canonicalCache = { tip: this.tip, blocks: this.branch(this.tip) };
return this.canonicalCache.blocks; // Internal immutable block references only; public APIs clone their slice.
}
private async validate(raw: TestnetBlock): Promise<Node> {
const ancestors = this.branch(raw.previousHash);
const parent = this.nodes.get(raw.previousHash);