wallet-storage
Version:
BRC100 conforming wallet, wallet storage and wallet signer components
854 lines (799 loc) • 25.6 kB
text/typescript
import {
Transaction as BsvTransaction,
AbortActionResult,
Beef,
InternalizeActionArgs,
InternalizeActionResult,
ListActionsArgs,
ListActionsResult,
ListOutputsArgs,
ListOutputsResult,
PubKeyHex,
ListCertificatesResult,
TrustSelf,
RelinquishCertificateArgs,
RelinquishOutputArgs
} from '@bsv/sdk'
import {
asArray,
asString,
entity,
sdk,
table,
verifyId,
verifyOne,
verifyOneOrNone,
verifyTruthy
} from '../index.client'
import { getBeefForTransaction } from './methods/getBeefForTransaction'
import {
GetReqsAndBeefDetail,
GetReqsAndBeefResult,
processAction
} from './methods/processAction'
import {
attemptToPostReqsToNetwork,
PostReqsToNetworkResult
} from './methods/attemptToPostReqsToNetwork'
import { listCertificates } from './methods/listCertificates'
import { createAction } from './methods/createAction'
import { internalizeAction } from './methods/internalizeAction'
import {
StorageReaderWriter,
StorageReaderWriterOptions
} from './StorageReaderWriter'
import { ProvenTxReq } from './schema/entities'
export abstract class StorageProvider
extends StorageReaderWriter
implements sdk.WalletStorageProvider
{
isDirty = false
_services?: sdk.WalletServices
feeModel: sdk.StorageFeeModel
commissionSatoshis: number
commissionPubKeyHex?: PubKeyHex
maxRecursionDepth?: number
static defaultOptions() {
return {
feeModel: <sdk.StorageFeeModel>{ model: 'sat/kb', value: 1 },
commissionSatoshis: 0,
commissionPubKeyHex: undefined
}
}
static createStorageBaseOptions(chain: sdk.Chain): StorageProviderOptions {
const options: StorageProviderOptions = {
...StorageProvider.defaultOptions(),
chain
}
return options
}
constructor(options: StorageProviderOptions) {
super(options)
this.feeModel = options.feeModel
this.commissionPubKeyHex = options.commissionPubKeyHex
this.commissionSatoshis = options.commissionSatoshis
}
abstract reviewStatus(args: {
agedLimit: Date
trx?: sdk.TrxToken
}): Promise<{ log: string }>
abstract purgeData(
params: sdk.PurgeParams,
trx?: sdk.TrxToken
): Promise<sdk.PurgeResults>
abstract allocateChangeInput(
userId: number,
basketId: number,
targetSatoshis: number,
exactSatoshis: number | undefined,
excludeSending: boolean,
transactionId: number
): Promise<table.Output | undefined>
abstract getProvenOrRawTx(
txid: string,
trx?: sdk.TrxToken
): Promise<sdk.ProvenOrRawTx>
abstract getRawTxOfKnownValidTransaction(
txid?: string,
offset?: number,
length?: number,
trx?: sdk.TrxToken
): Promise<number[] | undefined>
abstract getLabelsForTransactionId(
transactionId?: number,
trx?: sdk.TrxToken
): Promise<table.TxLabel[]>
abstract getTagsForOutputId(
outputId: number,
trx?: sdk.TrxToken
): Promise<table.OutputTag[]>
abstract listActions(
auth: sdk.AuthId,
args: sdk.ValidListActionsArgs
): Promise<ListActionsResult>
abstract listOutputs(
auth: sdk.AuthId,
args: sdk.ValidListOutputsArgs
): Promise<ListOutputsResult>
abstract countChangeInputs(
userId: number,
basketId: number,
excludeSending: boolean
): Promise<number>
abstract findCertificatesAuth(
auth: sdk.AuthId,
args: sdk.FindCertificatesArgs
): Promise<table.Certificate[]>
abstract findOutputBasketsAuth(
auth: sdk.AuthId,
args: sdk.FindOutputBasketsArgs
): Promise<table.OutputBasket[]>
abstract findOutputsAuth(
auth: sdk.AuthId,
args: sdk.FindOutputsArgs
): Promise<table.Output[]>
abstract insertCertificateAuth(
auth: sdk.AuthId,
certificate: table.CertificateX
): Promise<number>
override isStorageProvider(): boolean {
return true
}
setServices(v: sdk.WalletServices) {
this._services = v
}
getServices(): sdk.WalletServices {
if (!this._services)
throw new sdk.WERR_INVALID_OPERATION('Must setServices first.')
return this._services
}
async abortAction(
auth: sdk.AuthId,
args: Partial<table.Transaction>
): Promise<AbortActionResult> {
const r = await this.transaction(async trx => {
const tx = verifyOneOrNone(
await this.findTransactions({ partial: args, noRawTx: true, trx })
)
const unAbortableStatus: sdk.TransactionStatus[] = [
'completed',
'failed',
'sending',
'unproven'
]
if (
!tx ||
!tx.isOutgoing ||
-1 < unAbortableStatus.findIndex(s => s === tx.status)
)
throw new sdk.WERR_INVALID_PARAMETER(
'reference',
'an inprocess, outgoing action that has not been signed and shared to the network.'
)
await this.updateTransactionStatus(
'failed',
tx.transactionId,
undefined,
args.reference,
trx
)
if (tx.txid) {
const req = await entity.ProvenTxReq.fromStorageTxid(this, tx.txid, trx)
if (req) {
req.addHistoryNote({ what: 'aborted' })
req.status = 'invalid'
await req.updateStorageDynamicProperties(this, trx)
}
}
const r: AbortActionResult = {
aborted: true
}
return r
})
return r
}
async internalizeAction(
auth: sdk.AuthId,
args: InternalizeActionArgs
): Promise<InternalizeActionResult> {
return await internalizeAction(this, auth, args)
}
/**
* Given an array of transaction txids with current ProvenTxReq ready-to-share status,
* lookup their DojoProvenTxReqApi req records.
* For the txids with reqs and status still ready to send construct a single merged beef.
*
* @param txids
* @param knownTxids
* @param trx
*/
async getReqsAndBeefToShareWithWorld(
txids: string[],
knownTxids: string[],
trx?: sdk.TrxToken
): Promise<GetReqsAndBeefResult> {
const r: GetReqsAndBeefResult = {
beef: new Beef(),
details: []
}
for (const txid of txids) {
const d: GetReqsAndBeefDetail = {
txid,
status: 'unknown'
}
r.details.push(d)
try {
d.proven = verifyOneOrNone(
await this.findProvenTxs({ partial: { txid }, trx })
)
if (d.proven) d.status = 'alreadySent'
else {
const alreadySentStatus = [
'unmined',
'callback',
'unconfirmed',
'completed'
]
const readyToSendStatus = [
'sending',
'unsent',
'nosend',
'unprocessed'
]
const errorStatus = ['unknown', 'nonfinal', 'invalid', 'doubleSpend']
d.req = verifyOneOrNone(
await this.findProvenTxReqs({ partial: { txid }, trx })
)
if (!d.req) {
d.status = 'error'
d.error = `ERR_UNKNOWN_TXID: ${txid} was not found.`
} else if (errorStatus.indexOf(d.req.status) > -1) {
d.status = 'error'
d.error = `ERR_INVALID_PARAMETER: ${txid} is not ready to send.`
} else if (alreadySentStatus.indexOf(d.req.status) > -1) {
d.status = 'alreadySent'
} else if (readyToSendStatus.indexOf(d.req.status) > -1) {
if (!d.req.rawTx || !d.req.inputBEEF) {
d.status = 'error'
d.error = `ERR_INTERNAL: ${txid} req is missing rawTx or beef.`
} else d.status = 'readyToSend'
} else {
d.status = 'error'
d.error = `ERR_INTERNAL: ${txid} has unexpected req status ${d.req.status}`
}
if (d.status === 'readyToSend') {
await this.mergeReqToBeefToShareExternally(
d.req!,
r.beef,
knownTxids,
trx
)
}
}
} catch (eu: unknown) {
const e = sdk.WalletError.fromUnknown(eu)
d.error = `${e.name}: ${e.message}`
}
}
return r
}
async mergeReqToBeefToShareExternally(
req: table.ProvenTxReq,
mergeToBeef: Beef,
knownTxids: string[],
trx?: sdk.TrxToken
): Promise<void> {
const { rawTx, inputBEEF: beef } = req
if (!rawTx || !beef)
throw new sdk.WERR_INTERNAL(`req rawTx and beef must be valid.`)
mergeToBeef.mergeRawTx(asArray(rawTx))
mergeToBeef.mergeBeef(asArray(beef))
const tx = BsvTransaction.fromBinary(asArray(rawTx))
for (const input of tx.inputs) {
if (!input.sourceTXID)
throw new sdk.WERR_INTERNAL(
`req all transaction inputs must have valid sourceTXID`
)
const txid = input.sourceTXID
const btx = mergeToBeef.findTxid(txid)
if (!btx) {
if (knownTxids && knownTxids.indexOf(txid) > -1)
mergeToBeef.mergeTxidOnly(txid)
else
await this.getValidBeefForKnownTxid(
txid,
mergeToBeef,
undefined,
knownTxids,
trx
)
}
}
}
/**
* Checks if txid is a known valid ProvenTx and returns it if found.
* Next checks if txid is a current ProvenTxReq and returns that if found.
* If `newReq` is provided and an existing ProvenTxReq isn't found,
* use `newReq` to create a new ProvenTxReq.
*
* This is safe "findOrInsert" operation using retry if unique index constraint
* is violated by a race condition insert.
*
* @param txid
* @param newReq
* @param trx
* @returns
*/
async getProvenOrReq(
txid: string,
newReq?: table.ProvenTxReq,
trx?: sdk.TrxToken
): Promise<sdk.StorageProvenOrReq> {
if (newReq && txid !== newReq.txid)
throw new sdk.WERR_INVALID_PARAMETER('newReq', `same txid`)
const r: sdk.StorageProvenOrReq = { proven: undefined, req: undefined }
r.proven = verifyOneOrNone(
await this.findProvenTxs({ partial: { txid }, trx })
)
if (r.proven) return r
for (let retry = 0; ; retry++) {
try {
r.req = verifyOneOrNone(
await this.findProvenTxReqs({ partial: { txid }, trx })
)
if (!r.req && !newReq) break
if (!r.req && newReq) {
await this.insertProvenTxReq(newReq, trx)
}
if (r.req && newReq) {
// Merge history and notify into existing
const req1 = new ProvenTxReq(r.req)
req1.mergeHistory(newReq, undefined, true)
req1.mergeNotifyTransactionIds(newReq)
await req1.updateStorageDynamicProperties(this, trx)
}
break
} catch (eu: unknown) {
if (retry > 0) throw eu
}
}
return r
}
async updateTransactionsStatus(
transactionIds: number[],
status: sdk.TransactionStatus
): Promise<void> {
await this.transaction(async trx => {
for (const id of transactionIds) {
await this.updateTransactionStatus(
status,
id,
undefined,
undefined,
trx
)
}
})
}
/**
* For all `status` values besides 'failed', just updates the transaction records status property.
*
* For 'status' of 'failed', attempts to make outputs previously allocated as inputs to this transaction usable again.
*
* @throws ERR_DOJO_COMPLETED_TX if current status is 'completed' and new status is not 'completed.
* @throws ERR_DOJO_PROVEN_TX if transaction has proof or provenTxId and new status is not 'completed'.
*
* @param status
* @param transactionId
* @param userId
* @param reference
* @param trx
*/
async updateTransactionStatus(
status: sdk.TransactionStatus,
transactionId?: number,
userId?: number,
reference?: string,
trx?: sdk.TrxToken
): Promise<void> {
if (!transactionId && !(userId && reference))
throw new sdk.WERR_MISSING_PARAMETER(
'either transactionId or userId and reference'
)
await this.transaction(async trx => {
const where: Partial<table.Transaction> = {}
if (transactionId) where.transactionId = transactionId
if (userId) where.userId = userId
if (reference) where.reference = reference
const tx = verifyOne(
await this.findTransactions({ partial: where, noRawTx: true, trx })
)
//if (tx.status === status)
// no change required. Assume inputs and outputs spendable and spentBy are valid for status.
//return
// Once completed, this method cannot be used to "uncomplete" transaction.
if (
(status !== 'completed' && tx.status === 'completed') ||
tx.provenTxId
)
throw new sdk.WERR_INVALID_OPERATION(
'The status of a "completed" transaction cannot be changed.'
)
// It is not possible to un-fail a transaction. Information is lost and not recoverable.
if (status !== 'failed' && tx.status === 'failed')
throw new sdk.WERR_INVALID_OPERATION(
`A "failed" transaction may not be un-failed by this method.`
)
switch (status) {
case 'failed':
{
// Attempt to make outputs previously allocated as inputs to this transaction usable again.
// Only clear input's spentBy and reset spendable = true if it references this transaction
const t = new entity.Transaction(tx)
const inputs = await t.getInputs(this, trx)
for (const input of inputs) {
// input is a prior output belonging to userId that reference this transaction either by `spentBy`
// or by txid and vout.
await this.updateOutput(
verifyId(input.outputId),
{ spendable: true, spentBy: undefined },
trx
)
}
}
break
case 'nosend':
case 'unsigned':
case 'unprocessed':
case 'sending':
case 'unproven':
case 'completed':
break
default:
throw new sdk.WERR_INVALID_PARAMETER('status', `not be ${status}`)
}
await this.updateTransaction(tx.transactionId, { status }, trx)
}, trx)
}
async createAction(
auth: sdk.AuthId,
args: sdk.ValidCreateActionArgs
): Promise<sdk.StorageCreateActionResult> {
if (!auth.userId) throw new sdk.WERR_UNAUTHORIZED()
return await createAction(this, auth, args)
}
async processAction(
auth: sdk.AuthId,
args: sdk.StorageProcessActionArgs
): Promise<sdk.StorageProcessActionResults> {
if (!auth.userId) throw new sdk.WERR_UNAUTHORIZED()
return await processAction(this, auth, args)
}
async attemptToPostReqsToNetwork(
reqs: entity.ProvenTxReq[],
trx?: sdk.TrxToken
): Promise<PostReqsToNetworkResult> {
return await attemptToPostReqsToNetwork(this, reqs, trx)
}
async listCertificates(
auth: sdk.AuthId,
args: sdk.ValidListCertificatesArgs
): Promise<ListCertificatesResult> {
return await listCertificates(this, auth, args)
}
async verifyKnownValidTransaction(
txid: string,
trx?: sdk.TrxToken
): Promise<boolean> {
const { proven, rawTx } = await this.getProvenOrRawTx(txid, trx)
return proven != undefined || rawTx != undefined
}
async getValidBeefForKnownTxid(
txid: string,
mergeToBeef?: Beef,
trustSelf?: TrustSelf,
knownTxids?: string[],
trx?: sdk.TrxToken
): Promise<Beef> {
const beef = await this.getValidBeefForTxid(
txid,
mergeToBeef,
trustSelf,
knownTxids,
trx
)
if (!beef)
throw new sdk.WERR_INVALID_PARAMETER(
'txid',
`${txid} is not known to storage.`
)
return beef
}
async getValidBeefForTxid(
txid: string,
mergeToBeef?: Beef,
trustSelf?: TrustSelf,
knownTxids?: string[],
trx?: sdk.TrxToken
): Promise<Beef | undefined> {
const beef = mergeToBeef || new Beef()
const r = await this.getProvenOrRawTx(txid, trx)
if (r.proven) {
if (trustSelf === 'known') beef.mergeTxidOnly(txid)
else {
beef.mergeRawTx(r.proven.rawTx)
const mp = new entity.ProvenTx(r.proven).getMerklePath()
beef.mergeBump(mp)
return beef
}
}
if (r.rawTx && r.inputBEEF) {
if (trustSelf === 'known') beef.mergeTxidOnly(txid)
else {
beef.mergeRawTx(r.rawTx)
beef.mergeBeef(r.inputBEEF)
const tx = BsvTransaction.fromBinary(r.rawTx)
for (const input of tx.inputs) {
const btx = beef.findTxid(input.sourceTXID!)
if (!btx) {
if (knownTxids && knownTxids.indexOf(input.sourceTXID!) > -1)
beef.mergeTxidOnly(input.sourceTXID!)
else
await this.getValidBeefForKnownTxid(
input.sourceTXID!,
beef,
trustSelf,
knownTxids,
trx
)
}
}
return beef
}
}
return undefined
}
async getBeefForTransaction(
txid: string,
options: sdk.StorageGetBeefOptions
): Promise<Beef> {
return await getBeefForTransaction(this, txid, options)
}
async findMonitorEventById(
id: number,
trx?: sdk.TrxToken
): Promise<table.MonitorEvent | undefined> {
return verifyOneOrNone(
await this.findMonitorEvents({ partial: { id }, trx })
)
}
async relinquishCertificate(
auth: sdk.AuthId,
args: RelinquishCertificateArgs
): Promise<number> {
const vargs = sdk.validateRelinquishCertificateArgs(args)
const cert = verifyOne(
await this.findCertificates({
partial: {
certifier: vargs.certifier,
serialNumber: vargs.serialNumber,
type: vargs.type
}
})
)
return await this.updateCertificate(cert.certificateId, {
isDeleted: true
})
}
async relinquishOutput(
auth: sdk.AuthId,
args: RelinquishOutputArgs
): Promise<number> {
const vargs = sdk.validateRelinquishOutputArgs(args)
const { txid, vout } = sdk.parseWalletOutpoint(vargs.output)
const output = verifyOne(
await this.findOutputs({ partial: { txid, vout } })
)
return await this.updateOutput(output.outputId, { basketId: undefined })
}
async processSyncChunk(
args: sdk.RequestSyncChunkArgs,
chunk: sdk.SyncChunk
): Promise<sdk.ProcessSyncChunkResult> {
const user = verifyTruthy(
await this.findUserByIdentityKey(args.identityKey)
)
const ss = new entity.SyncState(
verifyOne(
await this.findSyncStates({
partial: {
storageIdentityKey: args.fromStorageIdentityKey,
userId: user.userId
}
})
)
)
const r = await ss.processSyncChunk(this, args, chunk)
return r
}
/**
* Handles storage changes when a valid MerklePath and mined block header are found for a ProvenTxReq txid.
*
* Performs the following storage updates (typically):
* 1. Lookup the exising `ProvenTxReq` record for its rawTx
* 2. Insert a new ProvenTx record using properties from `args` and rawTx, yielding a new provenTxId
* 3. Update ProvenTxReq record with status 'completed' and new provenTxId value (and history of status changed)
* 4. Unpack notify transactionIds from req and update each transaction's status to 'completed', provenTxId value.
* 5. Update ProvenTxReq history again to record that transactions have been notified.
* 6. Return results...
*
* Alterations of "typically" to handle:
*/
async updateProvenTxReqWithNewProvenTx(
args: sdk.UpdateProvenTxReqWithNewProvenTxArgs
): Promise<sdk.UpdateProvenTxReqWithNewProvenTxResult> {
const req = await entity.ProvenTxReq.fromStorageId(this, args.provenTxReqId)
let proven: entity.ProvenTx
if (req.provenTxId) {
// Someone beat us to it, grab what we need for results...
proven = new entity.ProvenTx(
verifyOne(await this.findProvenTxs({ partial: { txid: args.txid } }))
)
} else {
let isNew: boolean
;({ proven, isNew } = await this.transaction(async trx => {
const { proven: api, isNew } = await this.findOrInsertProvenTx(
{
created_at: new Date(),
updated_at: new Date(),
provenTxId: 0,
txid: args.txid,
height: args.height,
index: args.index,
merklePath: args.merklePath,
rawTx: req.rawTx,
blockHash: args.blockHash,
merkleRoot: args.merkleRoot
},
trx
)
proven = new entity.ProvenTx(api)
if (isNew) {
req.status = 'completed'
req.provenTxId = proven.provenTxId
await req.updateStorageDynamicProperties(this, trx)
// upate the transaction notifications outside of storage transaction....
}
return { proven, isNew }
}))
if (isNew) {
const ids = req.notify.transactionIds || []
if (ids.length > 0) {
for (const id of ids) {
try {
await this.updateTransaction(id, {
provenTxId: proven.provenTxId,
status: 'completed'
})
req.addHistoryNote(`transaction ${id} notified of ProvenTx`)
} catch (eu: unknown) {
const e = sdk.WalletError.fromUnknown(eu)
req.addHistoryNote({
what: 'transactionNotificationFailure',
error: `${e.code}: ${e.description}`
})
}
}
await req.updateStorageDynamicProperties(this)
}
}
}
const r: sdk.UpdateProvenTxReqWithNewProvenTxResult = {
status: req.status,
history: req.apiHistory,
provenTxId: proven.provenTxId
}
return r
}
/**
* For each spendable output in the 'default' basket of the authenticated user,
* verify that the output script, satoshis, vout and txid match that of an output
* still in the mempool of at least one service provider.
*
* @returns object with invalidSpendableOutputs array. A good result is an empty array.
*/
async confirmSpendableOutputs(): Promise<{
invalidSpendableOutputs: table.Output[]
}> {
const invalidSpendableOutputs: table.Output[] = []
const users = await this.findUsers({ partial: {} })
for (const { userId } of users) {
const defaultBasket = verifyOne(
await this.findOutputBaskets({ partial: { userId, name: 'default' } })
)
const where: Partial<table.Output> = {
userId,
basketId: defaultBasket.basketId,
spendable: true
}
const outputs = await this.findOutputs({ partial: where })
for (let i = outputs.length - 1; i >= 0; i--) {
const o = outputs[i]
const oid = verifyId(o.outputId)
if (o.spendable) {
let ok = false
if (o.lockingScript && o.lockingScript.length > 0) {
const r = await this.getServices().getUtxoStatus(
asString(o.lockingScript),
'script'
)
if (r.status === 'success' && r.isUtxo && r.details?.length > 0) {
const tx = await this.findTransactionById(o.transactionId)
if (
tx &&
tx.txid &&
r.details.some(
d =>
d.txid === tx.txid &&
d.satoshis === o.satoshis &&
d.index === o.vout
)
) {
ok = true
}
}
}
if (!ok) invalidSpendableOutputs.push(o)
}
}
}
return { invalidSpendableOutputs }
}
async updateProvenTxReqDynamics(
id: number,
update: Partial<table.ProvenTxReqDynamics>,
trx?: sdk.TrxToken
): Promise<number> {
const partial: Partial<table.ProvenTxReq> = {}
if (update['updated_at']) partial['updated_at'] = update['updated_at']
if (update['provenTxId']) partial['provenTxId'] = update['provenTxId']
if (update['status']) partial['status'] = update['status']
if (update['attempts']) partial['attempts'] = update['attempts']
if (update['notified']) partial['notified'] = update['notified']
if (update['batch']) partial['batch'] = update['batch']
if (update['history']) partial['history'] = update['history']
if (update['notify']) partial['notify'] = update['notify']
return await this.updateProvenTxReq(id, partial, trx)
}
}
export interface StorageProviderOptions extends StorageReaderWriterOptions {
chain: sdk.Chain
feeModel: sdk.StorageFeeModel
/**
* Transactions created by this Storage can charge a fee per transaction.
* A value of zero disables commission fees.
*/
commissionSatoshis: number
/**
* If commissionSatoshis is greater than zero, must be a valid public key hex string.
* The actual locking script for each commission will use a public key derived
* from this key by information stored in the commissions table.
*/
commissionPubKeyHex?: PubKeyHex
}
export function validateStorageFeeModel(
v?: sdk.StorageFeeModel
): sdk.StorageFeeModel {
const r: sdk.StorageFeeModel = {
model: 'sat/kb',
value: 1
}
if (typeof v === 'object') {
if (v.model !== 'sat/kb')
throw new sdk.WERR_INVALID_PARAMETER('StorageFeeModel.model', `"sat/kb"`)
if (typeof v.value === 'number') {
r.value = v.value
}
}
return r
}