Tx preview: fill missing signature weight in unsigned transactions

This commit is contained in:
natsoni
2025-07-02 13:16:16 +02:00
parent efaeacc249
commit 6842965d59
4 changed files with 244 additions and 17 deletions

View File

@@ -54,7 +54,10 @@
Redirecting to transaction page...
</ng-container>
</span>
<button *ngIf="!successBroadcast" [disabled]="isLoadingBroadcast" type="button" class="btn btn-sm btn-primary btn-broadcast" i18n="transaction.broadcast|Broadcast" (click)="postTx()">Broadcast</button>
<button *ngIf="!successBroadcast" [disabled]="isLoadingBroadcast || missingSignatures" i18n-ngbTooltip="transaction.missing-signature"
ngbTooltip="Transaction cannot be broadcasted because it's missing signature(s)" placement="bottom"
[disableTooltip]="!missingSignatures" type="button" class="btn btn-sm btn-primary btn-broadcast"
i18n="transaction.broadcast|Broadcast" (click)="postTx()">Broadcast</button>
<button *ngIf="successBroadcast" type="button" class="btn btn-sm btn-success no-cursor btn-broadcast" i18n="transaction.broadcasted|Broadcasted">Broadcasted</button>
</div>
}
@@ -159,11 +162,17 @@
<tbody>
<tr>
<td i18n="block.size">Size</td>
<td [innerHTML]="'&lrm;' + (transaction.size | bytes: 2)"></td>
<td>
<span [innerHTML]="'&lrm;' + (transaction.size + sizeFromMissingSig | bytes: 2)"></span>
<fa-icon *ngIf="tooltipSize" [ngbTooltip]="tooltipSize" class="icon-symbol" [icon]="['fas', 'info-circle']" [fixedWidth]="true"></fa-icon>
</td>
</tr>
<tr>
<td i18n="transaction.vsize|Transaction Virtual Size">Virtual size</td>
<td [innerHTML]="'&lrm;' + (transaction.weight / 4 | vbytes: 2)"></td>
<td>
<span [innerHTML]="'&lrm;' + ((transaction.weight + weightFromMissingSig) / 4 | vbytes: 2)"></span>
<fa-icon *ngIf="tooltipVsize" [ngbTooltip]="tooltipVsize" class="icon-symbol" [icon]="['fas', 'info-circle']" [fixedWidth]="true"></fa-icon>
</td>
</tr>
<tr *ngIf="adjustedVsize">
<td><ng-container i18n="transaction.adjusted-vsize|Transaction Adjusted VSize">Adjusted vsize</ng-container>
@@ -175,7 +184,10 @@
</tr>
<tr>
<td i18n="block.weight">Weight</td>
<td [innerHTML]="'&lrm;' + (transaction.weight | wuBytes: 2)"></td>
<td>
<span [innerHTML]="'&lrm;' + (transaction.weight + weightFromMissingSig | wuBytes: 2)"></span>
<fa-icon *ngIf="tooltipWeight" [ngbTooltip]="tooltipWeight" class="icon-symbol" [icon]="['fas', 'info-circle']" [fixedWidth]="true"></fa-icon>
</td>
</tr>
</tbody>
</table>

View File

@@ -199,4 +199,9 @@
margin-left: 0;
margin-top: 5px;
}
}
.icon-symbol {
color: rgba(255, 255, 255, 0.4);
margin-left: 5px;
}

View File

@@ -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<void> {
@@ -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();

View File

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