Fetching latest headlinesโ€ฆ
How I Built a Time-Locked Bitcoin Script with CSV and P2SH
NORTH AMERICA
๐Ÿ‡บ๐Ÿ‡ธ United Statesโ€ขJuly 3, 2026

How I Built a Time-Locked Bitcoin Script with CSV and P2SH

0 views0 likes0 comments
Originally published byDev.to
HODLing is the beginning.
But Bitcoin was meant to be programmed.

โ€œNot Just HODLing: Real Bitcoin Script Engineeringโ€ starts here.

Why Read This Article?

In the Bitcoin development world, most tutorials only teach you concepts, not how to actually write code, and even fewer guide you to thoroughly dissect a real Bitcoin transaction from the perspective of on-chain data and script principles.

This article uses a real case and complete code to help you master, in one go:

  1. P2SH script validation mechanism

  2. CSV (CheckSequenceVerify) timelock mechanism

  3. P2PKH signature validation mechanism

Youโ€™ll see not just code, but also step-by-step on-chain data animation, as well as debug ideas and common error analysis at every stage.

Article Structure Preview

  • Motivation and Case Overview

  • Building and Spending the P2SH Script (Full Code)

  • Three-Layer Script Mechanism Breakdown

  • Animated Stack Execution Walkthrough

  • Debugging and On-Chain Data Analysis

  • Summary and Takeaways

1. Motivation and Case Overview

The goal of this case:

  • Use Python to build a P2SH address that can only be spent after 3 blocks (a P2PKH script with a CSV timelock)

  • Actually send and spend coins, broadcasting on-chain for real

  • Through this single case, help you fully understand P2SH, CSV, and P2PKH script mechanisms, and be able to reverse every step in a block explorer

The real on-chain transaction for this case:

https://mempool.space/testnet/tx/34f5bf0cf328d77059b5674e71442ded8cdcfc723d0136733e0dbf180861906f

I recommend opening this link as you read, and comparing each script and data step by step.

2. Building and Spending the P2SH Script (Full Code)

1. Build the P2SH (P2PKH with CSV timelock)

from bitcoinutils.setup import setup
from bitcoinutils.transactions import Sequence
from bitcoinutils.keys import P2shAddress, PrivateKey
from bitcoinutils.script import Script
from bitcoinutils.constants import TYPE_RELATIVE_TIMELOCK
import os, sys
import configparser

conf = configparser.ConfigParser()
conf_file = os.path.join(os.path.abspath(os.path.dirname(sys.argv[0])), "wa_info.conf")
conf.read(conf_file)
def main():
    setup("testnet")
    relative_blocks = 3
    seq = Sequence(TYPE_RELATIVE_TIMELOCK, relative_blocks)
    p2pkh_sk = PrivateKey(conf.get("testnet3", "private_key_wif"))
    p2pkh_addr = p2pkh_sk.get_public_key().get_address()
    redeem_script = Script([
        seq.for_script(),
        "OP_CHECKSEQUENCEVERIFY",
        "OP_DROP",
        "OP_DUP",
        "OP_HASH160",
        p2pkh_addr.to_hash160(),
        "OP_EQUALVERIFY",
        "OP_CHECKSIG",
    ])
    print(f"Redeem script (hex): {redeem_script.to_hex()}")
    addr = P2shAddress.from_script(redeem_script)
    print(f"P2SH address: {addr.to_string()}")
    conf.set('testnet3', 'p2sh_csv_addr', addr.to_string())
    with open(conf_file, "w") as f:
        conf.write(f)
if __name__ == "__main__":
    main()

2. Spend the P2SH (spend script)

from bitcoinutils.setup import setup
from bitcoinutils.utils import to_satoshis
from bitcoinutils.transactions import Transaction, TxInput, TxOutput, Sequence
from bitcoinutils.keys import P2pkhAddress, PrivateKey
from bitcoinutils.script import Script
from bitcoinutils.constants import TYPE_RELATIVE_TIMELOCK
import os, sys
import configparser
import requests

conf = configparser.ConfigParser()
conf_file = os.path.join(os.path.abspath(os.path.dirname(sys.argv[0])), "wa_info.conf")
conf.read(conf_file)
def main():
    setup("testnet")
    relative_blocks = 3
    txid = "8e763d0269963e666949606e84e811a24df9cb525d7c436096b0c8a72be3533f"
    vout = 0
    seq = Sequence(TYPE_RELATIVE_TIMELOCK, relative_blocks)
    seq_for_n_seq = seq.for_input_sequence()
    txin = TxInput(txid, vout, sequence=seq_for_n_seq)
    p2pkh_sk = PrivateKey(conf.get("testnet3", "private_key_wif"))
    p2pkh_pk = p2pkh_sk.get_public_key().to_hex()
    p2pkh_addr = p2pkh_sk.get_public_key().get_address()
    redeem_script = Script([
        seq.for_script(),
        "OP_CHECKSEQUENCEVERIFY",
        "OP_DROP",
        "OP_DUP",
        "OP_HASH160",
        p2pkh_addr.to_hash160(),
        "OP_EQUALVERIFY",
        "OP_CHECKSIG",
    ])
    to_addr = P2pkhAddress("mn3XG8tZQjyUsoAmECTYFqVUECNJzEdapj")
    txout = TxOutput(to_satoshis(0.00000588), to_addr.to_script_pub_key())
    tx = Transaction([txin], [txout])
    print("Raw unsigned transaction:", tx.serialize())
    sig = p2pkh_sk.sign_input(tx, 0, redeem_script)
    txin.script_sig = Script([sig, p2pkh_pk, redeem_script.to_hex()])
    signed_tx = tx.serialize()
    print("Raw signed transaction:", signed_tx)
    print("TxId:", tx.get_txid())
    mempool_api = "<https://mempool.space/testnet/api/tx>"
    try:
        response = requests.post(mempool_api, data=signed_tx)
        if response.status_code == 200:
            txid = response.text
            print(f"Success! TxID: {txid}")
            print(f"View transaction: <https://mempool.space/testnet/tx/{txid}>")
        else:
            print(f"Broadcast failed: {response.text}")
    except Exception as e:
        print(f"Error: {e}")
if __name__ == "__main__":
    main()

3. Three-Layer Script Mechanism Breakdown

1. P2SH Validation (Outer Layer)

On-chain locking script (scriptPubKey):

OP_HASH160 <20-byte script hash> OP_EQUAL

Purpose: Checks if the redeem script you provide matches the hash locked in the output.

2. CSV Timelock Validation (Middle Layer)

First part of the redeem script:

3 OP_CHECKSEQUENCEVERIFY OP_DROP

Purpose: Only if nSequence is at least 3 (i.e., after 3 blocks), the script continues.

3. P2PKH Signature Validation (Inner Layer)

Second part of the redeem script:

OP_DUP OP_HASH160 <pubkey hash> OP_EQUALVERIFY OP_CHECKSIG

Purpose: Standard Bitcoin signature validation.

4. Animated Stack Execution Walkthrough

1. P2SH Validation Stage

Step 1: ScriptSig pushes redeem script

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚53b27576...88ac (redeem script)โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚...(pubkey, signature)         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Step 2: OP_HASH160

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚79b6f3...cabaadcc (redeem script HASH160)โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚...(pubkey, signature)         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Step 3: OP_PUSHBYTES_20

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚79b6f3...cabaadcc (script hash in output)โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚79b6f3...cabaadcc (redeem script HASH160)โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚...(pubkey, signature)         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Step 4: OP_EQUAL

  • Compares the top two hashes. If equal, continue; otherwise, fail.

2. CSV Timelock Validation Stage

Step 1: Push 3 (CSV lock value)

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚3           โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚0250be...   โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚30440220... โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Step 2: OP_CHECKSEQUENCEVERIFY

  • If nSequence < 3, fail with non-BIP68-final.

  • If nSequence โ‰ฅ 3, continue.

Step 3: OP_DROP

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚0250be...   โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚30440220... โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

3. P2PKH Signature Validation Stage

Step 1: OP_DUP

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚0250be...   โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚0250be...   โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚30440220... โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Step 2: OP_HASH160

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚5cdc28...   โ”‚  โ† pubkey HASH160
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚0250be...   โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚30440220... โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Step 3: OP_PUSHBYTES_20 (pubkey hash in script)

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚5cdc28...   โ”‚  โ† script's pubkey hash
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚5cdc28...   โ”‚  โ† just computed HASH160
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚0250be...   โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚30440220... โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Step 4: OP_EQUALVERIFY

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚0250be...   โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚30440220... โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Step 5: OP_CHECKSIG

  • Uses pubkey and signature to verify. If valid, script succeeds and coins are spent.

5. Debugging and On-Chain Data Analysis

1. Common Errors and Troubleshooting

(1) non-BIP68-final

  • Symptom: Broadcast fails with non-BIP68-final

  • Reason: You set a CSV timelock (e.g., 3 blocks), but not enough blocks have passed; nSequence is insufficient

  • Solution:

  • Wait for enough blocks before spending

  • Check that your relative_blocks parameter and nSequence match

(2) bad-txns-in-belowout

  • Symptom: Broadcast fails with bad-txns-in-belowout, value in < value out

  • Reason: Your input UTXO value is less than the output value (including change and fee)

  • Solution:

  • Check your input UTXO value

  • Set output and change properly, leaving enough for fees

2. On-Chain Data Analysis Walkthrough

Step 1: Open a block explorer

Step 2: Compare ScriptSig and scriptPubKey

  • ScriptSig: See OP_PUSHBYTES_71 (signature), OP_PUSHBYTES_33 (pubkey), OP_PUSHBYTES_28 (redeem script)

  • Previous output script (scriptPubKey): OP_HASH160 <20-byte hash> OP_EQUAL

Step 3: Manually step through script execution

  • Use ScriptSig data to execute P2SH validation (OP_HASH160 + OP_EQUAL)

  • If valid, execute redeem script: first CSV timelock (OP_CHECKSEQUENCEVERIFY), then P2PKH signature check

  • Use the stack animation above to follow each step

Step 4: Compare HEX and ASM

  • In the block explorer, find the HEX and ASM for ScriptSig and scriptPubKey

  • Match each byte and opcode to your code and the execution steps

3. Debugging Tips

  • When you see errors, check mempool.space or blockstream.info for actual on-chain data

  • Print raw transactions and script contents at each step, and compare with on-chain data

  • If script execution fails, draw the stack changes by hand to find where it went wrong

4. Real Case Analysis

For this case, you can see on mempool.space:

  • Each ScriptSig item (signature, pubkey, redeem script)

  • The scriptPubKey hash

  • The actual execution order and stack changes

  • If you spend too early, youโ€™ll see non-BIP68-final

With this code-onchain-debug workflow, you can not only write scripts, but also understand and debug every on-chain transaction.

6. Summary and Takeaways

Through a real case and code, you have mastered:

  • P2SH script validation

  • CSV timelock mechanism

  • P2PKH signature validation

  • Animated stack-based script execution

  • Debugging and on-chain data analysis

This is the essence of Real Bitcoin Script Engineering.

Source Code

All code and on-chain data analysis are available at my github repo

Feel free to Star and Fork!

References

By Aaron Recompile on June 29, 2025.

Canonical link

Exported from Medium on July 3, 2026.

Comments (0)

Sign in to join the discussion

Be the first to comment!