|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import hashlib |
| 4 | +import json |
| 5 | +from time import time |
| 6 | + |
| 7 | + |
| 8 | +class Block: |
| 9 | + """ |
| 10 | + A single block in a proof-of-work blockchain. |
| 11 | +
|
| 12 | + Each block contains data along with a cryptographic hash linking it to the |
| 13 | + previous block. The block's hash must satisfy a difficulty target (start |
| 14 | + with a certain number of zero bits) which is found through mining. |
| 15 | +
|
| 16 | + Attributes: |
| 17 | + index: Position of the block in the chain |
| 18 | + timestamp: Unix time when the block was created |
| 19 | + data: Payload stored in the block |
| 20 | + previous_hash: Hash of the previous block (hex digest) |
| 21 | + nonce: Number used to satisfy the proof-of-work requirement |
| 22 | + hash: SHA-256 hash of this block (hex digest) |
| 23 | +
|
| 24 | + >>> Block(0, 1000.0, "genesis", "0", 0, "0000abc") |
| 25 | + Block(0, 1000.0, 'genesis', '0', 0, hash=0000abc) |
| 26 | +
|
| 27 | + Blocks with the same content are equal: |
| 28 | + >>> Block(0, 0.0, "a", "0", 1, "ab") == Block(0, 0.0, "a", "0", 1, "ab") |
| 29 | + True |
| 30 | + >>> Block(0, 0.0, "a", "0", 1, "ab") == Block(0, 0.0, "b", "0", 1, "ab") |
| 31 | + False |
| 32 | + """ |
| 33 | + |
| 34 | + def __init__( |
| 35 | + self, |
| 36 | + index: int, |
| 37 | + timestamp: float, |
| 38 | + data: str, |
| 39 | + previous_hash: str, |
| 40 | + nonce: int, |
| 41 | + hash_value: str, |
| 42 | + ) -> None: |
| 43 | + self.index = index |
| 44 | + self.timestamp = timestamp |
| 45 | + self.data = data |
| 46 | + self.previous_hash = previous_hash |
| 47 | + self.nonce = nonce |
| 48 | + self.hash = hash_value |
| 49 | + |
| 50 | + def __repr__(self) -> str: |
| 51 | + return ( |
| 52 | + f"Block({self.index}, {self.timestamp}, " |
| 53 | + f"{self.data!r}, {self.previous_hash!r}, {self.nonce}, " |
| 54 | + f"hash={self.hash})" |
| 55 | + ) |
| 56 | + |
| 57 | + def __eq__(self, other: object) -> bool: |
| 58 | + if not isinstance(other, Block): |
| 59 | + return NotImplemented |
| 60 | + return ( |
| 61 | + self.index == other.index |
| 62 | + and self.timestamp == other.timestamp |
| 63 | + and self.data == other.data |
| 64 | + and self.previous_hash == other.previous_hash |
| 65 | + and self.nonce == other.nonce |
| 66 | + and self.hash == other.hash |
| 67 | + ) |
| 68 | + |
| 69 | + def to_dict(self) -> dict: |
| 70 | + """Serialise the block to a JSON-compatible dictionary. |
| 71 | +
|
| 72 | + >>> Block(0, 0.0, "a", "0", 0, "ab").to_dict()["data"] |
| 73 | + 'a' |
| 74 | + """ |
| 75 | + return { |
| 76 | + "index": self.index, |
| 77 | + "timestamp": self.timestamp, |
| 78 | + "data": self.data, |
| 79 | + "previous_hash": self.previous_hash, |
| 80 | + "nonce": self.nonce, |
| 81 | + "hash": self.hash, |
| 82 | + } |
| 83 | + |
| 84 | + |
| 85 | +def calculate_hash( |
| 86 | + index: int, |
| 87 | + timestamp: float, |
| 88 | + data: str, |
| 89 | + previous_hash: str, |
| 90 | + nonce: int, |
| 91 | +) -> str: |
| 92 | + """Compute the SHA-256 hash of a block's contents. |
| 93 | +
|
| 94 | + The hash is taken over the concatenation of index, timestamp, data, |
| 95 | + previous_hash, and nonce, serialised as a JSON string. |
| 96 | +
|
| 97 | + >>> h = calculate_hash(1, 0.0, "tx", "0000", 42) |
| 98 | + >>> isinstance(h, str) and len(h) == 64 |
| 99 | + True |
| 100 | +
|
| 101 | + Same inputs always produce the same hash: |
| 102 | + >>> a = calculate_hash(1, 0.0, "tx", "0000", 42) |
| 103 | + >>> a == calculate_hash(1, 0.0, "tx", "0000", 42) |
| 104 | + True |
| 105 | + """ |
| 106 | + payload = json.dumps( |
| 107 | + [index, timestamp, data, previous_hash, nonce], |
| 108 | + sort_keys=True, |
| 109 | + separators=(",", ":"), |
| 110 | + ) |
| 111 | + return hashlib.sha256(payload.encode()).hexdigest() |
| 112 | + |
| 113 | + |
| 114 | +def mine_block( |
| 115 | + index: int, |
| 116 | + timestamp: float, |
| 117 | + data: str, |
| 118 | + previous_hash: str, |
| 119 | + difficulty: int, |
| 120 | +) -> Block: |
| 121 | + """Mine a new block by finding a nonce that satisfies the difficulty target. |
| 122 | +
|
| 123 | + The block's hash must start with ``difficulty`` consecutive zero characters. |
| 124 | + This is a brute-force search that increments the nonce until a valid hash |
| 125 | + is found. Higher difficulty values require exponentially more work. |
| 126 | +
|
| 127 | + Difficulty must be a non-negative integer: |
| 128 | + Mined blocks always satisfy the proof-of-work requirement: |
| 129 | + >>> b = mine_block(1, 0.0, "hello", "abcd", 3) |
| 130 | + >>> b.hash[:3] |
| 131 | + '000' |
| 132 | +
|
| 133 | + >>> b = mine_block(2, 0.0, "world", b.hash, 4) |
| 134 | + >>> b.hash[:4] |
| 135 | + '0000' |
| 136 | +
|
| 137 | + Different nonces produce different hashes for the same block data: |
| 138 | + >>> a = calculate_hash(5, 0.0, "same", "prev", 0) |
| 139 | + >>> b = calculate_hash(5, 0.0, "same", "prev", 1) |
| 140 | + >>> a != b |
| 141 | + True |
| 142 | + """ |
| 143 | + prefix = "0" * difficulty |
| 144 | + nonce = 0 |
| 145 | + hash_value = calculate_hash(index, timestamp, data, previous_hash, nonce) |
| 146 | + while not hash_value.startswith(prefix): |
| 147 | + nonce += 1 |
| 148 | + hash_value = calculate_hash( |
| 149 | + index, timestamp, data, previous_hash, nonce |
| 150 | + ) |
| 151 | + return Block(index, timestamp, data, previous_hash, nonce, hash_value) |
| 152 | + |
| 153 | + |
| 154 | +class Blockchain: |
| 155 | + """ |
| 156 | + A simple proof-of-work blockchain. |
| 157 | +
|
| 158 | + Maintains a chain of blocks where each block references the hash of the |
| 159 | + previous block. New blocks are mined with an adjustable difficulty level, |
| 160 | + and the integrity of the chain can be verified at any time. |
| 161 | +
|
| 162 | + >>> chain = Blockchain(difficulty=2) |
| 163 | + >>> _ = chain.add_block("Payment: Alice -> Bob: 10 BTC") |
| 164 | + >>> _ = chain.add_block("Payment: Bob -> Charlie: 5 BTC") |
| 165 | + >>> len(chain) |
| 166 | + 3 |
| 167 | + >>> chain.is_valid() |
| 168 | + True |
| 169 | +
|
| 170 | + Tampering with block data invalidates the chain: |
| 171 | + >>> chain.chain[1].data = "Tampered!" |
| 172 | + >>> chain.is_valid() |
| 173 | + False |
| 174 | + """ |
| 175 | + |
| 176 | + def __init__(self, difficulty: int = 2) -> None: |
| 177 | + self.difficulty = difficulty |
| 178 | + self.chain: list[Block] = [] |
| 179 | + self._create_genesis_block() |
| 180 | + |
| 181 | + def _create_genesis_block(self) -> None: |
| 182 | + """Create the first block of the chain (the genesis block). |
| 183 | +
|
| 184 | + The genesis block has index 0, arbitrary data, and previous_hash "0". |
| 185 | + """ |
| 186 | + genesis = mine_block(0, time(), "Genesis Block", "0", self.difficulty) |
| 187 | + self.chain.append(genesis) |
| 188 | + |
| 189 | + def add_block(self, data: str) -> Block: |
| 190 | + """Add a new block with the given data to the chain. |
| 191 | +
|
| 192 | + The block is mined with the chain's configured difficulty level. |
| 193 | +
|
| 194 | + >>> chain = Blockchain(difficulty=1) |
| 195 | + >>> block = chain.add_block("test") |
| 196 | + >>> block.index |
| 197 | + 1 |
| 198 | + >>> block.data |
| 199 | + 'test' |
| 200 | + >>> chain.chain[-1].hash == block.hash |
| 201 | + True |
| 202 | + """ |
| 203 | + previous_block = self.chain[-1] |
| 204 | + new_block = mine_block( |
| 205 | + len(self.chain), |
| 206 | + time(), |
| 207 | + data, |
| 208 | + previous_block.hash, |
| 209 | + self.difficulty, |
| 210 | + ) |
| 211 | + self.chain.append(new_block) |
| 212 | + return new_block |
| 213 | + |
| 214 | + def is_valid(self) -> bool: |
| 215 | + """Verify the integrity of the entire blockchain. |
| 216 | +
|
| 217 | + Checks that: |
| 218 | + 1. Every block's hash matches its recomputed hash |
| 219 | + 2. Every block's previous_hash matches the hash of the preceding block |
| 220 | + 3. The genesis block has index 0 and previous_hash "0" |
| 221 | +
|
| 222 | + >>> chain = Blockchain(difficulty=1) |
| 223 | + >>> chain.is_valid() |
| 224 | + True |
| 225 | + """ |
| 226 | + for i in range(1, len(self.chain)): |
| 227 | + current = self.chain[i] |
| 228 | + previous = self.chain[i - 1] |
| 229 | + |
| 230 | + if current.hash != calculate_hash( |
| 231 | + current.index, |
| 232 | + current.timestamp, |
| 233 | + current.data, |
| 234 | + current.previous_hash, |
| 235 | + current.nonce, |
| 236 | + ): |
| 237 | + return False |
| 238 | + if current.previous_hash != previous.hash: |
| 239 | + return False |
| 240 | + return True |
| 241 | + |
| 242 | + def __len__(self) -> int: |
| 243 | + return len(self.chain) |
| 244 | + |
| 245 | + def to_json(self) -> str: |
| 246 | + """Serialise the entire blockchain to a JSON string. |
| 247 | +
|
| 248 | + >>> chain = Blockchain(difficulty=1) |
| 249 | + >>> _ = chain.add_block("data") |
| 250 | + >>> isinstance(chain.to_json(), str) |
| 251 | + True |
| 252 | + """ |
| 253 | + return json.dumps( |
| 254 | + [block.to_dict() for block in self.chain], indent=2 |
| 255 | + ) |
| 256 | + |
| 257 | + |
| 258 | +if __name__ == "__main__": |
| 259 | + from doctest import testmod |
| 260 | + |
| 261 | + testmod() |
| 262 | + |
| 263 | + print("Mining a simple blockchain (difficulty=3)...") |
| 264 | + chain = Blockchain(difficulty=3) |
| 265 | + chain.add_block("Transaction 1: Alice sends 10 BTC to Bob") |
| 266 | + chain.add_block("Transaction 2: Bob sends 5 BTC to Charlie") |
| 267 | + chain.add_block("Transaction 3: Charlie sends 2 BTC to Dave") |
| 268 | + |
| 269 | + print(f"\nBlockchain valid: {chain.is_valid()}") |
| 270 | + print(f"Total blocks: {len(chain)}") |
| 271 | + print(f"\nFull chain:\n{chain.to_json()}") |
0 commit comments