x402 work scheme: paying with proof-of-work, and who vouches for the job
An open x402 pull request adds a payment scheme with no token in it. The client pays by returning one proof-of-work solution to a mining job the server put in the 402. We rebuilt the first profile from the spec, ran it through the PR's own server and facilitator code, and checked who vouches that the job is real.
The proposal is PR #4057 on x402-foundation/x402, "feat(work): add proof-of-work payment scheme". It was opened on 2026-10-29 by 0xalank. The head commit when we read it was 189d3bf, it had two commits and no reviews, and GitHub reported its merge state as blocked. It adds two spec files (206 and 216 lines), a new @x402/work package with 17 unit tests, and changes in @x402/core and @x402/mcp.
We have looked at proof-of-work in x402 once before, in the Nano audit, where it is only an anti-spam stamp on a ledger block. Here it is the payment itself. That changes which questions matter.
How the scheme works
The 402 carries a requirement with scheme: "work", asset: "hash" and an amount that is a number of expected trials. In extra it carries a profile name such as quai/sha256d-v1 and a job: an ID, an expiresAt, an optional earlier refreshAfter, and a profile-defined mining template in job.work.
The price becomes a target, derived and never sent:
target = floor(2^256 / amount) - 1
valid = score <= target
The client mines until a candidate scores at or below the target, then returns { "solution": {...} } with the requirement copied into accepted. The facilitator checks the envelope, expiry, a fingerprint of the candidate and the replay store, then runs the profile's proof function. Settlement atomically consumes the fingerprint, and the fingerprint becomes the settlement transaction.
Two design choices stand out. Payment never depends on the native chain: if the same score also meets the native workshare target, the provider may submit it for credit, but "native acceptance never decides payment success". And there is no payer. A successful SettleResponse must omit payer, because a solution is not signed by anyone.
The first profiles are three Quai profiles: quai/sha256d-v1, quai/scrypt-v1 and quai/kawpow-v1, on quai:9 and quai:15000. The spec allows other chains later and names Bitcoin, Monero and Zcash profile IDs as examples only. We did not run a Quai node and make no claim about the Quai network itself. Everything below is about the x402 side.
What we ran
We checked out the PR head, built @x402/core and wrote a harness inside the work package. It implements the quai/sha256d-v1 candidate construction exactly as the spec text describes it: coinbase from coinbase1 || extraNonce1 || extraNonce2 || coinbase2, SHA-256d for the Merkle root, an 80-byte header with word-swapped previous hash, and the score as the reversed digest read as a big-endian integer. The fingerprint is the SHA-256 of profile, network and candidate, as in the spec.
Around that profile we used the PR's real WorkServerScheme and WorkFacilitatorScheme, the real x402ResourceServer, x402HTTPResourceServer and x402Facilitator, and the real header encoders. One simplification: the spec says the candidate must commit to the reward address through the Quai seal hash. Our profile checks that the coinbase bytes contain the 20-byte payTo. That stands in for the commitment and nothing more.
Finding 1: the job comes back from the client
On the paid retry the server has to know which job it issued. The PR's server scheme does this by resuming the job the client sent. In enrichPaymentRequiredResponse, if the client's accepted has the same network, asset, amount, payTo, timeout and profile as an offer, and its job has not expired, the server copies the client's version into the requirement. The facilitator then compares accepted to that requirement and finds them equal, because the server just made them equal.
The PR knows this. The doc comment on WorkFacilitatorProfile says the job "comes from the client's accepted requirement" and that verify "MUST authenticate it as an issued job". The scheme itself checks only the envelope, target, freshness and replay. So the whole question of whether this job was ever issued sits in profile code that the package does not ship.
We tested both sides through the real HTTP server. Profile A checks only the reward binding. Profile B also compares the full job to a map of jobs the issuer produced. In each run an honest client first asked for a 402 and solved the issued job. Then a second client, which never requested a job, built its own: ID forged-1, an arbitrary previous block hash, a coinbase containing the payTo, an expiresAt 30 seconds out. It solved that job at the same amount and sent it.
| Profile | Honest job | Forged job | Issuer calls |
|---|---|---|---|
| A: reward binding only | payment-verified | payment-verified, settle success | 1 |
| B: retained job compared | payment-verified | payment-error | 1 |
Under profile A the forged settlement returned success: true with a fingerprint as the transaction, and the issuer had been called once, for the honest client. The forger paid exactly what an honest client pays, 65,536 expected trials, but the work was against a template the provider never issued. For a real Quai profile, that work would never be eligible for native credit. The provider would have served the request and received nothing of value.
Two details make this sharper. The route's maxTimeoutSeconds defaulted to 300 in our harness, and the only lifetime check is that expiresAt is in the future and no more than maxTimeoutSeconds away. A forger therefore picks its own 300-second window. And under profile B the rejection came back as invalid_work_payload. The facilitator wraps every exception from profile.verify in that reason, so "job not issued", "stale native job" and "malformed template" look the same to the server and the logs.
This is not a bug in the sense of code that contradicts its spec. The spec says a third-party facilitator "MUST NOT advertise a profile unless it can authenticate the issued job". The gap is that nothing enforces it, and the one profile the tests use is a stub. The spec's three ways to authenticate are retained state, an issuer attestation, or an authenticated job-verification service, plus "public job data". That last one is the hard case. A facilitator that knows only the public job can confirm the coinbase commits to payTo, which is what profile A does. To know the template is current it also has to compare the previous block hash and bits against a live node. Without that, profile A is what you get.
Finding 2: price is the mean, delay is the tail
The amount is "expected trials". Real solves are geometric: each trial succeeds with probability near 1/amount, so time to solve is close to exponential. We measured it. 300 solves at amount 262,144, each against a fresh random job, on one JavaScript thread:
rate 137,436 hashes/s (solver, allocation-heavy)
mean trials 269,151 (nominal 262,144)
mean time 1.96 s
p50 / p90 1.33 s / 4.26 s
p99 / max 10.06 s / 12.84 s
theory p50 1.32 s, p99 8.78 s
The mean is 2.7 percent above nominal, inside the sampling error for 300 draws (about 5.8 percent). The sample p99 is the third-largest of 300 values, so it is noisy against the theoretical 8.78 s. The shape is what matters: the median is 0.69 of the mean and the 99th percentile is 4.6 times the mean.
One property is kind to the buyer. Because the process is memoryless, abandoning a job atrefreshAfter and starting on a new one costs nothing in expectation. Total expected work stays at the nominal amount however often the client restarts. What a short window changes is the chance of missing it. With P(miss) = exp(-T * rate / amount) and our measured rate:
| Amount (trials) | Mean solve | Miss, 10 s window | Miss, 30 s window |
|---|---|---|---|
| 65,536 (2^16) | 0.48 s | under 0.01 percent | 0 |
| 262,144 (2^18) | 1.9 s | 0.5 percent | under 0.01 percent |
| 1,048,576 (2^20) | 7.6 s | 27 percent | 2.0 percent |
| 4,194,304 (2^22) | 30.5 s | 72 percent | 37 percent |
| 16,777,216 (2^24) | 122 s | 92 percent | 78 percent |
This is arithmetic from one measured rate, not a measurement of each cell. To keep the miss rate under 1 percent in a 10-second window, this client needs an amount of about 298,000 trials or less. A GPU or ASIC client moves every row, but the shape stays. The scheme also puts a hard ceiling on price: the job must live no longer than maxTimeoutSeconds, so a high price needs hardware, not patience.
When the window is missed, the package's client scheme throws work job refresh required. The caller must request a fresh 402 and start again. That is correct, and it means a latency-sensitive agent should treat a miss as a normal event, not an error.
The unit also has no dollar value. A trial costs whatever the client's hardware and power make it cost. That is why the PR's own notes say hash is not a default asset, clients must allow it in spendControls.allowedAssets, and the cap is in trials. The default-cap behavior is covered in the spend-controls audit.
Finding 3: the job rides in both headers
The client must copy the whole requirement into accepted, and the job is inside the requirement. So the job appears in PAYMENT-REQUIRED on the way down and again in PAYMENT-SIGNATURE on the way up. For the spec's example sha256d job, we measured 956 bytes for the first and 952 bytes for the second. A KAWPOW-shaped job (header hash, seed hash, height, bits) was 860 bytes.
The sha256d and scrypt profiles can be much bigger. The spec allows coinbase1 and coinbase2 "at most 512 KiB combined" and up to 64 Merkle branches. We padded the coinbase fields and encoded with the real encodePaymentSignatureHeader:
| Coinbase, combined bytes | PAYMENT-SIGNATURE size |
|---|---|
| 1 KiB | 3,608 B |
| 4 KiB | 11,800 B |
| 6 KiB | 17,260 B |
| 8 KiB | 22,720 B |
| 64 KiB | 175,640 B |
| 512 KiB | 1,398,976 B |
Each KiB of coinbase adds about 2.7 KB to the header: hex doubles it, then JSON and base64 add a third. We read "512 KiB" as bytes after hex decoding. If it means hex characters, halve the last rows. Against the limits we measured in the browser and header-size audit, an 8,190-byte client limit is crossed at about 2.65 KiB of coinbase and Node's 16 KiB default at about 5.65 KiB. We did not measure a real Quai coinbase, so we cannot say where actual jobs land. The point is that the spec's own bound is two to three orders of magnitude above the limits real clients enforce, and the profile, not the scheme, owns the answer.
Finding 4: an invalid candidate costs the verifier, not the sender
The facilitator runs checks in cost order: envelope, expiry, fingerprint, replay store, then the proof. That is the right order. But a random candidate passes every cheap check, because its fingerprint is new. Producing one takes no work, while checking it takes one proof evaluation. verify and settle each run that evaluation.
We timed the two hash functions the Quai profiles name, on one core:
sha256d 174,429 /s 5.7 us per evaluation
scrypt 2,360 /s 424 us per evaluation (N=1024, r=1, p=1)
Scrypt costs 74 times more than SHA-256d to verify. For a valid payment the economics favor the defender, since the client spent about amount evaluations to produce one. For an invalid one the sender spends nothing. We did not measure KAWPOW, which needs the epoch cache. A profile that checks the job against retained state before the proof, as profile B does, also drops unissued-job spam for free.
What it means for LLM4Agents
Our gateway bills in USD stablecoins, and our upstream providers are paid in dollars. A work payment gives the provider a lottery ticket: it is worth something only when the score also meets the native target, and the spec puts the chance that a payment-valid solution is also eligible at about amount / w, for a native workshare expectation w. We cannot pass hashes upstream, so we are not going to accept this scheme. Nothing in the PR changes how our exact quotes work.
It still matters for three reasons. First, it is the first scheme where the server's "trusted requirement" is partly client-supplied by design. Whatever our gateway quotes must come from our own state and never from the client's accepted, and that property is worth protecting with a test. Second, our agents will meet merchants that offer this scheme next to exact. A client that fails on an unknown scheme, or on a 1 MB header, has a bug that only merchants can trigger. Third, "pay with compute" is a real option for an agent that has a CPU and no wallet. If that demand appears, the lessons above are the spec for doing it safely.
Staying on the frontier
- Do not build a work issuer or verifier now. The PR has no reviews and one profile family that depends on Quai-specific source adapters. Watch for review comments on job authentication and for the first release of
@x402/work. - Add a regression test that the gateway never trusts echoed fields. For
exact, assert that the amount, asset andpayToused in verification come from our own quote, not fromaccepted. The work scheme shows how easily that line moves. - Make our buyer-side client skip unknown schemes and cap header sizes. It should select an offer it can pay, ignore the rest, and refuse a 402 above a set size instead of buffering it. Test it with a 1.4 MB
PAYMENT-REQUIREDand aworkoffer listed first. - Keep unknown assets default-deny in spend controls.
hashmust stay outside any allowlist we ship, The metadata audit covers another case where payment-adjacent fields cannot be trusted. - If compute payments ever become a product, write the requirements first. A retained-job verifier that runs before the proof, per-job-ID rate limits, a distinct error reason for unissued jobs, an amount chosen so that
rate * window / amountis at least 4.6 for a 1 percent miss, and a header budget the profile must declare. - Re-read the PR after the first review round. The two open questions are whether the package ships a reference job authenticator, and whether the spec bounds the job size. Either answer changes this post.
Pay per call, in stablecoins or not at all
LLM4Agents gives autonomous agents an OpenAI-compatible gateway that bills per call over x402.
Register your agent