UNPKG

@bsv/overlay

Version:
652 lines (562 loc) 21.2 kB
import { Storage, type AppliedTransaction, type AppliedTransactionProofUpdate, type StoredTransactionRecord, type UnprovenAppliedTransactionCandidate } from '../Storage.js' import { Knex } from 'knex' import type { Output } from '../../Output.js' import { Transaction } from '@bsv/sdk' import { BASM_ZERO_HASH, type AdmittedTxRef, type RawTransactionRecord, type TopicAnchorTip, type TopicBlockAnchor, 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' ] as const export class KnexStorage implements Storage { knex: Knex constructor (knex: Knex) { this.knex = knex } private parseOutputRelations( value: string | Array<{ txid: string, outputIndex: number }> ): Array<{ txid: string, outputIndex: number }> { if (Array.isArray(value)) { return value } return JSON.parse(value) } private parseOutputRecord( row: any, includeBEEF: boolean, beefOverride?: number[] ): Output { 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) } } private binaryToNumberArray(value: any): number[] | undefined { if (value === undefined || value === null) { return undefined } return Array.from(Buffer.from(value)) } private binaryToHex(value: any): string | undefined { const array = this.binaryToNumberArray(value) if (array === undefined) { return undefined } return Buffer.from(array).toString('hex') } private transactionRecordFromBEEF(txid: string, beef: number[]): StoredTransactionRecord { 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 } } private async fetchTransactionBeefMap(txids: string[]): Promise<Map<string, number[]>> { if (txids.length === 0) { return new Map<string, number[]>() } const rows = await this.knex('transactions') .whereIn('txid', txids) .select(['txid', 'beef']) const beefByTxid = new Map<string, number[]>() 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: string, outputIndex: number, topic?: string, spent?: boolean, includeBEEF: boolean = false): Promise<Output | null> { const search: { 'outputs.txid': string 'outputs.outputIndex': number 'outputs.topic'?: string 'outputs.spent'?: boolean } = { '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: string[] = [...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: Array<{ txid: string, outputIndex: number }>, includeBEEF: boolean = false ): Promise<Output[]> { if (outpoints.length === 0) { return [] } const deduped = new Map<string, { txid: string, outputIndex: number }>() 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: string, includeBEEF: boolean = false): Promise<Output[]> { 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: string, since?: number, limit?: number, includeBEEF: boolean = false): Promise<Output[]> { // 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: string, outputIndex: number, topic: string): Promise<void> { 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: Output): Promise<void> { 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: Record<string, any> = { 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: string, outputIndex: number, topic?: string): Promise<void> { await this.knex('outputs').where({ txid, outputIndex, topic }).update('spent', true) } async updateConsumedBy (txid: string, outputIndex: number, topic: string, consumedBy: Array<{ txid: string, outputIndex: number }>): Promise<void> { await this.knex('outputs').where({ txid, outputIndex, topic }).update('consumedBy', JSON.stringify(consumedBy)) } async updateTransactionBEEF (txid: string, beef: number[]): Promise<void> { await this.upsertTransactionRecord(this.transactionRecordFromBEEF(txid, beef)) } async updateOutputBlockHeight (txid: string, outputIndex: number, topic: string, blockHeight: number): Promise<void> { await this.knex('outputs').where({ txid, outputIndex, topic }).update('blockHeight', blockHeight) } async upsertTransactionRecord(record: StoredTransactionRecord): Promise<void> { const insert: Record<string, any> = { 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: AppliedTransactionProofUpdate): Promise<void> { 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: AppliedTransaction): Promise<void> { 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: { txid: string, topic: string }): Promise<boolean> { 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: string, topic: string, since: number): Promise<void> { await this.knex('host_sync_state') .insert({ host, topic, since }) .onConflict(['host', 'topic']) .merge({ since }) } async getLastInteraction (host: string, topic: string): Promise<number> { const result = await this.knex('host_sync_state') .where({ host, topic }) .select('since') .first() return result ? result.since : 0 } async findAdmittedTransactionsForBlock(topic: string, blockHeight: number, blockHash?: string): Promise<AdmittedTxRef[]> { 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: TopicBlockAnchor): Promise<void> { 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: string, blockHeight: number, blockHash?: string): Promise<TopicBlockAnchor | undefined> { 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: string, fromHeight: number, toHeight: number): Promise<TopicBlockAnchor[]> { 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: string): Promise<TopicAnchorTip | undefined> { 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: string[]): Promise<RawTransactionRecord[]> { if (txids.length === 0) return [] const rows = await this.knex('transactions') .whereIn('txid', txids) .select(['txid', 'rawTx', 'beef']) const records: RawTransactionRecord[] = [] 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: string[]): Promise<Array<{ txid: string merklePath: string blockHeight?: number blockHash?: string blockIndex?: number merkleRoot?: string }>> { if (txids.length === 0) return [] const rows = await this.knex('transactions') .whereIn('txid', txids) .select(['txid', 'merklePath', 'blockHeight', 'blockHash', 'blockIndex', 'merkleRoot', 'beef']) const records: Array<{ txid: string merklePath: string blockHeight?: number blockHash?: string blockIndex?: number merkleRoot?: string }> = [] 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: number, topic?: string): Promise<UnprovenAppliedTransactionCandidate[]> { 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: UnprovenAppliedTransactionCandidate[] = [] 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: string, topic: string): Promise<void> { await this.knex('applied_transactions').where({ txid, topic }).del() } async findProvenAppliedTransactionsByBlockHash(blockHash: string): Promise<Array<{ txid: string, topic: string, blockHeight: number }>> { 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: number, toHeight: number, topic?: string): Promise<Array<{ txid: string, topic: string, blockHeight: number, blockHash?: string, merkleRoot?: string }>> { 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: string, topic: string): Promise<void> { 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 }) } }