Building Proof-of-Work Solvers
The Server Does Not Trust Your Answer, It Recomputes It
A proof-of-work challenge hands you a link and asks for a nonce that makes the hash of the pair start with enough zero bits. You send the nonce back and the edge recomputes the digest itself, byte for byte, before it believes a word of what you claimed. That is the whole design: the server never has to trust your arithmetic, only your bytes, and it only spends a single hash to check them.
What makes the control interesting to build against is that the cost sits on the visitor's CPU, not the server's. The edge hands out the same work to every browser that loads the page, so the price of admission is paid in milliseconds of your own machine. This is a close relative of the clearance-cookie flow covered in Proof-of-Work and Clearance Cookies, and it is the piece a managed challenge replaces when the vendor would rather not ship a widget at all. This lesson is about the shape of that work: how the chain is seeded, what difficulty means in bits, why the scan loop is shardable, and where the interesting engineering is once you go past a single SHA-256 round.
The Shape of a Hash-Chain Challenge
The unit of work is a link: a seed string that already incorporates everything that came before it. The seed in the snippet below is a host, a path prefix and a session-scoped identifier, so two sessions on the same edge get different chains and a chain cannot be lifted from one capture into another.
From that link you scan nonces. For each candidate you build a message, hash it, and test whether the digest's leading bits are zero. The first nonce that passes becomes part of the next link, which is why the acceptance column of the trace is mostly zeros: the last link of a chain is itself a valid solution for the chain's root.
| Element | Where it comes from | What the client may change |
|---|---|---|
| Challenge id | the challenge document, server issued | nothing |
| Seed / link | server issued, or the previous link's digest | nothing |
| Nonce | client chosen, scanned in order | everything |
| Algorithm | named by the challenge | nothing |
| Difficulty | server issued, in bits | nothing |
Three of the five inputs are fixed by the server. The nonce is the only degree of freedom, which is exactly why the control is fair: it measures willingness to spend CPU, not knowledge of a secret. It also explains why the challenge survives a replay of the answer: the link carries the session, so a nonce that was accepted for one session is worthless for the next one, and an operator cannot cache a solution and rent it out.
import hashlib
# The challenge as the edge publishes it: a seed string, an algorithm name, a
# difficulty in leading zero bits, and a link count. Nothing else.
SEED = "shop.example|/akam/2c1f/pow/8f21"
LINKS = 3
HASHES = {
"sha256": hashlib.sha256,
"sha3_256": hashlib.sha3_256,
"blake2s": hashlib.blake2s,
}
# A fixed planning constant, not a live measurement: hashes per second that one
# core sustains on this workload. Production reads this from its own fleet.
CORE_RATE = 250_000
def zero_bits(digest):
'''Number of leading zero bits in a digest, counted over the whole thing.'''
n = 0
for byte in digest:
if byte == 0:
n += 8
continue
n += 8 - byte.bit_length()
break
return n
class Solver:
'''A stateless nonce scan.
Any worker can run any contiguous slice of the nonce space, because the
verifier is a pure function of (link, nonce). That is what makes the work
shardable across a fleet and what lets one challenge be amortised.
'''
def __init__(self, algorithm, bits):
if algorithm not in HASHES:
raise KeyError(algorithm)
self.algorithm = algorithm
self.bits = bits
self.hash = HASHES[algorithm]
def digest(self, link, nonce):
return self.hash(link + nonce.to_bytes(4, "big")).digest()
def scan(self, link, start, budget):
'''Scan [start, start+budget). Return (nonce, attempts) or (None, attempts).
The budget counts hashes, not seconds, so the abort decision is a pure
function of the inputs and the table below is reproducible.
'''
for offset in range(budget):
nonce = start + offset
if zero_bits(self.digest(link, nonce)) >= self.bits:
return nonce, offset + 1
return None, budget
def verify(self, link, nonce):
'''What the edge does with the answer: recompute, compare, accept.'''
return zero_bits(self.digest(link, nonce)) >= self.bits
def solve(algorithm, bits, links, budget_each=4_000_000):
'''Walk the chain link by link; each accepted link seeds the next search.'''
solver = Solver(algorithm, bits)
link = hashlib.sha256(SEED.encode()).digest()
trace = []
for index in range(links):
nonce, attempts = solver.scan(link, 0, budget_each)
if nonce is None:
return solver, trace, None, attempts
link = solver.digest(link, nonce)
trace.append((index, nonce, attempts, link))
return solver, trace, link, sum(t[2] for t in trace)
print("hash-chain proof of work: attempt counts are exact, no clock, no network")
print("seed : {} (sha256 root link, {} links per solution)".format(SEED, LINKS))
print()
print("every accepted digest starts with the difficulty's zero bits, which is why")
print("the head column below is mostly zeros: the last link is itself a solution.")
print()
print("{:<5} {:<10} {:>5} {:>10} {:>10} {:>6} {:>9} {}".format(
"bits", "algorithm", "lnk", "attempts", "expected", "ratio", "nonce", "accepted digest[:16]"))
print("-" * 100)
for algorithm in ("sha256", "sha3_256", "blake2s"):
for bits in (12, 16, 18):
links = LINKS if bits == 16 else 1
_solver, trace, head, total = solve(algorithm, bits, links)
expected = links * (1 << bits)
print("{:<5} {:<10} {:>5} {:>10,} {:>10,} {:>6.2f} {:>9,} {}".format(
bits, algorithm, links, total, expected, total / expected,
trace[-1][1], head[:8].hex()))
print()
print("per-link trace at 16 bits over 3 links, sha256; each link seeds the next")
print("{:<5} {:>10} {:>10} {:>10} {}".format("link", "nonce", "attempts", "running", "digest[:24]"))
print("-" * 70)
_solver, trace, _head, _total = solve("sha256", 16, LINKS)
running = 0
for index, nonce, attempts, link in trace:
running += attempts
print("{:<5} {:>10,} {:>10,} {:>10,} {}".format(index, nonce, attempts, running, link[:12].hex()))
print()
print("abort and timeout handling: a link that exhausts its budget is a failure")
print("{:<6} {:>10} {:>10} {:<8} {:>8} {}".format(
"bits", "budget", "expected", "outcome", "spent", "what the caller must do"))
print("-" * 92)
for bits, budget in ((12, 500), (12, 8_000), (18, 500), (18, 600_000)):
solver, trace, head, spent = solve("sha256", bits, 1, budget_each=budget)
done = head is not None
print("{:<6} {:>10,} {:>10,} {:<8} {:>8,} {}".format(
bits, budget, 1 << bits, "solved" if done else "ABORT", spent,
"post the chain head with the retry token" if done
else "abandon the page, re-fetch, log a failed challenge"))
print()
print("amortising one challenge across a fleet: expected work divided by cores")
print("difficulty 22 bits is {:,} expected hashes at {:,} H/s per core".format(
1 << 22, CORE_RATE))
print("{:<8} {:>12} {:>12} {:>12} {}".format("workers", "total work", "per worker", "wall secs", "nonce range each worker owns"))
print("-" * 100)
for workers in (1, 8, 64, 256):
work = 1 << 22
print("{:<8} {:>12,} {:>12,} {:>12.2f} {}".format(
workers, work, work // workers, work / float(CORE_RATE * workers),
"0..{}".format(work // workers - 1)))
print()
print("an Argon2id-shaped challenge, modelled as lanes of 1 KiB compression blocks")
print("the permutation below is blake2b, not argon2's; what it reproduces is the")
print("shape of the cost: work is proportional to memory times passes, and the only")
print("parallelism is the lane count, because consecutive blocks depend on each other.")
LANES, BLOCKS, PASSES = 4, 64, 3
BLOCK_BYTES = 1024
matrix = [[bytes(1024) for _ in range(BLOCKS)] for _ in range(LANES)]
accesses = 0
for _pass in range(PASSES):
for lane in range(LANES):
for column in range(BLOCKS):
previous = matrix[lane][column - 1]
# A 2-input compression with a chained carry, which is what makes
# the next block's input depend on the current block's output.
carry = hashlib.blake2b(matrix[lane][column] + previous, digest_size=64).digest()
accesses += 1
matrix[lane][column] = (carry * 16)[:BLOCK_BYTES]
print("{:<6} {:<7} {:>10} {:>12} {:>12} {}".format(
"lanes", "passes", "blocks", "accesses", "memory", "parallelism"))
print("-" * 92)
for lanes in (1, 4, 16):
blocks = LANES * BLOCKS
print("{:<6} {:<7} {:>10,} {:>12,} {:>10,} KiB {:,} lanes, no more".format(
lanes, PASSES, blocks, lanes * BLOCKS * PASSES,
lanes * BLOCKS * BLOCK_BYTES // 1024, lanes))
print("modelled above: {} lanes x {} blocks x {} passes = {:,} block compressions, "
"{} KiB resident".format(LANES, BLOCKS, PASSES, accesses,
LANES * BLOCKS * BLOCK_BYTES // 1024))
print()
print("algorithm rotation: a solver is built against the name in the challenge")
print("{:<12} {:>7} {:<10} {:>11} {}".format(
"algorithm", "digest", "registry", "work growth", "behaviour of a solver not registered for it"))
print("-" * 104)
for name, size in (("sha256", 32), ("sha3_256", 32), ("blake2s", 32),
("argon2id", 32), ("md5", 16)):
known = name in HASHES
print("{:<12} {:>7} {:<10} {:>11} {}".format(
name, size, "yes" if known else "no", "2 to the n",
"verifies the chain head" if known else "KeyError, no answer posted, challenge re-read"))
print()
print("rotation drill: the site moves the challenge from sha256 to blake2s mid-run")
for name in ("sha256", "blake2s", "argon2id"):
try:
Solver(name, 12)
print(" {:<10} registered, solve as normal".format(name))
except KeyError as exc:
print(" {:<10} NOT registered ({}) - re-read the challenge and register it".format(
name, exc))
print()
print("verifier: the edge recomputes, so a wrong algorithm cannot be faked")
stale, trace, head, spent = solve("sha256", 12, 1)
wrong = Solver("blake2s", 12)
print(" chain head solved with sha256, announced as blake2s: verify={}".format(
wrong.verify(hashlib.sha256(SEED.encode()).digest(), trace[-1][1])))
print(" chain head solved with sha256, announced correctly: verify={}".format(
stale.verify(hashlib.sha256(SEED.encode()).digest(), trace[-1][1])))
print(" the head is {} bits of zeros wide, so the answer carries its own proof".format(
zero_bits(head)))
hash-chain proof of work: attempt counts are exact, no clock, no network
seed : shop.example|/akam/2c1f/pow/8f21 (sha256 root link, 3 links per solution)
every accepted digest starts with the difficulty's zero bits, which is why
the head column below is mostly zeros: the last link is itself a solution.
bits algorithm lnk attempts expected ratio nonce accepted digest[:16]
----------------------------------------------------------------------------------------------------
12 sha256 1 1,367 4,096 0.33 1,366 000638b67096fe5c
16 sha256 3 50,554 196,608 0.26 1,527 0000ef9dc8cddbc1
18 sha256 1 428,536 262,144 1.63 428,535 000011d9744c5419
12 sha3_256 1 556 4,096 0.14 555 0009b38dda64670e
16 sha3_256 3 211,270 196,608 1.07 8,841 00003a98ca33d718
18 sha3_256 1 349,671 262,144 1.33 349,670 00000c26baeefee6
12 blake2s 1 5,848 4,096 1.43 5,847 000d048caad394f5
16 blake2s 3 133,231 196,608 0.68 15,783 0000d9a4e042534c
18 blake2s 1 21,201 262,144 0.08 21,200 0000142da05d9e36
per-link trace at 16 bits over 3 links, sha256; each link seeds the next
link nonce attempts running digest[:24]
----------------------------------------------------------------------
0 22,363 22,364 22,364 0000681e05f30b894e25842d
1 26,661 26,662 49,026 00005d098db5e2305e7f0197
2 1,527 1,528 50,554 0000ef9dc8cddbc175538a77
abort and timeout handling: a link that exhausts its budget is a failure
bits budget expected outcome spent what the caller must do
--------------------------------------------------------------------------------------------
12 500 4,096 ABORT 500 abandon the page, re-fetch, log a failed challenge
12 8,000 4,096 solved 1,367 post the chain head with the retry token
18 500 262,144 ABORT 500 abandon the page, re-fetch, log a failed challenge
18 600,000 262,144 solved 428,536 post the chain head with the retry token
amortising one challenge across a fleet: expected work divided by cores
difficulty 22 bits is 4,194,304 expected hashes at 250,000 H/s per core
workers total work per worker wall secs nonce range each worker owns
----------------------------------------------------------------------------------------------------
1 4,194,304 4,194,304 16.78 0..4194303
8 4,194,304 524,288 2.10 0..524287
64 4,194,304 65,536 0.26 0..65535
256 4,194,304 16,384 0.07 0..16383
an Argon2id-shaped challenge, modelled as lanes of 1 KiB compression blocks
the permutation below is blake2b, not argon2's; what it reproduces is the
shape of the cost: work is proportional to memory times passes, and the only
parallelism is the lane count, because consecutive blocks depend on each other.
lanes passes blocks accesses memory parallelism
--------------------------------------------------------------------------------------------
1 3 256 192 64 KiB 1 lanes, no more
4 3 256 768 256 KiB 4 lanes, no more
16 3 256 3,072 1,024 KiB 16 lanes, no more
modelled above: 4 lanes x 64 blocks x 3 passes = 768 block compressions, 256 KiB resident
algorithm rotation: a solver is built against the name in the challenge
algorithm digest registry work growth behaviour of a solver not registered for it
--------------------------------------------------------------------------------------------------------
sha256 32 yes 2 to the n verifies the chain head
sha3_256 32 yes 2 to the n verifies the chain head
blake2s 32 yes 2 to the n verifies the chain head
argon2id 32 no 2 to the n KeyError, no answer posted, challenge re-read
md5 16 no 2 to the n KeyError, no answer posted, challenge re-read
rotation drill: the site moves the challenge from sha256 to blake2s mid-run
sha256 registered, solve as normal
blake2s registered, solve as normal
argon2id NOT registered ('argon2id') - re-read the challenge and register it
verifier: the edge recomputes, so a wrong algorithm cannot be faked
chain head solved with sha256, announced as blake2s: verify=False
chain head solved with sha256, announced correctly: verify=True
the head is 13 bits of zeros wide, so the answer carries its own proof
Difficulty Is Measured in Bits, Not Iterations
Older interfaces stated a target number of hash rounds. Modern ones state a bit count, and the difference matters for your cost model, because a bit count compounds. Going from 16 to 18 bits doubles the expected work; going from 16 to 20 quadruples it. There is no per-iteration overhead to amortise, because the expected number of attempts is exactly 2 to the power of the difficulty, with no constant factor to tune.
The ratio column in the run below is attempts divided by expectation, and it is
worth staring at. It wanders between 0.08 and 1.63 across nine independent
samples. A single solve is a coin-flip-heavy process; only the expectation is
stable. Any code that budgets wall-clock time from one observed solve will
miscalculate, because the sample variance at 18 bits is enormous in absolute
terms even though the distribution is perfectly tight in relative terms. Plan
from 2 to the power of the bits, never from the last run's stopwatch.
The Scan Loop Is a Pure Function of Its Inputs
The scan takes a link, a starting nonce and a budget measured in hashes, and returns either a nonce or nothing. There is no shared state, no lock, no ordering requirement between workers. That is not an implementation detail, it is the property that makes the control tractable: any worker can own any contiguous slice of the nonce space, and the union of the slices is the whole space.
The trace shows the three links of one 16-bit chain, each seeding the next, with the running total after every link. Fifty-four thousand hashes for sixteen bits over three links is unremarkable; twenty-two bits over three links is four million and takes a different class of machine budget. Decide which of those you are actually facing before you write any optimisation, because the two situations call for completely different code: one fits in a request handler, the other is a job scheduler.
Memory-Hard Variants Change the Bottleneck
Once a challenge is memory-hard the arithmetic stops being the interesting part. Argon2id's cost is memory multiplied by passes, and its parallelism is exactly the lane count, because block n depends on block n-1 within a lane. The snippet models this with blake2b, because blake2b is in the standard library and the point is the shape of the cost rather than byte-exact compatibility.
The table is the useful part. Doubling the lanes doubles resident memory and doubles the parallel speedup, and it does not change the total block count. What it changes is the failure mode: a container with a 256 MiB limit will let a 4-lane, 64-block, 3-pass job run and will kill a 16-lane one, so the parameters you copy out of a challenge document are not the parameters you can always afford. Read the parameters, then read your own memory limit, and decide which one you are going to change.
Algorithm Rotation Is a Compatibility Problem
Challengers rotate digests. The snippet registers three algorithms and then drills what happens when the site switches mid-run to one you have never seen: the lookup raises, no answer is posted, and the challenge has to be re-read.
The failure is loud, which is the right property, but only if you handle it as a
rotation signal rather than as a crash. Treat an unregistered algorithm name as
data, not as an exception, and re-read the challenge. A solver that hard-codes
hashlib.sha256 is correct for one week and then fails silently on the subset of
sessions that happen to be served a rotated challenge, which is the worst kind of
intermittent failure to debug. Keep a registry keyed by the name the challenge
actually printed, and let an unknown key mean "ask again" rather than "give up".
What the Verifier Actually Checks
The last section is the one that keeps the design honest. Solve a chain head with SHA-256 and announce it as BLAKE2s and verification fails, because the edge recomputed with the announced algorithm and got a different digest. Solve it and announce it correctly and it passes.
Notice what the verifier does not need: your process, your code, your machine, any trust in you at all. It needs the link, the nonce and the algorithm name, and it needs one hash. This is the property that separates a proof-of-work control from a CAPTCHA, and it is why a proof-of-work control stays cheap for the defender and expensive only for someone trying to answer an unbounded number of them. It is also why there is no solver artefact to steal: nothing you produce is reusable against a different session.
Amortising One Challenge Across a Fleet
Total expected work does not change when you add workers; what changes is wall clock time and the size of each worker's slice. At twenty-two bits, four million expected hashes against a rate of a quarter-million hashes per second per core is under seventeen seconds on one core and a fraction of a second across a hundred and sixty-four.
Two practical consequences. First, the fan-out is nearly free in wall-clock terms and expensive in coordination, so beyond roughly the point where each worker owns less than a few thousand hashes you are paying more in task dispatch than in hashing. Second, the nonce range in the table is the natural partition key, so workers never need to talk to each other; the only shared artefact is the winning nonce at the end. That is the whole reason a proof-of-work challenge is a poor bot control against anyone with a fleet, and the reason distributed fleets treat it as a scheduling exercise rather than a cryptographic one.
Measuring a Solver Instead of Guessing at One
The metrics worth logging per solve are hashes per second per worker, attempts consumed against expectation, aborts, and the time between challenge fetch and answer post. The abort table is the part that shows up in production: a budget of 500 hashes against a twelve-bit difficulty has a roughly 88 percent chance of aborting, because the expectation is 4,096. Budgets should be set several times the expectation, and the expectation should be recomputed whenever the difficulty moves.
If you cannot log attempts against expectation, you cannot tell a slow machine from a rotated challenge, and those two failures have opposite responses: one wants more cores, the other wants a new algorithm registration. When neither number explains the symptoms, the next place to look is the capture, and challenge debugging with recorded traffic is where a solver's belief and the wire finally line up.
Checklist
- Read the difficulty as a bit count, and compute expected work as 2 to the power of that count.
- Seed the scan from the server's link, and never from a value you cached.
- Budget in hashes, not seconds, so the abort decision is reproducible.
- Partition the nonce space by contiguous ranges; workers should not coordinate.
- Expect a wide attempt-to-expectation ratio and never plan from a single solve.
- Register every algorithm the challenge names, and treat an unknown name as a re-read trigger rather than as a fatal error.
- Check memory-hard parameters against your container's actual memory limit, not against the defaults in the vendor's example code.
- Log attempts, expectation, aborts and hashes per second for every solve, and alert when attempts sit below half of expectation for a sustained period.
The Legitimate Route
If you own the site, this work is yours to tune: keep the difficulty low enough that a mid-range phone clears it in a frame budget, and spend the budget on the edge instead. If you are collecting data you have a right to, an official API, a partner feed, or a written agreement with the site usually costs less than the maintenance on a solver, and the operator will usually hand you a key for less work than you are about to write. Defeating a paywall or re-adding traffic after a block is a different act from paying a CPU toll, and no amount of engineering makes those two the same thing. The practical test is the one an operator will already apply: if the challenge is there to ration a resource the owner sells, the solver is not a compatibility layer, and the correct next step is to ask for a commercial arrangement rather than to make the toll cheaper.