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..14f24d5f3
--- /dev/null
+++ b/frontend/src/app/components/ord-data/ord-data.component.html
@@ -0,0 +1,65 @@
+@if (minted) {
+
+ Mint
+ {{ minted >= 100000 ? (minted | amountShortener:undefined:undefined:true) : minted }}
+
+
+}
+@if (runestone?.etching?.supply) {
+ @if (runestone?.etching.premine > 0) {
+
+ Premine
+ {{ runestone.etching.premine >= 100000 ? (toNumber(runestone.etching.premine) | amountShortener:undefined:undefined:true) : runestone.etching.premine }}
+ {{ runestone.etching.symbol }}
+ {{ runestone.etching.spacedName }}
+ ({{ toNumber(runestone.etching.premine) / toNumber(runestone.etching.supply) * 100 | amountShortener:0}}% of total supply)
+
+ } @else {
+
+ Etching of
+ {{ runestone.etching.symbol }}
+ {{ runestone.etching.spacedName }}
+
+ }
+}
+@if (transferredRunes?.length && type === 'vout') {
+
@@ -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..7bb1604c6 100644
--- a/frontend/src/app/components/transactions-list/transactions-list.component.ts
+++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts
@@ -6,11 +6,14 @@ import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.inter
import { ElectrsApiService } from '../../services/electrs-api.service';
import { environment } from '../../../environments/environment';
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 { 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 '../../shared/ord/inscription.utils';
+import { Etching, Runestone } from '../../shared/ord/rune.utils';
@Component({
selector: 'app-transactions-list',
@@ -50,12 +53,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 +244,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 +395,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..5fcd75298
--- /dev/null
+++ b/frontend/src/app/services/ord-api.service.ts
@@ -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 = 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;
+ }
+}
diff --git a/frontend/src/app/shared/ord/inscription.utils.ts b/frontend/src/app/shared/ord/inscription.utils.ts
new file mode 100644
index 000000000..78095f22f
--- /dev/null
+++ b/frontend/src/app/shared/ord/inscription.utils.ts
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/shared/ord/rune.utils.ts b/frontend/src/app/shared/ord/rune.utils.ts
new file mode 100644
index 000000000..c23a55264
--- /dev/null
+++ b/frontend/src/app/shared/ord/rune.utils.ts
@@ -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;
+ 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;
+ }
+}
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,
| |