# RandomX in a Browser: Why Every Single Share Was Rejected

*Developer Blog · WebAssembly — 2026-10-03*

The [Browser Experiment](https://web.voltminer.net/) 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](https://github.com/WebCryptomining/WebRandomX),
  i.e. tevador's RandomX), running in one Web Worker, 1–4 C++ pthreads sharing a single
  ~2 GB dataset that is 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 share, job or login is altered in either direction.
- `Cross-Origin-Opener-Policy` + `Cross-Origin-Embedder-Policy`, because `SharedArrayBuffer`
  (and therefore one dataset shared by several threads) requires it.

Native mining on the same pool with the same wallet — 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: about one
submit every 10–20 minutes, and in one four-hour soak roughly 46 submits. Every submit was
answered 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 49617 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 = 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 this 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 to be 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 go 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: 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 one hash 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**. The hash is a perfectly plausible 32-byte
value — just not the hash of that input.

Note the shape of the bisect: one thread works, N threads do not. Whenever 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:

```c
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:

```c
if (globalRoundingMode == round_near_even) return wasm_f64x2_add(a, b);
// otherwise: 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. The result 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/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:

1. **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.
2. **Fix the build.** A `--shared-memory` Wasm link requires *every* object to declare the
   `atomics` target feature, which `-pthread` implies, while the single-threaded target must
   not carry it. The threaded library got its own CMake target instead of sharing one.
3. **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 never 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 31 invalid shares stayed 31 — the counter never moved.

## Lessons worth keeping

- **A local difficulty check validates a distribution, not correctness.** It tells you how
  often you *should* find shares, not that the 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 threads` isolated this in seconds after hours
  of pool-side theorising.
- **Make a cheap experiment.** Accept-everything target: two seconds per answer instead of
  a multi-hour soak. Look for the version of your test where the answer cannot be "no".
- **Porting to WebAssembly means auditing per-thread state.** Native miners get a real
  per-thread floating-point environment from the OS; the browser did not. That asymmetry is
  exactly why the Android app was fine while the tab was not.

## Try it

The Browser Experiment is live at <https://web.voltminer.net/> — desktop/laptop/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 SoftFloat are BSD-3-Clause, the Emscripten runtime
is MIT) are listed on the page itself.
