diff --git a/src/inscription/inscription-parser.service.bip110.spec.ts b/src/inscription/inscription-parser.service.bip110.spec.ts new file mode 100644 index 0000000..b8c5b1c --- /dev/null +++ b/src/inscription/inscription-parser.service.bip110.spec.ts @@ -0,0 +1,25 @@ +import { readTransaction } from '../../testdata/test.helper'; +import { InscriptionParserService } from './inscription-parser.service'; + +/** + * BIP-110 compatible envelope (ordinals/ord#4545). Payload identical to + * the classic ord envelope; only the outer wrapper differs. + * + * SYNTHETIC DATA: no BIP-110 shape inscription exists on chain yet. + * The fixture uses a "deadbeef"-repeat txid. GOLDEN RULE waived so the + * wire-format parsing is pinned ahead of the first real tx. + */ +describe('BIP-110 compatible inscription envelope', () => { + + it('parses a "Hello, world!" inscription wrapped in the BIP-110 envelope', async () => { + + const txid = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + const inscription = InscriptionParserService.parse(readTransaction(txid))[0]; + + expect(inscription.inscriptionId).toBe(`${txid}i0`); + expect(inscription.contentType).toBe('text/plain'); + expect(await inscription.getContent()).toBe('Hello, world!'); + // 4-byte "ord" marker + 5 pushes (32 bytes) + 3 drops. + expect(inscription.envelopeSize).toBe(35); + }); +}); diff --git a/src/inscription/inscription-parser.service.envelope.spec.ts b/src/inscription/inscription-parser.service.envelope.spec.ts index 86136d7..c0eae40 100644 --- a/src/inscription/inscription-parser.service.envelope.spec.ts +++ b/src/inscription/inscription-parser.service.envelope.spec.ts @@ -128,8 +128,6 @@ describe('Inscription parser', () => { expect(txWitness).toEqual('0000000000000000000000000000000000000000000000000000000000000000'); const raw = hexToBytes(txWitness); - const position = getNextInscriptionMark(raw, 0); - - expect(position).toEqual(-1); + expect(getNextInscriptionMark(raw, 0)).toBeNull(); }); }); diff --git a/src/inscription/inscription-parser.service.helper.spec.ts b/src/inscription/inscription-parser.service.helper.spec.ts index 65ab161..7d55ef6 100644 --- a/src/inscription/inscription-parser.service.helper.spec.ts +++ b/src/inscription/inscription-parser.service.helper.spec.ts @@ -46,37 +46,33 @@ describe('getKnownFieldValues', () => { describe('getNextInscriptionMark', () => { - it('should find the inscription mark (00 63 03 6f 72 64) and return the position after it', () => { + it('finds the classic inscription mark and returns the position after it', () => { const raw = new Uint8Array([0, 1, 2, 0x00, 0x63, 0x03, 0x6f, 0x72, 0x64, 10, 20]); - const startPosition = 0; - const expectedPosition = 9; - expect(getNextInscriptionMark(raw, startPosition)).toEqual(expectedPosition); + expect(getNextInscriptionMark(raw, 0)).toEqual({ pointer: 9, isClassic: true }); }); - it('should return -1 if the inscription mark is not found', () => { + it('returns null when the mark is not found', () => { const raw = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - const startPosition = 0; - expect(getNextInscriptionMark(raw, startPosition)).toEqual(-1); + expect(getNextInscriptionMark(raw, 0)).toBeNull(); }); - it('should correctly handle an empty array', () => { - const raw = new Uint8Array([]); - const startPosition = 0; - expect(getNextInscriptionMark(raw, startPosition)).toEqual(-1); + it('handles an empty array', () => { + expect(getNextInscriptionMark(new Uint8Array([]), 0)).toBeNull(); }); - it('should find the inscription mark even if it starts next to the end of the array', () => { + it('finds the mark near the end of the array', () => { const raw = new Uint8Array([0, 1, 2, 0x00, 0x63, 0x03, 0x6f, 0x72, 0x64]); - const startPosition = 3; - const expectedPosition = 9; - expect(getNextInscriptionMark(raw, startPosition)).toEqual(expectedPosition); + expect(getNextInscriptionMark(raw, 3)).toEqual({ pointer: 9, isClassic: true }); }); - it('should find the inscription mark starting exactly at the startPosition', () => { + it('finds the mark at startPosition', () => { const raw = new Uint8Array([0x00, 0x63, 0x03, 0x6f, 0x72, 0x64, 10, 20, 30]); - const startPosition = 0; - const expectedPosition = 6; - expect(getNextInscriptionMark(raw, startPosition)).toEqual(expectedPosition); + expect(getNextInscriptionMark(raw, 0)).toEqual({ pointer: 6, isClassic: true }); + }); + + it('classifies a bare "ord" push (no OP_FALSE OP_IF prefix) as BIP-110', () => { + const raw = new Uint8Array([0x03, 0x6f, 0x72, 0x64, 10, 20]); + expect(getNextInscriptionMark(raw, 0)).toEqual({ pointer: 4, isClassic: false }); }); }); diff --git a/src/inscription/inscription-parser.service.helper.ts b/src/inscription/inscription-parser.service.helper.ts index b673754..e8eb1c5 100644 --- a/src/inscription/inscription-parser.service.helper.ts +++ b/src/inscription/inscription-parser.service.helper.ts @@ -105,54 +105,47 @@ export function getKnownFieldValues(fields: { tag: number; value: Uint8Array }[] } /** - * 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. + * Envelope marker hex patterns emitted by ord. + * INSCRIPTION_MARK_HEX = OP_FALSE OP_IF OP_PUSHBYTES_3 'ord' + * BIP110_INSCRIPTION_MARK_HEX = OP_PUSHBYTES_3 'ord' (ord#4545) + */ +export const INSCRIPTION_MARK_HEX = '0063036f7264'; +export const BIP110_INSCRIPTION_MARK_HEX = '036f7264'; + +/** + * Find the next "ord" push in the raw witness. Returns the position + * immediately after the marker plus which envelope shape carries it + * (classic if the marker is preceded by OP_FALSE OP_IF, BIP-110 + * otherwise). * - * @returns The position immediately after the inscription mark, or -1 if not found. + * @returns { pointer, isClassic } or null when no more markers. */ -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; +export function getNextInscriptionMark(raw: Uint8Array, startPosition: number): { pointer: number; isClassic: boolean } | null { + + for (let i = startPosition; i <= raw.length - 4; i++) { + if (raw[i] === OP_PUSHBYTES_3 && + raw[i + 1] === 0x6f && + raw[i + 2] === 0x72 && + raw[i + 3] === 0x64) { + const isClassic = i >= 2 && raw[i - 2] === OP_FALSE && raw[i - 1] === OP_IF; + return { pointer: i + 4, isClassic }; } } - return -1; + return null; } /** * Checks if an inscription mark is found within a witness array. - * The Inscription mark hex corresponds to OP_FALSE, OP_IF, OP_PUSHBYTES_3, 'o', 'r', 'd'. - - * This code can potentially return false positive matches! + * Matches EITHER envelope shape via its exact hex pattern: + * - classic (OP_FALSE OP_IF OP_PUSHBYTES_3 'ord') + * - BIP-110 (OP_PUSHBYTES_3 'ord', ord#4545) * - * @param witness - Array of strings, each representing a hexadecimal encoded witness element. - * @returns True if an inscription mark is found, false otherwise. + * This code can potentially return false positive matches! */ export function hasInscription(witness: string[]): boolean { - - // OP_FALSE (0x00), OP_IF (0x63), OP_PUSHBYTES_3 (0x03), 'o', 'r', 'd' (0x6f, 0x72, 0x64) - const inscriptionMarkHex = '0063036f7264'; - - return isStringInArrayOfStrings(inscriptionMarkHex, witness); + return isStringInArrayOfStrings(INSCRIPTION_MARK_HEX, witness) + || isStringInArrayOfStrings(BIP110_INSCRIPTION_MARK_HEX, witness); } /** @@ -354,21 +347,17 @@ export function measureInscriptionSize(witness: string[]): number | null { return null; } - // Find the witness element that contains the inscription (the tapscript) - // OP_FALSE (0x00), OP_IF (0x63), OP_PUSHBYTES_3 (0x03), 'o', 'r', 'd' (0x6f, 0x72, 0x64) - const inscriptionMarkHex = '0063036f7264'; - const element = witness.find(e => e.includes(inscriptionMarkHex)); + // Find the classic-envelope witness element (the tapscript). + const element = witness.find(e => e.includes(INSCRIPTION_MARK_HEX)); if (!element) { return null; } const raw = hexToBytes(element); - // Find the start of the inscription using the inscription mark - const startPosition = getNextInscriptionMark(raw, 0); - - if (startPosition === -1) { - return null; // Inscription mark not found + const mark = getNextInscriptionMark(raw, 0); + if (!mark || !mark.isClassic) { + return null; } // Find the position of last OP_ENDIF (0x68) @@ -378,11 +367,10 @@ export function measureInscriptionSize(witness: string[]): number | null { return null; // OP_ENDIF not found } - // The size of the inscription is from the start position to the last OP_ENDIF - const inscriptionSize = opEndIfIndex - startPosition; - - // Add the size of the inscription mark (6 bytes) + OP_ENDIF (1 byte) - return inscriptionSize + 7; + // From the start of the classic wrapper (OP_FALSE OP_IF = 2 bytes + // before the 4-byte "ord" push, so mark.pointer - 6) to one past + // OP_ENDIF. + return opEndIfIndex + 1 - (mark.pointer - 6); } /** diff --git a/src/inscription/inscription-parser.service.ts b/src/inscription/inscription-parser.service.ts index 0b70f64..faa3c8f 100644 --- a/src/inscription/inscription-parser.service.ts +++ b/src/inscription/inscription-parser.service.ts @@ -7,13 +7,14 @@ import { hexToBytes, littleEndianBytesToNumber, } from '../lib/conversions'; -import { OP_0, OP_ENDIF } from '../lib/op-codes'; +import { OP_0, OP_2DROP, OP_DROP, OP_ENDIF } from '../lib/op-codes'; import { readPushdata } from '../lib/reader'; import { assertEsploraShape } from '../lib/transaction-shape'; import { DigitalArtifactType } from '../types/digital-artifact'; import { ParsedInscription } from '../types/parsed-inscription'; import { OnParseError } from '../types/parser-options'; import { + BIP110_INSCRIPTION_MARK_HEX, extractInscriptionId, extractPointer, getDecodedContent, @@ -21,6 +22,7 @@ import { getKnownFieldValues, getNextInscriptionMark, hasInscription, + INSCRIPTION_MARK_HEX, knownFields, } from './inscription-parser.service.helper'; import { parseProperties } from './inscription-parser.service.properties.helper'; @@ -113,32 +115,28 @@ export class InscriptionParserService { private static parseInscriptionsWithinWitness(witness: string[]): ParsedInscription[] | null { const inscriptions: ParsedInscription[] = []; - // OP_FALSE (0x00), OP_IF (0x63), OP_PUSHBYTES_3 (0x03), 'o', 'r', 'd' (0x6f, 0x72, 0x64) - const inscriptionMarkHex = '0063036f7264'; - // Only convert witness elements that contain the inscription mark. - // This avoids hexToBytes on the signature and control block elements, - // which is significant for large inscriptions (up to 4MB). + // Only convert witness elements that contain one of the two + // inscription marker patterns. This avoids hexToBytes on the + // signature and control block elements, which is significant for + // large inscriptions (up to 4MB). for (const element of witness) { - if (!element.includes(inscriptionMarkHex)) { + if (!element.includes(INSCRIPTION_MARK_HEX) && !element.includes(BIP110_INSCRIPTION_MARK_HEX)) { continue; } const raw = hexToBytes(element); let startPosition = 0; - while (true) { - const pointer = getNextInscriptionMark(raw, startPosition); - if (pointer === -1) break; // No more inscriptions found + const mark = getNextInscriptionMark(raw, startPosition); + if (!mark) break; - // Parse the inscription at the current position - const inscription = InscriptionParserService.extractInscriptionData(raw, pointer); + const inscription = InscriptionParserService.extractInscriptionData(raw, mark.pointer, mark.isClassic); if (inscription) { inscriptions.push(inscription); } - // Update startPosition for the next iteration - startPosition = pointer; + startPosition = mark.pointer; } } @@ -146,87 +144,98 @@ export class InscriptionParserService { } /** - * 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. - */ - private static 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) and calculates the envelope size. + * Extract an inscription starting one past the four-byte "ord" push. + * Both envelope shapes (classic + BIP-110 per ordinals/ord#4545) + * carry identical payloads -- tag/value fields, optional OP_0 body + * separator, body chunks -- and differ only in the terminator: * - * @param raw - The raw data to read. - * @param pointer - The current pointer where the reading starts. - * @returns The parsed inscription or nullx + * classic: OP_FALSE OP_IF <"ord"> ... OP_ENDIF + * BIP-110: <"ord"> ... {OP_DROP | OP_2DROP}+ (balances every push) */ - private static extractInscriptionData(raw: Uint8Array, pointer: number): ParsedInscription | null { + private static extractInscriptionData(raw: Uint8Array, pointer: number, isClassic: boolean): ParsedInscription | null { try { + const fields: { tag: number; value: Uint8Array }[] = []; + const body: Uint8Array[] = []; + let p = pointer; + let pushes = 0; // total pushes in the payload, BIP-110 uses it to size the drop walk - let fields: { tag: number; value: Uint8Array }[]; - let newPointer: number; - let slice: Uint8Array; + const isTerminator = isClassic + ? (op: number) => op === OP_ENDIF + : (op: number) => op === OP_DROP || op === OP_2DROP; - // Store the starting pointer (this is where the envelope starts) - const initialPointer = pointer; + // Fields until OP_0 body separator or terminator. + let slice: Uint8Array; + while (p < raw.length && raw[p] !== OP_0 && !isTerminator(raw[p])) { + // 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, p] = readPushdata(raw, p); + const tag = slice.length === 1 ? slice[0] : littleEndianBytesToNumber(slice); + [slice, p] = readPushdata(raw, p); + fields.push({ tag, value: slice }); + pushes += 2; + } - [fields, newPointer] = InscriptionParserService.extractFields(raw, pointer); + // Optional OP_0 body separator (also a push). + if (p < raw.length && raw[p] === OP_0) { + p++; + pushes++; + } - // 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 + // Body chunks until terminator. + while (p < raw.length && !isTerminator(raw[p])) { + [slice, p] = readPushdata(raw, p); + body.push(slice); + pushes++; } - // 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); + // Terminator: single OP_ENDIF (classic) or drop sequence balancing + // every push including the "ord" push (BIP-110). + let envelopeEnd: number; + if (isClassic) { + envelopeEnd = p + 1; // past OP_ENDIF + } else { + let depth = pushes + 1; // +1 for the "ord" push itself + while (p < raw.length && depth > 0) { + if (raw[p] === OP_DROP) depth -= 1; + else if (raw[p] === OP_2DROP) depth -= 2; + else return null; + if (depth < 0) return null; + p++; + } + if (depth !== 0) return null; + envelopeEnd = p; } - // +6 for OP_FALSE (1 byte) + OP_IF (1 byte) + OP_PUSH (1 byte) + "ord" (3 bytes) - // +1 for the OP_ENDIF - const envelopeSize = newPointer - initialPointer + 7; + // Envelope covers the marker plus the classic wrapper's OP_FALSE OP_IF. + const envelopeStart = pointer - 4 - (isClassic ? 2 : 0); + return InscriptionParserService.buildParsedInscription(fields, body, envelopeEnd - envelopeStart); + } catch { + return null; + } + } - let combinedData = concatUint8Arrays(data); + /** + * Build the ParsedInscription with its lazy accessors from parsed + * fields, body chunks, and envelope size. + */ + private static buildParsedInscription( + fields: { tag: number; value: Uint8Array }[], + data: Uint8Array[], + envelopeSize: number, + ): ParsedInscription { - const contentTypeRaw = getKnownFieldValue(fields, knownFields.content_type); - let contentType: string | undefined = undefined; + let combinedData = concatUint8Arrays(data); - // an inscriptions with no contentType is most probably a delegate - if (contentTypeRaw) { - // strings are (always) UTF-8, according to https://github.com/ordinals/ord/issues/2505 - contentType = bytesToUnicodeString(contentTypeRaw); - } + const contentTypeRaw = getKnownFieldValue(fields, knownFields.content_type); + let contentType: string | undefined = undefined; + + // an inscriptions with no contentType is most probably a delegate + if (contentTypeRaw) { + // strings are (always) UTF-8, according to https://github.com/ordinals/ord/issues/2505 + contentType = bytesToUnicodeString(contentTypeRaw); + } // figure out if the body is encoded via brotli or gzip const contentEncodingRaw = getKnownFieldValue(fields, knownFields.content_encoding); @@ -356,9 +365,5 @@ export class InscriptionParserService { envelopeSize, // The size of the envelope including the entire script contentSize: combinedData.length // The size of the content (the body of the inscription) }; - - } catch { - return null; - } } } diff --git a/src/lib/op-codes.ts b/src/lib/op-codes.ts index 5a581b7..b832c9b 100644 --- a/src/lib/op-codes.ts +++ b/src/lib/op-codes.ts @@ -13,6 +13,9 @@ export const OP_PUSHDATA2 = 0x4d; // 77 -- The next two bytes contain the numb 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_2DROP = 0x6d; // 109 -- Removes the top two stack items. +export const OP_DROP = 0x75; // 117 -- Removes the top stack item. + 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 diff --git a/testdata/tx_deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef.json b/testdata/tx_deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef.json new file mode 100644 index 0000000..a1ef912 --- /dev/null +++ b/testdata/tx_deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef.json @@ -0,0 +1,13 @@ +{ + "_comment": "SYNTHETIC / NOT ON CHAIN. Hand-crafted transaction for the BIP-110 envelope test (ordinals/ord#4545). Txid is 'deadbeef' repeated 8 times. Witness[1] is the envelope: OP_PUSHBYTES_3 'ord', then tag 1 (content_type) = 'text/plain', OP_0 body separator, 'Hello, world!' body, balanced by OP_2DROP OP_2DROP OP_DROP (5 pushes = 5 drops).", + "txid": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "vin": [ + { + "witness": [ + "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "036f726401010a746578742f706c61696e000d48656c6c6f2c20776f726c64216d6d75", + "c00000000000000000000000000000000000000000000000000000000000000000" + ] + } + ] +}