Skip to main content
digital payment systems cryptography banking protocols and blockchain internals

Blockchain Payment Architectures

7 min read Chapter 10 of 21

Blockchain Payment Architectures

Blockchain networks are payment systems. Strip away the ideology and speculation, and you’re left with a distributed ledger that tracks balances, validates transfers, and achieves consensus on transaction ordering without a central authority. Whether this architecture is superior to traditional payment rails depends entirely on the specific use case — and understanding why requires dissecting the mechanics.

UTXO vs Account Model

The diagram above contrasts the two dominant models for tracking balances in blockchain systems. The choice between UTXO and Account models has profound implications for parallelism, privacy, and smart contract expressiveness.

Bitcoin: Payment by Proof

Bitcoin’s design philosophy is radical: every payment is verified by every full node from first principles. There’s no “trust the bank’s database” — the transaction carries its own proof of validity.

Transaction Structure

A Bitcoin transaction consumes previous outputs (spending them) and creates new outputs (paying recipients):

from dataclasses import dataclass, field
import hashlib

@dataclass
class TransactionInput:
    """
    References a previous transaction output being spent.
    """
    prev_tx_hash: bytes   # 32 bytes — hash of the transaction containing the output
    output_index: int      # Which output of that transaction
    script_sig: bytes      # Unlocking script (proves ownership)
    sequence: int = 0xFFFFFFFF

@dataclass
class TransactionOutput:
    """
    Creates a new spendable output.
    """
    value_satoshis: int    # Amount in satoshis (1 BTC = 100,000,000 satoshis)
    script_pubkey: bytes   # Locking script (defines spending conditions)

@dataclass
class Transaction:
    version: int = 2
    inputs: list[TransactionInput] = field(default_factory=list)
    outputs: list[TransactionOutput] = field(default_factory=list)
    locktime: int = 0
    
    def txid(self) -> bytes:
        """
        Transaction ID = double SHA-256 of the serialized transaction.
        
        Note: the txid does NOT include witness data (SegWit).
        This separation was introduced to fix transaction malleability,
        which caused issues for payment channel protocols.
        """
        serialized = self.serialize()
        return hashlib.sha256(hashlib.sha256(serialized).digest()).digest()
    
    def serialize(self) -> bytes:
        """Serialize the transaction to its wire format."""
        result = bytearray()
        
        # Version (4 bytes, little-endian)
        result.extend(self.version.to_bytes(4, 'little'))
        
        # Input count (varint)
        result.extend(self._varint(len(self.inputs)))
        
        for inp in self.inputs:
            result.extend(inp.prev_tx_hash[::-1])  # Reversed byte order
            result.extend(inp.output_index.to_bytes(4, 'little'))
            result.extend(self._varint(len(inp.script_sig)))
            result.extend(inp.script_sig)
            result.extend(inp.sequence.to_bytes(4, 'little'))
        
        # Output count (varint)
        result.extend(self._varint(len(self.outputs)))
        
        for out in self.outputs:
            result.extend(out.value_satoshis.to_bytes(8, 'little'))
            result.extend(self._varint(len(out.script_pubkey)))
            result.extend(out.script_pubkey)
        
        # Locktime (4 bytes)
        result.extend(self.locktime.to_bytes(4, 'little'))
        
        return bytes(result)
    
    @staticmethod
    def _varint(n: int) -> bytes:
        if n < 0xFD:
            return bytes([n])
        elif n <= 0xFFFF:
            return b'\xFD' + n.to_bytes(2, 'little')
        elif n <= 0xFFFFFFFF:
            return b'\xFE' + n.to_bytes(4, 'little')
        else:
            return b'\xFF' + n.to_bytes(8, 'little')
    
    @property
    def fee(self) -> int:
        """
        Transaction fee = sum(inputs) - sum(outputs).
        
        The fee is implicit — it's whatever value is left over
        after all outputs are accounted for. The miner who includes
        this transaction in a block collects the fee.
        
        This design means there's no "fee field" that can be forged.
        The fee is a mathematical consequence of the inputs and outputs.
        """
        # In practice, you need to look up input values from the UTXO set
        # This is a simplification
        input_total = sum(getattr(inp, 'value', 0) for inp in self.inputs)
        output_total = sum(out.value_satoshis for out in self.outputs)
        return input_total - output_total

Script: Bitcoin’s Verification Language

Bitcoin Script is a stack-based language deliberately designed to be non-Turing-complete. It can express conditions like “this output can be spent by whoever provides a valid signature for public key X” but cannot express loops, recursion, or arbitrary computation.

class ScriptVM:
    """
    Simplified Bitcoin Script virtual machine.
    
    The VM executes the unlocking script (scriptSig) followed by
    the locking script (scriptPubKey). If the stack's top element
    is truthy after execution, the spend is valid.
    """
    
    def __init__(self):
        self.stack: list[bytes] = []
        self.alt_stack: list[bytes] = []
    
    def execute(self, script_sig: bytes, script_pubkey: bytes) -> bool:
        """
        Execute scriptSig + scriptPubKey and return True if valid.
        """
        # Execute scriptSig (pushes data onto stack)
        self._execute_script(script_sig)
        
        # Save stack state (for P2SH verification)
        stack_copy = list(self.stack)
        
        # Execute scriptPubKey (verifies conditions)
        self._execute_script(script_pubkey)
        
        # Transaction is valid if top of stack is truthy
        if not self.stack or self.stack[-1] == b'':
            return False
        return True
    
    def _execute_script(self, script: bytes):
        pos = 0
        while pos < len(script):
            opcode = script[pos]
            pos += 1
            
            # Data push opcodes (1-75 bytes)
            if 1 <= opcode <= 75:
                self.stack.append(script[pos:pos + opcode])
                pos += opcode
            
            elif opcode == 0x76:  # OP_DUP
                self.stack.append(self.stack[-1])
            
            elif opcode == 0xA9:  # OP_HASH160
                data = self.stack.pop()
                sha = hashlib.sha256(data).digest()
                h160 = hashlib.new('ripemd160', sha).digest()
                self.stack.append(h160)
            
            elif opcode == 0x88:  # OP_EQUALVERIFY
                a = self.stack.pop()
                b = self.stack.pop()
                if a != b:
                    raise ScriptError("OP_EQUALVERIFY failed")
            
            elif opcode == 0xAC:  # OP_CHECKSIG
                pubkey = self.stack.pop()
                signature = self.stack.pop()
                # Verify ECDSA signature (simplified)
                valid = self._verify_signature(signature, pubkey)
                self.stack.append(b'\x01' if valid else b'')
    
    def _verify_signature(self, sig: bytes, pubkey: bytes) -> bool:
        """Verify ECDSA signature against the transaction hash."""
        # In production: hash the transaction with SIGHASH flags,
        # then verify the ECDSA signature on secp256k1
        return True  # Placeholder

class ScriptError(Exception):
    pass

The most common script pattern is P2PKH (Pay-to-Public-Key-Hash):

Locking script (scriptPubKey):
  OP_DUP OP_HASH160 <pubkey_hash> OP_EQUALVERIFY OP_CHECKSIG

Unlocking script (scriptSig):
  <signature> <pubkey>

Execution:
  Stack: [signature, pubkey]     ← scriptSig pushed these
  OP_DUP:    [signature, pubkey, pubkey]
  OP_HASH160: [signature, pubkey, hash(pubkey)]
  <push>:    [signature, pubkey, hash(pubkey), expected_hash]
  OP_EQUALVERIFY: [signature, pubkey]  ← hashes match, continue
  OP_CHECKSIG: [true]  ← signature valid for this pubkey

Ethereum: Programmable Payments

Ethereum replaces Bitcoin’s UTXO model with an account model and its limited Script with a Turing-complete virtual machine (EVM). This enables payment logic that Bitcoin cannot express: escrow contracts, streaming payments, multi-party splits, and conditional releases.

from dataclasses import dataclass
from web3 import Web3

@dataclass
class EthereumTransaction:
    """
    An Ethereum transaction modifies world state by:
    1. Transferring ETH from sender to recipient
    2. Executing smart contract code
    3. Updating contract storage
    
    Unlike Bitcoin, Ethereum transactions have explicit gas pricing:
    - gasLimit: maximum compute units this transaction can consume
    - maxFeePerGas: maximum total fee per gas unit (EIP-1559)
    - maxPriorityFeePerGas: tip to the block proposer
    """
    to: str                       # Recipient address (or contract)
    value_wei: int                # Amount in wei (1 ETH = 10^18 wei)
    nonce: int                    # Sender's transaction count (replay protection)
    gas_limit: int                # Maximum gas units
    max_fee_per_gas: int          # Max fee per gas (wei)
    max_priority_fee_per_gas: int # Tip per gas (wei)
    data: bytes = b''             # Contract call data (ABI-encoded)
    chain_id: int = 1             # 1 = mainnet, prevents cross-chain replay

def build_payment_transaction(
    web3: Web3,
    sender: str,
    recipient: str,
    amount_eth: float,
    private_key: str
) -> str:
    """
    Build and sign an ETH transfer transaction.
    
    Key differences from traditional payment:
    - Nonce provides replay protection (like ATC in EMV)
    - Gas price is a market-driven fee (like interchange, but dynamic)
    - Transaction is final after ~12 seconds (1 block) with high confidence
    - No intermediary: sender signs, network validates, recipient credits
    """
    nonce = web3.eth.get_transaction_count(sender)
    
    # Get current gas prices from the network
    latest_block = web3.eth.get_block('latest')
    base_fee = latest_block['baseFeePerGas']
    
    transaction = {
        'to': recipient,
        'value': web3.to_wei(amount_eth, 'ether'),
        'nonce': nonce,
        'gas': 21000,  # Standard ETH transfer gas cost
        'maxFeePerGas': base_fee * 2,  # 2x base fee for inclusion buffer
        'maxPriorityFeePerGas': web3.to_wei(2, 'gwei'),  # Tip
        'chainId': 1,
        'type': 2,  # EIP-1559 transaction
    }
    
    signed = web3.eth.account.sign_transaction(transaction, private_key)
    tx_hash = web3.eth.send_raw_transaction(signed.raw_transaction)
    
    return tx_hash.hex()

Performance Comparison with Traditional Rails

MetricVisa NetworkBitcoinEthereumFedNow
TPS (peak)65,000730500+
FinalityT+1 day (settlement)~60 min (6 blocks)~12 sec (1 block)< 20 sec
Fee per txn$0.05-$0.30$1-$50 (variable)$0.50-$100 (variable)$0.01
Energy per txn~0.001 kWh~700 kWh (PoW)~0.03 kWh (PoS)Negligible
ReversibilityChargebacks possibleIrreversibleIrreversibleIrreversible
PrivacyPseudonymous (acquirer sees all)Pseudonymous (chain analysis)PseudonymousFull identity (KYC)

The throughput gap is the most visible limitation, but the finality model is more architecturally significant. Card networks offer fast authorization (2 seconds) but slow settlement (1-2 days). Bitcoin offers slow confirmation (60 minutes) but that IS the settlement — there’s no separate clearing and settlement phase. Ethereum’s ~12-second block time with single-slot finality provides a middle ground.

For payment system architects, the question isn’t “which is better?” but “which finality model matches my use case?” Cross-border B2B payments that currently take 3-5 days through correspondent banking can benefit from blockchain’s 12-second finality. Point-of-sale retail payments need the 2-second authorization that card networks provide — waiting 12 seconds for a block confirmation is unacceptable.