@bsv/wallet-toolbox
Version:
BRC100 conforming wallet, wallet storage and wallet signer components
1,297 lines (1,228 loc) • 89.9 kB
text/typescript
import { deleteDB, IDBPCursorWithValue, IDBPDatabase, IDBPTransaction, openDB } from 'idb'
import { ListActionsResult, ListOutputsResult } from '@bsv/sdk'
import {
TableCertificate,
TableCertificateField,
TableCertificateX,
TableCommission,
TableMonitorEvent,
TableOutput,
TableOutputBasket,
TableOutputTag,
TableOutputTagMap,
TableProvenTx,
TableProvenTxReq,
TableSettings,
TableSyncState,
TableTransaction,
TableTxLabel,
TableTxLabelMap,
TableUser
} from './schema/tables'
import { verifyOne, verifyOneOrNone } from '../utility/utilityHelpers'
import { StorageAdminStats, StorageProvider, StorageProviderOptions } from './StorageProvider'
import { StorageIdbSchema } from './schema/StorageIdbSchema'
import { DBType } from './StorageReader'
import { listActionsIdb } from './methods/listActionsIdb'
import { listOutputsIdb } from './methods/listOutputsIdb'
import { reviewStatusIdb } from './methods/reviewStatusIdb'
import { purgeDataIdb } from './methods/purgeDataIdb'
import {
AuthId,
FindCertificateFieldsArgs,
FindCertificatesArgs,
FindCommissionsArgs,
FindForUserSincePagedArgs,
FindMonitorEventsArgs,
FindOutputBasketsArgs,
FindOutputsArgs,
FindOutputTagMapsArgs,
FindOutputTagsArgs,
FindProvenTxReqsArgs,
FindProvenTxsArgs,
FindSyncStatesArgs,
FindTransactionsArgs,
FindTxLabelMapsArgs,
FindTxLabelsArgs,
FindUsersArgs,
ProvenOrRawTx,
PurgeParams,
PurgeResults,
TrxToken,
WalletStorageProvider
} from '../sdk/WalletStorage.interfaces'
import { WERR_INTERNAL, WERR_INVALID_OPERATION, WERR_INVALID_PARAMETER, WERR_UNAUTHORIZED } from '../sdk/WERR_errors'
import { EntityTimeStamp, TransactionStatus } from '../sdk/types'
import { ValidListActionsArgs, ValidListOutputsArgs } from '../sdk/validationHelpers'
export interface StorageIdbOptions extends StorageProviderOptions {}
/**
* This class implements the `StorageProvider` interface using IndexedDB,
* via the promises wrapper package `idb`.
*/
export class StorageIdb extends StorageProvider implements WalletStorageProvider {
dbName: string
db?: IDBPDatabase<StorageIdbSchema>
constructor(options: StorageIdbOptions) {
super(options)
this.dbName = `wallet-toolbox-${this.chain}net`
}
/**
* This method must be called at least once before any other method accesses the database,
* and each time the schema may have updated.
*
* If the database has already been created in this context, `storageName` and `storageIdentityKey`
* are ignored.
*
* @param storageName
* @param storageIdentityKey
* @returns
*/
async migrate(storageName: string, storageIdentityKey: string): Promise<string> {
const db = await this.verifyDB(storageName, storageIdentityKey)
return db.version.toString()
}
/**
* Following initial database initialization, this method verfies that db is ready for use.
*
* @throws `WERR_INVALID_OPERATION` if the database has not been initialized by a call to `migrate`.
*
* @param storageName
* @param storageIdentityKey
*
* @returns
*/
async verifyDB(storageName?: string, storageIdentityKey?: string): Promise<IDBPDatabase<StorageIdbSchema>> {
if (this.db) return this.db
this.db = await this.initDB(storageName, storageIdentityKey)
this._settings = (await this.db.getAll('settings'))[0]
this.whenLastAccess = new Date()
return this.db
}
/**
* Convert the standard optional `TrxToken` parameter into either a direct knex database instance,
* or a Knex.Transaction as appropriate.
*/
toDbTrx(
stores: string[],
mode: 'readonly' | 'readwrite',
trx?: TrxToken
): IDBPTransaction<StorageIdbSchema, string[], 'readwrite' | 'readonly'> {
if (trx) {
const t = trx as IDBPTransaction<StorageIdbSchema, string[], 'readwrite' | 'readonly'>
return t
} else {
if (!this.db) throw new Error('not initialized')
const db = this.db
const trx = db.transaction(stores || this.allStores, mode || 'readwrite')
this.whenLastAccess = new Date()
return trx
}
}
/**
* Called by `makeAvailable` to return storage `TableSettings`.
* Since this is the first async method that must be called by all clients,
* it is where async initialization occurs.
*
* After initialization, cached settings are returned.
*
* @param trx
*/
async readSettings(trx?: TrxToken): Promise<TableSettings> {
await this.verifyDB()
return this._settings!
}
async initDB(storageName?: string, storageIdentityKey?: string): Promise<IDBPDatabase<StorageIdbSchema>> {
const chain = this.chain
const maxOutputScript = 1024
const db = await openDB<StorageIdbSchema>(this.dbName, 1, {
upgrade(db, oldVersion, newVersion, transaction) {
if (!db.objectStoreNames.contains('proven_txs')) {
// proven_txs object store
const provenTxsStore = db.createObjectStore('proven_txs', {
keyPath: 'provenTxId',
autoIncrement: true
})
provenTxsStore.createIndex('txid', 'txid', { unique: true })
}
if (!db.objectStoreNames.contains('proven_tx_reqs')) {
// proven_tx_reqs object store
const provenTxReqsStore = db.createObjectStore('proven_tx_reqs', {
keyPath: 'provenTxReqId',
autoIncrement: true
})
provenTxReqsStore.createIndex('provenTxId', 'provenTxId')
provenTxReqsStore.createIndex('txid', 'txid', { unique: true })
provenTxReqsStore.createIndex('status', 'status')
provenTxReqsStore.createIndex('batch', 'batch')
}
if (!db.objectStoreNames.contains('users')) {
const users = db.createObjectStore('users', {
keyPath: 'userId',
autoIncrement: true
})
users.createIndex('identityKey', 'identityKey', { unique: true })
}
if (!db.objectStoreNames.contains('certificates')) {
// certificates object store
const certificatesStore = db.createObjectStore('certificates', {
keyPath: 'certificateId',
autoIncrement: true
})
certificatesStore.createIndex('userId', 'userId')
certificatesStore.createIndex(
'userId_type_certifier_serialNumber',
['userId', 'type', 'certifier', 'serialNumber'],
{ unique: true }
)
}
if (!db.objectStoreNames.contains('certificate_fields')) {
// certificate_fields object store
const certificateFieldsStore = db.createObjectStore('certificate_fields', {
keyPath: ['certificateId', 'fieldName'] // Composite key
})
certificateFieldsStore.createIndex('userId', 'userId')
certificateFieldsStore.createIndex('certificateId', 'certificateId')
}
if (!db.objectStoreNames.contains('output_baskets')) {
// output_baskets object store
const outputBasketsStore = db.createObjectStore('output_baskets', {
keyPath: 'basketId',
autoIncrement: true
})
outputBasketsStore.createIndex('userId', 'userId')
outputBasketsStore.createIndex('name_userId', ['name', 'userId'], { unique: true })
}
if (!db.objectStoreNames.contains('transactions')) {
// transactions object store
const transactionsStore = db.createObjectStore('transactions', {
keyPath: 'transactionId',
autoIncrement: true
})
transactionsStore.createIndex('userId', 'userId')
;(transactionsStore.createIndex('status', 'status'),
transactionsStore.createIndex('status_userId', ['status', 'userId']))
transactionsStore.createIndex('provenTxId', 'provenTxId')
transactionsStore.createIndex('reference', 'reference', { unique: true })
}
if (!db.objectStoreNames.contains('commissions')) {
// commissions object store
const commissionsStore = db.createObjectStore('commissions', {
keyPath: 'commissionId',
autoIncrement: true
})
commissionsStore.createIndex('userId', 'userId')
commissionsStore.createIndex('transactionId', 'transactionId', { unique: true })
}
if (!db.objectStoreNames.contains('outputs')) {
// outputs object store
const outputsStore = db.createObjectStore('outputs', {
keyPath: 'outputId',
autoIncrement: true
})
outputsStore.createIndex('userId', 'userId')
outputsStore.createIndex('transactionId', 'transactionId')
outputsStore.createIndex('basketId', 'basketId')
outputsStore.createIndex('spentBy', 'spentBy')
outputsStore.createIndex('transactionId_vout_userId', ['transactionId', 'vout', 'userId'], { unique: true })
}
if (!db.objectStoreNames.contains('output_tags')) {
// output_tags object store
const outputTagsStore = db.createObjectStore('output_tags', {
keyPath: 'outputTagId',
autoIncrement: true
})
outputTagsStore.createIndex('userId', 'userId')
outputTagsStore.createIndex('tag_userId', ['tag', 'userId'], { unique: true })
}
if (!db.objectStoreNames.contains('output_tags_map')) {
// output_tags_map object store
const outputTagsMapStore = db.createObjectStore('output_tags_map', {
keyPath: ['outputTagId', 'outputId']
})
outputTagsMapStore.createIndex('outputTagId', 'outputTagId')
outputTagsMapStore.createIndex('outputId', 'outputId')
}
if (!db.objectStoreNames.contains('tx_labels')) {
// tx_labels object store
const txLabelsStore = db.createObjectStore('tx_labels', {
keyPath: 'txLabelId',
autoIncrement: true
})
txLabelsStore.createIndex('userId', 'userId')
txLabelsStore.createIndex('label_userId', ['label', 'userId'], { unique: true })
}
if (!db.objectStoreNames.contains('tx_labels_map')) {
// tx_labels_map object store
const txLabelsMapStore = db.createObjectStore('tx_labels_map', {
keyPath: ['txLabelId', 'transactionId']
})
txLabelsMapStore.createIndex('txLabelId', 'txLabelId')
txLabelsMapStore.createIndex('transactionId', 'transactionId')
}
if (!db.objectStoreNames.contains('monitor_events')) {
// monitor_events object store
const monitorEventsStore = db.createObjectStore('monitor_events', {
keyPath: 'id',
autoIncrement: true
})
}
if (!db.objectStoreNames.contains('sync_states')) {
// sync_states object store
const syncStatesStore = db.createObjectStore('sync_states', {
keyPath: 'syncStateId',
autoIncrement: true
})
syncStatesStore.createIndex('userId', 'userId')
syncStatesStore.createIndex('refNum', 'refNum', { unique: true })
syncStatesStore.createIndex('status', 'status')
}
if (!db.objectStoreNames.contains('settings')) {
if (!storageName || !storageIdentityKey) {
throw new WERR_INVALID_OPERATION('migrate must be called before first access')
}
const settings = db.createObjectStore('settings', {
keyPath: 'storageIdentityKey'
})
const s: TableSettings = {
created_at: new Date(),
updated_at: new Date(),
storageIdentityKey,
storageName,
chain,
dbtype: 'IndexedDB',
maxOutputScript
}
settings.put(s)
}
}
})
return db
}
//
// StorageProvider abstract methods
//
async reviewStatus(args: { agedLimit: Date; trx?: TrxToken }): Promise<{ log: string }> {
return await reviewStatusIdb(this, args)
}
async purgeData(params: PurgeParams, trx?: TrxToken): Promise<PurgeResults> {
return await purgeDataIdb(this, params, trx)
}
/**
* Proceeds in three stages:
* 1. Find an output that exactly funds the transaction (if exactSatoshis is not undefined).
* 2. Find an output that overfunds by the least amount (targetSatoshis).
* 3. Find an output that comes as close to funding as possible (targetSatoshis).
* 4. Return undefined if no output is found.
*
* Outputs must belong to userId and basketId and have spendable true.
* Their corresponding transaction must have status of 'completed', 'unproven', or 'sending' (if excludeSending is false).
*
* @param userId
* @param basketId
* @param targetSatoshis
* @param exactSatoshis
* @param excludeSending
* @param transactionId
* @returns next funding output to add to transaction or undefined if there are none.
*/
async allocateChangeInput(
userId: number,
basketId: number,
targetSatoshis: number,
exactSatoshis: number | undefined,
excludeSending: boolean,
transactionId: number
): Promise<TableOutput | undefined> {
const dbTrx = this.toDbTrx(['outputs', 'transactions'], 'readwrite')
try {
const txStatus: TransactionStatus[] = ['completed', 'unproven']
if (!excludeSending) txStatus.push('sending')
const args: FindOutputsArgs = {
partial: { userId, basketId, spendable: true },
txStatus,
trx: dbTrx
}
const outputs = await this.findOutputs(args)
let output: TableOutput | undefined
let scores: { output: TableOutput; score: number }[] = []
for (const o of outputs) {
if (exactSatoshis && o.satoshis === exactSatoshis) {
output = o
break
}
const score = o.satoshis - targetSatoshis
scores.push({ output: o, score })
}
if (!output) {
// sort scores increasing by score property
scores = scores.sort((a, b) => a.score - b.score)
// find the first score that is greater than or equal to 0
const o = scores.find(s => s.score >= 0)
if (o) {
// stage 2 satisfied (minimally funded)
output = o.output
} else if (scores.length > 0) {
// stage 3 satisfied (minimally under-funded)
output = scores.slice(-1)[0].output
} else {
// no available funding outputs
output = undefined
}
}
if (output) {
// mark output as spent by transactionId
await this.updateOutput(output.outputId, { spendable: false, spentBy: transactionId }, dbTrx)
}
return output
} finally {
await dbTrx.done
}
}
async getProvenOrRawTx(txid: string, trx?: TrxToken): Promise<ProvenOrRawTx> {
const r: ProvenOrRawTx = {
proven: undefined,
rawTx: undefined,
inputBEEF: undefined
}
r.proven = verifyOneOrNone(await this.findProvenTxs({ partial: { txid: txid }, trx }))
if (!r.proven) {
const req = verifyOneOrNone(await this.findProvenTxReqs({ partial: { txid: txid }, trx }))
if (req && ['unsent', 'unmined', 'unconfirmed', 'sending', 'nosend', 'completed'].includes(req.status)) {
r.rawTx = req.rawTx
r.inputBEEF = req.inputBEEF
}
}
return r
}
async getRawTxOfKnownValidTransaction(
txid?: string,
offset?: number,
length?: number,
trx?: TrxToken
): Promise<number[] | undefined> {
if (!txid) return undefined
if (!this.isAvailable()) await this.makeAvailable()
let rawTx: number[] | undefined = undefined
const r = await this.getProvenOrRawTx(txid, trx)
if (r.proven) rawTx = r.proven.rawTx
else rawTx = r.rawTx
if (rawTx && offset !== undefined && length !== undefined && Number.isInteger(offset) && Number.isInteger(length)) {
rawTx = rawTx.slice(offset, offset + length)
}
return rawTx
}
async getLabelsForTransactionId(transactionId?: number, trx?: TrxToken): Promise<TableTxLabel[]> {
const maps = await this.findTxLabelMaps({ partial: { transactionId, isDeleted: false }, trx })
const labelIds = maps.map(m => m.txLabelId)
const labels: TableTxLabel[] = []
for (const txLabelId of labelIds) {
const label = verifyOne(await this.findTxLabels({ partial: { txLabelId, isDeleted: false }, trx }))
labels.push(label)
}
return labels
}
async getTagsForOutputId(outputId: number, trx?: TrxToken): Promise<TableOutputTag[]> {
const maps = await this.findOutputTagMaps({ partial: { outputId, isDeleted: false }, trx })
const tagIds = maps.map(m => m.outputTagId)
const tags: TableOutputTag[] = []
for (const outputTagId of tagIds) {
const tag = verifyOne(await this.findOutputTags({ partial: { outputTagId, isDeleted: false }, trx }))
tags.push(tag)
}
return tags
}
async listActions(auth: AuthId, vargs: ValidListActionsArgs): Promise<ListActionsResult> {
if (!auth.userId) throw new WERR_UNAUTHORIZED()
return await listActionsIdb(this, auth, vargs)
}
async listOutputs(auth: AuthId, vargs: ValidListOutputsArgs): Promise<ListOutputsResult> {
if (!auth.userId) throw new WERR_UNAUTHORIZED()
return await listOutputsIdb(this, auth, vargs)
}
async countChangeInputs(userId: number, basketId: number, excludeSending: boolean): Promise<number> {
const txStatus: TransactionStatus[] = ['completed', 'unproven']
if (!excludeSending) txStatus.push('sending')
const args: FindOutputsArgs = { partial: { userId, basketId }, txStatus }
let count = 0
await this.filterOutputs(args, r => {
count++
})
return count
}
async findCertificatesAuth(auth: AuthId, args: FindCertificatesArgs): Promise<TableCertificateX[]> {
if (!auth.userId || (args.partial.userId && args.partial.userId !== auth.userId)) throw new WERR_UNAUTHORIZED()
args.partial.userId = auth.userId
return await this.findCertificates(args)
}
async findOutputBasketsAuth(auth: AuthId, args: FindOutputBasketsArgs): Promise<TableOutputBasket[]> {
if (!auth.userId || (args.partial.userId && args.partial.userId !== auth.userId)) throw new WERR_UNAUTHORIZED()
args.partial.userId = auth.userId
return await this.findOutputBaskets(args)
}
async findOutputsAuth(auth: AuthId, args: FindOutputsArgs): Promise<TableOutput[]> {
if (!auth.userId || (args.partial.userId && args.partial.userId !== auth.userId)) throw new WERR_UNAUTHORIZED()
args.partial.userId = auth.userId
return await this.findOutputs(args)
}
async insertCertificateAuth(auth: AuthId, certificate: TableCertificateX): Promise<number> {
if (!auth.userId || (certificate.userId && certificate.userId !== auth.userId)) throw new WERR_UNAUTHORIZED()
certificate.userId = auth.userId
return await this.insertCertificate(certificate)
}
//
// StorageReaderWriter abstract methods
//
async dropAllData(): Promise<void> {
await deleteDB(this.dbName)
}
async filterOutputTagMaps(
args: FindOutputTagMapsArgs,
filtered: (v: TableOutputTagMap) => void,
userId?: number
): Promise<void> {
const offset = args.paged?.offset || 0
let skipped = 0
let count = 0
const dbTrx = this.toDbTrx(['output_tags_map'], 'readonly', args.trx)
let cursor:
| IDBPCursorWithValue<StorageIdbSchema, string[], 'output_tags_map', unknown, 'readwrite' | 'readonly'>
| IDBPCursorWithValue<StorageIdbSchema, string[], 'output_tags_map', 'outputTagId', 'readwrite' | 'readonly'>
| IDBPCursorWithValue<StorageIdbSchema, string[], 'output_tags_map', 'outputId', 'readwrite' | 'readonly'>
| null
if (args.partial?.outputTagId !== undefined) {
cursor = await dbTrx.objectStore('output_tags_map').index('outputTagId').openCursor(args.partial.outputTagId)
} else if (args.partial?.outputId !== undefined) {
cursor = await dbTrx.objectStore('output_tags_map').index('outputId').openCursor(args.partial.outputId)
} else {
cursor = await dbTrx.objectStore('output_tags_map').openCursor()
}
let firstTime = true
while (cursor) {
if (!firstTime) cursor = await cursor.continue()
if (!cursor) break
firstTime = false
const r = cursor.value
if (args.since && args.since > r.updated_at) continue
if (args.tagIds && !args.tagIds.includes(r.outputTagId)) continue
if (args.partial) {
if (args.partial.outputTagId && r.outputTagId !== args.partial.outputTagId) continue
if (args.partial.outputId && r.outputId !== args.partial.outputId) continue
if (args.partial.created_at && r.created_at.getTime() !== args.partial.created_at.getTime()) continue
if (args.partial.updated_at && r.updated_at.getTime() !== args.partial.updated_at.getTime()) continue
if (args.partial.isDeleted !== undefined && r.isDeleted !== args.partial.isDeleted) continue
}
if (userId !== undefined && r.txid) {
const count = await this.countOutputTags({ partial: { userId, outputTagId: r.outputTagId }, trx: args.trx })
if (count === 0) continue
}
if (skipped < offset) {
skipped++
continue
}
filtered(r)
count++
if (args.paged?.limit && count >= args.paged.limit) break
}
if (!args.trx) await dbTrx.done
}
async findOutputTagMaps(args: FindOutputTagMapsArgs): Promise<TableOutputTagMap[]> {
const results: TableOutputTagMap[] = []
await this.filterOutputTagMaps(args, r => {
results.push(this.validateEntity(r))
})
return results
}
async filterProvenTxReqs(
args: FindProvenTxReqsArgs,
filtered: (v: TableProvenTxReq) => void,
userId?: number
): Promise<void> {
if (args.partial.rawTx)
throw new WERR_INVALID_PARAMETER('args.partial.rawTx', `undefined. ProvenTxReqs may not be found by rawTx value.`)
if (args.partial.inputBEEF)
throw new WERR_INVALID_PARAMETER(
'args.partial.inputBEEF',
`undefined. ProvenTxReqs may not be found by inputBEEF value.`
)
const offset = args.paged?.offset || 0
let skipped = 0
let count = 0
const dbTrx = this.toDbTrx(['proven_tx_reqs'], 'readonly', args.trx)
let cursor:
| IDBPCursorWithValue<StorageIdbSchema, string[], 'proven_tx_reqs', unknown, 'readwrite' | 'readonly'>
| IDBPCursorWithValue<StorageIdbSchema, string[], 'proven_tx_reqs', 'provenTxId', 'readwrite' | 'readonly'>
| IDBPCursorWithValue<StorageIdbSchema, string[], 'proven_tx_reqs', 'txid', 'readwrite' | 'readonly'>
| IDBPCursorWithValue<StorageIdbSchema, string[], 'proven_tx_reqs', 'status', 'readwrite' | 'readonly'>
| IDBPCursorWithValue<StorageIdbSchema, string[], 'proven_tx_reqs', 'batch', 'readwrite' | 'readonly'>
| null
if (args.partial?.provenTxReqId) {
cursor = await dbTrx.objectStore('proven_tx_reqs').openCursor(args.partial.provenTxReqId)
} else if (args.partial?.provenTxId !== undefined) {
cursor = await dbTrx.objectStore('proven_tx_reqs').index('provenTxId').openCursor(args.partial.provenTxId)
} else if (args.partial?.txid !== undefined) {
cursor = await dbTrx.objectStore('proven_tx_reqs').index('txid').openCursor(args.partial.txid)
} else if (args.partial?.status !== undefined) {
cursor = await dbTrx.objectStore('proven_tx_reqs').index('status').openCursor(args.partial.status)
} else if (args.partial?.batch !== undefined) {
cursor = await dbTrx.objectStore('proven_tx_reqs').index('batch').openCursor(args.partial.batch)
} else {
cursor = await dbTrx.objectStore('proven_tx_reqs').openCursor()
}
let firstTime = true
while (cursor) {
if (!firstTime) cursor = await cursor.continue()
if (!cursor) break
firstTime = false
const r = cursor.value
if (args.since && args.since > r.updated_at) continue
if (args.partial) {
if (args.partial.provenTxReqId && r.provenTxReqId !== args.partial.provenTxReqId) continue
if (args.partial.provenTxId && r.provenTxId !== args.partial.provenTxId) continue
if (args.partial.created_at && r.created_at.getTime() !== args.partial.created_at.getTime()) continue
if (args.partial.updated_at && r.updated_at.getTime() !== args.partial.updated_at.getTime()) continue
if (args.partial.status && r.status !== args.partial.status) continue
if (args.partial.attempts !== undefined && r.attempts !== args.partial.attempts) continue
if (args.partial.notified !== undefined && r.notified !== args.partial.notified) continue
if (args.partial.txid && r.txid !== args.partial.txid) continue
if (args.partial.batch && r.batch !== args.partial.batch) continue
if (args.partial.history && r.history !== args.partial.history) continue
if (args.partial.notify && r.notify !== args.partial.notify) continue
}
if (userId !== undefined && r.txid) {
const count = await this.countTransactions({ partial: { userId, txid: r.txid }, trx: args.trx })
if (count === 0) continue
}
if (skipped < offset) {
skipped++
continue
}
filtered(r)
count++
if (args.paged?.limit && count >= args.paged.limit) break
}
if (!args.trx) await dbTrx.done
}
async findProvenTxReqs(args: FindProvenTxReqsArgs): Promise<TableProvenTxReq[]> {
const results: TableProvenTxReq[] = []
await this.filterProvenTxReqs(args, r => {
results.push(this.validateEntity(r))
})
return results
}
async filterProvenTxs(args: FindProvenTxsArgs, filtered: (v: TableProvenTx) => void, userId?: number): Promise<void> {
if (args.partial.rawTx)
throw new WERR_INVALID_PARAMETER('args.partial.rawTx', `undefined. ProvenTxs may not be found by rawTx value.`)
if (args.partial.merklePath)
throw new WERR_INVALID_PARAMETER(
'args.partial.merklePath',
`undefined. ProvenTxs may not be found by merklePath value.`
)
const offset = args.paged?.offset || 0
let skipped = 0
let count = 0
const dbTrx = this.toDbTrx(['proven_txs'], 'readonly', args.trx)
let cursor:
| IDBPCursorWithValue<StorageIdbSchema, string[], 'proven_txs', unknown, 'readwrite' | 'readonly'>
| IDBPCursorWithValue<StorageIdbSchema, string[], 'proven_txs', 'txid', 'readwrite' | 'readonly'>
| null
if (args.partial?.provenTxId) {
cursor = await dbTrx.objectStore('proven_txs').openCursor(args.partial.provenTxId)
} else if (args.partial?.txid !== undefined) {
cursor = await dbTrx.objectStore('proven_txs').index('txid').openCursor(args.partial.txid)
} else {
cursor = await dbTrx.objectStore('proven_txs').openCursor()
}
let firstTime = true
while (cursor) {
if (!firstTime) cursor = await cursor.continue()
if (!cursor) break
firstTime = false
const r = cursor.value
if (args.since && args.since > r.updated_at) continue
if (args.partial) {
if (args.partial.provenTxId && r.provenTxId !== args.partial.provenTxId) continue
if (args.partial.created_at && r.created_at.getTime() !== args.partial.created_at.getTime()) continue
if (args.partial.updated_at && r.updated_at.getTime() !== args.partial.updated_at.getTime()) continue
if (args.partial.txid && r.txid !== args.partial.txid) continue
if (args.partial.height !== undefined && r.height !== args.partial.height) continue
if (args.partial.index !== undefined && r.index !== args.partial.index) continue
if (args.partial.blockHash && r.blockHash !== args.partial.blockHash) continue
if (args.partial.merkleRoot && r.merkleRoot !== args.partial.merkleRoot) continue
}
if (userId !== undefined) {
const count = await this.countTransactions({ partial: { userId, provenTxId: r.provenTxId }, trx: args.trx })
if (count === 0) continue
}
if (skipped < offset) {
skipped++
continue
}
filtered(r)
count++
if (args.paged?.limit && count >= args.paged.limit) break
}
if (!args.trx) await dbTrx.done
}
async findProvenTxs(args: FindProvenTxsArgs): Promise<TableProvenTx[]> {
const results: TableProvenTx[] = []
await this.filterProvenTxs(args, r => {
results.push(this.validateEntity(r))
})
return results
}
async filterTxLabelMaps(
args: FindTxLabelMapsArgs,
filtered: (v: TableTxLabelMap) => void,
userId?: number
): Promise<void> {
const offset = args.paged?.offset || 0
let skipped = 0
let count = 0
const dbTrx = this.toDbTrx(['tx_labels_map'], 'readonly', args.trx)
let cursor:
| IDBPCursorWithValue<StorageIdbSchema, string[], 'tx_labels_map', unknown, 'readwrite' | 'readonly'>
| IDBPCursorWithValue<StorageIdbSchema, string[], 'tx_labels_map', 'transactionId', 'readwrite' | 'readonly'>
| IDBPCursorWithValue<StorageIdbSchema, string[], 'tx_labels_map', 'txLabelId', 'readwrite' | 'readonly'>
| null
if (args.partial?.transactionId !== undefined) {
cursor = await dbTrx.objectStore('tx_labels_map').index('transactionId').openCursor(args.partial.transactionId)
} else if (args.partial?.txLabelId !== undefined) {
cursor = await dbTrx.objectStore('tx_labels_map').index('txLabelId').openCursor(args.partial.txLabelId)
} else {
cursor = await dbTrx.objectStore('tx_labels_map').openCursor()
}
let firstTime = true
while (cursor) {
if (!firstTime) cursor = await cursor.continue()
if (!cursor) break
firstTime = false
const r = cursor.value
if (args.since && args.since > r.updated_at) continue
if (args.partial) {
if (args.partial.txLabelId && r.txLabelId !== args.partial.txLabelId) continue
if (args.partial.transactionId && r.transactionId !== args.partial.transactionId) continue
if (args.partial.created_at && r.created_at.getTime() !== args.partial.created_at.getTime()) continue
if (args.partial.updated_at && r.updated_at.getTime() !== args.partial.updated_at.getTime()) continue
if (args.partial.isDeleted !== undefined && r.isDeleted !== args.partial.isDeleted) continue
}
if (userId !== undefined) {
const count = await this.countTxLabels({ partial: { userId, txLabelId: r.txLabelId }, trx: args.trx })
if (count === 0) continue
}
if (skipped < offset) {
skipped++
continue
}
filtered(r)
count++
if (args.paged?.limit && count >= args.paged.limit) break
}
if (!args.trx) await dbTrx.done
}
async findTxLabelMaps(args: FindTxLabelMapsArgs): Promise<TableTxLabelMap[]> {
const results: TableTxLabelMap[] = []
await this.filterTxLabelMaps(args, r => {
results.push(this.validateEntity(r))
})
return results
}
async countOutputTagMaps(args: FindOutputTagMapsArgs): Promise<number> {
let count = 0
await this.filterOutputTagMaps(args, () => {
count++
})
return count
}
async countProvenTxReqs(args: FindProvenTxReqsArgs): Promise<number> {
let count = 0
await this.filterProvenTxReqs(args, () => {
count++
})
return count
}
async countProvenTxs(args: FindProvenTxsArgs): Promise<number> {
let count = 0
await this.filterProvenTxs(args, () => {
count++
})
return count
}
async countTxLabelMaps(args: FindTxLabelMapsArgs): Promise<number> {
let count = 0
await this.filterTxLabelMaps(args, () => {
count++
})
return count
}
async insertCertificate(certificate: TableCertificateX, trx?: TrxToken): Promise<number> {
const e = await this.validateEntityForInsert(certificate, trx, undefined, ['isDeleted'])
const fields = e.fields
if (e.fields) delete e.fields
if (e.certificateId === 0) delete e.certificateId
const dbTrx = this.toDbTrx(['certificates', 'certificate_fields'], 'readwrite', trx)
const store = dbTrx.objectStore('certificates')
try {
const id = Number(await store.add!(e))
certificate.certificateId = id
if (fields) {
for (const field of fields) {
field.certificateId = certificate.certificateId
field.userId = certificate.userId
await this.insertCertificateField(field, dbTrx)
}
}
} finally {
if (!trx) await dbTrx.done
}
return certificate.certificateId
}
async insertCertificateField(certificateField: TableCertificateField, trx?: TrxToken): Promise<void> {
const e = await this.validateEntityForInsert(certificateField, trx)
const dbTrx = this.toDbTrx(['certificate_fields'], 'readwrite', trx)
const store = dbTrx.objectStore('certificate_fields')
try {
await store.add!(e)
} finally {
if (!trx) await dbTrx.done
}
}
async insertCommission(commission: TableCommission, trx?: TrxToken): Promise<number> {
const e = await this.validateEntityForInsert(commission, trx)
if (e.commissionId === 0) delete e.commissionId
const dbTrx = this.toDbTrx(['commissions'], 'readwrite', trx)
const store = dbTrx.objectStore('commissions')
try {
const id = Number(await store.add!(e))
commission.commissionId = id
} finally {
if (!trx) await dbTrx.done
}
return commission.commissionId
}
async insertMonitorEvent(event: TableMonitorEvent, trx?: TrxToken): Promise<number> {
const e = await this.validateEntityForInsert(event, trx)
if (e.id === 0) delete e.id
const dbTrx = this.toDbTrx(['monitor_events'], 'readwrite', trx)
const store = dbTrx.objectStore('monitor_events')
try {
const id = Number(await store.add!(e))
event.id = id
} finally {
if (!trx) await dbTrx.done
}
return event.id
}
async insertOutput(output: TableOutput, trx?: TrxToken): Promise<number> {
const e = await this.validateEntityForInsert(output, trx)
if (e.outputId === 0) delete e.outputId
const dbTrx = this.toDbTrx(['outputs'], 'readwrite', trx)
const store = dbTrx.objectStore('outputs')
try {
const id = Number(await store.add!(e))
output.outputId = id
} finally {
if (!trx) await dbTrx.done
}
return output.outputId
}
async insertOutputBasket(basket: TableOutputBasket, trx?: TrxToken): Promise<number> {
const e = await this.validateEntityForInsert(basket, trx, undefined, ['isDeleted'])
if (e.basketId === 0) delete e.basketId
const dbTrx = this.toDbTrx(['output_baskets'], 'readwrite', trx)
const store = dbTrx.objectStore('output_baskets')
try {
const id = Number(await store.add!(e))
basket.basketId = id
} finally {
if (!trx) await dbTrx.done
}
return basket.basketId
}
async insertOutputTag(tag: TableOutputTag, trx?: TrxToken): Promise<number> {
const e = await this.validateEntityForInsert(tag, trx, undefined, ['isDeleted'])
if (e.outputTagId === 0) delete e.outputTagId
const dbTrx = this.toDbTrx(['output_tags'], 'readwrite', trx)
const store = dbTrx.objectStore('output_tags')
try {
const id = Number(await store.add!(e))
tag.outputTagId = id
} finally {
if (!trx) await dbTrx.done
}
return tag.outputTagId
}
async insertOutputTagMap(tagMap: TableOutputTagMap, trx?: TrxToken): Promise<void> {
const e = await this.validateEntityForInsert(tagMap, trx, undefined, ['isDeleted'])
const dbTrx = this.toDbTrx(['output_tags_map'], 'readwrite', trx)
const store = dbTrx.objectStore('output_tags_map')
try {
await store.add!(e)
} finally {
if (!trx) await dbTrx.done
}
}
async insertProvenTx(tx: TableProvenTx, trx?: TrxToken): Promise<number> {
const e = await this.validateEntityForInsert(tx, trx)
if (e.provenTxId === 0) delete e.provenTxId
const dbTrx = this.toDbTrx(['proven_txs'], 'readwrite', trx)
const store = dbTrx.objectStore('proven_txs')
try {
const id = Number(await store.add!(e))
tx.provenTxId = id
} finally {
if (!trx) await dbTrx.done
}
return tx.provenTxId
}
async insertProvenTxReq(tx: TableProvenTxReq, trx?: TrxToken): Promise<number> {
const e = await this.validateEntityForInsert(tx, trx)
if (e.provenTxReqId === 0) delete e.provenTxReqId
const dbTrx = this.toDbTrx(['proven_tx_reqs'], 'readwrite', trx)
const store = dbTrx.objectStore('proven_tx_reqs')
try {
const id = Number(await store.add!(e))
tx.provenTxReqId = id
} finally {
if (!trx) await dbTrx.done
}
return tx.provenTxReqId
}
async insertSyncState(syncState: TableSyncState, trx?: TrxToken): Promise<number> {
const e = await this.validateEntityForInsert(syncState, trx, ['when'], ['init'])
if (e.syncStateId === 0) delete e.syncStateId
const dbTrx = this.toDbTrx(['sync_states'], 'readwrite', trx)
const store = dbTrx.objectStore('sync_states')
try {
const id = Number(await store.add!(e))
syncState.syncStateId = id
} finally {
if (!trx) await dbTrx.done
}
return syncState.syncStateId
}
async insertTransaction(tx: TableTransaction, trx?: TrxToken): Promise<number> {
const e = await this.validateEntityForInsert(tx, trx)
if (e.transactionId === 0) delete e.transactionId
const dbTrx = this.toDbTrx(['transactions'], 'readwrite', trx)
const store = dbTrx.objectStore('transactions')
try {
const id = Number(await store.add!(e))
tx.transactionId = id
} finally {
if (!trx) await dbTrx.done
}
return tx.transactionId
}
async insertTxLabel(label: TableTxLabel, trx?: TrxToken): Promise<number> {
const e = await this.validateEntityForInsert(label, trx, undefined, ['isDeleted'])
if (e.txLabelId === 0) delete e.txLabelId
const dbTrx = this.toDbTrx(['tx_labels'], 'readwrite', trx)
const store = dbTrx.objectStore('tx_labels')
try {
const id = Number(await store.add!(e))
label.txLabelId = id
} finally {
if (!trx) await dbTrx.done
}
return label.txLabelId
}
async insertTxLabelMap(labelMap: TableTxLabelMap, trx?: TrxToken): Promise<void> {
const e = await this.validateEntityForInsert(labelMap, trx, undefined, ['isDeleted'])
const dbTrx = this.toDbTrx(['tx_labels_map'], 'readwrite', trx)
const store = dbTrx.objectStore('tx_labels_map')
try {
await store.add!(e)
} finally {
if (!trx) await dbTrx.done
}
}
async insertUser(user: TableUser, trx?: TrxToken): Promise<number> {
const e = await this.validateEntityForInsert(user, trx)
if (e.userId === 0) delete e.userId
const dbTrx = this.toDbTrx(['users'], 'readwrite', trx)
const store = dbTrx.objectStore('users')
try {
const id = Number(await store.add!(e))
user.userId = id
} finally {
if (!trx) await dbTrx.done
}
return user.userId
}
async updateIdb<T>(
id: number | number[],
update: Partial<T>,
keyProp: string,
storeName: string,
trx?: TrxToken
): Promise<number> {
if (update[keyProp] !== undefined && (Array.isArray(id) || update[keyProp] !== id)) {
throw new WERR_INVALID_PARAMETER(`update.${keyProp}`, `undefined`)
}
const u = this.validatePartialForUpdate(update)
const dbTrx = this.toDbTrx([storeName], 'readwrite', trx)
const store = dbTrx.objectStore(storeName)
const ids = Array.isArray(id) ? id : [id]
try {
for (const i of ids) {
const e = await store.get(i)
if (!e) throw new WERR_INVALID_PARAMETER('id', `an existing record to update ${keyProp} ${i} not found`)
const v: T = {
...e,
...u
}
const uid = await store.put!(v)
if (uid !== i) throw new WERR_INTERNAL(`updated id ${uid} does not match original ${id}`)
}
} finally {
if (!trx) await dbTrx.done
}
return 1
}
async updateIdbKey<T>(
key: (number | string)[],
update: Partial<T>,
keyProps: string[],
storeName: string,
trx?: TrxToken
): Promise<number> {
if (key.length !== keyProps.length)
throw new WERR_INTERNAL(`key.length ${key.length} !== keyProps.length ${keyProps.length}`)
for (let i = 0; i < key.length; i++) {
if (update[keyProps[i]] !== undefined && update[keyProps[i]] !== key[i]) {
throw new WERR_INVALID_PARAMETER(`update.${keyProps[i]}`, `undefined`)
}
}
const u = this.validatePartialForUpdate(update)
const dbTrx = this.toDbTrx([storeName], 'readwrite', trx)
const store = dbTrx.objectStore(storeName)
try {
const e = await store.get(key)
if (!e)
throw new WERR_INVALID_PARAMETER(
'key',
`an existing record to update ${keyProps.join(',')} ${key.join(',')} not found`
)
const v: T = {
...e,
...u
}
const uid = await store.put!(v)
for (let i = 0; i < key.length; i++) {
if (uid[i] !== key[i]) throw new WERR_INTERNAL(`updated key ${uid[i]} does not match original ${key[i]}`)
}
} finally {
if (!trx) await dbTrx.done
}
return 1
}
async updateCertificate(id: number, update: Partial<TableCertificate>, trx?: TrxToken): Promise<number> {
return this.updateIdb(id, update, 'certificateId', 'certificates', trx)
}
async updateCertificateField(
certificateId: number,
fieldName: string,
update: Partial<TableCertificateField>,
trx?: TrxToken
): Promise<number> {
return this.updateIdbKey(
[certificateId, fieldName],
update,
['certificateId', 'fieldName'],
'certificate_fields',
trx
)
}
async updateCommission(id: number, update: Partial<TableCommission>, trx?: TrxToken): Promise<number> {
return this.updateIdb(id, update, 'commissionId', 'commissions', trx)
}
async updateMonitorEvent(id: number, update: Partial<TableMonitorEvent>, trx?: TrxToken): Promise<number> {
return this.updateIdb(id, update, 'id', 'monitor_events', trx)
}
async updateOutput(id: number, update: Partial<TableOutput>, trx?: TrxToken): Promise<number> {
return this.updateIdb(id, update, 'outputId', 'outputs', trx)
}
async updateOutputBasket(id: number, update: Partial<TableOutputBasket>, trx?: TrxToken): Promise<number> {
return this.updateIdb(id, update, 'basketId', 'output_baskets', trx)
}
async updateOutputTag(id: number, update: Partial<TableOutputTag>, trx?: TrxToken): Promise<number> {
return this.updateIdb(id, update, 'outputTagId', 'output_tags', trx)
}
async updateProvenTx(id: number, update: Partial<TableProvenTx>, trx?: TrxToken): Promise<number> {
return this.updateIdb(id, update, 'provenTxId', 'proven_txs', trx)
}
async updateProvenTxReq(id: number | number[], update: Partial<TableProvenTxReq>, trx?: TrxToken): Promise<number> {
return this.updateIdb(id, update, 'provenTxReqId', 'proven_tx_reqs', trx)
}
async updateSyncState(id: number, update: Partial<TableSyncState>, trx?: TrxToken): Promise<number> {
return this.updateIdb(id, update, 'syncStateId', 'sync_states', trx)
}
async updateTransaction(id: number | number[], update: Partial<TableTransaction>, trx?: TrxToken): Promise<number> {
return this.updateIdb(id, update, 'transactionId', 'transactions', trx)
}
async updateTxLabel(id: number, update: Partial<TableTxLabel>, trx?: TrxToken): Promise<number> {
return this.updateIdb(id, update, 'txLabelId', 'tx_labels', trx)
}
async updateUser(id: number, update: Partial<TableUser>, trx?: TrxToken): Promise<number> {
return this.updateIdb(id, update, 'userId', 'users', trx)
}
async updateOutputTagMap(
outputId: number,
tagId: number,
update: Partial<TableOutputTagMap>,
trx?: TrxToken
): Promise<number> {
return this.updateIdbKey([tagId, outputId], update, ['outputTagId', 'outputId'], 'output_tags_map', trx)
}
async updateTxLabelMap(
transactionId: number,
txLabelId: number,
update: Partial<TableTxLabelMap>,
trx?: TrxToken
): Promise<number> {
return this.updateIdbKey([txLabelId, transactionId], update, ['txLabelId', 'transactionId'], 'tx_labels_map', trx)
}
//
// StorageReader abstract methods
//
async destroy(): Promise<void> {
if (this.db) {
this.db.close()
}
this.db = undefined
this._settings = undefined
}
allStores: string[] = [
'certificates',
'certificate_fields',
'commissions',
'monitor_events',
'outputs',
'output_baskets',
'output_tags',
'output_tags_map',
'proven_txs',
'proven_tx_reqs',
'sync_states',
'transactions',
'tx_labels',
'tx_labels_map',
'users'
]
/**
* @param scope
* @param trx
* @returns
*/
async transaction<T>(scope: (trx: TrxToken) => Promise<T>, trx?: TrxToken): Promise<T> {
if (trx) return await scope(trx)
const stores = this.allStores
const db = await this.verifyDB()
const tx = db.transaction(stores, 'readwrite')
try {
const r = await scope(tx as TrxToken)
await tx.done
return r
} catch (err) {
tx.abort()
await tx.done
throw err
}
}
async filterCertificateFields(
args: FindCertificateFieldsArgs,
filtered: (v: TableCertificateField) => void
): Promise<void> {
const offset = args.paged?.offset || 0
let skipped = 0
let count = 0
const dbTrx = this.toDbTrx(['certificate_fields'], 'readonly', args.trx)
let cursor:
| IDBPCursorWithValue<StorageIdbSchema, string[], 'certificate_fields', unknown, 'readwrite' | 'readonly'>
| IDBPCursorWithValue<StorageIdbSchema, string[], 'certificate_fields', 'userId', 'readwrite' | 'readonly'>
| IDBPCursorWithValue<StorageIdbSchema, string[], 'certificate_fields', 'certificateId', 'readwrite' | 'readonly'>
| null
if (args.partial?.certificateId !== undefined) {
cursor = await dbTrx
.objectStore('certificate_fields')
.index('certificateId')
.openCursor(args.partial.certificateId)
} else if (args.partial?.userId !== undefined) {
cursor = await dbTrx.objectStore('certificate_fields').index('userId').openCursor(args.partial.userId)
} else {
cursor = await dbTrx.objectStore('certificate_fields').openCursor()
}
let firstTime = true
while (cursor) {
if (!firstTime) cursor = await cursor.continue()
if (!cursor) break
firstTime = false
const r = cursor.value
if (args.since && args.since > r.updated_at) continue
if (args.partial) {
if (args.partial.userId && r.userId !== args.partial.userId) continue
if (args.partial.certificateId && r.certificateId !== args.partial.certificateId) continue
if (args.partial.created_at && r.created_at.getTime() !== args.partial.created_at.getTime()) continue
if (args.partial.updated_at && r.updated_at.getTime() !== args.partial.updated_at.getTime()) continue
if (args.partial.fieldName && r.fieldName !== args.partial.fieldName) continue
if (args.partial.fieldValue && r.fieldValue !== args.partial.fieldValue) continue
if (args.partial.masterKey && r.masterKey !== args.partial.masterKey) continue
}
if (skipped < offset) {
skipped++
continue
}
filtered(r)
count++
if (args.paged?.limit && count >= args.paged.limit) break
}
if (!args.trx) await dbTrx.done
}
async findCertificateFields(args: FindCertificateFieldsArgs): Promise<TableCertificateField[]> {
const result: TableCertificateField[] = []
await this.filterCertificateFields(args, r => {
result.push(this.validateEntity(r))
})
return result
}
async filterCertificates(args: FindCertificatesArgs, filtered: (v: TableCertificateX) => void): Promise<void> {
const offset = args.paged?.offset || 0
let skipped = 0
let count = 0
const dbTrx = this.toDbTrx(['certificates'], 'readonly', args.trx)
let cursor:
| IDBPCursorWithValue<StorageIdbSchema, string[], 'certificates', unknown, 'readwrite' | 'readonly'>
| IDBPCursorWithValue<StorageIdbSchema, string[], 'certificates', 'userId', 'readwrite' | 'readonly'>
| IDBPCursorWithValue<
StorageIdbSchema,
string[],
'certificates',
'userId_type_certifier_serialNumber',
'readwrite' | 'readonly'
>
| null
if (args.partial?.certificateId) {
cursor = await dbTrx.objectStore('certificates').openCursor(args.partial.certificateId)
} else if (args.partial?.userId !== undefined) {
if (args.partial?.type && args.partial?.certifier && args.partial?.serialNumber) {
cursor = await dbTrx
.objectStore('certificates')
.index('userId_type_certifier_serialNumber')
.openCursor([args.partial.userId, args.partial.type, args.partial.certifier, args.partial.serialNumber])
} else {
cursor = await dbTrx.objectStore('certificates').index('userId').openCursor(args.partial.userId)
}
} else {
cursor = await dbTrx.objectStore('certificates').openCursor()
}
let firstTime = true
while (cursor) {
if (!firstTime) cursor = await cursor.continue()
if (!cursor) break
firstTime = false
const r = cursor.value
if (args.since && args.since > r.updated_at) continue
if (args.certifiers && !args.certifiers.includes(r.certifier)) continue
if (args.types && !args.types.includes(r.type)) continue
if (args.partial) {
if (args.partial.userId && r.userId !== args.partial.userId) contin