UNPKG

@bsv/wallet-toolbox

Version:

BRC100 conforming wallet, wallet storage and wallet signer components

553 lines (476 loc) 18.7 kB
import { validateInteger, validateOptionalInteger, validateSatoshis } from '../../sdk/validationHelpers' import { WalletError } from '../../sdk/WalletError' import { StorageFeeModel } from '../../sdk/WalletStorage.interfaces' import { WERR_INSUFFICIENT_FUNDS, WERR_INTERNAL, WERR_INVALID_PARAMETER } from '../../sdk/WERR_errors' import { validateStorageFeeModel } from '../StorageProvider' import { transactionSize } from './utils' /** * An output of this satoshis amount will be adjusted to the largest fundable amount. */ export const maxPossibleSatoshis = 2099999999999999 export interface GenerateChangeSdkResult { allocatedChangeInputs: GenerateChangeSdkChangeInput[] changeOutputs: GenerateChangeSdkChangeOutput[] size: number fee: number satsPerKb: number maxPossibleSatoshisAdjustment?: { fixedOutputIndex: number satoshis: number } } /** * Simplifications: * - only support one change type with fixed length scripts. * - only support satsPerKb fee model. * * Confirms for each availbleChange output that it remains available as they are allocated and selects alternate if not. * * @param params * @returns */ export async function generateChangeSdk( params: GenerateChangeSdkParams, allocateChangeInput: ( targetSatoshis: number, exactSatoshis?: number ) => Promise<GenerateChangeSdkChangeInput | undefined>, releaseChangeInput: (outputId: number) => Promise<void> ): Promise<GenerateChangeSdkResult> { if (params.noLogging === false) logGenerateChangeSdkParams(params) const r: GenerateChangeSdkResult = { allocatedChangeInputs: [], changeOutputs: [], size: 0, fee: 0, satsPerKb: 0 } // eslint-disable-next-line no-useless-catch try { const vgcpr = validateGenerateChangeSdkParams(params) const satsPerKb = params.feeModel.value || 0 const randomVals = [...(params.randomVals || [])] const randomValsUsed: number[] = [] const nextRandomVal = (): number => { let val = 0 if (!randomVals || randomVals.length === 0) { val = Math.random() } else { val = randomVals.shift() || 0 randomVals.push(val) } // Capture random sequence used if not supplied randomValsUsed.push(val) return val } /** * @returns a random integer betweenn min and max, inclussive. */ const rand = (min: number, max: number): number => { if (max < min) throw new WERR_INVALID_PARAMETER('max', `less than min (${min}). max is (${max})`) return Math.floor(nextRandomVal() * (max - min + 1) + min) } const fixedInputs = params.fixedInputs const fixedOutputs = params.fixedOutputs /** * @returns sum of transaction fixedInputs satoshis and fundingInputs satoshis */ const funding = (): number => { return ( fixedInputs.reduce((a, e) => a + e.satoshis, 0) + r.allocatedChangeInputs.reduce((a, e) => a + e.satoshis, 0) ) } /** * @returns sum of transaction fixedOutputs satoshis */ const spending = (): number => { return fixedOutputs.reduce((a, e) => a + e.satoshis, 0) } /** * @returns sum of transaction changeOutputs satoshis */ const change = (): number => { return r.changeOutputs.reduce((a, e) => a + e.satoshis, 0) } const fee = (): number => funding() - spending() - change() const size = (addedChangeInputs?: number, addedChangeOutputs?: number): number => { const inputScriptLengths = [ ...fixedInputs.map(x => x.unlockingScriptLength), ...Array(r.allocatedChangeInputs.length + (addedChangeInputs || 0)).fill(params.changeUnlockingScriptLength) ] const outputScriptLengths = [ ...fixedOutputs.map(x => x.lockingScriptLength), ...Array(r.changeOutputs.length + (addedChangeOutputs || 0)).fill(params.changeLockingScriptLength) ] const size = transactionSize(inputScriptLengths, outputScriptLengths) return size } /** * @returns the target fee required for the transaction as currently configured under feeModel. */ const feeTarget = (addedChangeInputs?: number, addedChangeOutputs?: number): number => { const fee = Math.ceil((size(addedChangeInputs, addedChangeOutputs) / 1000) * satsPerKb) return fee } /** * @returns the current excess fee for the transaction as currently configured. * * This is funding() - spending() - change() - feeTarget() * * The goal is an excess fee of zero. * * A positive value is okay if the cost of an additional change output is greater. * * A negative value means the transaction is under funded, or over spends, and may be rejected. */ const feeExcess = (addedChangeInputs?: number, addedChangeOutputs?: number): number => { const fe = funding() - spending() - change() - feeTarget(addedChangeInputs, addedChangeOutputs) if (!addedChangeInputs && !addedChangeOutputs) feeExcessNow = fe return fe } // The most recent feeExcess() let feeExcessNow = 0 feeExcess() const hasTargetNetCount = params.targetNetCount !== undefined const targetNetCount = params.targetNetCount || 0 // current net change in count of change outputs const netChangeCount = (): number => { return r.changeOutputs.length - r.allocatedChangeInputs.length } const addOutputToBalanceNewInput = (): boolean => { if (!hasTargetNetCount) return false return netChangeCount() - 1 < targetNetCount } const releaseAllocatedChangeInputs = async (): Promise<void> => { while (r.allocatedChangeInputs.length > 0) { const i = r.allocatedChangeInputs.pop() if (i) { await releaseChangeInput(i.outputId) } } feeExcessNow = feeExcess() } // If we'd like to have more change outputs create them now. // They may be removed if it turns out we can't fund them. while ( (hasTargetNetCount && targetNetCount > netChangeCount()) || (r.changeOutputs.length === 0 && feeExcess() > 0) ) { r.changeOutputs.push({ satoshis: r.changeOutputs.length === 0 ? params.changeFirstSatoshis : params.changeInitialSatoshis, lockingScriptLength: params.changeLockingScriptLength }) } const fundTransaction = async (): Promise<void> => { let removingOutputs = false const attemptToFundTransaction = async (): Promise<boolean> => { if (feeExcess() > 0) return true let exactSatoshis: number | undefined = undefined if (!hasTargetNetCount && r.changeOutputs.length === 0) { exactSatoshis = -feeExcess(1) } const ao = addOutputToBalanceNewInput() ? 1 : 0 const targetSatoshis = -feeExcess(1, ao) + (ao === 1 ? 2 * params.changeInitialSatoshis : 0) const allocatedChangeInput = await allocateChangeInput(targetSatoshis, exactSatoshis) if (!allocatedChangeInput) { // Unable to add another funding change input return false } r.allocatedChangeInputs.push(allocatedChangeInput) if (!removingOutputs && feeExcess() > 0) { if (ao == 1 || r.changeOutputs.length === 0) { r.changeOutputs.push({ satoshis: Math.min( feeExcess(), r.changeOutputs.length === 0 ? params.changeFirstSatoshis : params.changeInitialSatoshis ), lockingScriptLength: params.changeLockingScriptLength }) } } return true } for (;;) { // This is the starvation loop, drops change outputs one at a time if unable to fund them... await releaseAllocatedChangeInputs() while (feeExcess() < 0) { // This is the funding loop, add one change input at a time... const ok = await attemptToFundTransaction() if (!ok) break } // Done if blanced overbalanced or impossible (all funding applied, all change outputs removed). if (feeExcess() >= 0 || r.changeOutputs.length === 0) break removingOutputs = true while (r.changeOutputs.length > 0 && feeExcess() < 0) { r.changeOutputs.pop() } if (feeExcess() < 0) // Not enough available funding even if no change outputs break // At this point we have a funded transaction, but there may be change outputs that are each costing as change input, // resulting in pointless churn of change outputs. // And remove change inputs that funded only a single change output (along with that output)... const changeInputs = [...r.allocatedChangeInputs] while (changeInputs.length > 1 && r.changeOutputs.length > 1) { const lastOutput = r.changeOutputs.slice(-1)[0] const i = changeInputs.findIndex(ci => ci.satoshis <= lastOutput.satoshis) if (i < 0) break r.changeOutputs.pop() changeInputs.splice(i, 1) } // and try again... } } /** * Add funding to achieve a non-negative feeExcess value, if necessary. */ await fundTransaction() if (feeExcess() < 0 && vgcpr.hasMaxPossibleOutput !== undefined) { // Reduce the fixed output with satoshis of maxPossibleSatoshis to what will just fund the transaction... if (fixedOutputs[vgcpr.hasMaxPossibleOutput].satoshis !== maxPossibleSatoshis) throw new WERR_INTERNAL() fixedOutputs[vgcpr.hasMaxPossibleOutput].satoshis += feeExcess() r.maxPossibleSatoshisAdjustment = { fixedOutputIndex: vgcpr.hasMaxPossibleOutput, satoshis: fixedOutputs[vgcpr.hasMaxPossibleOutput].satoshis } } /** * Trigger an account funding event if we don't have enough to cover this transaction. */ if (feeExcess() < 0) { await releaseAllocatedChangeInputs() throw new WERR_INSUFFICIENT_FUNDS(spending() + feeTarget(), -feeExcessNow) } /** * If needed, seek funding to avoid overspending on fees without a change output to recapture it. */ if (r.changeOutputs.length === 0 && feeExcessNow > 0) { await releaseAllocatedChangeInputs() throw new WERR_INSUFFICIENT_FUNDS(spending() + feeTarget(), params.changeFirstSatoshis) } /** * Distribute the excess fees across the changeOutputs added. */ while (r.changeOutputs.length > 0 && feeExcessNow > 0) { if (r.changeOutputs.length === 1) { r.changeOutputs[0].satoshis += feeExcessNow feeExcessNow = 0 } else if (r.changeOutputs[0].satoshis < params.changeInitialSatoshis) { const sats = Math.min(feeExcessNow, params.changeInitialSatoshis - r.changeOutputs[0].satoshis) feeExcessNow -= sats r.changeOutputs[0].satoshis += sats } else { // Distribute a random percentage between 25% and 50% but at least one satoshi const sats = Math.max(1, Math.floor((rand(2500, 5000) / 10000) * feeExcessNow)) feeExcessNow -= sats const index = rand(0, r.changeOutputs.length - 1) r.changeOutputs[index].satoshis += sats } } r.size = size() ;((r.fee = fee()), (r.satsPerKb = satsPerKb)) const { ok, log } = validateGenerateChangeSdkResult(params, r) if (!ok) { throw new WERR_INTERNAL(`generateChangeSdk error: ${log}`) } if (r.allocatedChangeInputs.length > 4 && r.changeOutputs.length > 4) { console.log('generateChangeSdk_Capture_too_many_ins_and_outs') logGenerateChangeSdkParams(params) } return r } catch (eu: unknown) { const e = WalletError.fromUnknown(eu) if (e.code === 'WERR_INSUFFICIENT_FUNDS') throw eu // Capture the params in cloud run log which has a 100k text length limit per line. // logGenerateChangeSdkParams(params, eu) throw eu } } export function validateGenerateChangeSdkResult( params: GenerateChangeSdkParams, r: GenerateChangeSdkResult ): { ok: boolean; log: string } { let ok = true let log = '' const sumIn = params.fixedInputs.reduce((a, e) => a + e.satoshis, 0) + r.allocatedChangeInputs.reduce((a, e) => a + e.satoshis, 0) const sumOut = params.fixedOutputs.reduce((a, e) => a + e.satoshis, 0) + r.changeOutputs.reduce((a, e) => a + e.satoshis, 0) if (r.fee && Number.isInteger(r.fee) && r.fee < 0) { log += `basic fee error ${r.fee};` ok = false } const feePaid = sumIn - sumOut if (feePaid !== r.fee) { log += `exact fee error ${feePaid} !== ${r.fee};` ok = false } const feeRequired = Math.ceil(((r.size || 0) / 1000) * (r.satsPerKb || 0)) if (feeRequired !== r.fee) { log += `required fee error ${feeRequired} !== ${r.fee};` ok = false } return { ok, log } } function logGenerateChangeSdkParams(params: GenerateChangeSdkParams, eu?: unknown) { let s = JSON.stringify(params) console.log(`generateChangeSdk params length ${s.length}${eu ? ` error: ${eu}` : ''}`) let i = -1 const maxlen = 99900 for (;;) { i++ console.log(`generateChangeSdk params ${i} XXX${s.slice(0, maxlen)}XXX`) s = s.slice(maxlen) if (!s || i > 100) break } } export interface GenerateChangeSdkParams { fixedInputs: GenerateChangeSdkInput[] fixedOutputs: GenerateChangeSdkOutput[] feeModel: StorageFeeModel /** * Target for number of new change outputs added minus number of funding change outputs consumed. * If undefined, only a single change output will be added if excess fees must be recaptured. */ targetNetCount?: number /** * Satoshi amount to initialize optional new change outputs. */ changeInitialSatoshis: number /** * Lowest amount value to assign to a change output. * Drop the output if unable to satisfy. * default 285 */ changeFirstSatoshis: number /** * Fixed change locking script length. * * For P2PKH template, 25 bytes */ changeLockingScriptLength: number /** * Fixed change unlocking script length. * * For P2PKH template, 107 bytes */ changeUnlockingScriptLength: number randomVals?: number[] noLogging?: boolean log?: string } export interface GenerateChangeSdkInput { satoshis: number unlockingScriptLength: number } export interface GenerateChangeSdkOutput { satoshis: number lockingScriptLength: number } export interface GenerateChangeSdkChangeInput { outputId: number satoshis: number } export interface GenerateChangeSdkChangeOutput { satoshis: number lockingScriptLength: number } export interface ValidateGenerateChangeSdkParamsResult { hasMaxPossibleOutput?: number } export function validateGenerateChangeSdkParams( params: GenerateChangeSdkParams ): ValidateGenerateChangeSdkParamsResult { if (!Array.isArray(params.fixedInputs)) throw new WERR_INVALID_PARAMETER('fixedInputs', 'an array of objects') const r: ValidateGenerateChangeSdkParamsResult = {} params.fixedInputs.forEach((x, i) => { validateSatoshis(x.satoshis, `fixedInputs[${i}].satoshis`) validateInteger(x.unlockingScriptLength, `fixedInputs[${i}].unlockingScriptLength`, undefined, 0) }) if (!Array.isArray(params.fixedOutputs)) throw new WERR_INVALID_PARAMETER('fixedOutputs', 'an array of objects') params.fixedOutputs.forEach((x, i) => { validateSatoshis(x.satoshis, `fixedOutputs[${i}].satoshis`) validateInteger(x.lockingScriptLength, `fixedOutputs[${i}].lockingScriptLength`, undefined, 0) if (x.satoshis === maxPossibleSatoshis) { if (r.hasMaxPossibleOutput !== undefined) throw new WERR_INVALID_PARAMETER( `fixedOutputs[${i}].satoshis`, `valid satoshis amount. Only one 'maxPossibleSatoshis' output allowed.` ) r.hasMaxPossibleOutput = i } }) params.feeModel = validateStorageFeeModel(params.feeModel) if (params.feeModel.model !== 'sat/kb') throw new WERR_INVALID_PARAMETER('feeModel.model', `'sat/kb'`) validateOptionalInteger(params.targetNetCount, `targetNetCount`) validateSatoshis(params.changeFirstSatoshis, 'changeFirstSatoshis', 1) validateSatoshis(params.changeInitialSatoshis, 'changeInitialSatoshis', 1) validateInteger(params.changeLockingScriptLength, `changeLockingScriptLength`) validateInteger(params.changeUnlockingScriptLength, `changeUnlockingScriptLength`) return r } export interface GenerateChangeSdkStorageChange extends GenerateChangeSdkChangeInput { spendable: boolean } export function generateChangeSdkMakeStorage(availableChange: GenerateChangeSdkChangeInput[]): { allocateChangeInput: ( targetSatoshis: number, exactSatoshis?: number ) => Promise<GenerateChangeSdkChangeInput | undefined> releaseChangeInput: (outputId: number) => Promise<void> getLog: () => string } { const change: GenerateChangeSdkStorageChange[] = availableChange.map(c => ({ ...c, spendable: true })) change.sort((a, b) => a.satoshis < b.satoshis ? -1 : a.satoshis > b.satoshis ? 1 : a.outputId < b.outputId ? -1 : a.outputId > b.outputId ? 1 : 0 ) let log = '' for (const c of change) log += `change ${c.satoshis} ${c.outputId}\n` const getLog = (): string => log const allocate = (c: GenerateChangeSdkStorageChange) => { log += ` -> ${c.satoshis} sats, id ${c.outputId}\n` c.spendable = false return c } const allocateChangeInput = async ( targetSatoshis: number, exactSatoshis?: number ): Promise<GenerateChangeSdkChangeInput | undefined> => { log += `allocate target ${targetSatoshis} exact ${exactSatoshis}` if (exactSatoshis !== undefined) { const exact = change.find(c => c.spendable && c.satoshis === exactSatoshis) if (exact) return allocate(exact) } const over = change.find(c => c.spendable && c.satoshis >= targetSatoshis) if (over) return allocate(over) let under: GenerateChangeSdkStorageChange | undefined = undefined for (let i = change.length - 1; i >= 0; i--) { if (change[i].spendable) { under = change[i] break } } if (under) return allocate(under) log += `\n` return undefined } const releaseChangeInput = async (outputId: number): Promise<void> => { log += `release id ${outputId}\n` const c = change.find(x => x.outputId === outputId) if (!c) throw new WERR_INTERNAL(`unknown outputId ${outputId}`) if (c.spendable) throw new WERR_INTERNAL(`release of spendable outputId ${outputId}`) c.spendable = true } return { allocateChangeInput, releaseChangeInput, getLog } }