wallet-storage
Version:
BRC100 conforming wallet, wallet storage and wallet signer components
518 lines (438 loc) • 19.5 kB
text/typescript
import { sdk, validateStorageFeeModel } from '../../index.client'
export interface GenerateChangeSdkResult {
allocatedChangeInputs: GenerateChangeSdkChangeInput[]
changeOutputs: GenerateChangeSdkChangeOutput[]
size: number
fee: number
satsPerKb: 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 {
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 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 = () : 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
// 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 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 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 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: unknown) {
const e = 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
}
}
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: sdk.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 function validateGenerateChangeSdkParams(params: GenerateChangeSdkParams) {
if (!Array.isArray(params.fixedInputs)) throw new sdk.WERR_INVALID_PARAMETER('fixedInputs', 'an array of objects')
params.fixedInputs.forEach((x, i) => {
sdk.validateSatoshis(x.satoshis, `fixedInputs[${i}].satoshis`)
sdk.validateInteger(x.unlockingScriptLength, `fixedInputs[${i}].unlockingScriptLength`, undefined, 0)
})
if (!Array.isArray(params.fixedOutputs)) throw new sdk.WERR_INVALID_PARAMETER('fixedOutputs', 'an array of objects')
params.fixedOutputs.forEach((x, i) => {
sdk.validateSatoshis(x.satoshis, `fixedOutputs[${i}].satoshis`)
sdk.validateInteger(x.lockingScriptLength, `fixedOutputs[${i}].lockingScriptLength`, undefined, 0)
})
params.feeModel = validateStorageFeeModel(params.feeModel)
if (params.feeModel.model !== 'sat/kb')
throw new sdk.WERR_INVALID_PARAMETER('feeModel.model', `'sat/kb'`)
sdk.validateOptionalInteger(params.targetNetCount, `targetNetCount`)
sdk.validateSatoshis(params.changeFirstSatoshis, 'changeFirstSatoshis', 1)
sdk.validateSatoshis(params.changeInitialSatoshis, 'changeInitialSatoshis', 1)
sdk.validateInteger(params.changeLockingScriptLength, `changeLockingScriptLength`)
sdk.validateInteger(params.changeUnlockingScriptLength, `changeUnlockingScriptLength`)
}
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 sdk.WERR_INTERNAL(`unknown outputId ${outputId}`);
if (c.spendable) throw new 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
*/
export function varUintSize(val: number): 1 | 3 | 5 | 9 {
if (val < 0) throw new 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
*/
export function transactionInputSize (scriptSize: number): number {
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
*/
export function transactionOutputSize (scriptSize: number): number {
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
*/
export function transactionSize (inputs: number[], outputs: number[]): number {
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
}