From 797f9e6be507e85d566705cd24a10013f45d589e Mon Sep 17 00:00:00 2001 From: Code Orange Devs Date: Sat, 26 Sep 2026 12:31:04 +0800 Subject: [PATCH] bitcoin-dojo: weeks 3-7 exercises graded against real Bitcoin data - Weeks 3-6 get exercise files, reference solutions and a check.py each: keys/addresses (BIP173 + Programming Bitcoin answers), tx parsing (reproduce real mainnet txids incl. block 170), Script + ECDSA (verify a real 2013 signature), SegWit (real txid, weight, BIP143 signature) - Week 7 capstone: verify your own signet transaction with your code - Fix week 2 importing a module that didn't exist; add FieldElement __rmul__ (point doubling over F_p was impossible without it); fix the mislabeled curve; week 1/2 checks report 'not implemented' instead of crashing; week 1/2 reference solutions - lib/dojo.py shared helpers; data/transactions.json real tx data - CI: all solutions pass on Python 3.8 and 3.12; stubs never crash Co-Authored-By: Claude Opus 5.5 --- .github/workflows/dojo.yml | 42 ++ .gitignore | 2 + bitcoin-dojo/README.md | 20 + bitcoin-dojo/data/transactions.json | 30 ++ bitcoin-dojo/lib/dojo.py | 227 ++++++++++ .../week-01/exercises/finite_fields.py | 139 ++++--- bitcoin-dojo/week-01/solutions/.gitkeep | 0 .../week-01/solutions/finite_fields.py | 328 +++++++++++++++ .../week-02/exercises/elliptic_curves.py | 117 +++++- bitcoin-dojo/week-02/solutions/.gitkeep | 0 .../week-02/solutions/elliptic_curves.py | 393 ++++++++++++++++++ bitcoin-dojo/week-03/check.py | 89 ++++ bitcoin-dojo/week-03/exercises/.gitkeep | 0 .../week-03/exercises/keys_addresses.py | 116 ++++++ bitcoin-dojo/week-03/solutions/.gitkeep | 0 .../week-03/solutions/keys_addresses.py | 61 +++ bitcoin-dojo/week-04/check.py | 86 ++++ bitcoin-dojo/week-04/exercises/.gitkeep | 0 .../week-04/exercises/transactions.py | 133 ++++++ bitcoin-dojo/week-04/solutions/.gitkeep | 0 .../week-04/solutions/transactions.py | 94 +++++ bitcoin-dojo/week-05/check.py | 117 ++++++ bitcoin-dojo/week-05/exercises/.gitkeep | 0 bitcoin-dojo/week-05/exercises/script_lab.py | 146 +++++++ bitcoin-dojo/week-05/solutions/.gitkeep | 0 bitcoin-dojo/week-05/solutions/script_lab.py | 121 ++++++ bitcoin-dojo/week-06/check.py | 93 +++++ bitcoin-dojo/week-06/exercises/.gitkeep | 0 bitcoin-dojo/week-06/exercises/segwit_lab.py | 138 ++++++ bitcoin-dojo/week-06/solutions/.gitkeep | 0 bitcoin-dojo/week-06/solutions/segwit_lab.py | 86 ++++ bitcoin-dojo/week-07/exercises/.gitkeep | 0 bitcoin-dojo/week-07/exercises/capstone.md | 34 ++ bitcoin-dojo/week-07/solutions/.gitkeep | 0 34 files changed, 2531 insertions(+), 81 deletions(-) create mode 100644 .github/workflows/dojo.yml create mode 100644 .gitignore create mode 100644 bitcoin-dojo/data/transactions.json create mode 100644 bitcoin-dojo/lib/dojo.py delete mode 100644 bitcoin-dojo/week-01/solutions/.gitkeep create mode 100644 bitcoin-dojo/week-01/solutions/finite_fields.py delete mode 100644 bitcoin-dojo/week-02/solutions/.gitkeep create mode 100644 bitcoin-dojo/week-02/solutions/elliptic_curves.py create mode 100644 bitcoin-dojo/week-03/check.py delete mode 100644 bitcoin-dojo/week-03/exercises/.gitkeep create mode 100644 bitcoin-dojo/week-03/exercises/keys_addresses.py delete mode 100644 bitcoin-dojo/week-03/solutions/.gitkeep create mode 100644 bitcoin-dojo/week-03/solutions/keys_addresses.py create mode 100644 bitcoin-dojo/week-04/check.py delete mode 100644 bitcoin-dojo/week-04/exercises/.gitkeep create mode 100644 bitcoin-dojo/week-04/exercises/transactions.py delete mode 100644 bitcoin-dojo/week-04/solutions/.gitkeep create mode 100644 bitcoin-dojo/week-04/solutions/transactions.py create mode 100644 bitcoin-dojo/week-05/check.py delete mode 100644 bitcoin-dojo/week-05/exercises/.gitkeep create mode 100644 bitcoin-dojo/week-05/exercises/script_lab.py delete mode 100644 bitcoin-dojo/week-05/solutions/.gitkeep create mode 100644 bitcoin-dojo/week-05/solutions/script_lab.py create mode 100644 bitcoin-dojo/week-06/check.py delete mode 100644 bitcoin-dojo/week-06/exercises/.gitkeep create mode 100644 bitcoin-dojo/week-06/exercises/segwit_lab.py delete mode 100644 bitcoin-dojo/week-06/solutions/.gitkeep create mode 100644 bitcoin-dojo/week-06/solutions/segwit_lab.py delete mode 100644 bitcoin-dojo/week-07/exercises/.gitkeep create mode 100644 bitcoin-dojo/week-07/exercises/capstone.md delete mode 100644 bitcoin-dojo/week-07/solutions/.gitkeep diff --git a/.github/workflows/dojo.yml b/.github/workflows/dojo.yml new file mode 100644 index 0000000..a165840 --- /dev/null +++ b/.github/workflows/dojo.yml @@ -0,0 +1,42 @@ +name: Bitcoin Dojo + +on: + push: + branches: [main] + paths: ["bitcoin-dojo/**", ".github/workflows/dojo.yml"] + pull_request: + paths: ["bitcoin-dojo/**", ".github/workflows/dojo.yml"] + +permissions: + contents: read + +jobs: + dojo: + runs-on: ubuntu-22.04 # still has Python 3.8 builds + strategy: + matrix: + python: ["3.8", "3.12"] # the Dojo promises Python 3.8+ + defaults: + run: + working-directory: bitcoin-dojo + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - name: Reference solutions pass every check + run: | + python3 week-01/solutions/finite_fields.py | tee /dev/stderr | grep -q "7/7 tests passing" + python3 week-02/solutions/elliptic_curves.py | tee /dev/stderr | grep -q "7/7 checks passing" + for w in 3 4 5 6; do python3 week-0$w/check.py --solution; done + + - name: Unfinished exercises report "not implemented", never crash + run: | + python3 week-01/exercises/finite_fields.py + python3 week-02/exercises/elliptic_curves.py || true + for w in 3 4 5 6; do + out=$(python3 week-0$w/check.py || true) + echo "$out" | tail -2 + if echo "$out" | grep -q "crashed\|Traceback"; then echo "::error::week $w stub crashed"; exit 1; fi + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/bitcoin-dojo/README.md b/bitcoin-dojo/README.md index bc93a5b..b25cfad 100644 --- a/bitcoin-dojo/README.md +++ b/bitcoin-dojo/README.md @@ -43,6 +43,24 @@ This is not a lecture course. Participants study independently, then defend thei --- +## Exercises and self-checks + +Every week has an exercise file to fill in and a checker that grades it. Run everything from the `bitcoin-dojo/` folder with plain Python 3.8+ (no installs): + +| Week | Fill in | Grade with | Checked against | +|---|---|---|---| +| 1 | `week-01/exercises/finite_fields.py` | `python3 week-01/exercises/finite_fields.py` | Programming Bitcoin ch. 1 | +| 2 | `week-02/exercises/elliptic_curves.py` | `python3 week-02/exercises/elliptic_curves.py` | Programming Bitcoin ch. 2-3 (uses your Week 1 FieldElement if it works) | +| 3 | `week-03/exercises/keys_addresses.py` | `python3 week-03/check.py` | BIP173's example key, the book's ch. 4 answers | +| 4 | `week-04/exercises/transactions.py` | `python3 week-04/check.py` | Real mainnet txids (incl. Satoshi to Hal Finney, block 170) | +| 5 | `week-05/exercises/script_lab.py` | `python3 week-05/check.py` | A real 2013 signature verified with your sighash, ECDSA and Script interpreter | +| 6 | `week-06/exercises/segwit_lab.py` | `python3 week-06/check.py` | A real segwit transaction's txid, weight and BIP143 signature | +| 7 | `week-07/exercises/capstone.md` | - | Your own signet transaction | + +Unfinished functions show as "not implemented yet", so you can go one at a time. Reference solutions are in each week's `solutions/` folder (`python3 week-0N/check.py --solution`). Try it yourself first! Shared helpers live in `lib/dojo.py`, and the real transactions are in `data/transactions.json`. + +--- + ## Week-by-Week Syllabus ### Week 1: Finite Fields @@ -201,6 +219,8 @@ Each participant presents: 3. Their chosen next step: which project, which Good First Issue, which fellowship **Next Steps for Graduates:** +- Do the [Week 7 capstone](week-07/exercises/capstone.md): verify your own signet transaction with your own code +- Continue into the [Privacy Track](../privacy-track/): its Silent Payments lab builds directly on this week-by-week math - Join the [rawBit Study Cohort](../rawbit/) to build raw transactions with an interactive visual tool - Start the [Decoding Bitcoin](../decoding-bitcoin/) cohort for deeper protocol work - Pick a Good First Issue from Bitcoin Core, rust-bitcoin, BDK, or LDK diff --git a/bitcoin-dojo/data/transactions.json b/bitcoin-dojo/data/transactions.json new file mode 100644 index 0000000..a9a7882 --- /dev/null +++ b/bitcoin-dojo/data/transactions.json @@ -0,0 +1,30 @@ +{ + "_source": "Raw mainnet transactions fetched from blockstream.info on 2026-09-26. Anyone can re-check them: https://blockstream.info/tx/", + "block170": { + "txid": "f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16", + "block": 170, + "note": "The first bitcoin payment between people: Satoshi to Hal Finney, 12 Jan 2009. Spends a P2PK output.", + "hex": "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000", + "prevout_value": 5000000000, + "fee": 0 + }, + "p2pkh_250000": { + "txid": "e1a8b604b02ce3c7f3c3e52d5148bb2b7721d576f4e01005928dcf55fea93399", + "block": 250000, + "note": "A 1-input, 2-output P2PKH payment from August 2013.", + "hex": "0100000001a41fae0cf3887ea6b1edacdb5847f65f028e85a5b8e7b43ea25aa914e017506f010000008c493046022100ae0c73b54655f004910eb83838175321047929c8e5a6509c488696fbda4a577b022100f753bd06c17a30006c5b6a6bdc183e1dadd3c1091da7ac6c9cb29d5bdf44591f014104a521cdd7eabc70621ecca5c57066b17ffbabf56320520f3e69e19175d86ad4ba348952cd81f54044cafdf48e8d98f85c9ec9849039847d005238817571efe13cffffffff0208320a1b000000001976a91426b07b806afc166148b33dc3c5926c7196260bdb88acf41f7539010000001976a914d033ae527f8045440e874c05700a729aab659da588ac00000000", + "prevout_script_pubkey": "76a91420f8a678537f49e8b41e1addf81bddcc2455053e88ac", + "prevout_value": 5712607500, + "fee": 10000 + }, + "p2wpkh_800000": { + "txid": "267b6e1b23e67c0f425d210f202ac94639ed9e4cfedbe67cd1fb16d6463adc84", + "block": 800000, + "note": "A 1-input, 2-output native segwit (P2WPKH) payment from July 2023.", + "hex": "020000000001013ffeedaa0f00670c5f54de07a491fb237ca0af20051486acfa3281b98d87d1bb0000000000fdffffff02d6400a0000000000160014239844b99fbd37594a8af52fe4b04c4371479ab4e268280100000000160014fc301156647cc55369a17eecfb28163b66b386b90247304402205998396024640465d880e232d5b6788f7c2794fff8ccbd505e3238b82a49c42f02205812328327b06544e0a8ccb7edd910e08b77a4af26cf776706710fef52c11ba601210299855e1692900b7ff03908f4ce8f1facf5abd9fa048f145af4de595ae7902ef700000000", + "prevout_script_pubkey": "00141922e20d78b61ab50236f76d0e8dddb2634aad0f", + "prevout_value": 20125664, + "fee": 28200, + "weight": 561 + } +} \ No newline at end of file diff --git a/bitcoin-dojo/lib/dojo.py b/bitcoin-dojo/lib/dojo.py new file mode 100644 index 0000000..7232b65 --- /dev/null +++ b/bitcoin-dojo/lib/dojo.py @@ -0,0 +1,227 @@ +""" +Bitcoin Dojo shared toolbox. PROVIDED - you built most of this in earlier weeks. + +Weeks 3-6 import from here so each week can focus on its own topic: + + G, N, P secp256k1 generator, group order, field prime + Point curve point: P + Q, -P, k * P, P.x, P.y, P.is_infinity + Point.from_sec(b) parse a 33- or 65-byte SEC public key + sha256, hash256 single and double SHA256 + hash160 RIPEMD160(SHA256(x)) + bech32_segwit(hrp, version, program) segwit address encoder (BIP173/350) + parse_der(sig) DER signature -> (r, s) + +Teaching code: slow and not constant-time. Never use it with real keys. +""" + +import hashlib + +P = 2**256 - 2**32 - 977 +N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 + + +# --------------------------------------------------------------------- curve +class Point: + __slots__ = ("x", "y") + + def __init__(self, x, y): + self.x, self.y = x, y + + @staticmethod + def infinity(): + return Point(None, None) + + @property + def is_infinity(self): + return self.x is None + + @staticmethod + def from_sec(b: bytes) -> "Point": + if b[0] == 4 and len(b) == 65: + return Point(int.from_bytes(b[1:33], "big"), int.from_bytes(b[33:], "big")) + if b[0] in (2, 3) and len(b) == 33: + x = int.from_bytes(b[1:], "big") + y = pow((pow(x, 3, P) + 7) % P, (P + 1) // 4, P) + if (y % 2 == 0) != (b[0] == 2): + y = P - y + return Point(x, y) + raise ValueError("not a SEC public key") + + def __eq__(self, o): + return isinstance(o, Point) and self.x == o.x and self.y == o.y + + def __hash__(self): + return hash((self.x, self.y)) + + def __neg__(self): + return self if self.is_infinity else Point(self.x, (-self.y) % P) + + def __add__(self, o): + if self.is_infinity: + return o + if o.is_infinity: + return self + if self.x == o.x: + if (self.y + o.y) % P == 0: + return Point.infinity() + s = 3 * self.x * self.x * pow(2 * self.y, P - 2, P) % P + else: + s = (o.y - self.y) * pow(o.x - self.x, P - 2, P) % P + x3 = (s * s - self.x - o.x) % P + return Point(x3, (s * (self.x - x3) - self.y) % P) + + def __rmul__(self, k): + k %= N + result, addend = Point.infinity(), self + while k: + if k & 1: + result = result + addend + addend = addend + addend + k >>= 1 + return result + + def __repr__(self): + return "Point(infinity)" if self.is_infinity else f"Point({self.x:#066x}, {self.y:#066x})" + + +G = Point(0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, + 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8) + + +# -------------------------------------------------------------------- hashes +def sha256(b: bytes) -> bytes: + return hashlib.sha256(b).digest() + + +def hash256(b: bytes) -> bytes: + return sha256(sha256(b)) + + +def hash160(b: bytes) -> bytes: + try: + return hashlib.new("ripemd160", sha256(b)).digest() + except ValueError: # some OpenSSL builds drop ripemd160 + return _ripemd160(sha256(b)) + + +# -------------------------------------------------------------------- bech32 +_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + +def _polymod(values): + gen = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3] + chk = 1 + for v in values: + top = chk >> 25 + chk = (chk & 0x1FFFFFF) << 5 ^ v + for i in range(5): + chk ^= gen[i] if ((top >> i) & 1) else 0 + return chk + + +def bech32_segwit(hrp: str, version: int, program: bytes) -> str: + """Segwit address: bech32 for version 0 (BIP173), bech32m for 1+ (BIP350).""" + acc, bits, data = 0, 0, [version] + for byte in program: + acc = (acc << 8) | byte + bits += 8 + while bits >= 5: + bits -= 5 + data.append((acc >> bits) & 31) + if bits: + data.append((acc << (5 - bits)) & 31) + const = 1 if version == 0 else 0x2BC830A3 + expanded = [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp] + pm = _polymod(expanded + data + [0] * 6) ^ const + return hrp + "1" + "".join(_CHARSET[d] for d in data + [(pm >> 5 * (5 - i)) & 31 for i in range(6)]) + + +# ----------------------------------------------------------------------- DER +def parse_der(sig: bytes): + """DER-encoded ECDSA signature -> (r, s). Trailing sighash byte must be stripped first.""" + if sig[0] != 0x30 or sig[1] + 2 != len(sig): + raise ValueError("bad DER signature") + assert sig[2] == 0x02 + rlen = sig[3] + r = int.from_bytes(sig[4:4 + rlen], "big") + assert sig[4 + rlen] == 0x02 + slen = sig[5 + rlen] + s = int.from_bytes(sig[6 + rlen:6 + rlen + slen], "big") + return r, s + + +# --------------------------------------------------------------- the grader +class NotImplementedYet(Exception): + pass + + +def need(value, what): + """Raise NotImplementedYet if an exercise returned None.""" + if value is None: + raise NotImplementedYet(what) + return value + + +def run_checks(title, checks): + """checks: list of (name, fn). Prints ✓ / ✗ / · and returns True if all pass.""" + print("=" * 60 + f"\n{title}\n" + "=" * 60) + passed = todo = 0 + for name, fn in checks: + try: + fn() + passed += 1 + print(f" ✓ {name}") + except NotImplementedYet as e: + todo += 1 + print(f" · {name}\n not implemented yet: {e}()") + except AssertionError as e: + print(f" ✗ {name}\n {e}") + except Exception as e: + print(f" ✗ {name}\n crashed: {type(e).__name__}: {e}") + print(f"\n{passed}/{len(checks)} checks passing") + if passed == len(checks): + print("All green. Bring your code and your discussion answers to the call. 🟠") + return passed == len(checks) + + +def load_week(file, module_path, solution): + """Import the participant's exercise file (or the reference solution).""" + import importlib.util + from pathlib import Path + folder = Path(file).resolve().parent / ("solutions" if solution else "exercises") + spec = importlib.util.spec_from_file_location("under_test", folder / module_path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +# ------------------------------------------ RIPEMD160 fallback (pure python) +def _ripemd160(msg: bytes) -> bytes: + import struct as s + def rol(x, n): return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF + def f(j, x, y, z): + return [x ^ y ^ z, (x & y) | (~x & z), (x | ~y) ^ z, (x & z) | (y & ~z), x ^ (y | ~z)][j // 16] & 0xFFFFFFFF + K1 = [0, 0x5A827999, 0x6ED9EBA1, 0x8F1BBCDC, 0xA953FD4E] + K2 = [0x50A28BE6, 0x5C4DD124, 0x6D703EF3, 0x7A6D76E9, 0] + R1 = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,7,4,13,1,10,6,15,3,12,0,9,5,2,14,11,8,3,10,14,4,9,15,8,1,2,7,0,6,13,11,5,12,1,9,11,10,0,8,12,4,13,3,7,15,14,5,6,2,4,0,5,9,7,12,2,10,14,1,3,8,11,6,15,13] + R2 = [5,14,7,0,9,2,11,4,13,6,15,8,1,10,3,12,6,11,3,7,0,13,5,10,14,15,8,12,4,9,1,2,15,5,1,3,7,14,6,9,11,8,12,2,10,0,4,13,8,6,4,1,3,11,15,0,5,12,2,13,9,7,10,14,12,15,10,4,1,5,8,7,6,2,13,14,0,3,9,11] + S1 = [11,14,15,12,5,8,7,9,11,13,14,15,6,7,9,8,7,6,8,13,11,9,7,15,7,12,15,9,11,7,13,12,11,13,6,7,14,9,13,15,14,8,13,6,5,12,7,5,11,12,14,15,14,15,9,8,9,14,5,6,8,6,5,12,9,15,5,11,6,8,13,12,5,12,13,14,11,8,5,6] + S2 = [8,9,9,11,13,15,15,5,7,7,8,11,14,14,12,6,9,13,15,7,12,8,9,11,7,7,12,7,6,15,13,11,9,7,15,11,8,6,6,14,12,13,5,14,13,13,7,5,15,5,8,11,14,14,6,14,6,9,12,9,12,5,15,8,8,5,12,9,12,5,14,6,8,13,6,5,15,13,11,11] + h = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0] + msg = msg + b"\x80" + b"\x00" * ((55 - len(msg)) % 64) + s.pack("= prime or num < 0: + error = f"Num {num} not in field range 0 to {prime - 1}" + raise ValueError(error) + self.num = num + self.prime = prime + + def __repr__(self): + return f"FieldElement_{self.prime}({self.num})" + + def __eq__(self, other): + """Two field elements are equal if they have the same num and prime.""" + # YOUR CODE HERE + # Hint: check that both num and prime match + return other is not None and self.num == other.num and self.prime == other.prime + + def __ne__(self, other): + """Two field elements are not equal if __eq__ returns False.""" + # YOUR CODE HERE + return not (self == other) + + def __add__(self, other): + """Add two field elements: (a + b) mod p""" + if self.prime != other.prime: + raise TypeError("Cannot add two numbers in different Fields") + # YOUR CODE HERE + # Hint: (self.num + other.num) % self.prime + return self.__class__((self.num + other.num) % self.prime, self.prime) + + def __sub__(self, other): + """Subtract two field elements: (a - b) mod p""" + if self.prime != other.prime: + raise TypeError("Cannot subtract two numbers in different Fields") + # YOUR CODE HERE + # Hint: (self.num - other.num) % self.prime + return self.__class__((self.num - other.num) % self.prime, self.prime) + + def __mul__(self, other): + """Multiply two field elements: (a * b) mod p""" + if self.prime != other.prime: + raise TypeError("Cannot multiply two numbers in different Fields") + # YOUR CODE HERE + return self.__class__((self.num * other.num) % self.prime, self.prime) + + def __pow__(self, exponent): + """Raise a field element to a power: (a ** exp) mod p + + Important: use Fermat's Little Theorem to handle negative exponents. + a^(p-1) = 1 (mod p), so a^(-1) = a^(p-2) (mod p) + Therefore: a^n = a^(n % (p-1)) (mod p) + """ + # YOUR CODE HERE + # Hint: n = exponent % (self.prime - 1) + # then use Python's built-in pow(self.num, n, self.prime) + n = exponent % (self.prime - 1) + return self.__class__(pow(self.num, n, self.prime), self.prime) + + def __rmul__(self, coefficient): + """PROVIDED: lets you write 3 * element. Week 2's elliptic-curve + formulas need this (e.g. 3 * x**2 when doubling a point).""" + return self.__class__((self.num * coefficient) % self.prime, self.prime) + + def __truediv__(self, other): + """Divide two field elements: a / b = a * b^(p-2) mod p + + Division in a finite field uses Fermat's Little Theorem: + b^(-1) = b^(p-2) mod p + So a / b = a * b^(p-2) mod p + """ + if self.prime != other.prime: + raise TypeError("Cannot divide two numbers in different Fields") + # YOUR CODE HERE + # Hint: use self * (other ** (self.prime - 2)) + # Or compute directly: (self.num * pow(other.num, self.prime - 2, self.prime)) % self.prime + return self * (other ** (self.prime - 2)) + + +# ============================================================ +# Exercise 2: Verify field properties +# ============================================================ +# +# A finite field must satisfy these properties: +# 1. Closure: a + b and a * b are in the field +# 2. Associativity: (a + b) + c = a + (b + c) +# 3. Commutativity: a + b = b + a +# 4. Additive identity: a + 0 = a +# 5. Multiplicative identity: a * 1 = a +# 6. Additive inverse: a + (-a) = 0 +# 7. Multiplicative inverse: a * a^(-1) = 1 (for a != 0) + +def verify_field_properties(prime): + """Verify all field properties hold for F_prime. + + Test with several random elements. Print results. + Returns True if all properties hold. + """ + import random + + all_passed = True + + # Pick random elements + a_num = random.randint(0, prime - 1) + b_num = random.randint(0, prime - 1) + c_num = random.randint(0, prime - 1) + + a = FieldElement(a_num, prime) + b = FieldElement(b_num, prime) + c = FieldElement(c_num, prime) + zero = FieldElement(0, prime) + one = FieldElement(1, prime) + + # YOUR CODE HERE: Test each property and print results + # Example: + # 1. Closure + result = a + b + assert result.num >= 0 and result.num < prime, "Closure failed for addition" + print(f" Closure (addition): {a} + {b} = {result} ... PASS") + + # 2. Commutativity of addition + assert a + b == b + a + # print(f" Commutativity (addition): PASS") + + # 3. Associativity of addition + assert (a + b) + c == a + (b + c) + + # 4. Additive identity + assert a + zero == a + + # 5. Multiplicative identity + assert a * one == a + + # 6. Additive inverse + neg_a = FieldElement((prime - a_num) % prime, prime) + assert a + neg_a == zero + + # 7. Multiplicative inverse (for non-zero elements) + if a_num != 0: + a_inv = a ** (prime - 2) + assert a * a_inv == one + print(" Commutativity, associativity, identities, inverses ... PASS") + + + + return all_passed + + +# ============================================================ +# Exercise 3: Why must the prime be prime? +# ============================================================ +# +# Try creating a "field" with a composite (non-prime) modulus. +# Show that multiplicative inverses break. + +def demonstrate_composite_failure(): + """Show that a composite modulus breaks the field properties. + + Try modulus = 15 (= 3 * 5). + Find an element that has no multiplicative inverse. + + Hint: 3 has no inverse mod 15 because gcd(3, 15) = 3 != 1 + """ + modulus = 15 # composite! + + print(f"\nTrying modulus = {modulus} (composite = 3 x 5)") + print("Looking for elements without multiplicative inverses:") + + # YOUR CODE HERE + # For each element a in {1, 2, ..., 14}, try to find b such that (a*b) % 15 == 1 + # Print which elements have no inverse + # + for a in range(1, modulus): + if not any((a * b) % modulus == 1 for b in range(1, modulus)): + print(f" {a} has NO multiplicative inverse mod {modulus}") + + +# ============================================================ +# Exercise 4: Bitcoin's finite field +# ============================================================ +# +# Bitcoin uses the prime: +# p = 2^256 - 2^32 - 977 +# +# This is the prime for the secp256k1 elliptic curve. + +def bitcoin_field_element(): + """Create a FieldElement using Bitcoin's actual prime. + + Demonstrate that arithmetic works even with these huge numbers. + """ + P = 2**256 - 2**32 - 977 + + print(f"\nBitcoin's prime p = {P}") + print(f"Number of digits: {len(str(P))}") + + # YOUR CODE HERE + # Create two field elements and perform operations + a = FieldElement(42, P) + b = FieldElement(99, P) + print(f" a + b = {(a + b).num}") + print(f" a / b, times b, gives back a: {(a / b) * b == a}") + assert (a / b) * b == a, "Division verification failed!" + + +# ============================================================ +# TESTS — Run these to verify your implementation +# ============================================================ + +def _check(name, fn): + """Run one test. Unimplemented methods return None, which shows up as + 'not implemented yet' instead of crashing the whole file.""" + try: + detail = fn() + print(f" ✓ {name}" + (f" {detail}" if detail else "")) + return "pass" + except (TypeError, AttributeError) as e: + if "NoneType" in str(e) or "None" in str(e): + print(f" · {name}\n not implemented yet") + return "todo" + print(f" ✗ {name}\n {type(e).__name__}: {e}") + return "fail" + except AssertionError as e: + if str(e).endswith("got None"): + print(f" · {name}\n not implemented yet") + return "todo" + print(f" ✗ {name}\n {e or 'assertion failed'}") + return "fail" + + +def _test_equality(): + a, b, c = FieldElement(7, 13), FieldElement(7, 13), FieldElement(6, 13) + if a.__eq__(b) is None: + raise TypeError("__eq__ returned None") + assert a == b, "Equal elements should be equal (implement __eq__)" + assert a != c, "Different elements should not be equal (implement __ne__)" + + +def _test_add(): + a, b = FieldElement(7, 13), FieldElement(12, 13) + assert a + b == FieldElement(6, 13), f"(7 + 12) % 13 should be 6, got {a + b}" + + +def _test_sub(): + a, b = FieldElement(6, 19), FieldElement(13, 19) + assert a - b == FieldElement(12, 19), f"(6 - 13) % 19 should be 12, got {a - b}" + + +def _test_mul(): + a, b = FieldElement(3, 13), FieldElement(12, 13) + assert a * b == FieldElement(10, 13), f"(3 * 12) % 13 should be 10, got {a * b}" + + +def _test_pow(): + a = FieldElement(3, 13) + assert a ** 3 == FieldElement(1, 13), f"3^3 % 13 should be 1, got {a ** 3}" + + +def _test_div(): + a, b = FieldElement(2, 19), FieldElement(7, 19) + assert a / b == FieldElement(3, 19), f"2/7 in F_19 should be 3, got {a / b}" + + +def _test_negative_exponent(): + a = FieldElement(7, 13) + assert (a ** -3) * (a ** 3) == FieldElement(1, 13), "a^-3 * a^3 should be 1 (use Fermat's little theorem)" + + +def run_tests(): + print("=" * 60) + print("Bitcoin Dojo — Week 1: Finite Field Tests") + print("=" * 60) + tests = [ + ("Equality", _test_equality), + ("Addition", _test_add), + ("Subtraction", _test_sub), + ("Multiplication", _test_mul), + ("Exponentiation", _test_pow), + ("Division", _test_div), + ("Negative exponent", _test_negative_exponent), + ] + results = [_check(name, fn) for name, fn in tests] + passed = results.count("pass") + print(f"\n{passed}/{len(tests)} tests passing") + if passed < len(tests): + print("Keep going: the exercises below need a working FieldElement.") + return + + print("\n\nExercise 2: Verify field properties for F_31") + verify_field_properties(31) + + print("\n\nExercise 3: Why must the modulus be prime?") + demonstrate_composite_failure() + + print("\n\nExercise 4: Bitcoin's actual finite field") + bitcoin_field_element() + + +if __name__ == "__main__": + run_tests() diff --git a/bitcoin-dojo/week-02/exercises/elliptic_curves.py b/bitcoin-dojo/week-02/exercises/elliptic_curves.py index 7e1f734..1d3e94f 100644 --- a/bitcoin-dojo/week-02/exercises/elliptic_curves.py +++ b/bitcoin-dojo/week-02/exercises/elliptic_curves.py @@ -13,19 +13,29 @@ - Discuss your solutions at the weekly Monday 11:00 UTC call """ -from week_01_solution import FieldElement # Use your Week 1 solution -# If you haven't completed Week 1, uncomment and use this minimal version: -# class FieldElement: -# def __init__(self, num, prime): -# self.num = num % prime; self.prime = prime -# def __repr__(self): return f"FieldElement_{self.prime}({self.num})" -# def __eq__(self, other): return self.num == other.num and self.prime == other.prime -# def __ne__(self, other): return not self.__eq__(other) -# def __add__(self, o): return FieldElement((self.num + o.num) % self.prime, self.prime) -# def __sub__(self, o): return FieldElement((self.num - o.num) % self.prime, self.prime) -# def __mul__(self, o): return FieldElement((self.num * o.num) % self.prime, self.prime) -# def __pow__(self, e): return FieldElement(pow(self.num, e % (self.prime-1), self.prime), self.prime) -# def __truediv__(self, o): return self * (o ** (self.prime - 2)) +import importlib.util +from pathlib import Path + + +def _load_field_element(): + """Use YOUR Week 1 FieldElement if it works; otherwise the reference one.""" + week1 = Path(__file__).resolve().parents[2] / "week-01" + for label, path in (("your Week 1", week1 / "exercises" / "finite_fields.py"), + ("the Week 1 reference solution", week1 / "solutions" / "finite_fields.py")): + try: + spec = importlib.util.spec_from_file_location(f"ff_{path.parent.name}", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + FE = mod.FieldElement + if FE(3, 7) + FE(5, 7) == FE(1, 7) and FE(2, 7) / FE(4, 7) == FE(4, 7): + print(f"(using FieldElement from {label})") + return FE + except Exception: + continue + raise SystemExit("No working FieldElement found - finish week-01/exercises/finite_fields.py first.") + + +FieldElement = _load_field_element() # ============================================================ @@ -125,21 +135,17 @@ def __add__(self, other): # Exercise 2: Point addition over real numbers # ============================================================ # -# Verify point addition works on the curve y^2 = x^3 + 5x + 7 +# Verify point addition works on the curve y^2 = x^3 - 7x + 10 def test_point_addition_reals(): """Test point addition over real numbers. - Curve: y^2 = x^3 + 5x + 7 + Curve: y^2 = x^3 - 7x + 10 YOUR TASK: Uncomment and verify these additions work. """ print("Exercise 2: Point addition over real numbers") - print("Curve: y^2 = x^3 + 5x + 7") - - # These points are on the curve y^2 = x^3 + 5x + 7: - # Verify: (-1)^2 = (-1)^3 + 5*(-1) + 7 → 1 = -1 - 5 + 7 = 1 ✓ - # Verify: (2)^2 = (-1)^3 + 5*(-1) + 7 → nope, need to find real points + print("Curve: y^2 = x^3 - 7x + 10") # For integer testing, use curve y^2 = x^3 - 7x + 10 # Point (1, 2): 4 = 1 - 7 + 10 = 4 ✓ @@ -301,6 +307,75 @@ def secp256k1_intro(): # universe? (~10^80) +# ============================================================ +# Checks - does your Point.__add__ give the textbook answers? +# ============================================================ + +def _check(name, fn): + try: + fn() + print(f" ✓ {name}") + return True + except (TypeError, AttributeError) as e: + print(f" · {name}\n not implemented yet ({e})") + except AssertionError as e: + print(f" ✗ {name}\n {e}") + return False + + +def _pt(x, y, a=0, b=7, prime=223): + F = lambda n: FieldElement(n % prime, prime) + return Point(F(x), F(y), F(a), F(b)) + + +def _inf(a=0, b=7, prime=223): + return Point(None, None, FieldElement(a, prime), FieldElement(b, prime)) + + +def _checks(): + def reals_add(): + r = Point(2, 5, 5, 7) + Point(-1, -1, 5, 7) + assert r == Point(3, -7, 5, 7), f"(2,5)+(-1,-1) on y^2=x^3+5x+7 should be (3,-7), got {r}" + + def reals_double(): + r = Point(-1, -1, 5, 7) + Point(-1, -1, 5, 7) + assert r == Point(18, 77, 5, 7), f"2*(-1,-1) on y^2=x^3+5x+7 should be (18,77), got {r}" + + def identity(): + p = _pt(192, 105) + assert p + _inf() == p and _inf() + p == p, "P + infinity should be P" + + def inverse(): + assert _pt(192, 105) + _pt(192, -105) == _inf(), "P + (-P) should be infinity" + + def field_add(): + r = _pt(192, 105) + _pt(17, 56) + assert r == _pt(170, 142), f"(192,105)+(17,56) over F_223 should be (170,142), got {r}" + + def field_double(): + r = _pt(47, 71) + _pt(47, 71) + assert r == _pt(36, 111), f"2*(47,71) over F_223 should be (36,111), got {r}" + + def group_order(): + G, cur, n = _pt(47, 71), _pt(47, 71), 1 + while cur != _inf(): + cur, n = cur + G, n + 1 + assert n < 300, "never reached infinity" + assert n == 21, f"order of (47,71) should be 21, got {n}" + + results = [_check(n, f) for n, f in ( + ("Real numbers: P1 + P2", reals_add), + ("Real numbers: point doubling", reals_double), + ("Identity: P + infinity", identity), + ("Inverse: P + (-P) = infinity", inverse), + ("F_223: P1 + P2", field_add), + ("F_223: point doubling", field_double), + ("F_223: order of (47, 71)", group_order), + )] + print(f"\n{sum(results)}/{len(results)} checks passing\n") + return all(results) + + # ============================================================ # Run all exercises # ============================================================ @@ -309,6 +384,8 @@ def secp256k1_intro(): print("=" * 60) print("Bitcoin Dojo — Week 2: Elliptic Curves") print("=" * 60) + if not _checks(): + raise SystemExit("Finish Point.__add__ (Exercise 1), then the walkthroughs below will run.") test_point_addition_reals() test_point_addition_finite_field() diff --git a/bitcoin-dojo/week-02/solutions/.gitkeep b/bitcoin-dojo/week-02/solutions/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/bitcoin-dojo/week-02/solutions/elliptic_curves.py b/bitcoin-dojo/week-02/solutions/elliptic_curves.py new file mode 100644 index 0000000..b105381 --- /dev/null +++ b/bitcoin-dojo/week-02/solutions/elliptic_curves.py @@ -0,0 +1,393 @@ +""" +Bitcoin Dojo — Week 2 REFERENCE SOLUTION: Elliptic Curves +Code Orange Dev School | codeorange.dev + +Based on Programming Bitcoin by Jimmy Song (Chapters 2-3) +https://github.com/jimmysong/programmingbitcoin + +Prerequisites: Complete Week 1 (finite_fields.py) first. + +Instructions: +- Complete each exercise by filling in the code where indicated +- Run this file with: python3 elliptic_curves.py +- Discuss your solutions at the weekly Monday 11:00 UTC call +""" + +import importlib.util +from pathlib import Path + + +def _load_field_element(): + """Use YOUR Week 1 FieldElement if it works; otherwise the reference one.""" + week1 = Path(__file__).resolve().parents[2] / "week-01" + for label, path in (("your Week 1", week1 / "exercises" / "finite_fields.py"), + ("the Week 1 reference solution", week1 / "solutions" / "finite_fields.py")): + try: + spec = importlib.util.spec_from_file_location(f"ff_{path.parent.name}", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + FE = mod.FieldElement + if FE(3, 7) + FE(5, 7) == FE(1, 7) and FE(2, 7) / FE(4, 7) == FE(4, 7): + print(f"(using FieldElement from {label})") + return FE + except Exception: + continue + raise SystemExit("No working FieldElement found - finish week-01/exercises/finite_fields.py first.") + + +FieldElement = _load_field_element() + + +# ============================================================ +# Exercise 1: Implement the Point class (over real numbers) +# ============================================================ +# +# An elliptic curve has the equation: y^2 = x^3 + ax + b +# A Point is a point on this curve (or the point at infinity). +# +# The point at infinity is represented by x=None, y=None. + +class Point: + """A point on an elliptic curve y^2 = x^3 + ax + b.""" + + def __init__(self, x, y, a, b): + self.a = a + self.b = b + self.x = x + self.y = y + + # Point at infinity (identity element) + if self.x is None and self.y is None: + return + + # Verify the point is on the curve + if self.y ** 2 != self.x ** 3 + a * x + b: + raise ValueError(f"({x}, {y}) is not on the curve y^2 = x^3 + {a}x + {b}") + + def __repr__(self): + if self.x is None: + return "Point(infinity)" + return f"Point({self.x}, {self.y})_{self.a}_{self.b}" + + def __eq__(self, other): + return (self.x == other.x and self.y == other.y + and self.a == other.a and self.b == other.b) + + def __ne__(self, other): + return not self.__eq__(other) + + def __add__(self, other): + """Point addition on the elliptic curve. + + There are several cases: + 1. One point is infinity (identity) → return the other + 2. Points have same x but different y → return infinity (vertical line) + 3. Points are different → use the slope formula + 4. Points are the same → use the tangent formula (point doubling) + 5. Point doubling where y=0 → return infinity + """ + if self.a != other.a or self.b != other.b: + raise TypeError(f"Points are not on the same curve") + + # Case 1: self is point at infinity + if self.x is None: + return other + + # Case 1: other is point at infinity + if other.x is None: + return self + + # Case 2: x coordinates are equal but y coordinates are different + # The line is vertical → result is point at infinity + if self.x == other.x and self.y != other.y: + return self.__class__(None, None, self.a, self.b) + + # Case 3: Points are different (x1 != x2) + # YOUR CODE HERE + # slope s = (y2 - y1) / (x2 - x1) + # x3 = s^2 - x1 - x2 + # y3 = s * (x1 - x3) - y1 + if self.x != other.x: + s = (other.y - self.y) / (other.x - self.x) + x3 = s ** 2 - self.x - other.x + y3 = s * (self.x - x3) - self.y + return self.__class__(x3, y3, self.a, self.b) + + # Case 4: Points are the same (point doubling, self == other) + # YOUR CODE HERE + # slope s = (3 * x1^2 + a) / (2 * y1) + # x3 = s^2 - 2*x1 + # y3 = s * (x1 - x3) - y1 + if self == other: + # Case 5: tangent is vertical (y = 0) + if self.y == 0 * self.x: # handles both int and FieldElement + return self.__class__(None, None, self.a, self.b) + + s = (3 * self.x ** 2 + self.a) / (2 * self.y) + x3 = s ** 2 - 2 * self.x + y3 = s * (self.x - x3) - self.y + return self.__class__(x3, y3, self.a, self.b) + + +# ============================================================ +# Exercise 2: Point addition over real numbers +# ============================================================ +# +# Verify point addition works on the curve y^2 = x^3 + 5x + 7 + +def test_point_addition_reals(): + """Test point addition over real numbers. + + Curve: y^2 = x^3 - 7x + 10 + + YOUR TASK: Uncomment and verify these additions work. + """ + print("Exercise 2: Point addition over real numbers") + print("Curve: y^2 = x^3 - 7x + 10") + + # For integer testing, use curve y^2 = x^3 - 7x + 10 + # Point (1, 2): 4 = 1 - 7 + 10 = 4 ✓ + # Point (2, 2): 4 = 8 - 14 + 10 = 4 ✓ + + # YOUR CODE HERE (reference solution below) + p1 = Point(1, 2, -7, 10) + p2 = Point(2, 2, -7, 10) + p3 = p1 + p2 + print(f" {p1} + {p2} = {p3}") + + # Test identity element + inf = Point(None, None, -7, 10) + assert p1 + inf == p1, "Adding infinity should return the same point" + print(f" {p1} + infinity = {p1 + inf} ... PASS") + + + +# ============================================================ +# Exercise 3: Point addition over finite fields +# ============================================================ +# +# This is where it gets real! Bitcoin uses elliptic curves over +# finite fields, not over real numbers. + +def test_point_addition_finite_field(): + """Test point addition over a finite field. + + Curve: y^2 = x^3 + 7 over F_223 (a=0, b=7, like Bitcoin's secp256k1!) + """ + print("\nExercise 3: Point addition over finite field F_223") + print("Curve: y^2 = x^3 + 7 (mod 223)") + + prime = 223 + a = FieldElement(0, prime) + b = FieldElement(7, prime) + + # Verify that (192, 105) is on the curve: + # 105^2 mod 223 = 11025 mod 223 = 81 + # 192^3 + 7 mod 223 = 7077881 mod 223 = 81 ✓ + x1 = FieldElement(192, prime) + y1 = FieldElement(105, prime) + p1 = Point(x1, y1, a, b) + print(f" Point 1: ({x1.num}, {y1.num})") + + # YOUR CODE HERE (reference solution below) + # 1. Verify that (17, 56) is also on the curve + x2 = FieldElement(17, prime) + y2 = FieldElement(56, prime) + p2 = Point(x2, y2, a, b) + print(f" Point 2: ({x2.num}, {y2.num})") + + # 2. Add them together + p3 = p1 + p2 + print(f" P1 + P2 = ({p3.x.num}, {p3.y.num})") + + # 3. Try point doubling: p1 + p1 + p4 = p1 + p1 + print(f" P1 + P1 = ({p4.x.num}, {p4.y.num})") + + + +# ============================================================ +# Exercise 4: Scalar multiplication and group order +# ============================================================ +# +# Scalar multiplication: n * P = P + P + P + ... (n times) +# The order of a point is the smallest n such that n * P = infinity. + +def scalar_multiplication(): + """Find the order of a generator point. + + On the curve y^2 = x^3 + 7 over F_223: + Start with point G = (47, 71) + Compute 2G, 3G, 4G, ... until you reach infinity. + The number of steps is the order of G. + """ + print("\nExercise 4: Scalar multiplication and group order") + + prime = 223 + a = FieldElement(0, prime) + b = FieldElement(7, prime) + + x = FieldElement(47, prime) + y = FieldElement(71, prime) + G = Point(x, y, a, b) + inf = Point(None, None, a, b) + + # YOUR CODE HERE (reference solution below) + current = G + for n in range(2, 300): + current = current + G + if current.x is not None: + print(f" {n}G = ({current.x.num}, {current.y.num})") + else: + print(f" {n}G = infinity <-- ORDER FOUND: {n}") + break + + # QUESTION: What is the order? Write your answer here: + order = n # 21 - the group generated by (47, 71) has 21 elements + # Discuss: Why does the group have a finite order? + # Discuss: How does this relate to Bitcoin's n value in secp256k1? + + + +# ============================================================ +# Exercise 5: Towards secp256k1 +# ============================================================ +# +# Bitcoin's elliptic curve secp256k1 uses: +# - Equation: y^2 = x^3 + 7 (same a=0, b=7 as our exercises!) +# - Prime p = 2^256 - 2^32 - 977 +# - Generator point G = (huge_x, huge_y) +# - Order n = a very large prime +# +# The security of Bitcoin depends on the fact that given Q = k*G, +# it is computationally infeasible to find k (the private key) +# even though you know Q (the public key) and G. +# This is the Elliptic Curve Discrete Logarithm Problem (ECDLP). + +def secp256k1_intro(): + """Demonstrate that secp256k1 uses the same curve equation as our exercises.""" + print("\nExercise 5: secp256k1 — Bitcoin's elliptic curve") + + P = 2**256 - 2**32 - 977 + print(f" Prime p = 2^256 - 2^32 - 977") + print(f" p = {P}") + print(f" p has {len(str(P))} digits") + print(f" Curve: y^2 = x^3 + 7 (a=0, b=7) — same as our exercises!") + + # The generator point G (in hex): + Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798 + Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8 + + print(f"\n Generator G:") + print(f" x = {hex(Gx)}") + print(f" y = {hex(Gy)}") + + # Verify G is on the curve: Gy^2 mod P should equal (Gx^3 + 7) mod P + lhs = pow(Gy, 2, P) + rhs = (pow(Gx, 3, P) + 7) % P + assert lhs == rhs, "G is not on the curve!" + print(f"\n Verification: Gy^2 mod p == Gx^3 + 7 mod p ... PASSED") + print(f" G is on the secp256k1 curve!") + + # The group order: + N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 + print(f"\n Group order n = {hex(N)}") + print(f" This means n*G = infinity (point at infinity)") + print(f" Any private key k must satisfy: 1 <= k < n") + + # YOUR CODE HERE + # QUESTION: If a private key is a random 256-bit number, how many + # possible private keys are there? (Answer: approximately 2^256 or ~10^77) + # QUESTION: How does this compare to the number of atoms in the observable + # universe? (~10^80) + + +# ============================================================ +# Checks - does your Point.__add__ give the textbook answers? +# ============================================================ + +def _check(name, fn): + try: + fn() + print(f" ✓ {name}") + return True + except (TypeError, AttributeError) as e: + print(f" · {name}\n not implemented yet ({e})") + except AssertionError as e: + print(f" ✗ {name}\n {e}") + return False + + +def _pt(x, y, a=0, b=7, prime=223): + F = lambda n: FieldElement(n % prime, prime) + return Point(F(x), F(y), F(a), F(b)) + + +def _inf(a=0, b=7, prime=223): + return Point(None, None, FieldElement(a, prime), FieldElement(b, prime)) + + +def _checks(): + def reals_add(): + r = Point(2, 5, 5, 7) + Point(-1, -1, 5, 7) + assert r == Point(3, -7, 5, 7), f"(2,5)+(-1,-1) on y^2=x^3+5x+7 should be (3,-7), got {r}" + + def reals_double(): + r = Point(-1, -1, 5, 7) + Point(-1, -1, 5, 7) + assert r == Point(18, 77, 5, 7), f"2*(-1,-1) on y^2=x^3+5x+7 should be (18,77), got {r}" + + def identity(): + p = _pt(192, 105) + assert p + _inf() == p and _inf() + p == p, "P + infinity should be P" + + def inverse(): + assert _pt(192, 105) + _pt(192, -105) == _inf(), "P + (-P) should be infinity" + + def field_add(): + r = _pt(192, 105) + _pt(17, 56) + assert r == _pt(170, 142), f"(192,105)+(17,56) over F_223 should be (170,142), got {r}" + + def field_double(): + r = _pt(47, 71) + _pt(47, 71) + assert r == _pt(36, 111), f"2*(47,71) over F_223 should be (36,111), got {r}" + + def group_order(): + G, cur, n = _pt(47, 71), _pt(47, 71), 1 + while cur != _inf(): + cur, n = cur + G, n + 1 + assert n < 300, "never reached infinity" + assert n == 21, f"order of (47,71) should be 21, got {n}" + + results = [_check(n, f) for n, f in ( + ("Real numbers: P1 + P2", reals_add), + ("Real numbers: point doubling", reals_double), + ("Identity: P + infinity", identity), + ("Inverse: P + (-P) = infinity", inverse), + ("F_223: P1 + P2", field_add), + ("F_223: point doubling", field_double), + ("F_223: order of (47, 71)", group_order), + )] + print(f"\n{sum(results)}/{len(results)} checks passing\n") + return all(results) + + +# ============================================================ +# Run all exercises +# ============================================================ + +if __name__ == "__main__": + print("=" * 60) + print("Bitcoin Dojo — Week 2: Elliptic Curves") + print("=" * 60) + if not _checks(): + raise SystemExit("Finish Point.__add__ (Exercise 1), then the walkthroughs below will run.") + + test_point_addition_reals() + test_point_addition_finite_field() + scalar_multiplication() + secp256k1_intro() + + print("\n" + "=" * 60) + print("Week 2 exercises complete!") + print("Bring your solutions to the Monday 11:00 UTC call.") + print("=" * 60) diff --git a/bitcoin-dojo/week-03/check.py b/bitcoin-dojo/week-03/check.py new file mode 100644 index 0000000..a070d3b --- /dev/null +++ b/bitcoin-dojo/week-03/check.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +Grade Week 3 (keys, addresses, encoding). + + python3 week-03/check.py # grades exercises/keys_addresses.py + python3 week-03/check.py --solution # grades the reference solution + +Expected values come from outside this repo: private key 1 is the example +in BIP173 (its P2WPKH address is BIP173's first test vector), and the rest are +the answers to Programming Bitcoin's Chapter 4 exercises. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib")) +from dojo import G, hash160, need, run_checks, load_week # noqa: E402 + +m = load_week(__file__, "keys_addresses.py", "--solution" in sys.argv) + + +def sec_compressed(): + got = need(m.sec(1 * G, compressed=True), "sec").hex() + assert got == "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", f"got {got}" + + +def sec_uncompressed(): + got = need(m.sec(1 * G, compressed=False), "sec").hex() + assert got.startswith("0479be667e") and got.endswith("fb10d4b8") and len(got) == 130, f"got {got}" + + +def base58(): + assert need(m.encode_base58(bytes.fromhex("00000a")), "encode_base58") == "11B", \ + "leading zero bytes become '1's: 00000a -> 11B" + got = m.encode_base58(bytes.fromhex("7c076ff316692a3d7eb3c3bb0f8b1488cf72e1afcd929e29307032997a838a3d")) + assert got == "9MA8fRQrT4u8Zj8ZRd6MAiiyaxb2Y1CMpvVkHQu5hVM6", f"got {got}" + + +def p2pkh_key1(): + got = need(m.p2pkh_address(m.sec(1 * G, True)), "p2pkh_address") + assert got == "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH", f"got {got}" + + +def p2pkh_uncompressed_differs(): + got = m.p2pkh_address(m.sec(1 * G, False)) + assert got == "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm", f"got {got} - same key, different address!" + + +def p2pkh_book_answers(): + cases = [(5002, False, True, "mmTPbXQFxboEtNRkwfh6K51jvdtHLxGeMA"), + (2020 ** 5, True, True, "mopVkxp8UhXqRYbCYJsbeE1h1fiF64jcoH"), + (0x12345DEADBEEF, True, False, "1F1Pn2y6pDb68E5nYJJeba4TLg2U7B6KF1")] + for secret, compressed, testnet, want in cases: + got = m.p2pkh_address(m.sec(secret * G, compressed), testnet=testnet) + assert got == want, f"secret {secret}: got {got}, want {want}" + + +def p2wpkh_bip173(): + got = need(m.p2wpkh_address(m.sec(1 * G, True)), "p2wpkh_address") + assert got == "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", f"got {got} (BIP173 test vector)" + + +def wif_key1(): + assert need(m.wif(1, compressed=True), "wif") == "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn" + assert m.wif(1, compressed=False) == "5HpHagT65TZzG1PH3CSu63k8DbpvD8s5ip4nEB3kEsreAnchuDf" + + +def decode_roundtrip(): + payload = need(m.decode_base58_check("1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH"), "decode_base58_check") + assert payload == b"\x00" + hash160(bytes.fromhex("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798")), \ + "decoding should return version byte + hash160" + try: + m.decode_base58_check("1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMJ") + except ValueError: + return + raise AssertionError("a corrupted address must raise ValueError (bad checksum)") + + +run_checks("Bitcoin Dojo - Week 3: Keys, Addresses & Encoding", [ + ("SEC: compressed public key of secret 1", sec_compressed), + ("SEC: uncompressed public key of secret 1", sec_uncompressed), + ("Base58 encoding", base58), + ("P2PKH address of secret 1", p2pkh_key1), + ("Uncompressed key gives a different address", p2pkh_uncompressed_differs), + ("P2PKH: Programming Bitcoin ch.4 answers (incl. testnet)", p2pkh_book_answers), + ("P2WPKH (bech32) address = BIP173 test vector", p2wpkh_bip173), + ("WIF private keys", wif_key1), + ("Base58Check decode + checksum validation", decode_roundtrip), +]) or sys.exit(1) diff --git a/bitcoin-dojo/week-03/exercises/.gitkeep b/bitcoin-dojo/week-03/exercises/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/bitcoin-dojo/week-03/exercises/keys_addresses.py b/bitcoin-dojo/week-03/exercises/keys_addresses.py new file mode 100644 index 0000000..0c35c8b --- /dev/null +++ b/bitcoin-dojo/week-03/exercises/keys_addresses.py @@ -0,0 +1,116 @@ +""" +Bitcoin Dojo - Week 3 Exercises: Keys, Addresses & Encoding +Code Orange Dev School | codeorange.dev + +Based on Programming Bitcoin, Chapter 4 +https://github.com/jimmysong/programmingbitcoin + +Fill in the functions, then grade yourself (from the bitcoin-dojo folder): + + python3 week-03/check.py + +The checks use real, public test vectors: BIP173's example key and the +answers to the book's exercises. Unfinished functions show up as +"not implemented yet", so you can go one at a time. + +Provided (from lib/dojo.py - you built the curve math in Weeks 1-2): + G the secp256k1 generator; secret * G is a public key Point + point.x, point.y coordinates (ints) + sha256(b), hash256(b), hash160(b) + bech32_segwit(hrp, version, program) +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "lib")) +from dojo import G, Point, sha256, hash256, hash160, bech32_segwit # noqa: E402,F401 + +BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + + +# --------------------------------------------------------------------------- +# Exercise 1 - SEC format (serializing a public key) +# +# uncompressed: 04 || x (32 bytes) || y (32 bytes) 65 bytes +# compressed: 02 if y is even, 03 if odd || x (32 bytes) 33 bytes +# +# Numbers are big-endian: n.to_bytes(32, "big") +# --------------------------------------------------------------------------- +def sec(point: Point, compressed: bool = True) -> bytes: + # YOUR CODE HERE + return None + + +# --------------------------------------------------------------------------- +# Exercise 2 - Base58 +# +# Treat the bytes as one big number (int.from_bytes(b, "big")), repeatedly +# divmod by 58, and map each remainder to BASE58_ALPHABET (building the string +# from the right). Every leading 0x00 byte becomes a leading "1". +# +# Why not base64? No 0/O/I/l to confuse, and no +/ characters. +# --------------------------------------------------------------------------- +def encode_base58(b: bytes) -> str: + # YOUR CODE HERE + return None + + +# --------------------------------------------------------------------------- +# Exercise 3 - Base58Check: append a 4-byte checksum, hash256(payload)[:4] +# --------------------------------------------------------------------------- +def encode_base58_check(payload: bytes) -> str: + # YOUR CODE HERE + return None + + +# --------------------------------------------------------------------------- +# Exercise 4 - Decode Base58Check and verify the checksum +# +# Reverse Exercise 2: num = num * 58 + ALPHABET.index(c), convert back to bytes, +# restore one 0x00 per leading "1". Split off the last 4 bytes and raise +# ValueError if they don't equal hash256(payload)[:4]. Return the payload. +# --------------------------------------------------------------------------- +def decode_base58_check(s: str) -> bytes: + # YOUR CODE HERE + return None + + +# --------------------------------------------------------------------------- +# Exercise 5 - P2PKH address: base58check(version || hash160(sec)) +# version byte: 0x00 mainnet, 0x6f testnet +# --------------------------------------------------------------------------- +def p2pkh_address(sec_bytes: bytes, testnet: bool = False) -> str: + # YOUR CODE HERE + return None + + +# --------------------------------------------------------------------------- +# Exercise 6 - Native segwit (P2WPKH) address +# bech32_segwit("bc" or "tb", 0, hash160(sec)) +# Compare with Exercise 5: same hash160, different wrapper. +# --------------------------------------------------------------------------- +def p2wpkh_address(sec_bytes: bytes, testnet: bool = False) -> str: + # YOUR CODE HERE + return None + + +# --------------------------------------------------------------------------- +# Exercise 7 - WIF (Wallet Import Format) for a private key +# base58check( 0x80 mainnet / 0xef testnet +# || secret as 32 bytes big-endian +# || 0x01 if the matching public key is compressed ) +# --------------------------------------------------------------------------- +def wif(secret: int, compressed: bool = True, testnet: bool = False) -> str: + # YOUR CODE HERE + return None + + +# --------------------------------------------------------------------------- +# Discussion (bring to the call): +# 1. Walk the full path from a 256-bit secret to a bc1q... address. Why does +# each step exist? +# 2. The same secret gives two different P2PKH addresses (compressed and +# uncompressed). Why can that lose people money when they restore a wallet? +# 3. Why hash160 and not just sha256? What do we gain and give up with 20 bytes? +# --------------------------------------------------------------------------- diff --git a/bitcoin-dojo/week-03/solutions/.gitkeep b/bitcoin-dojo/week-03/solutions/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/bitcoin-dojo/week-03/solutions/keys_addresses.py b/bitcoin-dojo/week-03/solutions/keys_addresses.py new file mode 100644 index 0000000..3ed2c80 --- /dev/null +++ b/bitcoin-dojo/week-03/solutions/keys_addresses.py @@ -0,0 +1,61 @@ +""" +Bitcoin Dojo - Week 3 REFERENCE SOLUTION: Keys, Addresses & Encoding +(Programming Bitcoin, Chapter 4) + +Grade it: python3 week-03/check.py --solution +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "lib")) +from dojo import G, Point, sha256, hash256, hash160, bech32_segwit # noqa: E402 + +BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + + +def sec(point: Point, compressed: bool = True) -> bytes: + if compressed: + return (b"\x02" if point.y % 2 == 0 else b"\x03") + point.x.to_bytes(32, "big") + return b"\x04" + point.x.to_bytes(32, "big") + point.y.to_bytes(32, "big") + + +def encode_base58(b: bytes) -> str: + leading_zeros = len(b) - len(b.lstrip(b"\x00")) + num = int.from_bytes(b, "big") + out = "" + while num > 0: + num, mod = divmod(num, 58) + out = BASE58_ALPHABET[mod] + out + return "1" * leading_zeros + out + + +def encode_base58_check(payload: bytes) -> str: + return encode_base58(payload + hash256(payload)[:4]) + + +def decode_base58_check(s: str) -> bytes: + num = 0 + for c in s: + num = num * 58 + BASE58_ALPHABET.index(c) + leading = len(s) - len(s.lstrip("1")) + raw = b"\x00" * leading + (num.to_bytes((num.bit_length() + 7) // 8, "big") if num else b"") + payload, checksum = raw[:-4], raw[-4:] + if hash256(payload)[:4] != checksum: + raise ValueError("bad checksum") + return payload + + +def p2pkh_address(sec_bytes: bytes, testnet: bool = False) -> str: + prefix = b"\x6f" if testnet else b"\x00" + return encode_base58_check(prefix + hash160(sec_bytes)) + + +def p2wpkh_address(sec_bytes: bytes, testnet: bool = False) -> str: + return bech32_segwit("tb" if testnet else "bc", 0, hash160(sec_bytes)) + + +def wif(secret: int, compressed: bool = True, testnet: bool = False) -> str: + prefix = b"\xef" if testnet else b"\x80" + suffix = b"\x01" if compressed else b"" + return encode_base58_check(prefix + secret.to_bytes(32, "big") + suffix) diff --git a/bitcoin-dojo/week-04/check.py b/bitcoin-dojo/week-04/check.py new file mode 100644 index 0000000..1452495 --- /dev/null +++ b/bitcoin-dojo/week-04/check.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +Grade Week 4 (serialisation & transactions). + + python3 week-04/check.py # grades exercises/transactions.py + python3 week-04/check.py --solution # grades the reference solution + +The transactions are real mainnet transactions (data/transactions.json). +Your parser and serializer pass only if you reproduce their real txids, +byte for byte. +""" + +import json +import sys +from io import BytesIO +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent / "lib")) +from dojo import need, run_checks, load_week # noqa: E402 + +m = load_week(__file__, "transactions.py", "--solution" in sys.argv) +TXS = json.loads((HERE.parent / "data" / "transactions.json").read_text()) +P2PKH, B170 = TXS["p2pkh_250000"], TXS["block170"] + + +def parse(hex_): + return need(m.Tx.parse(BytesIO(bytes.fromhex(hex_))), "Tx.parse") + + +def varint_read(): + for raw, want in (("64", 100), ("fdff00", 255), ("fd2b02", 555), ("fe7f110100", 70015), + ("ff6dc7ed3e60100000", 18005558675309)): + got = need(m.read_varint(BytesIO(bytes.fromhex(raw))), "read_varint") + assert got == want, f"{raw} should read as {want}, got {got}" + + +def varint_encode(): + for n, want in ((100, "64"), (255, "fdff00"), (555, "fd2b02"), (70015, "fe7f110100"), + (18005558675309, "ff6dc7ed3e60100000")): + got = need(m.encode_varint(n), "encode_varint").hex() + assert got == want, f"{n} should encode as {want}, got {got}" + + +def parse_fields(): + tx = parse(P2PKH["hex"]) + assert tx.version == 1, f"version should be 1, got {tx.version}" + assert len(tx.tx_ins) == 1 and len(tx.tx_outs) == 2, "expected 1 input and 2 outputs" + assert [o.amount for o in tx.tx_outs] == [453653000, 5258944500], \ + f"output amounts wrong: {[o.amount for o in tx.tx_outs]} (little-endian!)" + assert tx.tx_ins[0].prev_tx.hex().startswith("6f5017"), \ + "prev_tx should be shown big-endian: reverse the 32 bytes you read" + assert tx.tx_ins[0].prev_index == 1 and tx.locktime == 0 + + +def roundtrip(): + tx = parse(P2PKH["hex"]) + got = need(tx.serialize(), "Tx.serialize").hex() + assert got == P2PKH["hex"], "serialize(parse(raw)) must give back the exact raw bytes" + + +def txid_p2pkh(): + got = need(parse(P2PKH["hex"]).id(), "Tx.id") + assert got == P2PKH["txid"], f"got {got}, want the real txid {P2PKH['txid']}" + + +def txid_block170(): + got = need(parse(B170["hex"]).id(), "Tx.id") + assert got == B170["txid"], f"got {got}, want {B170['txid']}" + + +def fee(): + got = need(parse(P2PKH["hex"]).fee([P2PKH["prevout_value"]]), "Tx.fee") + assert got == P2PKH["fee"], f"fee should be {P2PKH['fee']} sats, got {got}" + assert parse(B170["hex"]).fee([B170["prevout_value"]]) == 0, "Satoshi's payment to Hal paid no fee" + + +run_checks("Bitcoin Dojo - Week 4: Serialisation & Transactions", [ + ("read_varint (Programming Bitcoin values)", varint_read), + ("encode_varint", varint_encode), + ("Parse a real 2013 transaction: fields", parse_fields), + ("Round trip: serialize(parse(raw)) == raw", roundtrip), + ("txid of the block 250,000 transaction", txid_p2pkh), + ("txid of Satoshi's payment to Hal Finney (block 170)", txid_block170), + ("Fee = inputs - outputs", fee), +]) or sys.exit(1) diff --git a/bitcoin-dojo/week-04/exercises/.gitkeep b/bitcoin-dojo/week-04/exercises/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/bitcoin-dojo/week-04/exercises/transactions.py b/bitcoin-dojo/week-04/exercises/transactions.py new file mode 100644 index 0000000..049d06c --- /dev/null +++ b/bitcoin-dojo/week-04/exercises/transactions.py @@ -0,0 +1,133 @@ +""" +Bitcoin Dojo - Week 4 Exercises: Serialisation & Transactions +Code Orange Dev School | codeorange.dev + +Based on Programming Bitcoin, Chapter 5 +https://github.com/jimmysong/programmingbitcoin + +Build a transaction parser and serializer, then prove it works on REAL +mainnet transactions: the checker only passes if you reproduce their actual +txids byte for byte. Run from the bitcoin-dojo folder: + + python3 week-04/check.py + +The transactions live in data/transactions.json, including block 170's +transaction, the first bitcoin payment between two people (Satoshi to Hal Finney). + +Byte order cheat sheet: + int.from_bytes(s.read(4), "little") read a little-endian uint32 + n.to_bytes(8, "little") write a little-endian uint64 + b[::-1] reverse bytes (txids are displayed reversed) +""" + +import sys +from io import BytesIO +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "lib")) +from dojo import hash256 # noqa: E402 + + +# --------------------------------------------------------------------------- +# Exercise 1 - Varints (CompactSize) +# < 0xfd 1 byte +# <= 0xffff 0xfd + 2 bytes little-endian +# <= 0xffffffff 0xfe + 4 bytes little-endian +# otherwise 0xff + 8 bytes little-endian +# --------------------------------------------------------------------------- +def read_varint(s: BytesIO) -> int: + # YOUR CODE HERE + return None + + +def encode_varint(i: int) -> bytes: + # YOUR CODE HERE + return None + + +# --------------------------------------------------------------------------- +# Exercise 2 - Inputs and outputs +# +# TxIn on the wire: prev_tx (32 bytes, reversed!) | prev_index (4, LE) +# | script_sig length (varint) | script_sig | sequence (4, LE) +# TxOut on the wire: amount (8, LE) | script_pubkey length (varint) | script_pubkey +# +# Store prev_tx the way explorers display it (big-endian): reverse after reading, +# reverse again before writing. +# --------------------------------------------------------------------------- +class TxIn: + def __init__(self, prev_tx: bytes, prev_index: int, script_sig: bytes, sequence: int): + self.prev_tx = prev_tx + self.prev_index = prev_index + self.script_sig = script_sig + self.sequence = sequence + + @classmethod + def parse(cls, s: BytesIO) -> "TxIn": + # YOUR CODE HERE + return None + + def serialize(self) -> bytes: + # YOUR CODE HERE + return None + + +class TxOut: + def __init__(self, amount: int, script_pubkey: bytes): + self.amount = amount + self.script_pubkey = script_pubkey + + @classmethod + def parse(cls, s: BytesIO) -> "TxOut": + # YOUR CODE HERE + return None + + def serialize(self) -> bytes: + # YOUR CODE HERE + return None + + +# --------------------------------------------------------------------------- +# Exercise 3 - The transaction +# +# version (4, LE) | input count (varint) | inputs | output count (varint) +# | outputs | locktime (4, LE) +# +# (This is the legacy format. Segwit adds a marker, a flag and witnesses - Week 6.) +# --------------------------------------------------------------------------- +class Tx: + def __init__(self, version: int, tx_ins, tx_outs, locktime: int): + self.version, self.tx_ins, self.tx_outs, self.locktime = version, tx_ins, tx_outs, locktime + + @classmethod + def parse(cls, s: BytesIO) -> "Tx": + # YOUR CODE HERE + return None + + def serialize(self) -> bytes: + # YOUR CODE HERE + return None + + # ----------------------------------------------------------------------- + # Exercise 4 - txid = hash256(serialization), reversed, as hex + # ----------------------------------------------------------------------- + def id(self) -> str: + # YOUR CODE HERE + return None + + # ----------------------------------------------------------------------- + # Exercise 5 - fee = sum of input values - sum of output amounts + # A transaction doesn't store its input values. Why not? (See discussion.) + # ----------------------------------------------------------------------- + def fee(self, input_values) -> int: + # YOUR CODE HERE + return None + + +# --------------------------------------------------------------------------- +# Discussion (bring to the call): +# 1. Why are txids displayed in the reverse byte order of the hash? +# 2. The fee isn't written anywhere in the transaction. How does a node know it? +# What does that imply for a hardware wallet that must show you the fee? +# 3. Block 170's transaction paid a fee of 0. Why was that fine in 2009? +# --------------------------------------------------------------------------- diff --git a/bitcoin-dojo/week-04/solutions/.gitkeep b/bitcoin-dojo/week-04/solutions/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/bitcoin-dojo/week-04/solutions/transactions.py b/bitcoin-dojo/week-04/solutions/transactions.py new file mode 100644 index 0000000..2b265e7 --- /dev/null +++ b/bitcoin-dojo/week-04/solutions/transactions.py @@ -0,0 +1,94 @@ +""" +Bitcoin Dojo - Week 4 REFERENCE SOLUTION: Serialisation & Transactions +(Programming Bitcoin, Chapter 5) + +Grade it: python3 week-04/check.py --solution +""" + +import sys +from io import BytesIO +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "lib")) +from dojo import hash256 # noqa: E402 + + +def read_varint(s: BytesIO) -> int: + i = s.read(1)[0] + if i == 0xFD: + return int.from_bytes(s.read(2), "little") + if i == 0xFE: + return int.from_bytes(s.read(4), "little") + if i == 0xFF: + return int.from_bytes(s.read(8), "little") + return i + + +def encode_varint(i: int) -> bytes: + if i < 0xFD: + return bytes([i]) + if i < 0x10000: + return b"\xfd" + i.to_bytes(2, "little") + if i < 0x100000000: + return b"\xfe" + i.to_bytes(4, "little") + return b"\xff" + i.to_bytes(8, "little") + + +class TxIn: + def __init__(self, prev_tx: bytes, prev_index: int, script_sig: bytes, sequence: int): + self.prev_tx = prev_tx # 32 bytes, as displayed (big-endian) + self.prev_index = prev_index + self.script_sig = script_sig # raw script bytes (without length prefix) + self.sequence = sequence + + @classmethod + def parse(cls, s: BytesIO) -> "TxIn": + prev_tx = s.read(32)[::-1] + prev_index = int.from_bytes(s.read(4), "little") + script_sig = s.read(read_varint(s)) + sequence = int.from_bytes(s.read(4), "little") + return cls(prev_tx, prev_index, script_sig, sequence) + + def serialize(self) -> bytes: + return (self.prev_tx[::-1] + self.prev_index.to_bytes(4, "little") + + encode_varint(len(self.script_sig)) + self.script_sig + + self.sequence.to_bytes(4, "little")) + + +class TxOut: + def __init__(self, amount: int, script_pubkey: bytes): + self.amount = amount # satoshis + self.script_pubkey = script_pubkey + + @classmethod + def parse(cls, s: BytesIO) -> "TxOut": + amount = int.from_bytes(s.read(8), "little") + return cls(amount, s.read(read_varint(s))) + + def serialize(self) -> bytes: + return self.amount.to_bytes(8, "little") + encode_varint(len(self.script_pubkey)) + self.script_pubkey + + +class Tx: + def __init__(self, version: int, tx_ins, tx_outs, locktime: int): + self.version, self.tx_ins, self.tx_outs, self.locktime = version, tx_ins, tx_outs, locktime + + @classmethod + def parse(cls, s: BytesIO) -> "Tx": + version = int.from_bytes(s.read(4), "little") + tx_ins = [TxIn.parse(s) for _ in range(read_varint(s))] + tx_outs = [TxOut.parse(s) for _ in range(read_varint(s))] + locktime = int.from_bytes(s.read(4), "little") + return cls(version, tx_ins, tx_outs, locktime) + + def serialize(self) -> bytes: + return (self.version.to_bytes(4, "little") + + encode_varint(len(self.tx_ins)) + b"".join(i.serialize() for i in self.tx_ins) + + encode_varint(len(self.tx_outs)) + b"".join(o.serialize() for o in self.tx_outs) + + self.locktime.to_bytes(4, "little")) + + def id(self) -> str: + return hash256(self.serialize())[::-1].hex() + + def fee(self, input_values) -> int: + return sum(input_values) - sum(o.amount for o in self.tx_outs) diff --git a/bitcoin-dojo/week-05/check.py b/bitcoin-dojo/week-05/check.py new file mode 100644 index 0000000..f09d681 --- /dev/null +++ b/bitcoin-dojo/week-05/check.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +""" +Grade Week 5 (Script & transaction validation). + + python3 week-05/check.py # grades exercises/script_lab.py + python3 week-05/check.py --solution # grades the reference solution + +The headline check verifies the ECDSA signature of a REAL mainnet transaction +from block 250,000. It only passes if your sighash, your ECDSA verify and your +Script interpreter are all correct. +""" + +import json +import sys +from io import BytesIO +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent / "lib")) +from dojo import G, N, hash160, hash256, need, run_checks, load_week # noqa: E402 + +m = load_week(__file__, "script_lab.py", "--solution" in sys.argv) +REAL = json.loads((HERE.parent / "data" / "transactions.json").read_text())["p2pkh_250000"] +TX = m.Tx.parse(BytesIO(bytes.fromhex(REAL["hex"]))) +SPK = bytes.fromhex(REAL["prevout_script_pubkey"]) +SIG, SEC = m.parse_script(TX.tx_ins[0].script_sig) + + +def _der(r, s): + def enc(n): + b = n.to_bytes(32, "big").lstrip(b"\x00") + return b"\x02" + bytes([len(b) + (b[0] >= 0x80)]) + (b"\x00" if b[0] >= 0x80 else b"") + b + body = enc(r) + enc(s) + return b"\x30" + bytes([len(body)]) + body + + +def _sign(secret, z, k): + """Test-only ECDSA signing with a fixed nonce. NEVER reuse k in real life.""" + r = (k * G).x % N + s = (z + r * secret) * pow(k, N - 2, N) % N + return _der(r, min(s, N - s)) + b"\x01" + + +def _sec(point): + return (b"\x02" if point.y % 2 == 0 else b"\x03") + point.x.to_bytes(32, "big") + + +def real_sighash(): + z = need(m.sig_hash(TX, 0, SPK), "sig_hash") + assert isinstance(z, int) and z.bit_length() > 200, "sig_hash should return the hash as a big integer" + assert hash160(SEC) == SPK[3:23], "sanity: the key in scriptSig matches the prevout's hash" + + +def real_signature(): + z = need(m.sig_hash(TX, 0, SPK), "sig_hash") + from dojo import parse_der, Point + r, s = parse_der(SIG[:-1]) + ok = need(m.verify(z, r, s, Point.from_sec(SEC)), "verify") + assert ok is True, "the real 2013 signature should verify (check sig_hash and verify)" + + +def tampered_fails(): + z = need(m.sig_hash(TX, 0, SPK), "sig_hash") + from dojo import parse_der, Point + r, s = parse_der(SIG[:-1]) + assert need(m.verify(z + 1, r, s, Point.from_sec(SEC)), "verify") is False, \ + "changing the message by 1 must make verification fail" + + +def p2pkh_script(): + z = m.sig_hash(TX, 0, SPK) + ok = need(m.evaluate(TX.tx_ins[0].script_sig, SPK, z), "evaluate") + assert ok is True, "scriptSig + scriptPubKey of the real transaction should evaluate to true" + + +def p2pkh_wrong_key(): + z = m.sig_hash(TX, 0, SPK) + wrong = SPK[:3] + bytes(20) + SPK[23:] + assert need(m.evaluate(TX.tx_ins[0].script_sig, wrong, z), "evaluate") is False, \ + "OP_EQUALVERIFY must fail when the pubkey hash doesn't match" + + +def multisig(): + z = int.from_bytes(hash256(b"Code Orange 2-of-3"), "big") + keys = [11, 22, 33] + redeem = bytes([0x52]) + b"".join(b"\x21" + _sec(k * G) for k in keys) + bytes([0x53, 0xAE]) + s1, s3 = _sign(11, z, 1001), _sign(33, z, 1003) + push = lambda b: bytes([len(b)]) + b + good = b"\x00" + push(s1) + push(s3) + assert need(m.evaluate(good, redeem, z), "evaluate") is True, "2-of-3 with keys 1 and 3 should pass" + bad = b"\x00" + push(s1) + push(_sign(44, z, 1004)) + assert m.evaluate(bad, redeem, z) is False, "a signature from a key not in the script must fail" + swapped = b"\x00" + push(s3) + push(s1) + assert m.evaluate(swapped, redeem, z) is False, "signatures must be in the same order as the keys" + + +def multisig_dummy(): + z = int.from_bytes(hash256(b"off by one"), "big") + redeem = bytes([0x51]) + b"\x21" + _sec(11 * G) + bytes([0x51, 0xAE]) + sig = _sign(11, z, 777) + no_dummy = bytes([len(sig)]) + sig + try: + result = need(m.evaluate(no_dummy, redeem, z), "evaluate") + except IndexError: + result = False + assert result is False, "without the extra dummy element, OP_CHECKMULTISIG must fail (the off-by-one)" + + +run_checks("Bitcoin Dojo - Week 5: Script & Transaction Validation", [ + ("Legacy sighash of a real 2013 transaction", real_sighash), + ("ECDSA: the real mainnet signature verifies", real_signature), + ("ECDSA: a tampered message fails", tampered_fails), + ("Script: P2PKH evaluates to true", p2pkh_script), + ("Script: wrong pubkey hash fails", p2pkh_wrong_key), + ("Script: 2-of-3 OP_CHECKMULTISIG", multisig), + ("Script: the OP_CHECKMULTISIG off-by-one", multisig_dummy), +]) or sys.exit(1) diff --git a/bitcoin-dojo/week-05/exercises/.gitkeep b/bitcoin-dojo/week-05/exercises/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/bitcoin-dojo/week-05/exercises/script_lab.py b/bitcoin-dojo/week-05/exercises/script_lab.py new file mode 100644 index 0000000..4852312 --- /dev/null +++ b/bitcoin-dojo/week-05/exercises/script_lab.py @@ -0,0 +1,146 @@ +""" +Bitcoin Dojo - Week 5 Exercises: Script & Transaction Validation +Code Orange Dev School | codeorange.dev + +Based on Programming Bitcoin, Chapters 3, 6 and 7 +https://github.com/jimmysong/programmingbitcoin + +You'll verify the signature on a REAL transaction from block 250,000 (2013), +using your own sighash, your own ECDSA and your own Script interpreter. +Run from the bitcoin-dojo folder: + + python3 week-05/check.py + +Provided: the curve (G, N, Point), hash160/hash256, parse_der, last week's Tx +parser, parse_script(), and check_sig() glue once your verify() works. +""" + +import copy +import importlib.util +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "lib")) +from dojo import G, N, Point, hash160, hash256, parse_der # noqa: E402 + +# PROVIDED: last week's transaction parser (reference version). +_spec = importlib.util.spec_from_file_location("week4", ROOT / "week-04" / "solutions" / "transactions.py") +_week4 = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_week4) +Tx = _week4.Tx + +OP_0, OP_DUP, OP_HASH160, OP_EQUAL, OP_EQUALVERIFY, OP_CHECKSIG, OP_CHECKMULTISIG = 0x00, 0x76, 0xA9, 0x87, 0x88, 0xAC, 0xAE +SIGHASH_ALL = 1 + + +def parse_script(raw: bytes): + """PROVIDED: bytes -> list of items. Data pushes become bytes, opcodes stay ints.""" + items, i = [], 0 + while i < len(raw): + op = raw[i] + i += 1 + if 1 <= op <= 75: + items.append(raw[i:i + op]) + i += op + elif op == 0x4C: # OP_PUSHDATA1 + n = raw[i] + items.append(raw[i + 1:i + 1 + n]) + i += 1 + n + else: + items.append(op) + return items + + +def sig_hash(tx, input_index: int, script_pubkey: bytes) -> int: + """Exercise 1 - the legacy signature hash (SIGHASH_ALL). + + What the signer actually signed: + 1. copy the transaction (copy.deepcopy) + 2. empty every input's script_sig (b"")... + 3. ...except input_index, which gets the script_pubkey of the coin it spends + 4. serialize, append SIGHASH_ALL as 4 bytes little-endian + 5. hash256 it and return int.from_bytes(..., "big") + """ + # YOUR CODE HERE + return None + + +def verify(z: int, r: int, s: int, pubkey: Point) -> bool: + """Exercise 2 - ECDSA verification (Programming Bitcoin ch. 3). + + s_inv = pow(s, N - 2, N) # modular inverse via Fermat + u = z * s_inv % N + v = r * s_inv % N + R = u * G + v * pubkey + valid if R.x % N == r + + Return True/False (reject r or s outside [1, N-1]). + """ + # YOUR CODE HERE + return None + + +def check_sig(der_with_type: bytes, sec: bytes, z: int) -> bool: + """PROVIDED glue: strip the sighash byte, parse DER and the key, then verify.""" + r, s = parse_der(der_with_type[:-1]) + return verify(z, r, s, Point.from_sec(sec)) + + +def evaluate(script_sig: bytes, script_pubkey: bytes, z: int) -> bool: + """Exercise 3 - a tiny Script interpreter. + + Run the scriptSig items, then the scriptPubKey items, on ONE stack. + Items are bytes (push them) or opcode ints. Implement: + OP_0 push b"" + OP_1..OP_16 (0x51-0x60) push bytes([item - 0x50]) + OP_DUP duplicate the top item + OP_HASH160 pop, push hash160(item) + OP_EQUAL pop two, push b"\x01" if equal else b"" + OP_EQUALVERIFY pop two, return False immediately if not equal + OP_CHECKSIG pop sec (top), then sig; push b"\x01" if check_sig(sig, sec, z) else b"" + OP_CHECKMULTISIG call op_checkmultisig(stack, z); return False if it returns False + anything else return False (unsupported) + Any opcode with too few stack items -> return False. + At the end: True if the stack is non-empty and the top isn't b"" or b"\x00". + """ + stack = [] + for item in parse_script(script_sig) + parse_script(script_pubkey): + if isinstance(item, bytes): + stack.append(item) + elif item == OP_DUP: + if not stack: + return False + stack.append(stack[-1]) + # YOUR CODE HERE: the other opcodes + else: + return None # replace with: return False, once the others are done + return None # replace with the final stack check + + +def op_checkmultisig(stack, z: int) -> bool: + """Exercise 4 - OP_CHECKMULTISIG, bug and all. + + Stack, top last: ... ... + 1. pop n, then n keys (restore their order), then m, then m sigs + 2. pop ONE MORE item: the famous off-by-one. Satoshi's code pops an extra + element, so every multisig scriptSig starts with OP_0. It can't be + fixed without a hard fork. + 3. each signature must match a key, in order: walk the keys forward and + never go back + 4. push b"\x01" if all m sigs matched, else b"". Return True (it ran). + m and n arrive as 1-byte numbers: int.from_bytes(b, "little"). + """ + # YOUR CODE HERE + return None + + +# --------------------------------------------------------------------------- +# Discussion (bring to the call): +# 1. Trace the real P2PKH script: what's on the stack after each opcode? +# 2. Why does the signer put the *previous* scriptPubKey into the sighash? +# 3. The off-by-one bug: why has Bitcoin kept it for 15+ years? What did +# BIP147 (NULLDUMMY) do about the extra element? +# 4. Look at the real transaction: its public key is uncompressed (65 bytes). +# What did that cost the sender in fees, compared to a compressed key? +# --------------------------------------------------------------------------- diff --git a/bitcoin-dojo/week-05/solutions/.gitkeep b/bitcoin-dojo/week-05/solutions/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/bitcoin-dojo/week-05/solutions/script_lab.py b/bitcoin-dojo/week-05/solutions/script_lab.py new file mode 100644 index 0000000..bc2c2c9 --- /dev/null +++ b/bitcoin-dojo/week-05/solutions/script_lab.py @@ -0,0 +1,121 @@ +""" +Bitcoin Dojo - Week 5 REFERENCE SOLUTION: Script & Transaction Validation +(Programming Bitcoin, Chapters 3, 6 and 7) + +Grade it: python3 week-05/check.py --solution +""" + +import copy +import importlib.util +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "lib")) +from dojo import G, N, Point, hash160, hash256, parse_der # noqa: E402 + +# PROVIDED: last week's transaction parser (reference version). +_spec = importlib.util.spec_from_file_location("week4", ROOT / "week-04" / "solutions" / "transactions.py") +_week4 = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_week4) +Tx = _week4.Tx + +OP_0, OP_DUP, OP_HASH160, OP_EQUAL, OP_EQUALVERIFY, OP_CHECKSIG, OP_CHECKMULTISIG = 0x00, 0x76, 0xA9, 0x87, 0x88, 0xAC, 0xAE +SIGHASH_ALL = 1 + + +def parse_script(raw: bytes): + """PROVIDED: bytes -> list of items. Data pushes become bytes, opcodes stay ints.""" + items, i = [], 0 + while i < len(raw): + op = raw[i] + i += 1 + if 1 <= op <= 75: + items.append(raw[i:i + op]) + i += op + elif op == 0x4C: # OP_PUSHDATA1 + n = raw[i] + items.append(raw[i + 1:i + 1 + n]) + i += 1 + n + else: + items.append(op) + return items + + +def sig_hash(tx, input_index: int, script_pubkey: bytes) -> int: + t = copy.deepcopy(tx) + for i, tx_in in enumerate(t.tx_ins): + tx_in.script_sig = script_pubkey if i == input_index else b"" + return int.from_bytes(hash256(t.serialize() + SIGHASH_ALL.to_bytes(4, "little")), "big") + + +def verify(z: int, r: int, s: int, pubkey: Point) -> bool: + if not (1 <= r < N and 1 <= s < N): + return False + s_inv = pow(s, N - 2, N) + R = (z * s_inv % N) * G + (r * s_inv % N) * pubkey + return not R.is_infinity and R.x % N == r + + +def check_sig(der_with_type: bytes, sec: bytes, z: int) -> bool: + """PROVIDED glue: strip the sighash byte, parse DER and the key, then verify.""" + r, s = parse_der(der_with_type[:-1]) + return verify(z, r, s, Point.from_sec(sec)) + + +def evaluate(script_sig: bytes, script_pubkey: bytes, z: int) -> bool: + stack = [] + for item in parse_script(script_sig) + parse_script(script_pubkey): + if isinstance(item, bytes): + stack.append(item) + elif item == OP_0: + stack.append(b"") + elif 0x51 <= item <= 0x60: # OP_1 .. OP_16 push the number 1..16 + stack.append(bytes([item - 0x50])) + elif item == OP_DUP: + if not stack: + return False + stack.append(stack[-1]) + elif item == OP_HASH160: + if not stack: + return False + stack.append(hash160(stack.pop())) + elif item in (OP_EQUAL, OP_EQUALVERIFY): + if len(stack) < 2: + return False + equal = stack.pop() == stack.pop() + if item == OP_EQUALVERIFY: + if not equal: + return False + else: + stack.append(b"\x01" if equal else b"") + elif item == OP_CHECKSIG: + if len(stack) < 2: + return False + sec, sig = stack.pop(), stack.pop() + stack.append(b"\x01" if check_sig(sig, sec, z) else b"") + elif item == OP_CHECKMULTISIG: + if not op_checkmultisig(stack, z): + return False + else: + return False # opcode not supported in this mini interpreter + return bool(stack) and stack[-1] not in (b"", b"\x00") + + +def op_checkmultisig(stack, z: int) -> bool: + """Stack (top last): dummy, sig_1..sig_m, m, key_1..key_n, n. Pushes result.""" + n = int.from_bytes(stack.pop(), "little") + keys = [stack.pop() for _ in range(n)][::-1] + m = int.from_bytes(stack.pop(), "little") + sigs = [stack.pop() for _ in range(m)][::-1] + stack.pop() # the off-by-one: CHECKMULTISIG consumes one extra element + k = 0 + for sig in sigs: # signatures must appear in the same order as the keys + while k < len(keys) and not check_sig(sig, keys[k], z): + k += 1 + if k == len(keys): + stack.append(b"") + return True + k += 1 + stack.append(b"\x01") + return True diff --git a/bitcoin-dojo/week-06/check.py b/bitcoin-dojo/week-06/check.py new file mode 100644 index 0000000..f0004ce --- /dev/null +++ b/bitcoin-dojo/week-06/check.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Grade Week 6 (SegWit & advanced transactions). + + python3 week-06/check.py # grades exercises/segwit_lab.py + python3 week-06/check.py --solution # grades the reference solution + +Uses a REAL native-segwit transaction from block 800,000 (data/transactions.json): +your txid, weight and BIP143 signature hash have to match reality. +""" + +import json +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent / "lib")) +from dojo import Point, hash160, need, parse_der, run_checks, load_week # noqa: E402 + +m = load_week(__file__, "segwit_lab.py", "--solution" in sys.argv) +REAL = json.loads((HERE.parent / "data" / "transactions.json").read_text())["p2wpkh_800000"] +RAW = bytes.fromhex(REAL["hex"]) + +sys.path.insert(0, str(HERE.parent / "week-05" / "solutions")) +from script_lab import verify # noqa: E402 (reference ECDSA from Week 5) + + +def parsed(): + return need(m.parse_segwit(RAW), "parse_segwit") + + +def parse(): + tx, witnesses = parsed() + assert len(tx.tx_ins) == 1 and len(tx.tx_outs) == 2, "expected 1 input, 2 outputs" + assert [o.amount for o in tx.tx_outs] == [671958, 19425506], f"amounts: {[o.amount for o in tx.tx_outs]}" + assert len(witnesses) == 1 and len(witnesses[0]) == 2, "one witness stack with 2 items (signature, pubkey)" + assert tx.tx_ins[0].script_sig == b"", "native segwit inputs have an EMPTY scriptSig" + + +def txid_matches(): + tx, _ = parsed() + got = need(m.txid(tx), "txid") + assert got == REAL["txid"], f"got {got}, want {REAL['txid']} (txid excludes witness data!)" + + +def wtxid_differs(): + tx, _ = parsed() + w = need(m.wtxid(RAW), "wtxid") + assert w != m.txid(tx) and len(w) == 64, "wtxid hashes the full serialization, so it differs from txid" + + +def weight_matches(): + tx, _ = parsed() + w = need(m.weight(tx, RAW), "weight") + assert w == REAL["weight"], f"weight should be {REAL['weight']} WU, got {w}" + assert need(m.vsize(w), "vsize") == 141, f"vsize should be ceil(561/4) = 141, got {m.vsize(w)}" + + +def bip143_signature(): + tx, witnesses = parsed() + sig, sec = witnesses[0] + pkh = bytes.fromhex(REAL["prevout_script_pubkey"])[2:] + assert hash160(sec) == pkh, "sanity: witness pubkey matches the prevout" + z = need(m.bip143_sighash_p2wpkh(tx, 0, pkh, REAL["prevout_value"]), "bip143_sighash_p2wpkh") + r, s = parse_der(sig[:-1]) + assert verify(z, r, s, Point.from_sec(sec)), \ + "the real signature doesn't verify against your sighash: check field order and the amount" + + +def locktimes(): + assert need(m.describe_locktime(0), "describe_locktime") == ("none", 0) + assert m.describe_locktime(800000) == ("height", 800000) + assert m.describe_locktime(1700000000) == ("time", 1700000000), "values >= 500,000,000 are UNIX times" + + +def cltv(): + pkh = bytes(range(20)) + got = need(m.cltv_p2pkh_script(800000, pkh), "cltv_p2pkh_script").hex() + want = "0300350cb17576a914" + pkh.hex() + "88ac" + assert got == want, f"got {got}\n want {want} (800000 = 0x0c3500, little-endian script number)" + got = m.cltv_p2pkh_script(128, pkh).hex() + assert got.startswith("028000b175"), "128 = 0x80 needs a 0x00 sign byte: 8000" + + +run_checks("Bitcoin Dojo - Week 6: SegWit & Advanced Transactions", [ + ("Parse a real segwit transaction (block 800,000)", parse), + ("txid matches mainnet (witness excluded)", txid_matches), + ("wtxid differs from txid", wtxid_differs), + ("Weight and vsize match mainnet", weight_matches), + ("BIP143 sighash: the real signature verifies", bip143_signature), + ("nLockTime: height vs time", locktimes), + ("Build a CLTV-locked P2PKH script", cltv), +]) or sys.exit(1) diff --git a/bitcoin-dojo/week-06/exercises/.gitkeep b/bitcoin-dojo/week-06/exercises/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/bitcoin-dojo/week-06/exercises/segwit_lab.py b/bitcoin-dojo/week-06/exercises/segwit_lab.py new file mode 100644 index 0000000..c1773bf --- /dev/null +++ b/bitcoin-dojo/week-06/exercises/segwit_lab.py @@ -0,0 +1,138 @@ +""" +Bitcoin Dojo - Week 6 Exercises: SegWit & Advanced Transactions +Code Orange Dev School | codeorange.dev + +Based on Programming Bitcoin (Chapter 13), BIP141 and BIP143 +https://github.com/jimmysong/programmingbitcoin + +You'll take apart a REAL native-segwit payment from block 800,000, match its +txid and weight to mainnet, and verify its signature with the BIP143 sighash. +Run from the bitcoin-dojo folder: + + python3 week-06/check.py + +Provided: Tx / TxIn / TxOut / read_varint / encode_varint (Week 4 reference), +hash256, and (inside the checker) Week 5's ECDSA verify. +""" + +import importlib.util +import sys +from io import BytesIO +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "lib")) +from dojo import hash256 # noqa: E402 + +_spec = importlib.util.spec_from_file_location("week4", ROOT / "week-04" / "solutions" / "transactions.py") +_w4 = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_w4) +Tx, TxIn, TxOut, read_varint, encode_varint = _w4.Tx, _w4.TxIn, _w4.TxOut, _w4.read_varint, _w4.encode_varint + + +# --------------------------------------------------------------------------- +# Exercise 1 - Parse the segwit serialization (BIP144) +# +# version (4) | marker 0x00 | flag 0x01 | inputs | outputs +# | witnesses: for EACH input, a varint item count, then varint-prefixed items +# | locktime (4) +# +# Return (Tx(version, tx_ins, tx_outs, locktime), witnesses) where witnesses is +# a list (one per input) of lists of bytes. Raise ValueError if marker/flag are +# not 00 01. Reuse TxIn.parse / TxOut.parse on a BytesIO. +# --------------------------------------------------------------------------- +def parse_segwit(raw: bytes): + # YOUR CODE HERE + return None + + +# --------------------------------------------------------------------------- +# Exercise 2 - Two ids +# txid: hash256 of the LEGACY serialization (tx.serialize() has no witness) +# wtxid: hash256 of the full raw bytes (with marker, flag, witnesses) +# Both displayed reversed, as hex. Why does fixing malleability need this split? +# --------------------------------------------------------------------------- +def txid(tx) -> str: + # YOUR CODE HERE + return None + + +def wtxid(raw: bytes) -> str: + # YOUR CODE HERE + return None + + +# --------------------------------------------------------------------------- +# Exercise 3 - Weight and virtual size (BIP141) +# weight = base_size * 3 + total_size +# base_size = length without witness data (len(tx.serialize())) +# total_size = length with everything (len(raw)) +# vsize = weight / 4, rounded UP +# Fee rates are sat/vB, so this is the number wallets actually pay for. +# --------------------------------------------------------------------------- +def weight(tx, raw: bytes) -> int: + # YOUR CODE HERE + return None + + +def vsize(w: int) -> int: + # YOUR CODE HERE + return None + + +# --------------------------------------------------------------------------- +# Exercise 4 - BIP143 signature hash for a P2WPKH input +# +# double-SHA256 of: +# version (4 LE) +# hashPrevouts = hash256(all inputs' prev_tx reversed + prev_index 4 LE) +# hashSequence = hash256(all inputs' sequence 4 LE) +# this input's outpoint (prev_tx reversed + prev_index 4 LE) +# scriptCode = 0x19 76 a9 14 88 ac +# amount (8 LE) <- NEW: signatures commit to the value being spent +# this input's sequence (4 LE) +# hashOutputs = hash256(all outputs serialized) +# locktime (4 LE) +# sighash type (4 LE) = 1 +# Return int.from_bytes(..., "big"). +# +# Spec + test vectors: https://github.com/bitcoin/bips/blob/master/bip-0143.mediawiki +# --------------------------------------------------------------------------- +def bip143_sighash_p2wpkh(tx, input_index: int, pubkey_hash: bytes, amount: int) -> int: + # YOUR CODE HERE + return None + + +# --------------------------------------------------------------------------- +# Exercise 5 - Timelocks +# +# describe_locktime(n): ("none", 0) if n == 0, ("height", n) if n < 500,000,000, +# otherwise ("time", n) - a UNIX timestamp. +# +# cltv_p2pkh_script(height, pubkey_hash): a P2PKH that can't be spent before +# a block height: +# OP_CHECKLOCKTIMEVERIFY OP_DROP +# OP_DUP OP_HASH160 <20-byte hash> OP_EQUALVERIFY OP_CHECKSIG +# Opcodes: CLTV 0xb1, DROP 0x75, DUP 0x76, HASH160 0xa9, EQUALVERIFY 0x88, CHECKSIG 0xac. +# Script numbers are minimal little-endian; if the top bit of the last byte is +# set, append 0x00 (it would otherwise mean "negative"). Push = length byte + data. +# --------------------------------------------------------------------------- +def describe_locktime(n: int): + # YOUR CODE HERE + return None + + +def cltv_p2pkh_script(height: int, pubkey_hash: bytes) -> bytes: + # YOUR CODE HERE + return None + + +# --------------------------------------------------------------------------- +# Discussion (bring to the call): +# 1. How does moving signatures out of the txid fix third-party malleability, +# and why did Lightning need that fix? +# 2. The BIP143 sighash commits to the input amount. What attack on hardware +# wallets does that prevent? +# 3. nLockTime vs nSequence (BIP68) vs OP_CHECKLOCKTIMEVERIFY vs +# OP_CHECKSEQUENCEVERIFY: absolute or relative? transaction or script? +# --------------------------------------------------------------------------- diff --git a/bitcoin-dojo/week-06/solutions/.gitkeep b/bitcoin-dojo/week-06/solutions/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/bitcoin-dojo/week-06/solutions/segwit_lab.py b/bitcoin-dojo/week-06/solutions/segwit_lab.py new file mode 100644 index 0000000..ebde48d --- /dev/null +++ b/bitcoin-dojo/week-06/solutions/segwit_lab.py @@ -0,0 +1,86 @@ +""" +Bitcoin Dojo - Week 6 REFERENCE SOLUTION: SegWit & Advanced Transactions +(Programming Bitcoin, Chapters 13, plus BIP141/143) + +Grade it: python3 week-06/check.py --solution +""" + +import importlib.util +import sys +from io import BytesIO +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "lib")) +from dojo import hash256 # noqa: E402 + +# PROVIDED: the Week 4 reference parser pieces. +_spec = importlib.util.spec_from_file_location("week4", ROOT / "week-04" / "solutions" / "transactions.py") +_w4 = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_w4) +Tx, TxIn, TxOut, read_varint, encode_varint = _w4.Tx, _w4.TxIn, _w4.TxOut, _w4.read_varint, _w4.encode_varint + + +def parse_segwit(raw: bytes): + s = BytesIO(raw) + version = int.from_bytes(s.read(4), "little") + marker, flag = s.read(2) + if (marker, flag) != (0, 1): + raise ValueError("not a segwit transaction") + tx_ins = [TxIn.parse(s) for _ in range(read_varint(s))] + tx_outs = [TxOut.parse(s) for _ in range(read_varint(s))] + witnesses = [[s.read(read_varint(s)) for _ in range(read_varint(s))] for _ in tx_ins] + locktime = int.from_bytes(s.read(4), "little") + return Tx(version, tx_ins, tx_outs, locktime), witnesses + + +def txid(tx) -> str: + return hash256(tx.serialize())[::-1].hex() + + +def wtxid(raw: bytes) -> str: + return hash256(raw)[::-1].hex() + + +def weight(tx, raw: bytes) -> int: + base = len(tx.serialize()) + return base * 3 + len(raw) + + +def vsize(w: int) -> int: + return (w + 3) // 4 + + +def bip143_sighash_p2wpkh(tx, input_index: int, pubkey_hash: bytes, amount: int) -> int: + hash_prevouts = hash256(b"".join(i.prev_tx[::-1] + i.prev_index.to_bytes(4, "little") for i in tx.tx_ins)) + hash_sequence = hash256(b"".join(i.sequence.to_bytes(4, "little") for i in tx.tx_ins)) + hash_outputs = hash256(b"".join(o.serialize() for o in tx.tx_outs)) + txin = tx.tx_ins[input_index] + script_code = b"\x19\x76\xa9\x14" + pubkey_hash + b"\x88\xac" + preimage = (tx.version.to_bytes(4, "little") + hash_prevouts + hash_sequence + + txin.prev_tx[::-1] + txin.prev_index.to_bytes(4, "little") + + script_code + amount.to_bytes(8, "little") + txin.sequence.to_bytes(4, "little") + + hash_outputs + tx.locktime.to_bytes(4, "little") + (1).to_bytes(4, "little")) + return int.from_bytes(hash256(preimage), "big") + + +def describe_locktime(n: int): + if n == 0: + return ("none", 0) + return ("height", n) if n < 500_000_000 else ("time", n) + + +def script_num(n: int) -> bytes: + out = b"" + while n: + out += bytes([n & 0xFF]) + n >>= 8 + if out and out[-1] & 0x80: + out += b"\x00" + return out + + +def cltv_p2pkh_script(height: int, pubkey_hash: bytes) -> bytes: + h = script_num(height) + return (bytes([len(h)]) + h + b"\xb1\x75" # OP_CHECKLOCKTIMEVERIFY OP_DROP + + b"\x76\xa9\x14" + pubkey_hash + b"\x88\xac") # then a normal P2PKH diff --git a/bitcoin-dojo/week-07/exercises/.gitkeep b/bitcoin-dojo/week-07/exercises/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/bitcoin-dojo/week-07/exercises/capstone.md b/bitcoin-dojo/week-07/exercises/capstone.md new file mode 100644 index 0000000..99e62b7 --- /dev/null +++ b/bitcoin-dojo/week-07/exercises/capstone.md @@ -0,0 +1,34 @@ +# Week 7 Capstone: Verify Your Own Transaction + +Everything you built in six weeks, pointed at a transaction **you** made. + +## 1. Make a transaction + +On **signet**, with any wallet (Sparrow, Bitcoin Core, BlueWallet...): receive some coins from a signet faucet, then send some to a second address you control. Use a native segwit (P2WPKH, `tb1q...`) wallet, since your Week 6 code handles that. + +Get the raw hex, the txid, and each input's previous output (script and amount). For example, from `https://mempool.space/signet/api/tx//hex` and `https://mempool.space/signet/api/tx/`. + +## 2. Verify it with your own code + +Write `verify_my_tx.py` that, using only your Week 3-6 code: + +1. parses the transaction (Week 6 `parse_segwit`) +2. checks your computed txid matches the explorer's +3. computes the weight and vsize, and the fee rate in sat/vB +4. for **every input**, computes the BIP143 sighash and verifies the signature in its witness (Week 5 `verify`) +5. prints the P2WPKH address of each input's public key (Week 3) and checks it matches the explorer + +If every signature verifies, you have independently validated a real Bitcoin transaction with code you wrote. That's what a full node does, billions of times over. + +## 3. Present it (graduation call, 5 minutes) + +- Your transaction, and what each part of the hex means +- One concept from the course that changed how you understand Bitcoin +- Your vibe-coded explainer website +- Your next step (below) + +## 4. Next step: pick one + +- **Keep building on these exact skills:** the [Privacy Track](../../../privacy-track/) Silent Payments lab uses the same curve math, tagged hashes and taproot keys. It's graded against the official BIP352 test vectors, and it's where many Code Orange contributors started +- **Go deeper on transactions:** [rawBit](../../../rawbit/) or [Decoding Bitcoin](../../../decoding-bitcoin/) +- **Contribute:** pick an issue from the Privacy Track's [curated issue pool](../../../privacy-track/ISSUE_POOL.md) and run the [PR checklist](../../../privacy-track/PR_CHECKLIST.md) before submitting diff --git a/bitcoin-dojo/week-07/solutions/.gitkeep b/bitcoin-dojo/week-07/solutions/.gitkeep deleted file mode 100644 index e69de29..0000000