Merge pull request #5881 from mempool/mononaut/sighash-highlighting

experimental sighash highlighting
This commit is contained in:
wiz 2025-05-09 00:56:19 -10:00 committed by GitHub
commit 093dffe3ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 760 additions and 25 deletions

View file

@ -147,7 +147,7 @@
</div>
<app-transactions-list #txList [transactions]="[transaction]" [transactionPage]="true" [txPreview]="true"></app-transactions-list>
<app-transactions-list #txList [transactions]="[transaction]" [transactionPage]="true" [txPreview]="true" forceSignaturesMode="all"></app-transactions-list>
<div class="title text-left">
<h2 i18n="transaction.details|Transaction Details">Details</h2>

View file

@ -28,7 +28,8 @@
<ng-template ngFor let-vin let-vindex="index" [ngForOf]="tx.vin.slice(0, getVinLimit(tx))" [ngForTrackBy]="trackByIndexFn">
<tr [ngClass]="{
'assetBox': (assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded) || inputIndex === vindex,
'highlight': this.addresses.length && ((vin.prevout?.scriptpubkey_type !== 'p2pk' && addresses.includes(vin.prevout?.scriptpubkey_address)) || this.addresses.includes(vin.prevout?.scriptpubkey.slice(2, -2)))
'highlight': this.addresses.length && ((vin.prevout?.scriptpubkey_type !== 'p2pk' && addresses.includes(vin.prevout?.scriptpubkey_address)) || this.addresses.includes(vin.prevout?.scriptpubkey.slice(2, -2))),
'sigged': selectedSig && selectedSig.txIndex === i && sigHighlights.vin[vindex],
}">
<td class="arrow-td">
<ng-template [ngIf]="vin.prevout === null && !vin.is_pegin" [ngIfElse]="hasPrevout">
@ -54,6 +55,36 @@
</ng-template>
</ng-template>
</td>
@if (tx['_showSignatures']) {
<td class="sig-td">
<div class="sig-stack">
@if (tx['_sigs'][vindex].length === 0 && signaturesMode === 'all') {
<span class="sig sig-key sig-no-lock" ngbTooltip="unsigned">
<fa-icon [icon]="['fas', 'lock-open']" [fixedWidth]="true"></fa-icon>
</span>
} @else if (showSig(tx['_sigs'][vindex])) {
@for (sig of tx['_sigs'][vindex].slice(0, 7); track sig.signature; let idx = $index) {
@if (idx < 7) {
<span
class="sig sig-key sighash-{{sig.sighash}}"
(mouseenter)="showSigInfo(i, vindex, sig)"
(mouseleave)="hideSigInfo()"
[ngbTooltip]="sighashLabels[sig.sighash]"
[class.hovered]="selectedSig && selectedSig.txIndex === i && selectedSig.vindex === vindex && selectedSig.sig === sig"
>
<fa-icon [icon]="['fas', 'key']" [fixedWidth]="true"></fa-icon>
</span>
}
}
@if (tx['_sigs'][vindex].length > 7) {
<span class="sig sig-overflow">
+{{ tx['_sigs'][vindex].length - 7 }}
</span>
}
}
</div>
</td>
}
<td class="address-cell">
<div [ngSwitch]="true">
<ng-container *ngSwitchCase="vin.is_coinbase"><span i18n="transactions-list.coinbase">Coinbase</span><ng-template [ngIf]="network !== 'liquid' && network !== 'liquidtestnet'">&nbsp;<span i18n="transactions-list.newly-generated-coins">(Newly Generated Coins)</span></ng-template><br /><a placement="bottom" [ngbTooltip]="vin.scriptsig | hex2ascii"><span class="badge badge-secondary scriptmessage longer">{{ vin.scriptsig | hex2ascii }}</span></a></ng-container>
@ -106,21 +137,32 @@
</tr>
<tr *ngIf="showOrdData[tx.txid + '-vin-' + vindex]?.show" [ngClass]="{
'assetBox': (assetsMinimal && vin.prevout && assetsMinimal[vin.prevout.asset] && !vin.is_coinbase && vin.prevout.scriptpubkey_address && tx._unblinded) || inputIndex === vindex,
'highlight': addresses?.length && (addresses.includes(vin.prevout?.scriptpubkey_address) || (vin.prevout?.scriptpubkey_type === 'p2pk' && addresses.includes(vin.prevout?.scriptpubkey.slice(2, -2))))
'highlight': addresses?.length && (addresses.includes(vin.prevout?.scriptpubkey_address) || (vin.prevout?.scriptpubkey_type === 'p2pk' && addresses.includes(vin.prevout?.scriptpubkey.slice(2, -2)))),
'sigged': selectedSig && selectedSig.txIndex === i && sigHighlights.vin[vindex],
}">
<td></td>
@if (tx['_showSignatures']) {
<td></td>
}
<td colspan="2">
<app-ord-data [inscriptions]="showOrdData[tx.txid + '-vin-' + vindex]['inscriptions']" [type]="'vin'"></app-ord-data>
</td>
</tr>
<tr *ngIf="(showDetails$ | async) === true">
<td colspan="3" class="details-container" >
<td [attr.colspan]="tx['_showSignatures'] ? 4 : 3" class="details-container" >
<table class="table table-striped table-fixed table-borderless details-table mb-3">
<tbody>
<ng-template [ngIf]="vin.scriptsig">
<tr>
<td i18n="transactions-list.scriptsig.asm|ScriptSig (ASM)">ScriptSig (ASM)</td>
<td style="text-align: left;" [innerHTML]="vin.scriptsig_asm | asmStyler"></td>
<td style="text-align: left;">
<app-asm
[asm]="vin.scriptsig_asm"
[annotations]="{ signatures: tx['_sigmap'], selectedSig: selectedSig?.txIndex === i && selectedSig?.vindex === vindex ? selectedSig.sig : null }"
(showSigInfo)="showSigInfo(i, vindex, $event)"
(hideSigInfo)="hideSigInfo()"
></app-asm>
</td>
</tr>
<tr>
<td i18n="transactions-list.scriptsig.hex|ScriptSig (HEX)">ScriptSig (HEX)</td>
@ -130,27 +172,47 @@
<tr *ngIf="vin.witness" class="vin-witness">
<td i18n="transactions-list.witness">Witness</td>
<td style="text-align: left;">
<ng-container *ngFor="let witness of vin.witness; index as i">
<ng-container *ngFor="let witness of vin.witness; index as windex">
<p class="witness-item">
<ng-container *ngIf="tx['_sigmap'][witness] as sigInfo">
<span class="sig sig-key sig-inline sighash-{{sigInfo.sig.sighash}}"
[class.hovered]="selectedSig && selectedSig.txIndex === i && selectedSig.vindex === vindex && selectedSig.sig === sigInfo.sig"
(mouseenter)="showSigInfo(i, vindex, sigInfo.sig)"
(mouseleave)="hideSigInfo()"
[ngbTooltip]="sighashLabels[sigInfo.sig.sighash]">
<fa-icon [icon]="['fas', 'key']" [fixedWidth]="true"></fa-icon>
</span>
</ng-container>
@if (witness.length > 1000) {
@if (!showFullWitness[vindex][i]) {
@if (!showFullWitness[vindex][windex]) {
{{ witness | slice:0:1000 }}
} @else {
{{ witness }}
}
} @else if (witness) {
{{ witness }}
<ng-container *ngIf="tx['_sigmap'][witness]?.sig.sighash !== 0 && tx['_sigmap'][witness] as sigInfo; else plainSig">
<span class="witness">
{{witness.slice(0, -2)}}<span class="sig sighash-{{sigInfo.sig.sighash}}"
[class.hovered]="selectedSig && selectedSig.txIndex === i && selectedSig.vindex === vindex && selectedSig.sig === sigInfo.sig"
(mouseenter)="showSigInfo(i, vindex, sigInfo.sig)"
(mouseleave)="hideSigInfo()"
[ngbTooltip]="sighashLabels[sigInfo.sig.sighash]">{{witness.slice(-2)}}</span>
</span>
</ng-container>
<ng-template #plainSig>
{{ witness }}
</ng-template>
} @else {
&lt;empty&gt;
}
</p>
@if (witness.length > 1000) {
<div style="display: flex;">
@if (!showFullWitness[vindex][i]) {
@if (!showFullWitness[vindex][windex]) {
<span>...</span>
}
<label class="btn btn-sm btn-primary mt-2" (click)="toggleShowFullWitness(vindex, i)" style="margin-left: auto;">
@if (!showFullWitness[vindex][i]) {
<label class="btn btn-sm btn-primary mt-2" (click)="toggleShowFullWitness(vindex, windex)" style="margin-left: auto;">
@if (!showFullWitness[vindex][windex]) {
<span i18n="show-all">Show all</span>
} @else {
<span i18n="show-less">Show less</span>
@ -201,7 +263,7 @@
</tr>
</ng-template>
<tr *ngIf="tx.vin.length > getVinLimit(tx)">
<td colspan="3" class="text-center">
<td [attr.colspan]="tx['_showSignatures'] ? 4 : 3" class="text-center">
<button class="btn btn-sm btn-primary mt-2" (click)="showMoreInputs(tx)">
<span *ngIf="getVinLimit(tx, true) >= tx.vin.length; else showMoreInputsLabel" i18n="show-all">Show all</span>
<ng-template #showMoreInputsLabel>
@ -221,7 +283,8 @@
<ng-template ngFor let-vout let-vindex="index" [ngForOf]="tx.vout.slice(0, getVoutLimit(tx))" [ngForTrackBy]="trackByIndexFn">
<tr [ngClass]="{
'assetBox': assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address && tx.vin && !tx.vin[0].is_coinbase && tx._unblinded || outputIndex === vindex,
'highlight': this.addresses.length && ((vout.scriptpubkey_type !== 'p2pk' && addresses.includes(vout.scriptpubkey_address)) || this.addresses.includes(vout.scriptpubkey.slice(2, -2)))
'highlight': this.addresses.length && ((vout.scriptpubkey_type !== 'p2pk' && addresses.includes(vout.scriptpubkey_address)) || this.addresses.includes(vout.scriptpubkey.slice(2, -2))),
'sigged': selectedSig && selectedSig.txIndex === i && sigHighlights.vout[vindex],
}">
<td class="address-cell">
<app-address-text
@ -303,14 +366,15 @@
<tr *ngIf="showOrdData[tx.txid + '-vout-' + vindex]?.show" [ngClass]="{
'assetBox': assetsMinimal && assetsMinimal[vout.asset] && vout.scriptpubkey_address && tx.vin && !tx.vin[0].is_coinbase && tx._unblinded || outputIndex === vindex,
'highlight': addresses?.length && (addresses.includes(vout.scriptpubkey_address) || (vout.scriptpubkey_type === 'p2pk' && addresses.includes(vout.scriptpubkey.slice(2, -2))))
'highlight': addresses?.length && (addresses.includes(vout.scriptpubkey_address) || (vout.scriptpubkey_type === 'p2pk' && addresses.includes(vout.scriptpubkey.slice(2, -2)))),
'sigged': selectedSig && selectedSig.txIndex === i && sigHighlights.vout[vindex],
}">
<td colspan="3">
<app-ord-data [runestone]="showOrdData[tx.txid + '-vout-' + vindex]['runestone']" [runeInfo]="showOrdData[tx.txid + '-vout-' + vindex]['runeInfo']" [type]="'vout'"></app-ord-data>
</td>
</tr>
<tr *ngIf="(showDetails$ | async) === true">
<td colspan="3" class=" details-container" >
<td [attr.colspan]="tx['_showSignatures'] ? 4 : 3" class=" details-container" >
<table class="table table-striped table-borderless details-table mb-3">
<tbody>
<tr>
@ -335,7 +399,7 @@
</tr>
</ng-template>
<tr *ngIf="tx.vout.length > getVoutLimit(tx)">
<td colspan="3" class="text-center">
<td [attr.colspan]="3" class="text-center">
<button class="btn btn-sm btn-primary mt-2" (click)="showMoreOutputs(tx)">
<span *ngIf="getVoutLimit(tx, true) >= tx.vout.length; else showMoreOutputsLabel" i18n="show-all">Show all</span>
<ng-template #showMoreOutputsLabel>

View file

@ -1,3 +1,17 @@
.col {
&:first-child {
padding-right: 0;
td:last-child {
padding-right: calc(0.3em + 15px);
}
}
&:last-child {
padding-left: 0;
td:first-child {
padding-left: calc(0.3em + 15px);
}
}
}
.arrow-td {
width: 30px;
@ -24,6 +38,118 @@
color: var(--grey);
}
.sig-td {
width: 30px;
position: relative;
}
.sig-stack {
position: relative;
width: 30px;
transition: transform 0.2s ease;
}
.sig-td:hover {
.sig-stack {
.sig-key {
@for $i from 1 through 8 {
&:nth-child(#{$i}) {
transform: translate(#{($i - 1) * 6}px, #{($i - 1) * 6}px);
}
}
}
.sig-overflow {
opacity: 1;
transform: translate(30px, 40px);
}
}
}
.sig {
cursor: pointer;
&.sig-overflow {
opacity: 0;
}
&.sighash-0 {
--sigColor: var(--green);
}
&.sighash-1 {
--sigColor: var(--green);
}
&.sighash-2 {
--sigColor: gold;
}
&.sighash-3 {
--sigColor: cornflowerblue;
}
&.sighash-129 {
--sigColor: darkviolet;
}
&.sighash-130 {
--sigColor: darkorange;
}
&.sighash-131 {
--sigColor: var(--pink);
}
&.sig-no-lock {
--sigColor: var(--red);
}
color: var(--sigColor);
&.sig-key {
@for $i from 1 through 8 {
&:nth-child(#{$i}) {
transform: translate(#{($i - 1) * 3}px, #{($i - 1) * 3}px);
color: color-mix(in srgb, var(--sigColor) #{100 - ($i - 1) * 10} + '%', black);
z-index: #{10 - $i};
}
}
@for $i from 4 through 8 {
&:nth-child(#{$i}) {
transform: translate(9px, 9px);
}
}
}
&:hover, &.hovered {
color: color-mix(in srgb, var(--sigColor) 80%, white);
}
::ng-deep svg path {
pointer-events: auto;
}
}
.sig-key {
position: absolute;
top: 0;
left: 0;
transition: transform 0.2s ease;
pointer-events: none;
&.sig-inline {
position: relative;
pointer-events: auto;
margin-right: -5px;
}
}
.sig-overflow {
// background-color: var(--tertiary);
color: white;
border-radius: 50%;
font-size: 10px;
font-weight: bold;
padding: 5px;
margin-left: 10px;
margin-right: 10px;
display: block;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.mobile-bottomcol {
margin-top: 15px;
@media (min-width: 992px) {
@ -142,6 +268,10 @@ h2 {
background-color: var(--stat-box-bg);
}
.sigged {
background-color: #ffffff22;
}
.summary {
margin-top: 10px;
}

View file

@ -1,5 +1,5 @@
import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, Output, EventEmitter, ChangeDetectorRef } from '@angular/core';
import { StateService } from '@app/services/state.service';
import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, Output, EventEmitter, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { StateService, SignaturesMode } from '@app/services/state.service';
import { CacheService } from '@app/services/cache.service';
import { Observable, ReplaySubject, BehaviorSubject, merge, Subscription, of, forkJoin } from 'rxjs';
import { Outspend, Transaction, Vin, Vout } from '@interfaces/electrs.interface';
@ -15,6 +15,9 @@ import { OrdApiService } from '@app/services/ord-api.service';
import { Inscription } from '@app/shared/ord/inscription.utils';
import { Etching, Runestone } from '@app/shared/ord/rune.utils';
import { ADDRESS_SIMILARITY_THRESHOLD, AddressMatch, AddressSimilarity, AddressType, AddressTypeInfo, checkedCompareAddressStrings, detectAddressType } from '@app/shared/address-utils';
import { processInputSignatures, Sighash, SigInfo, SighashLabels } from '@app/shared/transaction.utils';
import { ActivatedRoute } from '@angular/router';
import { SighashFlag } from '../../shared/transaction.utils';
@Component({
selector: 'app-transactions-list',
@ -22,7 +25,7 @@ import { ADDRESS_SIMILARITY_THRESHOLD, AddressMatch, AddressSimilarity, AddressT
styleUrls: ['./transactions-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TransactionsListComponent implements OnInit, OnChanges {
export class TransactionsListComponent implements OnInit, OnChanges, OnDestroy {
network = '';
nativeAssetId = this.stateService.network === 'liquidtestnet' ? environment.nativeTestAssetId : environment.nativeAssetId;
showMoreIncrement = 1000;
@ -39,12 +42,16 @@ export class TransactionsListComponent implements OnInit, OnChanges {
@Input() rowLimit = 12;
@Input() blockTime: number = 0; // Used for price calculation if all the transactions are in the same block
@Input() txPreview = false;
@Input() forceSignaturesMode: SignaturesMode = null;
@Output() loadMore = new EventEmitter();
latestBlock$: Observable<BlockExtended>;
outspendsSubscription: Subscription;
currencyChangeSubscription: Subscription;
networkSubscription: Subscription;
signaturesSubscription: Subscription;
queryParamsSubscription: Subscription;
currency: string;
refreshOutspends$: ReplaySubject<string[]> = new ReplaySubject();
refreshChannels$: ReplaySubject<string[]> = new ReplaySubject();
@ -58,6 +65,14 @@ export class TransactionsListComponent implements OnInit, OnChanges {
showOrdData: { [key: string]: { show: boolean; inscriptions?: Inscription[]; runestone?: Runestone, runeInfo?: { [id: string]: { etching: Etching; txid: string; } }; } } = {};
similarityMatches: Map<string, Map<string, { score: number, match: AddressMatch, group: number }>> = new Map();
selectedSig: { txIndex: number, vindex: number, sig: SigInfo } | null = null;
sigHighlights: { vin: boolean[], vout: boolean[] } = { vin: [], vout: [] };
sighashLabels = SighashLabels;
signaturesPreference: SignaturesMode = null;
signaturesOverride: SignaturesMode = null;
signaturesMode: SignaturesMode = 'interesting';
constructor(
public stateService: StateService,
private cacheService: CacheService,
@ -68,11 +83,29 @@ export class TransactionsListComponent implements OnInit, OnChanges {
private ref: ChangeDetectorRef,
private priceService: PriceService,
private storageService: StorageService,
) { }
private route: ActivatedRoute,
) {
this.signaturesMode = this.forceSignaturesMode || this.stateService.signaturesMode$.value;
}
ngOnInit(): void {
this.latestBlock$ = this.stateService.blocks$.pipe(map((blocks) => blocks[0]));
this.stateService.networkChanged$.subscribe((network) => this.network = network);
this.networkSubscription = this.stateService.networkChanged$.subscribe((network) => this.network = network);
this.signaturesSubscription = this.stateService.signaturesMode$.subscribe((mode) => {
this.signaturesMode = mode;
this.updateSignaturesMode();
});
this.queryParamsSubscription = this.route.queryParams.subscribe((params) => {
console.log('query params', params);
if (params['sigs'] && ['all', 'interesting', 'none'].includes(params['sigs'])) {
this.signaturesOverride = params['sigs'] as SignaturesMode;
this.updateSignaturesMode();
} else {
this.signaturesOverride = null;
this.updateSignaturesMode();
}
});
if (this.network === 'liquid' || this.network === 'liquidtestnet') {
this.assetsService.getAssetsMinimalJson$.subscribe((assets) => {
@ -200,12 +233,12 @@ export class TransactionsListComponent implements OnInit, OnChanges {
}
const confirmedTxs = this.transactions.filter((tx) => tx.status.confirmed).length;
this.transactions.forEach((tx) => {
tx['@voutLimit'] = true;
tx['@vinLimit'] = true;
if (tx['addressValue'] !== undefined) {
return;
}
tx['_showSignatures'] = false;
tx['_interestingSignatures'] = false;
if (this.addresses?.length) {
const addressIn = tx.vout.map(v => {
@ -278,6 +311,20 @@ export class TransactionsListComponent implements OnInit, OnChanges {
break;
}
}
// process signature data
tx['_sigs'] = tx.vin.map(vin => processInputSignatures(vin));
tx['_sigmap'] = tx['_sigs'].reduce((map, sigs, vindex) => {
sigs.forEach(sig => {
map[sig.signature] = { sig, vindex };
});
return map;
}, {});
if (!tx['_interestingSignatures']) {
tx['_interestingSignatures'] = tx['_sigs'].some(sigs => sigs.some(sig => this.sigIsInteresting(sig)));
}
tx['_showSignatures'] = this.shouldShowSignatures(tx);
}
tx.largeInput = tx.largeInput || tx.vin.some(vin => (vin?.prevout?.value > 1000000000));
@ -500,8 +547,64 @@ export class TransactionsListComponent implements OnInit, OnChanges {
}
}
showSigInfo(txIndex: number, vindex: number, sig: SigInfo): void {
this.selectedSig = { txIndex, vindex, sig };
this.sigHighlights = { vin: [], vout: [] };
for (let i = 0; i < this.transactions[txIndex].vin.length; i++) {
this.sigHighlights.vin.push(
i === vindex ||
!(Sighash.isACP(sig.sighash))
);
}
for (let i = 0; i < this.transactions[txIndex].vout.length; i++) {
this.sigHighlights.vout.push(
!(Sighash.isNone(sig.sighash)) && (
!(Sighash.isSingle(sig.sighash)) ||
i === vindex
)
);
}
this.ref.markForCheck();
}
hideSigInfo(): void {
this.selectedSig = null;
this.sigHighlights = { vin: [], vout: [] };
this.ref.markForCheck();
}
updateSignaturesMode(): void {
this.signaturesMode = this.signaturesOverride || this.forceSignaturesMode || this.signaturesPreference || 'interesting';
if (this.transactions?.length) {
for (const tx of this.transactions) {
tx['_showSignatures'] = this.shouldShowSignatures(tx);
}
}
}
showSig(sigs: SigInfo[]): boolean {
return this.signaturesMode === 'all' || (this.signaturesMode === 'interesting' && sigs.some(sig => this.sigIsInteresting(sig)));
}
sigIsInteresting(sig: SigInfo): boolean {
return sig.sighash !== SighashFlag.DEFAULT && sig.sighash !== SighashFlag.ALL;
}
shouldShowSignatures(tx): boolean {
switch (this.signaturesMode) {
case 'all':
return true;
case 'interesting':
return tx['_interestingSignatures'];
default:
return false;
}
}
ngOnDestroy(): void {
this.outspendsSubscription.unsubscribe();
this.currencyChangeSubscription?.unsubscribe();
this.networkSubscription.unsubscribe();
this.signaturesSubscription.unsubscribe();
}
}

View file

@ -42,6 +42,8 @@ export interface Customization {
};
}
export type SignaturesMode = 'all' | 'interesting' | 'none' | null;
export interface Env {
MAINNET_ENABLED: boolean;
TESTNET_ENABLED: boolean;
@ -150,6 +152,7 @@ export class StateService {
backend$ = new BehaviorSubject<'esplora' | 'electrum' | 'none'>('esplora');
networkChanged$ = new ReplaySubject<string>(1);
lightningChanged$ = new ReplaySubject<boolean>(1);
signaturesMode$: BehaviorSubject<SignaturesMode>;
blocksSubject$ = new BehaviorSubject<BlockExtended[]>([]);
blocks$: Observable<BlockExtended[]>;
transactions$ = new BehaviorSubject<TransactionStripped[]>(null);
@ -332,6 +335,8 @@ export class StateService {
this.blocksSubject$.next([]);
});
this.signaturesMode$ = new BehaviorSubject<SignaturesMode>(this.storageService.getValue('signatures-enabled') as SignaturesMode || null);
this.blockVSize = this.env.BLOCK_WEIGHT_UNITS / 4;
this.blocks$ = this.blocksSubject$.pipe(filter(blocks => blocks != null && blocks.length > 0));

View file

@ -0,0 +1,30 @@
<div class="asm">
@for (instruction of instructions; track instruction.instruction) {
<span [class]='opcodeStyles.get(instruction.instruction)'>OP_{{instruction.instruction}}</span>
@for (arg of instruction.args; track arg) {
<ng-container *ngIf="annotations.signatures[arg] as sigInfo; else plainArg">
<span class="sig sig-key sig-inline sighash-{{sigInfo.sig.sighash}}"
[class.hovered]="annotations.selectedSig && annotations.selectedSig === sigInfo.sig"
(mouseenter)="doShowSigInfo(sigInfo.sig)"
(mouseleave)="doHideSigInfo()"
[title]="sighashLabels[sigInfo.sig.sighash]">
<fa-icon [icon]="['fas', 'key']" [fixedWidth]="true"></fa-icon>
</span>
<ng-container *ngIf="sigInfo.sig.sighash !== 0; else plainSig">
{{arg.slice(0, -2)}}<span class="sig sighash-{{sigInfo.sig.sighash}}"
[class.hovered]="annotations.selectedSig && annotations.selectedSig === sigInfo.sig"
(mouseenter)="doShowSigInfo(sigInfo.sig)"
(mouseleave)="doHideSigInfo()"
[title]="sighashLabels[sigInfo.sig.sighash]">{{arg.slice(-2)}}</span>
</ng-container>
<ng-template #plainSig>
{{ arg }}
</ng-template>
</ng-container>
<ng-template #plainArg>
{{ arg }}
</ng-template>
}
<br>
}
</div>

View file

@ -0,0 +1,53 @@
.sig {
cursor: pointer;
&.sighash-0 {
--sigColor: var(--green);
}
&.sighash-1 {
--sigColor: var(--green);
}
&.sighash-2 {
--sigColor: gold;
}
&.sighash-3 {
--sigColor: cornflowerblue;
}
&.sighash-129 {
--sigColor: darkviolet;
}
&.sighash-130 {
--sigColor: darkorange;
}
&.sighash-131 {
--sigColor: var(--pink);
}
&.sig-no-lock {
--sigColor: var(--red);
}
color: var(--sigColor);
&:hover, &.hovered {
color: color-mix(in srgb, var(--sigColor) 60%, white);
}
::ng-deep svg path {
pointer-events: auto;
}
}
.sig-key {
position: absolute;
top: 0;
left: 0;
transition: transform 0.2s ease;
pointer-events: none;
&.sig-inline {
position: relative;
pointer-events: auto;
margin-left: 4px;
margin-right: -4px;
}
}

View file

@ -0,0 +1,178 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, SimpleChanges } from '@angular/core';
import { SigInfo, SighashLabels } from '@app/shared/transaction.utils';
@Component({
selector: 'app-asm',
templateUrl: './asm.component.html',
styleUrls: ['./asm.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AsmComponent {
@Input() asm: string;
@Input() crop: number = 0;
@Input() annotations: {
signatures: Record<string, { sig: SigInfo, vindex: number }>,
selectedSig: SigInfo | null
} = {
signatures: {},
selectedSig: null
};
@Output() showSigInfo = new EventEmitter<SigInfo>();
@Output() hideSigInfo = new EventEmitter<void>();
instructions: { instruction: string, args: string[] }[] = [];
sighashLabels: Record<number, string> = SighashLabels;
ngOnInit(): void {
this.parseASM();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['asm']) {
this.parseASM();
}
}
parseASM(): void {
let instructions = this.asm.split('OP_');
// trim instructions to a whole number of instructions with at most `crop` characters total
if (this.crop) {
let chars = 0;
for (let i = 0; i < instructions.length; i++) {
chars += instructions[i].length + 3;
if (chars > this.crop) {
instructions = instructions.slice(0, i);
break;
}
}
}
this.instructions = instructions.filter(instruction => instruction.trim() !== '').map(instruction => {
const parts = instruction.split(' ');
return {
instruction: parts[0],
args: parts.slice(1)
};
});
}
doShowSigInfo(sig: SigInfo): void {
this.showSigInfo.emit(sig);
}
doHideSigInfo(): void {
this.hideSigInfo.emit();
}
readonly opcodeStyles: Map<string, string> = new Map([
// Constants
['0', 'constants'],
['FALSE', 'constants'],
['TRUE', 'constants'],
...Array.from({length: 75}, (_, i) => [`PUSHBYTES_${i + 1}`, 'constants']),
['PUSHDATA1', 'constants'],
['PUSHDATA2', 'constants'],
['PUSHDATA4', 'constants'],
['PUSHNUM_NEG1', 'constants'],
...Array.from({length: 16}, (_, i) => [`PUSHNUM_${i + 1}`, 'constants']),
// Control flow
['NOP', 'control'],
['IF', 'control'],
['NOTIF', 'control'],
['ELSE', 'control'],
['ENDIF', 'control'],
['VERIFY', 'control'],
...Array.from({length: 70}, (_, i) => [`RETURN_${i + 186}`, 'control']),
// Stack
['TOALTSTACK', 'stack'],
['FROMALTSTACK', 'stack'],
['IFDUP', 'stack'],
['DEPTH', 'stack'],
['DROP', 'stack'],
['DUP', 'stack'],
['NIP', 'stack'],
['OVER', 'stack'],
['PICK', 'stack'],
['ROLL', 'stack'],
['ROT', 'stack'],
['SWAP', 'stack'],
['TUCK', 'stack'],
['2DROP', 'stack'],
['2DUP', 'stack'],
['3DUP', 'stack'],
['2OVER', 'stack'],
['2ROT', 'stack'],
['2SWAP', 'stack'],
// String
['CAT', 'splice'],
['SUBSTR', 'splice'],
['LEFT', 'splice'],
['RIGHT', 'splice'],
['SIZE', 'splice'],
// Logic
['INVERT', 'logic'],
['AND', 'logic'],
['OR', 'logic'],
['XOR', 'logic'],
['EQUAL', 'logic'],
['EQUALVERIFY', 'logic'],
// Arithmetic
['1ADD', 'arithmetic'],
['1SUB', 'arithmetic'],
['2MUL', 'arithmetic'],
['2DIV', 'arithmetic'],
['NEGATE', 'arithmetic'],
['ABS', 'arithmetic'],
['NOT', 'arithmetic'],
['0NOTEQUAL', 'arithmetic'],
['ADD', 'arithmetic'],
['SUB', 'arithmetic'],
['MUL', 'arithmetic'],
['DIV', 'arithmetic'],
['MOD', 'arithmetic'],
['LSHIFT', 'arithmetic'],
['RSHIFT', 'arithmetic'],
['BOOLAND', 'arithmetic'],
['BOOLOR', 'arithmetic'],
['NUMEQUAL', 'arithmetic'],
['NUMEQUALVERIFY', 'arithmetic'],
['NUMNOTEQUAL', 'arithmetic'],
['LESSTHAN', 'arithmetic'],
['GREATERTHAN', 'arithmetic'],
['LESSTHANOREQUAL', 'arithmetic'],
['GREATERTHANOREQUAL', 'arithmetic'],
['MIN', 'arithmetic'],
['MAX', 'arithmetic'],
['WITHIN', 'arithmetic'],
// Crypto
['RIPEMD160', 'crypto'],
['SHA1', 'crypto'],
['SHA256', 'crypto'],
['HASH160', 'crypto'],
['HASH256', 'crypto'],
['CODESEPARATOR', 'crypto'],
['CHECKSIG', 'crypto'],
['CHECKSIGVERIFY', 'crypto'],
['CHECKMULTISIG', 'crypto'],
['CHECKMULTISIGVERIFY', 'crypto'],
['CHECKSIGADD', 'crypto'],
// Locktime
['CLTV', 'locktime'],
['CSV', 'locktime'],
// Reserved
['RESERVED', 'reserved'],
['VER', 'reserved'],
['VERIF', 'reserved'],
['VERNOTIF', 'reserved'],
['RESERVED1', 'reserved'],
['RESERVED2', 'reserved'],
...Array.from({length: 10}, (_, i) => [`NOP${i + 1}`, 'reserved'])
] as [string, string][]);
}

View file

@ -1,3 +1,6 @@
import { Vin } from "../interfaces/electrs.interface";
import { AddressType, detectAddressType } from "./address-utils";
const opcodes = {
OP_FALSE: 0,
OP_0: 0,

View file

@ -7,7 +7,7 @@ import { faFilter, faAngleDown, faAngleUp, faAngleRight, faAngleLeft, faBolt, fa
faFileAlt, faRedoAlt, faArrowAltCircleRight, faExternalLinkAlt, faListUl, faDownload, faQrcode, faArrowRightArrowLeft, faArrowsRotate, faCircleLeft,
faFastForward, faWallet, faUserClock, faWrench, faUserFriends, faQuestionCircle, faHistory, faSignOutAlt, faKey, faSuitcase, faIdCardAlt, faNetworkWired, faUserCheck,
faCircleCheck, faUserCircle, faCheck, faRocket, faScaleBalanced, faHourglassStart, faHourglassHalf, faHourglassEnd, faWandMagicSparkles, faTimeline,
faCircleXmark, faCalendarCheck, faMoneyBillTrendUp, faRobot, faShareNodes, faCreditCard, faMicroscope, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
faCircleXmark, faCalendarCheck, faMoneyBillTrendUp, faRobot, faShareNodes, faCreditCard, faMicroscope, faExclamationTriangle, faLockOpen } from '@fortawesome/free-solid-svg-icons';
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
import { MenuComponent } from '@components/menu/menu.component';
import { PreviewTitleComponent } from '@components/master-page-preview/preview-title.component';
@ -18,6 +18,7 @@ import { Hex2asciiPipe } from '@app/shared/pipes/hex2ascii/hex2ascii.pipe';
import { Decimal2HexPipe } from '@app/shared/pipes/decimal2hex/decimal2hex.pipe';
import { FeeRoundingPipe } from '@app/shared/pipes/fee-rounding/fee-rounding.pipe';
import { AsmStylerPipe } from '@app/shared/pipes/asm-styler/asm-styler.pipe';
import { AsmComponent } from '@app/shared/components/asm/asm.component';
import { AbsolutePipe } from '@app/shared/pipes/absolute/absolute.pipe';
import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe';
import { ScriptpubkeyTypePipe } from '@app/shared/pipes/scriptpubkey-type-pipe/scriptpubkey-type.pipe';
@ -147,6 +148,7 @@ import { GithubLogin } from '../components/github-login.component/github-login.c
NoSanitizePipe,
Hex2asciiPipe,
AsmStylerPipe,
AsmComponent,
AbsolutePipe,
BytesPipe,
VbytesPipe,
@ -462,5 +464,6 @@ export class SharedModule {
library.addIcons(faCreditCard);
library.addIcons(faMicroscope);
library.addIcons(faExclamationTriangle);
library.addIcons(faLockOpen);
}
}

View file

@ -4,6 +4,7 @@ import { Transaction, Vin } from '@interfaces/electrs.interface';
import { CpfpInfo, RbfInfo, TransactionStripped } from '@interfaces/node-api.interface';
import { StateService } from '@app/services/state.service';
import { hash, Hash } from './sha256';
import { AddressType } from './address-utils';
// Bitcoin Core default policy settings
const MAX_STANDARD_TX_WEIGHT = 400_000;
@ -85,6 +86,171 @@ export function isDERSig(w: string): boolean {
);
}
export enum SighashFlag {
DEFAULT = 0,
ALL = 1,
NONE = 2,
SINGLE = 3,
ANYONECANPAY = 0x80
}
export type SighashValue =
SighashFlag.DEFAULT |
SighashFlag.ALL |
SighashFlag.NONE |
SighashFlag.SINGLE |
(SighashFlag.ALL & SighashFlag.ANYONECANPAY) |
(SighashFlag.NONE & SighashFlag.ANYONECANPAY) |
(SighashFlag.SINGLE & SighashFlag.ANYONECANPAY) |
(SighashFlag.ALL & SighashFlag.NONE);
export const SighashLabels: Record<number, string> = {
'0': 'SIGHASH_DEFAULT',
'1': 'SIGHASH_ALL',
'2': 'SIGHASH_NONE',
'3': 'SIGHASH_SINGLE',
'129': 'SIGHASH_ALL | ACP',
'130': 'SIGHASH_NONE | ACP',
'131': 'SIGHASH_SINGLE | ACP',
};
export interface SigInfo {
signature: string;
sighash: SighashValue;
}
export class Sighash {
static isACP(val: SighashValue): boolean {
return val >= SighashFlag.ANYONECANPAY;
}
static isNone(val: SighashValue): boolean {
return (val & 0x7F) === SighashFlag.NONE;
}
static isSingle(val: SighashValue): boolean {
return (val & 0x7F) === SighashFlag.SINGLE;
}
static isAll(val: SighashValue): boolean {
return (val & 0x7F) === SighashFlag.ALL;
}
static isDefault(val: SighashValue): boolean {
return val === SighashFlag.DEFAULT;
}
}
export function decodeSighashFlag(sighash: number): SighashValue {
if (sighash >= 0 && sighash <= 0x03 || sighash > 0x80 && sighash <= 0x83) {
return sighash as SighashValue;
}
return SighashFlag.DEFAULT;
}
export function extractDERSignaturesWitness(witness: string[]): SigInfo[] {
if (!witness?.length) {
return [];
}
const signatures: SigInfo[] = [];
for (const w of witness) {
if (isDERSig(w)) {
signatures.push({
signature: w,
sighash: decodeSighashFlag(parseInt(w.slice(-2), 16)),
});
}
}
return signatures;
}
export function extractDERSignaturesASM(script_asm: string): SigInfo[] {
if (!script_asm) {
return [];
}
const signatures: SigInfo[] = [];
const ops = script_asm.split(' ');
for (let i = 0; i < ops.length - 1; i++) {
// Look for OP_PUSHBYTES_N followed by a hex string
if (ops[i].startsWith('OP_PUSHBYTES_')) {
const hexData = ops[i + 1];
if (isDERSig(hexData)) {
const sighash = decodeSighashFlag(parseInt(hexData.slice(-2), 16));
signatures.push({
signature: hexData,
sighash
});
}
}
}
return signatures;
}
export function extractSchnorrSignatures(witnesses: string[]): SigInfo[] {
if (!witnesses?.length) {
return [];
}
const signatures: SigInfo[] = [];
for (const witness of witnesses) {
if (witness.length === 130) {
signatures.push({
signature: witness,
sighash: decodeSighashFlag(parseInt(witness.slice(-2), 16)),
});
} else if (witness.length === 128) {
signatures.push({
signature: witness,
sighash: SighashFlag.DEFAULT,
});
}
}
return signatures;
}
export function processInputSignatures(vin: Vin): SigInfo[] {
const addressType = vin.prevout?.scriptpubkey_type as AddressType;
let signatures: SigInfo[] = [];
switch(addressType) {
case 'p2pk':
case 'multisig':
case 'p2pkh':
signatures = extractDERSignaturesASM(vin.scriptsig_asm);
break;
case 'p2sh':
signatures = [...extractDERSignaturesASM(vin.scriptsig_asm), ...extractDERSignaturesASM(vin.inner_redeemscript_asm), ...extractDERSignaturesWitness(vin.witness || [])];
break;
case 'v0_p2wpkh':
signatures = extractDERSignaturesWitness(vin.witness || []);
break;
case 'v0_p2wsh':
signatures = extractDERSignaturesWitness(vin.witness || []);
break;
case 'v1_p2tr': {
const hasAnnex = 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);
signatures = extractSchnorrSignatures(stackItems);
}
} break;
default:
// non-signed input types?
break;
}
return signatures;
}
/**
* Validates most standardness rules
*