# Pearl (PRL) Stratum Protocol — prl.suprnova.cc

**Version 1.0 — 2026-06-03**
**Algorithm:** `pearlhash` (PoUW L1, GEMM + Plonky2 ZK proofs)
**Pool:** prl.suprnova.cc

This document specifies the wire protocol the suprnova Pearl pool speaks on its stratum ports so that third-party miner authors can implement compatible mining clients beside the existing reference miners (SRBMiner-MULTI, pearl-miner).

The protocol is a custom dialect of JSON-RPC over plaintext or TLS-wrapped TCP, modeled on the named-parameters form used by Monero / XMRig but adapted to Pearl's PoUW workload. It is the same dialect that the broader Pearl pool ecosystem uses, so a single miner implementation works against any compatible pool.

---

## 1. Endpoints

| Region | Host | Ports |
|---|---|---|
| EU  | `prl.suprnova.cc`       | 3370–3374 |
| US  | `us.prl.suprnova.cc`    | 3370–3374 |
| APAC| `apac.prl.suprnova.cc`  | 3370–3374 |

| Port | Difficulty mode | Starting share-difficulty | Encryption |
|---|---|---|---|
| 3370 | Fixed       | `1,000,000`        | Plain TCP |
| 3371 | Fixed       | `4,000,000`        | Plain TCP |
| 3372 | Fixed       | `16,000,000`       | Plain TCP |
| 3373 | VarDiff     | `2,000,000` (adaptive 500 K — 10 T) | Plain TCP |
| 3374 | VarDiff     | `2,000,000` (adaptive 500 K — 10 T) | TLS (use `stratum+ssl://`) |

**Recommended for new miners**: port `3373` (or `3374` for TLS). The VarDiff retargets every 30 s toward a 10-second-per-share cadence and has been tuned to handle anything from a single laptop GPU up to multi-petahash farms.

---

## 2. Transport

- **TCP**, single long-lived connection per worker. No connection pooling, no multiplexing.
- **TLS** only on port `3374` (TLS 1.2+, modern cipher suite — same TLS stack as nginx). Plain TCP on the others. Use `stratum+ssl://` in miner URLs that distinguish the two.
- **Framing**: newline-delimited JSON. One JSON value per line, terminated with `\n` (`0x0A`). No length prefix.
- **Encoding**: UTF-8.
- **Maximum line length**: 256 KiB. Required because the `mining.submit.params.plain_proof` payload is ~137 KB base64.
- **Idle / keepalive**: there is no application-level keepalive. The pool will not close an idle connection on its own; TCP keepalive on the OS is sufficient. Reconnection is at the miner's discretion.

---

## 3. JSON-RPC dialect

The protocol is a **custom flavour of JSON-RPC 2.0**:

- The `"jsonrpc": "2.0"` field is **optional and typically omitted** by both client and server.
- Request IDs are **numeric integers**. They do not need to be monotonic; you may reuse IDs (each method has only one outstanding response at a time per connection).
- Server-pushed notifications use **`"id": null`** to signal "no response expected".
- `params` is **always an object (named parameters)**, never an array. Positional-array params will be rejected with error code `20` (`params must be an object`).
- Method names are dotted lowercase: `mining.authorize`, `mining.notify`, `mining.submit`.

### Response shapes

**Success:**
```json
{"error": null, "id": <req_id>, "result": <value>}
```

**Error:**
```json
{"error": {"code": <int>, "msg": "<text>"}, "id": <req_id>, "result": null}
```

### Standard error codes

| Code | `msg` | Cause |
|---|---|---|
| 20 | `method not supported`        | Unknown `method` value |
| 20 | `params must be an object`    | `params` was an array, not a named object |
| 21 | `stale job`                   | `job_id` no longer matches the current template |
| 22 | `duplicate share`             | Identical `(job_id, plain_proof_digest)` already submitted |
| 23 | `low difficulty share`        | Submitted proof's jackpot hash does not meet the issued `target` |
| 24 | `wallet is missing`           | `mining.authorize` called without a `wallet` field |
| 25 | `invalid wallet`              | `wallet` is not a valid bech32m PRL address |
| 26 | `invalid proof`               | `plain_proof` failed sanity / Merkle / sample-GEMM verification |
| 27 | `unauthorized`                | `mining.submit` received before successful `mining.authorize` |

---

## 4. Client → Server messages

### 4.1 `mining.authorize`

Sent **once per connection**, immediately after TCP connect (and after TLS handshake if applicable). There is **no `mining.subscribe` step** — go straight to authorize.

```json
{
  "id": 1,
  "method": "mining.authorize",
  "params": {
    "wallet": "prl1pa9xwhukl2zse55lgvphxkjejl7draruuls22x2p2kwvqfhk7t2rqkj2cpl",
    "worker": "rig1",
    "pass":   "x",
    "agent":  "my-pearl-miner/0.1.0"
  }
}
```

**Required:**

- `wallet` *(string)* — bech32m PRL address (HRP `prl`, taproot witness v1). Server validates with error code 25 if malformed, 24 if absent.

**Optional:**

- `worker` *(string)* — display label for this rig / GPU group. Defaults to `default` if omitted.
- `pass` *(string)* — **see §6** for the `d=N` custom-difficulty syntax. May be omitted (treated as empty).
- `agent` *(string)* — miner user-agent. Cosmetic; used for per-miner statistics.

**Glued `wallet.worker` form (compatibility):** some miners (e.g. SRBMiner-MULTI) place the worker name in the `wallet` field separated by a dot — `prl1...44v.rtx3090`. The pool splits at the **first** `.` and treats the suffix as the worker name; any explicitly provided `worker` field still wins. This makes the pool drop-in compatible with miners that do not expose a separate worker CLI flag.

**Authorization timeout:** the pool gives clients **300 seconds (5 minutes)** between TCP connect and the `mining.authorize` request. This generous window accommodates Plonky2 circuit cold-start (90–180 s on most Blackwell/Ada GPUs). Connections that have not authorized within 300 s are closed.

**Pool's response sequence after authorize:**

1. Pool pushes a `mining.notify` with the current job (`"id": null`) — **before** the authorize ack.
2. Pool sends the `mining.authorize` ack: `{"error": null, "id": 1, "result": true}`.

A correct miner implementation **must accept an unsolicited `mining.notify` before the authorize ack** and stash the job. This is the same ordering as most XMRig-dialect pools.

### 4.2 `mining.submit`

```json
{
  "id": 10,
  "method": "mining.submit",
  "params": {
    "job_id":      "32fc29f1_500000",
    "plain_proof": "AAACAAAAAAAAAAIAAAAAAAAQAAAAAAAAAAEAAAAAAAA...<base64>..."
  }
}
```

**Required:**

- `job_id` *(string)* — must match the most recent `mining.notify.params.job_id` the client received. Submitting against a previous `job_id` yields error 21 (`stale job`).
- `plain_proof` *(base64 string)* — base64-encoded binary `PlainProof` structure. See **§7** for the binary layout. The pool re-verifies the proof's sanity, Merkle commitments, and jackpot hash before accepting.

**Notes:**

- There is **no separate `nonce`, `extranonce`, or `result_hash` field**. The randomness lives inside the `PlainProof` (in the sampled rows/columns and the `noise_rank` ⇒ `noise_seed` derivation). The miner picks its own randomness; the pool re-derives the public-data commitment from the header bytes it gave the miner and checks consistency.
- One `mining.submit` per accepted share. The pool does not accept multi-share batches.
- The pool responds before queueing the share for downstream accounting; ack latency is ~5–10 ms on a healthy connection.

### 4.3 No other client → server methods

The pool does not implement `mining.subscribe`, `mining.set_difficulty`, `mining.ping`, `keepalived`, or XMRig-style `login`. All four return error code 20 (`method not supported`).

---

## 5. Server → Client messages

### 5.1 `mining.notify`

```json
{
  "id": null,
  "method": "mining.notify",
  "params": {
    "header":  "00004020b6ed5da6...3aedfdbd4eb7c8a7...200e480bc06a1d6ae1820118",
    "height":  67204,
    "job_id":  "32fc29f1_500000",
    "target":  "000000000000218dcdb37c99ae924f227d028a1dfb9389b52007dd441355475a"
  }
}
```

**Fields:**

- `header` *(76-byte hex string, lowercase, unspaced)* — **incomplete** Pearl block header. The full header is 108 bytes; the trailing 32-byte `ProofCommitment` is what the miner constructs while mining (from the public-data section of its PlainProof). The pool supplies only the first 76 bytes.

  Layout (all multi-byte fields are little-endian on the wire):

  | Offset | Size | Field | Notes |
  |---|---|---|---|
  | 0   | 4  | `version`         | u32 LE. `0x20400000` for PoUW V1 blocks. |
  | 4   | 32 | `prev_block_hash` | Internal LE storage; displayed big-endian by block explorers. |
  | 36  | 32 | `merkle_root`     | Coinbase + tx merkle root. |
  | 68  | 4  | `timestamp`       | u32 LE Unix seconds. Miner MAY roll within ±2 hours. |
  | 72  | 4  | `bits`            | u32 LE compact nBits — the **network** target. |

- `height` *(integer)* — height of the block being mined (current tip + 1).

- `job_id` *(string)* — opaque identifier of the form `<8 hex chars>_<integer>`. The leading 8 hex characters are a per-template nonce; the trailing integer reflects the difficulty band the pool selected for this push. Miners should treat the entire value as **opaque** and echo it back unchanged in `mining.submit`.

- `target` *(64-hex-char string, U256 big-endian)* — **share-acceptance threshold**. A share is accepted iff the PlainProof's computed jackpot hash, interpreted as little-endian U256, is ≤ the target interpreted as little-endian U256. (Equivalently: hash treated as a 32-byte BE integer ≤ target BE integer — both conventions yield the same comparison; implementations should not mix endianness mid-comparison.)

  On VarDiff ports (`3373`, `3374`), this target adapts per worker. On fixed ports (`3370`–`3372`), it is constant for the duration of the connection and reflects the port's published difficulty.

**Cadence:**

- One `mining.notify` is pushed **immediately** after `mining.authorize` (before the auth ack).
- A new `mining.notify` is pushed whenever any of the following changes:
  - The pool advances to a new block template (new tip or BIP22 long-poll trigger from `pearld`).
  - The pool's coinbase or memory pool composition changes meaningfully.
  - The pool's VarDiff algorithm retargets the per-worker share difficulty.
- The pool **does not** issue `mining.set_difficulty` as a separate method. New difficulty is always conveyed by the new `target` in a `mining.notify`.

### 5.2 Response acks

Both `mining.authorize` and `mining.submit` are acknowledged with:

```json
{"error": null, "id": <req_id>, "result": true}
```

A `result: true` means accepted. Rejections come back with the error shape from §3 — for instance, a stale share looks like:

```json
{"error": {"code": 21, "msg": "stale job"}, "id": 10, "result": null}
```

---

## 6. Custom difficulty via the `pass` field

On the VarDiff ports (`3373`, `3374`), miners may **pin** their share difficulty by passing a `d=N` directive in the `pass` field of `mining.authorize`:

```json
"pass": "d=50000000"   // pin at 50,000,000
```

**Range:** `500,000 ≤ N ≤ 10,000,000,000,000` (500 K to 10 T). Values outside this range are clamped silently.

**Effect:**

- The pool uses `N` as the **starting** share difficulty for this worker.
- VarDiff retargeting continues to operate from that starting point — i.e. if the miner is much faster or slower than `N` implies, VarDiff will still drift toward the 10-second-per-share target.
- To suppress VarDiff entirely, prefer one of the **fixed** ports (`3370`–`3372`).

**SRBMiner-MULTI example** (the existing reference miner):

```bash
./SRBMiner-MULTI --algorithm pearlhash \
  --pool prl.suprnova.cc:3373 \
  --wallet prl1pa9xwhukl...rqkj2cpl \
  --worker rtx4090-rig1 \
  --password "d=8000000"
```

The pool also accepts `d=N` on fixed ports but ignores it there.

---

## 7. `PlainProof` binary structure

The `plain_proof` field in `mining.submit` carries a base64-encoded binary blob. The decoded blob is approximately **100–140 KB** (the exact size depends on the mining-config dimensions, but is constant within a single template). At the standard config `m=512, n=512, k=4096, noise_rank=256`, decoded length is exactly **102,512 bytes**.

### Top-level layout (little-endian throughout)

```rust
pub struct PlainProof {
    m:          u64,                   //  8 bytes LE
    n:          u64,                   //  8 bytes LE
    k:          u64,                   //  8 bytes LE
    noise_rank: u64,                   //  8 bytes LE
    a:          MatrixMerkleProof,     //  sampled rows of matrix A
    bt:         MatrixMerkleProof,     //  sampled columns of B^T
}
```

The first 32 bytes are the dimensional preamble. Example decoded preamble at the standard config:

```
00 02 00 00 00 00 00 00   ← m = 512
00 02 00 00 00 00 00 00   ← n = 512
00 10 00 00 00 00 00 00   ← k = 4096
00 01 00 00 00 00 00 00   ← noise_rank = 256
```

### `MatrixMerkleProof` layout

Each `MatrixMerkleProof` is a variable-length structure encoding (a) the sampled int8 entries of the relevant matrix and (b) the Blake3 Merkle inclusion proofs binding those entries to the public commitment in the block header.

```
struct MatrixMerkleProof {
    leaf_count:     u64 LE,        // number of leaves committed to (rows for A, cols for B^T)
    sample_count:   u64 LE,        // number of samples in this proof
    samples:        [Sample;  sample_count],
    siblings:       [Blake3;  variable],   // packed Merkle siblings (Blake3 32-byte hashes)
}

struct Sample {
    index:          u64 LE,        // row or column index in the original matrix
    values:         [i8; k],       // the actual int8 vector (k entries)
}

type Blake3 = [u8; 32];            // Blake3 hash, raw bytes
```

The `samples` block in `a` carries `sample_count` rows of `A` (each `k` int8s long). The `samples` block in `bt` carries `sample_count` columns of `B` (each `k` int8s long, presented row-major over `B^T`). The `siblings` arrays carry just enough Blake3 sibling hashes for a verifier to reconstruct the Merkle root over all leaves and compare it to the public-data commitment in the block header.

### Sanity constraints

The pool rejects with error code 26 (`invalid proof`) if any of these constraints fail:

- `noise_rank` is a power of two in `{32, 64, 128, 256, 512, 1024}`.
- `k ≥ 1024` and `k % 64 == 0`.
- `k ≥ 16 × noise_rank` (covers the noise-injection capacity).
- `k ≤ 4 × noise_rank²` (covers the noise-injection budget).
- `m, n ≤ 2²⁴`.
- Decoded byte size matches the template the pool currently issues (within ±0 bytes — the size is deterministic given `m, n, k, noise_rank`).

### What the pool verifies

The pool performs **share-time verification** that is intentionally cheap (~5–10 ms per share):

1. Sanity-check the preamble (above).
2. Reconstruct the `job_key` from the header bytes the miner used.
3. Re-derive the noise seeds from `job_key`.
4. Verify the `MatrixMerkleProof` for `A` — every sampled row's Blake3 leaves chain to the public commitment in the header.
5. Same for `B^T`.
6. Re-compute the jackpot value from the **sampled** rows/columns plus the derived noise (a small `sample_count × sample_count × k` int8 GEMM — milliseconds on CPU).
7. Re-hash the jackpot with Blake3 keyed by the noise seed.
8. Compare against the issued `target`.

The **full** `m × n × k` GEMM and the heavy Plonky2 proof generation stay with the miner. The pool only generates the full Plonky2 proof when a share *also* clears the **network** target — i.e. it is a real block, at which point the pool calls `submitblock` on `pearld`.

### Reference implementation pointers

The canonical PlainProof layout, sanity constraints, and verification logic live in the open-source Pearl node source tree:

- Rust struct: `pearl/zk-pow/src/ffi/plain_proof.rs`
- Sanity checks: `pearl/zk-pow/src/api/sanity_checks.rs`
- Public-data derivation: `pearl/zk-pow/src/api/proof.rs::PublicProofParams`
- Noise seed derivation: `pearl/zk-pow/src/circuit/pearl_noise.rs`

A working serializer can be lifted from the SRBMiner-MULTI source or from the open-source `pearl-miner` reference implementation; both produce byte-identical output for the same input.

---

## 8. Connection lifecycle

```
1. Client TCP-connects        (and TLS-handshakes on port 3374)
2. Client → mining.authorize  (NO subscribe step; submit within 300 s)
3. Server → mining.notify     (pushed FIRST, "id": null)
4. Server → authorize ack     ({"error":null, "id":<from 2>, "result":true})
   --- now mining ---
5. Server → mining.notify     (on new template / VarDiff retarget)
6. Client → mining.submit     (when miner finds a share that meets target)
7. Server → submit ack        (true or error code 21/22/23/26)
   loop 5–7
8. Either side closes TCP.    Client SHOULD reconnect with exponential backoff
                              (1 s, 2 s, 4 s, capped at ~30 s).
```

**Reconnection policy** for well-behaved miners:

- On clean connection close: reconnect after 1 s.
- On TCP error or TLS error: reconnect with exponential backoff (1, 2, 4, 8, 16, 30, 30, …).
- On error code 25 (`invalid wallet`): do **not** auto-reconnect. The wallet is wrong; surface the error to the user.
- On any other server-side error: continue with the standard backoff.

**Grace period:** for 30 s after a VarDiff retarget, the pool will accept shares at the *previous* difficulty as well as the new one. This avoids losing the work-in-flight when a retarget races with an outbound share.

---

## 9. Worked example

A complete authorize → notify → submit → ack transcript (whitespace added for readability — the wire is one line per JSON value):

```text
[client → server]
{"id":1,"method":"mining.authorize","params":{
   "wallet":"prl1pa9xwhukl2zse55lgvphxkjejl7draruuls22x2p2kwvqfhk7t2rqkj2cpl",
   "worker":"rtx4090-1",
   "pass":"d=8000000",
   "agent":"my-pearl-miner/0.1.0"
}}

[server → client]
{"id":null,"method":"mining.notify","params":{
   "header":"00004020b6ed5da6...c06a1d6ae1820118",
   "height":67204,
   "job_id":"32fc29f1_8000000",
   "target":"00000000000010cda8a44d2b...3f"
}}

[server → client]
{"error":null,"id":1,"result":true}

... miner crunches GEMM ...

[client → server]
{"id":10,"method":"mining.submit","params":{
   "job_id":"32fc29f1_8000000",
   "plain_proof":"AAACAAAAAAAAAAIAAAAAAAAQ...<~137 KB base64>...=="
}}

[server → client]
{"error":null,"id":10,"result":true}
```

---

## 10. Compatibility & versioning

This document describes **stratum protocol version 1.0**, the same dialect SRBMiner-MULTI 3.3.2+ speaks and the same dialect used by `pearl-miner` v11+. A miner implementing this spec will work against any compatible pool in the Pearl ecosystem.

**Stability guarantees:**

- The four field names in `mining.authorize.params` (`wallet`, `worker`, `pass`, `agent`) and the four field names in `mining.notify.params` (`header`, `height`, `job_id`, `target`) and the two field names in `mining.submit.params` (`job_id`, `plain_proof`) will not be renamed within v1.x.
- New **optional** fields may be added; clients MUST ignore unrecognised fields.
- The PlainProof binary layout is governed by the Pearl protocol itself (not the pool); changes there are driven by upstream Pearl PoUW upgrades and will be coordinated through the Pearl developer channels.
- Error codes 20–29 are reserved for the protocol. Pool-specific codes start at 100.

**Suggested minimum miner behaviours for spec conformance:**

- Accept an unsolicited `mining.notify` before the authorize ack.
- Re-target whenever a new `target` arrives (do not drop in-flight shares — submit them anyway; the 30 s grace period covers a single retarget overlap).
- Reconnect with exponential backoff on any error other than code 25.
- Honour the 256 KiB max line length when buffering server messages.

---

## 11. Reference miners

These are the currently known open / publicly distributed Pearl miners. They all speak this exact protocol against the suprnova pool:

- **SRBMiner-MULTI** — CPU+GPU, closed-source, the de-facto reference. `--algorithm pearlhash`.
- **pearl-miner** — open-source CUDA miner.

If you ship a new miner, we'd love to add it here. Open a PR against this document at the repo URL or email `mining@suprnova.cc` (subject: "Stratum spec — new miner").

---

## 12. Changelog

| Version | Date | Notes |
|---|---|---|
| 1.0 | 2026-06-03 | Initial public specification. |

---

*Copyright © suprnova.cc. This document is published openly under CC-BY-4.0 to encourage development of additional Pearl mining clients.*
