Merge pull request #5569 from mempool/natsoni/ord

Add option to display runestones and inscriptions metadata
This commit is contained in:
softsimon 2024-10-08 13:21:43 +09:00 committed by GitHub
commit 24ec31acd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1060 additions and 4 deletions

View File

@ -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,

View File

@ -0,0 +1,65 @@
@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.toString() }"></ng-container>
</ng-container>
}
@if (runestone?.etching?.supply) {
@if (runestone?.etching.premine > 0) {
<ng-container i18n="ord.premine-n-runes">
<span>Premine</span>
<span class="amount"> {{ runestone.etching.premine >= 100000 ? (toNumber(runestone.etching.premine) | amountShortener:undefined:undefined:true) : runestone.etching.premine }} </span>
{{ runestone.etching.symbol }}
<span class="name">{{ runestone.etching.spacedName }}</span>
<span> ({{ toNumber(runestone.etching.premine) / toNumber(runestone.etching.supply) * 100 | amountShortener:0}}% of total supply)</span>
</ng-container>
} @else {
<ng-container i18n="ord.etch-rune">
<span>Etching of</span>
{{ runestone.etching.symbol }}
<span class="name">{{ runestone.etching.spacedName }}</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 (inscriptions?.length && type === 'vin') {
<div *ngFor="let contentType of inscriptionsData | keyvalue">
<div>
@if (contentType.key !== 'undefined') {
<span class="badge badge-ord mr-1">{{ contentType.value.count > 1 ? contentType.value.count + " " : "" }}{{ contentType.value?.tag || contentType.key }}</span>
} @else {
<span class="badge badge-ord mr-1" i18n="unknown">Unknown</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 ((runestone && !minted && !runestone.etching?.supply && !transferredRunes?.length && type === 'vout') || (!inscriptions?.length && type === 'vin')) {
<i i18n="error.decoding-data">Error decoding data</i>
}
<ng-template #runeName let-id>
{{ runeInfo[id]?.etching.symbol || '' }}
<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]?.etching.spacedName }}</span>
</a>
</ng-template>

View 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: 200px;
}

View File

@ -0,0 +1,87 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { Runestone, Etching } from '../../shared/ord/rune.utils';
import { Inscription } from '../../shared/ord/inscription.utils';
@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 } };
@Input() type: 'vin' | 'vout';
toNumber = (value: bigint): number => Number(value);
// Inscriptions
inscriptionsData: { [key: string]: { count: number, totalSize: number, text?: string; json?: JSON; tag?: string; delegate?: string } };
// Rune mints
minted: number;
// Rune transfers
transferredRunes: { key: string; etching: Etching; txid: string }[] = [];
constructor() { }
ngOnChanges(changes: SimpleChanges): void {
if (changes.runestone && this.runestone) {
if (this.runestone.mint && this.runeInfo[this.runestone.mint.toString()]) {
const mint = this.runestone.mint.toString();
const terms = this.runeInfo[mint].etching.terms;
const amount = terms?.amount;
const divisibility = this.runeInfo[mint].etching.divisibility;
if (amount) {
this.minted = this.getAmount(amount, divisibility);
}
}
this.runestone.edicts.forEach(edict => {
if (this.runeInfo[edict.id.toString()]) {
this.transferredRunes.push({ key: edict.id.toString(), ...this.runeInfo[edict.id.toString()] });
}
});
}
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.is_cropped && !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: bigint, divisibility: number): number {
const divisor = BigInt(10) ** BigInt(divisibility);
const result = amount / divisor;
return result <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(result) : Number.MAX_SAFE_INTEGER;
}
}

View File

@ -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'"></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&nbsp;<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&nbsp;
@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'"></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">

View File

@ -175,4 +175,15 @@ h2 {
.witness-item { .witness-item {
overflow: hidden; overflow: hidden;
} }
} }
.badge-ord {
background-color: var(--grey);
position: relative;
top: -2px;
font-size: 81%;
border: 0;
&.primary {
background-color: var(--primary);
}
}

View File

@ -6,11 +6,14 @@ import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.inter
import { ElectrsApiService } from '../../services/electrs-api.service'; import { ElectrsApiService } from '../../services/electrs-api.service';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { AssetsService } from '../../services/assets.service'; import { AssetsService } from '../../services/assets.service';
import { filter, map, tap, switchMap, shareReplay, catchError } from 'rxjs/operators'; import { filter, map, tap, switchMap, catchError } from 'rxjs/operators';
import { BlockExtended } from '../../interfaces/node-api.interface'; 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 '../../shared/ord/inscription.utils';
import { Etching, Runestone } from '../../shared/ord/rune.utils';
@Component({ @Component({
selector: 'app-transactions-list', selector: 'app-transactions-list',
@ -50,12 +53,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 +244,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 +395,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();

View File

@ -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 {

View File

@ -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);
} }

View File

@ -0,0 +1,100 @@
import { Injectable } from '@angular/core';
import { catchError, forkJoin, map, Observable, of, switchMap, tap } from 'rxjs';
import { Inscription } from '../shared/ord/inscription.utils';
import { Transaction } from '../interfaces/electrs.interface';
import { getNextInscriptionMark, hexToBytes, extractInscriptionData } from '../shared/ord/inscription.utils';
import { decipherRunestone, Runestone, Etching, UNCOMMON_GOODS } from '../shared/ord/rune.utils';
import { ElectrsApiService } from './electrs-api.service';
@Injectable({
providedIn: 'root'
})
export class OrdApiService {
constructor(
private electrsApiService: ElectrsApiService,
) { }
decodeRunestone$(tx: Transaction): Observable<{ runestone: Runestone, runeInfo: { [id: string]: { etching: Etching; txid: string; } } }> {
const runestone = decipherRunestone(tx);
const runeInfo: { [id: string]: { etching: Etching; txid: string; } } = {};
if (runestone) {
const runesToFetch: Set<string> = new Set();
if (runestone.mint) {
runesToFetch.add(runestone.mint.toString());
}
if (runestone.edicts.length) {
runestone.edicts.forEach(edict => {
runesToFetch.add(edict.id.toString());
});
}
if (runesToFetch.size) {
const runeEtchingObservables = Array.from(runesToFetch).map(runeId => this.getEtchingFromRuneId$(runeId));
return forkJoin(runeEtchingObservables).pipe(
map((etchings) => {
etchings.forEach((el) => {
if (el) {
runeInfo[el.runeId] = { etching: el.etching, txid: el.txid };
}
});
return { runestone: runestone, runeInfo };
})
);
}
return of({ runestone: runestone, runeInfo });
} else {
return of({ runestone: null, runeInfo: {} });
}
}
// Get etching from runeId by looking up the transaction that etched the rune
getEtchingFromRuneId$(runeId: string): Observable<{ runeId: string; etching: Etching; txid: string; }> {
if (runeId === '1:0') {
return of({ runeId, etching: UNCOMMON_GOODS, txid: '0000000000000000000000000000000000000000000000000000000000000000' });
} else {
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 runestone = decipherRunestone(tx);
if (runestone) {
const etching = runestone.etching;
if (etching) {
return of({ runeId, etching, 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;
}
}

View File

@ -0,0 +1,409 @@
// Adapted from https://github.com/ordpool-space/ordpool-parser/tree/ce04d7a5b6bb1cf37b9fdadd77ba430f5bd6e7d6/src
// Utils functions to decode ord inscriptions
export const OP_FALSE = 0x00;
export const OP_IF = 0x63;
export const OP_0 = 0x00;
export const OP_PUSHBYTES_3 = 0x03; // 3 -- not an actual opcode, but used in documentation --> pushes the next 3 bytes onto the stack.
export const OP_PUSHDATA1 = 0x4c; // 76 -- The next byte contains the number of bytes to be pushed onto the stack.
export const OP_PUSHDATA2 = 0x4d; // 77 -- The next two bytes contain the number of bytes to be pushed onto the stack in little endian order.
export const OP_PUSHDATA4 = 0x4e; // 78 -- The next four bytes contain the number of bytes to be pushed onto the stack in little endian order.
export const OP_ENDIF = 0x68; // 104 -- Ends an if/else block.
export const OP_1NEGATE = 0x4f; // 79 -- The number -1 is pushed onto the stack.
export const OP_RESERVED = 0x50; // 80 -- Transaction is invalid unless occuring in an unexecuted OP_IF branch
export const OP_PUSHNUM_1 = 0x51; // 81 -- also known as OP_1
export const OP_PUSHNUM_2 = 0x52; // 82 -- also known as OP_2
export const OP_PUSHNUM_3 = 0x53; // 83 -- also known as OP_3
export const OP_PUSHNUM_4 = 0x54; // 84 -- also known as OP_4
export const OP_PUSHNUM_5 = 0x55; // 85 -- also known as OP_5
export const OP_PUSHNUM_6 = 0x56; // 86 -- also known as OP_6
export const OP_PUSHNUM_7 = 0x57; // 87 -- also known as OP_7
export const OP_PUSHNUM_8 = 0x58; // 88 -- also known as OP_8
export const OP_PUSHNUM_9 = 0x59; // 89 -- also known as OP_9
export const OP_PUSHNUM_10 = 0x5a; // 90 -- also known as OP_10
export const OP_PUSHNUM_11 = 0x5b; // 91 -- also known as OP_11
export const OP_PUSHNUM_12 = 0x5c; // 92 -- also known as OP_12
export const OP_PUSHNUM_13 = 0x5d; // 93 -- also known as OP_13
export const OP_PUSHNUM_14 = 0x5e; // 94 -- also known as OP_14
export const OP_PUSHNUM_15 = 0x5f; // 95 -- also known as OP_15
export const OP_PUSHNUM_16 = 0x60; // 96 -- also known as OP_16
export const OP_RETURN = 0x6a; // 106 -- a standard way of attaching extra data to transactions is to add a zero-value output with a scriptPubKey consisting of OP_RETURN followed by data
//////////////////////////// Helper ///////////////////////////////
/**
* Inscriptions may include fields before an optional body. Each field consists of two data pushes, a tag and a value.
* Currently, there are six defined fields:
*/
export const knownFields = {
// content_type, with a tag of 1, whose value is the MIME type of the body.
content_type: 0x01,
// pointer, with a tag of 2, see pointer docs: https://docs.ordinals.com/inscriptions/pointer.html
pointer: 0x02,
// parent, with a tag of 3, see provenance docs: https://docs.ordinals.com/inscriptions/provenance.html
parent: 0x03,
// metadata, with a tag of 5, see metadata docs: https://docs.ordinals.com/inscriptions/metadata.html
metadata: 0x05,
// metaprotocol, with a tag of 7, whose value is the metaprotocol identifier.
metaprotocol: 0x07,
// content_encoding, with a tag of 9, whose value is the encoding of the body.
content_encoding: 0x09,
// delegate, with a tag of 11, see delegate docs: https://docs.ordinals.com/inscriptions/delegate.html
delegate: 0xb
}
/**
* Retrieves the value for a given field from an array of field objects.
* It returns the value of the first object where the tag matches the specified field.
*
* @param fields - An array of objects containing tag and value properties.
* @param field - The field number to search for.
* @returns The value associated with the first matching field, or undefined if no match is found.
*/
export function getKnownFieldValue(fields: { tag: number; value: Uint8Array }[], field: number): Uint8Array | undefined {
const knownField = fields.find(x =>
x.tag === field);
if (knownField === undefined) {
return undefined;
}
return knownField.value;
}
/**
* Retrieves the values for a given field from an array of field objects.
* It returns the values of all objects where the tag matches the specified field.
*
* @param fields - An array of objects containing tag and value properties.
* @param field - The field number to search for.
* @returns An array of Uint8Array values associated with the matching fields. If no matches are found, an empty array is returned.
*/
export function getKnownFieldValues(fields: { tag: number; value: Uint8Array }[], field: number): Uint8Array[] {
const knownFields = fields.filter(x =>
x.tag === field
);
return knownFields.map(field => field.value);
}
/**
* Searches for the next position of the ordinal inscription mark (0063036f7264)
* within the raw transaction data, starting from a given position.
*
* This function looks for a specific sequence of 6 bytes that represents the start of an ordinal inscription.
* If the sequence is found, the function returns the index immediately following the inscription mark.
* If the sequence is not found, the function returns -1, indicating no inscription mark was found.
*
* Note: This function uses a simple hardcoded approach based on the fixed length of the inscription mark.
*
* @returns The position immediately after the inscription mark, or -1 if not found.
*/
export function getNextInscriptionMark(raw: Uint8Array, startPosition: number): number {
// OP_FALSE
// OP_IF
// OP_PUSHBYTES_3: This pushes the next 3 bytes onto the stack.
// 0x6f, 0x72, 0x64: These bytes translate to the ASCII string "ord"
const inscriptionMark = new Uint8Array([OP_FALSE, OP_IF, OP_PUSHBYTES_3, 0x6f, 0x72, 0x64]);
for (let index = startPosition; index <= raw.length - 6; index++) {
if (raw[index] === inscriptionMark[0] &&
raw[index + 1] === inscriptionMark[1] &&
raw[index + 2] === inscriptionMark[2] &&
raw[index + 3] === inscriptionMark[3] &&
raw[index + 4] === inscriptionMark[4] &&
raw[index + 5] === inscriptionMark[5]) {
return index + 6;
}
}
return -1;
}
/////////////////////////////// Reader ///////////////////////////////
/**
* Reads a specified number of bytes from a Uint8Array starting from a given pointer.
*
* @param raw - The Uint8Array from which bytes are to be read.
* @param pointer - The position in the array from where to start reading.
* @param n - The number of bytes to read.
* @returns A tuple containing the read bytes as Uint8Array and the updated pointer position.
*/
export function readBytes(raw: Uint8Array, pointer: number, n: number): [Uint8Array, number] {
const slice = raw.slice(pointer, pointer + n);
return [slice, pointer + n];
}
/**
* Reads data based on the Bitcoin script push opcode starting from a specified pointer in the raw data.
* Handles different opcodes and direct push (where the opcode itself signifies the number of bytes to push).
*
* @param raw - The raw transaction data as a Uint8Array.
* @param pointer - The current position in the raw data array.
* @returns A tuple containing the read data as Uint8Array and the updated pointer position.
*/
export function readPushdata(raw: Uint8Array, pointer: number): [Uint8Array, number] {
let [opcodeSlice, newPointer] = readBytes(raw, pointer, 1);
const opcode = opcodeSlice[0];
// Handle the special case of OP_0 (0x00) which pushes an empty array (interpreted as zero)
// fixes #18
if (opcode === OP_0) {
return [new Uint8Array(), newPointer];
}
// Handle the special case of OP_1NEGATE (-1)
if (opcode === OP_1NEGATE) {
// OP_1NEGATE pushes the value -1 onto the stack, represented as 0x81 in Bitcoin Script
return [new Uint8Array([0x81]), newPointer];
}
// Handle minimal push numbers OP_PUSHNUM_1 (0x51) to OP_PUSHNUM_16 (0x60)
// which are used to push the values 0x01 (decimal 1) through 0x10 (decimal 16) onto the stack.
// To get the value, we can subtract OP_RESERVED (0x50) from the opcode to get the value to be pushed.
if (opcode >= OP_PUSHNUM_1 && opcode <= OP_PUSHNUM_16) {
// Convert opcode to corresponding byte value
const byteValue = opcode - OP_RESERVED;
return [Uint8Array.from([byteValue]), newPointer];
}
// Handle direct push of 1 to 75 bytes (OP_PUSHBYTES_1 to OP_PUSHBYTES_75)
if (1 <= opcode && opcode <= 75) {
return readBytes(raw, newPointer, opcode);
}
let numBytes: number;
switch (opcode) {
case OP_PUSHDATA1: numBytes = 1; break;
case OP_PUSHDATA2: numBytes = 2; break;
case OP_PUSHDATA4: numBytes = 4; break;
default:
throw new Error(`Invalid push opcode ${opcode} at position ${pointer}`);
}
let [dataSizeArray, nextPointer] = readBytes(raw, newPointer, numBytes);
let dataSize = littleEndianBytesToNumber(dataSizeArray);
return readBytes(raw, nextPointer, dataSize);
}
//////////////////////////// Conversion ////////////////////////////
/**
* Converts a Uint8Array containing UTF-8 encoded data to a normal a UTF-16 encoded string.
*
* @param bytes - The Uint8Array containing UTF-8 encoded data.
* @returns The corresponding UTF-16 encoded JavaScript string.
*/
export function bytesToUnicodeString(bytes: Uint8Array): string {
const decoder = new TextDecoder('utf-8');
return decoder.decode(bytes);
}
/**
* Convert a Uint8Array to a string by treating each byte as a character code.
* It avoids interpreting bytes as UTF-8 encoded sequences.
* --> Again: it ignores UTF-8 encoding, which is necessary for binary content!
*
* Note: This method is different from just using `String.fromCharCode(...combinedData)` which can
* cause a "Maximum call stack size exceeded" error for large arrays due to the limitation of
* the spread operator in JavaScript. (previously the parser broke here, because of large content)
*
* @param bytes - The byte array to convert.
* @returns The resulting string where each byte value is treated as a direct character code.
*/
export function bytesToBinaryString(bytes: Uint8Array): string {
let resultStr = '';
for (let i = 0; i < bytes.length; i++) {
resultStr += String.fromCharCode(bytes[i]);
}
return resultStr;
}
/**
* Converts a hexadecimal string to a Uint8Array.
*
* @param hex - A string of hexadecimal characters.
* @returns A Uint8Array representing the hex string.
*/
export function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0, j = 0; i < hex.length; i += 2, j++) {
bytes[j] = parseInt(hex.slice(i, i + 2), 16);
}
return bytes;
}
/**
* Converts a Uint8Array to a hexadecimal string.
*
* @param bytes - A Uint8Array to convert.
* @returns A string of hexadecimal characters representing the byte array.
*/
export function bytesToHex(bytes: Uint8Array): string {
if (!bytes) {
return null;
}
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
}
/**
* Converts a little-endian byte array to a JavaScript number.
*
* This function interprets the provided bytes in little-endian format, where the least significant byte comes first.
* It constructs an integer value representing the number encoded by the bytes.
*
* @param byteArray - An array containing the bytes in little-endian format.
* @returns The number represented by the byte array.
*/
export function littleEndianBytesToNumber(byteArray: Uint8Array): number {
let number = 0;
for (let i = 0; i < byteArray.length; i++) {
// Extract each byte from byteArray, shift it to the left by 8 * i bits, and combine it with number.
// The shifting accounts for the little-endian format where the least significant byte comes first.
number |= byteArray[i] << (8 * i);
}
return number;
}
/**
* Concatenates multiple Uint8Array objects into a single Uint8Array.
*
* @param arrays - An array of Uint8Array objects to concatenate.
* @returns A new Uint8Array containing the concatenated results of the input arrays.
*/
export function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {
if (arrays.length === 0) {
return new Uint8Array();
}
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const array of arrays) {
result.set(array, offset);
offset += array.length;
}
return result;
}
////////////////////////////// Inscription ///////////////////////////
export interface Inscription {
body?: Uint8Array;
is_cropped?: boolean;
body_length?: number;
content_type?: Uint8Array;
content_type_str?: string;
delegate_txid?: string;
}
/**
* Extracts fields from the raw data until OP_0 is encountered.
*
* @param raw - The raw data to read.
* @param pointer - The current pointer where the reading starts.
* @returns An array of fields and the updated pointer position.
*/
export function extractFields(raw: Uint8Array, pointer: number): [{ tag: number; value: Uint8Array }[], number] {
const fields: { tag: number; value: Uint8Array }[] = [];
let newPointer = pointer;
let slice: Uint8Array;
while (newPointer < raw.length &&
// normal inscription - content follows now
(raw[newPointer] !== OP_0) &&
// delegate - inscription has no further content and ends directly here
(raw[newPointer] !== OP_ENDIF)
) {
// tags are encoded by ord as single-byte data pushes, but are accepted by ord as either single-byte pushes, or as OP_NUM data pushes.
// tags greater than or equal to 256 should be encoded as little endian integers with trailing zeros omitted.
// see: https://github.com/ordinals/ord/issues/2505
[slice, newPointer] = readPushdata(raw, newPointer);
const tag = slice.length === 1 ? slice[0] : littleEndianBytesToNumber(slice);
[slice, newPointer] = readPushdata(raw, newPointer);
const value = slice;
fields.push({ tag, value });
}
return [fields, newPointer];
}
/**
* Extracts inscription data starting from the current pointer.
* @param raw - The raw data to read.
* @param pointer - The current pointer where the reading starts.
* @returns The parsed inscription or nullx
*/
export function extractInscriptionData(raw: Uint8Array, pointer: number): Inscription | null {
try {
let fields: { tag: number; value: Uint8Array }[];
let newPointer: number;
let slice: Uint8Array;
[fields, newPointer] = extractFields(raw, pointer);
// Now we are at the beginning of the body
// (or at the end of the raw data if there's no body)
if (newPointer < raw.length && raw[newPointer] === OP_0) {
newPointer++; // Skip OP_0
}
// Collect body data until OP_ENDIF
const data: Uint8Array[] = [];
while (newPointer < raw.length && raw[newPointer] !== OP_ENDIF) {
[slice, newPointer] = readPushdata(raw, newPointer);
data.push(slice);
}
const combinedLengthOfAllArrays = data.reduce((acc, curr) => acc + curr.length, 0);
let combinedData = new Uint8Array(combinedLengthOfAllArrays);
// Copy all segments from data into combinedData, forming a single contiguous Uint8Array
let idx = 0;
for (const segment of data) {
combinedData.set(segment, idx);
idx += segment.length;
}
const contentTypeRaw = getKnownFieldValue(fields, knownFields.content_type);
let contentType: string;
if (!contentTypeRaw) {
contentType = 'undefined';
} else {
contentType = bytesToUnicodeString(contentTypeRaw);
}
return {
content_type_str: contentType,
body: combinedData.slice(0, 100_000), // Limit body to 100 kB for now
is_cropped: combinedData.length > 100_000,
body_length: combinedData.length,
delegate_txid: getKnownFieldValue(fields, knownFields.delegate) ? bytesToHex(getKnownFieldValue(fields, knownFields.delegate).reverse()) : null
};
} catch (ex) {
return null;
}
}

View File

@ -0,0 +1,255 @@
import { Transaction } from '../../interfaces/electrs.interface';
export const U128_MAX_BIGINT = 0xffff_ffff_ffff_ffff_ffff_ffff_ffff_ffffn;
export class RuneId {
block: number;
index: number;
constructor(block: number, index: number) {
this.block = block;
this.index = index;
}
toString(): string {
return `${this.block}:${this.index}`;
}
}
export type Etching = {
divisibility?: number;
premine?: bigint;
symbol?: string;
terms?: {
cap?: bigint;
amount?: bigint;
offset?: {
start?: bigint;
end?: bigint;
};
height?: {
start?: bigint;
end?: bigint;
};
};
turbo?: boolean;
name?: string;
spacedName?: string;
supply?: bigint;
};
export type Edict = {
id: RuneId;
amount: bigint;
output: number;
};
export type Runestone = {
mint?: RuneId;
pointer?: number;
edicts?: Edict[];
etching?: Etching;
};
type Message = {
fields: Record<number, bigint[]>;
edicts: Edict[];
}
export const UNCOMMON_GOODS: Etching = {
divisibility: 0,
premine: 0n,
symbol: '⧉',
terms: {
cap: U128_MAX_BIGINT,
amount: 1n,
offset: {
start: 0n,
end: 0n,
},
height: {
start: 840000n,
end: 1050000n,
},
},
turbo: false,
name: 'UNCOMMONGOODS',
spacedName: 'UNCOMMON•GOODS',
supply: U128_MAX_BIGINT,
};
enum Tag {
Body = 0,
Flags = 2,
Rune = 4,
Premine = 6,
Cap = 8,
Amount = 10,
HeightStart = 12,
HeightEnd = 14,
OffsetStart = 16,
OffsetEnd = 18,
Mint = 20,
Pointer = 22,
Cenotaph = 126,
Divisibility = 1,
Spacers = 3,
Symbol = 5,
Nop = 127,
}
const Flag = {
ETCHING: 1n,
TERMS: 1n << 1n,
TURBO: 1n << 2n,
CENOTAPH: 1n << 127n,
};
function hexToBytes(hex: string): Uint8Array {
return new Uint8Array(hex.match(/.{2}/g).map((byte) => parseInt(byte, 16)));
}
function decodeLEB128(bytes: Uint8Array): bigint[] {
const integers: bigint[] = [];
let index = 0;
while (index < bytes.length) {
let value = BigInt(0);
let shift = 0;
let byte: number;
do {
byte = bytes[index++];
value |= BigInt(byte & 0x7f) << BigInt(shift);
shift += 7;
} while (byte & 0x80);
integers.push(value);
}
return integers;
}
function integersToMessage(integers: bigint[]): Message {
const message = {
fields: {},
edicts: [],
};
let inBody = false;
while (integers.length) {
if (!inBody) {
// The integers are interpreted as a sequence of tag/value pairs, with duplicate tags appending their value to the field value.
const tag: Tag = Number(integers.shift());
if (tag === Tag.Body) {
inBody = true;
} else {
const value = integers.shift();
if (message.fields[tag]) {
message.fields[tag].push(value);
} else {
message.fields[tag] = [value];
}
}
} else {
// If a tag with value zero is encountered, all following integers are interpreted as a series of four-integer edicts, each consisting of a rune ID block height, rune ID transaction index, amount, and output.
const height = integers.shift();
const txIndex = integers.shift();
const amount = integers.shift();
const output = integers.shift();
message.edicts.push({
id: new RuneId(Number(height), Number(txIndex)),
amount,
output,
});
}
}
return message;
}
function parseRuneName(rune: bigint): string {
let name = '';
rune += 1n;
while (rune > 0n) {
name = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Number((rune - 1n) % 26n)] + name;
rune = (rune - 1n) / 26n;
}
return name;
}
function spaceRuneName(name: string, spacers: bigint): string {
let i = 0;
let spacedName = '';
while (spacers > 0n || i < name.length) {
spacedName += name[i];
if (spacers & 1n) {
spacedName += '•';
}
if (spacers > 0n) {
spacers >>= 1n;
}
i++;
}
return spacedName;
}
function messageToRunestone(message: Message): Runestone {
let etching: Etching | undefined;
let mint: RuneId | undefined;
let pointer: number | undefined;
const flags = message.fields[Tag.Flags]?.[0] || 0n;
if (flags & Flag.ETCHING) {
const hasTerms = (flags & Flag.TERMS) > 0n;
const isTurbo = (flags & Flag.TURBO) > 0n;
const name = parseRuneName(message.fields[Tag.Rune]?.[0] ?? 0n);
etching = {
divisibility: Number(message.fields[Tag.Divisibility]?.[0] ?? 0n),
premine: message.fields[Tag.Premine]?.[0],
symbol: message.fields[Tag.Symbol]?.[0] ? String.fromCodePoint(Number(message.fields[Tag.Symbol][0])) : '¤',
terms: hasTerms ? {
cap: message.fields[Tag.Cap]?.[0],
amount: message.fields[Tag.Amount]?.[0],
offset: {
start: message.fields[Tag.OffsetStart]?.[0],
end: message.fields[Tag.OffsetEnd]?.[0],
},
height: {
start: message.fields[Tag.HeightStart]?.[0],
end: message.fields[Tag.HeightEnd]?.[0],
},
} : undefined,
turbo: isTurbo,
name,
spacedName: spaceRuneName(name, message.fields[Tag.Spacers]?.[0] ?? 0n),
};
etching.supply = (
(etching.terms?.cap ?? 0n) * (etching.terms?.amount ?? 0n)
) + (etching.premine ?? 0n);
}
const mintField = message.fields[Tag.Mint];
if (mintField) {
mint = new RuneId(Number(mintField[0]), Number(mintField[1]));
}
const pointerField = message.fields[Tag.Pointer];
if (pointerField) {
pointer = Number(pointerField[0]);
}
return {
mint,
pointer,
edicts: message.edicts,
etching,
};
}
export function decipherRunestone(tx: Transaction): Runestone | void {
const payload = tx.vout.find((vout) => vout.scriptpubkey.startsWith('6a5d'))?.scriptpubkey_asm.replace(/OP_\w+|\s/g, '');
if (!payload) {
return;
}
try {
const integers = decodeLEB128(hexToBytes(payload));
const message = integersToMessage(integers);
return messageToRunestone(message);
} catch (error) {
console.error(error);
return;
}
}

View File

@ -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,