UNPKG

@bsv/wallet-toolbox

Version:

BRC100 conforming wallet, wallet storage and wallet signer components

485 lines (435 loc) 17 kB
import { Transaction as BsvTransaction, WalletPayment, BasketInsertion, InternalizeActionArgs, TransactionOutput, Beef } from '@bsv/sdk' import { shareReqsWithWorld } from './processAction' import { StorageProvider } from '../StorageProvider' import { AuthId, StorageInternalizeActionResult } from '../../sdk/WalletStorage.interfaces' import { TableOutput } from '../schema/tables/TableOutput' import { TableOutputBasket } from '../schema/tables/TableOutputBasket' import { TableTransaction } from '../schema/tables/TableTransaction' import { validateInternalizeActionArgs, ValidInternalizeActionArgs } from '../../sdk/validationHelpers' import { WERR_INTERNAL, WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors' import { randomBytesBase64, verifyId, verifyOne, verifyOneOrNone } from '../../utility/utilityHelpers' import { TransactionStatus } from '../../sdk/types' import { EntityProvenTxReq } from '../schema/entities/EntityProvenTxReq' /** * Internalize Action allows a wallet to take ownership of outputs in a pre-existing transaction. * The transaction may, or may not already be known to both the storage and user. * * Two types of outputs are handled: "wallet payments" and "basket insertions". * * A "basket insertion" output is considered a custom output and has no effect on the wallet's "balance". * * A "wallet payment" adds an outputs value to the wallet's change "balance". These outputs are assigned to the "default" basket. * * Processing starts with simple validation and then checks for a pre-existing transaction. * If the transaction is already known to the user, then the outputs are reviewed against the existing outputs treatment, * and merge rules are added to the arguments passed to the storage layer. * The existing transaction must be in the 'unproven' or 'completed' status. Any other status is an error. * * When the transaction already exists, the description is updated. The isOutgoing sense is not changed. * * "basket insertion" Merge Rules: * 1. The "default" basket may not be specified as the insertion basket. * 2. A change output in the "default" basket may not be target of an insertion into a different basket. * 3. These baskets do not affect the wallet's balance and are typed "custom". * * "wallet payment" Merge Rules: * 1. Targetting an existing change "default" basket output results in a no-op. No error. No alterations made. * 2. Targetting a previously "custom" non-change output converts it into a change output. This alters the transaction's `satoshis`, and the wallet balance. */ export async function internalizeAction( storage: StorageProvider, auth: AuthId, args: InternalizeActionArgs ): Promise<StorageInternalizeActionResult> { const ctx = new InternalizeActionContext(storage, auth, args) await ctx.asyncSetup() if (ctx.isMerge) await ctx.mergedInternalize() else await ctx.newInternalize() return ctx.r } interface BasketInsertionX extends BasketInsertion { /** incoming transaction output index */ vout: number /** incoming transaction output */ txo: TransactionOutput /** if valid, corresponding storage output */ eo?: TableOutput } interface WalletPaymentX extends WalletPayment { /** incoming transaction output index */ vout: number /** incoming transaction output */ txo: TransactionOutput /** if valid, corresponding storage output */ eo?: TableOutput /** corresponds to an existing change output */ ignore: boolean } class InternalizeActionContext { /** result to be returned */ r: StorageInternalizeActionResult /** the parsed input AtomicBEEF */ ab: Beef /** the incoming transaction extracted from AtomicBEEF */ tx: BsvTransaction /** the user's change basket */ changeBasket: TableOutputBasket /** cached baskets referenced by basket insertions */ baskets: Record<string, TableOutputBasket> /** existing storage transaction for this txid and userId */ etx?: TableTransaction /** existing outputs */ eos: TableOutput[] /** all the basket insertions from incoming outputs array */ basketInsertions: BasketInsertionX[] /** all the wallet payments from incoming outputs array */ walletPayments: WalletPaymentX[] userId: number vargs: ValidInternalizeActionArgs constructor( public storage: StorageProvider, public auth: AuthId, public args: InternalizeActionArgs ) { this.vargs = validateInternalizeActionArgs(args) this.userId = auth.userId! this.r = { accepted: true, isMerge: false, txid: '', satoshis: 0 } this.ab = new Beef() this.tx = new BsvTransaction() this.changeBasket = {} as TableOutputBasket this.baskets = {} this.basketInsertions = [] this.walletPayments = [] this.eos = [] } get isMerge(): boolean { return this.r.isMerge } set isMerge(v: boolean) { this.r.isMerge = v } get txid(): string { return this.r.txid } set txid(v: string) { this.r.txid = v } get satoshis(): number { return this.r.satoshis } set satoshis(v: number) { this.r.satoshis = v } async getBasket(basketName: string): Promise<TableOutputBasket> { let b = this.baskets[basketName] if (b) return b b = await this.storage.findOrInsertOutputBasket(this.userId, basketName) this.baskets[basketName] = b return b } async asyncSetup() { ;({ ab: this.ab, tx: this.tx, txid: this.txid } = await this.validateAtomicBeef(this.args.tx)) for (const o of this.args.outputs) { if (o.outputIndex < 0 || o.outputIndex >= this.tx.outputs.length) throw new WERR_INVALID_PARAMETER( 'outputIndex', `a valid output index in range 0 to ${this.tx.outputs.length - 1}` ) const txo = this.tx.outputs[o.outputIndex] switch (o.protocol) { case 'basket insertion': { if (!o.insertionRemittance || o.paymentRemittance) throw new WERR_INVALID_PARAMETER('basket insertion', 'valid insertionRemittance and no paymentRemittance') this.basketInsertions.push({ ...o.insertionRemittance, txo, vout: o.outputIndex }) } break case 'wallet payment': { if (o.insertionRemittance || !o.paymentRemittance) throw new WERR_INVALID_PARAMETER('wallet payment', 'valid paymentRemittance and no insertionRemittance') this.walletPayments.push({ ...o.paymentRemittance, txo, vout: o.outputIndex, ignore: false }) } break default: throw new WERR_INTERNAL(`unexpected protocol ${o.protocol}`) } } this.changeBasket = verifyOne( await this.storage.findOutputBaskets({ partial: { userId: this.userId, name: 'default' } }) ) this.baskets = {} this.etx = verifyOneOrNone( await this.storage.findTransactions({ partial: { userId: this.userId, txid: this.txid } }) ) if (this.etx && !(this.etx.status == 'completed' || this.etx.status === 'unproven' || this.etx.status === 'nosend')) throw new WERR_INVALID_PARAMETER( 'tx', `target transaction of internalizeAction has invalid status ${this.etx.status}.` ) this.isMerge = !!this.etx if (this.isMerge) { this.eos = await this.storage.findOutputs({ partial: { userId: this.userId, txid: this.txid } }) // It is possible for a transaction to have no outputs, or less outputs in storage than in the transaction itself. for (const eo of this.eos) { const bi = this.basketInsertions.find(b => b.vout === eo.vout) const wp = this.walletPayments.find(b => b.vout === eo.vout) if (bi && wp) throw new WERR_INVALID_PARAMETER('outputs', `unique outputIndex values`) if (bi) bi.eo = eo if (wp) wp.eo = eo } } for (const basket of this.basketInsertions) { if (this.isMerge && basket.eo) { // merging with an existing user output if (basket.eo.basketId === this.changeBasket.basketId) { // converting a change output to a user basket custom output this.satoshis -= basket.txo.satoshis! } } } for (const payment of this.walletPayments) { if (this.isMerge) { if (payment.eo) { // merging with an existing user output if (payment.eo.basketId === this.changeBasket.basketId) { // ignore attempts to internalize an existing change output. payment.ignore = true } else { // converting an existing non-change output to change... increases net satoshis this.satoshis += payment.txo.satoshis! } } else { // adding a previously untracked output of an existing transaction as change... increase net satoshis this.satoshis += payment.txo.satoshis! } } else { // If there are no existing outputs, all incoming wallet payment outputs add to net satoshis this.satoshis += payment.txo.satoshis! } } } async validateAtomicBeef(atomicBeef: number[]) { const ab = Beef.fromBinary(atomicBeef) const txValid = await ab.verify(await this.storage.getServices().getChainTracker(), false) if (!txValid || !ab.atomicTxid) throw new WERR_INVALID_PARAMETER('tx', 'valid AtomicBEEF') const txid = ab.atomicTxid const btx = ab.findTxid(txid) if (!btx) throw new WERR_INVALID_PARAMETER('tx', `valid AtomicBEEF with newest txid of ${txid}`) const tx = btx.tx! /* for (const i of tx.inputs) { if (!i.sourceTXID) throw new WERR_INTERNAL('beef Transactions must have sourceTXIDs') if (!i.sourceTransaction) { const btx = ab.findTxid(i.sourceTXID) if (!btx) throw new WERR_INVALID_PARAMETER('tx', `valid AtomicBEEF and contain input transaction with txid ${i.sourceTXID}`); i.sourceTransaction = btx.tx } } */ return { ab, tx, txid } } async findOrInsertTargetTransaction(satoshis: number, status: TransactionStatus): Promise<TableTransaction> { const now = new Date() const newTx: TableTransaction = { created_at: now, updated_at: now, transactionId: 0, status, satoshis, version: this.tx.version, lockTime: this.tx.lockTime, reference: randomBytesBase64(7), userId: this.userId, isOutgoing: false, description: this.args.description, inputBEEF: undefined, txid: this.txid, rawTx: undefined } const tr = await this.storage.findOrInsertTransaction(newTx) if (!tr.isNew) { if (!this.isMerge) throw new WERR_INVALID_PARAMETER('tx', `target transaction of internalizeAction is undergoing active changes.`) await this.storage.updateTransaction(tr.tx.transactionId!, { satoshis: tr.tx.satoshis + satoshis }) } return tr.tx } async mergedInternalize() { const transactionId = this.etx!.transactionId! await this.addLabels(transactionId) for (const payment of this.walletPayments) { if (payment.eo && !payment.ignore) await this.mergeWalletPaymentForOutput(transactionId, payment) else if (!payment.ignore) await this.storeNewWalletPaymentForOutput(transactionId, payment) } for (const basket of this.basketInsertions) { if (basket.eo) await this.mergeBasketInsertionForOutput(transactionId, basket) else await this.storeNewBasketInsertionForOutput(transactionId, basket) } } async newInternalize() { this.etx = await this.findOrInsertTargetTransaction(this.satoshis, 'unproven') const transactionId = this.etx!.transactionId! // transaction record for user is new, but the txid may not be new to storage // make sure storage pursues getting a proof for it. const newReq = EntityProvenTxReq.fromTxid(this.txid, this.tx.toBinary(), this.args.tx) // this status is only relevant if the transaction is new to storage. newReq.status = 'unsent' // this history and notify will be merged into an existing req if it exists. newReq.addHistoryNote({ what: 'internalizeAction', userId: this.userId }) newReq.addNotifyTransactionId(transactionId) const pr = await this.storage.getProvenOrReq(this.txid, newReq.toApi()) if (pr.isNew) { // This storage doesn't know about this txid yet. // TODO Can we immediately prove this txid? // TODO Do full validation on the transaction? // Attempt to broadcast it to the network, throwing an error if it fails. const { swr, ndr } = await shareReqsWithWorld(this.storage, this.userId, [this.txid], false) if (ndr![0].status !== 'success') { this.r.sendWithResults = swr this.r.notDelayedResults = ndr // abort the internalize action, WERR_REVIEW_ACTIONS exception will be thrown return } } await this.addLabels(transactionId) for (const payment of this.walletPayments) { await this.storeNewWalletPaymentForOutput(transactionId, payment) } for (const basket of this.basketInsertions) { await this.storeNewBasketInsertionForOutput(transactionId, basket) } } async addLabels(transactionId: number) { for (const label of this.vargs.labels) { const txLabel = await this.storage.findOrInsertTxLabel(this.userId, label) await this.storage.findOrInsertTxLabelMap(verifyId(transactionId), verifyId(txLabel.txLabelId)) } } async addBasketTags(basket: BasketInsertionX, outputId: number) { for (const tag of basket.tags || []) { await this.storage.tagOutput({ outputId, userId: this.userId }, tag) } } async storeNewWalletPaymentForOutput(transactionId: number, payment: WalletPaymentX): Promise<void> { const now = new Date() const txOut: TableOutput = { created_at: now, updated_at: now, outputId: 0, transactionId, userId: this.userId, spendable: true, lockingScript: payment.txo.lockingScript.toBinary(), vout: payment.vout, basketId: this.changeBasket.basketId!, satoshis: payment.txo.satoshis!, txid: this.txid, senderIdentityKey: payment.senderIdentityKey, type: 'P2PKH', providedBy: 'storage', purpose: 'change', derivationPrefix: payment.derivationPrefix!, derivationSuffix: payment.derivationSuffix, change: true, spentBy: undefined, customInstructions: undefined, outputDescription: '', spendingDescription: undefined } txOut.outputId = await this.storage.insertOutput(txOut) payment.eo = txOut } async mergeWalletPaymentForOutput(transactionId: number, payment: WalletPaymentX) { const outputId = payment.eo!.outputId! const update: Partial<TableOutput> = { basketId: this.changeBasket.basketId, type: 'P2PKH', customInstructions: undefined, change: true, providedBy: 'storage', purpose: 'change', senderIdentityKey: payment.senderIdentityKey, derivationPrefix: payment.derivationPrefix, derivationSuffix: payment.derivationSuffix } await this.storage.updateOutput(outputId, update) payment.eo = { ...payment.eo!, ...update } } async mergeBasketInsertionForOutput(transactionId: number, basket: BasketInsertionX) { const outputId = basket.eo!.outputId! const update: Partial<TableOutput> = { basketId: (await this.getBasket(basket.basket)).basketId, type: 'custom', customInstructions: basket.customInstructions, change: false, providedBy: 'you', purpose: '', senderIdentityKey: undefined, derivationPrefix: undefined, derivationSuffix: undefined } await this.storage.updateOutput(outputId, update) basket.eo = { ...basket.eo!, ...update } } async storeNewBasketInsertionForOutput(transactionId: number, basket: BasketInsertionX): Promise<void> { const now = new Date() const txOut: TableOutput = { created_at: now, updated_at: now, outputId: 0, transactionId, userId: this.userId, spendable: true, lockingScript: basket.txo.lockingScript.toBinary(), vout: basket.vout, basketId: (await this.getBasket(basket.basket)).basketId, satoshis: basket.txo.satoshis!, txid: this.txid, type: 'custom', customInstructions: basket.customInstructions, change: false, spentBy: undefined, outputDescription: '', spendingDescription: undefined, providedBy: 'you', purpose: '', senderIdentityKey: undefined, derivationPrefix: undefined, derivationSuffix: undefined } txOut.outputId = await this.storage.insertOutput(txOut) await this.addBasketTags(basket, txOut.outputId!) basket.eo = txOut } }