wallet-storage-client
Version:
Client only Wallet Storage
397 lines • 17.7 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateChangeSdk = generateChangeSdk;
exports.validateGenerateChangeSdkResult = validateGenerateChangeSdkResult;
exports.validateGenerateChangeSdkParams = validateGenerateChangeSdkParams;
exports.generateChangeSdkMakeStorage = generateChangeSdkMakeStorage;
exports.varUintSize = varUintSize;
exports.transactionInputSize = transactionInputSize;
exports.transactionOutputSize = transactionOutputSize;
exports.transactionSize = transactionSize;
const index_client_1 = require("../../index.client");
/**
* 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 {
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 index_client_1.sdk.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 = 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;
// and try again...
}
};
/**
* Add funding to achieve a non-negative feeExcess value, if necessary.
*/
await fundTransaction();
/**
* Trigger an account funding event if we don't have enough to cover this transaction.
*/
if (feeExcess() < 0) {
throw new index_client_1.sdk.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) {
throw new index_client_1.sdk.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 index_client_1.sdk.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 = index_client_1.sdk.WalletError.fromUnknown(eu);
if (e.code === 'ERR_DOJO_NOT_SUFFICIENT_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 index_client_1.sdk.WERR_INVALID_PARAMETER('fixedInputs', 'an array of objects');
params.fixedInputs.forEach((x, i) => {
index_client_1.sdk.validateSatoshis(x.satoshis, `fixedInputs[${i}].satoshis`);
index_client_1.sdk.validateInteger(x.unlockingScriptLength, `fixedInputs[${i}].unlockingScriptLength`, undefined, 0);
});
if (!Array.isArray(params.fixedOutputs))
throw new index_client_1.sdk.WERR_INVALID_PARAMETER('fixedOutputs', 'an array of objects');
params.fixedOutputs.forEach((x, i) => {
index_client_1.sdk.validateSatoshis(x.satoshis, `fixedOutputs[${i}].satoshis`);
index_client_1.sdk.validateInteger(x.lockingScriptLength, `fixedOutputs[${i}].lockingScriptLength`, undefined, 0);
});
params.feeModel = (0, index_client_1.validateStorageFeeModel)(params.feeModel);
if (params.feeModel.model !== 'sat/kb')
throw new index_client_1.sdk.WERR_INVALID_PARAMETER('feeModel.model', `'sat/kb'`);
index_client_1.sdk.validateOptionalInteger(params.targetNetCount, `targetNetCount`);
index_client_1.sdk.validateSatoshis(params.changeFirstSatoshis, 'changeFirstSatoshis', 1);
index_client_1.sdk.validateSatoshis(params.changeInitialSatoshis, 'changeInitialSatoshis', 1);
index_client_1.sdk.validateInteger(params.changeLockingScriptLength, `changeLockingScriptLength`);
index_client_1.sdk.validateInteger(params.changeUnlockingScriptLength, `changeUnlockingScriptLength`);
}
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 index_client_1.sdk.WERR_INTERNAL(`unknown outputId ${outputId}`);
if (c.spendable)
throw new index_client_1.sdk.WERR_INTERNAL(`release of spendable outputId ${outputId}`);
c.spendable = true;
};
return { allocateChangeInput, releaseChangeInput, getLog };
}
/**
* Returns the byte size required to encode number as Bitcoin VarUint
* @publicbody
*/
function varUintSize(val) {
if (val < 0)
throw new index_client_1.sdk.WERR_INVALID_PARAMETER('varUint', 'non-negative');
return (val <= 0xfc ? 1 : val <= 0xffff ? 3 : val <= 0xffffffff ? 5 : 9);
}
/**
* @param scriptSize byte length of input script
* @returns serialized byte length a transaction input
*/
function transactionInputSize(scriptSize) {
return 32 + // txid
4 + // vout
varUintSize(scriptSize) + // script length, this is already in bytes
scriptSize + // script
4; // sequence number
}
/**
* @param scriptSize byte length of output script
* @returns serialized byte length a transaction output
*/
function transactionOutputSize(scriptSize) {
return varUintSize(scriptSize) + // output script length, from script encoded as hex string
scriptSize + // output script
8; // output amount (satoshis)
}
/**
* Compute the serialized binary transaction size in bytes
* given the number of inputs and outputs,
* and the size of each script.
* @param inputs array of input script lengths, in bytes
* @param outputs array of output script lengths, in bytes
* @returns total transaction size in bytes
*/
function transactionSize(inputs, outputs) {
return 4 + // Version
varUintSize(inputs.length) + // Number of inputs
inputs.reduce((a, e) => a + transactionInputSize(e), 0) + // all inputs
varUintSize(outputs.length) + // Number of outputs
outputs.reduce((a, e) => a + transactionOutputSize(e), 0) + // all outputs
4; // lock time
}
//# sourceMappingURL=generateChange.js.map