Decode inscription / rune data client-side
This commit is contained in:
parent
4143a5f593
commit
8b6db768cd
@ -6,6 +6,7 @@ import { ZONE_SERVICE } from './injection-tokens';
|
|||||||
import { AppRoutingModule } from './app-routing.module';
|
import { AppRoutingModule } from './app-routing.module';
|
||||||
import { AppComponent } from './components/app/app.component';
|
import { AppComponent } from './components/app/app.component';
|
||||||
import { ElectrsApiService } from './services/electrs-api.service';
|
import { ElectrsApiService } from './services/electrs-api.service';
|
||||||
|
import { OrdApiService } from './services/ord-api.service';
|
||||||
import { StateService } from './services/state.service';
|
import { StateService } from './services/state.service';
|
||||||
import { CacheService } from './services/cache.service';
|
import { CacheService } from './services/cache.service';
|
||||||
import { PriceService } from './services/price.service';
|
import { PriceService } from './services/price.service';
|
||||||
@ -32,6 +33,7 @@ import { DatePipe } from '@angular/common';
|
|||||||
|
|
||||||
const providers = [
|
const providers = [
|
||||||
ElectrsApiService,
|
ElectrsApiService,
|
||||||
|
OrdApiService,
|
||||||
StateService,
|
StateService,
|
||||||
CacheService,
|
CacheService,
|
||||||
PriceService,
|
PriceService,
|
||||||
|
75
frontend/src/app/components/ord-data/ord-data.component.html
Normal file
75
frontend/src/app/components/ord-data/ord-data.component.html
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
@if (error) {
|
||||||
|
<div>
|
||||||
|
<i>Error fetching data (code {{ error.status }})</i>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
@if (minted) {
|
||||||
|
<ng-container i18n="ord.mint-n-runes">
|
||||||
|
<span>Mint</span>
|
||||||
|
<span class="amount"> {{ minted >= 100000 ? (minted | amountShortener:undefined:undefined:true) : minted }} </span>
|
||||||
|
<ng-container *ngTemplateOutlet="runeName; context: { $implicit: runestone.mint.unwrap().toString() }"></ng-container>
|
||||||
|
</ng-container>
|
||||||
|
}
|
||||||
|
@if (totalSupply > -1) {
|
||||||
|
@if (premined > 0) {
|
||||||
|
<ng-container i18n="ord.premine-n-runes">
|
||||||
|
<span>Premine</span>
|
||||||
|
<span class="amount"> {{ premined >= 100000 ? (premined | amountShortener:undefined:undefined:true) : premined }} </span>
|
||||||
|
{{ etchedSymbol }}
|
||||||
|
<span class="name">{{ etchedName }}</span>
|
||||||
|
<span> ({{ premined / totalSupply * 100 | amountShortener:0}}% of total supply)</span>
|
||||||
|
</ng-container>
|
||||||
|
} @else {
|
||||||
|
<ng-container i18n="ord.etch-rune">
|
||||||
|
<span>Etching of</span>
|
||||||
|
{{ etchedSymbol }}
|
||||||
|
<span class="name">{{ etchedName }}</span>
|
||||||
|
</ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (transferredRunes?.length && type === 'vout') {
|
||||||
|
<div *ngFor="let rune of transferredRunes">
|
||||||
|
<ng-container i18n="ord.transfer-rune">
|
||||||
|
<span>Transfer</span>
|
||||||
|
<ng-container *ngTemplateOutlet="runeName; context: { $implicit: rune.key }"></ng-container>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- @if (runestone && !runestone?.etching && !runestone?.mint && !transferredRunes?.length && type === 'vout') {
|
||||||
|
<div>
|
||||||
|
<i>No content in this runestone</i>
|
||||||
|
</div>
|
||||||
|
} -->
|
||||||
|
|
||||||
|
@if (inscriptions?.length && type === 'vin') {
|
||||||
|
<div *ngFor="let contentType of inscriptionsData | keyvalue">
|
||||||
|
<div>
|
||||||
|
<span class="badge badge-ord mr-1">{{ contentType.value.count > 1 ? contentType.value.count + " " : "" }}{{ contentType.value?.tag || contentType.key }}</span>
|
||||||
|
<span class="badge badge-ord" *ngIf="contentType.value.totalSize > 0">{{ contentType.value.totalSize | bytes:2:'B':undefined:true }}</span>
|
||||||
|
<a *ngIf="contentType.value.delegate" [routerLink]="['/tx' | relativeUrl, contentType.value.delegate]">
|
||||||
|
<span i18n="ord.source-inscription">Source inscription</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<pre *ngIf="contentType.value.json" class="name" style="white-space: pre-wrap; word-break: break-word;">{{ contentType.value.json | json }}</pre>
|
||||||
|
<pre *ngIf="contentType.value.text" class="name" style="white-space: pre-wrap; word-break: break-word;">{{ contentType.value.text }}</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!runestone && type === 'vout') {
|
||||||
|
<div class="skeleton-loader" style="width: 50%;"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!inscriptions?.length && type === 'vin') {
|
||||||
|
<div>
|
||||||
|
<i>Error decoding inscription data</i>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<ng-template #runeName let-id>
|
||||||
|
{{ runeInfo[id]?.etching.symbol.isSome() ? runeInfo[id]?.etching.symbol.unwrap() : '' }}
|
||||||
|
<a [routerLink]="id !== '1:0' ? ['/tx' | relativeUrl, runeInfo[id]?.txid] : null" [class.rune-link]="id !== '1:0'" [class.disabled]="id === '1:0'">
|
||||||
|
<span class="name">{{ runeInfo[id]?.name }}</span>
|
||||||
|
</a>
|
||||||
|
</ng-template>
|
35
frontend/src/app/components/ord-data/ord-data.component.scss
Normal file
35
frontend/src/app/components/ord-data/ord-data.component.scss
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
.amount {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.rune-link {
|
||||||
|
color: inherit;
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: var(--transparent-fg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.disabled {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
color: var(--transparent-fg);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-ord {
|
||||||
|
background-color: var(--grey);
|
||||||
|
position: relative;
|
||||||
|
top: -2px;
|
||||||
|
font-size: 81%;
|
||||||
|
&.primary {
|
||||||
|
background-color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
margin-top: 5px;
|
||||||
|
max-height: 150px;
|
||||||
|
}
|
140
frontend/src/app/components/ord-data/ord-data.component.ts
Normal file
140
frontend/src/app/components/ord-data/ord-data.component.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||||
|
import { Runestone } from '../../shared/ord/rune/runestone';
|
||||||
|
import { Etching } from '../../shared/ord/rune/etching';
|
||||||
|
import { u128, u32, u8 } from '../../shared/ord/rune/integer';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { SpacedRune } from '../../shared/ord/rune/spacedrune';
|
||||||
|
|
||||||
|
export interface Inscription {
|
||||||
|
body?: Uint8Array;
|
||||||
|
body_length?: number;
|
||||||
|
content_type?: Uint8Array;
|
||||||
|
content_type_str?: string;
|
||||||
|
delegate_txid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-ord-data',
|
||||||
|
templateUrl: './ord-data.component.html',
|
||||||
|
styleUrls: ['./ord-data.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class OrdDataComponent implements OnChanges {
|
||||||
|
@Input() inscriptions: Inscription[];
|
||||||
|
@Input() runestone: Runestone;
|
||||||
|
@Input() runeInfo: { [id: string]: { etching: Etching; txid: string; name?: string; } };
|
||||||
|
@Input() error: HttpErrorResponse;
|
||||||
|
@Input() type: 'vin' | 'vout';
|
||||||
|
|
||||||
|
// Inscriptions
|
||||||
|
inscriptionsData: { [key: string]: { count: number, totalSize: number, text?: string; json?: JSON; tag?: string; delegate?: string } };
|
||||||
|
// Rune mints
|
||||||
|
minted: number;
|
||||||
|
// Rune etching
|
||||||
|
premined: number = -1;
|
||||||
|
totalSupply: number = -1;
|
||||||
|
etchedName: string;
|
||||||
|
etchedSymbol: string;
|
||||||
|
// Rune transfers
|
||||||
|
transferredRunes: { key: string; etching: Etching; txid: string; name?: string; }[] = [];
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (changes.runestone && this.runestone) {
|
||||||
|
|
||||||
|
Object.keys(this.runeInfo).forEach((key) => {
|
||||||
|
const rune = this.runeInfo[key].etching.rune.isSome() ? this.runeInfo[key].etching.rune.unwrap() : null;
|
||||||
|
const spacers = this.runeInfo[key].etching.spacers.isSome() ? this.runeInfo[key].etching.spacers.unwrap() : u32(0);
|
||||||
|
if (rune) {
|
||||||
|
this.runeInfo[key].name = new SpacedRune(rune, Number(spacers)).toString();
|
||||||
|
}
|
||||||
|
this.transferredRunes.push({ key, ...this.runeInfo[key] });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
if (this.runestone.mint.isSome() && this.runeInfo[this.runestone.mint.unwrap().toString()]) {
|
||||||
|
const mint = this.runestone.mint.unwrap().toString();
|
||||||
|
this.transferredRunes = this.transferredRunes.filter(rune => rune.key !== mint);
|
||||||
|
const terms = this.runeInfo[mint].etching.terms.isSome() ? this.runeInfo[mint].etching.terms.unwrap() : null;
|
||||||
|
let amount: u128;
|
||||||
|
if (terms) {
|
||||||
|
amount = terms.amount.isSome() ? terms.amount.unwrap() : u128(0);
|
||||||
|
}
|
||||||
|
const divisibility = this.runeInfo[mint].etching.divisibility.isSome() ? this.runeInfo[mint].etching.divisibility.unwrap() : u8(0);
|
||||||
|
if (amount) {
|
||||||
|
this.minted = this.getAmount(amount, divisibility);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.runestone.etching.isSome()) {
|
||||||
|
const etching = this.runestone.etching.unwrap();
|
||||||
|
const rune = etching.rune.isSome() ? etching.rune.unwrap() : null;
|
||||||
|
const spacers = etching.spacers.isSome() ? etching.spacers.unwrap() : u32(0);
|
||||||
|
if (rune) {
|
||||||
|
this.etchedName = new SpacedRune(rune, Number(spacers)).toString();
|
||||||
|
}
|
||||||
|
this.etchedSymbol = etching.symbol.isSome() ? etching.symbol.unwrap() : '';
|
||||||
|
|
||||||
|
const divisibility = etching.divisibility.isSome() ? etching.divisibility.unwrap() : u8(0);
|
||||||
|
const premine = etching.premine.isSome() ? etching.premine.unwrap() : u128(0);
|
||||||
|
if (premine) {
|
||||||
|
this.premined = this.getAmount(premine, divisibility);
|
||||||
|
} else {
|
||||||
|
this.premined = 0;
|
||||||
|
}
|
||||||
|
const terms = etching.terms.isSome() ? etching.terms.unwrap() : null;
|
||||||
|
let amount: u128;
|
||||||
|
if (terms) {
|
||||||
|
amount = terms.amount.isSome() ? terms.amount.unwrap() : u128(0);
|
||||||
|
if (amount) {
|
||||||
|
const cap = terms.cap.isSome() ? terms.cap.unwrap() : u128(0);
|
||||||
|
this.totalSupply = this.premined + this.getAmount(amount, divisibility) * Number(cap);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.totalSupply = this.premined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes.inscriptions && this.inscriptions) {
|
||||||
|
|
||||||
|
if (this.inscriptions?.length) {
|
||||||
|
this.inscriptionsData = {};
|
||||||
|
this.inscriptions.forEach((inscription) => {
|
||||||
|
// General: count, total size, delegate
|
||||||
|
const key = inscription.content_type_str || 'undefined';
|
||||||
|
if (!this.inscriptionsData[key]) {
|
||||||
|
this.inscriptionsData[key] = { count: 0, totalSize: 0 };
|
||||||
|
}
|
||||||
|
this.inscriptionsData[key].count++;
|
||||||
|
this.inscriptionsData[key].totalSize += inscription.body_length;
|
||||||
|
if (inscription.delegate_txid && !this.inscriptionsData[key].delegate) {
|
||||||
|
this.inscriptionsData[key].delegate = inscription.delegate_txid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text / JSON data
|
||||||
|
if ((key.includes('text') || key.includes('json')) && inscription.body?.length && !this.inscriptionsData[key].text && !this.inscriptionsData[key].json) {
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
const text = decoder.decode(inscription.body);
|
||||||
|
try {
|
||||||
|
this.inscriptionsData[key].json = JSON.parse(text);
|
||||||
|
if (this.inscriptionsData[key].json['p']) {
|
||||||
|
this.inscriptionsData[key].tag = this.inscriptionsData[key].json['p'].toUpperCase();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.inscriptionsData[key].text = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAmount(amount: u128 | bigint, divisibility: u8): number {
|
||||||
|
const divisor = BigInt(10) ** BigInt(divisibility);
|
||||||
|
const result = amount / divisor;
|
||||||
|
|
||||||
|
return result <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(result) : Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
|
}
|
@ -81,7 +81,8 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right nowrap amount" [class]="{large: vin?.prevout?.value > 1000000000}">
|
<td class="text-right nowrap amount" [class]="{large: vin?.prevout?.value > 1000000000 || vin.isInscription}">
|
||||||
|
<button *ngIf="vin.isInscription" (click)="toggleOrdData(tx.txid, 'vin', vindex)" type="button" class="btn btn-sm badge badge-ord primary" style="margin-right: 10px;">Inscription</button>
|
||||||
<ng-template [ngIf]="vin.prevout && vin.prevout.asset && vin.prevout.asset !== nativeAssetId" [ngIfElse]="defaultOutput">
|
<ng-template [ngIf]="vin.prevout && vin.prevout.asset && vin.prevout.asset !== nativeAssetId" [ngIfElse]="defaultOutput">
|
||||||
<div *ngIf="assetsMinimal && assetsMinimal[vin.prevout.asset] else assetVinNotFound">
|
<div *ngIf="assetsMinimal && assetsMinimal[vin.prevout.asset] else assetVinNotFound">
|
||||||
<ng-container *ngTemplateOutlet="assetBox; context:{ $implicit: vin.prevout }"></ng-container>
|
<ng-container *ngTemplateOutlet="assetBox; context:{ $implicit: vin.prevout }"></ng-container>
|
||||||
@ -96,6 +97,15 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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': this.address !== '' && (vin.prevout?.scriptpubkey_address === this.address || (vin.prevout?.scriptpubkey_type === 'p2pk' && vin.prevout?.scriptpubkey.slice(2, -2) === this.address))
|
||||||
|
}">
|
||||||
|
<td></td>
|
||||||
|
<td colspan="2">
|
||||||
|
<app-ord-data [inscriptions]="showOrdData[tx.txid + '-vin-' + vindex]['inscriptions']" [type]="'vin'" [error]="showOrdData[tx.txid + '-vin-' + vindex]['error']"></app-ord-data>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr *ngIf="(showDetails$ | async) === true">
|
<tr *ngIf="(showDetails$ | async) === true">
|
||||||
<td colspan="3" class="details-container" >
|
<td colspan="3" class="details-container" >
|
||||||
<table class="table table-striped table-fixed table-borderless details-table mb-3">
|
<table class="table table-striped table-fixed table-borderless details-table mb-3">
|
||||||
@ -236,7 +246,12 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template #defaultscriptpubkey_type>
|
<ng-template #defaultscriptpubkey_type>
|
||||||
<ng-template [ngIf]="vout.scriptpubkey_type === 'op_return'" [ngIfElse]="otherPubkeyType">
|
<ng-template [ngIf]="vout.scriptpubkey_type === 'op_return'" [ngIfElse]="otherPubkeyType">
|
||||||
OP_RETURN <a placement="bottom" [ngbTooltip]="vout.scriptpubkey_asm | hex2ascii"><span *ngIf="vout.scriptpubkey_asm !== 'OP_RETURN'" class="badge badge-secondary scriptmessage">{{ vout.scriptpubkey_asm | hex2ascii }}</span></a>
|
OP_RETURN
|
||||||
|
@if (vout.isRunestone) {
|
||||||
|
<button (click)="toggleOrdData(tx.txid, 'vout', vindex)" type="button" class="btn btn-sm badge badge-ord">Runestone</button>
|
||||||
|
} @else {
|
||||||
|
<a placement="bottom" [ngbTooltip]="vout.scriptpubkey_asm | hex2ascii"><span *ngIf="vout.scriptpubkey_asm !== 'OP_RETURN'" class="badge badge-secondary scriptmessage">{{ vout.scriptpubkey_asm | hex2ascii }}</span></a>
|
||||||
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template #otherPubkeyType>{{ vout.scriptpubkey_type | scriptpubkeyType }}</ng-template>
|
<ng-template #otherPubkeyType>{{ vout.scriptpubkey_type | scriptpubkeyType }}</ng-template>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@ -276,6 +291,15 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
<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': this.address !== '' && (vout.scriptpubkey_address === this.address || (vout.scriptpubkey_type === 'p2pk' && vout.scriptpubkey.slice(2, -2) === this.address))
|
||||||
|
}">
|
||||||
|
<td colspan="3">
|
||||||
|
<app-ord-data [runestone]="showOrdData[tx.txid + '-vout-' + vindex]['runestone']" [runeInfo]="showOrdData[tx.txid + '-vout-' + vindex]['runeInfo']" [type]="'vout'" [error]="showOrdData[tx.txid + '-vout-' + vindex]['error']"></app-ord-data>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr *ngIf="(showDetails$ | async) === true">
|
<tr *ngIf="(showDetails$ | async) === true">
|
||||||
<td colspan="3" class=" details-container" >
|
<td colspan="3" class=" details-container" >
|
||||||
<table class="table table-striped table-borderless details-table mb-3">
|
<table class="table table-striped table-borderless details-table mb-3">
|
||||||
|
@ -176,3 +176,14 @@ h2 {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-ord {
|
||||||
|
background-color: var(--grey);
|
||||||
|
position: relative;
|
||||||
|
top: -2px;
|
||||||
|
font-size: 81%;
|
||||||
|
border: 0;
|
||||||
|
&.primary {
|
||||||
|
background-color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -11,6 +11,10 @@ import { BlockExtended } from '../../interfaces/node-api.interface';
|
|||||||
import { ApiService } from '../../services/api.service';
|
import { ApiService } from '../../services/api.service';
|
||||||
import { PriceService } from '../../services/price.service';
|
import { PriceService } from '../../services/price.service';
|
||||||
import { StorageService } from '../../services/storage.service';
|
import { StorageService } from '../../services/storage.service';
|
||||||
|
import { OrdApiService } from '../../services/ord-api.service';
|
||||||
|
import { Inscription } from '../ord-data/ord-data.component';
|
||||||
|
import { Runestone } from '../../shared/ord/rune/runestone';
|
||||||
|
import { Etching } from '../../shared/ord/rune/etching';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-transactions-list',
|
selector: 'app-transactions-list',
|
||||||
@ -50,12 +54,14 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
outputRowLimit: number = 12;
|
outputRowLimit: number = 12;
|
||||||
showFullScript: { [vinIndex: number]: boolean } = {};
|
showFullScript: { [vinIndex: number]: boolean } = {};
|
||||||
showFullWitness: { [vinIndex: number]: { [witnessIndex: number]: boolean } } = {};
|
showFullWitness: { [vinIndex: number]: { [witnessIndex: number]: boolean } } = {};
|
||||||
|
showOrdData: { [key: string]: { show: boolean; inscriptions?: Inscription[]; runestone?: Runestone, runeInfo?: { [id: string]: { etching: Etching; txid: string; } }; } } = {};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public stateService: StateService,
|
public stateService: StateService,
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private electrsApiService: ElectrsApiService,
|
private electrsApiService: ElectrsApiService,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
|
private ordApiService: OrdApiService,
|
||||||
private assetsService: AssetsService,
|
private assetsService: AssetsService,
|
||||||
private ref: ChangeDetectorRef,
|
private ref: ChangeDetectorRef,
|
||||||
private priceService: PriceService,
|
private priceService: PriceService,
|
||||||
@ -239,6 +245,24 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
tap((price) => tx['price'] = price),
|
tap((price) => tx['price'] = price),
|
||||||
).subscribe();
|
).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for ord data fingerprints in inputs and outputs
|
||||||
|
if (this.stateService.network !== 'liquid' && this.stateService.network !== 'liquidtestnet') {
|
||||||
|
for (let i = 0; i < tx.vin.length; i++) {
|
||||||
|
if (tx.vin[i].prevout?.scriptpubkey_type === 'v1_p2tr' && tx.vin[i].witness?.length) {
|
||||||
|
const hasAnnex = tx.vin[i].witness?.[tx.vin[i].witness.length - 1].startsWith('50');
|
||||||
|
if (tx.vin[i].witness.length > (hasAnnex ? 2 : 1) && tx.vin[i].witness[tx.vin[i].witness.length - (hasAnnex ? 3 : 2)].includes('0063036f7264')) {
|
||||||
|
tx.vin[i].isInscription = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < tx.vout.length; i++) {
|
||||||
|
if (tx.vout[i]?.scriptpubkey?.startsWith('6a5d')) {
|
||||||
|
tx.vout[i].isRunestone = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.blockTime && this.transactions?.length && this.currency) {
|
if (this.blockTime && this.transactions?.length && this.currency) {
|
||||||
@ -372,6 +396,40 @@ export class TransactionsListComponent implements OnInit, OnChanges {
|
|||||||
this.showFullWitness[vinIndex][witnessIndex] = !this.showFullWitness[vinIndex][witnessIndex];
|
this.showFullWitness[vinIndex][witnessIndex] = !this.showFullWitness[vinIndex][witnessIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleOrdData(txid: string, type: 'vin' | 'vout', index: number) {
|
||||||
|
const tx = this.transactions.find((tx) => tx.txid === txid);
|
||||||
|
if (!tx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = tx.txid + '-' + type + '-' + index;
|
||||||
|
this.showOrdData[key] = this.showOrdData[key] || { show: false };
|
||||||
|
|
||||||
|
if (type === 'vin') {
|
||||||
|
|
||||||
|
if (!this.showOrdData[key].inscriptions) {
|
||||||
|
const hasAnnex = tx.vin[index].witness?.[tx.vin[index].witness.length - 1].startsWith('50');
|
||||||
|
this.showOrdData[key].inscriptions = this.ordApiService.decodeInscriptions(tx.vin[index].witness[tx.vin[index].witness.length - (hasAnnex ? 3 : 2)]);
|
||||||
|
}
|
||||||
|
this.showOrdData[key].show = !this.showOrdData[key].show;
|
||||||
|
|
||||||
|
} else if (type === 'vout') {
|
||||||
|
|
||||||
|
if (!this.showOrdData[key].runestone) {
|
||||||
|
this.ordApiService.decodeRunestone$(tx).pipe(
|
||||||
|
tap((runestone) => {
|
||||||
|
if (runestone) {
|
||||||
|
Object.assign(this.showOrdData[key], runestone);
|
||||||
|
this.ref.markForCheck();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).subscribe();
|
||||||
|
}
|
||||||
|
this.showOrdData[key].show = !this.showOrdData[key].show;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.outspendsSubscription.unsubscribe();
|
this.outspendsSubscription.unsubscribe();
|
||||||
this.currencyChangeSubscription?.unsubscribe();
|
this.currencyChangeSubscription?.unsubscribe();
|
||||||
|
@ -74,6 +74,8 @@ export interface Vin {
|
|||||||
issuance?: Issuance;
|
issuance?: Issuance;
|
||||||
// Custom
|
// Custom
|
||||||
lazy?: boolean;
|
lazy?: boolean;
|
||||||
|
// Ord
|
||||||
|
isInscription?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Issuance {
|
interface Issuance {
|
||||||
@ -98,6 +100,8 @@ export interface Vout {
|
|||||||
valuecommitment?: number;
|
valuecommitment?: number;
|
||||||
asset?: string;
|
asset?: string;
|
||||||
pegout?: Pegout;
|
pegout?: Pegout;
|
||||||
|
// Ord
|
||||||
|
isRunestone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Pegout {
|
interface Pegout {
|
||||||
|
@ -107,6 +107,10 @@ export class ElectrsApiService {
|
|||||||
return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/block-height/' + height, {responseType: 'text'});
|
return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/block-height/' + height, {responseType: 'text'});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBlockTxId$(hash: string, index: number): Observable<string> {
|
||||||
|
return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/block/' + hash + '/txid/' + index, { responseType: 'text' });
|
||||||
|
}
|
||||||
|
|
||||||
getAddress$(address: string): Observable<Address> {
|
getAddress$(address: string): Observable<Address> {
|
||||||
return this.httpClient.get<Address>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address);
|
return this.httpClient.get<Address>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address);
|
||||||
}
|
}
|
||||||
|
114
frontend/src/app/services/ord-api.service.ts
Normal file
114
frontend/src/app/services/ord-api.service.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { catchError, forkJoin, map, Observable, of, switchMap, tap } from 'rxjs';
|
||||||
|
import { Inscription } from '../components/ord-data/ord-data.component';
|
||||||
|
import { Transaction } from '../interfaces/electrs.interface';
|
||||||
|
import { getNextInscriptionMark, hexToBytes, extractInscriptionData } from '../shared/ord/inscription.utils';
|
||||||
|
import { Runestone } from '../shared/ord/rune/runestone';
|
||||||
|
import { Etching } from '../shared/ord/rune/etching';
|
||||||
|
import { ElectrsApiService } from './electrs-api.service';
|
||||||
|
import { UNCOMMON_GOODS } from '../shared/ord/rune/runestone';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class OrdApiService {
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private electrsApiService: ElectrsApiService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
decodeRunestone$(tx: Transaction): Observable<{ runestone: Runestone, runeInfo: { [id: string]: { etching: Etching; txid: string; } } }> {
|
||||||
|
const runestoneTx = { vout: tx.vout.map(vout => ({ scriptpubkey: vout.scriptpubkey })) };
|
||||||
|
const decipher = Runestone.decipher(runestoneTx);
|
||||||
|
|
||||||
|
// For now, ignore cenotaphs
|
||||||
|
let message = decipher.isSome() ? decipher.unwrap() : null;
|
||||||
|
if (message?.type === 'cenotaph') {
|
||||||
|
return of({ runestone: null, runeInfo: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
const runestone = message as Runestone;
|
||||||
|
const runeInfo: { [id: string]: { etching: Etching; txid: string; } } = {};
|
||||||
|
const runesToFetch: Set<string> = new Set();
|
||||||
|
|
||||||
|
if (runestone) {
|
||||||
|
if (runestone.mint.isSome()) {
|
||||||
|
const mint = runestone.mint.unwrap().toString();
|
||||||
|
|
||||||
|
if (mint === '1:0') {
|
||||||
|
runeInfo[mint] = { etching: UNCOMMON_GOODS, txid: '0000000000000000000000000000000000000000000000000000000000000000' };
|
||||||
|
} else {
|
||||||
|
runesToFetch.add(mint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runestone.edicts.length) {
|
||||||
|
runestone.edicts.forEach(edict => {
|
||||||
|
runesToFetch.add(edict.id.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runesToFetch.size) {
|
||||||
|
const runeEtchingObservables = Array.from(runesToFetch).map(runeId => {
|
||||||
|
return this.getEtchingFromRuneId$(runeId).pipe(
|
||||||
|
tap(etching => {
|
||||||
|
if (etching) {
|
||||||
|
runeInfo[runeId] = etching;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return forkJoin(runeEtchingObservables).pipe(
|
||||||
|
map(() => {
|
||||||
|
return { runestone: runestone, runeInfo };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return of({ runestone: runestone, runeInfo });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get etching from runeId by looking up the transaction that etched the rune
|
||||||
|
getEtchingFromRuneId$(runeId: string): Observable<{ etching: Etching; txid: string; }> {
|
||||||
|
const [blockNumber, txIndex] = runeId.split(':');
|
||||||
|
|
||||||
|
return this.electrsApiService.getBlockHashFromHeight$(parseInt(blockNumber)).pipe(
|
||||||
|
switchMap(blockHash => this.electrsApiService.getBlockTxId$(blockHash, parseInt(txIndex))),
|
||||||
|
switchMap(txId => this.electrsApiService.getTransaction$(txId)),
|
||||||
|
switchMap(tx => {
|
||||||
|
const decipheredMessage = Runestone.decipher(tx);
|
||||||
|
if (decipheredMessage.isSome()) {
|
||||||
|
const message = decipheredMessage.unwrap();
|
||||||
|
if (message?.type === 'runestone' && message.etching.isSome()) {
|
||||||
|
return of({ etching: message.etching.unwrap(), txid: tx.txid });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return of(null);
|
||||||
|
}),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
decodeInscriptions(witness: string): Inscription[] | null {
|
||||||
|
|
||||||
|
const inscriptions: Inscription[] = [];
|
||||||
|
const raw = hexToBytes(witness);
|
||||||
|
let startPosition = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const pointer = getNextInscriptionMark(raw, startPosition);
|
||||||
|
if (pointer === -1) break;
|
||||||
|
|
||||||
|
const inscription = extractInscriptionData(raw, pointer);
|
||||||
|
if (inscription) {
|
||||||
|
inscriptions.push(inscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
startPosition = pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return inscriptions;
|
||||||
|
}
|
||||||
|
}
|
@ -102,6 +102,7 @@ import { AccelerationsListComponent } from '../components/acceleration/accelerat
|
|||||||
import { PendingStatsComponent } from '../components/acceleration/pending-stats/pending-stats.component';
|
import { PendingStatsComponent } from '../components/acceleration/pending-stats/pending-stats.component';
|
||||||
import { AccelerationStatsComponent } from '../components/acceleration/acceleration-stats/acceleration-stats.component';
|
import { AccelerationStatsComponent } from '../components/acceleration/acceleration-stats/acceleration-stats.component';
|
||||||
import { AccelerationSparklesComponent } from '../components/acceleration/sparkles/acceleration-sparkles.component';
|
import { AccelerationSparklesComponent } from '../components/acceleration/sparkles/acceleration-sparkles.component';
|
||||||
|
import { OrdDataComponent } from '../components/ord-data/ord-data.component';
|
||||||
|
|
||||||
import { BlockViewComponent } from '../components/block-view/block-view.component';
|
import { BlockViewComponent } from '../components/block-view/block-view.component';
|
||||||
import { EightBlocksComponent } from '../components/eight-blocks/eight-blocks.component';
|
import { EightBlocksComponent } from '../components/eight-blocks/eight-blocks.component';
|
||||||
@ -229,6 +230,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir
|
|||||||
AccelerationStatsComponent,
|
AccelerationStatsComponent,
|
||||||
PendingStatsComponent,
|
PendingStatsComponent,
|
||||||
AccelerationSparklesComponent,
|
AccelerationSparklesComponent,
|
||||||
|
OrdDataComponent,
|
||||||
HttpErrorComponent,
|
HttpErrorComponent,
|
||||||
TwitterWidgetComponent,
|
TwitterWidgetComponent,
|
||||||
FaucetComponent,
|
FaucetComponent,
|
||||||
@ -361,6 +363,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir
|
|||||||
AccelerationStatsComponent,
|
AccelerationStatsComponent,
|
||||||
PendingStatsComponent,
|
PendingStatsComponent,
|
||||||
AccelerationSparklesComponent,
|
AccelerationSparklesComponent,
|
||||||
|
OrdDataComponent,
|
||||||
HttpErrorComponent,
|
HttpErrorComponent,
|
||||||
TwitterWidgetComponent,
|
TwitterWidgetComponent,
|
||||||
TwitterLogin,
|
TwitterLogin,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user