skip to content
Skill Issue Dev | Dax the Dev
search
Part of series: Vanta In Practice

Stratum v1, the from-scratch Python version

Print view

Sections

I wrote about mining VANTA with a Bitaxe BM1368 — the hardware, the watts, the difficulty math, why solo mining a privacy fork actually pays off where solo mining Bitcoin in 2026 doesn’t. This post is the deeper companion: what the Python Stratum server does once you’ve decided to write one from scratch, and why the privacy chain forced a few changes that wouldn’t be required on a vanilla Bitcoin fork.

The whole server is one file: pool/stratum_server.py. No external dependencies — pure stdlib Python. Around 600 lines. Every line earns its place; this isn’t an optimised pool, it’s a correct pool that I can debug from a terminal at 4 AM.

Why Python at all

A reasonable thing to ask: “you’re shipping Rust everywhere else, why is the pool Python?”

Three reasons.

  1. Stratum v1 is a 200-line protocol. It’s JSON over a long-lived TCP connection. socketserver.ThreadingTCPServer is exactly the right shape: one thread per connected miner, blocking I/O, no async machinery to argue about.
  2. The interesting work is talking to vantad over JSON-RPC and to the L2 sidecar over REST. Both are HTTP-shaped. http.client and urllib.request are stdlib. Zero dependency surface.
  3. I can edit the running pool. When you’re debugging a chain at 4 AM and your Bitaxe disconnected, “edit the script and restart” is a faster path than “edit the Rust, recompile, redeploy, kill and restart.” Python wins on the iteration loop.

The full upstream story is that Vanta originally used a public-pool fork (Node.js) and the Python server is the replacement I wrote when the public-pool fork couldn’t handle the privacy-coinbase requirements. That’s the part the rest of this post is about.

Mandatory privacy mining

Vanta v2 chain consensus rejects any non-coinbase transaction that doesn’t satisfy the witness-v2 commitment-binding rules. Coinbase transactions are also required to be witness v2. From the top of the Stratum server:

SHIELDED_PUBKEY = os.environ.get("SHIELDED_PUBKEY", "").strip()
if not SHIELDED_PUBKEY or len(SHIELDED_PUBKEY) != 64:
    print("[FATAL] SHIELDED_PUBKEY env var is required (32-byte hex, 64 chars).", file=sys.stderr)
    print("        Vanta v2 chain has no transparent mining payouts.", file=sys.stderr)
    sys.exit(1)

The pool refuses to start without a shielded pubkey. There is no transparent fallback. A miner who points a Bitaxe at this server is paying out into a shielded note from block one.

The note-construction code is also worth quoting because it pinned down the on-chain format we ended up shipping:

def create_mining_note(value: int, owner_pubkey: bytes) -> tuple:
    """Create a private note for auto-shielded mining reward.
    Returns (commitment_hash, randomness)."""
    randomness = os.urandom(32)
    preimage = (
        struct.pack('<Q', value) +      # 8 bytes LE
        owner_pubkey +                    # 32 bytes
        struct.pack('<I', 0) +            # asset_type = 0 (native VANTA)
        randomness                        # 32 bytes
    )
    commitment = hash_with_domain(b"Vanta/NoteCommitment/v1", preimage)
    return commitment, randomness

This is exactly the commitment scheme vanta-core uses on the Rust side. The pool is hashing in Python because the pool is the thing that knows the value (the block subsidy) at coinbase-construction time. The wallet later trial-decrypts the encrypted-note submission and sees the same commitment land in its inbox.

witness_v2_script builds the scriptPubKey:

def witness_v2_script(commitment_hash: bytes) -> bytes:
    """Build witness v2 scriptPubKey: OP_2 PUSH32 <commitment>."""
    return bytes([0x52, 0x20]) + commitment_hash

OP_2 PUSH32 <commitment> is the witness-v2 anchor format; the C++ consensus code parses this exactly and uses the pushed 32 bytes as the input commitment when a future spend witness comes through. From the chain’s perspective, the coinbase pays into “this commitment” and the value field on the transaction is zero. The pool also adds an OP_RETURN anchor for L2 indexers to find:

def commitment_anchor_script(commitment_hash: bytes) -> bytes:
    """Build OP_RETURN anchor: OP_RETURN PUSH34 bb00 <commitment>."""
    payload = bytes([0xbb, 0x00]) + commitment_hash  # 34 bytes
    return bytes([0x6a]) + encode_varint(len(payload)) + payload

OP_RETURN 0xbb 0x00 <commitment> is the indexer-side anchor; vanta-node’s L1 watcher scans for this byte sequence and feeds matches into the SMT.

Solo-mining accounting vs pool accounting

Public pools track per-miner shares and pay out at end-of-round based on share contributions. The math is non-trivial: PPLNS, FPPS, score-based, etc. A solo pool doesn’t need any of that. Whoever finds the block keeps the whole reward. The Stratum server has miners, but the only thing it does with their share-count is monitoring, not accounting.

This simplification is huge. There’s no payout database, no end-of-round settlement, no fee policy, no withdrawal endpoint. The pool’s only persistent state is:

  1. The pending-L2-submission queue (pending_l2_submissions.json).
  2. Optionally the local note backup (SHIELDED_NOTES_FILE, off by default).

Both are JSON files. The pool can be killed, restarted, even moved between machines, and the only state that matters is the on-chain commitment + the L2 sidecar’s encrypted-note inbox. The pool host isn’t the source of truth for anything user-visible.

This is exactly how a solo-mining server should work. The complexity of a public-pool comes from settling between multiple miners. A solo-pool inherits none of that.

The L2 retry queue

This is the thing that ate a week to get right. The flow:

  1. Bitaxe submits a share that meets block difficulty.
  2. Pool calls submitblock against vantad.
  3. vantad accepts the block.
  4. Pool generates the encrypted note for the miner’s reward.
  5. Pool POSTs the encrypted note to vanta-node’s /submit endpoint.

What if step 5 fails? The L2 sidecar might be restarting, network might be flaky, sidecar might be slow under load. We can’t lose the encrypted note — without it, the miner’s wallet can’t discover the reward.

The first version retried in-process, blocking the share-acceptance loop. Bad idea: a slow L2 stalls the whole pool.

The second version queued the failed submission to a file and a background thread retried every 30 seconds:

def _retry_worker():
    """Background worker — drains the L2 retry queue every 30 seconds."""
    while True:
        try:
            drain_pending_l2_queue()
        except Exception as e:
            print(f"[SHIELD] retry worker error: {e}")
        time.sleep(30)

This is the version that shipped. Failed submissions go to pending_l2_submissions.json, get retried until accepted, get removed from the queue. The pool host can be restarted and the queue persists.

A subtle detail: this is called only on submitblock accept, not on every Stratum job-template push. From the comment in save_shielded_note:

"""Persist mining note and submit encrypted note to L2 for wallet discovery.

Called ONLY after a winning block is accepted by submitblock — never from
the per-share job-template path, otherwise the L2 SMT fills up with phantom
commitments for templates that never won the PoW race.
"""

The first version called this from the job-template path, which is the path that runs every time the pool decides to push fresh work to its connected miners. With a 1-minute block time and longpoll discipline, that’s roughly every 1–2 seconds. So the L2 SMT was getting hundreds of phantom commitments per actual block, all for blocks that never won the PoW race. That bug shipped to a testnet for about 6 hours before I noticed; the cleanup involved replaying the L2 from genesis with the fix applied. Don’t put non-idempotent side effects in your job-template path.

Encrypted-note construction

The encrypt_note_for_recipient function is the bit that lets a miner’s wallet find its reward without the chain leaking what was paid:

def encrypt_note_for_recipient(recipient_pubkey: bytes, value: int, asset_type: int,
                                randomness: bytes, commitment: bytes) -> dict:
    """Encrypt note data so the recipient can discover it via L2 sync.
    Matches vanta-core encrypt.rs exactly: domain-separated SHA256 + XOR stream."""
    ephemeral_secret = os.urandom(32)
    ephemeral_pubkey = hash_with_domain(b"Vanta/Ephemeral/v1", ephemeral_secret)
    shared_secret = hash_with_domain(b"Vanta/SharedSecret/v1", ephemeral_pubkey + recipient_pubkey)
    plaintext = struct.pack('<Q', value) + struct.pack('<I', asset_type) + randomness
    ciphertext = bytearray()
    for block_idx in range((len(plaintext) + 31) // 32):
        stream_input = shared_secret + struct.pack('<I', block_idx)
        keystream = hash_with_domain(b"Vanta/Stream/v1", stream_input)
        chunk = plaintext[block_idx * 32 : (block_idx + 1) * 32]
        for i, b in enumerate(chunk):
            ciphertext.append(b ^ keystream[i])
    return {
        "ephemeral_pubkey": list(ephemeral_pubkey),
        "ciphertext": list(ciphertext),
        "commitment": list(commitment),
    }

This is a hand-rolled XOR-stream cipher with domain-separated SHA-256 as the keystream generator. The comment notes “matches vanta-core encrypt.rs exactly” — the Rust side of the protocol does the same thing, so the wallet trial-decrypts in Rust against the Python-encrypted notes from the pool and they line up.

A note on this scheme that an auditor would (rightly) flag: a hand-rolled XOR stream cipher with SHA-256 as the keystream generator is fine for this use case (small, fixed-length plaintexts, ephemeral keys, no nonce reuse risk because every note has fresh randomness) but it is the kind of cryptographic choice you have to defend in a post-mortem. The papers’ Executive Summary mentions that the encryption layer for general transfers is XChaCha20-Poly1305 — for the coinbase auto-shield the Stratum server uses this lighter scheme because the value, asset_type, and randomness are all already domain-separated by the broader commitment construction. TODO: Dax confirm we want to align the coinbase encryption scheme with the general-transfer XChaCha20 path before mainnet calcification.

Longpoll discipline

The other thing the Stratum server has to get right is fresh work. With 1-minute block times, a miner that’s still hashing against last block’s template is wasting effort. The pool polls vantad for getblocktemplate with a longpollid, blocks until either a new template is available or a timeout fires, then immediately pushes a new mining.notify to every connected miner.

The window from “new block found by someone else” to “all my Bitaxes have new work” is the metric I tune. With local RPC and longpoll, it’s ~50ms. Worth the engineering — every wasted second of stale work is a measurable hashrate drop on the miner side.

What I would do differently

  1. Go async Python. The current threadpool pattern is fine for a handful of Bitaxes. If a public solo-pool ever grew to thousands of miners, threads would be the wrong choice. asyncio + aiohttp is the swap.
  2. Ship a CPU miner alongside. I noted this in the Bitaxe post. 200 lines of Rust. Should have done it day one.
  3. Health endpoint. A /health HTTP route that returns the pool’s view of itself: connected miners, last block found, current difficulty, retry queue depth. Trivial; on the list.
  4. Move the encryption scheme to align with the rest of the chain. As the TODO above.

Further reading

Hire me — book a 30-min call $ book →