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
+63 -12
View File
@@ -1,8 +1,8 @@
import { createHash, createPublicKey, verify } from 'node:crypto';
import { z } from 'zod';
import { NETWORK, CONSENSUS, headerMessage, blockClaimMessage, nftTransferMessage, statsSnapshotTuple,
import { NETWORK, CONSENSUS, headerMessage, blockClaimMessage, nftTransferMessage, nftTradeMessage, statsSnapshotTuple,
type BlockHeader, type TestnetBlock, type NftTransfer, type NftState, type NftStateLayer,
type NftStateView, type MiningTemplate } from '../../shared/testnet.ts';
isNftTrade, type NftStateView, type NftTrade, type TestnetTransaction, type MiningTemplate } from '../../shared/testnet.ts';
import type { Article } from '../../shared/protocol.ts';
import { deriveStats, type MintStatsSnapshot } from '../../shared/enrichment.ts';
import type { PowEngine } from './randomx.ts';
@@ -20,6 +20,25 @@ const signature = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).length(88);
const integer = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
export const transferSchema = z.object({ network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID),
cardHash: hex, from: hex, to: hex, sequence: integer, publicKey: key, signature }).strict();
const tradeLegSchema = z.object({ cardHash: hex, sequence: integer }).strict();
export const tradeSchema = z.object({ type: z.literal('trade'), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID),
proposer: hex, counterparty: hex, offered: z.array(tradeLegSchema).min(1).max(CONSENSUS.maxTradeCards),
requested: z.array(tradeLegSchema).min(1).max(CONSENSUS.maxTradeCards),
proposerPublicKey: key, proposerSignature: signature, counterpartyPublicKey: key,
counterpartySignature: signature }).strict().superRefine((trade, ctx) => {
const offered = new Set(trade.offered.map(leg => leg.cardHash));
const requested = new Set(trade.requested.map(leg => leg.cardHash));
if (trade.proposer === trade.counterparty) ctx.addIssue({ code: 'custom', message: 'Trade parties must differ' });
if (offered.size !== trade.offered.length || requested.size !== trade.requested.length ||
trade.requested.some(leg => offered.has(leg.cardHash))) {
ctx.addIssue({ code: 'custom', message: 'Trade cards must be unique and cannot appear on both sides' });
}
if (!verifySignature(trade.proposerPublicKey, trade.proposer, nftTradeMessage(trade), trade.proposerSignature) ||
!verifySignature(trade.counterpartyPublicKey, trade.counterparty, nftTradeMessage(trade), trade.counterpartySignature)) {
ctx.addIssue({ code: 'custom', message: 'Trade requires valid signatures from both parties' });
}
});
export const transactionSchema = z.union([transferSchema, tradeSchema]);
const articleSchema = z.object({ pageId: integer.positive(), title: z.string().trim().min(1).max(200),
extract: z.string().trim().min(1).max(1000), image: z.string().url().max(1500),
url: z.string().url().max(500), revisionId: integer.positive(),
@@ -71,10 +90,10 @@ const statsSnapshotSchema = z.object({ capturedAt: z.string().datetime(), revisi
ctx.addIssue({ code: 'custom', message: 'Derived statistics do not match the page metrics', path: ['stats'] });
}
});
export const blockSchema = z.object({ version: z.literal(5), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID),
export const blockSchema = z.object({ version: z.literal(6), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID),
height: integer, previousHash: hex, createdAt: integer, article: articleSchema, statsSnapshot: statsSnapshotSchema, owner: hex,
publicKey: key, nonce: integer, target: hex, key: hex, transactionsRoot: hex, stateRoot: hex,
hash: hex, tokenId: hex, powHash: hex, signature, transactions: z.array(transferSchema).max(CONSENSUS.maxTransactions) }).strict();
hash: hex, tokenId: hex, powHash: hex, signature, transactions: z.array(transactionSchema).max(CONSENSUS.maxTransactions) }).strict();
export function address(publicKey: string): string { return sha256(Buffer.from(publicKey, 'base64')); }
export function verifySignature(publicKey: string, owner: string, message: string, sig: string): boolean {
@@ -91,6 +110,10 @@ export function transferId(t: NftTransfer): string {
// Exclude malleable ECDSA signature bytes from transaction identity.
return sha256(nftTransferMessage(t));
}
export function transactionId(transaction: TestnetTransaction): string {
if (isNftTrade(transaction)) return sha256(nftTradeMessage(transaction));
return transferId(transaction);
}
export function merkleRoot(leaves: string[]): string {
if (!leaves.length) return sha256(`${NETWORK}:empty`);
let layer = leaves.map(leaf => sha256(Buffer.concat([Buffer.from([0]), Buffer.from(leaf,'hex')])));
@@ -179,14 +202,42 @@ export function applyTransferOverlay(state: NftStateView, tx: NftTransfer, heigh
changes[tx.cardHash] = { ...nft, owner: tx.to, sequence: tx.sequence + 1 };
return { kind: 'layer', parent: state, changes };
}
export function transition(parent: NftStateView, header: BlockHeader, txs: NftTransfer[]): NftStateLayer {
export function applyTradeOverlay(state: NftStateView, trade: NftTrade, height: number): NftStateLayer {
const parsed = tradeSchema.parse(trade);
const allLegs = [...parsed.offered, ...parsed.requested];
const current = allLegs.map(leg => stateEntry(state, leg.cardHash));
if (current.some((entry, index) => !entry || entry.sequence !== allLegs[index].sequence ||
height - entry.mintedHeight < CONSENSUS.coinbaseMaturity)) {
throw new Error('Trade contains missing, stale, or immature NFTs');
}
if (current.slice(0, parsed.offered.length).some(entry => entry!.owner !== parsed.proposer) ||
current.slice(parsed.offered.length).some(entry => entry!.owner !== parsed.counterparty)) {
throw new Error('Trade NFT owners do not match the signed parties');
}
const changes: NftState = Object.create(null) as NftState;
parsed.offered.forEach((leg, index) => {
const nft = current[index]!;
changes[leg.cardHash] = { ...nft, owner: parsed.counterparty, sequence: nft.sequence + 1 };
});
parsed.requested.forEach((leg, index) => {
const nft = current[parsed.offered.length + index]!;
changes[leg.cardHash] = { ...nft, owner: parsed.proposer, sequence: nft.sequence + 1 };
});
return { kind: 'layer', parent: state, changes };
}
export function applyTransactionOverlay(state: NftStateView, transaction: TestnetTransaction, height: number): NftStateLayer {
return isNftTrade(transaction)
? applyTradeOverlay(state, transaction, height)
: applyTransferOverlay(state, transaction, height);
}
export function transition(parent: NftStateView, header: BlockHeader, txs: TestnetTransaction[]): NftStateLayer {
let next = parent;
const ids = new Set<string>();
for (const tx of txs) {
const id = transferId(tx);
for (const transaction of txs) {
const id = transactionId(transaction);
if (ids.has(id)) throw new Error('Duplicate transfer');
ids.add(id);
next = applyTransferOverlay(next, tx, header.height);
next = applyTransactionOverlay(next, transaction, header.height);
}
const id = mintId(header);
if (stateEntry(next, id)) throw new Error('Duplicate NFT issuance');
@@ -195,16 +246,16 @@ export function transition(parent: NftStateView, header: BlockHeader, txs: NftTr
return { kind: 'layer', parent: next, changes };
}
export function makeTemplate(ancestors: TestnetBlock[], state: NftStateView, owner: string,
publicKey: string, transactions: NftTransfer[], article: Article, statsSnapshot: MintStatsSnapshot,
publicKey: string, transactions: TestnetTransaction[], article: Article, statsSnapshot: MintStatsSnapshot,
now = Math.floor(Date.now()/1000)): MiningTemplate {
if (!hex.safeParse(owner).success || address(publicKey) !== owner) throw new Error('Invalid miner identity');
const parsedArticle = articleSchema.parse(article);
const parsedStats = statsSnapshotSchema.parse(statsSnapshot);
const previousHash = ancestors.at(-1)?.hash ?? GENESIS_HASH;
const header: BlockHeader = { version: 5, network: NETWORK, chainId: CHAIN_ID,
const header: BlockHeader = { version: 6, network: NETWORK, chainId: CHAIN_ID,
height: ancestors.length + 1, previousHash, createdAt: Math.max(now, medianTime(ancestors) + 1),
article: parsedArticle, statsSnapshot: parsedStats, owner, publicKey, nonce: 0, target: expectedTarget(ancestors),
key: expectedKey(ancestors, ancestors.length + 1), transactionsRoot: merkleRoot(transactions.map(transferId)), stateRoot: '' };
key: expectedKey(ancestors, ancestors.length + 1), transactionsRoot: merkleRoot(transactions.map(transactionId)), stateRoot: '' };
header.stateRoot = stateRoot(transition(state, header, transactions));
return { header, transactions, blob: miningBlob(header).toString('hex'), nonceOffset: 32 };
}
@@ -215,7 +266,7 @@ export async function validateBlock(raw: unknown, ancestors: TestnetBlock[], par
if (b.height !== ancestors.length + 1 || b.previousHash !== (ancestors.at(-1)?.hash ?? GENESIS_HASH)) throw new Error('Invalid parent/height');
if (b.createdAt <= medianTime(ancestors) || b.createdAt > now + CONSENSUS.maxFutureSeconds) throw new Error('Invalid block timestamp');
if (b.target !== expectedTarget(ancestors) || b.key !== expectedKey(ancestors, b.height)) throw new Error('Invalid difficulty or RandomX epoch key');
if (b.transactionsRoot !== merkleRoot(b.transactions.map(transferId))) throw new Error('Invalid transaction root');
if (b.transactionsRoot !== merkleRoot(b.transactions.map(transactionId))) throw new Error('Invalid transaction root');
const next = transition(parentState, b, b.transactions);
if (b.stateRoot !== stateRoot(next) || b.tokenId !== mintId(b)) throw new Error('Invalid NFT state commitment');
const blob = miningBlob(b);
+27 -4
View File
@@ -6,7 +6,8 @@ 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 { testnetOwnerOf, transactionCardIds } from '../../shared/testnet.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';
@@ -53,6 +54,17 @@ app.use(['/api','/p2p'],(req,res,next)=>{
app.get('/api/state',(_req,res)=>res.json(ledger.snapshot()));
app.get('/api/chain',(_req,res)=>res.json(ledger.snapshot()));
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');
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;}
@@ -71,8 +83,8 @@ app.get('/p2p/mempool',(_req,res)=>res.json({transactions:ledger.snapshot().pend
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++;}
const owner=testnetOwnerOf(s,block.tokenId)??block.owner;
const sequence=s.transfers.filter(tx=>transactionCardIds(tx).includes(block.tokenId)).length;
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});
@@ -95,12 +107,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')));
+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');
+10 -7
View File
@@ -3,8 +3,8 @@ import { request as httpsRequest } from 'node:https';
import dns from 'node:dns/promises';
import { BlockList, isIP } from 'node:net';
import { z } from 'zod';
import { NETWORK, type TestnetBlock, type NftTransfer } from '../../shared/testnet.ts';
import { CHAIN_ID, blockSchema, transferId, transferSchema } from './consensus.ts';
import { NETWORK, type TestnetBlock, type TestnetTransaction } from '../../shared/testnet.ts';
import { CHAIN_ID, blockSchema, transactionId, transactionSchema } from './consensus.ts';
import type { Ledger } from './ledger.ts';
const MAX_BODY = 2 * 1024 * 1024;
@@ -27,7 +27,7 @@ const remoteStatus = z.object({ network: z.literal(NETWORK), chainId: z.literal(
size: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), tip: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict();
const pageSchema = z.object({ blocks: z.array(blockSchema).max(32),
next: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable() }).strict();
const poolSchema = z.object({ transactions: z.array(transferSchema).max(256) }).strict();
const poolSchema = z.object({ transactions: z.array(transactionSchema).max(256) }).strict();
type Peer = { url: string; connected: boolean; error?: string; offset: number; busy: boolean };
/** Outbound-only fixed allowlist. HTTP routes are deliberately left to the caller.
@@ -90,10 +90,13 @@ export class PeerNetwork {
if (!known) throw new Error('Cannot broadcast an unaccepted block');
await this.broadcast('/p2p/block', known);
}
async broadcastTransfer(tx: NftTransfer): Promise<void> {
const id = transferId(transferSchema.parse(tx));
async broadcastTransfer(tx: TestnetTransaction): Promise<void> {
return this.broadcastTransaction(tx);
}
async broadcastTransaction(tx: TestnetTransaction): Promise<void> {
const id = transactionId(transactionSchema.parse(tx));
const snapshot = this.ledger.snapshot();
const known = [...snapshot.pendingTransfers, ...snapshot.transfers].find(t => transferId(t) === id);
const known = [...snapshot.pendingTransfers, ...snapshot.transfers].find(t => transactionId(t) === id);
if (!known) throw new Error('Cannot broadcast an unaccepted transfer');
await this.broadcast('/p2p/transaction', known);
}
@@ -134,7 +137,7 @@ export class PeerNetwork {
for (const tx of pool.transactions) {
if (this.closed) throw new Error('Peer network closed');
// Valid remote transactions may be immature/conflicting on our current tip.
try { await this.ledger.submitTransfer(tx); } catch { /* Independently reject; do not relay. */ }
try { await this.ledger.submitTransaction(tx); } catch { /* Independently reject; do not relay. */ }
}
}