TeachMeBitcoin

Approximate costs (relative):

From TeachMeBitcoin, the free encyclopedia Reading time: 6 min

25. Script Execution and Signature Caching

Overview

Signature verification is the most computationally expensive operation in Bitcoin script execution. OP_CHECKSIG, OP_CHECKMULTISIG, and OP_CHECKSIGADD each require one or more ECDSA or Schnorr signature verification operations, which involve elliptic curve point multiplication — a costly operation even on modern hardware.

When validating a block, Bitcoin nodes must execute every script in every transaction. Without optimization, this can become a significant bottleneck. Signature caching is the primary optimization technique used by Bitcoin Core to address this.

The Cost of Signature Verification

# Approximate costs (relative):
ECDSA verification (secp256k1, optimized): ~50-100 microseconds
Schnorr verification (secp256k1, optimized): ~40-80 microseconds

# A block with 3,000 transactions:
Average 2 inputs per transaction = 6,000 signature verifications
Total time without optimization: ~300-600 milliseconds per block

# With optimization target: <100ms per block
→ Caching is essential

Script Execution Engine: Overview

The Bitcoin script interpreter processes scripts through a stack-based virtual machine:

class ScriptInterpreter:
    def __init__(self):
        self.stack = []
        self.altstack = []
        self.flags = ScriptVerifyFlags()

    def execute(self, script_bytes, tx, input_index):
        """Execute a script against a transaction."""
        ops = parse_script(script_bytes)

        for op in ops:
            if op.is_push_data():
                self.stack.append(op.data)
            else:
                self.execute_opcode(op.code, tx, input_index)

        # Script succeeds if top of stack is truthy
        return bool(self.stack) and cast_to_bool(self.stack[-1])

    def execute_opcode(self, opcode, tx, input_index):
        if opcode == OP_CHECKSIG:
            pubkey = self.stack.pop()
            sig = self.stack.pop()
            result = self.check_sig(sig, pubkey, tx, input_index)
            self.stack.append(b'\x01' if result else b'')

The Signature Cache

Bitcoin Core maintains a global signature cache (implemented as an CSignatureCache) to avoid re-verifying signatures that have already been validated:

// Simplified C++ (from Bitcoin Core concept):

class CSignatureCache {
    // Hash set for O(1) lookup
    // Stores hashes of (signature, pubkey, sighash) triples
    std::unordered_set<uint256> valid_sigs;

    uint256 compute_cache_key(
        const std::vector<unsigned char>& sig,
        const CPubKey& pubkey,
        const uint256& sighash
    ) {
        // Hash the three components together
        CHashWriter ss(SER_GETHASH, 0);
        ss << sig << pubkey << sighash;
        return ss.GetHash();
    }

public:
    bool get(const std::vector<unsigned char>& sig,
             const CPubKey& pubkey,
             const uint256& sighash) {
        uint256 key = compute_cache_key(sig, pubkey, sighash);
        return valid_sigs.count(key) > 0;
    }

    void set(const std::vector<unsigned char>& sig,
             const CPubKey& pubkey,
             const uint256& sighash) {
        uint256 key = compute_cache_key(sig, pubkey, sighash);
        valid_sigs.insert(key);
    }
};

Cache Lookup Flow

OP_CHECKSIG execution with cache:

1. Pop pubkey and signature from stack

2. Compute sighash (serialize transaction, hash it)

3. Compute cache key = HASH(sig || pubkey || sighash)

4. Check cache:
   a. Cache HIT → return cached result immediately (no ECDSA verification)
   b. Cache MISS → perform full ECDSA/Schnorr verification
                   store result in cache
                   return result

Performance:
  Cache hit:  < 1 microsecond (hash lookup)
  Cache miss: 50-100 microseconds (full ECC verification)
python
def checksig_with_cache(sig, pubkey, tx, input_index, sig_cache):
    """OP_CHECKSIG implementation with caching."""
    # 1. Compute the sighash
    sighash_type = sig[-1]
    sighash = compute_sighash(tx, input_index, sighash_type)

    # 2. Check cache
    cache_key = hash_cache_entry(sig, pubkey, sighash)

    if sig_cache.get(cache_key):
        return True  # Fast path: already verified

    # 3. Full verification (slow path)
    result = ecdsa_verify(sig[:-1], pubkey, sighash)

    # 4. Cache the result if valid
    if result:
        sig_cache.set(cache_key, True)

    return result

Why Cache Valid-Only Results

The cache stores only valid signatures (not invalid ones). This is a security design decision:

Reason: An attacker could generate many deliberately invalid signatures,
        caching them all and eventually evicting valid cached entries.
        By only caching valid sigs, the cache serves as a "known good" set.

Collision resistance: The cache key is a 256-bit hash of three components.
        The probability of accidental collision is negligible (2^-256).
        An intentional preimage attack would require breaking SHA-256.

Parallel Validation

Modern Bitcoin Core uses multiple threads for script validation, leveraging the cache for thread safety:

Block arrives with N transactions:
├── Thread 1: validates tx_0, tx_1, ..., tx_(N/4)
├── Thread 2: validates tx_(N/4+1), ..., tx_(N/2)
├── Thread 3: validates tx_(N/2+1), ..., tx_(3N/4)
└── Thread 4: validates tx_(3N/4+1), ..., tx_N

Shared signature cache with mutex:
  Thread 1 verifies sig_A, caches it
  Thread 3 encounters same sig_A → cache hit, no reverification

Mempool vs Block Validation

Scenario: Transaction T enters the mempool.
  → Scripts executed, signatures verified
  → Valid signatures cached

Later: Block containing T arrives.
  → Scripts must be re-executed (consensus requires it)
  → But signature cache has the sig/pubkey/sighash entries
  → OP_CHECKSIG returns immediately with cached result
  → Block validation is dramatically faster

This is the main practical benefit of signature caching:
transactions validated in the mempool don't need full sig verification
when they later appear in a block.

Script Execution Limits and DoS Protection

# Consensus limits on script execution:
MAX_SCRIPT_SIZE         = 10,000 bytes
MAX_STACK_SIZE          = 1,000 elements
MAX_SCRIPT_ELEMENT_SIZE = 520 bytes per stack element
MAX_OPS_PER_SCRIPT      = 201 opcodes (not counting pushdata)
MAX_PUBKEYS_PER_MULTISIG = 20

# Signature operation (sigop) counting:
P2PKH input:         1 sigop
P2PK input:          1 sigop
OP_CHECKMULTISIG:    N sigops (or 20 if N is unknown)
MAX_BLOCK_SIGOPS:    20,000 per block

# Tapscript limits (looser due to Schnorr efficiency):
MAX_OPS_PER_SCRIPT: No limit (individual opcodes don't count toward limit)
Sigops: Counted differently — each OP_CHECKSIG/ADD counts as 50 "weight units"

Batch Verification for Schnorr Signatures

Taproot's Schnorr signatures enable batch verification, a technique that verifies multiple signatures simultaneously with far less work than verifying each independently:

def batch_verify_schnorr(sigs_and_keys: list, message_hashes: list) -> bool:
    """
    Verify multiple Schnorr signatures at once.
    Faster than individual verification when len(sigs_and_keys) > 1.
    """
    # Collect all (R, s, P, msg) tuples
    # Use random linear combination to check all at once
    # The batch verification equation:
    #   Σ(a_i * s_i) * G == Σ(a_i * R_i) + Σ(a_i * H(R_i||P_i||m_i) * P_i)
    # Where a_i are random scalars for each signature

    # Speed improvement:
    # Individual: n * 2 scalar multiplications
    # Batch:      ~1.5 * n scalar multiplications (using Pippenger's algorithm)
    # → ~25% speedup at n=100, ~50% at n=1000+

    ...

Complete Script Execution Summary

When a Bitcoin node receives a transaction:

1. Syntax check: Is the script valid bytecode?

2. Push/pop validation: Do all opcodes have correct stack inputs?

3. Standard scripts check: Is this a recognizable script type?
   (P2PKH, P2SH, P2WPKH, P2WSH, P2TR — others may be non-standard)

4. Execute scriptSig

5. Execute scriptPubKey

6. For P2SH: deserialize and execute redeemScript

7. For SegWit: validate witness program and data

8. For Taproot:
   a. Key path: verify single Schnorr signature against output key
   b. Script path: verify Merkle proof, then execute leaf script

9. Final stack check: top of stack must be truthy (non-zero, non-empty)

At step 6-8: OP_CHECKSIG/CHECKSIGADD consulted the signature cache
  → Cache hit: instant result
  → Cache miss: full ECC verification, then cache the valid result
☕ Help support TeachMeBitcoin

TeachMeBitcoin is an ad-free, open-source educational repository curated by a passionate team of Bitcoin researchers and educators for public benefit. If you found our articles helpful, please consider supporting our hosting and ongoing content updates with a clean donation:

Ethereum: 0x578417C51783663D8A6A811B3544E1f779D39A85
Bitcoin: bc1q77k9e95rn669kpzyjr8ke9w95zhk7pa5s63qzz
Solana: 4ycT2ayqeMucixj3wS8Ay8Tq9NRDYRPKYbj3UGESyQ4J
Address copied to clipboard!