mirror of
https://github.com/mempool/mempool.git
synced 2026-08-13 12:33:11 +02:00
456 lines
15 KiB
TypeScript
456 lines
15 KiB
TypeScript
import { Component, ChangeDetectionStrategy, Input, OnChanges, NgZone, Output, SimpleChanges, ChangeDetectorRef, EventEmitter } from '@angular/core';
|
|
import { Router } from '@angular/router';
|
|
import { Location } from '@angular/common';
|
|
import { EChartsOption } from '@app/graphs/echarts';
|
|
import { ScriptInfo } from '@app/shared/script.utils';
|
|
import { computeLeafHash, taggedHash, addressToScriptPubKey } 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 '@app/shared/pipes/relative-url/relative-url.pipe';
|
|
|
|
interface TaprootTree {
|
|
name: string; // the TapBranch hash or TapLeaf script hash
|
|
value?: LeafNode;
|
|
special?: 'internalKey' | 'merkleRoot';
|
|
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'],
|
|
standalone: false,
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
})
|
|
export class TaprootAddressScriptsComponent implements OnChanges {
|
|
@Input() address: string;
|
|
@Input() network?: string;
|
|
@Input() scripts: Map<string, ScriptInfo>;
|
|
@Output() tapTreeIncomplete = new EventEmitter<boolean>(true);
|
|
|
|
tree: TaprootTree;
|
|
croppedTree: TaprootTree;
|
|
croppedTreeDepth: number = 7;
|
|
depth: number = 0;
|
|
depthShown: number;
|
|
height: number;
|
|
levelHeight: number = 40;
|
|
fullTreeShown: boolean;
|
|
maybeTapTreeIncomplete: boolean = false;
|
|
isNUMS: boolean = false;
|
|
|
|
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.scripts?.currentValue && changes.scripts.currentValue.size) {
|
|
this.buildTree(Array.from(this.scripts.values()));
|
|
this.prepareTree(this.tree, 0);
|
|
this.tapTreeIncomplete.emit(this.maybeTapTreeIncomplete);
|
|
this.cropTree();
|
|
this.toggleTree(this.fullTreeShown, false);
|
|
}
|
|
}
|
|
|
|
buildTree(scripts: ScriptInfo[]): void {
|
|
this.depth = 0;
|
|
this.maybeTapTreeIncomplete = false;
|
|
|
|
// 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]>[] = [];
|
|
const leaves = new Map<string, LeafNode>();
|
|
const ensureLevels = (levels: number) => {
|
|
while (treeStructure.length < levels) {
|
|
treeStructure.push(new Map<string, [string, string]>());
|
|
}
|
|
};
|
|
|
|
for (const script of scripts) {
|
|
const leafVersion = script.taprootInfo.scriptPath.leafVersion;
|
|
const merklePath = script.taprootInfo.scriptPath.merkleBranches.slice();
|
|
this.depth = Math.max(this.depth, merklePath.length);
|
|
ensureLevels(merklePath.length);
|
|
const tapLeaf = computeLeafHash(script.hex, leafVersion);
|
|
leaves.set(tapLeaf, { leafVersion, script, merklePath });
|
|
let k = tapLeaf;
|
|
for (let j = 0; j < merklePath.length; j++) {
|
|
const e = merklePath[j];
|
|
const firstChild = k < e ? k : e;
|
|
const secondChild = firstChild === k ? e : k;
|
|
const parentHash = taggedHash('TapBranch', firstChild + secondChild);
|
|
const previousLevelIndex = merklePath.length - j - 1;
|
|
const level = treeStructure[previousLevelIndex];
|
|
if (level.has(parentHash)) {
|
|
break;
|
|
}
|
|
level.set(parentHash, [firstChild, secondChild]);
|
|
k = parentHash;
|
|
}
|
|
}
|
|
|
|
// Expand the tree to include public key, internal key and merkle root
|
|
const internalKeyNode = scripts[0].taprootInfo.scriptPath.internalKey;
|
|
const merkleRootNode = treeStructure.length > 0 ? treeStructure[0].keys().next().value : leaves.keys().next().value;
|
|
const outputKeyNode = addressToScriptPubKey(this.address, this.network || this.stateService.network || '').scriptPubKey.slice(4);
|
|
treeStructure.unshift(new Map<string, [string, string]>());
|
|
treeStructure[0].set(outputKeyNode, [internalKeyNode, merkleRootNode]);
|
|
this.depth++;
|
|
this.isNUMS = scripts[0].taprootInfo.scriptPath.isNUMS;
|
|
|
|
// Build the tree recursively
|
|
const recursiveBuild = (hash: string, depth: number, special?: TaprootTree['special']): TaprootTree => {
|
|
const node: TaprootTree = {
|
|
name: hash,
|
|
depth: depth
|
|
};
|
|
|
|
if (special) {
|
|
node.special = special;
|
|
}
|
|
|
|
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, depth === 0 ? 'internalKey' : undefined),
|
|
recursiveBuild(secondChild, depth + 1, depth === 0 ? 'merkleRoot' : undefined)
|
|
];
|
|
}
|
|
|
|
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|Public Key}',
|
|
offset: [0, -5],
|
|
rich: {
|
|
pill: {
|
|
...basePillStyle,
|
|
backgroundColor: 'var(--primary)',
|
|
color: '#fff',
|
|
},
|
|
},
|
|
};
|
|
node.tooltip = [
|
|
{ label: 'Public Key', content: node.name.slice(0, 10) + '…' + node.name.slice(-10) },
|
|
];
|
|
}
|
|
|
|
if (node.children) {
|
|
if (depth > 0) {
|
|
if (!node.special) {
|
|
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 - 1).toString() },
|
|
];
|
|
} else if (node.special === 'merkleRoot') {
|
|
node.label = {
|
|
formatter: '{pill|Taproot}',
|
|
offset: [0, -5],
|
|
rich: {
|
|
pill: {
|
|
...basePillStyle,
|
|
backgroundColor: 'var(--tertiary)',
|
|
color: '#fff',
|
|
},
|
|
},
|
|
};
|
|
node.tooltip = [
|
|
{ label: 'Merkle Root', content: node.name.slice(0, 10) + '…' + node.name.slice(-10) },
|
|
];
|
|
}
|
|
}
|
|
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 - 1).toString() },
|
|
{ label: 'Leaf Version', content: node.value.leafVersion.toString(16) },
|
|
];
|
|
|
|
} else if (node.special === 'internalKey') {
|
|
const internalKeyLabel = this.isNUMS ? 'Internal Key 🚫' : 'Internal Key';
|
|
node.label = {
|
|
formatter: `{pill|${internalKeyLabel}}`,
|
|
offset: [0, -5],
|
|
rich: {
|
|
pill: {
|
|
...basePillStyle,
|
|
backgroundColor: this.isNUMS ? 'var(--grey)' : 'var(--tertiary)',
|
|
color: '#fff',
|
|
},
|
|
},
|
|
};
|
|
node.tooltip = [
|
|
{ label: 'Internal Key', content: node.name.slice(0, 10) + '…' + node.name.slice(-10) },
|
|
];
|
|
} 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 - 1).toString() },
|
|
];
|
|
this.maybeTapTreeIncomplete = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
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: ${item.label === 'Internal Key' && this.isNUMS ? '#ffdddd' : '#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>`;
|
|
} else if (node.value?.script?.type === 'inner_simplicityscript') {
|
|
const hex = node.value.script.hex.slice(0, 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>Simplicity script: ${hex} ${node.value.script.hex.length > 300 ? '...' : ''}</td>
|
|
</div>`;
|
|
}
|
|
|
|
let hiddenScriptsMessage = '';
|
|
if (node.tooltip[0].label === 'Hash') {
|
|
const remaining = 128 - (node.depth - 1);
|
|
let upperBoundHtml: string;
|
|
if (remaining === 0) {
|
|
upperBoundHtml = '1';
|
|
} else if (remaining <= 39) {
|
|
upperBoundHtml = (2 ** remaining).toLocaleString();
|
|
} else {
|
|
upperBoundHtml = `2<sup style="font-size: 0.85em;">${remaining}</sup>`;
|
|
}
|
|
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 ${upperBoundHtml === '1' ? 'exactly 1 script' : `at most ${upperBoundHtml} scripts`}.
|
|
</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 });
|
|
}
|
|
});
|
|
|
|
}
|
|
}
|