Merge pull request #5825 from mempool/natsoni/taptree

Taptree widget on addresss page
This commit is contained in:
mononaut 2025-04-13 10:13:45 +08:00 committed by GitHub
commit a941d52c91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 526 additions and 16 deletions

View File

@ -73,6 +73,20 @@
</div>
</div>
<ng-container *ngIf="hasTapTree">
<br>
<div class="title-tx">
<h2 class="text-left" i18n="address.taproot-tree">Taproot Tree</h2>
</div>
<div class="box">
<div class="row">
<div class="col-md">
<app-taproot-address-scripts [address]="addressTypeInfo"></app-taproot-address-scripts>
</div>
</div>
</div>
</ng-container>
<ng-container *ngIf="(stateService.backend$ | async) === 'esplora' && address && transactions && transactions.length > 2">
<br>
<div class="title-tx">
@ -288,7 +302,7 @@
<span placement="bottom" class="badge badge-primary">
<app-address-type [address]="addressTypeInfo"></app-address-type>
</span>
<app-address-labels [channel]="exampleChannel" [address]="addressTypeInfo" class="ml-1"></app-address-labels>
<app-address-labels *ngIf="!hasTapTree" [channel]="exampleChannel" [address]="addressTypeInfo" class="ml-1"></app-address-labels>
</td>
</ng-template>

View File

@ -115,6 +115,7 @@ export class AddressComponent implements OnInit, OnDestroy {
addressLoadingStatus$: Observable<number>;
addressInfo: null | AddressInformation = null;
addressTypeInfo: null | AddressTypeInfo;
hasTapTree: boolean;
fullyLoaded = false;
chainStats: AddressStats;
@ -283,10 +284,17 @@ export class AddressComponent implements OnInit, OnDestroy {
this.isLoadingTransactions = false;
let addressVin: Vin[] = [];
let vinIds: string[] = [];
for (const tx of this.transactions) {
addressVin = addressVin.concat(tx.vin.filter(v => v.prevout?.scriptpubkey_address === this.address.address));
tx.vin.forEach((v, index) => {
if (v.prevout?.scriptpubkey_address === this.address.address) {
addressVin.push(v);
vinIds.push(`${tx.txid}:${index}`);
}
});
}
this.addressTypeInfo.processInputs(addressVin);
this.addressTypeInfo.processInputs(addressVin, vinIds);
this.hasTapTree = this.addressTypeInfo.tapscript && this.addressTypeInfo.scripts.values().next().value.scriptPath.length / 2 > 33;
// hack to trigger change detection
this.addressTypeInfo = this.addressTypeInfo.clone();

View File

@ -0,0 +1,20 @@
<div>
<div class="echarts" *browserOnly echarts [initOpts]="chartInitOptions" [options]="chartOptions" (chartInit)="onChartInit($event)" [style.height.px]="height"></div>
<div class="text-center loadingGraphs" *ngIf="!stateService.isBrowser">
<div class="spinner-border text-light"></div>
</div>
<div [class.fade-out]="stateService.isBrowser && !fullTreeShown && depthShown < depth"></div>
<div class="toggle-wrapper" *ngIf="stateService.isBrowser && (depthShown < depth || fullTreeShown)">
<button class="btn btn-sm btn-primary graph-toggle" (click)="toggleTree(true)" *ngIf="depthShown < depth && !fullTreeShown; else collapseBtn">
<span i18n="show-all">Show all</span>
(<ng-container *ngIf="(depth - depthShown) === 1"><ng-container *ngTemplateOutlet="xRemainingSingular"></ng-container></ng-container>
<ng-container *ngIf="(depth - depthShown) > 1"><ng-container *ngTemplateOutlet="xRemaining; context: {$implicit: depth - depthShown}"></ng-container></ng-container>)
</button>
<ng-template #collapseBtn>
<button class="btn btn-sm btn-primary graph-toggle" (click)="toggleTree(false)"><span i18n="show-less">Show less</span></button>
</ng-template>
</div>
</div>
<ng-template #xRemaining let-x i18n="x-levels-remaining">{{ x }} levels remaining</ng-template>
<ng-template #xRemainingSingular let-x i18n="1-level-remaining">1 level remaining</ng-template>

View File

@ -0,0 +1,31 @@
.fade-out {
position: relative;
&::before {
content: '';
position: absolute;
width: 100%;
height: 40px;
top: -40px;
background: linear-gradient(to top,
var(--fade-out-box-bg-end) 0%,
var(--fade-out-box-bg-end) 30%,
var(--fade-out-box-bg-start) 100%);
z-index: 10000000;
}
}
.toggle-wrapper {
display: flex;
justify-content: center;
width: 100%;
}
.button-container {
display: flex;
justify-content: center;
}
.graph-toggle {
margin-top: 10px;
}

View File

@ -0,0 +1,395 @@
import { Component, ChangeDetectionStrategy, Input, OnChanges, NgZone, SimpleChanges, ChangeDetectorRef } from '@angular/core';
import { Router } from '@angular/router';
import { Location } from '@angular/common';
import { AddressTypeInfo } from '@app/shared/address-utils';
import { EChartsOption } from '@app/graphs/echarts';
import { ScriptInfo } from '@app/shared/script.utils';
import { compactSize, taggedHash, uint8ArrayToHexString } from '@app/shared/transaction.utils';
import { StateService } from '@app/services/state.service';
import { AsmStylerPipe } from '@app/shared/pipes/asm-styler/asm-styler.pipe';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
interface TaprootTree {
name: string; // the TapBranch hash or TapLeaf script hash
value?: LeafNode;
depth?: number;
children?: [TaprootTree, TaprootTree];
// ECharts properties
symbol?: string;
symbolSize?: number;
symbolOffset?: number[];
label?: any;
tooltip?: { label: string, content?: string }[];
}
interface LeafNode {
leafVersion: number;
script: ScriptInfo;
merklePath: string[];
}
@Component({
selector: 'app-taproot-address-scripts',
templateUrl: './taproot-address-scripts.component.html',
styleUrls: ['./taproot-address-scripts.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaprootAddressScriptsComponent implements OnChanges {
@Input() address: AddressTypeInfo;
tree: TaprootTree;
croppedTree: TaprootTree;
croppedTreeDepth: number = 7;
depth: number = 0;
depthShown: number;
height: number;
levelHeight: number = 40;
fullTreeShown: boolean;
chartOptions: EChartsOption = {};
chartInitOptions = {
renderer: 'svg',
};
chartInstance: any;
isTouchscreen: boolean = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || (navigator as any).msMaxTouchPoints > 0;
constructor(
public stateService: StateService,
private asmStylerPipe: AsmStylerPipe,
private cd: ChangeDetectorRef,
private location: Location,
private relativeUrlPipe: RelativeUrlPipe,
private router: Router,
private zone: NgZone,
) { }
ngOnChanges(changes: SimpleChanges) {
if (changes.address?.currentValue.scripts && changes.address.currentValue.scripts.size) {
this.buildTree(Array.from(this.address.scripts.values()));
this.prepareTree(this.tree, 0);
this.cropTree();
this.toggleTree(this.fullTreeShown, false);
}
}
buildTree(scripts: ScriptInfo[]): void {
// Parse script paths into merklePaths list and calculate depth
const merklePaths: { leafVersion: number, merklePath: string[] }[] = [];
this.depth = 0;
for (const script of scripts) {
const controlBlock = script.scriptPath;
const m = ((controlBlock.length / 2) - 33) / 32;
if (!Number.isInteger(m) || m <= 0) {
throw new Error("Merkle path length must be >= 1");
}
const leafVersion = parseInt(controlBlock.slice(0, 2), 16) & 0xfe;
const merklePath = [];
for (let i = 0; i < m; i++) {
merklePath.push(controlBlock.slice(66 + i * 64, 66 + (i + 1) * 64));
}
if (merklePath.length > this.depth) {
this.depth = merklePath.length;
}
merklePaths.push({ leafVersion, merklePath });
}
// treeStructure is a list of maps, where each map contains as keys the hashes of the nodes at that depth, and as values the hashes of its two children
const treeStructure: Map<string, [string, string]>[] = [];
for (let i = 0; i < this.depth; i++) {
treeStructure.push(new Map<string, [string, string]>());
}
const leaves = new Map<string, LeafNode>();
for (let i = 0; i < scripts.length; i++) {
const script = scripts[i];
const merklePath = merklePaths[i].merklePath;
const leafVersion = merklePaths[i].leafVersion;
const tapLeaf = taggedHash('TapLeaf', leafVersion.toString(16) + uint8ArrayToHexString(compactSize(script.hex.length / 2)) + script.hex);
leaves.set(tapLeaf, { leafVersion, script, merklePath });
let k = tapLeaf;
for (let j = 0; j < merklePath.length; j++) {
const e = merklePath[j];
const [firstChild, secondChild] = [k, e].sort((a, b) => a.localeCompare(b));
const parentHash = taggedHash('TapBranch', firstChild + secondChild);
treeStructure[merklePath.length - j - 1].set(parentHash, [firstChild, secondChild]);
k = parentHash;
}
}
// Build the tree recursively
const recursiveBuild = (hash: string, depth: number): TaprootTree => {
const node: TaprootTree = {
name: hash,
depth: depth
};
if (leaves.has(hash)) {
node.value = leaves.get(hash);
return node;
}
if (depth < treeStructure.length && treeStructure[depth].has(hash)) {
const [firstChild, secondChild] = treeStructure[depth].get(hash);
node.children = [
recursiveBuild(firstChild, depth + 1),
recursiveBuild(secondChild, depth + 1)
];
}
return node;
};
const root = treeStructure[0].keys().next().value;
this.tree = recursiveBuild(root, 0);
}
cropTree(): void {
const cropNode = (node: TaprootTree, currentDepth: number) => {
if (!node) {
return;
}
if (currentDepth === this.croppedTreeDepth && node.children) {
delete node.children;
return;
}
if (node.children) {
cropNode(node.children[0], currentDepth + 1);
cropNode(node.children[1], currentDepth + 1);
}
};
this.croppedTree = JSON.parse(JSON.stringify(this.tree));
cropNode(this.croppedTree, 0);
}
toggleTree(show: boolean, delay = true): void {
this.fullTreeShown = show;
this.depthShown = show ? this.depth : Math.min(this.depth, this.croppedTreeDepth);
if (show) {
this.height = (this.depthShown + 1) * this.levelHeight;
setTimeout(() => {
this.prepareChartOptions(this.tree);
this.cd.markForCheck();
}, 115);
} else {
this.prepareChartOptions(this.croppedTree);
if (!delay) {
this.height = (this.depthShown + 1) * this.levelHeight;
} else {
setTimeout(() => {
this.height = (this.depthShown + 1) * this.levelHeight;
this.cd.markForCheck();
}, 200);
}
}
}
prepareTree(node: TaprootTree, depth: number): void {
if (!node) {
return;
}
node.depth = depth;
node.symbol = 'none';
const basePillStyle = {
align: 'center',
padding: [3, 6],
borderRadius: 10,
fontSize: 10,
fontWeight: 'bold',
fontFamily: 'system-ui',
};
if (depth === 0) {
node.symbol = 'none';
node.label = {
formatter: '{pill|Taproot}',
offset: [0, -5],
rich: {
pill: {
...basePillStyle,
backgroundColor: 'var(--tertiary)',
color: '#fff',
},
},
};
node.tooltip = [
{ label: 'TapRoot Hash', content: node.name.slice(0, 10) + '…' + node.name.slice(-10) },
];
}
if (node.children) {
if (depth > 0) {
node.symbol = 'circle';
node.symbolSize = 10;
node.symbolOffset = [0, 5];
node.label = { formatter: '' };
node.tooltip = [
{ label: 'TapBranch Hash', content: node.name.slice(0, 10) + '…' + node.name.slice(-10) },
{ label: 'Depth', content: depth.toString() },
];
}
this.prepareTree(node.children[0], depth + 1);
this.prepareTree(node.children[1], depth + 1);
} else {
if (node.value) {
const script = node.value.script;
const label = script.template?.label;
node.label = {
formatter: `{pill|${label || 'Script'}}`,
offset: [0, 5],
verticalAlign: 'middle',
rich: {
pill: {
...basePillStyle,
backgroundColor: '#ffc107',
color: '#212529'
}
}
};
node.tooltip = [
{ label: 'TapLeaf Hash', content: node.name.slice(0, 10) + '…' + node.name.slice(-10) },
{ label: 'Depth', content: depth.toString() },
{ label: 'Leaf Version', content: node.value.leafVersion.toString(16) },
];
} else {
node.symbol = 'circle';
node.symbolSize = 10;
node.symbolOffset = [0, 5];
node.label = { formatter: '' };
node.tooltip = [
{ label: 'Hash', content: node.name.slice(0, 10) + '…' + node.name.slice(-10) },
{ label: 'Depth', content: depth.toString() },
];
}
}
}
prepareChartOptions(tree: TaprootTree) {
if (!tree) {
return;
}
this.chartOptions = {
tooltip: {
show: true,
backgroundColor: 'rgba(17, 19, 31, 1)',
borderRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.5)',
confine: true,
textStyle: {
color: '#b1b1b1',
},
borderColor: '#000',
formatter: (params: any) => {
const node: TaprootTree = params.data;
if (!node.tooltip) {
return '';
}
let rows = node.tooltip.map(
(item) =>
`<tr>
<td style="color: #fff; padding-right: 5px; width: 30%">${item.label}</td>
<td style="color: #b1b1b1; text-align: right">${item.content}</td>
</tr>`
).join('');
if (node.value?.script.vinId) {
const [txid, vinIndex] = node.value.script.vinId.split(':');
rows += `
<tr>
<td style="color: #fff; padding-right: 5px; width: 30%">Last used in tx</td>
<td style="color: #b1b1b1; text-align: right">
<a href="${this.relativeUrlPipe.transform('/tx/' + txid)}?mode=details#vin=${vinIndex}">${txid.slice(0, 10) + '…' + txid.slice(-10)}</a>
</td>
</tr>`;
}
let asmContent = '';
if (node.value?.script?.asm) {
const asm = this.asmStylerPipe.transform(node.value.script.asm, 300);
asmContent = `
<div style="margin-top: 10px; border-top: 1px solid #333; padding-top: 5px; word-break: break-all; white-space: normal; font-family: monospace; font-size: 12px;">
<td>${asm} ${node.value.script.asm.length > 300 ? '...' : ''}</td>
</div>`;
}
let hiddenScriptsMessage = '';
if (node.tooltip[0].label === 'Hash') {
hiddenScriptsMessage = `
<div style="margin-top: 8px; color: #888; font-size: 11px; line-height: 1.3; font-style: italic; border-top: 1px solid #333; padding-top: 6px; word-break: break-word; white-space: normal">
This node might commit to one or more scripts that have not been revealed yet.
</div>`;
}
return `
<div style="max-width: 300px; pointer-events: auto;"">
<table style="width: 100%; table-layout: fixed;">
<tbody>${rows}</tbody>
</table>
${asmContent}
${hiddenScriptsMessage}
</div>`;
},
},
series: [{
type: 'tree',
data: [tree as any],
top: '20',
bottom: '20',
right: 0,
left: 0,
height: Math.max(140, this.depthShown * this.levelHeight),
lineStyle: {
curveness: 0.9,
width: 2,
},
emphasis: {
focus: 'ancestor',
itemStyle: {
color: '#ccc',
},
lineStyle: {
color: '#ccc',
}
},
orient: 'TB',
expandAndCollapse: false,
animationDuration: 250,
animationDurationUpdate: 250,
}],
};
}
onChartInit(ec) {
this.chartInstance = ec;
this.chartInstance.on('click', 'series', this.onChartClick.bind(this));
}
onChartClick(e): void {
if (this.isTouchscreen) { // show tooltip on touchscreen, and click on link in tooltip to navigate
return;
}
if (!e.data.value?.script.vinId) {
return;
}
const [txid, vinIndex] = e.data.value.script.vinId.split(':');
const url = this.router.createUrlTree([this.relativeUrlPipe.transform('/tx'), txid], { fragment: 'vin=' + vinIndex });
this.zone.run(() => {
if (e.event?.event?.ctrlKey || e.event?.event?.metaKey) {
const fullUrl = this.location.prepareExternalUrl(this.router.serializeUrl(url));
window.open(fullUrl, '_blank');
} else {
this.router.navigate([this.relativeUrlPipe.transform('/tx'), txid], { fragment: 'vin=' + vinIndex });
}
});
}
}

View File

@ -171,7 +171,7 @@
<td i18n="transactions-list.p2wsh-witness-script">P2WSH witness script</td>
</ng-template>
<td style="text-align: left;">
<div [innerHTML]="vin.inner_witnessscript_asm | asmStyler:showFullScript[vindex]"></div>
<div [innerHTML]="vin.inner_witnessscript_asm | asmStyler: (showFullScript[vindex] ? 0 : 1000)"></div>
<div *ngIf="vin.inner_witnessscript_asm.length > 1000" style="display: flex;">
<span *ngIf="!showFullScript[vindex]">...</span>
<label class="btn btn-sm btn-primary mt-2" (click)="toggleShowFullScript(vindex)" style="margin-left: auto;">

View File

@ -1,6 +1,6 @@
// Import tree-shakeable echarts
import * as echarts from 'echarts/core';
import { LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart, CustomChart } from 'echarts/charts';
import { LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart, CustomChart, TreeChart } from 'echarts/charts';
import { TitleComponent, TooltipComponent, GridComponent, LegendComponent, GeoComponent, DataZoomComponent, VisualMapComponent, MarkLineComponent, GraphicComponent } from 'echarts/components';
import { SVGRenderer, CanvasRenderer } from 'echarts/renderers';
// Typescript interfaces
@ -13,6 +13,6 @@ echarts.use([
LegendComponent, GeoComponent, DataZoomComponent,
VisualMapComponent, MarkLineComponent,
LineChart, LinesChart, BarChart, TreemapChart, PieChart, ScatterChart, GaugeChart,
CustomChart, GraphicComponent
CustomChart, GraphicComponent, TreeChart
]);
export { echarts, EChartsOption, TreemapSeriesOption, LineSeriesOption, PieSeriesOption };

View File

@ -40,7 +40,9 @@ import { AddressGraphComponent } from '@components/address-graph/address-graph.c
import { UtxoGraphComponent } from '@components/utxo-graph/utxo-graph.component';
import { ActiveAccelerationBox } from '@components/acceleration/active-acceleration-box/active-acceleration-box.component';
import { AddressesTreemap } from '@components/addresses-treemap/addresses-treemap.component';
import { TaprootAddressScriptsComponent } from '@components/taproot-address-scripts/taproot-address-scripts.component';
import { CommonModule } from '@angular/common';
import { AsmStylerPipe } from '@app/shared/pipes/asm-styler/asm-styler.pipe';
@NgModule({
declarations: [
@ -83,6 +85,7 @@ import { CommonModule } from '@angular/common';
UtxoGraphComponent,
ActiveAccelerationBox,
AddressesTreemap,
TaprootAddressScriptsComponent,
],
imports: [
CommonModule,
@ -95,6 +98,9 @@ import { CommonModule } from '@angular/common';
exports: [
NgxEchartsModule,
ActiveAccelerationBox,
],
providers: [
AsmStylerPipe
]
})
export class GraphsModule { }

View File

@ -152,14 +152,17 @@ export class AddressTypeInfo {
return cloned;
}
public processInputs(vin: Vin[] = []): void {
public processInputs(vin: Vin[] = [], vinIds: string[] = []): void {
// taproot can have multiple script paths
if (this.type === 'v1_p2tr') {
for (const v of vin) {
for (let i = 0; i < vin.length; i++) {
const v = vin[i];
if (v.inner_witnessscript_asm) {
this.tapscript = true;
const controlBlock = v.witness[v.witness.length - 1].startsWith('50') ? v.witness[v.witness.length - 2] : v.witness[v.witness.length - 1];
this.processScript(new ScriptInfo('inner_witnessscript', undefined, v.inner_witnessscript_asm, v.witness, controlBlock));
const hasAnnex = v.witness[v.witness.length - 1].startsWith('50');
const controlBlock = hasAnnex ? v.witness[v.witness.length - 2] : v.witness[v.witness.length - 1];
const scriptHex = hasAnnex ? v.witness[v.witness.length - 3] : v.witness[v.witness.length - 2];
this.processScript(new ScriptInfo('inner_witnessscript', scriptHex, v.inner_witnessscript_asm, v.witness, controlBlock, vinIds?.[i]));
}
}
// for single-script types, if we've seen one input we've seen them all
@ -225,6 +228,9 @@ export class AddressTypeInfo {
}
private processScript(script: ScriptInfo): void {
if (this.scripts.has(script.key)) {
return;
}
this.scripts.set(script.key, script);
if (script.template?.type === 'multisig') {
this.isMultisig = { m: script.template['m'], n: script.template['n'] };

View File

@ -5,7 +5,7 @@ import { Pipe, PipeTransform } from '@angular/core';
})
export class AsmStylerPipe implements PipeTransform {
transform(asm: string, showAll = true): string {
transform(asm: string, crop: number = 0): string {
const instructions = asm.split('OP_');
let out = '';
let chars = -3;
@ -13,7 +13,7 @@ export class AsmStylerPipe implements PipeTransform {
if (instruction === '') {
continue;
}
if (!showAll && chars > 1000) {
if (crop && chars > crop) {
break;
}
chars += instruction.length + 3;

View File

@ -174,15 +174,19 @@ export class ScriptInfo {
scriptPath?: string;
hex?: string;
asm?: string;
vinId?: string;
template: ScriptTemplate;
constructor(type: ScriptType, hex?: string, asm?: string, witness?: string[], scriptPath?: string) {
constructor(type: ScriptType, hex?: string, asm?: string, witness?: string[], scriptPath?: string, vinId?: string) {
this.type = type;
this.hex = hex;
this.asm = asm;
if (scriptPath) {
this.scriptPath = scriptPath;
}
if (vinId) {
this.vinId = vinId;
}
if (this.asm) {
this.template = detectScriptTemplate(this.type, this.asm, witness);
}

View File

@ -3,7 +3,7 @@ import { getVarIntLength, parseMultisigScript, isPoint } from '@app/shared/scrip
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 } from './sha256';
import { hash, Hash } from './sha256';
// Bitcoin Core default policy settings
const MAX_STANDARD_TX_WEIGHT = 400_000;
@ -1380,11 +1380,11 @@ function toWords(bytes) {
}
// Helper functions
function uint8ArrayToHexString(uint8Array: Uint8Array): string {
export function uint8ArrayToHexString(uint8Array: Uint8Array): string {
return Array.from(uint8Array).map(byte => byte.toString(16).padStart(2, '0')).join('');
}
function hexStringToUint8Array(hex: string): Uint8Array {
export function hexStringToUint8Array(hex: string): Uint8Array {
const buf = new Uint8Array(hex.length / 2);
for (let i = 0; i < buf.length; i++) {
buf[i] = parseInt(hex.substr(i * 2, 2), 16);
@ -1521,6 +1521,32 @@ function readVector(buffer: Uint8Array, offset: number): [Uint8Array[], number]
return [vector, updatedOffset];
}
// SHA256(SHA256(tag) || SHA256(tag) || dataHex)
export function taggedHash(tag: string, dataHex: string): string {
const encoder = new TextEncoder();
const tagHash = hash(encoder.encode(tag));
return uint8ArrayToHexString(hash(new Uint8Array([...tagHash, ...tagHash, ...hexStringToUint8Array(dataHex)])));
}
export function compactSize(n: number): Uint8Array {
if (n <= 252) {
return new Uint8Array([n]);
} else if (n <= 0xffff) {
return new Uint8Array([0xfd, n & 0xff, (n >> 8) & 0xff]);
} else if (n <= 0xffffffff) {
return new Uint8Array([0xfe, n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >> 24) & 0xff]);
} else {
const buffer = new Uint8Array(9);
buffer[0] = 0xff;
let num = BigInt(n);
for (let i = 1; i <= 8; i++) {
buffer[i] = Number(num & BigInt(0xff));
num >>= BigInt(8);
}
return buffer;
}
}
// Inversed the opcodes object from https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/utils/bitcoin-script.ts#L1
const opcodes = {
0: 'OP_0',