Bitcoin Script from First Principles

# bitcoin# blockchain# programming# tutorial
Bitcoin Script from First PrinciplesAturo Phil

Every Bitcoin transaction output contains a locking script. Every input that spends it contains an...

Every Bitcoin transaction output contains a locking script. Every input that spends it contains an unlocking script. The combination of the two, executed together, determines whether a spend is valid. This is Bitcoin Script, and most developers who work with Bitcoin have never written it directly.

That gap matters. Understanding Script is what separates developers who use Bitcoin from developers who can build on Bitcoin. Every output type, P2PKH, P2SH, P2WPKH, P2WSH, P2TR, is defined in terms of Script. Smart contracts on Bitcoin are Script programs.

This post starts from first principles.


What Bitcoin Script Is

Bitcoin Script is a stack-based, intentionally non-Turing-complete language. It has no loops, no recursion, and no ability to read external state. Every program terminates. Every execution is deterministic.

These constraints are features. A Script program that is too complex to analyze is a Script program that miners will reject. The language is designed for verifiable, auditable spending conditions.

Script execution works like an RPN calculator:

  1. Start with an empty stack
  2. Read bytes from the script left to right
  3. Data values push onto the stack
  4. Opcodes pop arguments from the stack, operate, and push results

A script is valid if execution completes without errors and the top of the stack is a non-zero, non-empty value.


The Stack in Detail

Script: OP_1 OP_2 OP_ADD OP_3 OP_EQUAL

Initial stack: []

OP_1 → push 1          stack: [1]
OP_2 → push 2          stack: [1, 2]
OP_ADD → pop 2 items,
         push sum       stack: [3]
OP_3 → push 3          stack: [3, 3]
OP_EQUAL → pop 2 items,
           push 1 if equal  stack: [1]

Final stack: [1] — top is truthy — script succeeds
Enter fullscreen mode Exit fullscreen mode

This is the classic "hash puzzle" structure: anyone who knows the inputs that produce the right result can spend.


Data Push Opcodes

Data is pushed onto the stack using push opcodes. The opcode determines how many bytes follow as data:

Opcode Value Effect
OP_0 0x00 Push empty byte array (falsy)
OP_1..OP_16 0x51..0x60 Push integers 1–16
OP_1NEGATE 0x4f Push -1
0x01..0x4b N Push next N bytes
OP_PUSHDATA1 0x4c Push next byte = length, then read that many bytes
OP_PUSHDATA2 0x4d Push next 2 bytes = length, then read
OP_PUSHDATA4 0x4e Push next 4 bytes = length, then read

In practice, data pushes are produced by script construction libraries. But reading raw transactions requires understanding which bytes are opcodes and which are data lengths.

def parse_script(script_bytes: bytes) -> list:
    """Parse raw script bytes into (opcode, data) tuples."""
    OPCODES = {
        0x00: 'OP_0',
        0x51: 'OP_1', 0x52: 'OP_2', 0x53: 'OP_3',
        0x76: 'OP_DUP', 0xa9: 'OP_HASH160',
        0x88: 'OP_EQUALVERIFY', 0xac: 'OP_CHECKSIG',
        0x87: 'OP_EQUAL', 0x93: 'OP_ADD',
        0xb1: 'OP_CHECKLOCKTIMEVERIFY',
        0xb2: 'OP_CHECKSEQUENCEVERIFY',
        0xae: 'OP_CHECKMULTISIG',
        0x6a: 'OP_RETURN',
    }
    result = []
    i = 0
    while i < len(script_bytes):
        byte = script_bytes[i]
        if 0x01 <= byte <= 0x4b:
            data = script_bytes[i+1:i+1+byte]
            result.append(('PUSH', data.hex()))
            i += 1 + byte
        elif byte == 0x4c:  # OP_PUSHDATA1
            length = script_bytes[i+1]
            data = script_bytes[i+2:i+2+length]
            result.append(('PUSH', data.hex()))
            i += 2 + length
        else:
            result.append((OPCODES.get(byte, f'OP_UNKNOWN_{byte:02x}'), None))
            i += 1
    return result
Enter fullscreen mode Exit fullscreen mode

The Standard Script Templates

P2PKH — Pay to Public Key Hash

The most common pre-SegWit script type. Locks funds to a hash of a public key.

Locking script (scriptPubKey):

OP_DUP OP_HASH160 <pubkey_hash> OP_EQUALVERIFY OP_CHECKSIG
Enter fullscreen mode Exit fullscreen mode

Unlocking script (scriptSig):

<signature> <public_key>
Enter fullscreen mode Exit fullscreen mode

Combined execution:

Stack after scriptSig executes: [<sig>, <pubkey>]

OP_DUP:        duplicate top item      [<sig>, <pubkey>, <pubkey>]
OP_HASH160:    hash160 of top item     [<sig>, <pubkey>, <hash160(pubkey)>]
<pubkey_hash>: push expected hash      [<sig>, <pubkey>, <hash160(pubkey)>, <expected_hash>]
OP_EQUALVERIFY: pop 2, check equal,
                fail if not            [<sig>, <pubkey>]
OP_CHECKSIG:   pop sig and pubkey,
               verify, push 1 if valid [1]
Enter fullscreen mode Exit fullscreen mode

In hex, a standard P2PKH scriptPubKey:

76 a9 14 <20-byte-hash> 88 ac
↑  ↑  ↑                 ↑  ↑
│  │  └── push 20 bytes  │  └── OP_CHECKSIG
│  └───── OP_HASH160     └────── OP_EQUALVERIFY
└──────── OP_DUP
Enter fullscreen mode Exit fullscreen mode

P2SH — Pay to Script Hash

P2SH moves complexity from the output to the input. The output commits to a hash of the spending script. The spender reveals the script and satisfies it.

Locking script:

OP_HASH160 <script_hash> OP_EQUAL
Enter fullscreen mode Exit fullscreen mode

Unlocking script:

<data that satisfies redeem_script> <redeem_script>
Enter fullscreen mode Exit fullscreen mode

Execution: the redeem script is hashed and compared to script_hash. If equal, the redeem script is then deserialized and executed with the remaining stack items.

This is how P2SH multisig works:

Redeem script: OP_2 <pubkey1> <pubkey2> <pubkey3> OP_3 OP_CHECKMULTISIG
Script hash: HASH160(redeem_script)

Unlocking script: OP_0 <sig1> <sig2> <redeem_script>
Enter fullscreen mode Exit fullscreen mode

The OP_0 is a workaround for a historical bug in OP_CHECKMULTISIG that pops one extra item from the stack.

P2WPKH — Native SegWit v0

SegWit moved the unlocking data (signatures and public keys) into a witness field, separate from the scriptSig. The scriptPubKey for P2WPKH is:

OP_0 <20-byte-pubkey-hash>
Enter fullscreen mode Exit fullscreen mode

The OP_0 is the witness version (0 for SegWit v0). The validation rules for version 0 with a 20-byte hash are: treat it as P2PKH and look for the signature and public key in the witness field, not the scriptSig.

Transaction structure with SegWit:

@dataclass
class TxInput:
    txid: str
    vout: int
    scriptSig: bytes     # empty for native SegWit inputs
    sequence: int
    witness: list[bytes] # signature and pubkey go here

# For P2WPKH:
# witness = [<signature>, <pubkey>]
# scriptSig = b''  (empty)
Enter fullscreen mode Exit fullscreen mode

SegWit transactions have a flag byte and a marker byte in the serialization:

version (4 bytes)
marker (0x00)         ← SegWit flag
flag (0x01)           ← SegWit flag
inputs (compact size + input data)
outputs (compact size + output data)
witness (one stack per input)
locktime (4 bytes)
Enter fullscreen mode Exit fullscreen mode

P2WSH — SegWit Script Hash

P2WSH is P2SH but for SegWit. The scriptPubKey commits to the SHA256 (not HASH160) of the witness script:

OP_0 <32-byte-script-hash>
Enter fullscreen mode Exit fullscreen mode

The witness contains the script and the data satisfying it:

witness: [<data_items...>, <witness_script>]
Enter fullscreen mode Exit fullscreen mode

SHA256 is used instead of HASH160 because 32 bytes gives better collision resistance for more complex scripts.


Writing a Custom Locking Script

The classic example: a hash puzzle. Anyone who knows the preimage of a hash can spend.

import hashlib

def create_hash_puzzle_script(secret: bytes) -> bytes:
    """
    Locking script: OP_SHA256 <hash> OP_EQUAL
    Spending script: <secret>
    """
    secret_hash = hashlib.sha256(secret).digest()
    return bytes([
        0xa8,               # OP_SHA256
        0x20,               # push 32 bytes
        *secret_hash,       # the expected hash
        0x87                # OP_EQUAL
    ])

def create_hash_puzzle_spend(secret: bytes) -> bytes:
    """Unlocking script: push the preimage."""
    return bytes([len(secret)]) + secret

# Usage
secret = b"correct horse battery staple"
lock = create_hash_puzzle_script(secret)
unlock = create_hash_puzzle_spend(secret)

# In production: wrap in P2SH or P2WSH
# The raw hash puzzle script is non-standard
Enter fullscreen mode Exit fullscreen mode

To make this spendable on mainnet, wrap it in P2WSH:

def wrap_in_p2wsh(witness_script: bytes) -> bytes:
    """
    Create a P2WSH scriptPubKey committing to witness_script.
    """
    script_hash = hashlib.sha256(witness_script).digest()
    return bytes([0x00, 0x20]) + script_hash  # OP_0 + push 32 bytes

# Spending: witness = [<secret>, <witness_script>]
witness = [create_hash_puzzle_spend(secret), lock]
Enter fullscreen mode Exit fullscreen mode

Time-Lock Opcodes

OP_CHECKLOCKTIMEVERIFY (OP_CLTV, BIP 65)

Requires the spending transaction's nLockTime to be at least the value on the stack. Used for absolute timelocks.

Script: <expiry_block> OP_CHECKLOCKTIMEVERIFY OP_DROP OP_DUP OP_HASH160 <pubkey_hash> OP_EQUALVERIFY OP_CHECKSIG
Enter fullscreen mode Exit fullscreen mode

This output cannot be spent until expiry_block has passed AND the transaction's nLockTime >= expiry_block AND the input's nSequence < 0xFFFFFFFF (to allow nLockTime to apply).

def create_timelock_p2pkh(pubkey_hash: bytes, lock_height: int) -> bytes:
    """P2PKH with absolute timelock."""
    height_bytes = lock_height.to_bytes(
        (lock_height.bit_length() + 7) // 8, 'little'
    )
    return bytes([
        len(height_bytes), *height_bytes,  # push lock height
        0xb1,                              # OP_CHECKLOCKTIMEVERIFY
        0x75,                              # OP_DROP
        0x76,                              # OP_DUP
        0xa9,                              # OP_HASH160
        0x14, *pubkey_hash,                # push 20-byte hash
        0x88,                              # OP_EQUALVERIFY
        0xac                               # OP_CHECKSIG
    ])
Enter fullscreen mode Exit fullscreen mode

OP_CHECKSEQUENCEVERIFY (OP_CSV, BIP 112)

Relative timelock. Requires the spending input's nSequence to encode a minimum relative delay since the UTXO was confirmed. Used for Lightning's to_self_delay and Hash Time-Locked Contracts.

def create_csv_script(pubkey_hash: bytes, delay_blocks: int) -> bytes:
    """Output spendable only after delay_blocks confirmations."""
    delay_bytes = delay_blocks.to_bytes(
        (delay_blocks.bit_length() + 7) // 8, 'little'
    )
    return bytes([
        len(delay_bytes), *delay_bytes,    # push delay
        0xb2,                              # OP_CHECKSEQUENCEVERIFY
        0x75,                              # OP_DROP
        0x76,                              # OP_DUP
        0xa9,                              # OP_HASH160
        0x14, *pubkey_hash,
        0x88,                              # OP_EQUALVERIFY
        0xac                               # OP_CHECKSIG
    ])
Enter fullscreen mode Exit fullscreen mode

OP_CHECKMULTISIG

Multisig requires M signatures from N possible public keys. The bare form is non-standard above 3-of-3 but works in P2SH or P2WSH.

Script: OP_<m> <pubkey1> ... <pubkeyN> OP_<n> OP_CHECKMULTISIG
Enter fullscreen mode Exit fullscreen mode

The historical bug: OP_CHECKMULTISIG pops one extra item from the stack after consuming all signatures and public keys. The extra item is ignored. By convention, scripts always push OP_0 as the first item in the unlocking script to satisfy this.

def create_multisig_redeem_script(m: int, pubkeys: list[bytes]) -> bytes:
    """
    Create an m-of-n multisig redeem script for P2SH or P2WSH.
    m: required signatures
    pubkeys: list of 33-byte compressed public keys
    """
    n = len(pubkeys)
    if m > n or m < 1 or n > 20:
        raise ValueError(f"Invalid multisig parameters: {m} of {n}")

    script = bytes([0x50 + m])  # OP_1..OP_16
    for pk in pubkeys:
        script += bytes([len(pk)]) + pk
    script += bytes([0x50 + n])  # OP_1..OP_16
    script += bytes([0xae])      # OP_CHECKMULTISIG
    return script

# Spending script for 2-of-3:
# OP_0 <sig1> <sig2> <redeem_script>
Enter fullscreen mode Exit fullscreen mode

Reading Scripts in Raw Transactions

Using Bitcoin Core's decoderawtransaction:

bitcoin-cli decoderawtransaction <hex>
Enter fullscreen mode Exit fullscreen mode

Or in Python using python-bitcoinlib:

from bitcoin.core import CTransaction
from bitcoin.core.script import CScript
import binascii

raw_tx = bytes.fromhex("0200000001...")
tx = CTransaction.deserialize(raw_tx)

for i, output in enumerate(tx.vout):
    script = CScript(output.scriptPubKey)
    print(f"Output {i}: {output.nValue} sat")
    print(f"  Script: {script}")

    # Classify
    if script.is_p2pkh():
        print("  Type: P2PKH")
    elif script.is_p2sh():
        print("  Type: P2SH")
    elif script.is_witness_v0_keyhash():
        print("  Type: P2WPKH")
    elif script.is_witness_v0_scripthash():
        print("  Type: P2WSH")
    elif len(script) == 34 and script[0] == 0x51 and script[1] == 0x20:
        print("  Type: P2TR (Taproot)")
Enter fullscreen mode Exit fullscreen mode

What You Cannot Do

Bitcoin Script deliberately excludes:

  • Loops — no repeated execution
  • Recursion — no self-reference
  • External state — cannot read block data, oracle prices, or other transactions
  • Multiplication between script values (only by constants)
  • String operations beyond basic concatenation (removed)

Proposed additions that are not yet in Script: OP_VAULT, OP_CHECKTEMPLATEVERIFY (CTV), OP_CAT. These would enable covenants, outputs that restrict what future transactions spending them can look like. They remain under active discussion.


Further Reading