This commit is contained in:
2026-10-19 21:47:38 -04:00
parent f8eeae826f
commit b1d2b5c10e
17 changed files with 549 additions and 92 deletions
+48 -23
View File
@@ -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');