Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/dojo.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
20 changes: 20 additions & 0 deletions bitcoin-dojo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions bitcoin-dojo/data/transactions.json
Original file line number Diff line number Diff line change
@@ -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/<txid>",
"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
}
}
227 changes: 227 additions & 0 deletions bitcoin-dojo/lib/dojo.py
Original file line number Diff line number Diff line change
@@ -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("<Q", 8 * len(msg))
for i in range(0, len(msg), 64):
X = s.unpack("<16I", msg[i:i + 64])
a1, b1, c1, d1, e1 = h
a2, b2, c2, d2, e2 = h
for j in range(80):
t = (rol((a1 + f(j, b1, c1, d1) + X[R1[j]] + K1[j // 16]) & 0xFFFFFFFF, S1[j]) + e1) & 0xFFFFFFFF
a1, e1, d1, c1, b1 = e1, d1, rol(c1, 10), b1, t
t = (rol((a2 + f(79 - j, b2, c2, d2) + X[R2[j]] + K2[j // 16]) & 0xFFFFFFFF, S2[j]) + e2) & 0xFFFFFFFF
a2, e2, d2, c2, b2 = e2, d2, rol(c2, 10), b2, t
t = (h[1] + c1 + d2) & 0xFFFFFFFF
h[1] = (h[2] + d1 + e2) & 0xFFFFFFFF
h[2] = (h[3] + e1 + a2) & 0xFFFFFFFF
h[3] = (h[4] + a1 + b2) & 0xFFFFFFFF
h[4] = (h[0] + b1 + c2) & 0xFFFFFFFF
h[0] = t
return s.pack("<5I", *h)
Loading
Loading