Chapter 2: Bitcoin Script Fundamentals#


Chapter 1 ended on a single idea: an address is never more than a stand-in for a locking script. This chapter is about that script. Before any of it runs, though, you need to know what the script is actually locking - so we start with the UTXO model, then introduce Bitcoin Script and trace a real P2PKH spend through the stack, opcode by opcode. Everything Taproot does later is built on this same execution model.

# Chapter environment: bitcoinutils (load once, reuse in subsequent code cells)
from bitcoinutils.setup import setup
from bitcoinutils.utils import to_satoshis
from bitcoinutils.transactions import Transaction, TxInput, TxOutput
from bitcoinutils.keys import P2wpkhAddress, P2pkhAddress, PrivateKey
from bitcoinutils.script import Script

2.1 UTXO Model: Digital Cash, Not Digital Banking#

Before looking at scripts, it helps to be precise about how Bitcoin holds value. It does not keep account balances. It uses the Unspent Transaction Output (UTXO) model, which behaves far more like physical cash than like a bank account.

Cash vs Banking: A Mental Model#

A bank account is a single number that goes up and down. Cash is a handful of discrete bills, and you spend by handing over whole bills and taking change. Bitcoin works the second way.

Traditional Banking (account model):

  • Your account shows a balance: $500

  • Spending $350 simply deducts from your balance

  • Result: Account balance updates to $150

  • No need to handle “change”

Bitcoin UTXO Model (cash model):

  • You don’t have a “$500 balance”

  • Instead, you have specific “bills”: one $200 bill and three $100 bills

  • To spend $350, you must provide $400 worth of bills ($200 + $100 + $100)

  • You receive $50 in change as a new “bill”

  • Result: You now have one $100 bill and one $50 bill

That cash-like behavior is not a quirk of the interface - it is the foundation of Bitcoin’s design and security model.

UTXO Model in Practice#

Trace a single payment from Alice to Bob.

Initial state:

  • Alice owns a 10 BTC UTXO

  • Bob owns no bitcoin

Alice sends 7 BTC to Bob:

  1. Transaction input: Alice’s 10 BTC UTXO (must be consumed entirely)

  2. Transaction outputs:

    • 7 BTC to Bob (new UTXO)

    • 3 BTC change back to Alice (new UTXO)

  3. Result: The original 10 BTC UTXO is destroyed, two new UTXOs are created

Each UTXO is named by the transaction that created it plus its position in that transaction’s output list - transaction_id:output_index:

  • Bob’s UTXO: TX123:0 (7 BTC)

  • Alice’s change: TX123:1 (3 BTC)

UTXO Key Properties#

A few properties follow directly from the cash model, and they are worth stating because the rest of the book leans on them:

  • Complete consumption: UTXOs must be spent in their entirety - no partial spending.

  • Atomic creation: Transactions either succeed completely (all inputs consumed, all outputs created) or fail completely.

  • Change handling: Any difference between input and output amounts becomes the transaction fee, unless explicitly returned as change.

  • Parallel processing: Since each UTXO can only be spent once, multiple transactions can be validated in parallel without complex state management.

2.2 Bitcoin Script and P2PKH Fundamentals#

Bitcoin Script: Programmable Spending Conditions#

A UTXO carries more than an amount. It carries a locking script (ScriptPubKey) that states the conditions under which it can be spent. Spending it means supplying an unlocking script (ScriptSig) that satisfies those conditions. The two are checked together, and only then does the network treat the spend as valid.

Script Architecture#

Unlocking Script (ScriptSig) + Locking Script (ScriptPubKey) -> Valid/Invalid

Locking script (ScriptPubKey):

  • Attached to each UTXO output

  • Defines spending conditions

  • Example: “Only spendable by someone who can provide a valid signature for public key X”

Unlocking script (ScriptSig):

  • Provided when spending a UTXO

  • Contains data needed to satisfy the locking script

  • Example: “Here’s my signature and public key”

To validate, the node combines the two scripts, runs them as a single program, and accepts the spend only if the final result is TRUE.

Stack-Based Execution#

Bitcoin Script runs on a stack, the same model used by languages like Forth or PostScript. Every operation works on a Last-In-First-Out (LIFO) stack: data gets pushed on, opcodes pop their arguments off and push results back. A short arithmetic example shows the whole mechanism.

Initial stack: empty

│ (empty)                               │
└───────────────────────────────────────┘

PUSH 3

│ 3                                     │
└───────────────────────────────────────┘

PUSH 5


│ 5                                     │
│ 3                                     │
└───────────────────────────────────────┘

ADD operation


│ 8                                     │
└───────────────────────────────────────┘

The ADD step is the pattern in miniature: pop the top two numbers (5, then 3), add them, push the result (8). Nothing else is going on. That predictability is exactly why the model can carry complex spending conditions without becoming a security liability.

P2PKH: The Foundation Script#

Pay-to-Public-Key-Hash (P2PKH) is the most fundamental script type, and the right place to learn the stack model before Taproot complicates it.

P2PKH locking script

OP_DUP OP_HASH160 <pubkey_hash> OP_EQUALVERIFY OP_CHECKSIG

In words: this UTXO is spendable by anyone who can present a public key that hashes to pubkey_hash, together with a valid signature from the matching private key.

P2PKH unlocking script

<signature> <public_key>

The spender provides two things: a digital signature proving control of the private key, and the public key itself, which the script will hash and check against the committed hash.

Real Example: Satoshi to Hal Finney#

Bitcoin’s first ever payment - Satoshi Nakamoto sending 10 BTC to Hal Finney - is the natural example.

Transaction ID: f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16

Transaction structure:

  • Input: Satoshi’s coinbase UTXO (50 BTC from mining)

  • Outputs:

    • 10 BTC to Hal Finney

    • 40 BTC change back to Satoshi

One caveat: that 2009 transaction used P2PK (Pay-to-Public-Key), embedding the public key directly in the locking script, not P2PKH. P2PKH came shortly after and became the norm, because hashing the public key is both smaller on-chain and keeps the key hidden until spend. The walkthrough below keeps Hal as the spender but uses a P2PKH script, so the stack trace matches the form Bitcoin actually settled on.

P2PKH Execution (Hal Finney Example)#

Take Hal later spending a P2PKH-locked 10 BTC and walk the script end to end. The unlocking script runs first, pushing its two items; then the locking script’s opcodes consume them. Each step below shows the stack right after the operation named.

Locking: OP_DUP OP_HASH160 <hash> OP_EQUALVERIFY OP_CHECKSIG Unlocking: <signature> <public_key>


│ 02898711...8519 (public_key)          │
│ 30440220...914f01 (signature)         │
└───────────────────────────────────────┘
  1. OP_DUP: Duplicate the top stack item (public key):


│ 02898711...8519 (public_key)          │
│ 02898711...8519 (public_key)          │
│ 30440220...914f01 (signature)         │
└───────────────────────────────────────┘
  1. OP_HASH160: Hash the top stack item:


│ 340cfcff...7a571 (hash160_result)     │
│ 02898711...8519 (public_key)          │
│ 30440220...914f01 (signature)         │
└───────────────────────────────────────┘
  1. Push expected hash: From the locking script:


│ 340cfcff...7a571 (expected_hash)      │
│ 340cfcff...7a571 (computed_hash)      │
│ 02898711...8519 (public_key)          │
│ 30440220...914f01 (signature)         │
└───────────────────────────────────────┘
  1. OP_EQUALVERIFY: Compare the top two items, remove both if equal:


│ 02898711...8519 (public_key)          │
│ 30440220...914f01 (signature)         │
└───────────────────────────────────────┘
(Script fails if hashes don't match)
  1. OP_CHECKSIG: Verify the signature against the public key and transaction:


│ 1 (TRUE)                              │
└───────────────────────────────────────┘
  1. Final check: The script succeeds because the only item left on the stack is non-zero.

P2PKH Security Properties#

Four properties fall out of this design, and each one matters later:

  • Pre-image resistance: The public-key hash sits in front of the key itself, so the public key stays hidden until the first spend - which also buys some margin against a future break in ECDSA.

  • Signature verification: OP_CHECKSIG ties the spend to the private key cryptographically: only the holder of that key can produce a passing signature.

  • Transaction integrity: Because the signature commits to the transaction’s details, altering the transaction after signing makes the signature no longer verify.

  • Replay protection: Since each signature is bound to one specific transaction, it cannot be lifted and replayed elsewhere.

2.3 Practical Implementation: Building P2PKH Transactions#

Building a Real Testnet Legacy-to-SegWit Transaction#

The cleanest way to see all of this hold together is to build one real transaction. Below, a Legacy P2PKH input pays a SegWit output on testnet; afterward we take the broadcast result apart and trace its script on the stack.

# Example 1: Build P2PKH transaction
# Reference: code/chapter02/01_build_p2pkh_transaction.py

setup('testnet')
private_key = PrivateKey('cPeon9fBsW2BxwJTALj3hGzh9vm8C52Uqsce7MzXGS1iFJkPF4AT')
public_key = private_key.get_public_key()
from_address = P2pkhAddress('myYHJtG3cyoRseuTwvViGHgP2efAvZkYa4')
to_address = P2wpkhAddress('tb1qckeg66a6jx3xjw5mrpmte5ujjv3cjrajtvm9r4')

txin = TxInput('34b90a15d0a9ec9ff3d7bed2536533c73278a9559391cb8c9778b7e7141806f7', 1)
txout = TxOutput(to_satoshis(0.00029400), to_address.to_script_pub_key())
tx = Transaction([txin], [txout])
p2pkh_script = from_address.to_script_pub_key()
signature = private_key.sign_input(tx, 0, p2pkh_script)
txin.script_sig = Script([signature, public_key.to_hex()])
signed_tx = tx.serialize()

print(f"Transaction size: {tx.get_size()} bytes")

Key Functions#

The script leans on bitcoinutils calls in three groups. Setup and keys: setup('testnet') points the library at testnet, PrivateKey() loads a key from WIF, and P2pkhAddress() / P2wpkhAddress() build the Legacy sender and SegWit receiver. Construction: TxInput(txid, vout) references the UTXO being spent, TxOutput(amount, script_pubkey) sets destination and amount, and Transaction([txin], [txout]) assembles them. Script and signature: sign_input(tx, idx, script) signs one input, and Script([sig, pk]) packs the signature and public key into the unlocking script.

Real Data Analysis and Stack Execution#

Running the code produces a real transaction that was broadcast to testnet. We can pull its bytes back apart and confirm the script does exactly what the trace predicted.

TXID: bf41b47481a9d1c99af0b62bb36bc864182312f39a3e1e06c8f6304ba8e58355

ScriptSig: 473044...8519 (signature + public key) ScriptPubKey: 76a914c5b28d6b...890fb288ac (OP_DUP OP_HASH160 hash OP_EQUALVERIFY OP_CHECKSIG)

P2PKH Stack Execution (Brief)#

│ sig │ → │ pk, sig │ → OP_DUP → │ pk, pk, sig │ → OP_HASH160 → hash match → OP_CHECKSIG → │ 1 (TRUE) │

From P2PKH to Advanced Scripts#

P2PKH is the smallest complete example of Bitcoin’s programmable money: stack-based execution, a hash commitment, and a signature check. Everything that follows reuses those three pieces and changes only what sits between them.

P2SH (Pay-to-Script-Hash):

  • Hashes an entire script instead of a public key, so spending conditions can be arbitrarily complex while the address stays short

  • Moves script complexity from the chain to the spender

  • The first step toward the script trees Taproot is built on

P2WPKH (Pay-to-Witness-Public-Key-Hash):

  • Keeps P2PKH’s logic but moves the signature into a separate witness

  • Separates signature data from transaction data

  • Fixes malleability, which is what Lightning depends on

P2TR (Pay-to-Taproot):

  • Carries the same stack model into Schnorr signatures and Merkle-committed script trees

  • Lets complex spending conditions look like simple payments

The opcodes get richer, but the execution model on this page does not change. In the next chapter we move to P2SH, which hides an entire script behind a single hash and reveals it only at spending time.

Chapter Summary#

This chapter set up the two things every later chapter assumes. The UTXO model holds value as discrete outputs, each consumed whole, which is what lets transactions validate in parallel with no shared balance to lock. And Bitcoin Script attaches a locking script to each output that an unlocking script must satisfy, checked together on a single LIFO stack.

  • P2PKH on the stack - OP_DUP OP_HASH160 <hash> OP_EQUALVERIFY OP_CHECKSIG: duplicate and hash the public key, confirm it matches the committed hash, then verify the signature.

  • From construction to chain - using bitcoinutils, we built, signed, and broadcast a real testnet P2PKH spend and traced its bytes back through the same seven steps.

Next. Chapter 3 moves to P2SH, which hides an entire script behind a single hash and reveals it only at spending time - the first step toward the script trees Taproot is built on.