@bsv/overlay
Version:
BSV Blockchain Overlay Services Engine
652 lines (562 loc) • 21.2 kB
text/typescript
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 })
}
}