simple sighash highlighting

This commit is contained in:
Mononaut 2025-04-09 09:48:41 +00:00
parent 86a99d871a
commit 47b28035eb
No known key found for this signature in database
GPG Key ID: A3F058E41374C04E
7 changed files with 352 additions and 10 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" [signatures]="true"></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,30 @@
</ng-template>
</ng-template>
</td>
@if (signatures) {
<td class="sig-td">
<div class="sig-stack">
@if (tx['_sigs'][vindex].length === 0) {
<span class="sig-key sig-no-lock" title="unsigned">
<fa-icon [icon]="['fas', 'lock-open']" [fixedWidth]="true"></fa-icon>
</span>
} @else {
@for (sig of tx['_sigs'][vindex]; track sig.signature; let idx = $index) {
@if (idx < 10) {
<span class="sig-key sighash-{{sig.sighash}}" (mouseenter)="showSigInfo(i, vindex, sig)" (mouseleave)="hideSigInfo()" [title]="sighashLabels[sig.sighash]">
<fa-icon [icon]="['fas', 'key']" [fixedWidth]="true"></fa-icon>
</span>
}
}
@if (tx['_sigs'][vindex].length > 10) {
<span class="sig-key sig-overflow">
+{{ tx['_sigs'][vindex].length - 10 }}
</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,15 +131,19 @@
</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 (signatures) {
<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]="signatures ? 4 : 3" class="details-container" >
<table class="table table-striped table-fixed table-borderless details-table mb-3">
<tbody>
<ng-template [ngIf]="vin.scriptsig">
@ -201,7 +230,7 @@
</tr>
</ng-template>
<tr *ngIf="tx.vin.length > getVinLimit(tx)">
<td colspan="3" class="text-center">
<td [attr.colspan]="signatures ? 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 +250,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 +333,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]="signatures ? 4 : 3" class=" details-container" >
<table class="table table-striped table-borderless details-table mb-3">
<tbody>
<tr>
@ -335,7 +366,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,103 @@
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 10 {
&:nth-child(#{$i}) {
transform: translate(#{($i - 1) * 6}px, #{($i - 1) * 6}px);
}
}
}
}
}
.sig-key {
position: absolute;
top: 0;
left: 0;
cursor: pointer;
transition: transform 0.2s ease;
pointer-events: none;
// opacity: 0.8;
&.sighash-0 {
--keycolor: var(--green);
}
&.sighash-1 {
--keycolor: var(--green);
}
&.sighash-2 {
--keycolor: gold;
}
&.sighash-3 {
--keycolor: cornflowerblue;
}
&.sighash-129 {
--keycolor: darkviolet;
}
&.sighash-130 {
--keycolor: darkorange;
}
&.sighash-131 {
--keycolor: var(--pink);
}
&.sig-no-lock {
--keycolor: var(--red);
}
color: var(--keycolor);
@for $i from 1 through 3 {
&:nth-child(#{$i}) {
transform: translate(#{($i - 1) * 3}px, #{($i - 1) * 3}px);
color: color-mix(in srgb, var(--keycolor) #{100 - ($i - 1) * 20} + '%', white);
z-index: #{10 - $i};
}
}
@for $i from 4 through 10 {
&:nth-child(#{$i}) {
transform: translate(9px, 9px);
color: color-mix(in srgb, var(--keycolor) 60%, white);
z-index: #{10 - $i};
}
}
&:hover {
// opacity: 1;
color: color-mix(in srgb, var(--keycolor) 80%, black);
}
::ng-deep svg path {
pointer-events: auto;
}
}
.sig-overflow {
// background-color: var(--tertiary);
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
z-index: 1;
}
.mobile-bottomcol {
margin-top: 15px;
@media (min-width: 992px) {
@ -142,6 +253,10 @@ h2 {
background-color: var(--stat-box-bg);
}
.sigged {
background-color: #ffffff22;
}
.summary {
margin-top: 10px;
}

View File

@ -15,6 +15,7 @@ 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 } from '../../shared/transaction.utils';
@Component({
selector: 'app-transactions-list',
@ -39,6 +40,7 @@ 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() signatures = true;
@Output() loadMore = new EventEmitter();
@ -58,6 +60,18 @@ 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: { [sighash: string]: 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',
};
constructor(
public stateService: StateService,
private cacheService: CacheService,
@ -278,6 +292,9 @@ export class TransactionsListComponent implements OnInit, OnChanges {
break;
}
}
// process signature data
tx['_sigs'] = tx.vin.map(vin => processInputSignatures(vin));
}
tx.largeInput = tx.largeInput || tx.vin.some(vin => (vin?.prevout?.value > 1000000000));
@ -500,6 +517,32 @@ 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();
}
ngOnDestroy(): void {
this.outspendsSubscription.unsubscribe();
this.currencyChangeSubscription?.unsubscribe();

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, faBook, 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, faFaucetDrip, 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';
@ -467,5 +467,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,154 @@ 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 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.slice(0, -2), // Remove sighash byte
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':
signatures = extractSchnorrSignatures(vin.witness.slice(0, vin.witness.length - ((vin.witness.length > 1 && vin.witness[vin.witness.length - 1].startsWith('50')) ? 1 : 0)));
break;
default:
// non-signed input types?
break;
}
return signatures;
}
/**
* Validates most standardness rules
*