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 @@
+ 0">
+
+
+
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;