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') { +
+ + Transfer + + +
+} + +@if (inscriptions?.length && type === 'vin') { +
+
+ @if (contentType.key !== 'undefined') { + {{ contentType.value.count > 1 ? contentType.value.count + " " : "" }}{{ contentType.value?.tag || contentType.key }} + } @else { + Unknown + } + {{ contentType.value.totalSize | bytes:2:'B':undefined:true }} + + Source inscription + +
+
{{ contentType.value.json | json }}
+
{{ contentType.value.text }}
+
+} + +@if (!runestone && type === 'vout') { +
+} + +@if ((runestone && !minted && !runestone.etching?.supply && !transferredRunes?.length && type === 'vout') || (!inscriptions?.length && type === 'vin')) { + Error decoding data +} + + + {{ runeInfo[id]?.etching.symbol || '' }} + + {{ runeInfo[id]?.etching.spacedName }} + + \ 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..b218359d9 --- /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: 200px; +} \ 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..6c6d2af20 --- /dev/null +++ b/frontend/src/app/components/ord-data/ord-data.component.ts @@ -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; + } +} 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..217eab7d7 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..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,