UNPKG

@bsv/wallet-toolbox

Version:

BRC100 conforming wallet, wallet storage and wallet signer components

1,297 lines (1,228 loc) 89.9 kB
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