UNPKG

@bsv/overlay

Version:
535 lines 21.3 kB
import { Transaction } from '@bsv/sdk'; import { BASM_ZERO_HASH, extractMerkleProofMetadata } from '../../BASM.js'; const OUTPUT_SELECT_FIELDS = [ 'outputs.txid', 'outputs.outputIndex', 'outputs.outputScript', 'outputs.topic', 'outputs.satoshis', 'outputs.outputsConsumed', 'outputs.spent', 'outputs.consumedBy', 'outputs.blockHeight', 'outputs.score' ]; export class KnexStorage { knex; constructor(knex) { this.knex = knex; } parseOutputRelations(value) { if (Array.isArray(value)) { return value; } return JSON.parse(value); } parseOutputRecord(row, includeBEEF, beefOverride) { return { ...row, outputScript: Array.from(row.outputScript), beef: (() => { if (!includeBEEF) return undefined; if (beefOverride != null) return beefOverride; return row.beef != null ? Array.from(row.beef) : undefined; })(), spent: Boolean(row.spent), outputsConsumed: this.parseOutputRelations(row.outputsConsumed), consumedBy: this.parseOutputRelations(row.consumedBy) }; } binaryToNumberArray(value) { if (value === undefined || value === null) { return undefined; } return Array.from(Buffer.from(value)); } binaryToHex(value) { const array = this.binaryToNumberArray(value); if (array === undefined) { return undefined; } return Buffer.from(array).toString('hex'); } transactionRecordFromBEEF(txid, beef) { const tx = Transaction.fromBEEF(beef); const metadata = extractMerkleProofMetadata(txid, tx.merklePath); return { txid, beef: tx.merklePath === undefined ? beef : tx.toAtomicBEEF(), rawTx: Array.from(tx.toBinary()), merklePath: tx.merklePath?.toBinary(), blockHeight: metadata?.blockHeight, blockIndex: metadata?.blockIndex, merkleRoot: metadata?.merkleRoot }; } async fetchTransactionBeefMap(txids) { if (txids.length === 0) { return new Map(); } const rows = await this.knex('transactions') .whereIn('txid', txids) .select(['txid', 'beef']); const beefByTxid = new Map(); for (const row of rows) { if (row.beef !== undefined && row.beef !== null) { beefByTxid.set(row.txid, Array.from(row.beef)); } } return beefByTxid; } async findOutput(txid, outputIndex, topic, spent, includeBEEF = false) { const search = { 'outputs.txid': txid, 'outputs.outputIndex': outputIndex }; if (topic !== undefined) search['outputs.topic'] = topic; if (spent !== undefined) search['outputs.spent'] = spent; const query = this.knex('outputs').where(search); const selectFields = [...OUTPUT_SELECT_FIELDS]; if (includeBEEF) { // eslint-disable-next-line @typescript-eslint/no-floating-promises query.leftJoin('transactions', 'outputs.txid', 'transactions.txid'); selectFields.push('transactions.beef'); } const output = await query.select(selectFields).first(); if (output === undefined || output === null) { return null; } return this.parseOutputRecord(output, includeBEEF); } async findOutputsByOutpoints(outpoints, includeBEEF = false) { if (outpoints.length === 0) { return []; } const deduped = new Map(); for (const outpoint of outpoints) { deduped.set(`${outpoint.txid}:${outpoint.outputIndex}`, outpoint); } const rows = await this.knex('outputs') .whereIn(['outputs.txid', 'outputs.outputIndex'], Array.from(deduped.values()).map(outpoint => [outpoint.txid, outpoint.outputIndex])) .select([...OUTPUT_SELECT_FIELDS]); if (rows === undefined || rows.length === 0) { return []; } if (!includeBEEF) { return rows.map(row => this.parseOutputRecord(row, false)); } const txids = Array.from(new Set(rows.map(row => row.txid))); const beefByTxid = await this.fetchTransactionBeefMap(txids); return rows.map(row => this.parseOutputRecord(row, true, beefByTxid.get(row.txid))); } async findOutputsForTransaction(txid, includeBEEF = false) { const outputs = await this.knex('outputs') .where({ 'outputs.txid': txid }) .select([...OUTPUT_SELECT_FIELDS]); if (outputs === undefined || outputs.length === 0) { return []; } if (!includeBEEF) { return outputs.map(output => this.parseOutputRecord(output, false)); } const beefByTxid = await this.fetchTransactionBeefMap([txid]); return outputs.map(output => this.parseOutputRecord(output, true, beefByTxid.get(output.txid))); } async findUTXOsForTopic(topic, since, limit, includeBEEF = false) { // Base query to get outputs const query = this.knex('outputs').where({ 'outputs.topic': topic, 'outputs.spent': false }); // If provided, additionally filters UTXOs by score if (since !== undefined && since > 0) { // eslint-disable-next-line @typescript-eslint/no-floating-promises query.andWhere('outputs.score', '>=', since); } // Sort by score // eslint-disable-next-line @typescript-eslint/no-floating-promises query.orderBy('outputs.score', 'asc'); // Apply limit if specified if (limit !== undefined && limit > 0) { // eslint-disable-next-line @typescript-eslint/no-floating-promises query.limit(limit); } const outputs = await query.select([...OUTPUT_SELECT_FIELDS]); if (outputs === undefined || outputs.length === 0) { return []; } if (!includeBEEF) { return outputs.map(output => this.parseOutputRecord(output, false)); } const txids = Array.from(new Set(outputs.map(output => output.txid))); const beefByTxid = await this.fetchTransactionBeefMap(txids); return outputs.map(output => this.parseOutputRecord(output, true, beefByTxid.get(output.txid))); } async deleteOutput(txid, outputIndex, topic) { await this.knex.transaction(async (trx) => { // Delete the specific output await trx('outputs').where({ txid, outputIndex, topic }).del(); // Check how many outputs reference the same transaction const remainingOutputs = await trx('outputs').where({ txid }).count('* as count').first(); if (remainingOutputs !== undefined && Number(remainingOutputs.count) === 0) { // If no more outputs reference the transaction, delete the beef await trx('transactions').where({ txid }).del(); } }); } async insertOutput(output) { await this.knex.transaction(async (trx) => { const existing = await trx('outputs').where({ txid: output.txid, outputIndex: Number(output.outputIndex), topic: output.topic }).first(); if (existing === undefined || existing === null) { await trx('outputs').insert({ txid: output.txid, outputIndex: Number(output.outputIndex), outputScript: Buffer.from(output.outputScript), topic: output.topic, satoshis: Number(output.satoshis), outputsConsumed: JSON.stringify(output.outputsConsumed), consumedBy: JSON.stringify(output.consumedBy), spent: output.spent, score: output.score, blockHeight: output.blockHeight }); } if (output.beef !== undefined) { const record = this.transactionRecordFromBEEF(output.txid, output.beef); const transactionRecord = { txid: output.txid, updatedAt: new Date() }; if (record.beef !== undefined) transactionRecord.beef = Buffer.from(record.beef); if (record.rawTx !== undefined) transactionRecord.rawTx = Buffer.from(record.rawTx); if (record.merklePath !== undefined) transactionRecord.merklePath = Buffer.from(record.merklePath); if (record.blockHeight !== undefined) transactionRecord.blockHeight = record.blockHeight; if (record.blockIndex !== undefined) transactionRecord.blockIndex = record.blockIndex; if (record.merkleRoot !== undefined) transactionRecord.merkleRoot = record.merkleRoot; const mergeRecord = { ...transactionRecord }; delete mergeRecord.txid; await trx('transactions').insert(transactionRecord).onConflict('txid').merge(mergeRecord); } }); } async markUTXOAsSpent(txid, outputIndex, topic) { await this.knex('outputs').where({ txid, outputIndex, topic }).update('spent', true); } async updateConsumedBy(txid, outputIndex, topic, consumedBy) { await this.knex('outputs').where({ txid, outputIndex, topic }).update('consumedBy', JSON.stringify(consumedBy)); } async updateTransactionBEEF(txid, beef) { await this.upsertTransactionRecord(this.transactionRecordFromBEEF(txid, beef)); } async updateOutputBlockHeight(txid, outputIndex, topic, blockHeight) { await this.knex('outputs').where({ txid, outputIndex, topic }).update('blockHeight', blockHeight); } async upsertTransactionRecord(record) { const insert = { txid: record.txid, updatedAt: new Date() }; if (record.beef !== undefined) insert.beef = Buffer.from(record.beef); if (record.rawTx !== undefined) insert.rawTx = Buffer.from(record.rawTx); if (record.merklePath !== undefined) insert.merklePath = Buffer.from(record.merklePath); if (record.blockHeight !== undefined) insert.blockHeight = record.blockHeight; if (record.blockHash !== undefined) insert.blockHash = record.blockHash; if (record.blockIndex !== undefined) insert.blockIndex = record.blockIndex; if (record.merkleRoot !== undefined) insert.merkleRoot = record.merkleRoot; const merge = { ...insert }; delete merge.txid; await this.knex('transactions') .insert(insert) .onConflict('txid') .merge(merge); } async updateAppliedTransactionProof(record) { await this.knex('applied_transactions') .where({ txid: record.txid, topic: record.topic }) .update({ blockHeight: record.blockHeight, blockHash: record.blockHash, blockIndex: record.blockIndex, merkleRoot: record.merkleRoot, proven: true }); if (record.blockHeight !== undefined) { await this.knex('outputs') .where({ txid: record.txid, topic: record.topic }) .update({ blockHeight: record.blockHeight }); } } async insertAppliedTransaction(tx) { await this.knex('applied_transactions').insert({ txid: tx.txid, topic: tx.topic, blockHeight: tx.blockHeight, blockHash: tx.blockHash, blockIndex: tx.blockIndex, merkleRoot: tx.merkleRoot, firstSeenHeight: tx.firstSeenHeight, proven: tx.proven ?? false }); } async doesAppliedTransactionExist(tx) { const result = await this.knex('applied_transactions') .where({ txid: tx.txid, topic: tx.topic }) .select(this.knex.raw('1')) .first(); return !!result; } async updateLastInteraction(host, topic, since) { await this.knex('host_sync_state') .insert({ host, topic, since }) .onConflict(['host', 'topic']) .merge({ since }); } async getLastInteraction(host, topic) { const result = await this.knex('host_sync_state') .where({ host, topic }) .select('since') .first(); return result ? result.since : 0; } async findAdmittedTransactionsForBlock(topic, blockHeight, blockHash) { let query = this.knex('applied_transactions') .where({ topic, blockHeight, proven: true }) .whereNotNull('blockIndex'); if (blockHash !== undefined) { query = query.andWhere({ blockHash }); } const rows = await query .select(['txid', 'blockIndex']) .orderBy('blockIndex', 'asc'); return rows.map(row => ({ txid: row.txid, blockIndex: Number(row.blockIndex) })); } async upsertTopicBlockAnchor(anchor) { await this.knex('topic_block_anchors') .insert({ topic: anchor.topic, blockHeight: anchor.blockHeight, blockHash: anchor.blockHash, basmRoot: anchor.basmRoot, admittedCount: anchor.admittedCount, tac: anchor.tac, updatedAt: new Date() }) .onConflict(['topic', 'blockHeight']) .merge({ blockHash: anchor.blockHash, basmRoot: anchor.basmRoot, admittedCount: anchor.admittedCount, tac: anchor.tac, updatedAt: new Date() }); } async findTopicBlockAnchor(topic, blockHeight, blockHash) { let query = this.knex('topic_block_anchors').where({ topic, blockHeight }); if (blockHash !== undefined) { query = query.andWhere({ blockHash }); } const row = await query.first(); if (row === undefined) { return undefined; } return { topic: row.topic, blockHeight: Number(row.blockHeight), blockHash: row.blockHash, basmRoot: row.basmRoot, admittedCount: Number(row.admittedCount), tac: row.tac }; } async findTopicBlockAnchors(topic, fromHeight, toHeight) { const rows = await this.knex('topic_block_anchors') .where({ topic }) .andWhere('blockHeight', '>=', fromHeight) .andWhere('blockHeight', '<=', toHeight) .select(['topic', 'blockHeight', 'blockHash', 'basmRoot', 'admittedCount', 'tac']) .orderBy('blockHeight', 'asc'); return rows.map(row => ({ topic: row.topic, blockHeight: Number(row.blockHeight), blockHash: row.blockHash, basmRoot: row.basmRoot, admittedCount: Number(row.admittedCount), tac: row.tac })); } async findTopicAnchorTip(topic) { const row = await this.knex('topic_block_anchors') .where({ topic }) .orderBy('blockHeight', 'desc') .first(); if (row === undefined) { return { topic, blockHeight: -1, tac: BASM_ZERO_HASH }; } return { topic: row.topic, blockHeight: Number(row.blockHeight), blockHash: row.blockHash, basmRoot: row.basmRoot, admittedCount: Number(row.admittedCount), tac: row.tac }; } async findRawTransactions(txids) { if (txids.length === 0) return []; const rows = await this.knex('transactions') .whereIn('txid', txids) .select(['txid', 'rawTx', 'beef']); const records = []; for (const row of rows) { let rawTx = this.binaryToHex(row.rawTx); if (rawTx === undefined) { const beef = this.binaryToNumberArray(row.beef); if (beef !== undefined) { rawTx = Transaction.fromBEEF(beef, row.txid).toHex(); } } if (rawTx !== undefined) { records.push({ txid: row.txid, rawTx }); } } return records; } async findTransactionMerklePaths(txids) { if (txids.length === 0) return []; const rows = await this.knex('transactions') .whereIn('txid', txids) .select(['txid', 'merklePath', 'blockHeight', 'blockHash', 'blockIndex', 'merkleRoot', 'beef']); const records = []; for (const row of rows) { let merklePath = this.binaryToHex(row.merklePath); if (merklePath === undefined) { const beef = this.binaryToNumberArray(row.beef); if (beef !== undefined) { merklePath = Transaction.fromBEEF(beef, row.txid).merklePath?.toHex(); } } if (merklePath !== undefined) { records.push({ txid: row.txid, merklePath, blockHeight: row.blockHeight === undefined || row.blockHeight === null ? undefined : Number(row.blockHeight), blockHash: row.blockHash, blockIndex: row.blockIndex === undefined || row.blockIndex === null ? undefined : Number(row.blockIndex), merkleRoot: row.merkleRoot }); } } return records; } async findUnprovenAppliedTransactions(cutoffHeight, topic) { let query = this.knex('applied_transactions') .where(builder => { builder.where({ proven: false }).orWhereNull('proven'); }) .whereNotNull('firstSeenHeight') .andWhere('firstSeenHeight', '<=', cutoffHeight) .select(['txid', 'topic', 'firstSeenHeight']); if (topic !== undefined) { query = query.andWhere({ topic }); } const rows = await query; const candidates = []; for (const row of rows) { const outputs = await this.knex('outputs') .where({ txid: row.txid, topic: row.topic }) .select(['txid', 'outputIndex']); candidates.push({ txid: row.txid, topic: row.topic, firstSeenHeight: row.firstSeenHeight === undefined || row.firstSeenHeight === null ? undefined : Number(row.firstSeenHeight), outputs: outputs.map(output => ({ txid: output.txid, outputIndex: Number(output.outputIndex) })) }); } return candidates; } async deleteAppliedTransaction(txid, topic) { await this.knex('applied_transactions').where({ txid, topic }).del(); } async findProvenAppliedTransactionsByBlockHash(blockHash) { const rows = await this.knex('applied_transactions') .where({ blockHash, proven: true }) .whereNotNull('blockHeight') .select(['txid', 'topic', 'blockHeight']); return rows.map(row => ({ txid: row.txid, topic: row.topic, blockHeight: Number(row.blockHeight) })); } async findProvenAppliedTransactionsInRange(fromHeight, toHeight, topic) { let query = this.knex('applied_transactions') .where({ proven: true }) .andWhere('blockHeight', '>=', fromHeight) .andWhere('blockHeight', '<=', toHeight); if (topic !== undefined) { query = query.andWhere({ topic }); } const rows = await query.select(['txid', 'topic', 'blockHeight', 'blockHash', 'merkleRoot']); return rows.map(row => ({ txid: row.txid, topic: row.topic, blockHeight: Number(row.blockHeight), blockHash: row.blockHash ?? undefined, merkleRoot: row.merkleRoot ?? undefined })); } async demoteAppliedTransactionToUnproven(txid, topic) { await this.knex('applied_transactions') .where({ txid, topic }) .update({ blockHeight: null, blockHash: null, blockIndex: null, merkleRoot: null, proven: false }); await this.knex('outputs') .where({ txid, topic }) .update({ blockHeight: null }); } } //# sourceMappingURL=KnexStorage.js.map