Developer Blog · WebAssembly
RandomX in a Browser: Why Every Single Share Was Rejected
The Browser Experiment runs RandomX in a Web Worker
and sends shares to MoneroOcean under your wallet. It works now. For a while it
did something much more interesting: it mined, found shares at exactly the rate the
difficulty predicted, submitted them, and had every single one rejected with
Low difficulty share.
The worst kind of bug is the one that looks like a performance problem. This is that story, because the debugging technique is more useful than the fix.
The setup
- RandomX compiled to WebAssembly (WebRandomX, i.e. tevador's RandomX), running in one Web Worker, 1–4 C++ pthreads sharing a single ~2 GB dataset cached in OPFS between visits.
- One relay hop: browsers cannot open a raw TLS stratum socket, so a small origin-locked relay on our VPS forwards the WebSocket to the pool. It rewrites nothing — your wallet is the stratum login, and no login, job or share is altered in either direction.
Cross-Origin-Opener-Policy+Cross-Origin-Embedder-Policy, becauseSharedArrayBuffer— and therefore one dataset shared by several threads — requires it.
Native mining on the same pool with the same wallet, from our Android app, was accepting shares normally: 358 accepted / 0 rejected. So the pool was fine, the wallet was fine, and the rate was fine.
The symptom
At share difficulty 10 000 we should find one share per ~10 000 hashes. We did: one submit every 10–20 minutes, roughly 46 submits in a four-hour soak. Every submit came back with the same string:
submit job=22804288 nonce=e8eee8ce result=5c447b2a…0100 hashDiff=49617 jobDiff=10000 local=ok
Share rejected: Low difficulty share
Read that twice: our hashDiff said 49 617 against a required
10 000. The pool disagreed — same job id, same nonce, same 32-byte result.
What it was not
We burned a lot of time on the wrong theories, so here is the elimination round for free:
- Not the pool, port or dialect — jobs parsed, the 4-byte little-endian target decoded to exactly the difficulty we asked for, keepalives worked.
- Not the nonce offset — for a current Monero block header the nonce sits at byte 39: version (1) + version (1) + a 5-byte varint timestamp + 32-byte previous hash = 39, then the 4-byte nonce, then the tx-count byte and the 32-byte merkle root, for a 76-byte hashing blob. Our writes were in the right place, and the phone uses the same offset.
- Not the algorithm or the blob — our WebAssembly hash was byte-identical to native tevador RandomX for the same job blob, seed and nonce. We tested that until we were sick of it.
- Not “too slow” — the observed share rate matched the difficulty almost exactly. A slow miner finds fewer shares; it does not find shares the pool calls invalid.
- Not the local difficulty check — we rebuilt it as exact 256-bit arithmetic
(the pool's own
hashBuffDiff). Still rejected. - Not the pool's blob reconstruction — the pool does not hash the blob it sent you; it rebuilds the block template, inserts your nonce and hashes that. That is fine if your nonce and their nonce land in the same bytes. Later, accepted shares proved the two views agree byte for byte.
The experiment that cracked it
The mine entry point can be called with a target of 0xFFFFFFFF,
i.e. difficulty 1, where the very first hash qualifies. That turns “did we find a
share” into “does the miner's answer survive cross-examination”, and it answers
in about two seconds instead of an hour.
So: mine with an accept-everything target, take the nonce it reports, hash that nonce with the single-VM hash function, and compare.
threads=1 nonce=01000000 mine=49e4bc7a…76db hash(nonce)=49e4bc7a…76db MATCH
threads=2 nonce=01000000 mine=0f57199e…e64d hash(nonce)=49e4bc7a…76db MISMATCH
threads=4 nonce=44332211 mine=26c027fd…e49b hash(nonce)=1324e537…c93a MISMATCH
There it is. Single-threaded the miner is honest. Multi-threaded, the “result” it reports is not the hash of the nonce it reports — a perfectly plausible 32-byte value that is the hash of nothing in particular.
Note the shape of the bisect: one thread works, N threads do not. When a bug appears only with parallelism, stop looking at the algorithm and start looking at shared state.
The root cause
WebAssembly has no fesetround. RandomX needs a floating-point rounding mode, so the
WebAssembly port emulated it in a variable:
uint_fast8_t globalRoundingMode = round_min; /* process-global */
The RandomX bytecode has a CFROUND instruction and it is executed inside the
hashing loop — it writes that variable. The vector maths then reads it, in the hottest path
of the hash, to pick between the fast SIMD implementation and a software float fallback:
if (globalRoundingMode == round_near_even) return wasm_f64x2_add(a, b);
/* otherwise: the slow, exact softfloat path */
One VM per thread, one rounding mode for all of them. Two threads hashing at once overwrote each other's rounding mode mid-program, so each thread's arithmetic was evaluated under its neighbour's mode. What comes out is a valid-looking hash of nothing in particular.
Why did this hide for so long? Because a wrong but uniform 256-bit value passes
your own difficulty check at exactly the right rate. The miner could not tell “I found a
share” from “I found a number”. Every rate-based metric — H/s, submits per hour,
even the local hashDiff >= jobDiff gate — looked healthy. Only the pool could
see the difference, and all it says is Low difficulty share, the same string it uses
for a share that is merely too small.
The fix
Three changes, none of them clever:
- Make the rounding mode per-thread. Both softfloat globals became
__thread. Emscripten's pthread builds support thread-local storage, so each hashing thread now keeps its own mode. Two lines. - Fix the build. A
--shared-memoryWasm link requires every object to declare theatomicstarget feature, which-pthreadimplies, while the single-threaded target must not carry it. The threaded library got its own CMake target instead of sharing one. - Never trust the miner about its own work. Every solved share is now re-hashed with the single-thread hash function before it is submitted. If the two disagree the share is dropped and the log says so — the pool never sees garbage, and we cannot silently return to this failure mode.
The verification
One hour, four threads, full 2 GB dataset, live pool:
260,444 hashes · 21 accepted · 0 rejected · 0 self-check drops · ~90 H/s
And the same job blob and nonce hashed by native RandomX on ARM agrees with the browser byte for
byte. MoneroOcean now lists the tab's VMW-… worker next to the phone's
AX-… worker, and the wallet's invalid-share counter did not move at all.
Lessons worth keeping
- A local difficulty check validates a distribution, not correctness. It tells you how often you should find shares, not that a share is real.
- “Wrong but uniform” is the nastiest failure mode, because every metric you already have looks fine. When something upstream disagrees with you, verify the primitive your own metrics are built on.
- Bisect on the concurrency axis.
1 vs N threadsisolated this in seconds, after hours of pool-side theorising. - Make a cheap experiment. An accept-everything target gave an answer every two seconds instead of a multi-hour soak. Look for the version of your test in which the answer cannot be “no”.
- Porting to WebAssembly means auditing per-thread state. Native miners get a real floating-point environment per thread from the OS; the browser did not. That asymmetry is exactly why the Android app was fine while the tab was not.
Try it: web.voltminer.net — desktop, laptop or Chromebook, 1–4 threads, one 2 GB dataset cached in the browser. Two honest expectations: it is an experiment (tens of H/s — a phone core beats a tab core), and backgrounding the tab stops it.
0% fee. We take no cut of your hashrate: the address you type is the pool login, and the relay rewrites nothing. MoneroOcean may charge its own pool fee. Third-party licences for the bundled Wasm — RandomX/WebRandomX and Berkeley SoftFloat are BSD-3-Clause, the Emscripten runtime is MIT — are listed on the page itself.