A look at the conversation between a Monero mining pool and a RandomX miner like XMRig. Despite being called "Stratum", the Monero/CryptoNote variant doesn't speak the Bitcoin protocol — it's a JSON-RPC over raw TCP dialect with just four verbs: login, getjob, submit, keepalived. Add server-pushed job notifications and you have the entire mining loop.
A mining pool sits between the Monero daemon and a fleet of miners. It talks JSON-RPC over HTTP to monerod (to pull block templates and submit found blocks) and a line-delimited JSON-RPC over TCP protocol — the CryptoNote Stratum dialect — to thousands of miners. The pool's job is to chop the network's monolithic mining target into bite-sized shares that small miners can actually find, then aggregate everyone's hashes into a credit ledger.
A miner opens a single long-lived TCP socket (usually wrapped in TLS) and never closes it for the whole session — sometimes days. Every message is a JSON object on its own line, terminated by \n. There are exactly four client methods and one server push in the entire protocol.
TCP to pool.host:3333 (or 14444 for TLS). One socket per miner. The pool may run many ports each with a different starting difficulty.
First message must be login: wallet address (the payout), optional .worker_id suffix, miner agent string, supported algos.
The login response embeds the first job. No separate getjob needed. Miner starts hashing the moment it parses this reply.
Miner pushes submit on every share. Pool pushes job on every chain tip change. Both sides exchange keepalived pings to hold the socket.
The pool validates the wallet address against its expected prefix (mainnet starts with 4 or 8), splits the worker ID off the address (everything after the first dot or +), creates a session, fetches the current block template if it doesn't have one cached, and ships back a session_id the miner will quote in every subsequent submit.
Decodes the base58 wallet, checks the network prefix byte matches mainnet (0x12) or subaddress (0x2a). Reject if junk.
Generates a random session_id, indexes the miner by it. Stores agent string, IP, starting difficulty, last-share timestamp.
Pulls the current block template, mutates its extra_nonce field so this miner gets a unique search space, computes the per-miner share target.
blobThe blob is the heart of every job. It's the hex-encoded serialized block header that the miner will hash. The pool has already inserted a coinbase paying the pool's wallet, picked the tx set, and computed the merkle root. The miner's only freedom is the nonce field at bytes 39–42: it changes those 4 bytes, recomputes the RandomX hash of the whole blob, checks against the target.
| Field | Type | Meaning |
|---|---|---|
| blob | hex string | The serialized mining blob. ~76 bytes for Monero. The miner hashes this exact byte sequence with only the nonce mutated. |
| job_id | opaque string | Pool-assigned tag. Echoed back in submit so the pool knows which template the share refers to. |
| target | hex (LE u32 or u64) | The share difficulty target. Hash result interpreted as little-endian; if hash < target → valid share. |
| algo | string | rx/0 for mainline Monero RandomX. Other variants exist for forks (rx/wow, rx/arq). |
| height | integer | Block height this template builds on. Used by the miner for logging and to drop stale work. |
| seed_hash | hex string | The RandomX dataset seed (changes every ~2048 blocks). Triggers the miner to rebuild its 2 GB dataset. |
| next_seed_hash | hex (optional) | Heads-up about the next seed so the miner can pre-build the dataset off-thread before the rotation. |
The pool injects an extra_nonce inside the miner transaction so each connected miner gets a slightly different blob — that way two miners never duplicate work on the same 4-byte nonce range. If the miner ever sees the same blob twice, the pool has a bug.
RandomX is Monero's ASIC-resistant PoW (since Nov 2019). It compiles a per-seed random program into native machine code and executes it inside a 2 GB dataset, hammering the CPU's branch predictor, cache, and integer/FP units the way real workloads do. The whole point: a general-purpose CPU is roughly as efficient at this as bespoke silicon, which is why XMRig and friends are CPU miners.
On first job (or seed change): allocate ~2 GB, expand seed_hash into the dataset via Argon2d + AES. Takes ~30s. Done once per ~2048 blocks.
For each candidate nonce: write nonce into blob[39..42], run RandomX(blob) → 32-byte hash. Each thread takes a slice of the 232 nonce space.
Interpret last 8 bytes of hash as little-endian u64. If hash_le < target → it's a share. If hash_le < network_target → it's a block.
Stop everything, fire off submit with the winning nonce and hash, resume hashing from where the thread left off. Latency matters: stale shares get rejected.
The current network difficulty makes a real block hash a 1-in-billions event per nonce. A small miner could hash for months without ever finding one. So the pool sets a much easier target — typically tuned so each miner finds one share every ~30 seconds. Shares are proof the miner did work; they don't go on chain, but they earn the miner credit in the pool's accounting. Only when one miner's share happens to also satisfy the network target does an actual block get produced.
When a miner finds a nonce satisfying its share target, it sends a submit with four fields: session id, job id, the winning nonce (as 8-hex little-endian), and the resulting hash. The pool validates in a strict order and replies either OK or a typed error.
| Check | Failure mode | What it catches |
|---|---|---|
| 1. Session known | Unauthenticated | The id matches an active session. Rejects shares from miners who never logged in or have been kicked. |
| 2. Job exists | Invalid job id | The job_id is in the session's recent-jobs cache. Pools usually keep the last 4-8 jobs to absorb in-flight shares after a job change. |
| 3. Nonce well-formed | Malformed nonce | Regex ^[0-9a-f]{8}$. Eight lowercase hex characters, no more, no less. |
| 4. Not duplicate | Duplicate share | The pool maintains a per-job set of seen nonces. Resubmitting the same nonce is either a bug or an attempted credit-stuffing exploit. |
| 5. Not stale | Block expired | If the job's height is below current chain tip, the work is on a dead template — share dropped, miner's stale counter ticks up. |
| 6. Hash < target | Low difficulty share | Pool re-runs RandomX on the submitted blob+nonce, compares to the per-miner target. (Trusted pools may skip this for trusted miners — "share trust".) |
| 7. Hash < net target? | — (the good case) | If yes, this share is a block. Pool immediately calls submit_block on monerod with the full blob. |
Whenever monerod tells the pool about a new block (via ZMQ chain_main or by polling get_block_template), every miner currently hashing on the old template is wasting electricity. The pool fans out a job notification — the one JSON-RPC frame that has no id field, marking it as a one-way push the miner must not reply to.
On receipt, the miner immediately drops its current nonce loop and starts over with the new blob. Any share computed against the old job_id is now stale; if it arrives at the pool after the rotation, it's counted as a stale share and (typically) not credited. Good miners minimize stale rate to under 1%.
(1) New block from monerod's ZMQ — clean job, drop everything. (2) Template refresh (extra tx in mempool, more fees) — optional, some pools merge. (3) Merge-mining tip change.
10k connected miners means 10k goroutines/sockets all writing simultaneously. This is why xmrig-proxy exists: it concentrates 100k miners into a few hundred upstream pool sockets.
From the moment monerod sees a new block to the moment every miner is hashing the new template: typically <500ms. Each extra millisecond is hashpower burned on dead work.
A pool serves both a Raspberry Pi doing 200 H/s and a 64-core Threadripper doing 50 kH/s. One target can't fit both — too easy and the Threadripper drowns the pool in submits; too hard and the Pi never finds a share. VarDiff retargets each miner individually to keep the share rate near a configured cadence (typically 1 share / 30s).
Pool watches the rolling interval between this miner's shares over the last retargetTime (e.g. 30s) and computes the ratio vs the goal.
If the miner is too fast, raise the target's difficulty (lower the numeric target). Too slow, lower it. Bounded by maxJump (usually ±100%) and a variancePercent deadband to avoid thrash.
The next pushed job simply carries the new target field. No special "set_difficulty" message exists in the CryptoNote dialect — difficulty rides on the job.
| Scheme | How miners are paid | Trade-off |
|---|---|---|
| PPLNS | Pay-per-last-N-shares. When a block is found, reward is split across the last N shares submitted to the pool (sliding window). | Most common in Monero. Penalizes pool-hopping. Income is variance-bound to the pool's luck. |
| PPS | Pay-per-share. Pool pays a fixed XMR amount per accepted share, immediately, from its own reserves. | Predictable income for the miner. Pool bears all the variance risk; charges higher fee. |
| SOLO | Miner gets the entire block reward when their share happens to be the winning one. Nothing otherwise. | For large miners. Pool acts as pure stratum/template service. |
| P2Pool | Decentralized sidechain — each "share" is itself a mini-block on the sidechain. No central operator; payouts settle on the Monero chain automatically. | Trustless, non-custodial. Slightly higher orphan rate. Increasingly popular in Monero post-2021. |