Script Validation

Lesson, slides, and applied problem sets.

View Slides

Lesson

Script Validation (P2PKH-lite)

This module is where Bitcoin's "don't trust, verify" becomes executable. You will build a tiny script engine that behaves deterministically under malformed data, stack errors, and signature failures.

The challenge is not just crypto. It is the combination of:

  • byte-level script parsing,
  • strict stack-machine semantics,
  • deterministic signature-hash construction, and
  • fail-closed behavior on every invalid path.

Learning goals

  • Parse mixed pushdata/opcode scripts without desynchronizing the byte stream.
  • Execute P2PKH-limited opcodes with exact stack pop/push order.
  • Build the exact simplified sighash used by the pack.
  • Verify DER signatures over a parsed uncompressed P-256 public key.
  • Diagnose failure modes quickly using stack-state checkpoints.

Scope of this module (important)

This is a toy but strict validator:

  • Hash op is HASH256 (double SHA-256), not Bitcoin HASH160.
  • Curve is P-256, not secp256k1.
  • Supported opcodes are only: OP_DUP, OP_HASH256, OP_EQUALVERIFY, OP_CHECKSIG.
  • Any unknown opcode or malformed pushdata is immediate failure.

1) P2PKH execution flow at byte level

You execute two scripts in order against one shared stack:

  1. scriptSig
  2. scriptPubKey

Typical bytes in this module:

scriptSig:

  • len(sig) (1 byte, 1..75)
  • sig bytes
  • len(pubkey) (1 byte, usually 65)
  • pubkey bytes (0x04 || X32 || Y32)

scriptPubKey (P2PKH-lite):

  • 0x76 OP_DUP
  • 0xAA OP_HASH256
  • 0x20 push 32 bytes
  • <32-byte pubkey hash>
  • 0x88 OP_EQUALVERIFY
  • 0xAC OP_CHECKSIG

Mental model

  • scriptSig prepares witness data (signature + pubkey).
  • scriptPubKey enforces lock conditions (hash match + signature check).

2) Parser contract: pushdata vs opcode

At byte script[i]:

  • If 1 <= script[i] <= 75: this is pushdata length n.
    • Require i+1+n <= len(script), else fail.
    • Push exact n bytes to stack.
    • Advance by 1+n.
  • Else treat as opcode byte.
    • One-byte opcodes only in this module.

Why parser strictness matters

A single off-by-one in pushdata length changes where opcodes are read. That means different nodes could "execute" different programs over identical bytes.

Hidden-test aligned failures

Your parser must return false on:

  • declared push length running past script end,
  • unknown opcode values.

3) Stack semantics and exact pop order

OP_DUP (0x76)

  • Requires at least 1 stack item.
  • Duplicates top item (copy bytes, do not alias mutable backing unexpectedly).

OP_HASH256 (0xAA)

  • Requires at least 1 stack item.
  • Replaces top with HASH256(top).

OP_EQUALVERIFY (0x88)

  • Requires at least 2 items.
  • Pops two items (a=top, b=next).
  • Fails unless byte-equal.
  • Pushes nothing on success.

OP_CHECKSIG (0xAC)

  • Requires at least 2 items.
  • Pops pubkey from top, then signature from next.
  • Verifies signature against simplified sighash.
  • Pushes 1 byte if valid, else 0 byte.

Final success rule

After both scripts:

  • stack must be non-empty
  • top item must be "truthy" (at least one non-zero byte)

Any parse error, stack underflow, invalid pubkey encoding, invalid curve point, or failed signature/hash check => return false.


4) Simplified sighash used in this pack

For tx, inputIndex, and current scriptPubKey:

S = HASH256(tx.ID || uint32_le(inputIndex) || scriptPubKey)

Implementation details:

  • tx.ID is 32 bytes.
  • inputIndex is 4-byte little-endian.
  • Concatenate raw bytes in that exact order.
  • Apply SHA-256 twice.

Why this exact format matters

If you accidentally use big-endian input index or hash only once, all signatures will fail even with correct keys.


5) Public key and signature verification contract

Pubkey parsing

Accept only uncompressed encoding:

  • length exactly 65
  • first byte 0x04
  • X = bytes [1:33], Y = bytes [33:65]
  • point must satisfy elliptic.P256().IsOnCurve(X, Y)

Signature check

Use:

  • ecdsa.VerifyASN1(pub, sighash[:], sig)

Invalid DER signature, wrong key, or wrong sighash should produce false path by pushing 0 at OP_CHECKSIG and eventually failing truthiness.


6) End-to-end stack trace (worked)

Assume:

  • scriptSig pushes [sig, pub]
  • scriptPubKey is DUP HASH256 <expectedHash> EQUALVERIFY CHECKSIG

Initial stack: []

After scriptSig:

  1. push sig -> [sig]
  2. push pub -> [sig, pub]

Execute scriptPubKey:

  1. OP_DUP -> [sig, pub, pub]
  2. OP_HASH256 -> [sig, pub, H(pub)]
  3. push <expectedHash> -> [sig, pub, H(pub), expected]
  4. OP_EQUALVERIFY
    • pop expected, H(pub)
    • if unequal -> fail
    • if equal -> [sig, pub]
  5. OP_CHECKSIG
    • pop pub, then sig
    • verify over S
    • push [1] if valid else [0]

End:

  • [1] => success
  • [0] or empty => failure

7) Near-solution architecture

Top-level evaluator

func EvalP2PKH(tx Transaction, inputIndex int, scriptSig, scriptPubKey []byte) bool {
    if inputIndex < 0 || inputIndex >= len(tx.Inputs) {
        return false
    }

    stack := make([][]byte, 0)
    if !execScript(&stack, scriptSig, tx, inputIndex, scriptPubKey) {
        return false
    }
    if !execScript(&stack, scriptPubKey, tx, inputIndex, scriptPubKey) {
        return false
    }

    if len(stack) == 0 {
        return false
    }
    return !isZero(stack[len(stack)-1])
}

Script executor skeleton

func execScript(stack *[][]byte, script []byte, tx Transaction, idx int, scriptPubKey []byte) bool {
    for i := 0; i < len(script); {
        op := script[i]

        if op >= 1 && op <= 75 {
            n := int(op)
            if i+1+n > len(script) {
                return false
            }
            data := make([]byte, n)
            copy(data, script[i+1:i+1+n])
            *stack = append(*stack, data)
            i += 1 + n
            continue
        }

        switch op {
        case 0x76: // DUP
            // underflow checks + copy
        case 0xAA: // HASH256
            // replace top with hash
        case 0x88: // EQUALVERIFY
            // pop two, compare, fail if mismatch
        case 0xAC: // CHECKSIG
            // pop pubkey then sig, verify, push 1/0
        default:
            return false
        }
        i++
    }
    return true
}

8) Failure matrix (what should return false)

  • inputIndex out of range.
  • malformed pushdata length.
  • unknown opcode.
  • stack underflow on any opcode.
  • hash mismatch at OP_EQUALVERIFY.
  • bad pubkey length or wrong prefix.
  • pubkey point not on curve.
  • invalid ASN.1 DER signature or signature mismatch.
  • final top-of-stack all-zero or empty stack.

If your implementation fails hidden tests, check this matrix first.


9) Debugging checklist

  1. Parser position: log i and decoded token kind.
  2. Stack snapshots: log stack depth after each opcode.
  3. Pop order: ensure CHECKSIG pops pubkey first, signature second.
  4. Sighash bytes: verify byte order for inputIndex is little-endian.
  5. Curve check: reject off-curve points before verification.
  6. Truthiness: final top item is false only if all bytes are zero.

What you will build

  • Byte parser for pushdata + opcode stream.
  • Deterministic stack engine for P2PKH-lite opcodes.
  • Simplified Bitcoin-style signature verification pipeline.
  • Fail-closed behavior on malformed inputs.

Start by implementing parser + stack underflow guards, then add opcode behavior, then wire signature verification last.


Module Items

Join Discord