wallet-storage
Version:
BRC100 conforming wallet, wallet storage and wallet signer components
373 lines (317 loc) • 14.9 kB
text/typescript
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { Beef, Transaction as BsvTransaction, SendWithResult, SendWithResultStatus } from '@bsv/sdk'
import { asArray, asString, entity, parseTxScriptOffsets, randomBytesBase64, sdk, sha256Hash, stampLog, stampLogFormat, StorageProvider, table, TxScriptOffsets, validateStorageFeeModel, verifyId, verifyNumber, verifyOne, verifyOneOrNone, verifyTruthy } from "../../index.client";
export async function processAction(
storage: StorageProvider,
auth: sdk.AuthId,
args: sdk.StorageProcessActionArgs
)
: Promise<sdk.StorageProcessActionResults>
{
stampLog(args.log, `start storage processActionSdk`)
const userId = verifyId(auth.userId)
const r: sdk.StorageProcessActionResults = {
sendWithResults: undefined
}
let req: entity.ProvenTxReq | undefined
const txidsOfReqsToShareWithWorld: string[] = [...args.sendWith]
if (args.isNewTx) {
const vargs = await validateCommitNewTxToStorageArgs(storage, userId, args);
({ req, log: args.log } = await commitNewTxToStorage(storage, userId, vargs));
if (!req) throw new sdk.WERR_INTERNAL()
// Add the new txid to sendWith unless there are no others to send and the noSend option is set.
if (args.isNoSend && !args.isSendWith)
stampLog(args.log, `... storage processActionSdk newTx committed noSend`)
else {
txidsOfReqsToShareWithWorld.push(req.txid)
stampLog(args.log, `... storage processActionSdk newTx committed sendWith ${req.txid}`)
}
}
const swr = await shareReqsWithWorld(storage, userId, txidsOfReqsToShareWithWorld, args.isDelayed)
if (args.isSendWith) {
r.sendWithResults = swr
}
stampLog(args.log, `end storage processActionSdk`)
return r
}
/**
* Verifies that all the txids are known reqs with ready-to-share status.
* Assigns a batch identifier and updates all the provenTxReqs.
* If not isDelayed, triggers an initial attempt to broadcast the batch and returns the results.
*
* @param storage
* @param userId
* @param txids
* @param isDelayed
*/
async function shareReqsWithWorld(storage: StorageProvider, userId: number, txids: string[], isDelayed: boolean)
: Promise<SendWithResult[]>
{
if (txids.length < 1) return []
// Collect what we know about these sendWith transaction txids from storage.
const r = await storage.getReqsAndBeefToShareWithWorld(txids, [])
// Initialize aggregate results for each txid
const ars: { txid: string, getReq: GetReqsAndBeefDetail, postBeef?: sdk.PostTxResultForTxid }[] = []
for (const getReq of r.details) ars.push({ txid: getReq.txid, getReq })
// Filter original txids down to reqIds that are available and need sending
const readyToSendReqs = r.details.filter(d => d.status === 'readyToSend').map(d => new entity.ProvenTxReq(d.req!))
const readyToSendReqIds = readyToSendReqs.map(r => r.id)
const transactionIds = readyToSendReqs.map(r => r.notify.transactionIds || []).flat()
// If there are reqs to send, verify that we have a valid aggregate beef for them.
// If isDelayed, this (or a different beef) will have to be rebuilt at the time of sending.
if (readyToSendReqs.length > 0) {
const beefIsValid = await r.beef.verify(await storage.getServices().getChainTracker())
if (!beefIsValid)
throw new sdk.WERR_INTERNAL(`merged Beef failed validation.`)
}
// Set req batch property for the reqs being sent
// If delayed, also bump status to 'unsent' and we're done here
const batch = txids.length > 1 ? randomBytesBase64(16) : undefined
if (isDelayed) {
// Just bump the req status to 'unsent' to enable background sending...
if (readyToSendReqIds.length > 0) {
await storage.transaction(async trx => {
await storage.updateProvenTxReq(readyToSendReqIds, { status: 'unsent', batch }, trx)
await storage.updateTransaction(transactionIds, { status: 'sending' }, trx)
})
}
return createSendWithResults();
}
if (readyToSendReqIds.length < 1) {
return createSendWithResults();
}
if (batch) {
// Keep batch values in sync...
for (const req of readyToSendReqs) req.batch = batch
await storage.updateProvenTxReq(readyToSendReqIds, { batch })
}
//
// Handle the NON-DELAYED-SEND-NOW case
//
const prtn = await storage.attemptToPostReqsToNetwork(readyToSendReqs)
// merge the individual PostBeefResultForTxid results to postBeef in aggregate results.
for (const ar of ars) {
const d = prtn.details.find(d => d.txid === ar.txid)
if (d) {
ar.postBeef = d.pbrft
}
}
const rs = createSendWithResults();
return rs
function createSendWithResults(): SendWithResult[] {
const rs: SendWithResult[] = []
for (const ar of ars) {
let status: SendWithResultStatus = 'failed';
if (ar.getReq.status === 'alreadySent')
status = 'unproven';
else if (ar.getReq.status === 'readyToSend' && (isDelayed || ar.postBeef?.status === 'success'))
status = 'sending';
rs.push({
txid: ar.txid,
status
});
}
return rs
}
}
interface ReqTxStatus { req: sdk.ProvenTxReqStatus, tx: sdk.TransactionStatus }
interface ValidCommitNewTxToStorageArgs {
// validated input args
reference: string,
txid: string,
rawTx: number[],
isNoSend: boolean,
isDelayed: boolean,
isSendWith: boolean
log?: string
// validated dependent args
tx: BsvTransaction
txScriptOffsets: TxScriptOffsets
transactionId: number
transaction: table.Transaction
inputOutputs: table.Output[]
outputOutputs: table.Output[]
commission: table.Commission | undefined
beef: Beef
req: entity.ProvenTxReq
outputUpdates: { id: number, update: Partial<table.Output> }[]
transactionUpdate: Partial<table.Transaction>
postStatus?: ReqTxStatus
}
async function validateCommitNewTxToStorageArgs(storage: StorageProvider, userId: number, params: sdk.StorageProcessActionArgs)
: Promise<ValidCommitNewTxToStorageArgs>
{
if (!params.reference || !params.txid || !params.rawTx)
throw new sdk.WERR_INVALID_OPERATION('One or more expected params are undefined.')
let tx: BsvTransaction
try {
tx = BsvTransaction.fromBinary(params.rawTx)
} catch (e: unknown) {
throw new sdk.WERR_INVALID_OPERATION('Parsing serialized transaction failed.')
}
if (params.txid !== tx.id('hex'))
throw new sdk.WERR_INVALID_OPERATION(`Hash of serialized transaction doesn't match expected txid`)
if (!(await storage.getServices()).nLockTimeIsFinal(tx)) {
throw new sdk.WERR_INVALID_OPERATION(`This transaction is not final.
Ensure that the transaction meets the rules for being a finalized
which can be found at https://wiki.bitcoinsv.io/index.php/NLocktime_and_nSequence`)
}
const txScriptOffsets = parseTxScriptOffsets(params.rawTx)
const transaction = verifyOne(await storage.findTransactions({ partial: { userId, reference: params.reference } }))
if (!transaction.isOutgoing) throw new sdk.WERR_INVALID_OPERATION('isOutgoing is not true')
if (!transaction.inputBEEF) throw new sdk.WERR_INVALID_OPERATION()
const beef = Beef.fromBinary(asArray(transaction.inputBEEF))
// TODO: Could check beef validates transaction inputs...
// Transaction must have unsigned or unprocessed status
if (transaction.status !== 'unsigned' && transaction.status !== 'unprocessed')
throw new sdk.WERR_INVALID_OPERATION(`invalid transaction status ${transaction.status}`)
const transactionId = verifyId(transaction.transactionId)
const outputOutputs = await storage.findOutputs({ partial: { userId, transactionId } })
const inputOutputs = await storage.findOutputs({ partial: { userId, spentBy: transactionId } })
const commission = verifyOneOrNone(await storage.findCommissions({ partial: { transactionId, userId } }))
if (storage.commissionSatoshis > 0) {
// A commission is required...
if (!commission) throw new sdk.WERR_INTERNAL()
const commissionValid = tx.outputs
.some(x => x.satoshis === commission.satoshis && x.lockingScript.toHex() === asString(commission.lockingScript!))
if (!commissionValid)
throw new sdk.WERR_INVALID_OPERATION('Transaction did not include an output to cover service fee.')
}
const req = entity.ProvenTxReq.fromTxid(params.txid, params.rawTx, transaction.inputBEEF)
req.addNotifyTransactionId(transactionId)
// "Processing" a transaction is the final step of creating a new one.
// If it is to be sent to the network directly (prior to return from processAction),
// then there is status pre-send and post-send.
// Otherwise there is no post-send status.
// Note that isSendWith trumps isNoSend, e.g. isNoSend && !isSendWith
//
// Determine what status the req and transaction should have pre- at the end of processing.
// Pre-Status (to newReq/newTx) Post-Status (to all sent reqs/txs)
// req tx req tx
// isNoSend noSend noSend
// !isNoSend && isDelayed unsent unprocessed
// !isNoSend && !isDelayed unprocessed unprocessed sending/unmined sending/unproven This is the only case that sends immediately.
let postStatus: ReqTxStatus | undefined = undefined
let status: ReqTxStatus
if (params.isNoSend && !params.isSendWith)
status = { req: 'nosend', tx: 'nosend' }
else if (!params.isNoSend && params.isDelayed)
status = { req: 'unsent', tx: 'unprocessed'}
else if (!params.isNoSend && !params.isDelayed) {
status = { req: 'unprocessed', tx: 'unprocessed' }
postStatus = { req: 'unmined', tx: 'unproven' }
} else
throw new sdk.WERR_INTERNAL('logic error')
req.status = status.req
const vargs: ValidCommitNewTxToStorageArgs = {
reference: params.reference,
txid: params.txid,
rawTx: params.rawTx,
isSendWith: !!params.sendWith && params.sendWith.length > 0,
isDelayed: params.isDelayed,
isNoSend: params.isNoSend,
// Properties with values added during validation.
tx,
txScriptOffsets,
transactionId,
transaction,
inputOutputs,
outputOutputs,
commission,
beef,
req,
outputUpdates: [],
// update txid, status in transactions table and drop rawTransaction value
transactionUpdate: {
txid: params.txid,
rawTx: undefined,
inputBEEF: undefined,
status: status.tx
},
postStatus
}
// update outputs with txid, script offsets and lengths, drop long output scripts from outputs table
// outputs spendable will be updated for change to true and all others to !!o.tracked when tx has been broadcast
// MAX_OUTPUTSCRIPT_LENGTH is limit for scripts left in outputs table
for (const o of vargs.outputOutputs) {
const vout = verifyTruthy(o.vout)
const offset = vargs.txScriptOffsets.outputs[vout]
const rawTxScript = asString(vargs.rawTx.slice(offset.offset, offset.offset + offset.length))
if (o.lockingScript && rawTxScript !== asString(o.lockingScript))
throw new sdk.WERR_INVALID_OPERATION(`rawTx output locking script for vout ${vout} not equal to expected output script.`)
if (tx.outputs[vout].lockingScript.toHex() !== rawTxScript)
throw new sdk.WERR_INVALID_OPERATION(`parsed transaction output locking script for vout ${vout} not equal to expected output script.`)
const update: Partial<table.Output> = {
txid: vargs.txid,
spendable: true, // spendability is gated by transaction status. Remains true until the output is spent.
scriptLength: offset.length,
scriptOffset: offset.offset,
}
if (offset.length > (await storage.getSettings()).maxOutputScript)
// Remove long lockingScript data from outputs table, will be read from rawTx in proven_tx or proven_tx_reqs tables.
update.lockingScript = undefined
vargs.outputUpdates.push({ id: o.outputId!, update })
}
return vargs
}
export interface CommitNewTxResults {
req: entity.ProvenTxReq
log?: string
}
async function commitNewTxToStorage(
storage: StorageProvider,
userId: number,
vargs: ValidCommitNewTxToStorageArgs,
)
: Promise<CommitNewTxResults>
{
let log = vargs.log
log = stampLog(log, `start storage commitNewTxToStorage`)
let req: entity.ProvenTxReq | undefined
await storage.transaction(async trx => {
log = stampLog(log, `... storage commitNewTxToStorage storage transaction start`)
// Create initial 'nosend' proven_tx_req record to store signed, valid rawTx and input beef
req = await vargs.req.insertOrMerge(storage, trx)
log = stampLog(log, `... storage commitNewTxToStorage req inserted`)
for (const ou of vargs.outputUpdates) {
await storage.updateOutput(ou.id, ou.update, trx)
}
log = stampLog(log, `... storage commitNewTxToStorage outputs updated`)
await storage.updateTransaction(vargs.transactionId, vargs.transactionUpdate, trx)
log = stampLog(log, `... storage commitNewTxToStorage storage transaction end`)
})
log = stampLog(log, `... storage commitNewTxToStorage storage transaction await done`)
const r: CommitNewTxResults = {
req: verifyTruthy(req),
log
}
log = stampLog(log, `end storage commitNewTxToStorage`)
return r
}
export interface GetReqsAndBeefDetail {
txid: string,
req?: table.ProvenTxReq,
proven?: table.ProvenTx,
status: 'readyToSend' | 'alreadySent' | 'error' | 'unknown',
error?: string
}
export interface GetReqsAndBeefResult {
beef: Beef,
details: GetReqsAndBeefDetail[]
}
export interface PostBeefResultForTxidApi {
txid: string
/**
* 'success' - The transaction was accepted for processing
*/
status: 'success' | 'error'
/**
* if true, the transaction was already known to this service. Usually treat as a success.
*
* Potentially stop posting to additional transaction processors.
*/
alreadyKnown?: boolean
blockHash?: string
blockHeight?: number
merklePath?: string
}