All files / src/storage/methods processActionSdk.ts

70.71% Statements 99/140
40.62% Branches 26/64
84.61% Functions 11/13
72.65% Lines 93/128

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  28x 28x   28x       3x   3x 3x         3x   3x 2x 2x 2x   2x 2x             3x   3x 1x     3x   3x                               3x     1x     1x 2x     2x 2x 2x       1x 1x 1x           1x 1x   1x 1x 1x 1x     1x                                                           1x 1x 2x 2x   2x 2x 2x         1x                                                                         2x   2x     2x 2x       2x   2x         2x 2x 2x 2x 2x     2x   2x 2x 2x   2x 2x                 2x 2x                           2x   2x 2x                 2x 2x                                                             2x 3x 3x 3x 3x   3x   3x           3x     3x     2x                             2x   2x   2x       2x 2x     2x   2x   2x 3x     2x   2x   2x     2x   2x         2x   2x                                                                      
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import * as bsv from '@bsv/sdk'
import { asArray, asString, entity, parseTxScriptOffsets, randomBytesBase64, sdk, sha256Hash, stampLog, stampLogFormat, StorageBase, table, TxScriptOffsets, validateStorageFeeModel, verifyId, verifyNumber, verifyOne, verifyOneOrNone, verifyTruthy } from "../..";
 
export async function processActionSdk(dojo: StorageBase, params: sdk.StorageProcessActionSdkParams, originator?: sdk.OriginatorDomainNameStringUnder250Bytes)
: Promise<sdk.StorageProcessActionSdkResults>
{
 
    stampLog(params.log, `start dojo processActionSdk`)
 
    const userId = params.userId
    const r: sdk.StorageProcessActionSdkResults = {
        sendWithResults: undefined
    }
 
    let req: entity.ProvenTxReq | undefined
    const txidsOfReqsToShareWithWorld: string[] = [...params.sendWith]
 
    if (params.isNewTx) {
        const vargs = await validateCommitNewTxToStorageArgs(dojo, userId, params);
        ({ req, log: params.log } = await commitNewTxToStorage(dojo, 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 (params.isNoSend && !params.isSendWith)
            stampLog(params.log, `... dojo processActionSdk newTx committed noSend`)
        else E{
            txidsOfReqsToShareWithWorld.push(req.txid)
            stampLog(params.log, `... dojo processActionSdk newTx committed sendWith ${req.txid}`)
        }
    }
 
    const swr = await shareReqsWithWorld(dojo, userId, txidsOfReqsToShareWithWorld, params.isDelayed)
 
    if (params.isSendWith) {
        r.sendWithResults = swr
    }
 
    stampLog(params.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 dojo 
 * @param userId 
 * @param txids 
 * @param isDelayed 
 */
async function shareReqsWithWorld(dojo: StorageBase, userId: number, txids: string[], isDelayed: boolean)
: Promise<sdk.SendWithResult[]>
{
    if (txids.length < 1) return []
 
    // Collect what we know about these sendWith transaction txids from storage.
    const r = await dojo.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 dojo.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
    if (isDelayed) {
        // Just bump the req status to 'unsent' to enable background sending...
        if (readyToSendReqIds.length > 0) {
            await dojo.transaction(async trx => {
                await dojo.updateProvenTxReq(readyToSendReqIds, { status: 'unsent', batch }, trx)
                await dojo.updateTransaction(transactionIds, { status: 'sending' }, trx)
            })
        }
        return createSendWithResults();
    }
 
    Iif (readyToSendReqIds.length < 1) {
        return createSendWithResults();
    }
 
    Iif (batch) {
        // Keep batch values in sync...
        for (const req of readyToSendReqs) req.batch = batch
        await dojo.updateProvenTxReq(readyToSendReqIds, { batch })
    }
 
    //
    // Handle the NON-DELAYED-SEND-NOW case
    //
    const prtn = await dojo.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)
        Iif (d) {
            ar.postBeef = d.pbrft
        }
    }
 
    const rs = createSendWithResults();
 
    return rs
 
    function createSendWithResults(): sdk.SendWithResult[] {
        const rs: sdk.SendWithResult[] = []
        for (const ar of ars) {
            let status: sdk.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: bsv.Transaction
    txScriptOffsets: TxScriptOffsets
    transactionId: number
    transaction: table.Transaction
    inputOutputs: table.Output[]
    outputOutputs: table.Output[]
    commission: table.Commission | undefined
    beef: bsv.Beef
 
    req: entity.ProvenTxReq
    outputUpdates: { id: number, update: Partial<table.Output> }[]
    transactionUpdate: Partial<table.Transaction>
    postStatus?: ReqTxStatus
}
 
async function validateCommitNewTxToStorageArgs(dojo: StorageBase, userId: number, params: sdk.StorageProcessActionSdkParams)
: Promise<ValidCommitNewTxToStorageArgs>
{
    const storage = dojo
 
    Iif (!params.reference || !params.txid || !params.rawTx)
        throw new sdk.WERR_INVALID_OPERATION('One or more expected params are undefined.')
    let tx: bsv.Transaction
    try {
        tx = bsv.Transaction.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 dojo.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 = bsv.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 (dojo.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 dojo.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(
    dojo: StorageBase,
    userId: number,
    vargs: ValidCommitNewTxToStorageArgs,
)
: Promise<DojoCommitNewTxResults>
{
    let log = vargs.log
 
    log = stampLog(log, `start dojo commitNewTxToStorage`)
 
    const storage = dojo
 
    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: bsv.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
}