From 6842965d59cf2d7f0d5a33ac2e1582577e4a9caa Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 2 Jul 2025 13:16:16 +0200 Subject: [PATCH] Tx preview: fill missing signature weight in unsigned transactions --- .../transaction-raw.component.html | 20 ++- .../transaction-raw.component.scss | 5 + .../transaction/transaction-raw.component.ts | 78 ++++++++- frontend/src/app/shared/transaction.utils.ts | 158 +++++++++++++++++- 4 files changed, 244 insertions(+), 17 deletions(-) diff --git a/frontend/src/app/components/transaction/transaction-raw.component.html b/frontend/src/app/components/transaction/transaction-raw.component.html index 40c45bf45..7fc449ca4 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.html +++ b/frontend/src/app/components/transaction/transaction-raw.component.html @@ -54,7 +54,10 @@ Redirecting to transaction page... - + } @@ -159,11 +162,17 @@ Size - + + + + Virtual size - + + + + Adjusted vsize @@ -175,7 +184,10 @@ Weight - + + + + diff --git a/frontend/src/app/components/transaction/transaction-raw.component.scss b/frontend/src/app/components/transaction/transaction-raw.component.scss index a4b386cee..8d5402ca0 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.scss +++ b/frontend/src/app/components/transaction/transaction-raw.component.scss @@ -199,4 +199,9 @@ margin-left: 0; margin-top: 5px; } +} + +.icon-symbol { + color: rgba(255, 255, 255, 0.4); + margin-left: 5px; } \ No newline at end of file diff --git a/frontend/src/app/components/transaction/transaction-raw.component.ts b/frontend/src/app/components/transaction/transaction-raw.component.ts index b3559769c..c77029530 100644 --- a/frontend/src/app/components/transaction/transaction-raw.component.ts +++ b/frontend/src/app/components/transaction/transaction-raw.component.ts @@ -1,8 +1,11 @@ import { Component, OnInit, HostListener, ViewChild, ElementRef, OnDestroy } from '@angular/core'; +import { BytesPipe } from '../../shared/pipes/bytes-pipe/bytes.pipe'; +import { VbytesPipe } from '../../shared/pipes/bytes-pipe/vbytes.pipe'; +import { WuBytesPipe } from '../../shared/pipes/bytes-pipe/wubytes.pipe'; import { Transaction, Vout } from '@interfaces/electrs.interface'; import { StateService } from '../../services/state.service'; import { Filter, toFilters } from '../../shared/filters.utils'; -import { decodeRawTransaction, getTransactionFlags, addInnerScriptsToVin, countSigops } from '../../shared/transaction.utils'; +import { decodeRawTransaction, getTransactionFlags, addInnerScriptsToVin, countSigops, fillUnsignedInput } from '../../shared/transaction.utils'; import { catchError, firstValueFrom, Subscription, switchMap, tap, throwError, timer } from 'rxjs'; import { WebsocketService } from '../../services/websocket.service'; import { ActivatedRoute, Router } from '@angular/router'; @@ -40,6 +43,12 @@ export class TransactionRawComponent implements OnInit, OnDestroy { isCoinbase: boolean; broadcastSubscription: Subscription; fragmentSubscription: Subscription; + weightFromMissingSig: number = 0; + sizeFromMissingSig: number = 0; + missingSignatures: boolean; + tooltipSize: string; + tooltipVsize: string; + tooltipWeight: string; isMobile: boolean; @ViewChild('graphContainer') @@ -71,6 +80,9 @@ export class TransactionRawComponent implements OnInit, OnDestroy { public seoService: SeoService, public apiService: ApiService, public relativeUrlPipe: RelativeUrlPipe, + public bytesPipe: BytesPipe, + public vbytesPipe: VbytesPipe, + public wuBytesPipe: WuBytesPipe, ) {} ngOnInit(): void { @@ -105,6 +117,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { try { const { tx, hex, psbt } = decodeRawTransaction(this.pushTxForm.get('txRaw').value.trim(), this.stateService.network); await this.fetchPrevouts(tx); + this.checkSignatures(tx, hex); await this.fetchCpfpInfo(tx); this.processTransaction(tx, hex, psbt); } catch (error) { @@ -157,6 +170,45 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.isLoadingPrevouts = false; } } + } + + checkSignatures(transaction: Transaction, hex: string): void { + let missingWitnessBytes = 0; + let missingNonWitnessBytes = 0; + + let isSegwitTransaction = hex.substring(8, 10) === '00' && hex.substring(10, 12) === '01'; + let segwitFlagSet = false; + + transaction.vin.forEach(vin => { + addInnerScriptsToVin(vin); + const result = fillUnsignedInput(vin); + vin['_missingSigs'] = result.missingSigs; + if (result.addToWitness) { + missingWitnessBytes += result.bytes; + } else { + missingNonWitnessBytes += result.bytes; + } + if (!isSegwitTransaction && result.addToWitness) { + segwitFlagSet = true; + isSegwitTransaction = true; + } + }); + + if (segwitFlagSet) { // we just added witness to a legacy transaction: need to add weight corresponding to segwit flag and compact sizes + missingWitnessBytes += 2; // marker and flag + missingWitnessBytes += transaction.vin.length; // 1 byte compact size per input (assume witness stack count < 253) + } + + this.sizeFromMissingSig = missingWitnessBytes + missingNonWitnessBytes; + this.weightFromMissingSig = missingWitnessBytes + 4 * missingNonWitnessBytes; + + if (this.weightFromMissingSig) { + this.tooltipSize = `Includes ${this.bytesPipe.transform(this.sizeFromMissingSig, 2, undefined, undefined, true)} added for missing signatures`; + this.tooltipVsize = `Includes ${this.vbytesPipe.transform(this.weightFromMissingSig / 4, 2, undefined, undefined, true)} added for missing signatures`; + this.tooltipWeight = `Includes ${this.wuBytesPipe.transform(this.weightFromMissingSig, 2, undefined, undefined, true)} added for missing signatures`; + } + + this.missingSignatures = transaction.vin.some(input => input['_missingSigs'] > 0); if (this.hasPrevouts) { transaction.fee = transaction.vin.some(input => input.is_coinbase) @@ -164,11 +216,16 @@ export class TransactionRawComponent implements OnInit, OnDestroy { : transaction.vin.reduce((fee, input) => { return fee + (input.prevout?.value || 0); }, 0) - transaction.vout.reduce((sum, output) => sum + output.value, 0); - transaction.feePerVsize = transaction.fee / (transaction.weight / 4); + transaction.feePerVsize = transaction.fee / ((transaction.weight + this.weightFromMissingSig) / 4); + transaction.sigops = countSigops(transaction); + this.adjustedVsize = Math.max((transaction.weight + this.weightFromMissingSig) / 4, transaction.sigops * 5); + const adjustedFeePerVsize = transaction.fee / this.adjustedVsize; + if (adjustedFeePerVsize !== transaction.feePerVsize) { + transaction.effectiveFeePerVsize = adjustedFeePerVsize; + this.cpfpInfo = { ancestors: [], effectiveFeePerVsize: adjustedFeePerVsize }; + this.hasEffectiveFeeRate = true; + } } - - transaction.vin.forEach(addInnerScriptsToVin); - transaction.sigops = countSigops(transaction); } async fetchCpfpInfo(transaction: Transaction): Promise { @@ -178,7 +235,7 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.isLoadingCpfpInfo = true; const cpfpInfo: CpfpInfo[] = await firstValueFrom(this.apiService.getCpfpLocalTx$([{ txid: transaction.txid, - weight: transaction.weight, + weight: transaction.weight + this.weightFromMissingSig, sigops: transaction.sigops, fee: transaction.fee, vin: transaction.vin, @@ -215,9 +272,6 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.transaction.flags = getTransactionFlags(this.transaction, this.cpfpInfo, null, null, this.stateService.network); this.filters = this.transaction.flags ? toFilters(this.transaction.flags).filter(f => f.txPage) : []; - if (this.transaction.sigops >= 0) { - this.adjustedVsize = Math.max(this.transaction.weight / 4, this.transaction.sigops * 5); - } this.setupGraph(); this.setFlowEnabled(); @@ -286,6 +340,12 @@ export class TransactionRawComponent implements OnInit, OnDestroy { this.filters = []; this.hasPrevouts = false; this.missingPrevouts = []; + this.weightFromMissingSig = 0; + this.sizeFromMissingSig = 0; + this.missingSignatures = false; + this.tooltipSize = null; + this.tooltipVsize = null; + this.tooltipWeight = null; this.stateService.markBlock$.next({}); this.mempoolBlocksSubscription?.unsubscribe(); this.broadcastSubscription?.unsubscribe(); diff --git a/frontend/src/app/shared/transaction.utils.ts b/frontend/src/app/shared/transaction.utils.ts index 17e47fcb8..b98067238 100644 --- a/frontend/src/app/shared/transaction.utils.ts +++ b/frontend/src/app/shared/transaction.utils.ts @@ -1,5 +1,5 @@ import { TransactionFlags } from '@app/shared/filters.utils'; -import { getVarIntLength, parseMultisigScript, isPoint } from '@app/shared/script.utils'; +import { getVarIntLength, parseMultisigScript, isPoint, parseTapscriptMultisig, parseTapscriptUnanimousMultisig } from '@app/shared/script.utils'; import { Transaction, Vin } from '@interfaces/electrs.interface'; import { CpfpInfo, RbfInfo, TransactionStripped } from '@interfaces/node-api.interface'; import { StateService } from '@app/services/state.service'; @@ -287,12 +287,12 @@ export function processInputSignatures(vin: Vin): SigInfo[] { signatures = extractDERSignaturesWitness(vin.witness || []); break; case 'v1_p2tr': { - const hasAnnex = vin.witness.length > 1 &&vin.witness[vin.witness.length - 1].startsWith('50'); - const isKeyspend = vin.witness.length === (hasAnnex ? 2 : 1); + const hasAnnex = vin.witness?.length > 1 && vin.witness[vin.witness.length - 1].startsWith('50'); + const isKeyspend = vin.witness?.length === (hasAnnex ? 2 : 1); if (isKeyspend) { signatures = extractSchnorrSignatures(vin.witness); } else { - const stackItems = vin.witness.slice(0, hasAnnex ? -3 : -2); + const stackItems = vin.witness?.slice(0, hasAnnex ? -3 : -2); signatures = extractSchnorrSignatures(stackItems); } } break; @@ -303,6 +303,156 @@ export function processInputSignatures(vin: Vin): SigInfo[] { return signatures; } +/* + * returns the number of missing signatures, the number of bytes to add to the transaction + * and whether these should benefit from witness discounting + * - Add a DER sig in scriptsig/witness: 71 bytes signature + 1 push or witness size byte = 72 bytes + * - Add a public key in scriptsig/witness: 33 bytes pubkey + 1 push or witness size byte = 34 bytes + * - Add a Schnorr sig in witness: 64 bytes signature + 1 witness size byte = 65 bytes +*/ +export function fillUnsignedInput(vin: Vin): { missingSigs: number, bytes: number, addToWitness: boolean } { + let missingSigs = 0; + let bytes = 0; + let addToWitness = false; + + const addressType = vin.prevout?.scriptpubkey_type as AddressType; + let signatures: SigInfo[] = []; + let multisig: { m: number, n: number } | null = null; + switch (addressType) { + case 'p2pk': + signatures = extractDERSignaturesASM(vin.scriptsig_asm); + if (!signatures.length) { + missingSigs = 1; + bytes = 72; + } + break; + case 'multisig': + signatures = extractDERSignaturesASM(vin.scriptsig_asm); + multisig = parseMultisigScript(vin.prevout.scriptpubkey_asm); + if (multisig && multisig.m - signatures.length > 0) { + missingSigs = multisig.m - signatures.length; + bytes = 72 * missingSigs + 1; // add empty stack item required for OP_CHECKMULTISIG + const scriptsigLength = vin.scriptsig.length / 2; + const newLength = scriptsigLength + bytes; + if (scriptsigLength < 253 && newLength >= 253) { + bytes += 2; // Increase scriptsig's compact size from 1 to 3 bytes + } + } + break; + case 'p2pkh': + signatures = extractDERSignaturesASM(vin.scriptsig_asm); + if (!signatures.length) { + missingSigs = 1; + bytes = 106; // 72 + 34 (sig + public key) + } + break; + case 'p2sh': + // Check for P2SH multisig + multisig = parseMultisigScript(vin.inner_redeemscript_asm); + if (multisig) { + signatures = extractDERSignaturesASM(vin.scriptsig_asm); + if (multisig.m - signatures.length > 0) { + missingSigs = multisig.m - signatures.length; + bytes = 72 * missingSigs + 1; // empty push required for OP_CHECKMULTISIG + const scriptsigLength = vin.scriptsig.length / 2; + const newLength = scriptsigLength + bytes; + if (scriptsigLength < 253 && newLength >= 253) { + bytes += 2; // Increase scriptsig's compact size from 1 to 3 bytes + } + } + } + + // P2SH-P2WSH + if (/OP_0 OP_PUSHBYTES_32 [a-fA-F0-9]{64}/.test(vin.inner_redeemscript_asm) && vin.inner_witnessscript_asm) { + // Check for P2WSH multisig + multisig = parseMultisigScript(vin.inner_witnessscript_asm); + if (multisig) { + signatures = extractDERSignaturesWitness(vin.witness || []); + if (multisig.m - signatures.length > 0) { + missingSigs = multisig.m - signatures.length; + bytes = 72 * missingSigs + 1; // empty push required for OP_CHECKMULTISIG + addToWitness = true; + } + } + } + + // P2SH-P2WPKH + if (/OP_0 OP_PUSHBYTES_20 [a-fA-F0-9]{40}/.test(vin.inner_redeemscript_asm)) { + signatures = extractDERSignaturesWitness(vin.witness || []); + if (!signatures.length) { + missingSigs = 1; + bytes = 106; // 72 + 34 (sig + public key) + addToWitness = true; + } + } + break; + case 'v0_p2wpkh': + signatures = extractDERSignaturesWitness(vin.witness || []); + if (!signatures.length) { + missingSigs = 1; + bytes = 106; // 72 + 34 (sig + public key) + addToWitness = true; + } + break; + case 'v0_p2wsh': + signatures = extractDERSignaturesWitness(vin.witness || []); + multisig = parseMultisigScript(vin.inner_witnessscript_asm); + if (multisig) { + signatures = extractDERSignaturesWitness(vin.witness || []); + if (multisig.m - signatures.length > 0) { + missingSigs = multisig.m - signatures.length; + bytes = 72 * missingSigs + 1; // empty push required for OP_CHECKMULTISIG + addToWitness = true; + } + } + break; + case 'v1_p2tr': + const hasAnnex = vin.witness?.length > 1 && vin.witness[vin.witness.length - 1].startsWith('50'); + if (vin.inner_witnessscript_asm) { + const stackItems = vin.witness.slice(0, hasAnnex ? -3 : -2); + if (/^OP_PUSHBYTES_32 [a-fA-F0-9]{64} OP_CHECKSIG$/.test(vin.inner_witnessscript_asm)) { + signatures = extractSchnorrSignatures(stackItems); + if (!signatures.length) { + missingSigs = 1; + bytes = 65; + addToWitness = true; + } + } + + multisig = parseTapscriptMultisig(vin.inner_witnessscript_asm); + if (multisig) { + signatures = extractSchnorrSignatures(stackItems); + if (multisig.m - signatures.length > 0) { + missingSigs = multisig.m - signatures.length; + bytes = 65 * missingSigs + (multisig.n - multisig.m); // empty witness items for each non-signing keys + addToWitness = true; + } + } + + let unanimousMultisig = parseTapscriptUnanimousMultisig(vin.inner_witnessscript_asm); + if (unanimousMultisig) { + signatures = extractSchnorrSignatures(stackItems); + if (unanimousMultisig - signatures.length > 0) { + missingSigs = unanimousMultisig - signatures.length; + bytes = 65 * missingSigs; + addToWitness = true; + } + } + } else { // Assume keyspend + signatures = extractSchnorrSignatures(vin.witness?.slice(0, hasAnnex ? -1 : undefined) || []); + if (!signatures.length) { + missingSigs = 1; + bytes = 65; + addToWitness = true; + } + } + break; + default: + break; + } + return { missingSigs, bytes, addToWitness }; +} + /** * Validates most standardness rules *