From 8b6db768cd73915008c35956bb293b97f2323326 Mon Sep 17 00:00:00 2001 From: natsoni Date: Mon, 7 Oct 2024 20:03:36 +0900 Subject: [PATCH] Decode inscription / rune data client-side --- frontend/src/app/app.module.ts | 2 + .../ord-data/ord-data.component.html | 75 ++++++++++ .../ord-data/ord-data.component.scss | 35 +++++ .../components/ord-data/ord-data.component.ts | 140 ++++++++++++++++++ .../transactions-list.component.html | 28 +++- .../transactions-list.component.scss | 13 +- .../transactions-list.component.ts | 58 ++++++++ .../src/app/interfaces/electrs.interface.ts | 4 + .../src/app/services/electrs-api.service.ts | 4 + frontend/src/app/services/ord-api.service.ts | 114 ++++++++++++++ frontend/src/app/shared/shared.module.ts | 3 + 11 files changed, 473 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/components/ord-data/ord-data.component.html create mode 100644 frontend/src/app/components/ord-data/ord-data.component.scss create mode 100644 frontend/src/app/components/ord-data/ord-data.component.ts create mode 100644 frontend/src/app/services/ord-api.service.ts diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index d1129a602..52fbc9f87 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -6,6 +6,7 @@ import { ZONE_SERVICE } from './injection-tokens'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './components/app/app.component'; import { ElectrsApiService } from './services/electrs-api.service'; +import { OrdApiService } from './services/ord-api.service'; import { StateService } from './services/state.service'; import { CacheService } from './services/cache.service'; import { PriceService } from './services/price.service'; @@ -32,6 +33,7 @@ import { DatePipe } from '@angular/common'; const providers = [ ElectrsApiService, + OrdApiService, StateService, CacheService, PriceService, diff --git a/frontend/src/app/components/ord-data/ord-data.component.html b/frontend/src/app/components/ord-data/ord-data.component.html new file mode 100644 index 000000000..be9a24715 --- /dev/null +++ b/frontend/src/app/components/ord-data/ord-data.component.html @@ -0,0 +1,75 @@ +@if (error) { +
+ Error fetching data (code {{ error.status }}) +
+} @else { + @if (minted) { + + Mint + {{ minted >= 100000 ? (minted | amountShortener:undefined:undefined:true) : minted }} + + + } + @if (totalSupply > -1) { + @if (premined > 0) { + + Premine + {{ premined >= 100000 ? (premined | amountShortener:undefined:undefined:true) : premined }} + {{ etchedSymbol }} + {{ etchedName }} + ({{ premined / totalSupply * 100 | amountShortener:0}}% of total supply) + + } @else { + + Etching of + {{ etchedSymbol }} + {{ etchedName }} + + } + } + @if (transferredRunes?.length && type === 'vout') { +
+ + Transfer + + +
+ } + + + + @if (inscriptions?.length && type === 'vin') { +
+
+ {{ contentType.value.count > 1 ? contentType.value.count + " " : "" }}{{ contentType.value?.tag || contentType.key }} + {{ contentType.value.totalSize | bytes:2:'B':undefined:true }} + + Source inscription + +
+
{{ contentType.value.json | json }}
+
{{ contentType.value.text }}
+
+ } + + @if (!runestone && type === 'vout') { +
+ } + + @if (!inscriptions?.length && type === 'vin') { +
+ Error decoding inscription data +
+ } +} + + + {{ runeInfo[id]?.etching.symbol.isSome() ? runeInfo[id]?.etching.symbol.unwrap() : '' }} + + {{ runeInfo[id]?.name }} + + \ No newline at end of file diff --git a/frontend/src/app/components/ord-data/ord-data.component.scss b/frontend/src/app/components/ord-data/ord-data.component.scss new file mode 100644 index 000000000..7cb2cdca6 --- /dev/null +++ b/frontend/src/app/components/ord-data/ord-data.component.scss @@ -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; +} \ No newline at end of file diff --git a/frontend/src/app/components/ord-data/ord-data.component.ts b/frontend/src/app/components/ord-data/ord-data.component.ts new file mode 100644 index 000000000..8d7eef973 --- /dev/null +++ b/frontend/src/app/components/ord-data/ord-data.component.ts @@ -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; + } +} diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.html b/frontend/src/app/components/transactions-list/transactions-list.component.html index 9b88678b4..26187ecde 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -81,7 +81,8 @@ - + +
@@ -96,6 +97,15 @@ + + + + + + @@ -236,7 +246,12 @@ - OP_RETURN {{ vout.scriptpubkey_asm | hex2ascii }} + OP_RETURN  + @if (vout.isRunestone) { + + } @else { + {{ vout.scriptpubkey_asm | hex2ascii }} + } {{ vout.scriptpubkey_type | scriptpubkeyType }} @@ -276,6 +291,15 @@ + + + +
+ +
diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.scss b/frontend/src/app/components/transactions-list/transactions-list.component.scss index 280e36b0f..335464060 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.scss +++ b/frontend/src/app/components/transactions-list/transactions-list.component.scss @@ -175,4 +175,15 @@ h2 { .witness-item { overflow: hidden; } -} \ No newline at end of file +} + +.badge-ord { + background-color: var(--grey); + position: relative; + top: -2px; + font-size: 81%; + border: 0; + &.primary { + background-color: var(--primary); + } +} diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index 316a6ab85..1f45d5241 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -11,6 +11,10 @@ import { BlockExtended } from '../../interfaces/node-api.interface'; import { ApiService } from '../../services/api.service'; import { PriceService } from '../../services/price.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({ selector: 'app-transactions-list', @@ -50,12 +54,14 @@ export class TransactionsListComponent implements OnInit, OnChanges { outputRowLimit: number = 12; showFullScript: { [vinIndex: 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( public stateService: StateService, private cacheService: CacheService, private electrsApiService: ElectrsApiService, private apiService: ApiService, + private ordApiService: OrdApiService, private assetsService: AssetsService, private ref: ChangeDetectorRef, private priceService: PriceService, @@ -239,6 +245,24 @@ export class TransactionsListComponent implements OnInit, OnChanges { tap((price) => tx['price'] = price), ).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) { @@ -372,6 +396,40 @@ export class TransactionsListComponent implements OnInit, OnChanges { 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 { this.outspendsSubscription.unsubscribe(); this.currencyChangeSubscription?.unsubscribe(); diff --git a/frontend/src/app/interfaces/electrs.interface.ts b/frontend/src/app/interfaces/electrs.interface.ts index 5bc5bfc1d..95a749b60 100644 --- a/frontend/src/app/interfaces/electrs.interface.ts +++ b/frontend/src/app/interfaces/electrs.interface.ts @@ -74,6 +74,8 @@ export interface Vin { issuance?: Issuance; // Custom lazy?: boolean; + // Ord + isInscription?: boolean; } interface Issuance { @@ -98,6 +100,8 @@ export interface Vout { valuecommitment?: number; asset?: string; pegout?: Pegout; + // Ord + isRunestone?: boolean; } interface Pegout { diff --git a/frontend/src/app/services/electrs-api.service.ts b/frontend/src/app/services/electrs-api.service.ts index 8e991782b..f1468f8aa 100644 --- a/frontend/src/app/services/electrs-api.service.ts +++ b/frontend/src/app/services/electrs-api.service.ts @@ -107,6 +107,10 @@ export class ElectrsApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/block-height/' + height, {responseType: 'text'}); } + getBlockTxId$(hash: string, index: number): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/block/' + hash + '/txid/' + index, { responseType: 'text' }); + } + getAddress$(address: string): Observable
{ return this.httpClient.get
(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address); } diff --git a/frontend/src/app/services/ord-api.service.ts b/frontend/src/app/services/ord-api.service.ts new file mode 100644 index 000000000..bc726e839 --- /dev/null +++ b/frontend/src/app/services/ord-api.service.ts @@ -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 = 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; + } +} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 92b461548..25a60a70f 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -102,6 +102,7 @@ import { AccelerationsListComponent } from '../components/acceleration/accelerat import { PendingStatsComponent } from '../components/acceleration/pending-stats/pending-stats.component'; import { AccelerationStatsComponent } from '../components/acceleration/acceleration-stats/acceleration-stats.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 { EightBlocksComponent } from '../components/eight-blocks/eight-blocks.component'; @@ -229,6 +230,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir AccelerationStatsComponent, PendingStatsComponent, AccelerationSparklesComponent, + OrdDataComponent, HttpErrorComponent, TwitterWidgetComponent, FaucetComponent, @@ -361,6 +363,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir AccelerationStatsComponent, PendingStatsComponent, AccelerationSparklesComponent, + OrdDataComponent, HttpErrorComponent, TwitterWidgetComponent, TwitterLogin,