diff --git a/frontend/src/app/components/address/address.component.html b/frontend/src/app/components/address/address.component.html index f05caf371..fd6b1ae45 100644 --- a/frontend/src/app/components/address/address.component.html +++ b/frontend/src/app/components/address/address.component.html @@ -135,6 +135,19 @@ + +
+
+

Cost to Spend

+ +
+
+ +
+

diff --git a/frontend/src/app/components/address/address.component.ts b/frontend/src/app/components/address/address.component.ts index e11d7cfa4..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 { @@ -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(); @@ -326,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); } @@ -520,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 new file mode 100644 index 000000000..ef4e39690 --- /dev/null +++ b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.html @@ -0,0 +1,179 @@ + +
+
+
+
+ Parameters +
+ + + + + + + + + + + + + + + +
vsize per input + ~{{ cost.inputVsize | number }} vB +
Spendable UTXOs{{ utxoCount | number }}
Fee rate (~30 min) + +
+
+
+
+ Cost to spend +
+ + + + + + + + + + + +
+ Spend all together + + + +
+ Spend separately + + + +
+
+ Effective balance +
+ + + + + + + + + + + +
+ Min effective balance + + + +
+ Max effective balance + + + +
+
+
+
+ + + +
+
+ Fee data unavailable +
+
+
+ +
+
+
+ + + + + + +
+
+
+
+
+
+
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..689add2b3 --- /dev/null +++ b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.scss @@ -0,0 +1,24 @@ +.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; +} 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..292afb26b --- /dev/null +++ b/frontend/src/app/components/address/cost-to-spend/cost-to-spend.component.ts @@ -0,0 +1,89 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + OnChanges, + OnInit, +} from '@angular/core'; +import { BehaviorSubject, Observable, combineLatest, of } from 'rxjs'; +import { map, timeout, catchError, startWith } from 'rxjs/operators'; +import { StateService } from '@app/services/state.service'; +import { + AddressTypeInfo, + TX_OVERHEAD_VSIZE, + TYPICAL_OUTPUT_VSIZE, + 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; + estimated: boolean; + minCost: number; + maxCost: number; + minEffectiveBalance: number; + maxEffectiveBalance: 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; + // Bridges @Input changes into the reactive pipeline so cost recalculates on live balance updates + 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)), + 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 { + this.inputs$.next(); + } + + private calculate(feeRate: number): CostToSpend { + const { vsize: inputVsize, estimated } = estimateInputVsize( + this.addressTypeInfo, + this.addressTypeInfo.observedInputVsize + ); + const overhead = TX_OVERHEAD_VSIZE + TYPICAL_OUTPUT_VSIZE; + // 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 { + feeRate, + inputVsize, + estimated, + minCost, + maxCost, + // 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/graphs/graphs.module.ts b/frontend/src/app/graphs/graphs.module.ts index 737d3a5fa..5bd6b0146 100644 --- a/frontend/src/app/graphs/graphs.module.ts +++ b/frontend/src/app/graphs/graphs.module.ts @@ -36,6 +36,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'; @@ -55,6 +56,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..8190aa220 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'; @@ -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; @@ -159,6 +160,7 @@ export class AddressTypeInfo { cloned.isMultisig = this.isMultisig; cloned.tapscript = this.tapscript; cloned.simplicity = this.simplicity; + cloned.observedInputVsize = this.observedInputVsize; return cloned; } @@ -256,6 +258,120 @@ 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 { + // 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 + // 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 +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. + * + * `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;