Aturo PhilEvery 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.
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:
A script is valid if execution completes without errors and the top of the stack is a non-zero, non-empty value.
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
This is the classic "hash puzzle" structure: anyone who knows the inputs that produce the right result can spend.
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
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
Unlocking script (scriptSig):
<signature> <public_key>
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]
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
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
Unlocking script:
<data that satisfies redeem_script> <redeem_script>
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>
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>
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)
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)
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>
The witness contains the script and the data satisfying it:
witness: [<data_items...>, <witness_script>]
SHA256 is used instead of HASH160 because 32 bytes gives better collision resistance for more complex scripts.
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
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]
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
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
])
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
])
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
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>
Using Bitcoin Core's decoderawtransaction:
bitcoin-cli decoderawtransaction <hex>
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)")
Bitcoin Script deliberately excludes:
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.