This commit is contained in:
Jonathan 2026-08-13 11:34:31 +07:00 committed by GitHub
commit 55c2e651af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 432 additions and 3 deletions

View file

@ -135,6 +135,19 @@
</div>
</ng-container>
<ng-container *ngIf="network !== 'liquid' && network !== 'liquidtestnet' && !address.electrum && addressTypeInfo && spendableUtxoCount > 0">
<br>
<div class="title-tx">
<h2 *ngIf="showCostToSpend" class="text-start float-start" i18n="address.cost-to-spend">Cost to Spend</h2>
<button type="button" class="btn btn-outline-info btn-sm float-end" (click)="showCostToSpend = !showCostToSpend">
<ng-container *ngIf="!showCostToSpend" i18n="address.show-cost-to-spend|Show cost to spend">Cost to Spend</ng-container>
<ng-container *ngIf="showCostToSpend" i18n="address.hide-cost-to-spend|Hide cost to spend">Hide</ng-container>
</button>
<div class="clearfix"></div>
</div>
<app-cost-to-spend *ngIf="showCostToSpend" [addressTypeInfo]="addressTypeInfo" [utxoCount]="spendableUtxoCount" [balance]="chainStats.balance + mempoolStats.balance"></app-cost-to-spend>
</ng-container>
<br>
<div class="title-tx">
<h2 class="text-start">

View file

@ -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;

View file

@ -0,0 +1,179 @@
<ng-container *ngIf="{ cost: costToSpend$ | async } as state">
<div class="box" *ngIf="state.cost as cost; else notReady">
<div class="row align-items-start">
<div class="col-md">
<div
class="text-muted small text-uppercase mb-1"
i18n="address.cost-to-spend.parameters|Cost to spend parameters group"
>
Parameters
</div>
<table class="table table-borderless table-striped cost-to-spend-table">
<tbody>
<tr>
<td i18n="address.cost-to-spend.input-vsize">vsize per input</td>
<td class="wrap-cell">
<span
*ngIf="cost.estimated"
class="estimate-marker"
i18n-ngbTooltip="address.cost-to-spend.estimated-tooltip"
ngbTooltip="Estimated for this script type; actual size varies"
placement="bottom"
>~</span
>{{ cost.inputVsize | number }} <span class="symbol">vB</span>
</td>
</tr>
<tr>
<td i18n="address.cost-to-spend.utxos">Spendable UTXOs</td>
<td class="wrap-cell">{{ utxoCount | number }}</td>
</tr>
<tr>
<td i18n="address.cost-to-spend.fee-rate">Fee rate (~30 min)</td>
<td class="wrap-cell">
<app-fee-rate [fee]="cost.feeRate"></app-fee-rate>
</td>
</tr>
</tbody>
</table>
</div>
<div class="col-md">
<div
class="text-muted small text-uppercase mb-1"
i18n="address.cost-to-spend.group-cost|Cost to spend group header"
>
Cost to spend
</div>
<table
class="table table-borderless table-striped cost-to-spend-table mb-3"
>
<tbody>
<tr>
<td
i18n-ngbTooltip="address.cost-to-spend.min-cost-tooltip"
ngbTooltip="Best case: all UTXOs consolidated into a single transaction, so the per-transaction overhead is shared"
placement="bottom"
>
<span i18n="address.cost-to-spend.min-cost"
>Spend all together</span
>
</td>
<td class="wrap-cell">
<app-amount
[satoshis]="cost.minCost"
[noFiat]="true"
></app-amount>
<span class="fiat"
><app-fiat [value]="cost.minCost"></app-fiat
></span>
</td>
</tr>
<tr>
<td
i18n-ngbTooltip="address.cost-to-spend.max-cost-tooltip"
ngbTooltip="Worst case: each UTXO is spent separately, each in its own transaction with one typical output, so every spend pays full overhead"
placement="bottom"
>
<span i18n="address.cost-to-spend.max-cost"
>Spend separately</span
>
</td>
<td class="wrap-cell">
<app-amount
[satoshis]="cost.maxCost"
[noFiat]="true"
></app-amount>
<span class="fiat"
><app-fiat [value]="cost.maxCost"></app-fiat
></span>
</td>
</tr>
</tbody>
</table>
<div
class="text-muted small text-uppercase mb-1"
i18n="
address.cost-to-spend.group-effective-balance|Effective balance
group header
"
>
Effective balance
</div>
<table class="table table-borderless table-striped cost-to-spend-table">
<tbody>
<tr>
<td
i18n-ngbTooltip="
address.cost-to-spend.min-effective-balance-tooltip
"
ngbTooltip="Balance minus the maximum cost to spend (each UTXO spent separately)"
placement="bottom"
>
<span i18n="address.cost-to-spend.min-effective-balance"
>Min effective balance</span
>
</td>
<td class="wrap-cell">
<app-amount
[satoshis]="cost.minEffectiveBalance"
[noFiat]="true"
></app-amount>
<span class="fiat"
><app-fiat [value]="cost.minEffectiveBalance"></app-fiat
></span>
</td>
</tr>
<tr>
<td
i18n-ngbTooltip="
address.cost-to-spend.max-effective-balance-tooltip
"
ngbTooltip="Balance minus the minimum cost to spend (all UTXOs consolidated)"
placement="bottom"
>
<span i18n="address.cost-to-spend.max-effective-balance"
>Max effective balance</span
>
</td>
<td class="wrap-cell">
<app-amount
[satoshis]="cost.maxEffectiveBalance"
[noFiat]="true"
></app-amount>
<span class="fiat"
><app-fiat [value]="cost.maxEffectiveBalance"></app-fiat
></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<ng-template #notReady>
<ng-container *ngIf="state.cost === null; else loading">
<div class="box">
<div class="text-center" i18n="address.cost-to-spend.unavailable">
Fee data unavailable
</div>
</div>
</ng-container>
<ng-template #loading>
<div class="box">
<div class="row">
<div class="col-md" *ngFor="let col of [1, 2]">
<table
class="table table-borderless table-striped cost-to-spend-table"
>
<tbody>
<tr *ngFor="let i of [1, 2, 3, 4]">
<td colspan="2"><span class="skeleton-loader"></span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</ng-template>
</ng-template>
</ng-container>

View file

@ -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;
}

View file

@ -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<CostToSpend | null | undefined>;
// Bridges @Input changes into the reactive pipeline so cost recalculates on live balance updates
private inputs$ = new BehaviorSubject<void>(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),
};
}
}

View file

@ -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,

View file

@ -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<Record<AddressType, number>> = {
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;