UNPKG

wallet-storage

Version:

BRC100 conforming wallet, wallet storage and wallet signer components

473 lines (433 loc) 18.4 kB
import { MerklePath } from "@bsv/sdk" import { arraysEqual, asString, entity, sdk, StorageProvider, table, verifyId, verifyOne, verifyOneOrNone, verifyTruthy, WalletStorageManager } from "../../../index.client"; import { EntityBase } from "."; import { StorageProcessActionArgs } from "../../../sdk"; export class ProvenTxReq extends EntityBase<table.ProvenTxReq> { static async fromStorageTxid(storage: entity.EntityStorage, txid: string, trx?: sdk.TrxToken) : Promise<ProvenTxReq | undefined> { const reqApi = verifyOneOrNone(await storage.findProvenTxReqs({ partial: { txid }, trx })) if (!reqApi) return undefined return new ProvenTxReq(reqApi) } static async fromStorageId(storage: entity.EntityStorage, id: number, trx?: sdk.TrxToken) : Promise<ProvenTxReq> { const reqApi = verifyOneOrNone(await storage.findProvenTxReqs({ partial: { provenTxReqId: id }, trx })) if (!reqApi) throw new sdk.WERR_INTERNAL(`proven_tx_reqs with id ${id} is missing.`) return new ProvenTxReq(reqApi) } static fromTxid(txid: string, rawTx: number[], inputBEEF?: number[]) : ProvenTxReq { const now = new Date() return new ProvenTxReq({ provenTxReqId: 0, created_at: now, updated_at: now, txid, inputBEEF, rawTx, status: 'unknown', history: '{}', notify: '{}', attempts: 0, notified: false }) } history: ProvenTxReqHistory notify: ProvenTxReqNotify packApiHistory() { this.api.history = JSON.stringify(this.history) } packApiNotify() { this.api.notify = JSON.stringify(this.notify) } unpackApiHistory() { this.history = JSON.parse(this.api.history) } unpackApiNotify() { this.notify = JSON.parse(this.api.notify) } get apiHistory() : string { this.packApiHistory(); return this.api.history } get apiNotify() : string { this.packApiNotify(); return this.api.notify } set apiHistory(v: string) { this.api.history = v; this.unpackApiHistory() } set apiNotify(v: string) { this.api.notify = v; this.unpackApiNotify() } updateApi() : void { this.packApiHistory() this.packApiNotify() } unpackApi() : void { this.unpackApiHistory() this.unpackApiNotify() if (this.notify.transactionIds) { // Cleanup null values and duplicates. const transactionIds: number[] = [] for (const id of this.notify.transactionIds) { if (Number.isInteger(id) && !transactionIds.some(txid => txid === id)) transactionIds.push(id) } this.notify.transactionIds = transactionIds } } async refreshFromStorage(storage: entity.EntityStorage | WalletStorageManager) : Promise<void> { const newApi = verifyOne(await storage.findProvenTxReqs({ partial: { provenTxReqId: this.id } })) this.api = newApi this.unpackApi() } constructor(api?: table.ProvenTxReq) { const now = new Date() super(api || { provenTxReqId: 0, created_at: now, updated_at: now, txid: "", rawTx: [], history: "", notify: "", attempts: 0, status: 'unknown', notified: false }) this.history = {} this.notify = {} this.unpackApi() } /** * Returns history to only what followed since date. */ historySince(since: Date) : ProvenTxReqHistory { const fh: ProvenTxReqHistory = { notes: {} } const filter = since.toISOString() const notes = this.history.notes if (notes && fh.notes) { for (const key of Object.keys(notes)) if (key > filter) fh.notes[key] = notes[key] } return fh } historyPretty(since?: Date, indent = 0) : string { const h = since ? this.historySince(since) : { ...this.history } if (!h.notes) return '' const keyLimit = since ? since.toISOString() : undefined let log = '' for (const key of Object.keys(h.notes)) { if (keyLimit && key < keyLimit) continue h.notes[key] = this.parseHistoryNote(h.notes[key]); log += `${key}: ${h.notes[key]}\n` } if (log.slice(-1) !== '\n') log += '\n' return log } getHistorySummary() : ProvenTxReqHistorySummaryApi { const summary: ProvenTxReqHistorySummaryApi = { setToCompleted: false, setToUnmined: false, setToCallback: false, setToDoubleSpend: false, setToSending: false, setToUnconfirmed: false } const h = this.history if (h.notes) { for (const key of Object.keys(h.notes)) { this.parseHistoryNote(h.notes[key], summary); } } return summary } parseHistoryNote(note: string, summary?: ProvenTxReqHistorySummaryApi) : string { const c = summary || { setToCompleted: false, setToUnmined: false, setToCallback: false, setToDoubleSpend: false, setToSending: false, setToUnconfirmed: false, } try { const v = JSON.parse(note); switch (v.what) { case "postReqsToNetwork result": { const r = v["result"] as any return `posted by ${v["name"]} status=${r.status} txid=${r.txid}` } break; case "getMerkleProof invalid": { return `getMerkleProof failing after ${v["attempts"]} attempts over ${v["ageInMinutes"]} minutes` } break; case "ProvenTxReq.set status": { const status: sdk.ProvenTxReqStatus = v.new switch (status) { case 'completed': c.setToCompleted = true; break; case 'unmined': c.setToUnmined = true; break; case 'callback': c.setToCallback = true; break; case 'doubleSpend': c.setToDoubleSpend = true; break; case 'sending': c.setToSending = true; break; case 'unconfirmed': c.setToUnconfirmed = true; break; default: break; } return `set status ${v.old} to ${v.new}`; } break; case "notified": return `notified`; default: break; } } catch { /** */ } return note } addNotifyTransactionId(id: number) { if (!Number.isInteger(id)) throw new sdk.WERR_INVALID_PARAMETER('id', 'integer') const s = new Set(this.notify.transactionIds || []) s.add(id) this.notify.transactionIds = [...s].sort((a, b) => a > b ? 1 : a < b ? -1 : 0) this.notified = false } addHistoryNote<T extends { what: string }>(note: string | T, when?: Date, noDupes?: boolean) { if (!this.history.notes) this.history.notes = {} if (typeof note === 'string') note = JSON.stringify({ what: "string", note }) else note = JSON.stringify(note) when ||= new Date() let msecs = when.getTime() let key = when.toISOString() if (!noDupes) { while (this.history.notes[key]) { // Make sure new key (timestamp) will not overwrite existing. // Fudge the time by 1 msec forward until unique msecs += 1 key = new Date(msecs).toISOString() } } if (!this.history.notes[key]) this.history.notes[key] = note } /** * Updates database record with current state of this entity. * @param storage * @param trx */ async updateStorage(storage: entity.EntityStorage, trx?: sdk.TrxToken) { this.updated_at = new Date() this.updateApi() if (this.id === 0) { await storage.insertProvenTxReq(this.api) } const update: Partial<table.ProvenTxReq> = { ...this.api } await storage.updateProvenTxReq(this.id, update, trx) } /** * Update storage with changes to non-static properties: * updated_at * provenTxId * status * history * notify * notified * attempts * batch * * @param storage * @param trx */ async updateStorageDynamicProperties(storage: WalletStorageManager | StorageProvider, trx?: sdk.TrxToken) { this.updated_at = new Date() this.updateApi() const update: Partial<table.ProvenTxReqDynamics> = { updated_at: this.api.updated_at, provenTxId: this.api.provenTxId, status: this.api.status, history: this.api.history, notify: this.api.notify, notified: this.api.notified, attempts: this.api.attempts, batch: this.api.batch } if (storage.isStorageProvider()) { const sp = storage as StorageProvider await sp.updateProvenTxReqDynamics(this.id, update, trx) } else { const wsm = storage as WalletStorageManager await wsm.runAsStorageProvider(async (sp) => { await sp.updateProvenTxReqDynamics(this.id, update, trx) }) } } async insertOrMerge(storage: entity.EntityStorage, trx?: sdk.TrxToken): Promise<ProvenTxReq> { const req = await storage.transaction<ProvenTxReq>(async trx => { let reqApi0 = this.toApi() const { req: reqApi1, isNew } = await storage.findOrInsertProvenTxReq(reqApi0, trx) if (isNew) { return new entity.ProvenTxReq(reqApi1) } else { const req = new ProvenTxReq(reqApi1) req.mergeNotifyTransactionIds(reqApi0) req.mergeHistory(reqApi0) await req.updateStorage(storage, trx) return req } }, trx) return req } /** * See `ProvenTxReqStatusApi` */ get status() { return this.api.status } set status(v: sdk.ProvenTxReqStatus) { if (v !== this.api.status) { this.addHistoryNote({ what: "ProvenTxReq.set status", old: this.api.status, new: v }) this.api.status = v } } get provenTxReqId() { return this.api.provenTxReqId } set provenTxReqId(v: number) { this.api.provenTxReqId = v } get created_at() { return this.api.created_at } set created_at(v: Date) { this.api.created_at = v } get updated_at() { return this.api.updated_at } set updated_at(v: Date) { this.api.updated_at = v } get txid() { return this.api.txid } set txid(v: string) { this.api.txid = v } get inputBEEF() { return this.api.inputBEEF } set inputBEEF(v: number[] | undefined) { this.api.inputBEEF = v } get rawTx() { return this.api.rawTx } set rawTx(v: number[]) { this.api.rawTx = v } get attempts() { return this.api.attempts } set attempts(v: number) { this.api.attempts = v } get provenTxId() { return this.api.provenTxId } set provenTxId(v: number | undefined) { this.api.provenTxId = v } get notified() { return this.api.notified } set notified(v: boolean) { this.api.notified = v } get batch() { return this.api.batch } set batch(v: string | undefined) { this.api.batch = v } override get id() { return this.api.provenTxReqId } override set id(v: number) { this.api.provenTxReqId = v } override get entityName(): string { return 'ProvenTxReq' } override get entityTable(): string { return 'proven_tx_reqs' } /** * 'convergent' equality must satisfy (A sync B) equals (B sync A) */ override equals(ei: table.ProvenTxReq, syncMap?: entity.SyncMap | undefined): boolean { const eo = this.toApi() if ( eo.txid != ei.txid || !arraysEqual(eo.rawTx, ei.rawTx) || !eo.inputBEEF && ei.inputBEEF || eo.inputBEEF && !ei.inputBEEF || eo.inputBEEF && ei.inputBEEF && !arraysEqual(eo.inputBEEF, ei.inputBEEF) || eo.batch != ei.batch ) return false if (syncMap) { if ( // attempts doesn't matter for convergent equality // history doesn't matter for convergent equality // only local transactionIds matter, that cared about this txid in sorted order eo.provenTxReqId !== syncMap.provenTxReq.idMap[verifyId(ei.provenTxReqId)] || !eo.provenTxId && ei.provenTxId || eo.provenTxId && !ei.provenTxId || ei.provenTxId && eo.provenTxId !== syncMap.provenTx.idMap[ei.provenTxId] // || eo.created_at !== minDate(ei.created_at, eo.created_at) // || eo.updated_at !== maxDate(ei.updated_at, eo.updated_at) ) return false } else { if ( eo.attempts != ei.attempts || eo.history != ei.history || eo.notify != ei.notify || eo.provenTxReqId !== ei.provenTxReqId || eo.provenTxId !== ei.provenTxId // || eo.created_at !== ei.created_at // || eo.updated_at !== ei.updated_at ) return false } return true } static async mergeFind(storage: entity.EntityStorage, userId: number, ei: table.ProvenTxReq, syncMap: entity.SyncMap, trx?: sdk.TrxToken) : Promise<{ found: boolean, eo: ProvenTxReq, eiId: number }> { const ef = verifyOneOrNone(await storage.findProvenTxReqs({ partial: { txid: ei.txid }, trx })) return { found: !!ef, eo: new ProvenTxReq(ef || { ...ei }), eiId: verifyId(ei.provenTxReqId) } } mapNotifyTransactionIds(syncMap: entity.SyncMap) : void { // Map external notification transaction ids to local ids const externalIds = this.notify.transactionIds || [] this.notify.transactionIds = [] for (const transactionId of externalIds) { const localTxId: number | undefined = syncMap.transaction.idMap[transactionId] if (localTxId) { this.addNotifyTransactionId(localTxId) } } } mergeNotifyTransactionIds(ei: table.ProvenTxReq, syncMap?: entity.SyncMap) : void { // Map external notification transaction ids to local ids and merge them if they exist. const eie = new ProvenTxReq(ei) if (eie.notify.transactionIds) { this.notify.transactionIds ||= [] for (const transactionId of eie.notify.transactionIds) { const localTxId: number | undefined = syncMap ? syncMap.transaction.idMap[transactionId] : transactionId if (localTxId) { this.addNotifyTransactionId(localTxId) } } } } // eslint-disable-next-line @typescript-eslint/no-unused-vars mergeHistory(ei: table.ProvenTxReq, syncMap?: entity.SyncMap, noDupes?: boolean) : void { const eie = new ProvenTxReq(ei) if (eie.history.notes) { for (const [k, v] of Object.entries(eie.history.notes)) { this.addHistoryNote(v, new Date(k), noDupes) } } } static isTerminalStatus(status: sdk.ProvenTxReqStatus) : boolean { return sdk.ProvenTxReqTerminalStatus.some(s => s === status) } override async mergeNew(storage: entity.EntityStorage, userId: number, syncMap: entity.SyncMap, trx?: sdk.TrxToken): Promise<void> { if (this.provenTxId) this.provenTxId = syncMap.provenTx.idMap[this.provenTxId] this.mapNotifyTransactionIds(syncMap) this.provenTxReqId = 0 this.provenTxReqId = await storage.insertProvenTxReq(this.toApi(), trx) } /** * When merging `ProvenTxReq`, care is taken to avoid short-cirtuiting notification: `status` must not transition to `completed` without * passing through `notifying`. Thus a full convergent merge passes through these sequence steps: * 1. Remote storage completes before local storage. * 2. The remotely completed req and ProvenTx sync to local storage. * 3. The local storage transitions to `notifying`, after merging the remote attempts and history. * 4. The local storage notifies, transitioning to `completed`. * 5. Having been updated, the local req, but not ProvenTx sync to remote storage, but do not merge because the earlier `completed` wins. * 6. Convergent equality is achieved (completing work - history and attempts are equal) * * On terminal failure: `doubleSpend` trumps `invalid` as it contains more data. */ override async mergeExisting(storage: entity.EntityStorage, since: Date | undefined, ei: table.ProvenTxReq, syncMap: entity.SyncMap, trx?: sdk.TrxToken): Promise<boolean> { if (!this.batch && ei.batch) this.batch = ei.batch else if (this.batch && ei.batch && this.batch !== ei.batch) throw new sdk.WERR_INTERNAL('ProvenTxReq merge batch not equal.') this.mergeHistory(ei, syncMap) this.mergeNotifyTransactionIds(ei, syncMap) this.updated_at = new Date() await storage.updateProvenTxReq(this.id, this.toApi(), trx) return false } } export interface ProvenTxReqHistorySummaryApi { setToCompleted: boolean setToCallback: boolean setToUnmined: boolean setToDoubleSpend: boolean setToSending: boolean setToUnconfirmed: boolean } export interface ProvenTxReqHistory { /** * Keys are Date().toISOString() * Values are a description of what happened. */ notes?: Record<string, string> } export interface ProvenTxReqNotify { transactionIds?: number[] }