All files / src/storage/methods processAction.ts

75.71% Statements 106/140
46.87% Branches 30/64
80% Functions 12/15
76.98% Lines 97/126

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373  57x 57x   57x               7x   7x 7x         7x   7x 6x 6x 6x   6x 6x             7x   7x 1x     7x   7x                               7x     1x     1x 2x     2x 2x 2x       1x 1x 1x           1x 1x                     1x       1x   2x 1x           1x   1x 3x 2x 2x       1x   1x     1x 1x 2x 2x   2x 2x 2x         1x                                                                         6x     6x 6x       6x   6x         6x 6x 6x 6x 6x     6x   6x 6x 6x   6x 6x                 6x 6x                           6x   6x 6x                 6x 6x                                                             6x 13x 13x 13x 13x   13x   13x           13x     13x     6x                             6x   6x       6x 6x     6x   6x   6x 13x     6x   6x   6x     6x   6x         6x   6x                                                                      
// 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 dojo 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));
        Iif (!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, `... dojo processActionSdk newTx committed noSend`)
        else E{
            txidsOfReqsToShareWithWorld.push(req.txid)
            stampLog(args.log, `... dojo 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 dojo 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())
        Iif (!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
    Iif (isDelayed) {
        // Just bump the req status to 'unsent' to enable background sending...
        Iif (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();
    }
 
    Iif (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';
            Iif (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>
{
    Iif (!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.')
    }
    Iif (params.txid !== tx.id('hex'))
        throw new sdk.WERR_INVALID_OPERATION(`Hash of serialized transaction doesn't match expected txid`)
    Iif (!(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 } }))
    Iif (!transaction.isOutgoing) throw new sdk.WERR_INVALID_OPERATION('isOutgoing is not true')
    Iif (!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
    Iif (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 } }))
    Iif (storage.commissionSatoshis > 0) {
        // A commission is required...
        Iif (!commission) throw new sdk.WERR_INTERNAL()
        const commissionValid = tx.outputs
            .some(x => x.satoshis === commission.satoshis && x.lockingScript.toHex() === asString(commission.lockingScript!))
        Iif (!commissionValid)
            throw new sdk.WERR_INVALID_OPERATION('Transaction did not include an output to cover dojo 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 Eif (!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 dojo/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))
        Iif (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.`)
        Iif (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,
        }
        Iif (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 DojoCommitNewTxResults {
   req: entity.ProvenTxReq
   log?: string
}
 
async function commitNewTxToStorage(
    storage: StorageProvider,
    userId: number,
    vargs: ValidCommitNewTxToStorageArgs,
)
: Promise<DojoCommitNewTxResults>
{
    let log = vargs.log
 
    log = stampLog(log, `start dojo commitNewTxToStorage`)
 
    let req: entity.ProvenTxReq | undefined
 
    await storage.transaction(async trx => {
        log = stampLog(log, `... dojo 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, `... dojo commitNewTxToStorage req inserted`)
 
        for (const ou of vargs.outputUpdates) {
            await storage.updateOutput(ou.id, ou.update, trx)
        }
 
        log = stampLog(log, `... dojo commitNewTxToStorage outputs updated`)
 
        await storage.updateTransaction(vargs.transactionId, vargs.transactionUpdate, trx)
 
        log = stampLog(log, `... dojo commitNewTxToStorage storage transaction end`)
    })
 
    log = stampLog(log, `... dojo commitNewTxToStorage storage transaction await done`)
 
    const r: DojoCommitNewTxResults = {
        req: verifyTruthy(req),
        log
    }
 
    log = stampLog(log, `end dojo 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
}