From d3416ad783d78212d688fe67c6e7573496faf894 Mon Sep 17 00:00:00 2001 From: jramos0 Date: Fri, 19 Jun 2026 04:04:16 -0600 Subject: [PATCH 1/5] Feat: UTXO cost spending page --- .../components/address/address.component.html | 11 ++ .../components/address/address.component.scss | 10 ++ .../components/address/address.component.ts | 3 +- .../cost-to-spend.component.html | 117 ++++++++++++++++++ .../cost-to-spend.component.scss | 28 +++++ .../cost-to-spend/cost-to-spend.component.ts | 77 ++++++++++++ frontend/src/app/graphs/graphs.module.ts | 2 + frontend/src/app/shared/address-utils.ts | 78 +++++++++++- 8 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.html create mode 100644 frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.scss create mode 100644 frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.ts diff --git a/frontend/src/app/components/address/address.component.html b/frontend/src/app/components/address/address.component.html index f05caf371..e9760b8f6 100644 --- a/frontend/src/app/components/address/address.component.html +++ b/frontend/src/app/components/address/address.component.html @@ -135,6 +135,17 @@ + +
+
+

+ Cost to Spend + +

+
+ +
+

diff --git a/frontend/src/app/components/address/address.component.scss b/frontend/src/app/components/address/address.component.scss index 06e15ad67..04051a9dc 100644 --- a/frontend/src/app/components/address/address.component.scss +++ b/frontend/src/app/components/address/address.component.scss @@ -133,3 +133,13 @@ h1 { padding-bottom: 0; font-size: 0.8rem; } + +.cost-to-spend-toggle { + cursor: pointer; + user-select: none; + fa-icon { + font-size: 0.8em; + margin-left: 6px; + color: var(--transparent-fg); + } +} diff --git a/frontend/src/app/components/address/address.component.ts b/frontend/src/app/components/address/address.component.ts index e11d7cfa4..a3a97a1f4 100644 --- a/frontend/src/app/components/address/address.component.ts +++ b/frontend/src/app/components/address/address.component.ts @@ -124,6 +124,7 @@ export class AddressComponent implements OnInit, OnDestroy { addressTypeInfo: null | AddressTypeInfo; tapTreeIncomplete: boolean = false; taprootPsbtExpanded: boolean = false; + showCostToSpend: boolean = false; psbtForm: UntypedFormGroup; psbtError?: string; accelerationsSubscription: Subscription; @@ -159,7 +160,7 @@ export class AddressComponent implements OnInit, OnDestroy { this.network = network; this.updateAccelerationSubscription(); }); - this.websocketService.want(['blocks']); + this.websocketService.want(['blocks', 'mempool-blocks']); this.psbtForm = this.formBuilder.group({ psbt: [''], tapleaf: [''], taptree: [''], ikey: [''] }); this.onResize(); diff --git a/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.html b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.html new file mode 100644 index 000000000..3bd6b5319 --- /dev/null +++ b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.html @@ -0,0 +1,117 @@ +
+
+
+ + + + + + + + + + + + + + + +
vsize per input + ~{{ cost.inputVsize | number }} vB +
Fee rate (~30 min) + +
+ Max cost to spend + + + +
+
+
+ + + + + + + + + + + + + + + +
Spendable UTXOs{{ utxoCount | number }}
+ Min cost to spend + + + +
+ Effective balance range + + + + +
+
+
+
+ + +
+
+
+ + + + + + +
+
+
+
+
diff --git a/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.scss b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.scss new file mode 100644 index 000000000..d2e282583 --- /dev/null +++ b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.scss @@ -0,0 +1,28 @@ +.cost-to-spend-table { + margin-bottom: 0; + + tr td { + &:last-child { + text-align: right; + } + + &.wrap-cell { + white-space: nowrap; + @media (max-width: 575.98px) { + white-space: normal; + } + } + } +} + +.fiat { + margin-left: 10px; +} + +.estimate-marker { + cursor: help; +} + +.range-sep { + margin: 0 4px; +} diff --git a/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.ts b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.ts new file mode 100644 index 000000000..002905459 --- /dev/null +++ b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.ts @@ -0,0 +1,77 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + OnChanges, + OnInit, +} from '@angular/core'; +import { BehaviorSubject, Observable, combineLatest } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { StateService } from '@app/services/state.service'; +import { + AddressTypeInfo, + TX_OVERHEAD_VSIZE, + TYPICAL_OUTPUT_VSIZE, + estimateInputVsize, +} from '@app/shared/address-utils'; + +interface CostToSpend { + feeRate: number; + inputVsize: number; + estimated: boolean; + minCost: number; + maxCost: number; + worstEffectiveBalance: number; + bestEffectiveBalance: number; +} + +@Component({ + selector: 'app-cost-to-spend', + templateUrl: './cost-to-spend.component.html', + styleUrls: ['./cost-to-spend.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class CostToSpendComponent implements OnInit, OnChanges { + @Input() addressTypeInfo: AddressTypeInfo; + @Input() utxoCount: number; + @Input() balance: number; + + costToSpend$: Observable; + private inputs$ = new BehaviorSubject(undefined); + + constructor(private stateService: StateService) {} + + ngOnInit(): void { + this.costToSpend$ = combineLatest([ + this.stateService.recommendedFees$, + this.inputs$, + ]).pipe(map(([fees]) => this.calculate(fees.halfHourFee))); + } + + ngOnChanges(): void { + this.inputs$.next(); + } + + private calculate(feeRate: number): CostToSpend { + const { vsize: inputVsize, estimated } = estimateInputVsize( + this.addressTypeInfo + ); + const overhead = TX_OVERHEAD_VSIZE + TYPICAL_OUTPUT_VSIZE; + // min: consolidate every UTXO in a single transaction (overhead amortized) + const minCost = Math.round(this.utxoCount * inputVsize * feeRate); + // max: spend each UTXO in its own transaction, rounding each individual + // spend up to whole satoshis before summing + const maxCost = + this.utxoCount * Math.ceil((inputVsize + overhead) * feeRate); + return { + feeRate, + inputVsize, + estimated, + minCost, + maxCost, + worstEffectiveBalance: Math.max(0, this.balance - maxCost), + bestEffectiveBalance: Math.max(0, this.balance - minCost), + }; + } +} diff --git a/frontend/src/app/graphs/graphs.module.ts b/frontend/src/app/graphs/graphs.module.ts index 5994a1775..1b5b32b9d 100644 --- a/frontend/src/app/graphs/graphs.module.ts +++ b/frontend/src/app/graphs/graphs.module.ts @@ -35,6 +35,7 @@ import { HashrateChartComponent } from '@components/hashrate-chart/hashrate-char import { HashrateChartPoolsComponent } from '@components/hashrates-chart-pools/hashrate-chart-pools.component'; import { BlockHealthGraphComponent } from '@components/block-health-graph/block-health-graph.component'; import { AddressComponent } from '@components/address/address.component'; +import { CostToSpendComponent } from '@components/address/cost-to-spend/cost-to-spend.component'; import { WalletComponent } from '@components/wallet/wallet.component'; import { WalletPreviewComponent } from '@components/wallet/wallet-preview.component'; import { AddressGraphComponent } from '@components/address-graph/address-graph.component'; @@ -54,6 +55,7 @@ import { CommonModule } from '@angular/common'; CustomDashboardComponent, MempoolBlockComponent, AddressComponent, + CostToSpendComponent, WalletComponent, WalletPreviewComponent, diff --git a/frontend/src/app/shared/address-utils.ts b/frontend/src/app/shared/address-utils.ts index cbdbb19ef..6223fcb85 100644 --- a/frontend/src/app/shared/address-utils.ts +++ b/frontend/src/app/shared/address-utils.ts @@ -1,5 +1,5 @@ import '@angular/localize/init'; -import { ScriptInfo } from '@app/shared/script.utils'; +import { ScriptInfo, getVarIntLength } from '@app/shared/script.utils'; import { Vin, Vout } from '@interfaces/electrs.interface'; import { BECH32_CHARS_LW, BASE58_CHARS, HEX_CHARS } from '@app/shared/regex.utils'; import { parseTaproot } from './transaction.utils'; @@ -256,6 +256,82 @@ export class AddressTypeInfo { } } +// vsize of typical transaction overhead when spending a UTXO in its own +// transaction: version + locktime + in/out counts + segwit marker/flag (~11 vB) +// plus one typical output (~31 vB for a p2wpkh recipient). +export const TX_OVERHEAD_VSIZE = 11; +export const TYPICAL_OUTPUT_VSIZE = 31; + +// estimated vsize (vB) of a single input spending each address type, for the +// common single-key case. Derived from the per-component byte costs documented +// in fillUnsignedInput (transaction.utils.ts): DER sig 72 B, pubkey 34 B, +// Schnorr sig 65 B, with the 4x witness discount applied. +const INPUT_VSIZE: Partial> = { + p2pk: 114, + p2pkh: 148, + 'p2sh-p2wpkh': 91, + v0_p2wpkh: 68, + v1_p2tr: 58, // keyspend +}; + +// rough vsize of an m-of-n multisig input. `wrapped` adds the p2sh redeemscript +// pushed in the scriptsig (p2sh-p2wsh); otherwise native p2wsh. +function multisigWitnessInputVsize(m: number, n: number, wrapped: boolean): number { + const nonWitness = wrapped ? 75 : 41; // outpoint + sequence (+ redeemscript push) + const witnessScript = 3 + n * 34; // OP_m + n*push33 + OP_n + OP_CHECKMULTISIG + const witnessBytes = 1 + 1 + m * 73 + getVarIntLength(witnessScript) + witnessScript; + return Math.round(nonWitness + witnessBytes / 4); +} + +/** + * Estimates the vsize (vB) of a single input spending from this address. + * + * `estimated` flags address types whose input size is variable (multisig, + * p2wsh, tapscript script-path, unknown) and therefore only approximated. + * + * `observedVsize`, when provided, is the realized vsize of an input extracted + * from a previous spend and always takes priority over the type-based estimate. + */ +export function estimateInputVsize(info: AddressTypeInfo, observedVsize?: number): { vsize: number; estimated: boolean } { + if (observedVsize !== undefined) { + return { vsize: observedVsize, estimated: false }; + } + + switch (info.type) { + case 'p2pk': + case 'p2pkh': + case 'p2sh-p2wpkh': + case 'v0_p2wpkh': + return { vsize: INPUT_VSIZE[info.type], estimated: false }; + case 'v1_p2tr': + // 58 vB is a typical key-path estimate; without an observed spend we + // can't tell key-path from a (variable) script-path spend, so it stays + // approximate + return { vsize: INPUT_VSIZE.v1_p2tr, estimated: true }; + case 'v0_p2wsh': + if (info.isMultisig) { + return { vsize: multisigWitnessInputVsize(info.isMultisig.m, info.isMultisig.n, false), estimated: true }; + } + return { vsize: 105, estimated: true }; // assume 2-of-3 + case 'p2sh-p2wsh': + if (info.isMultisig) { + return { vsize: multisigWitnessInputVsize(info.isMultisig.m, info.isMultisig.n, true), estimated: true }; + } + return { vsize: 139, estimated: true }; // assume 2-of-3 + case 'multisig': + if (info.isMultisig) { + // bare multisig: signatures + redeemscript in the scriptsig (no discount) + return { vsize: 41 + info.isMultisig.m * 73 + (3 + info.isMultisig.n * 34), estimated: true }; + } + return { vsize: 252, estimated: true }; // assume 2-of-3 + case 'p2sh': + // unresolved p2sh (no spend seen yet): assume nested segwit single-key + return { vsize: INPUT_VSIZE['p2sh-p2wpkh'], estimated: true }; + default: + return { vsize: INPUT_VSIZE.v0_p2wpkh, estimated: true }; + } +} + export interface AddressMatch { prefix: string; postfix: string; From b569880359aa9c247a57da9cb4411ac779c29d94 Mon Sep 17 00:00:00 2001 From: jramos0 Date: Fri, 19 Jun 2026 04:14:41 -0600 Subject: [PATCH 2/5] Feat: UTXO cost spending page progress --- .../cost-to-spend.component.html | 86 ++++++++++++------- .../cost-to-spend.component.scss | 13 ++- .../cost-to-spend/cost-to-spend.component.ts | 9 +- 3 files changed, 68 insertions(+), 40 deletions(-) diff --git a/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.html b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.html index 3bd6b5319..4c73d4c01 100644 --- a/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.html +++ b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.html @@ -16,42 +16,22 @@ >{{ cost.inputVsize | number }} vB + + Spendable UTXOs + {{ utxoCount | number }} + Fee rate (~30 min) - - - Max cost to spend - - - - - -

- - - - + + + + + + + + @@ -106,7 +128,7 @@
Spendable UTXOs{{ utxoCount | number }}
- Effective balance rangeMax cost to spend - + +
+ Min effective balance + + +
+ Max effective balance + + +
- + diff --git a/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.scss b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.scss index d2e282583..4af8a0680 100644 --- a/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.scss +++ b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.scss @@ -1,6 +1,15 @@ .cost-to-spend-table { margin-bottom: 0; + thead th { + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--transparent-fg); + padding-bottom: 2px; + } + tr td { &:last-child { text-align: right; @@ -22,7 +31,3 @@ .estimate-marker { cursor: help; } - -.range-sep { - margin: 0 4px; -} diff --git a/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.ts b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.ts index 002905459..148d0dbc3 100644 --- a/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.ts +++ b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.ts @@ -21,8 +21,8 @@ interface CostToSpend { estimated: boolean; minCost: number; maxCost: number; - worstEffectiveBalance: number; - bestEffectiveBalance: number; + minEffectiveBalance: number; + maxEffectiveBalance: number; } @Component({ @@ -70,8 +70,9 @@ export class CostToSpendComponent implements OnInit, OnChanges { estimated, minCost, maxCost, - worstEffectiveBalance: Math.max(0, this.balance - maxCost), - bestEffectiveBalance: Math.max(0, this.balance - minCost), + // min effective balance subtracts the max cost; max subtracts the min + minEffectiveBalance: Math.max(0, this.balance - maxCost), + maxEffectiveBalance: Math.max(0, this.balance - minCost), }; } } From bc48e2314a38927b70d9e263f52cdcf91b8b0a65 Mon Sep 17 00:00:00 2001 From: jramos0 Date: Tue, 23 Jun 2026 04:22:26 -0600 Subject: [PATCH 3/5] Adding Cost to spend display --- .../components/address/address.component.html | 14 +- .../components/address/address.component.scss | 10 - .../components/address/address.component.ts | 7 +- .../cost-to-spend.component.html | 273 +++++++++--------- .../cost-to-spend/cost-to-spend.component.ts | 31 +- frontend/src/app/shared/address-utils.ts | 37 +++ 6 files changed, 215 insertions(+), 157 deletions(-) diff --git a/frontend/src/app/components/address/address.component.html b/frontend/src/app/components/address/address.component.html index e9760b8f6..fab8d0242 100644 --- a/frontend/src/app/components/address/address.component.html +++ b/frontend/src/app/components/address/address.component.html @@ -135,15 +135,17 @@ - +
-

- Cost to Spend - -

+

Cost to Spend

+ +
- +

diff --git a/frontend/src/app/components/address/address.component.scss b/frontend/src/app/components/address/address.component.scss index 04051a9dc..06e15ad67 100644 --- a/frontend/src/app/components/address/address.component.scss +++ b/frontend/src/app/components/address/address.component.scss @@ -133,13 +133,3 @@ h1 { padding-bottom: 0; font-size: 0.8rem; } - -.cost-to-spend-toggle { - cursor: pointer; - user-select: none; - fa-icon { - font-size: 0.8em; - margin-left: 6px; - color: var(--transparent-fg); - } -} diff --git a/frontend/src/app/components/address/address.component.ts b/frontend/src/app/components/address/address.component.ts index a3a97a1f4..00853dcbb 100644 --- a/frontend/src/app/components/address/address.component.ts +++ b/frontend/src/app/components/address/address.component.ts @@ -12,7 +12,7 @@ import { of, merge, Subscription, Observable, forkJoin } from 'rxjs'; import { SeoService } from '@app/services/seo.service'; import { seoDescriptionNetwork } from '@app/shared/common.utils'; import { AddressInformation } from '@interfaces/node-api.interface'; -import { AddressTypeInfo } from '@app/shared/address-utils'; +import { AddressTypeInfo, observedInputVsize } from '@app/shared/address-utils'; import { extractTapLeaves, fillTapTree, convertTextToBuffer, PsbtKeyValue } from '@app/shared/transaction.utils'; class AddressStats implements ChainStats { @@ -327,6 +327,7 @@ export class AddressComponent implements OnInit, OnDestroy { }); } this.addressTypeInfo.processInputs(addressVin, vinIds); + this.addressTypeInfo.observedInputVsize = observedInputVsize(addressVin); if (this.addressTypeInfo.type === 'v1_p2tr' && !this.addressTypeInfo.tapscript) { this.setTapTreeIncomplete(true); } @@ -521,6 +522,10 @@ export class AddressComponent implements OnInit, OnDestroy { this.mempoolStats = new AddressStats(this.address.mempool_stats, this.address.address); } + get spendableUtxoCount(): number { + return this.chainStats.utxos + this.mempoolStats.utxos; + } + setBalancePeriod(period: 'all' | '1m'): boolean { this.balancePeriod = period; return false; diff --git a/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.html b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.html index 4c73d4c01..1df233640 100644 --- a/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.html +++ b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.html @@ -1,139 +1,152 @@ -
-
-
-
- - - - - - - - - - - - - - -
vsize per input - ~{{ cost.inputVsize | number }} vB -
Spendable UTXOs{{ utxoCount | number }}
Fee rate (~30 min) - -
-
-
- - - - - - - - - - - - - - - - - - - -
- Min cost to spend - - - -
- Max cost to spend - - - -
- Min effective balance - - - -
- Max effective balance - - - -
-
- - - - -
+ +
-
+
- - + + + + + + + + + + + + + +
vsize per input + ~{{ cost.inputVsize | number }} vB +
Spendable UTXOs{{ utxoCount | number }}
Fee rate (~30 min) + +
+
+
+ + + + + + + + + + + + + + + + +
+ Spend all together + + + +
+ Spend separately + + + +
+ Min effective balance + + + +
+ Max effective balance + + + +
- + + + +
+
+ Fee data unavailable +
+
+
+ +
+
+
+ + + + + + +
+
+
+
+
+
+ diff --git a/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.ts b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.ts index 148d0dbc3..292afb26b 100644 --- a/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.ts +++ b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.ts @@ -5,8 +5,8 @@ import { OnChanges, OnInit, } from '@angular/core'; -import { BehaviorSubject, Observable, combineLatest } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { BehaviorSubject, Observable, combineLatest, of } from 'rxjs'; +import { map, timeout, catchError, startWith } from 'rxjs/operators'; import { StateService } from '@app/services/state.service'; import { AddressTypeInfo, @@ -15,6 +15,9 @@ import { estimateInputVsize, } from '@app/shared/address-utils'; +// give up waiting for fees over the websocket after this long +const FEE_TIMEOUT_MS = 10000; + interface CostToSpend { feeRate: number; inputVsize: number; @@ -37,7 +40,8 @@ export class CostToSpendComponent implements OnInit, OnChanges { @Input() utxoCount: number; @Input() balance: number; - costToSpend$: Observable; + costToSpend$: Observable; + // Bridges @Input changes into the reactive pipeline so cost recalculates on live balance updates private inputs$ = new BehaviorSubject(undefined); constructor(private stateService: StateService) {} @@ -46,7 +50,12 @@ export class CostToSpendComponent implements OnInit, OnChanges { this.costToSpend$ = combineLatest([ this.stateService.recommendedFees$, this.inputs$, - ]).pipe(map(([fees]) => this.calculate(fees.halfHourFee))); + ]).pipe( + map(([fees]) => this.calculate(fees.halfHourFee)), + timeout({ first: FEE_TIMEOUT_MS }), // fees never arrived (websocket failure) + catchError(() => of(null)), // null = unavailable; stream closes, but *ngIf recreates the component on navigation so the state can't persist across addresses + startWith(undefined), + ); } ngOnChanges(): void { @@ -55,13 +64,15 @@ export class CostToSpendComponent implements OnInit, OnChanges { private calculate(feeRate: number): CostToSpend { const { vsize: inputVsize, estimated } = estimateInputVsize( - this.addressTypeInfo + this.addressTypeInfo, + this.addressTypeInfo.observedInputVsize ); const overhead = TX_OVERHEAD_VSIZE + TYPICAL_OUTPUT_VSIZE; - // min: consolidate every UTXO in a single transaction (overhead amortized) - const minCost = Math.round(this.utxoCount * inputVsize * feeRate); - // max: spend each UTXO in its own transaction, rounding each individual - // spend up to whole satoshis before summing + // consolidate all UTXOs into one tx overhead paid once (theoretical lower bound) + const minCost = Math.ceil( + (this.utxoCount * inputVsize + overhead) * feeRate + ); + // max spend each UTXO in its own transaction, rounding each individual const maxCost = this.utxoCount * Math.ceil((inputVsize + overhead) * feeRate); return { @@ -70,7 +81,7 @@ export class CostToSpendComponent implements OnInit, OnChanges { estimated, minCost, maxCost, - // min effective balance subtracts the max cost; max subtracts the min + // min effective balance subtracts the max cost & max subtracts the min minEffectiveBalance: Math.max(0, this.balance - maxCost), maxEffectiveBalance: Math.max(0, this.balance - minCost), }; diff --git a/frontend/src/app/shared/address-utils.ts b/frontend/src/app/shared/address-utils.ts index 6223fcb85..a9481b0b0 100644 --- a/frontend/src/app/shared/address-utils.ts +++ b/frontend/src/app/shared/address-utils.ts @@ -137,6 +137,7 @@ export class AddressTypeInfo { isMultisig?: { m: number, n: number }; tapscript?: boolean; simplicity?: boolean; + observedInputVsize?: number; // median realized input vsize from previous spends constructor (network: string, address: string, type?: AddressType, vin?: Vin[], vout?: Vout) { this.network = network; @@ -283,6 +284,42 @@ function multisigWitnessInputVsize(m: number, n: number, wrapped: boolean): numb return Math.round(nonWitness + witnessBytes / 4); } +// realized weight (WU) of a spent input, reconstructed from its on-chain scriptsig + witness +function vinWeightUnits(vin: Vin): number | null { + if (!vin || vin.is_coinbase) { + return null; + } + const scriptsigLen = (vin.scriptsig?.length || 0) / 2; + const base = 36 + getVarIntLength(scriptsigLen) + scriptsigLen + 4; + let witnessBytes = 0; + // count witness only when present; legacy inputs carry no witness section + if (vin.witness && vin.witness.length) { + witnessBytes += getVarIntLength(vin.witness.length); + for (const item of vin.witness) { + const itemLen = item.length / 2; + witnessBytes += getVarIntLength(itemLen) + itemLen; + } + } + return base * 4 + witnessBytes; +} + +/** + * Median realized input vsize (vB) across previous spends, or undefined if none + * are measurable. Kept fractional so rounding error doesn't accumulate per UTXO. + */ +export function observedInputVsize(vins: Vin[]): number | undefined { + const vsizes = (vins || []) + .map((v) => vinWeightUnits(v)) + .filter((wu): wu is number => wu !== null && wu > 0) + .map((wu) => wu / 4); + if (!vsizes.length) { + return undefined; + } + vsizes.sort((a, b) => a - b); + const mid = Math.floor(vsizes.length / 2); + return vsizes.length % 2 ? vsizes[mid] : (vsizes[mid - 1] + vsizes[mid]) / 2; +} + /** * Estimates the vsize (vB) of a single input spending from this address. * From f3d79dbda8cb28a6e1b67dda8eeab82ed2bdd034 Mon Sep 17 00:00:00 2001 From: jramos0 Date: Tue, 23 Jun 2026 22:35:11 -0600 Subject: [PATCH 4/5] Fixing copilot warnings --- frontend/src/app/shared/address-utils.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/shared/address-utils.ts b/frontend/src/app/shared/address-utils.ts index a9481b0b0..8190aa220 100644 --- a/frontend/src/app/shared/address-utils.ts +++ b/frontend/src/app/shared/address-utils.ts @@ -160,6 +160,7 @@ export class AddressTypeInfo { cloned.isMultisig = this.isMultisig; cloned.tapscript = this.tapscript; cloned.simplicity = this.simplicity; + cloned.observedInputVsize = this.observedInputVsize; return cloned; } @@ -278,10 +279,12 @@ const INPUT_VSIZE: Partial> = { // rough vsize of an m-of-n multisig input. `wrapped` adds the p2sh redeemscript // pushed in the scriptsig (p2sh-p2wsh); otherwise native p2wsh. function multisigWitnessInputVsize(m: number, n: number, wrapped: boolean): number { - const nonWitness = wrapped ? 75 : 41; // outpoint + sequence (+ redeemscript push) + // wrapped (p2sh-p2wsh) scriptsig pushes the 34-byte witness program: 1-byte push opcode + 34 bytes + const nonWitness = wrapped ? 76 : 41; // outpoint + sequence (+ redeemscript push) const witnessScript = 3 + n * 34; // OP_m + n*push33 + OP_n + OP_CHECKMULTISIG - const witnessBytes = 1 + 1 + m * 73 + getVarIntLength(witnessScript) + witnessScript; - return Math.round(nonWitness + witnessBytes / 4); + // stack: item count + dummy + m*(length prefix + signature) + script length prefix + script + const witnessBytes = 1 + 1 + m * (1 + 72) + getVarIntLength(witnessScript) + witnessScript; + return Math.ceil(nonWitness + witnessBytes / 4); // consensus vsize rounds up } // realized weight (WU) of a spent input, reconstructed from its on-chain scriptsig + witness From 852f0de7eb83ff9ab45f0a824b90fab79f9478e0 Mon Sep 17 00:00:00 2001 From: jramos0 Date: Wed, 24 Jun 2026 03:36:13 -0600 Subject: [PATCH 5/5] Improving reading of the cost to spend table --- .../components/address/address.component.html | 2 +- .../cost-to-spend.component.html | 31 +++++++++++++++++-- .../cost-to-spend.component.scss | 9 ------ 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/components/address/address.component.html b/frontend/src/app/components/address/address.component.html index fab8d0242..fd6b1ae45 100644 --- a/frontend/src/app/components/address/address.component.html +++ b/frontend/src/app/components/address/address.component.html @@ -141,7 +141,7 @@

Cost to Spend

diff --git a/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.html b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.html index 1df233640..ef4e39690 100644 --- a/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.html +++ b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.html @@ -1,7 +1,13 @@
-
+
+
+ Parameters +
@@ -31,7 +37,15 @@
- +
+ Cost to spend +
+
+ +
+
+ Effective balance +
+ +