Canonical indexes and recoverable market observation
This commit is contained in:
+21
-15
@@ -13,6 +13,7 @@ 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';
|
||||
import { CanonicalIndex } from './canonical-index.ts';
|
||||
|
||||
const MAX_PENDING = 256;
|
||||
const MAX_DECK_PAGE = 24;
|
||||
@@ -39,11 +40,14 @@ export class Ledger {
|
||||
private readonly file: string;
|
||||
private readonly db: DatabaseSync;
|
||||
private readonly pow: PowEngine;
|
||||
private readonly index: CanonicalIndex;
|
||||
private closed = false;
|
||||
private revision = 0;
|
||||
private reorgs = 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; }
|
||||
private constructor(file: string, db: DatabaseSync, pow: PowEngine) {
|
||||
this.file = file; this.db = db; this.pow = pow; this.index = new CanonicalIndex(db);
|
||||
}
|
||||
|
||||
static async open(dir: string, pow: PowEngine): Promise<Ledger> {
|
||||
const directory = join(dir, PROFILE);
|
||||
@@ -141,6 +145,7 @@ export class Ledger {
|
||||
ledger.pending.push(tx);
|
||||
}
|
||||
ledger.pendingState = state;
|
||||
ledger.index.initialize(ledger.canonical(), materializeState(ledger.tipState()));
|
||||
return ledger;
|
||||
} catch (error) {
|
||||
db.close();
|
||||
@@ -192,7 +197,9 @@ export class Ledger {
|
||||
[...disconnected.flatMap(b => b.transactions), ...this.pending]) : undefined;
|
||||
const pending = filtered?.pending ?? this.pending;
|
||||
// Nothing visible changes until the atomic replacement succeeds.
|
||||
await this.persist(node.block, pending);
|
||||
const oldHashes = new Set(oldChain.map(b => b.hash));
|
||||
const connected = wins ? chain.filter(b => !oldHashes.has(b.hash)) : [];
|
||||
this.persist(node.block, pending, disconnected, connected);
|
||||
this.nodes.set(node.block.hash, node);
|
||||
if (wins) this.tip = node.block.hash;
|
||||
if (disconnected.length) this.reorgs++;
|
||||
@@ -217,7 +224,7 @@ export class Ledger {
|
||||
if (this.pending.length >= MAX_PENDING) throw new Error('Testnet mempool limit: 256 transfers');
|
||||
const nextPendingState = applyTransactionOverlay(this.pendingState, tx, this.height() + 1);
|
||||
const pending = [...this.pending, tx];
|
||||
await this.persist(undefined, pending);
|
||||
this.persist(undefined, pending);
|
||||
this.pending = pending;
|
||||
this.pendingState = nextPendingState;
|
||||
this.revision++;
|
||||
@@ -246,6 +253,10 @@ export class Ledger {
|
||||
}
|
||||
peerStatus() { return { network: NETWORK, chainId: CHAIN_ID, size: this.size, tip: this.tip }; }
|
||||
pendingTransactions(): TestnetTransaction[] { return structuredClone(this.pending); }
|
||||
canonicalEvents(after: number, limit = 64) { return this.index.events(after, limit); }
|
||||
canonicalHead(): number { return this.index.head(); }
|
||||
nft(token: string) { return this.index.nft(token, this.height()); }
|
||||
canonicalTransaction(id: string) { return this.index.transaction(id); }
|
||||
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.');
|
||||
@@ -263,18 +274,10 @@ export class Ledger {
|
||||
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;
|
||||
const { total, nfts } = this.index.deck(address, offset, limit, this.height());
|
||||
const next = offset + nfts.length < total ? offset + nfts.length : null;
|
||||
return structuredClone({ network: NETWORK, chainId: CHAIN_ID, address, tip: this.tip,
|
||||
offset, limit, total: owned.length, next, nfts });
|
||||
offset, limit, total, next, nfts });
|
||||
}
|
||||
|
||||
private height(): number { return this.nodes.get(this.tip)?.block.height ?? 0; }
|
||||
@@ -322,7 +325,8 @@ export class Ledger {
|
||||
this.queue = result.catch(() => undefined).finally(() => { this.queued--; });
|
||||
return result;
|
||||
}
|
||||
private async persist(block: TestnetBlock | undefined, pendingTransfers: TestnetTransaction[]): Promise<void> {
|
||||
private persist(block: TestnetBlock | undefined, pendingTransfers: TestnetTransaction[],
|
||||
disconnected: TestnetBlock[] = [], connected: TestnetBlock[] = []): void {
|
||||
this.db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
if (block) this.db.prepare('INSERT INTO blocks(hash, previous_hash, block_json) VALUES(?, ?, ?)')
|
||||
@@ -330,6 +334,8 @@ export class Ledger {
|
||||
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, transactionId(tx), JSON.stringify(tx)));
|
||||
for (const old of [...disconnected].reverse()) this.index.disconnect(old);
|
||||
for (const next of connected) this.index.connect(next);
|
||||
this.db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK');
|
||||
|
||||
Reference in New Issue
Block a user