From d31c2665eea4b287a85f4f8127e3be6782459944 Mon Sep 17 00:00:00 2001 From: natsoni Date: Mon, 7 Oct 2024 20:01:55 +0900 Subject: [PATCH 01/14] Add inscriptions parsing code --- .../src/app/shared/ord/inscription.utils.ts | 401 ++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 frontend/src/app/shared/ord/inscription.utils.ts 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..efa9e8fe8 --- /dev/null +++ b/frontend/src/app/shared/ord/inscription.utils.ts @@ -0,0 +1,401 @@ +// Adapted from https://github.com/ordpool-space/ordpool-parser/tree/ce04d7a5b6bb1cf37b9fdadd77ba430f5bd6e7d6/src +// Utils functions to decode ord inscriptions + +import { Inscription } from "../../components/ord-data/ord-data.component"; + +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 /////////////////////////// + +/** + * 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, 150), // Limit body to 150 bytes for now + 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 From 4143a5f5935d90e031f2d001cbcff7d8ffb46412 Mon Sep 17 00:00:00 2001 From: natsoni Date: Mon, 7 Oct 2024 20:03:10 +0900 Subject: [PATCH 02/14] Add runestone protocol implementation --- frontend/src/app/shared/ord/rune/artifact.ts | 4 + frontend/src/app/shared/ord/rune/cenotaph.ts | 14 + frontend/src/app/shared/ord/rune/constants.ts | 7 + frontend/src/app/shared/ord/rune/edict.ts | 34 ++ frontend/src/app/shared/ord/rune/etching.ts | 54 +++ frontend/src/app/shared/ord/rune/flag.ts | 20 + frontend/src/app/shared/ord/rune/flaw.ts | 12 + .../src/app/shared/ord/rune/integer/index.ts | 4 + .../src/app/shared/ord/rune/integer/u128.ts | 176 ++++++++ .../src/app/shared/ord/rune/integer/u32.ts | 58 +++ .../src/app/shared/ord/rune/integer/u64.ts | 58 +++ .../src/app/shared/ord/rune/integer/u8.ts | 58 +++ frontend/src/app/shared/ord/rune/message.ts | 67 +++ frontend/src/app/shared/ord/rune/monads.ts | 392 ++++++++++++++++++ frontend/src/app/shared/ord/rune/rune.ts | 23 + frontend/src/app/shared/ord/rune/runeid.ts | 89 ++++ frontend/src/app/shared/ord/rune/runestone.ts | 258 ++++++++++++ frontend/src/app/shared/ord/rune/script.ts | 237 +++++++++++ frontend/src/app/shared/ord/rune/seekarray.ts | 43 ++ .../src/app/shared/ord/rune/spacedrune.ts | 21 + frontend/src/app/shared/ord/rune/tag.ts | 60 +++ frontend/src/app/shared/ord/rune/terms.ts | 9 + frontend/src/app/shared/ord/rune/utils.ts | 6 + 23 files changed, 1704 insertions(+) create mode 100644 frontend/src/app/shared/ord/rune/artifact.ts create mode 100644 frontend/src/app/shared/ord/rune/cenotaph.ts create mode 100644 frontend/src/app/shared/ord/rune/constants.ts create mode 100644 frontend/src/app/shared/ord/rune/edict.ts create mode 100644 frontend/src/app/shared/ord/rune/etching.ts create mode 100644 frontend/src/app/shared/ord/rune/flag.ts create mode 100644 frontend/src/app/shared/ord/rune/flaw.ts create mode 100644 frontend/src/app/shared/ord/rune/integer/index.ts create mode 100644 frontend/src/app/shared/ord/rune/integer/u128.ts create mode 100644 frontend/src/app/shared/ord/rune/integer/u32.ts create mode 100644 frontend/src/app/shared/ord/rune/integer/u64.ts create mode 100644 frontend/src/app/shared/ord/rune/integer/u8.ts create mode 100644 frontend/src/app/shared/ord/rune/message.ts create mode 100644 frontend/src/app/shared/ord/rune/monads.ts create mode 100644 frontend/src/app/shared/ord/rune/rune.ts create mode 100644 frontend/src/app/shared/ord/rune/runeid.ts create mode 100644 frontend/src/app/shared/ord/rune/runestone.ts create mode 100644 frontend/src/app/shared/ord/rune/script.ts create mode 100644 frontend/src/app/shared/ord/rune/seekarray.ts create mode 100644 frontend/src/app/shared/ord/rune/spacedrune.ts create mode 100644 frontend/src/app/shared/ord/rune/tag.ts create mode 100644 frontend/src/app/shared/ord/rune/terms.ts create mode 100644 frontend/src/app/shared/ord/rune/utils.ts diff --git a/frontend/src/app/shared/ord/rune/artifact.ts b/frontend/src/app/shared/ord/rune/artifact.ts new file mode 100644 index 000000000..2eba9f158 --- /dev/null +++ b/frontend/src/app/shared/ord/rune/artifact.ts @@ -0,0 +1,4 @@ +import { Cenotaph } from './cenotaph'; +import { Runestone } from './runestone'; + +export type Artifact = Cenotaph | Runestone; diff --git a/frontend/src/app/shared/ord/rune/cenotaph.ts b/frontend/src/app/shared/ord/rune/cenotaph.ts new file mode 100644 index 000000000..368a0f938 --- /dev/null +++ b/frontend/src/app/shared/ord/rune/cenotaph.ts @@ -0,0 +1,14 @@ +import { Flaw } from './flaw'; +import { None, Option } from './monads'; +import { Rune } from './rune'; +import { RuneId } from './runeid'; + +export class Cenotaph { + readonly type = 'cenotaph'; + + constructor( + readonly flaws: Flaw[], + readonly etching: Option = None, + readonly mint: Option = None + ) {} +} diff --git a/frontend/src/app/shared/ord/rune/constants.ts b/frontend/src/app/shared/ord/rune/constants.ts new file mode 100644 index 000000000..0e4bab116 --- /dev/null +++ b/frontend/src/app/shared/ord/rune/constants.ts @@ -0,0 +1,7 @@ +import { u8 } from './integer'; +import { opcodes } from './script'; + +export const MAX_DIVISIBILITY = u8(38); + +export const OP_RETURN = opcodes.OP_RETURN; +export const MAGIC_NUMBER = opcodes.OP_13; diff --git a/frontend/src/app/shared/ord/rune/edict.ts b/frontend/src/app/shared/ord/rune/edict.ts new file mode 100644 index 000000000..ede5865a6 --- /dev/null +++ b/frontend/src/app/shared/ord/rune/edict.ts @@ -0,0 +1,34 @@ +import { Option, Some, None } from './monads'; +import { RuneId } from './runeid'; +import { u128, u32 } from './integer'; + +export type Edict = { + id: RuneId; + amount: u128; + output: u32; +}; + +export namespace Edict { + export function fromIntegers( + numOutputs: number, + id: RuneId, + amount: u128, + output: u128 + ): Option { + if (id.block === 0n && id.tx > 0n) { + return None; + } + + const optionOutputU32 = u128.tryIntoU32(output); + if (optionOutputU32.isNone()) { + return None; + } + const outputU32 = optionOutputU32.unwrap(); + + if (outputU32 > numOutputs) { + return None; + } + + return Some({ id, amount, output: outputU32 }); + } +} diff --git a/frontend/src/app/shared/ord/rune/etching.ts b/frontend/src/app/shared/ord/rune/etching.ts new file mode 100644 index 000000000..edc245565 --- /dev/null +++ b/frontend/src/app/shared/ord/rune/etching.ts @@ -0,0 +1,54 @@ +import { None, Option, Some } from './monads'; +import { Terms } from './terms'; +import { Rune } from './rune'; +import { u128, u32, u8 } from './integer'; + +type RuneEtchingBase = { + divisibility?: number; + premine?: bigint; + symbol?: string; + terms?: { + cap?: bigint; + amount?: bigint; + offset?: { + start?: bigint; + end?: bigint; + }; + height?: { + start?: bigint; + end?: bigint; + }; + }; + turbo?: boolean; +}; + +export type RuneEtchingSpec = RuneEtchingBase & { runeName?: string }; + +export class Etching { + readonly symbol: Option; + + constructor( + readonly divisibility: Option, + readonly rune: Option, + readonly spacers: Option, + symbol: Option, + readonly terms: Option, + readonly premine: Option, + readonly turbo: boolean + ) { + this.symbol = symbol.andThen((value) => { + const codePoint = value.codePointAt(0); + return codePoint !== undefined ? Some(String.fromCodePoint(codePoint)) : None; + }); + } + + get supply(): Option { + const premine = this.premine.unwrapOr(u128(0)); + const cap = this.terms.andThen((terms) => terms.cap).unwrapOr(u128(0)); + const amount = this.terms.andThen((terms) => terms.amount).unwrapOr(u128(0)); + + return u128 + .checkedMultiply(cap, amount) + .andThen((multiplyResult) => u128.checkedAdd(premine, multiplyResult)); + } +} diff --git a/frontend/src/app/shared/ord/rune/flag.ts b/frontend/src/app/shared/ord/rune/flag.ts new file mode 100644 index 000000000..317c74ae5 --- /dev/null +++ b/frontend/src/app/shared/ord/rune/flag.ts @@ -0,0 +1,20 @@ +import { u128 } from './integer'; + +export enum Flag { + ETCHING = 0, + TERMS = 1, + TURBO = 2, + CENOTAPH = 127, +} + +export namespace Flag { + export function mask(flag: Flag): u128 { + return u128(1n << BigInt(flag)); + } + + export function take(flags: u128, flag: Flag): { set: boolean; flags: u128 } { + const mask = Flag.mask(flag); + const set = (flags & mask) !== 0n; + return { set, flags: set ? u128(flags - mask) : flags }; + } +} diff --git a/frontend/src/app/shared/ord/rune/flaw.ts b/frontend/src/app/shared/ord/rune/flaw.ts new file mode 100644 index 000000000..2ed5ea506 --- /dev/null +++ b/frontend/src/app/shared/ord/rune/flaw.ts @@ -0,0 +1,12 @@ +export enum Flaw { + EDICT_OUTPUT, + EDICT_RUNE_ID, + INVALID_SCRIPT, + OPCODE, + SUPPLY_OVERFLOW, + TRAILING_INTEGERS, + TRUNCATED_FIELD, + UNRECOGNIZED_EVEN_TAG, + UNRECOGNIZED_FLAG, + VARINT, +} diff --git a/frontend/src/app/shared/ord/rune/integer/index.ts b/frontend/src/app/shared/ord/rune/integer/index.ts new file mode 100644 index 000000000..3c54a77e7 --- /dev/null +++ b/frontend/src/app/shared/ord/rune/integer/index.ts @@ -0,0 +1,4 @@ +export { u8 } from './u8'; +export { u32 } from './u32'; +export { u64 } from './u64'; +export { u128 } from './u128'; diff --git a/frontend/src/app/shared/ord/rune/integer/u128.ts b/frontend/src/app/shared/ord/rune/integer/u128.ts new file mode 100644 index 000000000..78de8506f --- /dev/null +++ b/frontend/src/app/shared/ord/rune/integer/u128.ts @@ -0,0 +1,176 @@ +import { None, Option, Some } from '../monads'; +import { SeekArray } from '../seekarray'; +import { u64 } from './u64'; +import { u32 } from './u32'; +import { u8 } from './u8'; + +/** + * A little utility type used for nominal typing. + * + * See {@link https://michalzalecki.com/nominal-typing-in-typescript/} + */ +type BigTypedNumber = bigint & { + /** + * # !!! DO NOT USE THIS PROPERTY IN YOUR CODE !!! + * ## This is just used to make each `BigTypedNumber` alias unique for Typescript and doesn't actually exist. + * @ignore + * @private + * @readonly + * @type {undefined} + */ + readonly __kind__: T; +}; + +/** + * ## 128-bit unsigned integer + * + * - **Value Range:** `0` to `340282366920938463463374607431768211455` + * - **Size in bytes:** `16` + * - **Web IDL type:** `bigint` + * - **Equivalent C type:** `uint128_t` + */ +export type u128 = BigTypedNumber<'u128'>; + +export const U128_MAX_BIGINT = 0xffff_ffff_ffff_ffff_ffff_ffff_ffff_ffffn; + +/** + * Convert Number or BigInt to 128-bit unsigned integer. + * @param num - The Number or BigInt to convert. + * @returns - The resulting 128-bit unsigned integer (BigInt). + */ +export function u128(num: number | bigint): u128 { + if (typeof num == 'bigint') { + if (num < 0n || num > U128_MAX_BIGINT) { + throw new Error('num is out of range'); + } + } else { + if (!Number.isSafeInteger(num) || num < 0) { + throw new Error('num is not a valid integer'); + } + } + + return BigInt(num) as u128; +} + +export namespace u128 { + export const MAX = u128(U128_MAX_BIGINT); + + export function checkedAdd(x: u128, y: u128): Option { + const result = x + y; + if (result > u128.MAX) { + return None; + } + + return Some(u128(result)); + } + + export function checkedAddThrow(x: u128, y: u128): u128 { + const option = u128.checkedAdd(x, y); + if (option.isNone()) { + throw new Error('checked add overflow'); + } + return option.unwrap(); + } + + export function checkedSub(x: u128, y: u128): Option { + const result = x - y; + if (result < 0n) { + return None; + } + + return Some(u128(result)); + } + + export function checkedSubThrow(x: u128, y: u128): u128 { + const option = u128.checkedSub(x, y); + if (option.isNone()) { + throw new Error('checked sub overflow'); + } + return option.unwrap(); + } + + export function checkedMultiply(x: u128, y: u128): Option { + const result = x * y; + if (result > u128.MAX) { + return None; + } + + return Some(u128(result)); + } + + export function saturatingAdd(x: u128, y: u128): u128 { + const result = x + y; + return result > u128.MAX ? u128.MAX : u128(result); + } + + export function saturatingMultiply(x: u128, y: u128): u128 { + const result = x * y; + return result > u128.MAX ? u128.MAX : u128(result); + } + + export function saturatingSub(x: u128, y: u128): u128 { + return u128(x < y ? 0 : x - y); + } + + export function decodeVarInt(seekArray: SeekArray): Option { + try { + return Some(tryDecodeVarInt(seekArray)); + } catch (e) { + return None; + } + } + + export function tryDecodeVarInt(seekArray: SeekArray): u128 { + let result: u128 = u128(0); + for (let i = 0; i <= 18; i++) { + const byte = seekArray.readUInt8(); + if (byte === undefined) throw new Error('Unterminated or invalid data'); + + // Ensure all operations are done in bigint domain. + const byteBigint = BigInt(byte); + const value = u128(byteBigint & 0x7Fn); // Ensure the 'value' is treated as u128. + + if (i === 18 && (value & 0x7Cn) !== 0n) throw new Error('Overflow'); + + // Use bigint addition instead of bitwise OR to combine the results, + // and ensure shifting is handled correctly within the bigint domain. + result = u128(result + (value << (7n * BigInt(i)))); + + if ((byte & 0x80) === 0) return result; + } + throw new Error('Overlong encoding'); + } + + export function encodeVarInt(value: u128): Uint8Array { + const bytes = []; + while (value >> 7n > 0n) { + bytes.push(Number(value & 0x7Fn) | 0x80); + value = u128(value >> 7n); // Explicitly cast the shifted value back to u128 + } + bytes.push(Number(value & 0x7Fn)); + return new Uint8Array(bytes); + } + + export function tryIntoU64(n: u128): Option { + return n > u64.MAX ? None : Some(u64(n)); + } + + export function tryIntoU32(n: u128): Option { + return n > u32.MAX ? None : Some(u32(n)); + } + + export function tryIntoU8(n: u128): Option { + return n > u8.MAX ? None : Some(u8(n)); + } +} + +export function* getAllU128(data: Uint8Array): Generator { + const seekArray = new SeekArray(data); + while (!seekArray.isFinished()) { + const nextValue = u128.decodeVarInt(seekArray); + if (nextValue.isNone()) { + return; + } + yield nextValue.unwrap(); + } +} diff --git a/frontend/src/app/shared/ord/rune/integer/u32.ts b/frontend/src/app/shared/ord/rune/integer/u32.ts new file mode 100644 index 000000000..90e517bb8 --- /dev/null +++ b/frontend/src/app/shared/ord/rune/integer/u32.ts @@ -0,0 +1,58 @@ +import { None, Option, Some } from '../monads'; + +/** + * A little utility type used for nominal typing. + * + * See {@link https://michalzalecki.com/nominal-typing-in-typescript/} + */ +type BigTypedNumber = bigint & { + /** + * # !!! DO NOT USE THIS PROPERTY IN YOUR CODE !!! + * ## This is just used to make each `BigTypedNumber` alias unique for Typescript and doesn't actually exist. + * @ignore + * @private + * @readonly + * @type {undefined} + */ + readonly __kind__: T; +}; + +export type u32 = BigTypedNumber<'u32'>; + +export const U32_MAX_BIGINT = 0xffff_ffffn; + +export function u32(num: number | bigint): u32 { + if (typeof num == 'bigint') { + if (num < 0n || num > U32_MAX_BIGINT) { + throw new Error('num is out of range'); + } + } else { + if (!Number.isSafeInteger(num) || num < 0) { + throw new Error('num is not a valid integer'); + } + } + + return BigInt(num) as u32; +} + +export namespace u32 { + export const MAX = u32(U32_MAX_BIGINT); + + export function checkedAdd(x: u32, y: u32): Option { + const result = x + y; + if (result > u32.MAX) { + return None; + } + + return Some(u32(result)); + } + + export function checkedSub(x: u32, y: u32): Option { + const result = x - y; + if (result < 0n) { + return None; + } + + return Some(u32(result)); + } +} diff --git a/frontend/src/app/shared/ord/rune/integer/u64.ts b/frontend/src/app/shared/ord/rune/integer/u64.ts new file mode 100644 index 000000000..8010dd99c --- /dev/null +++ b/frontend/src/app/shared/ord/rune/integer/u64.ts @@ -0,0 +1,58 @@ +import { None, Option, Some } from '../monads'; + +/** + * A little utility type used for nominal typing. + * + * See {@link https://michalzalecki.com/nominal-typing-in-typescript/} + */ +type BigTypedNumber = bigint & { + /** + * # !!! DO NOT USE THIS PROPERTY IN YOUR CODE !!! + * ## This is just used to make each `BigTypedNumber` alias unique for Typescript and doesn't actually exist. + * @ignore + * @private + * @readonly + * @type {undefined} + */ + readonly __kind__: T; +}; + +export type u64 = BigTypedNumber<'u64'>; + +export const U64_MAX_BIGINT = 0xffff_ffff_ffff_ffffn; + +export function u64(num: number | bigint): u64 { + if (typeof num == 'bigint') { + if (num < 0n || num > U64_MAX_BIGINT) { + throw new Error('num is out of range'); + } + } else { + if (!Number.isSafeInteger(num) || num < 0) { + throw new Error('num is not a valid integer'); + } + } + + return BigInt(num) as u64; +} + +export namespace u64 { + export const MAX = u64(U64_MAX_BIGINT); + + export function checkedAdd(x: u64, y: u64): Option { + const result = x + y; + if (result > u64.MAX) { + return None; + } + + return Some(u64(result)); + } + + export function checkedSub(x: u64, y: u64): Option { + const result = x - y; + if (result < 0n) { + return None; + } + + return Some(u64(result)); + } +} diff --git a/frontend/src/app/shared/ord/rune/integer/u8.ts b/frontend/src/app/shared/ord/rune/integer/u8.ts new file mode 100644 index 000000000..5676421b0 --- /dev/null +++ b/frontend/src/app/shared/ord/rune/integer/u8.ts @@ -0,0 +1,58 @@ +import { None, Option, Some } from '../monads'; + +/** + * A little utility type used for nominal typing. + * + * See {@link https://michalzalecki.com/nominal-typing-in-typescript/} + */ +type BigTypedNumber = bigint & { + /** + * # !!! DO NOT USE THIS PROPERTY IN YOUR CODE !!! + * ## This is just used to make each `BigTypedNumber` alias unique for Typescript and doesn't actually exist. + * @ignore + * @private + * @readonly + * @type {undefined} + */ + readonly __kind__: T; +}; + +export type u8 = BigTypedNumber<'u8'>; + +export const U8_MAX_BIGINT = 0xffn; + +export function u8(num: number | bigint): u8 { + if (typeof num == 'bigint') { + if (num < 0n || num > U8_MAX_BIGINT) { + throw new Error('num is out of range'); + } + } else { + if (!Number.isSafeInteger(num) || num < 0) { + throw new Error('num is not a valid integer'); + } + } + + return BigInt(num) as u8; +} + +export namespace u8 { + export const MAX = u8(U8_MAX_BIGINT); + + export function checkedAdd(x: u8, y: u8): Option { + const result = x + y; + if (result > u8.MAX) { + return None; + } + + return Some(u8(result)); + } + + export function checkedSub(x: u8, y: u8): Option { + const result = x - y; + if (result < 0n) { + return None; + } + + return Some(u8(result)); + } +} diff --git a/frontend/src/app/shared/ord/rune/message.ts b/frontend/src/app/shared/ord/rune/message.ts new file mode 100644 index 000000000..cad1a8ced --- /dev/null +++ b/frontend/src/app/shared/ord/rune/message.ts @@ -0,0 +1,67 @@ +import { Edict } from './edict'; +import { Flaw } from './flaw'; +import { u128, u64, u32 } from './integer'; +import { RuneId } from './runeid'; +import { Tag } from './tag'; + +export class Message { + constructor( + readonly flaws: Flaw[], + readonly edicts: Edict[], + readonly fields: Map + ) {} + + static fromIntegers(numOutputs: number, payload: u128[]): Message { + const edicts: Edict[] = []; + const fields = new Map(); + const flaws: Flaw[] = []; + + for (const i of [...Array(Math.ceil(payload.length / 2)).keys()].map((n) => n * 2)) { + const tag = payload[i]; + + if (u128(Tag.BODY) === tag) { + let id = new RuneId(u64(0), u32(0)); + const chunkSize = 4; + + const body = payload.slice(i + 1); + for (let j = 0; j < body.length; j += chunkSize) { + const chunk = body.slice(j, j + chunkSize); + if (chunk.length !== chunkSize) { + flaws.push(Flaw.TRAILING_INTEGERS); + break; + } + + const optionNext = id.next(chunk[0], chunk[1]); + if (optionNext.isNone()) { + flaws.push(Flaw.EDICT_RUNE_ID); + break; + } + const next = optionNext.unwrap(); + + const optionEdict = Edict.fromIntegers(numOutputs, next, chunk[2], chunk[3]); + if (optionEdict.isNone()) { + flaws.push(Flaw.EDICT_OUTPUT); + break; + } + const edict = optionEdict.unwrap(); + + id = next; + edicts.push(edict); + } + break; + } + + const value = payload[i + 1]; + if (value === undefined) { + flaws.push(Flaw.TRUNCATED_FIELD); + break; + } + + const values = fields.get(tag) ?? []; + values.push(value); + fields.set(tag, values); + } + + return new Message(flaws, edicts, fields); + } +} diff --git a/frontend/src/app/shared/ord/rune/monads.ts b/frontend/src/app/shared/ord/rune/monads.ts new file mode 100644 index 000000000..7822acca9 --- /dev/null +++ b/frontend/src/app/shared/ord/rune/monads.ts @@ -0,0 +1,392 @@ +// Copied with MIT License from link below: +// https://github.com/thames-technology/monads/blob/de957d3d68449d659518d99be4ea74bbb70dfc8e/src/option/option.ts + +/** + * Type representing any value except 'undefined'. + * This is useful when working with strict null checks, ensuring that a value can be null but not undefined. + */ +type NonUndefined = {} | null; // eslint-disable-line @typescript-eslint/ban-types + +/** + * Enum-like object to represent the type of an Option (Some or None). + */ +export const OptionType = { + Some: Symbol(':some'), + None: Symbol(':none'), +}; + +/** + * Interface for handling match operations on an Option. + * Allows executing different logic based on the Option being Some or None. + */ +interface Match { + some: (val: A) => B; + none: (() => B) | B; +} + +/** + * The Option interface representing an optional value. + * An Option is either Some, holding a value, or None, indicating the absence of a value. + */ +export interface Option { + /** + * Represents the type of the Option: either Some or None. Useful for debugging and runtime checks. + */ + type: symbol; + + /** + * Determines if the Option is a Some. + * + * @returns true if the Option is Some, otherwise false. + * + * #### Example + * + * ```ts + * console.log(Some(5).isSome()); // true + * console.log(None.isSome()); // false + * ``` + */ + isSome(): boolean; + + /** + * Determines if the Option is None. + * + * @returns true if the Option is None, otherwise false. + * + * #### Example + * + * ```ts + * console.log(Some(5).isNone()); // false + * console.log(None.isNone()); // true + * ``` + */ + isNone(): boolean; + + /** + * Performs a match operation on the Option, allowing for branching logic based on its state. + * This method takes an object with functions for each case (Some or None) and executes + * the corresponding function based on the Option's state, returning the result. + * + * @param fn An object containing two properties: `some` and `none`, which are functions + * to handle the Some and None cases, respectively. + * @returns The result of applying the corresponding function based on the Option's state. + * + * #### Example + * + * ```ts + * const optionSome = Some(5); + * const matchResultSome = optionSome.match({ + * some: (value) => `The value is ${value}.`, + * none: () => 'There is no value.', + * }); + * console.log(matchResultSome); // Outputs: "The value is 5." + * + * const optionNone = None; + * const matchResultNone = optionNone.match({ + * some: (value) => `The value is ${value}.`, + * none: () => 'There is no value.', + * }); + * console.log(matchResultNone); // Outputs: "There is no value." + * ``` + */ + match(fn: Match): U; + + /** + * Applies a function to the contained value (if any), or returns a default if None. + * + * @param fn A function that takes a value of type T and returns a value of type U. + * @returns An Option containing the function's return value if the original Option is Some, otherwise None. + * + * #### Examples + * + * ```ts + * const length = Some("hello").map(s => s.length); // Some(5) + * const noneLength = None.map(s => s.length); // None + * ``` + */ + map(fn: (val: T) => U): Option; + + inspect(fn: (val: T) => void): Option; + + /** + * Transforms the Option into another by applying a function to the contained value, + * chaining multiple potentially failing operations. + * + * @param fn A function that takes a value of type T and returns an Option of type U. + * @returns The Option returned by the function if the original Option is Some, otherwise None. + * + * #### Examples + * + * ```ts + * const parse = (s: string) => { + * const parsed = parseInt(s); + * return isNaN(parsed) ? None : Some(parsed); + * }; + * const result = Some("123").andThen(parse); // Some(123) + * const noResult = Some("abc").andThen(parse); // None + * ``` + */ + andThen(fn: (val: T) => Option): Option; + + /** + * Returns this Option if it is Some, otherwise returns the option provided as a parameter. + * + * @param optb The alternative Option to return if the original Option is None. + * @returns The original Option if it is Some, otherwise `optb`. + * + * #### Examples + * + * ```ts + * const defaultOption = Some("default"); + * const someOption = Some("some").or(defaultOption); // Some("some") + * const noneOption = None.or(defaultOption); // Some("default") + * ``` + */ + or(optb: Option): Option; + + orElse(optb: () => Option): Option; + + /** + * Returns the option provided as a parameter if the original Option is Some, otherwise returns None. + * + * @param optb The Option to return if the original Option is Some. + * @returns `optb` if the original Option is Some, otherwise None. + * + * #### Examples + * + * ```ts + * const anotherOption = Some("another"); + * const someOption = Some("some").and(anotherOption); // Some("another") + * const noneOption = None.and(anotherOption); // None + * ``` + */ + and(optb: Option): Option; + + /** + * Returns the contained value if Some, otherwise returns the provided default value. + * + * @param def The default value to return if the Option is None. + * @returns The contained value if Some, otherwise `def`. + * + * #### Examples + * + * ```ts + * const someValue = Some("value").unwrapOr("default"); // "value" + * const noneValue = None.unwrapOr("default"); // "default" + * ``` + */ + unwrapOr(def: T): T; + + /** + * Unwraps an Option, yielding the contained value if Some, otherwise throws an error. + * + * @returns The contained value. + * @throws Error if the Option is None. + * + * #### Examples + * + * ```ts + * console.log(Some("value").unwrap()); // "value" + * console.log(None.unwrap()); // throws Error + * ``` + */ + unwrap(): T | never; +} + +/** + * Implementation of Option representing a value (Some). + */ +interface SomeOption extends Option { + unwrap(): T; +} + +/** + * Implementation of Option representing the absence of a value (None). + */ +interface NoneOption extends Option { + unwrap(): never; +} + +/** + * Represents a Some value of Option. + */ +class SomeImpl implements SomeOption { + constructor(private readonly val: T) {} + + get type() { + return OptionType.Some; + } + + isSome() { + return true; + } + + isNone() { + return false; + } + + match(fn: Match): B { + return fn.some(this.val); + } + + map(fn: (val: T) => U): Option { + return Some(fn(this.val)); + } + + inspect(fn: (val: T) => void): Option { + fn(this.val); + return this; + } + + andThen(fn: (val: T) => Option): Option { + return fn(this.val); + } + + or(_optb: Option): Option { + return this; + } + + orElse(optb: () => Option): Option { + return this; + } + + and(optb: Option): Option { + return optb; + } + + unwrapOr(_def: T): T { + return this.val; + } + + unwrap(): T { + return this.val; + } +} + +/** + * Represents a None value of Option. + */ +class NoneImpl implements NoneOption { + get type() { + return OptionType.None; + } + + isSome() { + return false; + } + + isNone() { + return true; + } + + match({ none }: Match): U { + if (typeof none === 'function') { + return (none as () => U)(); + } + + return none; + } + + map(_fn: (val: T) => U): Option { + return new NoneImpl(); + } + + inspect(fn: (val: T) => void): Option { + return this; + } + + andThen(_fn: (val: T) => Option): Option { + return new NoneImpl(); + } + + or(optb: Option): Option { + return optb; + } + + orElse(optb: () => Option): Option { + return optb(); + } + + and(_optb: Option): Option { + return new NoneImpl(); + } + + unwrapOr(def: T): T { + return def; + } + + unwrap(): never { + throw new ReferenceError('Trying to unwrap None.'); + } +} + +/** + * Creates a Some instance of Option containing the given value. + * This function is used to represent the presence of a value in an operation that may not always produce a value. + * + * @param val The value to be wrapped in a Some Option. + * @returns An Option instance representing the presence of a value. + * + * #### Example + * + * ```ts + * const option = Some(42); + * console.log(option.unwrap()); // Outputs: 42 + * ``` + */ +export function Some(val: T): Option { + return new SomeImpl(val); +} + +/** + * The singleton instance representing None, an Option with no value. + * This constant is used to represent the absence of a value in operations that may not always produce a value. + * + * #### Example + * + * ```ts + * const option = None; + * console.log(option.isNone()); // Outputs: true + * ``` + */ +export const None: Option = new NoneImpl(); // eslint-disable-line @typescript-eslint/no-explicit-any + +/** + * Type guard to check if an Option is a Some value. + * This function is used to narrow down the type of an Option to SomeOption in TypeScript's type system. + * + * @param val The Option to be checked. + * @returns true if the provided Option is a SomeOption, false otherwise. + * + * #### Example + * + * ```ts + * const option = Some('Success'); + * if (isSome(option)) { + * console.log('Option has a value:', option.unwrap()); + * } + * ``` + */ +export function isSome(val: Option): val is SomeOption { + return val.isSome(); +} + +/** + * Type guard to check if an Option is a None value. + * This function is used to narrow down the type of an Option to NoneOption in TypeScript's type system. + * + * @param val The Option to be checked. + * @returns true if the provided Option is a NoneOption, false otherwise. + * + * #### Example + * + * ```ts + * const option = None; + * if (isNone(option)) { + * console.log('Option does not have a value.'); + * } + * ``` + */ +export function isNone(val: Option): val is NoneOption { + return val.isNone(); +} diff --git a/frontend/src/app/shared/ord/rune/rune.ts b/frontend/src/app/shared/ord/rune/rune.ts new file mode 100644 index 000000000..c0dd96e1b --- /dev/null +++ b/frontend/src/app/shared/ord/rune/rune.ts @@ -0,0 +1,23 @@ +import { u128 } from './integer'; + +export class Rune { + + constructor(readonly value: u128) {} + + toString() { + let n = this.value; + + if (n === u128.MAX) { + return 'BCGDENLQRQWDSLRUGSNLBTMFIJAV'; + } + + n = u128(n + 1n); + let symbol = ''; + while (n > 0) { + symbol = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Number((n - 1n) % 26n)] + symbol; + n = u128((n - 1n) / 26n); + } + + return symbol; + } +} diff --git a/frontend/src/app/shared/ord/rune/runeid.ts b/frontend/src/app/shared/ord/rune/runeid.ts new file mode 100644 index 000000000..ca0e938b7 --- /dev/null +++ b/frontend/src/app/shared/ord/rune/runeid.ts @@ -0,0 +1,89 @@ +import { None, Option, Some } from './monads'; +import { u64, u32, u128 } from './integer'; + +export class RuneId { + constructor(readonly block: u64, readonly tx: u32) {} + + static new(block: u64, tx: u32): Option { + const id = new RuneId(block, tx); + + if (id.block === 0n && id.tx > 0) { + return None; + } + + return Some(id); + } + + static sort(runeIds: RuneId[]): RuneId[] { + return [...runeIds].sort((x, y) => Number(x.block - y.block || x.tx - y.tx)); + } + + delta(next: RuneId): Option<[u128, u128]> { + const optionBlock = u64.checkedSub(next.block, this.block); + if (optionBlock.isNone()) { + return None; + } + const block = optionBlock.unwrap(); + + let tx: u32; + if (block === 0n) { + const optionTx = u32.checkedSub(next.tx, this.tx); + if (optionTx.isNone()) { + return None; + } + tx = optionTx.unwrap(); + } else { + tx = next.tx; + } + + return Some([u128(block), u128(tx)]); + } + + next(block: u128, tx: u128): Option { + const optionBlock = u128.tryIntoU64(block); + const optionTx = u128.tryIntoU32(tx); + + if (optionBlock.isNone() || optionTx.isNone()) { + return None; + } + + const blockU64 = optionBlock.unwrap(); + const txU32 = optionTx.unwrap(); + + const nextBlock = u64.checkedAdd(this.block, blockU64); + if (nextBlock.isNone()) { + return None; + } + + let nextTx: u32; + if (blockU64 === 0n) { + const optionAdd = u32.checkedAdd(this.tx, txU32); + if (optionAdd.isNone()) { + return None; + } + + nextTx = optionAdd.unwrap(); + } else { + nextTx = txU32; + } + + return RuneId.new(nextBlock.unwrap(), nextTx); + } + + toString() { + return `${this.block}:${this.tx}`; + } + + static fromString(s: string) { + const parts = s.split(':'); + if (parts.length !== 2) { + throw new Error(`invalid rune ID: ${s}`); + } + + const [block, tx] = parts; + if (!/^\d+$/.test(block) || !/^\d+$/.test(tx)) { + throw new Error(`invalid rune ID: ${s}`); + } + return new RuneId(u64(BigInt(block)), u32(BigInt(tx))); + } +} diff --git a/frontend/src/app/shared/ord/rune/runestone.ts b/frontend/src/app/shared/ord/rune/runestone.ts new file mode 100644 index 000000000..c71cdcd90 --- /dev/null +++ b/frontend/src/app/shared/ord/rune/runestone.ts @@ -0,0 +1,258 @@ +import { concatUint8Arrays, hexToBytes } from '../inscription.utils'; +import { Artifact } from './artifact'; +import { Cenotaph } from './cenotaph'; +import { MAGIC_NUMBER, MAX_DIVISIBILITY, OP_RETURN } from './constants'; +import { Edict } from './edict'; +import { Etching } from './etching'; +import { Flag } from './flag'; +import { Flaw } from './flaw'; +import { u128, u32, u64, u8 } from './integer'; +import { Message } from './message'; +import { None, Option, Some } from './monads'; +import { Rune } from './rune'; +import { RuneId } from './runeid'; +import { script } from './script'; +import { SeekArray } from './seekarray'; +import { Tag } from './tag'; + +export const MAX_SPACERS = 0b00000111_11111111_11111111_11111111; + +export const UNCOMMON_GOODS = new Etching( + Some(u8(0)), + Some(new Rune(u128(2055900680524219742n))), + Some(u32(128)), + Some('⧉'), + Some({ + amount: Some(u128(1)), + cap: Some(u128(340282366920938463463374607431768211455n)), + height: [Some(u64(840000)), Some(u64(1050000))], + offset: [Some(u64(0)), Some(u64(0))], + }), + Some(u128(0)), + false +); + +// New: Esplora format instead of Bitcoin RPC format +export type RunestoneTx = { + vout: { + scriptpubkey: string + }[]; +}; + +type Payload = Uint8Array | Flaw; + +export class Runestone { + readonly type = 'runestone'; + + constructor( + readonly mint: Option, + readonly pointer: Option, + readonly edicts: Edict[], + readonly etching: Option + ) {} + + static decipher(transaction: RunestoneTx): Option { + const optionPayload = Runestone.payload(transaction); + if (optionPayload.isNone()) { + return None; + } + const payload = optionPayload.unwrap(); + if (!(payload instanceof Uint8Array)) { + return Some(new Cenotaph([payload])); + } + + const optionIntegers = Runestone.integers(payload); + if (optionIntegers.isNone()) { + return Some(new Cenotaph([Flaw.VARINT])); + } + + const { flaws, edicts, fields } = Message.fromIntegers( + transaction.vout.length, + optionIntegers.unwrap() + ); + + let flags = Tag.take(Tag.FLAGS, fields, 1, ([value]) => Some(value)).unwrapOr(u128(0)); + + const etchingResult = Flag.take(flags, Flag.ETCHING); + const etchingFlag = etchingResult.set; + flags = etchingResult.flags; + + const etching: Option = etchingFlag + ? (() => { + const divisibility = Tag.take( + Tag.DIVISIBILITY, + fields, + 1, + ([value]): Option => + u128 + .tryIntoU8(value) + .andThen((value) => (value <= MAX_DIVISIBILITY ? Some(value) : None)) + ); + + const rune = Tag.take(Tag.RUNE, fields, 1, ([value]) => Some(new Rune(value))); + + const spacers = Tag.take( + Tag.SPACERS, + fields, + 1, + ([value]): Option => + u128.tryIntoU32(value).andThen((value) => (value <= MAX_SPACERS ? Some(value) : None)) + ); + + const symbol = Tag.take(Tag.SYMBOL, fields, 1, ([value]) => + u128.tryIntoU32(value).andThen((value) => { + try { + return Some(String.fromCodePoint(Number(value))); + } catch (e) { + return None; + } + }) + ); + + const termsResult = Flag.take(flags, Flag.TERMS); + const termsFlag = termsResult.set; + flags = termsResult.flags; + + const terms = termsFlag + ? (() => { + const amount = Tag.take(Tag.AMOUNT, fields, 1, ([value]) => Some(value)); + + const cap = Tag.take(Tag.CAP, fields, 1, ([value]) => Some(value)); + + const offset = [ + Tag.take(Tag.OFFSET_START, fields, 1, ([value]) => u128.tryIntoU64(value)), + Tag.take(Tag.OFFSET_END, fields, 1, ([value]) => u128.tryIntoU64(value)), + ] as const; + + const height = [ + Tag.take(Tag.HEIGHT_START, fields, 1, ([value]) => u128.tryIntoU64(value)), + Tag.take(Tag.HEIGHT_END, fields, 1, ([value]) => u128.tryIntoU64(value)), + ] as const; + + return Some({ amount, cap, offset, height }); + })() + : None; + + const premine = Tag.take(Tag.PREMINE, fields, 1, ([value]) => Some(value)); + + const turboResult = Flag.take(flags, Flag.TURBO); + const turbo = etchingResult.set; + flags = turboResult.flags; + + return Some(new Etching(divisibility, rune, spacers, symbol, terms, premine, turbo)); + })() + : None; + + const mint = Tag.take(Tag.MINT, fields, 2, ([block, tx]): Option => { + const optionBlockU64 = u128.tryIntoU64(block); + const optionTxU32 = u128.tryIntoU32(tx); + + if (optionBlockU64.isNone() || optionTxU32.isNone()) { + return None; + } + + return RuneId.new(optionBlockU64.unwrap(), optionTxU32.unwrap()); + }); + + const pointer = Tag.take( + Tag.POINTER, + fields, + 1, + ([value]): Option => + u128 + .tryIntoU32(value) + .andThen((value) => (value < transaction.vout.length ? Some(value) : None)) + ); + + if (etching.map((etching) => etching.supply.isNone()).unwrapOr(false)) { + flaws.push(Flaw.SUPPLY_OVERFLOW); + } + + if (flags !== 0n) { + flaws.push(Flaw.UNRECOGNIZED_FLAG); + } + + if ([...fields.keys()].find((tag) => tag % 2n === 0n) !== undefined) { + flaws.push(Flaw.UNRECOGNIZED_EVEN_TAG); + } + + if (flaws.length !== 0) { + return Some( + new Cenotaph( + flaws, + etching.andThen((etching) => etching.rune), + mint + ) + ); + } + + return Some(new Runestone(mint, pointer, edicts, etching)); + } + + static payload(transaction: RunestoneTx): Option { + // search transaction outputs for payload + for (const output of transaction.vout) { + const instructions = script.decompile(hexToBytes(output.scriptpubkey)); + if (instructions === null) { + throw new Error('unable to decompile'); + } + + // payload starts with OP_RETURN + let nextInstructionResult = instructions.next(); + if (nextInstructionResult.done || nextInstructionResult.value !== OP_RETURN) { + continue; + } + + // followed by the protocol identifier + nextInstructionResult = instructions.next(); + if ( + nextInstructionResult.done || + nextInstructionResult.value instanceof Uint8Array || + nextInstructionResult.value !== MAGIC_NUMBER + ) { + continue; + } + + // construct the payload by concatinating remaining data pushes + let payloads: Uint8Array[] = []; + + do { + nextInstructionResult = instructions.next(); + + if (nextInstructionResult.done) { + const decodedSuccessfully = nextInstructionResult.value; + if (!decodedSuccessfully) { + return Some(Flaw.INVALID_SCRIPT); + } + break; + } + + const instruction = nextInstructionResult.value; + if (instruction instanceof Uint8Array) { + payloads.push(instruction); + } else { + return Some(Flaw.OPCODE); + } + } while (true); + + return Some(concatUint8Arrays(payloads)); + } + + return None; + } + + static integers(payload: Uint8Array): Option { + const integers: u128[] = []; + + const seekArray = new SeekArray(payload); + while (!seekArray.isFinished()) { + const optionInt = u128.decodeVarInt(seekArray); + if (optionInt.isNone()) { + return None; + } + integers.push(optionInt.unwrap()); + } + + return Some(integers); + } +} diff --git a/frontend/src/app/shared/ord/rune/script.ts b/frontend/src/app/shared/ord/rune/script.ts new file mode 100644 index 000000000..67d579ab8 --- /dev/null +++ b/frontend/src/app/shared/ord/rune/script.ts @@ -0,0 +1,237 @@ +namespace pushdata { + /** + * Calculates the encoding length of a number used for push data in Bitcoin transactions. + * @param i The number to calculate the encoding length for. + * @returns The encoding length of the number. + */ + export function encodingLength(i: number): number { + return i < OPS.OP_PUSHDATA1 ? 1 : i <= 0xff ? 2 : i <= 0xffff ? 3 : 5; + } + + /** + * Decodes a byte array and returns information about the opcode, number, and size. + * @param array - The byte array to decode. + * @param offset - The offset within the array to start decoding. + * @returns An object containing the opcode, number, and size, or null if decoding fails. + */ + export function decode( + array: Uint8Array, + offset: number + ): { + opcode: number; + number: number; + size: number; + } | null { + const dataView = new DataView(array.buffer, array.byteOffset, array.byteLength); + const opcode = dataView.getUint8(offset); + let num: number; + let size: number; + + // ~6 bit + if (opcode < OPS.OP_PUSHDATA1) { + num = opcode; + size = 1; + + // 8 bit + } else if (opcode === OPS.OP_PUSHDATA1) { + if (offset + 2 > array.length) return null; + num = dataView.getUint8(offset + 1); + size = 2; + + // 16 bit + } else if (opcode === OPS.OP_PUSHDATA2) { + if (offset + 3 > array.length) return null; + num = dataView.getUint16(offset + 1, true); // true for little-endian + size = 3; + + // 32 bit + } else { + if (offset + 5 > array.length) return null; + if (opcode !== OPS.OP_PUSHDATA4) throw new Error('Unexpected opcode'); + + num = dataView.getUint32(offset + 1, true); // true for little-endian + size = 5; + } + + return { + opcode, + number: num, + size, + }; + } +} + +const OPS = { + OP_FALSE: 0, + OP_0: 0, + OP_PUSHDATA1: 76, + OP_PUSHDATA2: 77, + OP_PUSHDATA4: 78, + OP_1NEGATE: 79, + OP_RESERVED: 80, + OP_TRUE: 81, + OP_1: 81, + OP_2: 82, + OP_3: 83, + OP_4: 84, + OP_5: 85, + OP_6: 86, + OP_7: 87, + OP_8: 88, + OP_9: 89, + OP_10: 90, + OP_11: 91, + OP_12: 92, + OP_13: 93, + OP_14: 94, + OP_15: 95, + OP_16: 96, + + OP_NOP: 97, + OP_VER: 98, + OP_IF: 99, + OP_NOTIF: 100, + OP_VERIF: 101, + OP_VERNOTIF: 102, + OP_ELSE: 103, + OP_ENDIF: 104, + OP_VERIFY: 105, + OP_RETURN: 106, + + OP_TOALTSTACK: 107, + OP_FROMALTSTACK: 108, + OP_2DROP: 109, + OP_2DUP: 110, + OP_3DUP: 111, + OP_2OVER: 112, + OP_2ROT: 113, + OP_2SWAP: 114, + OP_IFDUP: 115, + OP_DEPTH: 116, + OP_DROP: 117, + OP_DUP: 118, + OP_NIP: 119, + OP_OVER: 120, + OP_PICK: 121, + OP_ROLL: 122, + OP_ROT: 123, + OP_SWAP: 124, + OP_TUCK: 125, + + OP_CAT: 126, + OP_SUBSTR: 127, + OP_LEFT: 128, + OP_RIGHT: 129, + OP_SIZE: 130, + + OP_INVERT: 131, + OP_AND: 132, + OP_OR: 133, + OP_XOR: 134, + OP_EQUAL: 135, + OP_EQUALVERIFY: 136, + OP_RESERVED1: 137, + OP_RESERVED2: 138, + + OP_1ADD: 139, + OP_1SUB: 140, + OP_2MUL: 141, + OP_2DIV: 142, + OP_NEGATE: 143, + OP_ABS: 144, + OP_NOT: 145, + OP_0NOTEQUAL: 146, + OP_ADD: 147, + OP_SUB: 148, + OP_MUL: 149, + OP_DIV: 150, + OP_MOD: 151, + OP_LSHIFT: 152, + OP_RSHIFT: 153, + + OP_BOOLAND: 154, + OP_BOOLOR: 155, + OP_NUMEQUAL: 156, + OP_NUMEQUALVERIFY: 157, + OP_NUMNOTEQUAL: 158, + OP_LESSTHAN: 159, + OP_GREATERTHAN: 160, + OP_LESSTHANOREQUAL: 161, + OP_GREATERTHANOREQUAL: 162, + OP_MIN: 163, + OP_MAX: 164, + + OP_WITHIN: 165, + + OP_RIPEMD160: 166, + OP_SHA1: 167, + OP_SHA256: 168, + OP_HASH160: 169, + OP_HASH256: 170, + OP_CODESEPARATOR: 171, + OP_CHECKSIG: 172, + OP_CHECKSIGVERIFY: 173, + OP_CHECKMULTISIG: 174, + OP_CHECKMULTISIGVERIFY: 175, + + OP_NOP1: 176, + + OP_NOP2: 177, + OP_CHECKLOCKTIMEVERIFY: 177, + + OP_NOP3: 178, + OP_CHECKSEQUENCEVERIFY: 178, + + OP_NOP4: 179, + OP_NOP5: 180, + OP_NOP6: 181, + OP_NOP7: 182, + OP_NOP8: 183, + OP_NOP9: 184, + OP_NOP10: 185, + + OP_CHECKSIGADD: 186, + + OP_PUBKEYHASH: 253, + OP_PUBKEY: 254, + OP_INVALIDOPCODE: 255, +} as const; + +export const opcodes = OPS; + +export namespace script { + export type Instruction = number | Uint8Array; + + export function* decompile(array: Uint8Array): Generator { + let i = 0; + + while (i < array.length) { + const opcode = array[i]; + + // data chunk + if (opcode >= OPS.OP_0 && opcode <= OPS.OP_PUSHDATA4) { + const d = pushdata.decode(array, i); + + // did reading a pushDataInt fail? + if (d === null) return false; + i += d.size; + + // attempt to read too much data? + if (i + d.number > array.length) return false; + + const data = array.subarray(i, i + d.number); + i += d.number; + + yield data; + + // opcode + } else { + yield opcode; + + i += 1; + } + } + + return true; + } +} diff --git a/frontend/src/app/shared/ord/rune/seekarray.ts b/frontend/src/app/shared/ord/rune/seekarray.ts new file mode 100644 index 000000000..1f465cbd3 --- /dev/null +++ b/frontend/src/app/shared/ord/rune/seekarray.ts @@ -0,0 +1,43 @@ +/** + * This class provides a way to read data sequentially from a Uint8Array with automatic cursor management. + * It utilizes DataView for handling multi-byte data types. + * + * This replaces the SeekBuffer from the original runestone-lib! + */ +export class SeekArray { + + public seekIndex: number = 0; + private dataView: DataView; + + /** + * Constructs a SeekArray instance. + * + * @param array - The Uint8Array from which data will be read. + */ + constructor(private array: Uint8Array) { + this.dataView = new DataView(array.buffer, array.byteOffset, array.byteLength); + } + + /** + * Reads an unsigned 8-bit integer from the current position and advances the seek index by 1 byte. + * + * @returns The read value or undefined if reading beyond the end of the array. + */ + readUInt8(): number | undefined { + if (this.isFinished()) { + return undefined; + } + const value = this.dataView.getUint8(this.seekIndex); + this.seekIndex += 1; + return value; + } + + /** + * Checks if the seek index has reached or surpassed the length of the underlying array. + * + * @returns true if there are no more bytes to read, false otherwise. + */ + isFinished(): boolean { + return this.seekIndex >= this.array.length; + } +} diff --git a/frontend/src/app/shared/ord/rune/spacedrune.ts b/frontend/src/app/shared/ord/rune/spacedrune.ts new file mode 100644 index 000000000..b00b0da3a --- /dev/null +++ b/frontend/src/app/shared/ord/rune/spacedrune.ts @@ -0,0 +1,21 @@ +import { Rune } from './rune'; + +export class SpacedRune { + constructor(readonly rune: Rune, readonly spacers: number) {} + + toString(): string { + const rune = this.rune.toString(); + let i = 0; + let result = ''; + for (const c of rune) { + result += c; + + if (i < rune.length - 1 && (this.spacers & (1 << i)) !== 0) { + result += '•'; + } + i++; + } + + return result; + } +} diff --git a/frontend/src/app/shared/ord/rune/tag.ts b/frontend/src/app/shared/ord/rune/tag.ts new file mode 100644 index 000000000..8e39925d4 --- /dev/null +++ b/frontend/src/app/shared/ord/rune/tag.ts @@ -0,0 +1,60 @@ +import { None, Option, Some } from './monads'; +import { u128 } from './integer'; +import { FixedArray } from './utils'; + +export enum Tag { + BODY = 0, + FLAGS = 2, + RUNE = 4, + + PREMINE = 6, + CAP = 8, + AMOUNT = 10, + HEIGHT_START = 12, + HEIGHT_END = 14, + OFFSET_START = 16, + OFFSET_END = 18, + MINT = 20, + POINTER = 22, + CENOTAPH = 126, + + DIVISIBILITY = 1, + SPACERS = 3, + SYMBOL = 5, + NOP = 127, +} + +export namespace Tag { + export function take( + tag: Tag, + fields: Map, + n: N, + withFn: (values: FixedArray) => Option + ): Option { + const field = fields.get(u128(tag)); + if (field === undefined) { + return None; + } + + const values: u128[] = []; + for (const i of [...Array(n).keys()]) { + if (field[i] === undefined) { + return None; + } + values[i] = field[i]; + } + + const optionValue = withFn(values as FixedArray); + if (optionValue.isNone()) { + return None; + } + + field.splice(0, n); + + if (field.length === 0) { + fields.delete(u128(tag)); + } + + return Some(optionValue.unwrap()); + } +} diff --git a/frontend/src/app/shared/ord/rune/terms.ts b/frontend/src/app/shared/ord/rune/terms.ts new file mode 100644 index 000000000..464c166e0 --- /dev/null +++ b/frontend/src/app/shared/ord/rune/terms.ts @@ -0,0 +1,9 @@ +import { Option } from './monads'; +import { u128, u64 } from './integer'; + +export type Terms = { + amount: Option; + cap: Option; + height: readonly [Option, Option]; + offset: readonly [Option, Option]; +}; 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..a6fa8e0a1 --- /dev/null +++ b/frontend/src/app/shared/ord/rune/utils.ts @@ -0,0 +1,6 @@ +type GrowToSize = A['length'] extends N + ? A + : GrowToSize; + +export type FixedArray = GrowToSize; + From 8b6db768cd73915008c35956bb293b97f2323326 Mon Sep 17 00:00:00 2001 From: natsoni Date: Mon, 7 Oct 2024 20:03:36 +0900 Subject: [PATCH 03/14] Decode inscription / rune data client-side --- frontend/src/app/app.module.ts | 2 + .../ord-data/ord-data.component.html | 75 ++++++++++ .../ord-data/ord-data.component.scss | 35 +++++ .../components/ord-data/ord-data.component.ts | 140 ++++++++++++++++++ .../transactions-list.component.html | 28 +++- .../transactions-list.component.scss | 13 +- .../transactions-list.component.ts | 58 ++++++++ .../src/app/interfaces/electrs.interface.ts | 4 + .../src/app/services/electrs-api.service.ts | 4 + frontend/src/app/services/ord-api.service.ts | 114 ++++++++++++++ frontend/src/app/shared/shared.module.ts | 3 + 11 files changed, 473 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/components/ord-data/ord-data.component.html create mode 100644 frontend/src/app/components/ord-data/ord-data.component.scss create mode 100644 frontend/src/app/components/ord-data/ord-data.component.ts create mode 100644 frontend/src/app/services/ord-api.service.ts diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index d1129a602..52fbc9f87 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -6,6 +6,7 @@ import { ZONE_SERVICE } from './injection-tokens'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './components/app/app.component'; import { ElectrsApiService } from './services/electrs-api.service'; +import { OrdApiService } from './services/ord-api.service'; import { StateService } from './services/state.service'; import { CacheService } from './services/cache.service'; import { PriceService } from './services/price.service'; @@ -32,6 +33,7 @@ import { DatePipe } from '@angular/common'; const providers = [ ElectrsApiService, + OrdApiService, StateService, CacheService, PriceService, diff --git a/frontend/src/app/components/ord-data/ord-data.component.html b/frontend/src/app/components/ord-data/ord-data.component.html new file mode 100644 index 000000000..be9a24715 --- /dev/null +++ b/frontend/src/app/components/ord-data/ord-data.component.html @@ -0,0 +1,75 @@ +@if (error) { +
+ Error fetching data (code {{ error.status }}) +
+} @else { + @if (minted) { + + Mint + {{ minted >= 100000 ? (minted | amountShortener:undefined:undefined:true) : minted }} + + + } + @if (totalSupply > -1) { + @if (premined > 0) { + + Premine + {{ premined >= 100000 ? (premined | amountShortener:undefined:undefined:true) : premined }} + {{ etchedSymbol }} + {{ etchedName }} + ({{ premined / totalSupply * 100 | amountShortener:0}}% of total supply) + + } @else { + + Etching of + {{ etchedSymbol }} + {{ etchedName }} + + } + } + @if (transferredRunes?.length && type === 'vout') { +
+ + Transfer + + +
+ } + + + + @if (inscriptions?.length && type === 'vin') { +
+
+ {{ contentType.value.count > 1 ? contentType.value.count + " " : "" }}{{ contentType.value?.tag || contentType.key }} + {{ contentType.value.totalSize | bytes:2:'B':undefined:true }} + + Source inscription + +
+
{{ contentType.value.json | json }}
+
{{ contentType.value.text }}
+
+ } + + @if (!runestone && type === 'vout') { +
+ } + + @if (!inscriptions?.length && type === 'vin') { +
+ Error decoding inscription data +
+ } +} + + + {{ runeInfo[id]?.etching.symbol.isSome() ? runeInfo[id]?.etching.symbol.unwrap() : '' }} + + {{ runeInfo[id]?.name }} + + \ No newline at end of file diff --git a/frontend/src/app/components/ord-data/ord-data.component.scss b/frontend/src/app/components/ord-data/ord-data.component.scss new file mode 100644 index 000000000..7cb2cdca6 --- /dev/null +++ b/frontend/src/app/components/ord-data/ord-data.component.scss @@ -0,0 +1,35 @@ +.amount { + font-weight: bold; +} + +a.rune-link { + color: inherit; + &:hover { + text-decoration: underline; + text-decoration-color: var(--transparent-fg); + } +} + +a.disabled { + text-decoration: none; +} + +.name { + color: var(--transparent-fg); + font-weight: 700; +} + +.badge-ord { + background-color: var(--grey); + position: relative; + top: -2px; + font-size: 81%; + &.primary { + background-color: var(--primary); + } +} + +pre { + margin-top: 5px; + max-height: 150px; +} \ No newline at end of file diff --git a/frontend/src/app/components/ord-data/ord-data.component.ts b/frontend/src/app/components/ord-data/ord-data.component.ts new file mode 100644 index 000000000..8d7eef973 --- /dev/null +++ b/frontend/src/app/components/ord-data/ord-data.component.ts @@ -0,0 +1,140 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { Runestone } from '../../shared/ord/rune/runestone'; +import { Etching } from '../../shared/ord/rune/etching'; +import { u128, u32, u8 } from '../../shared/ord/rune/integer'; +import { HttpErrorResponse } from '@angular/common/http'; +import { SpacedRune } from '../../shared/ord/rune/spacedrune'; + +export interface Inscription { + body?: Uint8Array; + body_length?: number; + content_type?: Uint8Array; + content_type_str?: string; + delegate_txid?: string; +} + +@Component({ + selector: 'app-ord-data', + templateUrl: './ord-data.component.html', + styleUrls: ['./ord-data.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class OrdDataComponent implements OnChanges { + @Input() inscriptions: Inscription[]; + @Input() runestone: Runestone; + @Input() runeInfo: { [id: string]: { etching: Etching; txid: string; name?: string; } }; + @Input() error: HttpErrorResponse; + @Input() type: 'vin' | 'vout'; + + // Inscriptions + inscriptionsData: { [key: string]: { count: number, totalSize: number, text?: string; json?: JSON; tag?: string; delegate?: string } }; + // Rune mints + minted: number; + // Rune etching + premined: number = -1; + totalSupply: number = -1; + etchedName: string; + etchedSymbol: string; + // Rune transfers + transferredRunes: { key: string; etching: Etching; txid: string; name?: string; }[] = []; + + constructor() { } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.runestone && this.runestone) { + + Object.keys(this.runeInfo).forEach((key) => { + const rune = this.runeInfo[key].etching.rune.isSome() ? this.runeInfo[key].etching.rune.unwrap() : null; + const spacers = this.runeInfo[key].etching.spacers.isSome() ? this.runeInfo[key].etching.spacers.unwrap() : u32(0); + if (rune) { + this.runeInfo[key].name = new SpacedRune(rune, Number(spacers)).toString(); + } + this.transferredRunes.push({ key, ...this.runeInfo[key] }); + }); + + + if (this.runestone.mint.isSome() && this.runeInfo[this.runestone.mint.unwrap().toString()]) { + const mint = this.runestone.mint.unwrap().toString(); + this.transferredRunes = this.transferredRunes.filter(rune => rune.key !== mint); + const terms = this.runeInfo[mint].etching.terms.isSome() ? this.runeInfo[mint].etching.terms.unwrap() : null; + let amount: u128; + if (terms) { + amount = terms.amount.isSome() ? terms.amount.unwrap() : u128(0); + } + const divisibility = this.runeInfo[mint].etching.divisibility.isSome() ? this.runeInfo[mint].etching.divisibility.unwrap() : u8(0); + if (amount) { + this.minted = this.getAmount(amount, divisibility); + } + } + + if (this.runestone.etching.isSome()) { + const etching = this.runestone.etching.unwrap(); + const rune = etching.rune.isSome() ? etching.rune.unwrap() : null; + const spacers = etching.spacers.isSome() ? etching.spacers.unwrap() : u32(0); + if (rune) { + this.etchedName = new SpacedRune(rune, Number(spacers)).toString(); + } + this.etchedSymbol = etching.symbol.isSome() ? etching.symbol.unwrap() : ''; + + const divisibility = etching.divisibility.isSome() ? etching.divisibility.unwrap() : u8(0); + const premine = etching.premine.isSome() ? etching.premine.unwrap() : u128(0); + if (premine) { + this.premined = this.getAmount(premine, divisibility); + } else { + this.premined = 0; + } + const terms = etching.terms.isSome() ? etching.terms.unwrap() : null; + let amount: u128; + if (terms) { + amount = terms.amount.isSome() ? terms.amount.unwrap() : u128(0); + if (amount) { + const cap = terms.cap.isSome() ? terms.cap.unwrap() : u128(0); + this.totalSupply = this.premined + this.getAmount(amount, divisibility) * Number(cap); + } + } else { + this.totalSupply = this.premined; + } + } + } + + if (changes.inscriptions && this.inscriptions) { + + if (this.inscriptions?.length) { + this.inscriptionsData = {}; + this.inscriptions.forEach((inscription) => { + // General: count, total size, delegate + const key = inscription.content_type_str || 'undefined'; + if (!this.inscriptionsData[key]) { + this.inscriptionsData[key] = { count: 0, totalSize: 0 }; + } + this.inscriptionsData[key].count++; + this.inscriptionsData[key].totalSize += inscription.body_length; + if (inscription.delegate_txid && !this.inscriptionsData[key].delegate) { + this.inscriptionsData[key].delegate = inscription.delegate_txid; + } + + // Text / JSON data + if ((key.includes('text') || key.includes('json')) && inscription.body?.length && !this.inscriptionsData[key].text && !this.inscriptionsData[key].json) { + const decoder = new TextDecoder('utf-8'); + const text = decoder.decode(inscription.body); + try { + this.inscriptionsData[key].json = JSON.parse(text); + if (this.inscriptionsData[key].json['p']) { + this.inscriptionsData[key].tag = this.inscriptionsData[key].json['p'].toUpperCase(); + } + } catch (e) { + this.inscriptionsData[key].text = text; + } + } + }); + } + } + } + + getAmount(amount: u128 | bigint, divisibility: u8): number { + const divisor = BigInt(10) ** BigInt(divisibility); + const result = amount / divisor; + + return result <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(result) : Number.MAX_SAFE_INTEGER; + } +} diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.html b/frontend/src/app/components/transactions-list/transactions-list.component.html index 9b88678b4..26187ecde 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -81,7 +81,8 @@ - + +
@@ -96,6 +97,15 @@ + + + + + + @@ -236,7 +246,12 @@ - OP_RETURN {{ vout.scriptpubkey_asm | hex2ascii }} + OP_RETURN  + @if (vout.isRunestone) { + + } @else { + {{ vout.scriptpubkey_asm | hex2ascii }} + } {{ vout.scriptpubkey_type | scriptpubkeyType }} @@ -276,6 +291,15 @@ + + + +
+ +
diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.scss b/frontend/src/app/components/transactions-list/transactions-list.component.scss index 280e36b0f..335464060 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.scss +++ b/frontend/src/app/components/transactions-list/transactions-list.component.scss @@ -175,4 +175,15 @@ h2 { .witness-item { overflow: hidden; } -} \ No newline at end of file +} + +.badge-ord { + background-color: var(--grey); + position: relative; + top: -2px; + font-size: 81%; + border: 0; + &.primary { + background-color: var(--primary); + } +} diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index 316a6ab85..1f45d5241 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -11,6 +11,10 @@ import { BlockExtended } from '../../interfaces/node-api.interface'; import { ApiService } from '../../services/api.service'; import { PriceService } from '../../services/price.service'; import { StorageService } from '../../services/storage.service'; +import { OrdApiService } from '../../services/ord-api.service'; +import { Inscription } from '../ord-data/ord-data.component'; +import { Runestone } from '../../shared/ord/rune/runestone'; +import { Etching } from '../../shared/ord/rune/etching'; @Component({ selector: 'app-transactions-list', @@ -50,12 +54,14 @@ export class TransactionsListComponent implements OnInit, OnChanges { outputRowLimit: number = 12; showFullScript: { [vinIndex: number]: boolean } = {}; showFullWitness: { [vinIndex: number]: { [witnessIndex: number]: boolean } } = {}; + showOrdData: { [key: string]: { show: boolean; inscriptions?: Inscription[]; runestone?: Runestone, runeInfo?: { [id: string]: { etching: Etching; txid: string; } }; } } = {}; constructor( public stateService: StateService, private cacheService: CacheService, private electrsApiService: ElectrsApiService, private apiService: ApiService, + private ordApiService: OrdApiService, private assetsService: AssetsService, private ref: ChangeDetectorRef, private priceService: PriceService, @@ -239,6 +245,24 @@ export class TransactionsListComponent implements OnInit, OnChanges { tap((price) => tx['price'] = price), ).subscribe(); } + + // Check for ord data fingerprints in inputs and outputs + if (this.stateService.network !== 'liquid' && this.stateService.network !== 'liquidtestnet') { + for (let i = 0; i < tx.vin.length; i++) { + if (tx.vin[i].prevout?.scriptpubkey_type === 'v1_p2tr' && tx.vin[i].witness?.length) { + const hasAnnex = tx.vin[i].witness?.[tx.vin[i].witness.length - 1].startsWith('50'); + if (tx.vin[i].witness.length > (hasAnnex ? 2 : 1) && tx.vin[i].witness[tx.vin[i].witness.length - (hasAnnex ? 3 : 2)].includes('0063036f7264')) { + tx.vin[i].isInscription = true; + } + } + } + for (let i = 0; i < tx.vout.length; i++) { + if (tx.vout[i]?.scriptpubkey?.startsWith('6a5d')) { + tx.vout[i].isRunestone = true; + break; + } + } + } }); if (this.blockTime && this.transactions?.length && this.currency) { @@ -372,6 +396,40 @@ export class TransactionsListComponent implements OnInit, OnChanges { this.showFullWitness[vinIndex][witnessIndex] = !this.showFullWitness[vinIndex][witnessIndex]; } + toggleOrdData(txid: string, type: 'vin' | 'vout', index: number) { + const tx = this.transactions.find((tx) => tx.txid === txid); + if (!tx) { + return; + } + + const key = tx.txid + '-' + type + '-' + index; + this.showOrdData[key] = this.showOrdData[key] || { show: false }; + + if (type === 'vin') { + + if (!this.showOrdData[key].inscriptions) { + const hasAnnex = tx.vin[index].witness?.[tx.vin[index].witness.length - 1].startsWith('50'); + this.showOrdData[key].inscriptions = this.ordApiService.decodeInscriptions(tx.vin[index].witness[tx.vin[index].witness.length - (hasAnnex ? 3 : 2)]); + } + this.showOrdData[key].show = !this.showOrdData[key].show; + + } else if (type === 'vout') { + + if (!this.showOrdData[key].runestone) { + this.ordApiService.decodeRunestone$(tx).pipe( + tap((runestone) => { + if (runestone) { + Object.assign(this.showOrdData[key], runestone); + this.ref.markForCheck(); + } + }), + ).subscribe(); + } + this.showOrdData[key].show = !this.showOrdData[key].show; + + } + } + ngOnDestroy(): void { this.outspendsSubscription.unsubscribe(); this.currencyChangeSubscription?.unsubscribe(); diff --git a/frontend/src/app/interfaces/electrs.interface.ts b/frontend/src/app/interfaces/electrs.interface.ts index 5bc5bfc1d..95a749b60 100644 --- a/frontend/src/app/interfaces/electrs.interface.ts +++ b/frontend/src/app/interfaces/electrs.interface.ts @@ -74,6 +74,8 @@ export interface Vin { issuance?: Issuance; // Custom lazy?: boolean; + // Ord + isInscription?: boolean; } interface Issuance { @@ -98,6 +100,8 @@ export interface Vout { valuecommitment?: number; asset?: string; pegout?: Pegout; + // Ord + isRunestone?: boolean; } interface Pegout { diff --git a/frontend/src/app/services/electrs-api.service.ts b/frontend/src/app/services/electrs-api.service.ts index 8e991782b..f1468f8aa 100644 --- a/frontend/src/app/services/electrs-api.service.ts +++ b/frontend/src/app/services/electrs-api.service.ts @@ -107,6 +107,10 @@ export class ElectrsApiService { return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/block-height/' + height, {responseType: 'text'}); } + getBlockTxId$(hash: string, index: number): Observable { + return this.httpClient.get(this.apiBaseUrl + this.apiBasePath + '/api/block/' + hash + '/txid/' + index, { responseType: 'text' }); + } + getAddress$(address: string): Observable
{ return this.httpClient.get
(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address); } diff --git a/frontend/src/app/services/ord-api.service.ts b/frontend/src/app/services/ord-api.service.ts new file mode 100644 index 000000000..bc726e839 --- /dev/null +++ b/frontend/src/app/services/ord-api.service.ts @@ -0,0 +1,114 @@ +import { Injectable } from '@angular/core'; +import { catchError, forkJoin, map, Observable, of, switchMap, tap } from 'rxjs'; +import { Inscription } from '../components/ord-data/ord-data.component'; +import { Transaction } from '../interfaces/electrs.interface'; +import { getNextInscriptionMark, hexToBytes, extractInscriptionData } from '../shared/ord/inscription.utils'; +import { Runestone } from '../shared/ord/rune/runestone'; +import { Etching } from '../shared/ord/rune/etching'; +import { ElectrsApiService } from './electrs-api.service'; +import { UNCOMMON_GOODS } from '../shared/ord/rune/runestone'; + +@Injectable({ + providedIn: 'root' +}) +export class OrdApiService { + + constructor( + private electrsApiService: ElectrsApiService, + ) { } + + decodeRunestone$(tx: Transaction): Observable<{ runestone: Runestone, runeInfo: { [id: string]: { etching: Etching; txid: string; } } }> { + const runestoneTx = { vout: tx.vout.map(vout => ({ scriptpubkey: vout.scriptpubkey })) }; + const decipher = Runestone.decipher(runestoneTx); + + // For now, ignore cenotaphs + let message = decipher.isSome() ? decipher.unwrap() : null; + if (message?.type === 'cenotaph') { + return of({ runestone: null, runeInfo: {} }); + } + + const runestone = message as Runestone; + const runeInfo: { [id: string]: { etching: Etching; txid: string; } } = {}; + const runesToFetch: Set = new Set(); + + if (runestone) { + if (runestone.mint.isSome()) { + const mint = runestone.mint.unwrap().toString(); + + if (mint === '1:0') { + runeInfo[mint] = { etching: UNCOMMON_GOODS, txid: '0000000000000000000000000000000000000000000000000000000000000000' }; + } else { + runesToFetch.add(mint); + } + } + + if (runestone.edicts.length) { + runestone.edicts.forEach(edict => { + runesToFetch.add(edict.id.toString()); + }); + } + + if (runesToFetch.size) { + const runeEtchingObservables = Array.from(runesToFetch).map(runeId => { + return this.getEtchingFromRuneId$(runeId).pipe( + tap(etching => { + if (etching) { + runeInfo[runeId] = etching; + } + }) + ); + }); + + return forkJoin(runeEtchingObservables).pipe( + map(() => { + return { runestone: runestone, runeInfo }; + }) + ); + } + } + + return of({ runestone: runestone, runeInfo }); + } + + // Get etching from runeId by looking up the transaction that etched the rune + getEtchingFromRuneId$(runeId: string): Observable<{ etching: Etching; txid: string; }> { + const [blockNumber, txIndex] = runeId.split(':'); + + return this.electrsApiService.getBlockHashFromHeight$(parseInt(blockNumber)).pipe( + switchMap(blockHash => this.electrsApiService.getBlockTxId$(blockHash, parseInt(txIndex))), + switchMap(txId => this.electrsApiService.getTransaction$(txId)), + switchMap(tx => { + const decipheredMessage = Runestone.decipher(tx); + if (decipheredMessage.isSome()) { + const message = decipheredMessage.unwrap(); + if (message?.type === 'runestone' && message.etching.isSome()) { + return of({ etching: message.etching.unwrap(), txid: tx.txid }); + } + } + return of(null); + }), + catchError(() => of(null)) + ); + } + + decodeInscriptions(witness: string): Inscription[] | null { + + const inscriptions: Inscription[] = []; + const raw = hexToBytes(witness); + let startPosition = 0; + + while (true) { + const pointer = getNextInscriptionMark(raw, startPosition); + if (pointer === -1) break; + + const inscription = extractInscriptionData(raw, pointer); + if (inscription) { + inscriptions.push(inscription); + } + + startPosition = pointer; + } + + return inscriptions; + } +} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 92b461548..25a60a70f 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -102,6 +102,7 @@ import { AccelerationsListComponent } from '../components/acceleration/accelerat import { PendingStatsComponent } from '../components/acceleration/pending-stats/pending-stats.component'; import { AccelerationStatsComponent } from '../components/acceleration/acceleration-stats/acceleration-stats.component'; import { AccelerationSparklesComponent } from '../components/acceleration/sparkles/acceleration-sparkles.component'; +import { OrdDataComponent } from '../components/ord-data/ord-data.component'; import { BlockViewComponent } from '../components/block-view/block-view.component'; import { EightBlocksComponent } from '../components/eight-blocks/eight-blocks.component'; @@ -229,6 +230,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir AccelerationStatsComponent, PendingStatsComponent, AccelerationSparklesComponent, + OrdDataComponent, HttpErrorComponent, TwitterWidgetComponent, FaucetComponent, @@ -361,6 +363,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir AccelerationStatsComponent, PendingStatsComponent, AccelerationSparklesComponent, + OrdDataComponent, HttpErrorComponent, TwitterWidgetComponent, TwitterLogin, From acae5a33b08fe4bd0edb7560cd41b0ac5a26f273 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 8 Oct 2024 01:41:35 +0000 Subject: [PATCH 04/14] replace rune parsing dependencies with minimal reimplementation --- .../ord-data/ord-data.component.html | 30 +- .../components/ord-data/ord-data.component.ts | 72 +--- .../transactions-list.component.ts | 7 +- frontend/src/app/services/ord-api.service.ts | 41 +- frontend/src/app/shared/ord/rune.utils.ts | 258 ++++++++++++ frontend/src/app/shared/ord/rune/artifact.ts | 4 - frontend/src/app/shared/ord/rune/cenotaph.ts | 14 - frontend/src/app/shared/ord/rune/constants.ts | 7 - frontend/src/app/shared/ord/rune/edict.ts | 34 -- frontend/src/app/shared/ord/rune/etching.ts | 54 --- frontend/src/app/shared/ord/rune/flag.ts | 20 - frontend/src/app/shared/ord/rune/flaw.ts | 12 - .../src/app/shared/ord/rune/integer/index.ts | 4 - .../src/app/shared/ord/rune/integer/u128.ts | 176 -------- .../src/app/shared/ord/rune/integer/u32.ts | 58 --- .../src/app/shared/ord/rune/integer/u64.ts | 58 --- .../src/app/shared/ord/rune/integer/u8.ts | 58 --- frontend/src/app/shared/ord/rune/message.ts | 67 --- frontend/src/app/shared/ord/rune/monads.ts | 392 ------------------ frontend/src/app/shared/ord/rune/rune.ts | 23 - frontend/src/app/shared/ord/rune/runeid.ts | 89 ---- frontend/src/app/shared/ord/rune/runestone.ts | 258 ------------ frontend/src/app/shared/ord/rune/script.ts | 237 ----------- frontend/src/app/shared/ord/rune/seekarray.ts | 43 -- .../src/app/shared/ord/rune/spacedrune.ts | 21 - frontend/src/app/shared/ord/rune/tag.ts | 60 --- frontend/src/app/shared/ord/rune/terms.ts | 9 - frontend/src/app/shared/ord/rune/utils.ts | 6 - 28 files changed, 300 insertions(+), 1812 deletions(-) create mode 100644 frontend/src/app/shared/ord/rune.utils.ts delete mode 100644 frontend/src/app/shared/ord/rune/artifact.ts delete mode 100644 frontend/src/app/shared/ord/rune/cenotaph.ts delete mode 100644 frontend/src/app/shared/ord/rune/constants.ts delete mode 100644 frontend/src/app/shared/ord/rune/edict.ts delete mode 100644 frontend/src/app/shared/ord/rune/etching.ts delete mode 100644 frontend/src/app/shared/ord/rune/flag.ts delete mode 100644 frontend/src/app/shared/ord/rune/flaw.ts delete mode 100644 frontend/src/app/shared/ord/rune/integer/index.ts delete mode 100644 frontend/src/app/shared/ord/rune/integer/u128.ts delete mode 100644 frontend/src/app/shared/ord/rune/integer/u32.ts delete mode 100644 frontend/src/app/shared/ord/rune/integer/u64.ts delete mode 100644 frontend/src/app/shared/ord/rune/integer/u8.ts delete mode 100644 frontend/src/app/shared/ord/rune/message.ts delete mode 100644 frontend/src/app/shared/ord/rune/monads.ts delete mode 100644 frontend/src/app/shared/ord/rune/rune.ts delete mode 100644 frontend/src/app/shared/ord/rune/runeid.ts delete mode 100644 frontend/src/app/shared/ord/rune/runestone.ts delete mode 100644 frontend/src/app/shared/ord/rune/script.ts delete mode 100644 frontend/src/app/shared/ord/rune/seekarray.ts delete mode 100644 frontend/src/app/shared/ord/rune/spacedrune.ts delete mode 100644 frontend/src/app/shared/ord/rune/tag.ts delete mode 100644 frontend/src/app/shared/ord/rune/terms.ts delete mode 100644 frontend/src/app/shared/ord/rune/utils.ts diff --git a/frontend/src/app/components/ord-data/ord-data.component.html b/frontend/src/app/components/ord-data/ord-data.component.html index be9a24715..696e7ea17 100644 --- a/frontend/src/app/components/ord-data/ord-data.component.html +++ b/frontend/src/app/components/ord-data/ord-data.component.html @@ -7,23 +7,23 @@ Mint {{ minted >= 100000 ? (minted | amountShortener:undefined:undefined:true) : minted }} - + } - @if (totalSupply > -1) { - @if (premined > 0) { + @if (runestone?.etching?.supply) { + @if (runestone?.etching.premine > 0) { Premine - {{ premined >= 100000 ? (premined | amountShortener:undefined:undefined:true) : premined }} - {{ etchedSymbol }} - {{ etchedName }} - ({{ premined / totalSupply * 100 | amountShortener:0}}% of total supply) + {{ 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 { + } @else { Etching of - {{ etchedSymbol }} - {{ etchedName }} + {{ runestone.etching.symbol }} + {{ runestone.etching.spacedName }} } } @@ -36,12 +36,6 @@ } - - @if (inscriptions?.length && type === 'vin') {
@@ -68,8 +62,8 @@ } - {{ runeInfo[id]?.etching.symbol.isSome() ? runeInfo[id]?.etching.symbol.unwrap() : '' }} + {{ runeInfo[id]?.etching.symbol || '' }} - {{ runeInfo[id]?.name }} + {{ runeInfo[id]?.etching.spacedName }} \ 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 index 8d7eef973..233b8d243 100644 --- a/frontend/src/app/components/ord-data/ord-data.component.ts +++ b/frontend/src/app/components/ord-data/ord-data.component.ts @@ -1,9 +1,6 @@ import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; -import { Runestone } from '../../shared/ord/rune/runestone'; -import { Etching } from '../../shared/ord/rune/etching'; -import { u128, u32, u8 } from '../../shared/ord/rune/integer'; import { HttpErrorResponse } from '@angular/common/http'; -import { SpacedRune } from '../../shared/ord/rune/spacedrune'; +import { Runestone, Etching } from '../../shared/ord/rune.utils'; export interface Inscription { body?: Uint8Array; @@ -22,79 +19,34 @@ export interface Inscription { export class OrdDataComponent implements OnChanges { @Input() inscriptions: Inscription[]; @Input() runestone: Runestone; - @Input() runeInfo: { [id: string]: { etching: Etching; txid: string; name?: string; } }; + @Input() runeInfo: { [id: string]: { etching: Etching; txid: string } }; @Input() error: HttpErrorResponse; @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 etching - premined: number = -1; - totalSupply: number = -1; - etchedName: string; - etchedSymbol: string; // Rune transfers - transferredRunes: { key: string; etching: Etching; txid: string; name?: string; }[] = []; + transferredRunes: { key: string; etching: Etching; txid: string }[] = []; constructor() { } ngOnChanges(changes: SimpleChanges): void { if (changes.runestone && this.runestone) { - - Object.keys(this.runeInfo).forEach((key) => { - const rune = this.runeInfo[key].etching.rune.isSome() ? this.runeInfo[key].etching.rune.unwrap() : null; - const spacers = this.runeInfo[key].etching.spacers.isSome() ? this.runeInfo[key].etching.spacers.unwrap() : u32(0); - if (rune) { - this.runeInfo[key].name = new SpacedRune(rune, Number(spacers)).toString(); - } - this.transferredRunes.push({ key, ...this.runeInfo[key] }); - }); - - - if (this.runestone.mint.isSome() && this.runeInfo[this.runestone.mint.unwrap().toString()]) { - const mint = this.runestone.mint.unwrap().toString(); + this.transferredRunes = Object.entries(this.runeInfo).map(([key, runeInfo]) => ({ key, ...runeInfo })); + if (this.runestone.mint && this.runeInfo[this.runestone.mint.toString()]) { + const mint = this.runestone.mint.toString(); this.transferredRunes = this.transferredRunes.filter(rune => rune.key !== mint); - const terms = this.runeInfo[mint].etching.terms.isSome() ? this.runeInfo[mint].etching.terms.unwrap() : null; - let amount: u128; - if (terms) { - amount = terms.amount.isSome() ? terms.amount.unwrap() : u128(0); - } - const divisibility = this.runeInfo[mint].etching.divisibility.isSome() ? this.runeInfo[mint].etching.divisibility.unwrap() : u8(0); + 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); } } - - if (this.runestone.etching.isSome()) { - const etching = this.runestone.etching.unwrap(); - const rune = etching.rune.isSome() ? etching.rune.unwrap() : null; - const spacers = etching.spacers.isSome() ? etching.spacers.unwrap() : u32(0); - if (rune) { - this.etchedName = new SpacedRune(rune, Number(spacers)).toString(); - } - this.etchedSymbol = etching.symbol.isSome() ? etching.symbol.unwrap() : ''; - - const divisibility = etching.divisibility.isSome() ? etching.divisibility.unwrap() : u8(0); - const premine = etching.premine.isSome() ? etching.premine.unwrap() : u128(0); - if (premine) { - this.premined = this.getAmount(premine, divisibility); - } else { - this.premined = 0; - } - const terms = etching.terms.isSome() ? etching.terms.unwrap() : null; - let amount: u128; - if (terms) { - amount = terms.amount.isSome() ? terms.amount.unwrap() : u128(0); - if (amount) { - const cap = terms.cap.isSome() ? terms.cap.unwrap() : u128(0); - this.totalSupply = this.premined + this.getAmount(amount, divisibility) * Number(cap); - } - } else { - this.totalSupply = this.premined; - } - } } if (changes.inscriptions && this.inscriptions) { @@ -131,7 +83,7 @@ export class OrdDataComponent implements OnChanges { } } - getAmount(amount: u128 | bigint, divisibility: u8): number { + getAmount(amount: bigint, divisibility: number): number { const divisor = BigInt(10) ** BigInt(divisibility); const result = amount / divisor; 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 1f45d5241..706ee9684 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -6,15 +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 '../ord-data/ord-data.component'; -import { Runestone } from '../../shared/ord/rune/runestone'; -import { Etching } from '../../shared/ord/rune/etching'; +import { Etching, Runestone } from '../../shared/ord/rune.utils'; @Component({ selector: 'app-transactions-list', @@ -261,7 +260,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { tx.vout[i].isRunestone = true; break; } - } + } } }); diff --git a/frontend/src/app/services/ord-api.service.ts b/frontend/src/app/services/ord-api.service.ts index bc726e839..da75a74af 100644 --- a/frontend/src/app/services/ord-api.service.ts +++ b/frontend/src/app/services/ord-api.service.ts @@ -3,10 +3,9 @@ import { catchError, forkJoin, map, Observable, of, switchMap, tap } from 'rxjs' import { Inscription } from '../components/ord-data/ord-data.component'; import { Transaction } from '../interfaces/electrs.interface'; import { getNextInscriptionMark, hexToBytes, extractInscriptionData } from '../shared/ord/inscription.utils'; -import { Runestone } from '../shared/ord/rune/runestone'; -import { Etching } from '../shared/ord/rune/etching'; +import { decipherRunestone, Runestone, Etching, UNCOMMON_GOODS } from '../shared/ord/rune.utils'; import { ElectrsApiService } from './electrs-api.service'; -import { UNCOMMON_GOODS } from '../shared/ord/rune/runestone'; + @Injectable({ providedIn: 'root' @@ -18,27 +17,16 @@ export class OrdApiService { ) { } decodeRunestone$(tx: Transaction): Observable<{ runestone: Runestone, runeInfo: { [id: string]: { etching: Etching; txid: string; } } }> { - const runestoneTx = { vout: tx.vout.map(vout => ({ scriptpubkey: vout.scriptpubkey })) }; - const decipher = Runestone.decipher(runestoneTx); - - // For now, ignore cenotaphs - let message = decipher.isSome() ? decipher.unwrap() : null; - if (message?.type === 'cenotaph') { - return of({ runestone: null, runeInfo: {} }); - } - - const runestone = message as Runestone; + const runestone = decipherRunestone(tx); const runeInfo: { [id: string]: { etching: Etching; txid: string; } } = {}; const runesToFetch: Set = new Set(); if (runestone) { - if (runestone.mint.isSome()) { - const mint = runestone.mint.unwrap().toString(); - - if (mint === '1:0') { - runeInfo[mint] = { etching: UNCOMMON_GOODS, txid: '0000000000000000000000000000000000000000000000000000000000000000' }; + if (runestone.mint) { + if (runestone.mint.toString() === '1:0') { + runeInfo[runestone.mint.toString()] = { etching: UNCOMMON_GOODS, txid: '0000000000000000000000000000000000000000000000000000000000000000' }; } else { - runesToFetch.add(mint); + runesToFetch.add(runestone.mint.toString()); } } @@ -65,9 +53,10 @@ export class OrdApiService { }) ); } + return of({ runestone: runestone, runeInfo }); + } else { + return of({ runestone: null, runeInfo: {} }); } - - return of({ runestone: runestone, runeInfo }); } // Get etching from runeId by looking up the transaction that etched the rune @@ -78,11 +67,11 @@ export class OrdApiService { switchMap(blockHash => this.electrsApiService.getBlockTxId$(blockHash, parseInt(txIndex))), switchMap(txId => this.electrsApiService.getTransaction$(txId)), switchMap(tx => { - const decipheredMessage = Runestone.decipher(tx); - if (decipheredMessage.isSome()) { - const message = decipheredMessage.unwrap(); - if (message?.type === 'runestone' && message.etching.isSome()) { - return of({ etching: message.etching.unwrap(), txid: tx.txid }); + const runestone = decipherRunestone(tx); + if (runestone) { + const etching = runestone.etching; + if (etching) { + return of({ etching, txid: tx.txid }); } } return of(null); 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..a1f947b46 --- /dev/null +++ b/frontend/src/app/shared/ord/rune.utils.ts @@ -0,0 +1,258 @@ +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: { + block: height, + index: 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]); + etching = { + divisibility: Number(message.fields[Tag.Divisibility][0]), + 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/ord/rune/artifact.ts b/frontend/src/app/shared/ord/rune/artifact.ts deleted file mode 100644 index 2eba9f158..000000000 --- a/frontend/src/app/shared/ord/rune/artifact.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Cenotaph } from './cenotaph'; -import { Runestone } from './runestone'; - -export type Artifact = Cenotaph | Runestone; diff --git a/frontend/src/app/shared/ord/rune/cenotaph.ts b/frontend/src/app/shared/ord/rune/cenotaph.ts deleted file mode 100644 index 368a0f938..000000000 --- a/frontend/src/app/shared/ord/rune/cenotaph.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Flaw } from './flaw'; -import { None, Option } from './monads'; -import { Rune } from './rune'; -import { RuneId } from './runeid'; - -export class Cenotaph { - readonly type = 'cenotaph'; - - constructor( - readonly flaws: Flaw[], - readonly etching: Option = None, - readonly mint: Option = None - ) {} -} diff --git a/frontend/src/app/shared/ord/rune/constants.ts b/frontend/src/app/shared/ord/rune/constants.ts deleted file mode 100644 index 0e4bab116..000000000 --- a/frontend/src/app/shared/ord/rune/constants.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { u8 } from './integer'; -import { opcodes } from './script'; - -export const MAX_DIVISIBILITY = u8(38); - -export const OP_RETURN = opcodes.OP_RETURN; -export const MAGIC_NUMBER = opcodes.OP_13; diff --git a/frontend/src/app/shared/ord/rune/edict.ts b/frontend/src/app/shared/ord/rune/edict.ts deleted file mode 100644 index ede5865a6..000000000 --- a/frontend/src/app/shared/ord/rune/edict.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Option, Some, None } from './monads'; -import { RuneId } from './runeid'; -import { u128, u32 } from './integer'; - -export type Edict = { - id: RuneId; - amount: u128; - output: u32; -}; - -export namespace Edict { - export function fromIntegers( - numOutputs: number, - id: RuneId, - amount: u128, - output: u128 - ): Option { - if (id.block === 0n && id.tx > 0n) { - return None; - } - - const optionOutputU32 = u128.tryIntoU32(output); - if (optionOutputU32.isNone()) { - return None; - } - const outputU32 = optionOutputU32.unwrap(); - - if (outputU32 > numOutputs) { - return None; - } - - return Some({ id, amount, output: outputU32 }); - } -} diff --git a/frontend/src/app/shared/ord/rune/etching.ts b/frontend/src/app/shared/ord/rune/etching.ts deleted file mode 100644 index edc245565..000000000 --- a/frontend/src/app/shared/ord/rune/etching.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { None, Option, Some } from './monads'; -import { Terms } from './terms'; -import { Rune } from './rune'; -import { u128, u32, u8 } from './integer'; - -type RuneEtchingBase = { - divisibility?: number; - premine?: bigint; - symbol?: string; - terms?: { - cap?: bigint; - amount?: bigint; - offset?: { - start?: bigint; - end?: bigint; - }; - height?: { - start?: bigint; - end?: bigint; - }; - }; - turbo?: boolean; -}; - -export type RuneEtchingSpec = RuneEtchingBase & { runeName?: string }; - -export class Etching { - readonly symbol: Option; - - constructor( - readonly divisibility: Option, - readonly rune: Option, - readonly spacers: Option, - symbol: Option, - readonly terms: Option, - readonly premine: Option, - readonly turbo: boolean - ) { - this.symbol = symbol.andThen((value) => { - const codePoint = value.codePointAt(0); - return codePoint !== undefined ? Some(String.fromCodePoint(codePoint)) : None; - }); - } - - get supply(): Option { - const premine = this.premine.unwrapOr(u128(0)); - const cap = this.terms.andThen((terms) => terms.cap).unwrapOr(u128(0)); - const amount = this.terms.andThen((terms) => terms.amount).unwrapOr(u128(0)); - - return u128 - .checkedMultiply(cap, amount) - .andThen((multiplyResult) => u128.checkedAdd(premine, multiplyResult)); - } -} diff --git a/frontend/src/app/shared/ord/rune/flag.ts b/frontend/src/app/shared/ord/rune/flag.ts deleted file mode 100644 index 317c74ae5..000000000 --- a/frontend/src/app/shared/ord/rune/flag.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { u128 } from './integer'; - -export enum Flag { - ETCHING = 0, - TERMS = 1, - TURBO = 2, - CENOTAPH = 127, -} - -export namespace Flag { - export function mask(flag: Flag): u128 { - return u128(1n << BigInt(flag)); - } - - export function take(flags: u128, flag: Flag): { set: boolean; flags: u128 } { - const mask = Flag.mask(flag); - const set = (flags & mask) !== 0n; - return { set, flags: set ? u128(flags - mask) : flags }; - } -} diff --git a/frontend/src/app/shared/ord/rune/flaw.ts b/frontend/src/app/shared/ord/rune/flaw.ts deleted file mode 100644 index 2ed5ea506..000000000 --- a/frontend/src/app/shared/ord/rune/flaw.ts +++ /dev/null @@ -1,12 +0,0 @@ -export enum Flaw { - EDICT_OUTPUT, - EDICT_RUNE_ID, - INVALID_SCRIPT, - OPCODE, - SUPPLY_OVERFLOW, - TRAILING_INTEGERS, - TRUNCATED_FIELD, - UNRECOGNIZED_EVEN_TAG, - UNRECOGNIZED_FLAG, - VARINT, -} diff --git a/frontend/src/app/shared/ord/rune/integer/index.ts b/frontend/src/app/shared/ord/rune/integer/index.ts deleted file mode 100644 index 3c54a77e7..000000000 --- a/frontend/src/app/shared/ord/rune/integer/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { u8 } from './u8'; -export { u32 } from './u32'; -export { u64 } from './u64'; -export { u128 } from './u128'; diff --git a/frontend/src/app/shared/ord/rune/integer/u128.ts b/frontend/src/app/shared/ord/rune/integer/u128.ts deleted file mode 100644 index 78de8506f..000000000 --- a/frontend/src/app/shared/ord/rune/integer/u128.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { None, Option, Some } from '../monads'; -import { SeekArray } from '../seekarray'; -import { u64 } from './u64'; -import { u32 } from './u32'; -import { u8 } from './u8'; - -/** - * A little utility type used for nominal typing. - * - * See {@link https://michalzalecki.com/nominal-typing-in-typescript/} - */ -type BigTypedNumber = bigint & { - /** - * # !!! DO NOT USE THIS PROPERTY IN YOUR CODE !!! - * ## This is just used to make each `BigTypedNumber` alias unique for Typescript and doesn't actually exist. - * @ignore - * @private - * @readonly - * @type {undefined} - */ - readonly __kind__: T; -}; - -/** - * ## 128-bit unsigned integer - * - * - **Value Range:** `0` to `340282366920938463463374607431768211455` - * - **Size in bytes:** `16` - * - **Web IDL type:** `bigint` - * - **Equivalent C type:** `uint128_t` - */ -export type u128 = BigTypedNumber<'u128'>; - -export const U128_MAX_BIGINT = 0xffff_ffff_ffff_ffff_ffff_ffff_ffff_ffffn; - -/** - * Convert Number or BigInt to 128-bit unsigned integer. - * @param num - The Number or BigInt to convert. - * @returns - The resulting 128-bit unsigned integer (BigInt). - */ -export function u128(num: number | bigint): u128 { - if (typeof num == 'bigint') { - if (num < 0n || num > U128_MAX_BIGINT) { - throw new Error('num is out of range'); - } - } else { - if (!Number.isSafeInteger(num) || num < 0) { - throw new Error('num is not a valid integer'); - } - } - - return BigInt(num) as u128; -} - -export namespace u128 { - export const MAX = u128(U128_MAX_BIGINT); - - export function checkedAdd(x: u128, y: u128): Option { - const result = x + y; - if (result > u128.MAX) { - return None; - } - - return Some(u128(result)); - } - - export function checkedAddThrow(x: u128, y: u128): u128 { - const option = u128.checkedAdd(x, y); - if (option.isNone()) { - throw new Error('checked add overflow'); - } - return option.unwrap(); - } - - export function checkedSub(x: u128, y: u128): Option { - const result = x - y; - if (result < 0n) { - return None; - } - - return Some(u128(result)); - } - - export function checkedSubThrow(x: u128, y: u128): u128 { - const option = u128.checkedSub(x, y); - if (option.isNone()) { - throw new Error('checked sub overflow'); - } - return option.unwrap(); - } - - export function checkedMultiply(x: u128, y: u128): Option { - const result = x * y; - if (result > u128.MAX) { - return None; - } - - return Some(u128(result)); - } - - export function saturatingAdd(x: u128, y: u128): u128 { - const result = x + y; - return result > u128.MAX ? u128.MAX : u128(result); - } - - export function saturatingMultiply(x: u128, y: u128): u128 { - const result = x * y; - return result > u128.MAX ? u128.MAX : u128(result); - } - - export function saturatingSub(x: u128, y: u128): u128 { - return u128(x < y ? 0 : x - y); - } - - export function decodeVarInt(seekArray: SeekArray): Option { - try { - return Some(tryDecodeVarInt(seekArray)); - } catch (e) { - return None; - } - } - - export function tryDecodeVarInt(seekArray: SeekArray): u128 { - let result: u128 = u128(0); - for (let i = 0; i <= 18; i++) { - const byte = seekArray.readUInt8(); - if (byte === undefined) throw new Error('Unterminated or invalid data'); - - // Ensure all operations are done in bigint domain. - const byteBigint = BigInt(byte); - const value = u128(byteBigint & 0x7Fn); // Ensure the 'value' is treated as u128. - - if (i === 18 && (value & 0x7Cn) !== 0n) throw new Error('Overflow'); - - // Use bigint addition instead of bitwise OR to combine the results, - // and ensure shifting is handled correctly within the bigint domain. - result = u128(result + (value << (7n * BigInt(i)))); - - if ((byte & 0x80) === 0) return result; - } - throw new Error('Overlong encoding'); - } - - export function encodeVarInt(value: u128): Uint8Array { - const bytes = []; - while (value >> 7n > 0n) { - bytes.push(Number(value & 0x7Fn) | 0x80); - value = u128(value >> 7n); // Explicitly cast the shifted value back to u128 - } - bytes.push(Number(value & 0x7Fn)); - return new Uint8Array(bytes); - } - - export function tryIntoU64(n: u128): Option { - return n > u64.MAX ? None : Some(u64(n)); - } - - export function tryIntoU32(n: u128): Option { - return n > u32.MAX ? None : Some(u32(n)); - } - - export function tryIntoU8(n: u128): Option { - return n > u8.MAX ? None : Some(u8(n)); - } -} - -export function* getAllU128(data: Uint8Array): Generator { - const seekArray = new SeekArray(data); - while (!seekArray.isFinished()) { - const nextValue = u128.decodeVarInt(seekArray); - if (nextValue.isNone()) { - return; - } - yield nextValue.unwrap(); - } -} diff --git a/frontend/src/app/shared/ord/rune/integer/u32.ts b/frontend/src/app/shared/ord/rune/integer/u32.ts deleted file mode 100644 index 90e517bb8..000000000 --- a/frontend/src/app/shared/ord/rune/integer/u32.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { None, Option, Some } from '../monads'; - -/** - * A little utility type used for nominal typing. - * - * See {@link https://michalzalecki.com/nominal-typing-in-typescript/} - */ -type BigTypedNumber = bigint & { - /** - * # !!! DO NOT USE THIS PROPERTY IN YOUR CODE !!! - * ## This is just used to make each `BigTypedNumber` alias unique for Typescript and doesn't actually exist. - * @ignore - * @private - * @readonly - * @type {undefined} - */ - readonly __kind__: T; -}; - -export type u32 = BigTypedNumber<'u32'>; - -export const U32_MAX_BIGINT = 0xffff_ffffn; - -export function u32(num: number | bigint): u32 { - if (typeof num == 'bigint') { - if (num < 0n || num > U32_MAX_BIGINT) { - throw new Error('num is out of range'); - } - } else { - if (!Number.isSafeInteger(num) || num < 0) { - throw new Error('num is not a valid integer'); - } - } - - return BigInt(num) as u32; -} - -export namespace u32 { - export const MAX = u32(U32_MAX_BIGINT); - - export function checkedAdd(x: u32, y: u32): Option { - const result = x + y; - if (result > u32.MAX) { - return None; - } - - return Some(u32(result)); - } - - export function checkedSub(x: u32, y: u32): Option { - const result = x - y; - if (result < 0n) { - return None; - } - - return Some(u32(result)); - } -} diff --git a/frontend/src/app/shared/ord/rune/integer/u64.ts b/frontend/src/app/shared/ord/rune/integer/u64.ts deleted file mode 100644 index 8010dd99c..000000000 --- a/frontend/src/app/shared/ord/rune/integer/u64.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { None, Option, Some } from '../monads'; - -/** - * A little utility type used for nominal typing. - * - * See {@link https://michalzalecki.com/nominal-typing-in-typescript/} - */ -type BigTypedNumber = bigint & { - /** - * # !!! DO NOT USE THIS PROPERTY IN YOUR CODE !!! - * ## This is just used to make each `BigTypedNumber` alias unique for Typescript and doesn't actually exist. - * @ignore - * @private - * @readonly - * @type {undefined} - */ - readonly __kind__: T; -}; - -export type u64 = BigTypedNumber<'u64'>; - -export const U64_MAX_BIGINT = 0xffff_ffff_ffff_ffffn; - -export function u64(num: number | bigint): u64 { - if (typeof num == 'bigint') { - if (num < 0n || num > U64_MAX_BIGINT) { - throw new Error('num is out of range'); - } - } else { - if (!Number.isSafeInteger(num) || num < 0) { - throw new Error('num is not a valid integer'); - } - } - - return BigInt(num) as u64; -} - -export namespace u64 { - export const MAX = u64(U64_MAX_BIGINT); - - export function checkedAdd(x: u64, y: u64): Option { - const result = x + y; - if (result > u64.MAX) { - return None; - } - - return Some(u64(result)); - } - - export function checkedSub(x: u64, y: u64): Option { - const result = x - y; - if (result < 0n) { - return None; - } - - return Some(u64(result)); - } -} diff --git a/frontend/src/app/shared/ord/rune/integer/u8.ts b/frontend/src/app/shared/ord/rune/integer/u8.ts deleted file mode 100644 index 5676421b0..000000000 --- a/frontend/src/app/shared/ord/rune/integer/u8.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { None, Option, Some } from '../monads'; - -/** - * A little utility type used for nominal typing. - * - * See {@link https://michalzalecki.com/nominal-typing-in-typescript/} - */ -type BigTypedNumber = bigint & { - /** - * # !!! DO NOT USE THIS PROPERTY IN YOUR CODE !!! - * ## This is just used to make each `BigTypedNumber` alias unique for Typescript and doesn't actually exist. - * @ignore - * @private - * @readonly - * @type {undefined} - */ - readonly __kind__: T; -}; - -export type u8 = BigTypedNumber<'u8'>; - -export const U8_MAX_BIGINT = 0xffn; - -export function u8(num: number | bigint): u8 { - if (typeof num == 'bigint') { - if (num < 0n || num > U8_MAX_BIGINT) { - throw new Error('num is out of range'); - } - } else { - if (!Number.isSafeInteger(num) || num < 0) { - throw new Error('num is not a valid integer'); - } - } - - return BigInt(num) as u8; -} - -export namespace u8 { - export const MAX = u8(U8_MAX_BIGINT); - - export function checkedAdd(x: u8, y: u8): Option { - const result = x + y; - if (result > u8.MAX) { - return None; - } - - return Some(u8(result)); - } - - export function checkedSub(x: u8, y: u8): Option { - const result = x - y; - if (result < 0n) { - return None; - } - - return Some(u8(result)); - } -} diff --git a/frontend/src/app/shared/ord/rune/message.ts b/frontend/src/app/shared/ord/rune/message.ts deleted file mode 100644 index cad1a8ced..000000000 --- a/frontend/src/app/shared/ord/rune/message.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Edict } from './edict'; -import { Flaw } from './flaw'; -import { u128, u64, u32 } from './integer'; -import { RuneId } from './runeid'; -import { Tag } from './tag'; - -export class Message { - constructor( - readonly flaws: Flaw[], - readonly edicts: Edict[], - readonly fields: Map - ) {} - - static fromIntegers(numOutputs: number, payload: u128[]): Message { - const edicts: Edict[] = []; - const fields = new Map(); - const flaws: Flaw[] = []; - - for (const i of [...Array(Math.ceil(payload.length / 2)).keys()].map((n) => n * 2)) { - const tag = payload[i]; - - if (u128(Tag.BODY) === tag) { - let id = new RuneId(u64(0), u32(0)); - const chunkSize = 4; - - const body = payload.slice(i + 1); - for (let j = 0; j < body.length; j += chunkSize) { - const chunk = body.slice(j, j + chunkSize); - if (chunk.length !== chunkSize) { - flaws.push(Flaw.TRAILING_INTEGERS); - break; - } - - const optionNext = id.next(chunk[0], chunk[1]); - if (optionNext.isNone()) { - flaws.push(Flaw.EDICT_RUNE_ID); - break; - } - const next = optionNext.unwrap(); - - const optionEdict = Edict.fromIntegers(numOutputs, next, chunk[2], chunk[3]); - if (optionEdict.isNone()) { - flaws.push(Flaw.EDICT_OUTPUT); - break; - } - const edict = optionEdict.unwrap(); - - id = next; - edicts.push(edict); - } - break; - } - - const value = payload[i + 1]; - if (value === undefined) { - flaws.push(Flaw.TRUNCATED_FIELD); - break; - } - - const values = fields.get(tag) ?? []; - values.push(value); - fields.set(tag, values); - } - - return new Message(flaws, edicts, fields); - } -} diff --git a/frontend/src/app/shared/ord/rune/monads.ts b/frontend/src/app/shared/ord/rune/monads.ts deleted file mode 100644 index 7822acca9..000000000 --- a/frontend/src/app/shared/ord/rune/monads.ts +++ /dev/null @@ -1,392 +0,0 @@ -// Copied with MIT License from link below: -// https://github.com/thames-technology/monads/blob/de957d3d68449d659518d99be4ea74bbb70dfc8e/src/option/option.ts - -/** - * Type representing any value except 'undefined'. - * This is useful when working with strict null checks, ensuring that a value can be null but not undefined. - */ -type NonUndefined = {} | null; // eslint-disable-line @typescript-eslint/ban-types - -/** - * Enum-like object to represent the type of an Option (Some or None). - */ -export const OptionType = { - Some: Symbol(':some'), - None: Symbol(':none'), -}; - -/** - * Interface for handling match operations on an Option. - * Allows executing different logic based on the Option being Some or None. - */ -interface Match { - some: (val: A) => B; - none: (() => B) | B; -} - -/** - * The Option interface representing an optional value. - * An Option is either Some, holding a value, or None, indicating the absence of a value. - */ -export interface Option { - /** - * Represents the type of the Option: either Some or None. Useful for debugging and runtime checks. - */ - type: symbol; - - /** - * Determines if the Option is a Some. - * - * @returns true if the Option is Some, otherwise false. - * - * #### Example - * - * ```ts - * console.log(Some(5).isSome()); // true - * console.log(None.isSome()); // false - * ``` - */ - isSome(): boolean; - - /** - * Determines if the Option is None. - * - * @returns true if the Option is None, otherwise false. - * - * #### Example - * - * ```ts - * console.log(Some(5).isNone()); // false - * console.log(None.isNone()); // true - * ``` - */ - isNone(): boolean; - - /** - * Performs a match operation on the Option, allowing for branching logic based on its state. - * This method takes an object with functions for each case (Some or None) and executes - * the corresponding function based on the Option's state, returning the result. - * - * @param fn An object containing two properties: `some` and `none`, which are functions - * to handle the Some and None cases, respectively. - * @returns The result of applying the corresponding function based on the Option's state. - * - * #### Example - * - * ```ts - * const optionSome = Some(5); - * const matchResultSome = optionSome.match({ - * some: (value) => `The value is ${value}.`, - * none: () => 'There is no value.', - * }); - * console.log(matchResultSome); // Outputs: "The value is 5." - * - * const optionNone = None; - * const matchResultNone = optionNone.match({ - * some: (value) => `The value is ${value}.`, - * none: () => 'There is no value.', - * }); - * console.log(matchResultNone); // Outputs: "There is no value." - * ``` - */ - match(fn: Match): U; - - /** - * Applies a function to the contained value (if any), or returns a default if None. - * - * @param fn A function that takes a value of type T and returns a value of type U. - * @returns An Option containing the function's return value if the original Option is Some, otherwise None. - * - * #### Examples - * - * ```ts - * const length = Some("hello").map(s => s.length); // Some(5) - * const noneLength = None.map(s => s.length); // None - * ``` - */ - map(fn: (val: T) => U): Option; - - inspect(fn: (val: T) => void): Option; - - /** - * Transforms the Option into another by applying a function to the contained value, - * chaining multiple potentially failing operations. - * - * @param fn A function that takes a value of type T and returns an Option of type U. - * @returns The Option returned by the function if the original Option is Some, otherwise None. - * - * #### Examples - * - * ```ts - * const parse = (s: string) => { - * const parsed = parseInt(s); - * return isNaN(parsed) ? None : Some(parsed); - * }; - * const result = Some("123").andThen(parse); // Some(123) - * const noResult = Some("abc").andThen(parse); // None - * ``` - */ - andThen(fn: (val: T) => Option): Option; - - /** - * Returns this Option if it is Some, otherwise returns the option provided as a parameter. - * - * @param optb The alternative Option to return if the original Option is None. - * @returns The original Option if it is Some, otherwise `optb`. - * - * #### Examples - * - * ```ts - * const defaultOption = Some("default"); - * const someOption = Some("some").or(defaultOption); // Some("some") - * const noneOption = None.or(defaultOption); // Some("default") - * ``` - */ - or(optb: Option): Option; - - orElse(optb: () => Option): Option; - - /** - * Returns the option provided as a parameter if the original Option is Some, otherwise returns None. - * - * @param optb The Option to return if the original Option is Some. - * @returns `optb` if the original Option is Some, otherwise None. - * - * #### Examples - * - * ```ts - * const anotherOption = Some("another"); - * const someOption = Some("some").and(anotherOption); // Some("another") - * const noneOption = None.and(anotherOption); // None - * ``` - */ - and(optb: Option): Option; - - /** - * Returns the contained value if Some, otherwise returns the provided default value. - * - * @param def The default value to return if the Option is None. - * @returns The contained value if Some, otherwise `def`. - * - * #### Examples - * - * ```ts - * const someValue = Some("value").unwrapOr("default"); // "value" - * const noneValue = None.unwrapOr("default"); // "default" - * ``` - */ - unwrapOr(def: T): T; - - /** - * Unwraps an Option, yielding the contained value if Some, otherwise throws an error. - * - * @returns The contained value. - * @throws Error if the Option is None. - * - * #### Examples - * - * ```ts - * console.log(Some("value").unwrap()); // "value" - * console.log(None.unwrap()); // throws Error - * ``` - */ - unwrap(): T | never; -} - -/** - * Implementation of Option representing a value (Some). - */ -interface SomeOption extends Option { - unwrap(): T; -} - -/** - * Implementation of Option representing the absence of a value (None). - */ -interface NoneOption extends Option { - unwrap(): never; -} - -/** - * Represents a Some value of Option. - */ -class SomeImpl implements SomeOption { - constructor(private readonly val: T) {} - - get type() { - return OptionType.Some; - } - - isSome() { - return true; - } - - isNone() { - return false; - } - - match(fn: Match): B { - return fn.some(this.val); - } - - map(fn: (val: T) => U): Option { - return Some(fn(this.val)); - } - - inspect(fn: (val: T) => void): Option { - fn(this.val); - return this; - } - - andThen(fn: (val: T) => Option): Option { - return fn(this.val); - } - - or(_optb: Option): Option { - return this; - } - - orElse(optb: () => Option): Option { - return this; - } - - and(optb: Option): Option { - return optb; - } - - unwrapOr(_def: T): T { - return this.val; - } - - unwrap(): T { - return this.val; - } -} - -/** - * Represents a None value of Option. - */ -class NoneImpl implements NoneOption { - get type() { - return OptionType.None; - } - - isSome() { - return false; - } - - isNone() { - return true; - } - - match({ none }: Match): U { - if (typeof none === 'function') { - return (none as () => U)(); - } - - return none; - } - - map(_fn: (val: T) => U): Option { - return new NoneImpl(); - } - - inspect(fn: (val: T) => void): Option { - return this; - } - - andThen(_fn: (val: T) => Option): Option { - return new NoneImpl(); - } - - or(optb: Option): Option { - return optb; - } - - orElse(optb: () => Option): Option { - return optb(); - } - - and(_optb: Option): Option { - return new NoneImpl(); - } - - unwrapOr(def: T): T { - return def; - } - - unwrap(): never { - throw new ReferenceError('Trying to unwrap None.'); - } -} - -/** - * Creates a Some instance of Option containing the given value. - * This function is used to represent the presence of a value in an operation that may not always produce a value. - * - * @param val The value to be wrapped in a Some Option. - * @returns An Option instance representing the presence of a value. - * - * #### Example - * - * ```ts - * const option = Some(42); - * console.log(option.unwrap()); // Outputs: 42 - * ``` - */ -export function Some(val: T): Option { - return new SomeImpl(val); -} - -/** - * The singleton instance representing None, an Option with no value. - * This constant is used to represent the absence of a value in operations that may not always produce a value. - * - * #### Example - * - * ```ts - * const option = None; - * console.log(option.isNone()); // Outputs: true - * ``` - */ -export const None: Option = new NoneImpl(); // eslint-disable-line @typescript-eslint/no-explicit-any - -/** - * Type guard to check if an Option is a Some value. - * This function is used to narrow down the type of an Option to SomeOption in TypeScript's type system. - * - * @param val The Option to be checked. - * @returns true if the provided Option is a SomeOption, false otherwise. - * - * #### Example - * - * ```ts - * const option = Some('Success'); - * if (isSome(option)) { - * console.log('Option has a value:', option.unwrap()); - * } - * ``` - */ -export function isSome(val: Option): val is SomeOption { - return val.isSome(); -} - -/** - * Type guard to check if an Option is a None value. - * This function is used to narrow down the type of an Option to NoneOption in TypeScript's type system. - * - * @param val The Option to be checked. - * @returns true if the provided Option is a NoneOption, false otherwise. - * - * #### Example - * - * ```ts - * const option = None; - * if (isNone(option)) { - * console.log('Option does not have a value.'); - * } - * ``` - */ -export function isNone(val: Option): val is NoneOption { - return val.isNone(); -} diff --git a/frontend/src/app/shared/ord/rune/rune.ts b/frontend/src/app/shared/ord/rune/rune.ts deleted file mode 100644 index c0dd96e1b..000000000 --- a/frontend/src/app/shared/ord/rune/rune.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { u128 } from './integer'; - -export class Rune { - - constructor(readonly value: u128) {} - - toString() { - let n = this.value; - - if (n === u128.MAX) { - return 'BCGDENLQRQWDSLRUGSNLBTMFIJAV'; - } - - n = u128(n + 1n); - let symbol = ''; - while (n > 0) { - symbol = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Number((n - 1n) % 26n)] + symbol; - n = u128((n - 1n) / 26n); - } - - return symbol; - } -} diff --git a/frontend/src/app/shared/ord/rune/runeid.ts b/frontend/src/app/shared/ord/rune/runeid.ts deleted file mode 100644 index ca0e938b7..000000000 --- a/frontend/src/app/shared/ord/rune/runeid.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { None, Option, Some } from './monads'; -import { u64, u32, u128 } from './integer'; - -export class RuneId { - constructor(readonly block: u64, readonly tx: u32) {} - - static new(block: u64, tx: u32): Option { - const id = new RuneId(block, tx); - - if (id.block === 0n && id.tx > 0) { - return None; - } - - return Some(id); - } - - static sort(runeIds: RuneId[]): RuneId[] { - return [...runeIds].sort((x, y) => Number(x.block - y.block || x.tx - y.tx)); - } - - delta(next: RuneId): Option<[u128, u128]> { - const optionBlock = u64.checkedSub(next.block, this.block); - if (optionBlock.isNone()) { - return None; - } - const block = optionBlock.unwrap(); - - let tx: u32; - if (block === 0n) { - const optionTx = u32.checkedSub(next.tx, this.tx); - if (optionTx.isNone()) { - return None; - } - tx = optionTx.unwrap(); - } else { - tx = next.tx; - } - - return Some([u128(block), u128(tx)]); - } - - next(block: u128, tx: u128): Option { - const optionBlock = u128.tryIntoU64(block); - const optionTx = u128.tryIntoU32(tx); - - if (optionBlock.isNone() || optionTx.isNone()) { - return None; - } - - const blockU64 = optionBlock.unwrap(); - const txU32 = optionTx.unwrap(); - - const nextBlock = u64.checkedAdd(this.block, blockU64); - if (nextBlock.isNone()) { - return None; - } - - let nextTx: u32; - if (blockU64 === 0n) { - const optionAdd = u32.checkedAdd(this.tx, txU32); - if (optionAdd.isNone()) { - return None; - } - - nextTx = optionAdd.unwrap(); - } else { - nextTx = txU32; - } - - return RuneId.new(nextBlock.unwrap(), nextTx); - } - - toString() { - return `${this.block}:${this.tx}`; - } - - static fromString(s: string) { - const parts = s.split(':'); - if (parts.length !== 2) { - throw new Error(`invalid rune ID: ${s}`); - } - - const [block, tx] = parts; - if (!/^\d+$/.test(block) || !/^\d+$/.test(tx)) { - throw new Error(`invalid rune ID: ${s}`); - } - return new RuneId(u64(BigInt(block)), u32(BigInt(tx))); - } -} diff --git a/frontend/src/app/shared/ord/rune/runestone.ts b/frontend/src/app/shared/ord/rune/runestone.ts deleted file mode 100644 index c71cdcd90..000000000 --- a/frontend/src/app/shared/ord/rune/runestone.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { concatUint8Arrays, hexToBytes } from '../inscription.utils'; -import { Artifact } from './artifact'; -import { Cenotaph } from './cenotaph'; -import { MAGIC_NUMBER, MAX_DIVISIBILITY, OP_RETURN } from './constants'; -import { Edict } from './edict'; -import { Etching } from './etching'; -import { Flag } from './flag'; -import { Flaw } from './flaw'; -import { u128, u32, u64, u8 } from './integer'; -import { Message } from './message'; -import { None, Option, Some } from './monads'; -import { Rune } from './rune'; -import { RuneId } from './runeid'; -import { script } from './script'; -import { SeekArray } from './seekarray'; -import { Tag } from './tag'; - -export const MAX_SPACERS = 0b00000111_11111111_11111111_11111111; - -export const UNCOMMON_GOODS = new Etching( - Some(u8(0)), - Some(new Rune(u128(2055900680524219742n))), - Some(u32(128)), - Some('⧉'), - Some({ - amount: Some(u128(1)), - cap: Some(u128(340282366920938463463374607431768211455n)), - height: [Some(u64(840000)), Some(u64(1050000))], - offset: [Some(u64(0)), Some(u64(0))], - }), - Some(u128(0)), - false -); - -// New: Esplora format instead of Bitcoin RPC format -export type RunestoneTx = { - vout: { - scriptpubkey: string - }[]; -}; - -type Payload = Uint8Array | Flaw; - -export class Runestone { - readonly type = 'runestone'; - - constructor( - readonly mint: Option, - readonly pointer: Option, - readonly edicts: Edict[], - readonly etching: Option - ) {} - - static decipher(transaction: RunestoneTx): Option { - const optionPayload = Runestone.payload(transaction); - if (optionPayload.isNone()) { - return None; - } - const payload = optionPayload.unwrap(); - if (!(payload instanceof Uint8Array)) { - return Some(new Cenotaph([payload])); - } - - const optionIntegers = Runestone.integers(payload); - if (optionIntegers.isNone()) { - return Some(new Cenotaph([Flaw.VARINT])); - } - - const { flaws, edicts, fields } = Message.fromIntegers( - transaction.vout.length, - optionIntegers.unwrap() - ); - - let flags = Tag.take(Tag.FLAGS, fields, 1, ([value]) => Some(value)).unwrapOr(u128(0)); - - const etchingResult = Flag.take(flags, Flag.ETCHING); - const etchingFlag = etchingResult.set; - flags = etchingResult.flags; - - const etching: Option = etchingFlag - ? (() => { - const divisibility = Tag.take( - Tag.DIVISIBILITY, - fields, - 1, - ([value]): Option => - u128 - .tryIntoU8(value) - .andThen((value) => (value <= MAX_DIVISIBILITY ? Some(value) : None)) - ); - - const rune = Tag.take(Tag.RUNE, fields, 1, ([value]) => Some(new Rune(value))); - - const spacers = Tag.take( - Tag.SPACERS, - fields, - 1, - ([value]): Option => - u128.tryIntoU32(value).andThen((value) => (value <= MAX_SPACERS ? Some(value) : None)) - ); - - const symbol = Tag.take(Tag.SYMBOL, fields, 1, ([value]) => - u128.tryIntoU32(value).andThen((value) => { - try { - return Some(String.fromCodePoint(Number(value))); - } catch (e) { - return None; - } - }) - ); - - const termsResult = Flag.take(flags, Flag.TERMS); - const termsFlag = termsResult.set; - flags = termsResult.flags; - - const terms = termsFlag - ? (() => { - const amount = Tag.take(Tag.AMOUNT, fields, 1, ([value]) => Some(value)); - - const cap = Tag.take(Tag.CAP, fields, 1, ([value]) => Some(value)); - - const offset = [ - Tag.take(Tag.OFFSET_START, fields, 1, ([value]) => u128.tryIntoU64(value)), - Tag.take(Tag.OFFSET_END, fields, 1, ([value]) => u128.tryIntoU64(value)), - ] as const; - - const height = [ - Tag.take(Tag.HEIGHT_START, fields, 1, ([value]) => u128.tryIntoU64(value)), - Tag.take(Tag.HEIGHT_END, fields, 1, ([value]) => u128.tryIntoU64(value)), - ] as const; - - return Some({ amount, cap, offset, height }); - })() - : None; - - const premine = Tag.take(Tag.PREMINE, fields, 1, ([value]) => Some(value)); - - const turboResult = Flag.take(flags, Flag.TURBO); - const turbo = etchingResult.set; - flags = turboResult.flags; - - return Some(new Etching(divisibility, rune, spacers, symbol, terms, premine, turbo)); - })() - : None; - - const mint = Tag.take(Tag.MINT, fields, 2, ([block, tx]): Option => { - const optionBlockU64 = u128.tryIntoU64(block); - const optionTxU32 = u128.tryIntoU32(tx); - - if (optionBlockU64.isNone() || optionTxU32.isNone()) { - return None; - } - - return RuneId.new(optionBlockU64.unwrap(), optionTxU32.unwrap()); - }); - - const pointer = Tag.take( - Tag.POINTER, - fields, - 1, - ([value]): Option => - u128 - .tryIntoU32(value) - .andThen((value) => (value < transaction.vout.length ? Some(value) : None)) - ); - - if (etching.map((etching) => etching.supply.isNone()).unwrapOr(false)) { - flaws.push(Flaw.SUPPLY_OVERFLOW); - } - - if (flags !== 0n) { - flaws.push(Flaw.UNRECOGNIZED_FLAG); - } - - if ([...fields.keys()].find((tag) => tag % 2n === 0n) !== undefined) { - flaws.push(Flaw.UNRECOGNIZED_EVEN_TAG); - } - - if (flaws.length !== 0) { - return Some( - new Cenotaph( - flaws, - etching.andThen((etching) => etching.rune), - mint - ) - ); - } - - return Some(new Runestone(mint, pointer, edicts, etching)); - } - - static payload(transaction: RunestoneTx): Option { - // search transaction outputs for payload - for (const output of transaction.vout) { - const instructions = script.decompile(hexToBytes(output.scriptpubkey)); - if (instructions === null) { - throw new Error('unable to decompile'); - } - - // payload starts with OP_RETURN - let nextInstructionResult = instructions.next(); - if (nextInstructionResult.done || nextInstructionResult.value !== OP_RETURN) { - continue; - } - - // followed by the protocol identifier - nextInstructionResult = instructions.next(); - if ( - nextInstructionResult.done || - nextInstructionResult.value instanceof Uint8Array || - nextInstructionResult.value !== MAGIC_NUMBER - ) { - continue; - } - - // construct the payload by concatinating remaining data pushes - let payloads: Uint8Array[] = []; - - do { - nextInstructionResult = instructions.next(); - - if (nextInstructionResult.done) { - const decodedSuccessfully = nextInstructionResult.value; - if (!decodedSuccessfully) { - return Some(Flaw.INVALID_SCRIPT); - } - break; - } - - const instruction = nextInstructionResult.value; - if (instruction instanceof Uint8Array) { - payloads.push(instruction); - } else { - return Some(Flaw.OPCODE); - } - } while (true); - - return Some(concatUint8Arrays(payloads)); - } - - return None; - } - - static integers(payload: Uint8Array): Option { - const integers: u128[] = []; - - const seekArray = new SeekArray(payload); - while (!seekArray.isFinished()) { - const optionInt = u128.decodeVarInt(seekArray); - if (optionInt.isNone()) { - return None; - } - integers.push(optionInt.unwrap()); - } - - return Some(integers); - } -} diff --git a/frontend/src/app/shared/ord/rune/script.ts b/frontend/src/app/shared/ord/rune/script.ts deleted file mode 100644 index 67d579ab8..000000000 --- a/frontend/src/app/shared/ord/rune/script.ts +++ /dev/null @@ -1,237 +0,0 @@ -namespace pushdata { - /** - * Calculates the encoding length of a number used for push data in Bitcoin transactions. - * @param i The number to calculate the encoding length for. - * @returns The encoding length of the number. - */ - export function encodingLength(i: number): number { - return i < OPS.OP_PUSHDATA1 ? 1 : i <= 0xff ? 2 : i <= 0xffff ? 3 : 5; - } - - /** - * Decodes a byte array and returns information about the opcode, number, and size. - * @param array - The byte array to decode. - * @param offset - The offset within the array to start decoding. - * @returns An object containing the opcode, number, and size, or null if decoding fails. - */ - export function decode( - array: Uint8Array, - offset: number - ): { - opcode: number; - number: number; - size: number; - } | null { - const dataView = new DataView(array.buffer, array.byteOffset, array.byteLength); - const opcode = dataView.getUint8(offset); - let num: number; - let size: number; - - // ~6 bit - if (opcode < OPS.OP_PUSHDATA1) { - num = opcode; - size = 1; - - // 8 bit - } else if (opcode === OPS.OP_PUSHDATA1) { - if (offset + 2 > array.length) return null; - num = dataView.getUint8(offset + 1); - size = 2; - - // 16 bit - } else if (opcode === OPS.OP_PUSHDATA2) { - if (offset + 3 > array.length) return null; - num = dataView.getUint16(offset + 1, true); // true for little-endian - size = 3; - - // 32 bit - } else { - if (offset + 5 > array.length) return null; - if (opcode !== OPS.OP_PUSHDATA4) throw new Error('Unexpected opcode'); - - num = dataView.getUint32(offset + 1, true); // true for little-endian - size = 5; - } - - return { - opcode, - number: num, - size, - }; - } -} - -const OPS = { - OP_FALSE: 0, - OP_0: 0, - OP_PUSHDATA1: 76, - OP_PUSHDATA2: 77, - OP_PUSHDATA4: 78, - OP_1NEGATE: 79, - OP_RESERVED: 80, - OP_TRUE: 81, - OP_1: 81, - OP_2: 82, - OP_3: 83, - OP_4: 84, - OP_5: 85, - OP_6: 86, - OP_7: 87, - OP_8: 88, - OP_9: 89, - OP_10: 90, - OP_11: 91, - OP_12: 92, - OP_13: 93, - OP_14: 94, - OP_15: 95, - OP_16: 96, - - OP_NOP: 97, - OP_VER: 98, - OP_IF: 99, - OP_NOTIF: 100, - OP_VERIF: 101, - OP_VERNOTIF: 102, - OP_ELSE: 103, - OP_ENDIF: 104, - OP_VERIFY: 105, - OP_RETURN: 106, - - OP_TOALTSTACK: 107, - OP_FROMALTSTACK: 108, - OP_2DROP: 109, - OP_2DUP: 110, - OP_3DUP: 111, - OP_2OVER: 112, - OP_2ROT: 113, - OP_2SWAP: 114, - OP_IFDUP: 115, - OP_DEPTH: 116, - OP_DROP: 117, - OP_DUP: 118, - OP_NIP: 119, - OP_OVER: 120, - OP_PICK: 121, - OP_ROLL: 122, - OP_ROT: 123, - OP_SWAP: 124, - OP_TUCK: 125, - - OP_CAT: 126, - OP_SUBSTR: 127, - OP_LEFT: 128, - OP_RIGHT: 129, - OP_SIZE: 130, - - OP_INVERT: 131, - OP_AND: 132, - OP_OR: 133, - OP_XOR: 134, - OP_EQUAL: 135, - OP_EQUALVERIFY: 136, - OP_RESERVED1: 137, - OP_RESERVED2: 138, - - OP_1ADD: 139, - OP_1SUB: 140, - OP_2MUL: 141, - OP_2DIV: 142, - OP_NEGATE: 143, - OP_ABS: 144, - OP_NOT: 145, - OP_0NOTEQUAL: 146, - OP_ADD: 147, - OP_SUB: 148, - OP_MUL: 149, - OP_DIV: 150, - OP_MOD: 151, - OP_LSHIFT: 152, - OP_RSHIFT: 153, - - OP_BOOLAND: 154, - OP_BOOLOR: 155, - OP_NUMEQUAL: 156, - OP_NUMEQUALVERIFY: 157, - OP_NUMNOTEQUAL: 158, - OP_LESSTHAN: 159, - OP_GREATERTHAN: 160, - OP_LESSTHANOREQUAL: 161, - OP_GREATERTHANOREQUAL: 162, - OP_MIN: 163, - OP_MAX: 164, - - OP_WITHIN: 165, - - OP_RIPEMD160: 166, - OP_SHA1: 167, - OP_SHA256: 168, - OP_HASH160: 169, - OP_HASH256: 170, - OP_CODESEPARATOR: 171, - OP_CHECKSIG: 172, - OP_CHECKSIGVERIFY: 173, - OP_CHECKMULTISIG: 174, - OP_CHECKMULTISIGVERIFY: 175, - - OP_NOP1: 176, - - OP_NOP2: 177, - OP_CHECKLOCKTIMEVERIFY: 177, - - OP_NOP3: 178, - OP_CHECKSEQUENCEVERIFY: 178, - - OP_NOP4: 179, - OP_NOP5: 180, - OP_NOP6: 181, - OP_NOP7: 182, - OP_NOP8: 183, - OP_NOP9: 184, - OP_NOP10: 185, - - OP_CHECKSIGADD: 186, - - OP_PUBKEYHASH: 253, - OP_PUBKEY: 254, - OP_INVALIDOPCODE: 255, -} as const; - -export const opcodes = OPS; - -export namespace script { - export type Instruction = number | Uint8Array; - - export function* decompile(array: Uint8Array): Generator { - let i = 0; - - while (i < array.length) { - const opcode = array[i]; - - // data chunk - if (opcode >= OPS.OP_0 && opcode <= OPS.OP_PUSHDATA4) { - const d = pushdata.decode(array, i); - - // did reading a pushDataInt fail? - if (d === null) return false; - i += d.size; - - // attempt to read too much data? - if (i + d.number > array.length) return false; - - const data = array.subarray(i, i + d.number); - i += d.number; - - yield data; - - // opcode - } else { - yield opcode; - - i += 1; - } - } - - return true; - } -} diff --git a/frontend/src/app/shared/ord/rune/seekarray.ts b/frontend/src/app/shared/ord/rune/seekarray.ts deleted file mode 100644 index 1f465cbd3..000000000 --- a/frontend/src/app/shared/ord/rune/seekarray.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * This class provides a way to read data sequentially from a Uint8Array with automatic cursor management. - * It utilizes DataView for handling multi-byte data types. - * - * This replaces the SeekBuffer from the original runestone-lib! - */ -export class SeekArray { - - public seekIndex: number = 0; - private dataView: DataView; - - /** - * Constructs a SeekArray instance. - * - * @param array - The Uint8Array from which data will be read. - */ - constructor(private array: Uint8Array) { - this.dataView = new DataView(array.buffer, array.byteOffset, array.byteLength); - } - - /** - * Reads an unsigned 8-bit integer from the current position and advances the seek index by 1 byte. - * - * @returns The read value or undefined if reading beyond the end of the array. - */ - readUInt8(): number | undefined { - if (this.isFinished()) { - return undefined; - } - const value = this.dataView.getUint8(this.seekIndex); - this.seekIndex += 1; - return value; - } - - /** - * Checks if the seek index has reached or surpassed the length of the underlying array. - * - * @returns true if there are no more bytes to read, false otherwise. - */ - isFinished(): boolean { - return this.seekIndex >= this.array.length; - } -} diff --git a/frontend/src/app/shared/ord/rune/spacedrune.ts b/frontend/src/app/shared/ord/rune/spacedrune.ts deleted file mode 100644 index b00b0da3a..000000000 --- a/frontend/src/app/shared/ord/rune/spacedrune.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Rune } from './rune'; - -export class SpacedRune { - constructor(readonly rune: Rune, readonly spacers: number) {} - - toString(): string { - const rune = this.rune.toString(); - let i = 0; - let result = ''; - for (const c of rune) { - result += c; - - if (i < rune.length - 1 && (this.spacers & (1 << i)) !== 0) { - result += '•'; - } - i++; - } - - return result; - } -} diff --git a/frontend/src/app/shared/ord/rune/tag.ts b/frontend/src/app/shared/ord/rune/tag.ts deleted file mode 100644 index 8e39925d4..000000000 --- a/frontend/src/app/shared/ord/rune/tag.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { None, Option, Some } from './monads'; -import { u128 } from './integer'; -import { FixedArray } from './utils'; - -export enum Tag { - BODY = 0, - FLAGS = 2, - RUNE = 4, - - PREMINE = 6, - CAP = 8, - AMOUNT = 10, - HEIGHT_START = 12, - HEIGHT_END = 14, - OFFSET_START = 16, - OFFSET_END = 18, - MINT = 20, - POINTER = 22, - CENOTAPH = 126, - - DIVISIBILITY = 1, - SPACERS = 3, - SYMBOL = 5, - NOP = 127, -} - -export namespace Tag { - export function take( - tag: Tag, - fields: Map, - n: N, - withFn: (values: FixedArray) => Option - ): Option { - const field = fields.get(u128(tag)); - if (field === undefined) { - return None; - } - - const values: u128[] = []; - for (const i of [...Array(n).keys()]) { - if (field[i] === undefined) { - return None; - } - values[i] = field[i]; - } - - const optionValue = withFn(values as FixedArray); - if (optionValue.isNone()) { - return None; - } - - field.splice(0, n); - - if (field.length === 0) { - fields.delete(u128(tag)); - } - - return Some(optionValue.unwrap()); - } -} diff --git a/frontend/src/app/shared/ord/rune/terms.ts b/frontend/src/app/shared/ord/rune/terms.ts deleted file mode 100644 index 464c166e0..000000000 --- a/frontend/src/app/shared/ord/rune/terms.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Option } from './monads'; -import { u128, u64 } from './integer'; - -export type Terms = { - amount: Option; - cap: Option; - height: readonly [Option, Option]; - offset: readonly [Option, Option]; -}; diff --git a/frontend/src/app/shared/ord/rune/utils.ts b/frontend/src/app/shared/ord/rune/utils.ts deleted file mode 100644 index a6fa8e0a1..000000000 --- a/frontend/src/app/shared/ord/rune/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -type GrowToSize = A['length'] extends N - ? A - : GrowToSize; - -export type FixedArray = GrowToSize; - From 65f080d5268b7365a0a0f428ddc340fca6a19d8d Mon Sep 17 00:00:00 2001 From: natsoni Date: Tue, 8 Oct 2024 11:24:17 +0900 Subject: [PATCH 05/14] FIx error handling logic in ord-data --- .../ord-data/ord-data.component.html | 104 ++++++++---------- .../components/ord-data/ord-data.component.ts | 2 - .../transactions-list.component.html | 4 +- 3 files changed, 50 insertions(+), 60 deletions(-) diff --git a/frontend/src/app/components/ord-data/ord-data.component.html b/frontend/src/app/components/ord-data/ord-data.component.html index 696e7ea17..97dbc0d9d 100644 --- a/frontend/src/app/components/ord-data/ord-data.component.html +++ b/frontend/src/app/components/ord-data/ord-data.component.html @@ -1,64 +1,56 @@ -@if (error) { -
- Error fetching data (code {{ error.status }}) -
-} @else { - @if (minted) { - - Mint - {{ minted >= 100000 ? (minted | amountShortener:undefined:undefined:true) : minted }} - +@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 (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 (transferredRunes?.length && type === 'vout') { +
+ + Transfer + + +
+} - @if (inscriptions?.length && type === 'vin') { -
-
- {{ contentType.value.count > 1 ? contentType.value.count + " " : "" }}{{ contentType.value?.tag || contentType.key }} - {{ contentType.value.totalSize | bytes:2:'B':undefined:true }} - - Source inscription - -
-
{{ contentType.value.json | json }}
-
{{ contentType.value.text }}
-
- } - - @if (!runestone && type === 'vout') { -
- } - - @if (!inscriptions?.length && type === 'vin') { +@if (inscriptions?.length && type === 'vin') { +
- Error decoding inscription data + {{ contentType.value.count > 1 ? contentType.value.count + " " : "" }}{{ contentType.value?.tag || contentType.key }} + {{ contentType.value.totalSize | bytes:2:'B':undefined:true }} + + Source inscription +
- } +
{{ contentType.value.json | json }}
+
{{ contentType.value.text }}
+
+} + +@if (!runestone && type === 'vout') { +
+} + +@if ((runestone && !minted && !runestone.etching?.supply && !transferredRunes?.length && type === 'vout') || (!inscriptions?.length && type === 'vin')) { + Error decoding data } diff --git a/frontend/src/app/components/ord-data/ord-data.component.ts b/frontend/src/app/components/ord-data/ord-data.component.ts index 233b8d243..ccc77bce6 100644 --- a/frontend/src/app/components/ord-data/ord-data.component.ts +++ b/frontend/src/app/components/ord-data/ord-data.component.ts @@ -1,5 +1,4 @@ import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; -import { HttpErrorResponse } from '@angular/common/http'; import { Runestone, Etching } from '../../shared/ord/rune.utils'; export interface Inscription { @@ -20,7 +19,6 @@ export class OrdDataComponent implements OnChanges { @Input() inscriptions: Inscription[]; @Input() runestone: Runestone; @Input() runeInfo: { [id: string]: { etching: Etching; txid: string } }; - @Input() error: HttpErrorResponse; @Input() type: 'vin' | 'vout'; toNumber = (value: bigint): number => Number(value); 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 26187ecde..217eab7d7 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.html +++ b/frontend/src/app/components/transactions-list/transactions-list.component.html @@ -103,7 +103,7 @@ }">
@@ -297,7 +297,7 @@ 'highlight': this.address !== '' && (vout.scriptpubkey_address === this.address || (vout.scriptpubkey_type === 'p2pk' && vout.scriptpubkey.slice(2, -2) === this.address)) }"> From 15b3c88a1f56cdbd98a77b326ee2da4d5e9a4a7a Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 8 Oct 2024 02:40:14 +0000 Subject: [PATCH 06/14] fix optional rune divisibility bug --- frontend/src/app/shared/ord/rune.utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/shared/ord/rune.utils.ts b/frontend/src/app/shared/ord/rune.utils.ts index a1f947b46..c36f3ef06 100644 --- a/frontend/src/app/shared/ord/rune.utils.ts +++ b/frontend/src/app/shared/ord/rune.utils.ts @@ -201,9 +201,9 @@ function messageToRunestone(message: Message): Runestone { if (flags & Flag.ETCHING) { const hasTerms = (flags & Flag.TERMS) > 0n; const isTurbo = (flags & Flag.TURBO) > 0n; - const name = parseRuneName(message.fields[Tag.Rune][0]); + const name = parseRuneName(message.fields[Tag.Rune]?.[0] ?? 0n); etching = { - divisibility: Number(message.fields[Tag.Divisibility][0]), + 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 ? { From 040c067aac47d855284a90f9357d1f6b8d872cfd Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 8 Oct 2024 02:49:46 +0000 Subject: [PATCH 07/14] fix rune edict wrong id type bug --- frontend/src/app/shared/ord/rune.utils.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/src/app/shared/ord/rune.utils.ts b/frontend/src/app/shared/ord/rune.utils.ts index c36f3ef06..c23a55264 100644 --- a/frontend/src/app/shared/ord/rune.utils.ts +++ b/frontend/src/app/shared/ord/rune.utils.ts @@ -154,10 +154,7 @@ function integersToMessage(integers: bigint[]): Message { const amount = integers.shift(); const output = integers.shift(); message.edicts.push({ - id: { - block: height, - index: txIndex, - }, + id: new RuneId(Number(height), Number(txIndex)), amount, output, }); From 177bbc83f3f73cd5dbcf98f8271c607e13250fab Mon Sep 17 00:00:00 2001 From: natsoni Date: Tue, 8 Oct 2024 12:38:12 +0900 Subject: [PATCH 08/14] Clean up etches fetching logic --- frontend/src/app/services/ord-api.service.ts | 63 ++++++++++---------- 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/frontend/src/app/services/ord-api.service.ts b/frontend/src/app/services/ord-api.service.ts index da75a74af..6a38e5b17 100644 --- a/frontend/src/app/services/ord-api.service.ts +++ b/frontend/src/app/services/ord-api.service.ts @@ -19,15 +19,12 @@ export class OrdApiService { 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; } } = {}; - const runesToFetch: Set = new Set(); if (runestone) { + const runesToFetch: Set = new Set(); + if (runestone.mint) { - if (runestone.mint.toString() === '1:0') { - runeInfo[runestone.mint.toString()] = { etching: UNCOMMON_GOODS, txid: '0000000000000000000000000000000000000000000000000000000000000000' }; - } else { - runesToFetch.add(runestone.mint.toString()); - } + runesToFetch.add(runestone.mint.toString()); } if (runestone.edicts.length) { @@ -37,18 +34,15 @@ export class OrdApiService { } if (runesToFetch.size) { - const runeEtchingObservables = Array.from(runesToFetch).map(runeId => { - return this.getEtchingFromRuneId$(runeId).pipe( - tap(etching => { - if (etching) { - runeInfo[runeId] = etching; - } - }) - ); - }); + const runeEtchingObservables = Array.from(runesToFetch).map(runeId => this.getEtchingFromRuneId$(runeId)); return forkJoin(runeEtchingObservables).pipe( - map(() => { + map((etchings) => { + etchings.forEach((el) => { + if (el) { + runeInfo[el.runeId] = { etching: el.etching, txid: el.txid }; + } + }); return { runestone: runestone, runeInfo }; }) ); @@ -60,24 +54,27 @@ export class OrdApiService { } // Get etching from runeId by looking up the transaction that etched the rune - getEtchingFromRuneId$(runeId: string): Observable<{ etching: Etching; txid: string; }> { - const [blockNumber, txIndex] = runeId.split(':'); - - return this.electrsApiService.getBlockHashFromHeight$(parseInt(blockNumber)).pipe( - switchMap(blockHash => this.electrsApiService.getBlockTxId$(blockHash, parseInt(txIndex))), - switchMap(txId => this.electrsApiService.getTransaction$(txId)), - switchMap(tx => { - const runestone = decipherRunestone(tx); - if (runestone) { - const etching = runestone.etching; - if (etching) { - return of({ etching, txid: tx.txid }); + 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)) - ); + return of(null); + }), + catchError(() => of(null)) + ); + } } decodeInscriptions(witness: string): Inscription[] | null { From e440c3f235070e135713ada13b4299235cbf7f8a Mon Sep 17 00:00:00 2001 From: natsoni Date: Tue, 8 Oct 2024 12:40:25 +0900 Subject: [PATCH 09/14] Fix edicts displaying --- .../src/app/components/ord-data/ord-data.component.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/components/ord-data/ord-data.component.ts b/frontend/src/app/components/ord-data/ord-data.component.ts index ccc77bce6..ccf8f6eae 100644 --- a/frontend/src/app/components/ord-data/ord-data.component.ts +++ b/frontend/src/app/components/ord-data/ord-data.component.ts @@ -34,10 +34,8 @@ export class OrdDataComponent implements OnChanges { ngOnChanges(changes: SimpleChanges): void { if (changes.runestone && this.runestone) { - this.transferredRunes = Object.entries(this.runeInfo).map(([key, runeInfo]) => ({ key, ...runeInfo })); if (this.runestone.mint && this.runeInfo[this.runestone.mint.toString()]) { const mint = this.runestone.mint.toString(); - this.transferredRunes = this.transferredRunes.filter(rune => rune.key !== mint); const terms = this.runeInfo[mint].etching.terms; const amount = terms?.amount; const divisibility = this.runeInfo[mint].etching.divisibility; @@ -45,6 +43,12 @@ export class OrdDataComponent implements OnChanges { 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) { From 0a614291760ca51d4664e5a8f2853385f3b26887 Mon Sep 17 00:00:00 2001 From: natsoni Date: Tue, 8 Oct 2024 12:41:14 +0900 Subject: [PATCH 10/14] Increase inscription max height --- frontend/src/app/components/ord-data/ord-data.component.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/components/ord-data/ord-data.component.scss b/frontend/src/app/components/ord-data/ord-data.component.scss index 7cb2cdca6..b218359d9 100644 --- a/frontend/src/app/components/ord-data/ord-data.component.scss +++ b/frontend/src/app/components/ord-data/ord-data.component.scss @@ -31,5 +31,5 @@ a.disabled { pre { margin-top: 5px; - max-height: 150px; + max-height: 200px; } \ No newline at end of file From 1ddb8a39c9aa9dab934e63d59f80093a95b8d696 Mon Sep 17 00:00:00 2001 From: natsoni Date: Tue, 8 Oct 2024 12:50:56 +0900 Subject: [PATCH 11/14] Show text inscriptions up to 50kB --- frontend/src/app/components/ord-data/ord-data.component.ts | 3 ++- frontend/src/app/shared/ord/inscription.utils.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/components/ord-data/ord-data.component.ts b/frontend/src/app/components/ord-data/ord-data.component.ts index ccf8f6eae..0e18750f1 100644 --- a/frontend/src/app/components/ord-data/ord-data.component.ts +++ b/frontend/src/app/components/ord-data/ord-data.component.ts @@ -3,6 +3,7 @@ import { Runestone, Etching } from '../../shared/ord/rune.utils'; export interface Inscription { body?: Uint8Array; + is_cropped?: boolean; body_length?: number; content_type?: Uint8Array; content_type_str?: string; @@ -68,7 +69,7 @@ export class OrdDataComponent implements OnChanges { } // Text / JSON data - if ((key.includes('text') || key.includes('json')) && inscription.body?.length && !this.inscriptionsData[key].text && !this.inscriptionsData[key].json) { + 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 { diff --git a/frontend/src/app/shared/ord/inscription.utils.ts b/frontend/src/app/shared/ord/inscription.utils.ts index efa9e8fe8..e62f892d7 100644 --- a/frontend/src/app/shared/ord/inscription.utils.ts +++ b/frontend/src/app/shared/ord/inscription.utils.ts @@ -390,7 +390,8 @@ export function extractInscriptionData(raw: Uint8Array, pointer: number): Inscri return { content_type_str: contentType, - body: combinedData.slice(0, 150), // Limit body to 150 bytes for now + body: combinedData.slice(0, 50_000), // Limit body to 50 kB for now + is_cropped: combinedData.length > 50_000, body_length: combinedData.length, delegate_txid: getKnownFieldValue(fields, knownFields.delegate) ? bytesToHex(getKnownFieldValue(fields, knownFields.delegate).reverse()) : null }; From 57a05c80a25cd7d3510b169d7495652ecaaac495 Mon Sep 17 00:00:00 2001 From: natsoni Date: Tue, 8 Oct 2024 12:53:18 +0900 Subject: [PATCH 12/14] Move inscription type to utils --- .../src/app/components/ord-data/ord-data.component.ts | 10 +--------- .../transactions-list/transactions-list.component.ts | 2 +- frontend/src/app/services/ord-api.service.ts | 2 +- frontend/src/app/shared/ord/inscription.utils.ts | 11 +++++++++-- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/components/ord-data/ord-data.component.ts b/frontend/src/app/components/ord-data/ord-data.component.ts index 0e18750f1..40e189f7b 100644 --- a/frontend/src/app/components/ord-data/ord-data.component.ts +++ b/frontend/src/app/components/ord-data/ord-data.component.ts @@ -1,14 +1,6 @@ import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { Runestone, Etching } from '../../shared/ord/rune.utils'; - -export interface Inscription { - body?: Uint8Array; - is_cropped?: boolean; - body_length?: number; - content_type?: Uint8Array; - content_type_str?: string; - delegate_txid?: string; -} +import { Inscription } from '../../shared/ord/inscription.utils'; @Component({ selector: 'app-ord-data', 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 706ee9684..7bb1604c6 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -12,7 +12,7 @@ import { ApiService } from '../../services/api.service'; import { PriceService } from '../../services/price.service'; import { StorageService } from '../../services/storage.service'; import { OrdApiService } from '../../services/ord-api.service'; -import { Inscription } from '../ord-data/ord-data.component'; +import { Inscription } from '../../shared/ord/inscription.utils'; import { Etching, Runestone } from '../../shared/ord/rune.utils'; @Component({ diff --git a/frontend/src/app/services/ord-api.service.ts b/frontend/src/app/services/ord-api.service.ts index 6a38e5b17..5fcd75298 100644 --- a/frontend/src/app/services/ord-api.service.ts +++ b/frontend/src/app/services/ord-api.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { catchError, forkJoin, map, Observable, of, switchMap, tap } from 'rxjs'; -import { Inscription } from '../components/ord-data/ord-data.component'; +import { 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'; diff --git a/frontend/src/app/shared/ord/inscription.utils.ts b/frontend/src/app/shared/ord/inscription.utils.ts index e62f892d7..f4d92b206 100644 --- a/frontend/src/app/shared/ord/inscription.utils.ts +++ b/frontend/src/app/shared/ord/inscription.utils.ts @@ -1,8 +1,6 @@ // Adapted from https://github.com/ordpool-space/ordpool-parser/tree/ce04d7a5b6bb1cf37b9fdadd77ba430f5bd6e7d6/src // Utils functions to decode ord inscriptions -import { Inscription } from "../../components/ord-data/ord-data.component"; - export const OP_FALSE = 0x00; export const OP_IF = 0x63; export const OP_0 = 0x00; @@ -304,6 +302,15 @@ export function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array { ////////////////////////////// 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. * From 3486c35f5e28db3d68060b7dd940a700b869a766 Mon Sep 17 00:00:00 2001 From: natsoni Date: Tue, 8 Oct 2024 12:59:36 +0900 Subject: [PATCH 13/14] 50kb -> 100kb --- frontend/src/app/shared/ord/inscription.utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/shared/ord/inscription.utils.ts b/frontend/src/app/shared/ord/inscription.utils.ts index f4d92b206..78095f22f 100644 --- a/frontend/src/app/shared/ord/inscription.utils.ts +++ b/frontend/src/app/shared/ord/inscription.utils.ts @@ -397,8 +397,8 @@ export function extractInscriptionData(raw: Uint8Array, pointer: number): Inscri return { content_type_str: contentType, - body: combinedData.slice(0, 50_000), // Limit body to 50 kB for now - is_cropped: combinedData.length > 50_000, + 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 }; From 1b2f1b38b45eae900b464e557a25d108dd03f80a Mon Sep 17 00:00:00 2001 From: natsoni Date: Tue, 8 Oct 2024 13:09:19 +0900 Subject: [PATCH 14/14] undefined -> unknown --- .../src/app/components/ord-data/ord-data.component.html | 6 +++++- frontend/src/app/components/ord-data/ord-data.component.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/components/ord-data/ord-data.component.html b/frontend/src/app/components/ord-data/ord-data.component.html index 97dbc0d9d..14f24d5f3 100644 --- a/frontend/src/app/components/ord-data/ord-data.component.html +++ b/frontend/src/app/components/ord-data/ord-data.component.html @@ -34,7 +34,11 @@ @if (inscriptions?.length && type === 'vin') {
- {{ contentType.value.count > 1 ? contentType.value.count + " " : "" }}{{ contentType.value?.tag || contentType.key }} + @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 diff --git a/frontend/src/app/components/ord-data/ord-data.component.ts b/frontend/src/app/components/ord-data/ord-data.component.ts index 40e189f7b..6c6d2af20 100644 --- a/frontend/src/app/components/ord-data/ord-data.component.ts +++ b/frontend/src/app/components/ord-data/ord-data.component.ts @@ -41,7 +41,7 @@ export class OrdDataComponent implements OnChanges { if (this.runeInfo[edict.id.toString()]) { this.transferredRunes.push({ key: edict.id.toString(), ...this.runeInfo[edict.id.toString()] }); } - }); + }); } if (changes.inscriptions && this.inscriptions) {
- +
- +