UNPKG

@bsv/wallet-toolbox-client

Version:
403 lines 18.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.maxPossibleSatoshis = void 0; exports.generateChangeSdk = generateChangeSdk; exports.validateGenerateChangeSdkResult = validateGenerateChangeSdkResult; exports.validateGenerateChangeSdkParams = validateGenerateChangeSdkParams; exports.generateChangeSdkMakeStorage = generateChangeSdkMakeStorage; const validationHelpers_1 = require("../../sdk/validationHelpers"); const WalletError_1 = require("../../sdk/WalletError"); const WERR_errors_1 = require("../../sdk/WERR_errors"); const StorageProvider_1 = require("../StorageProvider"); const utils_1 = require("./utils"); /** * An output of this satoshis amount will be adjusted to the largest fundable amount. */ exports.maxPossibleSatoshis = 2099999999999999; /** * 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 */ async function generateChangeSdk(params, allocateChangeInput, releaseChangeInput) { if (params.noLogging === false) logGenerateChangeSdkParams(params); const r = { 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 = []; const nextRandomVal = () => { 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, max) => { if (max < min) throw new WERR_errors_1.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 = () => { 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 = () => { return fixedOutputs.reduce((a, e) => a + e.satoshis, 0); }; /** * @returns sum of transaction changeOutputs satoshis */ const change = () => { return r.changeOutputs.reduce((a, e) => a + e.satoshis, 0); }; const fee = () => funding() - spending() - change(); const size = (addedChangeInputs, addedChangeOutputs) => { 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 = (0, utils_1.transactionSize)(inputScriptLengths, outputScriptLengths); return size; }; /** * @returns the target fee required for the transaction as currently configured under feeModel. */ const feeTarget = (addedChangeInputs, addedChangeOutputs) => { 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, addedChangeOutputs) => { 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 = () => { return r.changeOutputs.length - r.allocatedChangeInputs.length; }; const addOutputToBalanceNewInput = () => { if (!hasTargetNetCount) return false; return netChangeCount() - 1 < targetNetCount; }; const releaseAllocatedChangeInputs = async () => { 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 () => { let removingOutputs = false; const attemptToFundTransaction = async () => { if (feeExcess() > 0) return true; let exactSatoshis = 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 !== exports.maxPossibleSatoshis) throw new WERR_errors_1.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_errors_1.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_errors_1.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_errors_1.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) { const e = WalletError_1.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; } } function validateGenerateChangeSdkResult(params, r) { 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, eu) { 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; } } function validateGenerateChangeSdkParams(params) { if (!Array.isArray(params.fixedInputs)) throw new WERR_errors_1.WERR_INVALID_PARAMETER('fixedInputs', 'an array of objects'); const r = {}; params.fixedInputs.forEach((x, i) => { (0, validationHelpers_1.validateSatoshis)(x.satoshis, `fixedInputs[${i}].satoshis`); (0, validationHelpers_1.validateInteger)(x.unlockingScriptLength, `fixedInputs[${i}].unlockingScriptLength`, undefined, 0); }); if (!Array.isArray(params.fixedOutputs)) throw new WERR_errors_1.WERR_INVALID_PARAMETER('fixedOutputs', 'an array of objects'); params.fixedOutputs.forEach((x, i) => { (0, validationHelpers_1.validateSatoshis)(x.satoshis, `fixedOutputs[${i}].satoshis`); (0, validationHelpers_1.validateInteger)(x.lockingScriptLength, `fixedOutputs[${i}].lockingScriptLength`, undefined, 0); if (x.satoshis === exports.maxPossibleSatoshis) { if (r.hasMaxPossibleOutput !== undefined) throw new WERR_errors_1.WERR_INVALID_PARAMETER(`fixedOutputs[${i}].satoshis`, `valid satoshis amount. Only one 'maxPossibleSatoshis' output allowed.`); r.hasMaxPossibleOutput = i; } }); params.feeModel = (0, StorageProvider_1.validateStorageFeeModel)(params.feeModel); if (params.feeModel.model !== 'sat/kb') throw new WERR_errors_1.WERR_INVALID_PARAMETER('feeModel.model', `'sat/kb'`); (0, validationHelpers_1.validateOptionalInteger)(params.targetNetCount, `targetNetCount`); (0, validationHelpers_1.validateSatoshis)(params.changeFirstSatoshis, 'changeFirstSatoshis', 1); (0, validationHelpers_1.validateSatoshis)(params.changeInitialSatoshis, 'changeInitialSatoshis', 1); (0, validationHelpers_1.validateInteger)(params.changeLockingScriptLength, `changeLockingScriptLength`); (0, validationHelpers_1.validateInteger)(params.changeUnlockingScriptLength, `changeUnlockingScriptLength`); return r; } function generateChangeSdkMakeStorage(availableChange) { const change = 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 = () => log; const allocate = (c) => { log += ` -> ${c.satoshis} sats, id ${c.outputId}\n`; c.spendable = false; return c; }; const allocateChangeInput = async (targetSatoshis, exactSatoshis) => { 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 = 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) => { log += `release id ${outputId}\n`; const c = change.find(x => x.outputId === outputId); if (!c) throw new WERR_errors_1.WERR_INTERNAL(`unknown outputId ${outputId}`); if (c.spendable) throw new WERR_errors_1.WERR_INTERNAL(`release of spendable outputId ${outputId}`); c.spendable = true; }; return { allocateChangeInput, releaseChangeInput, getLog }; } //# sourceMappingURL=generateChange.js.map