DEV Community

Aturo Phil
Aturo Phil

Posted on

How Ordinals Work Under the Hood: Taproot, Witness Data, and the Commit-Reveal Pattern

Bitcoin Development Series, Part 2 of 4


Ordinals are at the center of one of the most heated protocol debates in Bitcoin right now (see Part 1 on BIP-110). But most of the commentary stays at the application layer, NFTs, JPEG files on-chain, satoshi numbering. The actual mechanism is more interesting than the culture war around it. This post goes through exactly how data gets inscribed into a Bitcoin transaction, why it works within the existing rules, and how you can interact with it programmatically.


The Foundation: How Bitcoin Numbers Satoshis

The Ordinals protocol assigns a unique serial number to every satoshi, based purely on the order they were mined. The first satoshi of the first coinbase transaction is ordinal 0. The numbering increments across every output of every block in sequence, forever.

def ordinal_range_for_block(block_height: int, subsidy_sats: int) -> tuple[int, int]:
    """
    Returns the (first, last) ordinal numbers for a given block's coinbase output.
    Simplified — does not account for transaction fees being added to coinbase.
    """
    INITIAL_SUBSIDY = 50 * 100_000_000  # 5,000,000,000 sats
    halvings = block_height // 210_000

    # Calculate how many sats have been issued before this block
    total_issued = 0
    remaining_height = block_height
    current_subsidy = INITIAL_SUBSIDY

    for i in range(halvings):
        blocks_in_era = 210_000
        total_issued += blocks_in_era * current_subsidy
        current_subsidy //= 2
        remaining_height -= blocks_in_era

    total_issued += remaining_height * current_subsidy
    first_ordinal = total_issued
    last_ordinal  = total_issued + subsidy_sats - 1

    return first_ordinal, last_ordinal

# Block 840,000 — the 2024 halving block
# Subsidy dropped from 625,000,000 to 312,500,000 sats (6.25 BTC -> 3.125 BTC)
first, last = ordinal_range_for_block(840_000, 312_500_000)
print(f"Ordinals {first:,} through {last:,}")
Enter fullscreen mode Exit fullscreen mode

Ordinal numbers follow specific transfer rules: the first satoshi of an input maps to the first satoshi of the first output, in order. This is what lets the protocol track a specific satoshi through any number of transactions without any on-chain state beyond the UTXO set.


How Inscriptions Are Stored: The Witness Envelope

Inscriptions attach content to a satoshi by placing arbitrary data inside a transaction's witness, specifically inside a tapscript executed during a commit-reveal sequence.

The data envelope looks like this:

OP_FALSE
OP_IF
  OP_PUSH "ord"
  OP_PUSH 1
  OP_PUSH <content-type as bytes>   # e.g. "text/plain;charset=utf-8"
  OP_PUSH 0
  OP_PUSH <content bytes, chunked at 520 bytes each>
OP_ENDIF
Enter fullscreen mode Exit fullscreen mode

The OP_FALSE OP_IF ... OP_ENDIF pattern is the key trick. Because OP_FALSE is evaluated first, the OP_IF branch is never executed during script validation, the data inside it is parsed but never touched by the script interpreter. This means it does not affect the validity of the transaction at all. It just sits in the witness as inert bytes that the ord indexer reads.

This is not a bug or an exploit. Tapscript witness data has always been allowed to contain arbitrary byte pushes inside unexecuted branches. Ordinals discovered that you can use this to embed content up to ~4MB per input (the segwit block weight limit), for a fraction of the fee cost of putting the same data in a standard output.


The Commit-Reveal Pattern

Inscriptions use a two-transaction sequence:

Commit transaction: Creates a P2TR (Pay-to-Taproot) output that commits to a tapscript containing the inscription data. The data is not yet revealed to the network.

Reveal transaction: Spends that output, which forces the full tapscript including the inscription envelope to be broadcast and included in the witness. This is when the data actually appears on-chain.

Commit TX                         Reveal TX
──────────────────────────────    ──────────────────────────────────────────
Input:  any UTXO                  Input:  the commit output
Output: P2TR committing to        Witness: full tapscript with inscription
        inscription script              envelope inside OP_FALSE OP_IF block
        (hash only visible)       Output: destination address (the
                                          inscribed sat lands here)
Enter fullscreen mode Exit fullscreen mode

The reason for two transactions is cryptographic: you cannot include the full script in the witness of the commit transaction (there is no script to spend yet). The commit output locks funds to the hash of the tapscript, and the reveal transaction proves the preimage by actually executing the spend path.


Implementing a Text Inscription in TypeScript

Here is a minimal working example using bitcoinjs-lib and the @scure/btc-signer library. This targets regtest/testnet, never run unsigned transaction code on mainnet without auditing it.

npm install bitcoinjs-lib @scure/btc-signer tiny-secp256k1 ecpair
Enter fullscreen mode Exit fullscreen mode
import * as bitcoin from "bitcoinjs-lib";
import * as ecc from "tiny-secp256k1";
import ECPairFactory from "ecpair";

bitcoin.initEccLib(ecc);
const ECPair = ECPairFactory(ecc);

const NETWORK = bitcoin.networks.regtest;

/**
 * Build the inscription tapscript.
 * This is the script that will be embedded in the witness
 * of the reveal transaction.
 */
function buildInscriptionScript(
  publicKey: Buffer,
  contentType: string,
  content: Buffer
): Buffer {
  const contentTypeBytes = Buffer.from(contentType, "utf8");

  // Split content into 520-byte chunks (max push size in Bitcoin script)
  const chunks: Buffer[] = [];
  for (let i = 0; i < content.length; i += 520) {
    chunks.push(content.slice(i, i + 520));
  }

  const scriptParts: (Buffer | number)[] = [
    publicKey,
    bitcoin.script.OPS.OP_CHECKSIG,
    bitcoin.script.OPS.OP_FALSE,
    bitcoin.script.OPS.OP_IF,
    Buffer.from("ord", "utf8"),    // protocol marker
    Buffer.from([1]),               // field: content type
    contentTypeBytes,
    Buffer.from([0]),               // field: body
    ...chunks,
    bitcoin.script.OPS.OP_ENDIF,
  ];

  return bitcoin.script.compile(scriptParts as bitcoin.script.Stack);
}

/**
 * Build the commit transaction output script (P2TR).
 * The inscription script is hashed and committed to as a tap leaf.
 */
function buildCommitOutput(
  internalKey: Buffer,
  inscriptionScript: Buffer
): { output: Buffer; tapLeafScript: bitcoin.Taptree } {
  const tapLeaf = {
    output: inscriptionScript,
    version: 0xc0,  // leaf version for tapscript
  };

  const { output } = bitcoin.payments.p2tr({
    internalPubkey: internalKey.slice(1, 33), // x-only public key
    scriptTree: tapLeaf,
    network: NETWORK,
  });

  return { output: output!, tapLeafScript: tapLeaf };
}

// Example usage
const keyPair        = ECPair.makeRandom({ network: NETWORK });
const internalKey    = keyPair.publicKey;
const content        = Buffer.from("Hello from Bitcoin. This is an on-chain inscription.", "utf8");
const contentType    = "text/plain;charset=utf-8";

const inscriptionScript         = buildInscriptionScript(internalKey, contentType, content);
const { output, tapLeafScript } = buildCommitOutput(internalKey, inscriptionScript);

console.log("Commit output script (hex):", output.toString("hex"));
console.log("Inscription script size:   ", inscriptionScript.length, "bytes");

// At this point you would:
// 1. Fund the commit output by broadcasting a transaction that pays to `output`
// 2. Build the reveal transaction spending that output
//    with the full inscriptionScript in the witness
// 3. The `ord` indexer will pick up the content from the witness
Enter fullscreen mode Exit fullscreen mode

Reading an Existing Inscription from the Chain

If you want to decode an inscription from a transaction you already have, here is how to parse the witness data:

import httpx

def fetch_inscription_from_txid(txid: str) -> dict | None:
    """
    Fetches a transaction from mempool.space and extracts
    an Ordinals inscription from the first input's witness, if present.
    """
    resp = httpx.get(f"https://mempool.space/api/tx/{txid}")
    resp.raise_for_status()
    tx = resp.json()

    for vin in tx.get("vin", []):
        witness = vin.get("witness", [])
        for item in witness:
            raw = bytes.fromhex(item)
            result = parse_inscription_envelope(raw)
            if result:
                return result
    return None

def parse_inscription_envelope(script_bytes: bytes) -> dict | None:
    """
    Looks for the OP_FALSE OP_IF ord ... OP_ENDIF envelope
    and extracts content-type and body if found.
    """
    ORD_MARKER = b"ord"
    idx = script_bytes.find(ORD_MARKER)
    if idx == -1:
        return None

    # Minimal parser — production indexers handle edge cases
    try:
        # After "ord" marker: field 1 = content type, field 0 = body
        after_marker = script_bytes[idx + len(ORD_MARKER):]
        # Next byte is field tag (0x01 = content-type)
        if after_marker[0] != 0x01:
            return None

        ct_len  = after_marker[1]
        ct_end  = 2 + ct_len
        content_type = after_marker[2:ct_end].decode("utf-8", errors="replace")

        # Next field tag (0x00 = body)
        if after_marker[ct_end] != 0x00:
            return None

        body_start = ct_end + 1
        body       = after_marker[body_start:]

        # Strip trailing OP_ENDIF (0x68) if present
        if body.endswith(b"\x68"):
            body = body[:-1]

        return {
            "content_type": content_type,
            "body_length":  len(body),
            "body_preview": body[:200].decode("utf-8", errors="replace")
            if "text" in content_type else body[:200].hex(),
        }
    except (IndexError, UnicodeDecodeError):
        return None

# Try a known inscription transaction on mainnet
result = fetch_inscription_from_txid(
    "6fb976ab49dcec017f1e201e84395983204ae1a7c2abf7ced0a85d692e442799"
)
print(result)
# => {'content_type': 'text/plain;charset=utf-8', 'body_length': ..., 'body_preview': ...}
Enter fullscreen mode Exit fullscreen mode

Why This Is the Center of BIP-110

The 42-byte cap proposed by BIP-110 targets the OP_PUSH <content bytes> lines inside the OP_IF envelope. Any single push exceeding 42 bytes during the restriction window would make the transaction non-standard (and ultimately invalid under upgraded nodes). Since even a short text inscription typically exceeds 42 bytes, this would effectively shut down all Ordinals activity during the year-long window.

Whether or not that is the right call is a governance question (covered in Part 1). The technical point is that Ordinals are not exploiting a bug they are using a feature of tapscript witness data that has always existed. BIP-110 would introduce a new rule to restrict a specific use of that feature.


Further Reading


Next in this series: Post-Halving Bitcoin Mining Economics — what permanently changed for protocol developers after the 2024 halving, and how to query fee and mempool data yourself.

Top comments (0)