The pool side of the DATUM protocol for the BLAKE2b Bitcoin chain (BTCB2), as run by
P2Block (datum.p2block.com:28915, 1 % with your own gateway,
2 % on the hosted stratum). This is a fork of
GaltRanch/wavicles-prime (PyBLØCK's WAVICLES
Prime), which is itself Lazarus primed plus the window-snapshot commitment and carry-forward
described below. Everything P2Block added is listed in
P2Block modifications; the protocol, the TIDES rule and the gateways
supported are unchanged, so any stock datum_gateway or ratum-gateway points at this Prime
unpatched.
Any stock datum_gateway — OCEAN, CONVOY,
the BLAKE2b forks by FlyTheElephant1 and
iohzrd, or the packaged
StartOS build — and
ratum-gateway point at this Prime (see
Supported gateways). The gateway's own node builds every block
template. The Prime never sees or chooses transactions; it does three things:
- Dictates the coinbase. When a gateway asks for a coinbaser, the Prime answers with the current TIDES split of the window: one output per miner, proportional to work, after the pool fee, then the pool's own output last so the list sums to the requested value. The gateway builds its coinbase from that list, so a block found by anyone's miner pays everyone in the window.
- Verifies every share. Each share is rebuilt into a full BLAKE2b header v2 from the job the gateway described (prevhash, nbits, merkle branches, coinbase halves) and the miner's nonce fields, hashed, and checked against the share target — and against the network target, so a block is recognised before the gateway says so. The coinbase inside the share is parsed and compared with the split the Prime actually issued for that job; a coinbase paying anywhere else is rejected.
- Keeps the ledger. Accepted work is credited to the miner's payout address in an append-only window sized in multiples of network difficulty. On a block, the split that was paid (or, for a stock gateway's pool-only "empty" coinbase, the amount the pool now owes the window) is recorded, and the Prime submits the assembled block to its own node too.
Branch p2block = upstream GaltRanch/wavicles-prime at the pinned commit + the commits below.
Builds are tagged p2block-vX.Y.Z; the pool's pool-primed image is built from that tag.
The pool's control plane (poolcore, from the /admin console) writes this file; the Prime
re-reads it within 5 s of any change. Nothing here needs a restart.
{
"fee_bps": 100, // live DATUM-tier fee; null/absent = prime.toml fee-bps
"stratum_fee_bps": 200, // live hosted-tier fee; null/absent = prime.toml stratum-fee-bps
"fee_overrides": { "bc1q…": 50 }, // per payout address, applies on BOTH paths (partners, promotions)
"banned_identities": ["bc1q…"], // every share from these identities is refused, nothing credited
"banned_ips": ["203.0.113.9", "198.51.100.0/24"], // sessions from these addresses are refused at accept
"updated": "2026-09-15T04:00:00Z"
}Rules: fees are basis points, 0–10000; override keys are canonicalised like usernames (bech32
lowercased, .worker stripped); unknown keys are rejected. A file that fails validation is
logged and ignored, keeping the previous controls in force; a removed file returns to the
prime.toml values with no bans. Every reload is logged with a one-line summary.
tides::SplitParams gained fee_overrides; bps_for(identity) returns the (stratum, datum)
basis points that apply. The uncapped and the capped ("pay like CHIRP") paths both honour it,
and the capped fee is computed exactly per identity. The window snapshot is now v2:
params.fee_overrides is committed alongside the tier fees and the rule text names it, so a
block paying someone a different rate is still reproducible from the chain.
tools/verify_wavicles_block.py recomputes v2 snapshots (and applies the hosted-tier rate to
stratum_work on every path, as the Prime does).
A miner behind their own gateway is one identity to the pool, but the gateway forwards the
full stratum username. The Prime now keeps (identity, worker) counters — accepted, work,
last share, a 10-minute hashrate — and exposes them as workers[] in stats.json. Entries
expire after an hour of silence. The pool's dashboard shows them as per-rig rows next to the
hosted-tier ones.
pool.fee_bps / pool.stratum_fee_bps are the effective values (pool.config_fee_bps /
pool.config_stratum_fee_bps keep the file values); every window.miners[] row carries its
fee_bps / stratum_fee_bps; controls summarises what is loaded; workers[] as above.
git remote add upstream https://github.com/GaltRanch/wavicles-prime
git fetch upstream && git rebase upstream/main p2block # our commits are few and self-contained
cargo test && cargo fmt --all -- --check
git tag p2block-vX.Y.Z && git push --force-with-lease origin p2block && git push origin p2block-vX.Y.ZThen bump WAVICLES_REF in the pool repo's deploy/images/primed/Dockerfile. Generic pieces
(the controls file, per-worker stats) are candidates for upstream pull requests; the fee
override and ban policy are P2Block's.
Upstream is the Prime behind WAVICLES, PyBLØCK's DATUM pool. It is Lazarus primed plus
two things that make the TIDES split verifiable from the chain and keep the pool from ever
holding a balance for anyone (both kept unchanged here):
- Window-snapshot commitment. Every coinbaser is computed from a window state; that state
(identities and work, carry, parameters, the resulting split) is serialized as canonical JSON,
hashed with BLAKE2b-256, and committed as the first output of the coinbaser:
OP_RETURN <snapshot-tag> || hash(zero sats). The snapshot is published at/snapshot/<hash>on the stats port.tools/verify_wavicles_block.py --height Hchecks that the block's commitment hashes the published snapshot, recomputes the split from it, and compares every output on chain by scriptPubKey. What still rests on the pool is that the window reflects the shares gateways really sent; signed share receipts are the next step. - Carry-forward instead of "owed". What a coinbase could not pay — dust under the minimum
payout, outputs a gateway's coinbase class dropped, or a block found on an empty window — is
recorded per identity in
carry.jsononce the block settles and paid by the next coinbases out of the pool's own output, fee included. The pool nets exactly its fee over time and never custodies payouts.stats.jsonexposes it underwavicles.carry.
Config keys: commit-snapshot, snapshot-tag, carry-forward (all on by default). The wire
format, the gateways supported and the TIDES rule are unchanged; a stock gateway pays the
OP_RETURN like any other dictated output.
Credit: the Prime itself is AwokenLazarus/Bitcoin prime/
(MIT); the WAVICLES changes are PyBLØCK's (GaltRanch/wavicles-prime);
the modifications above are P2Block's. All MIT.
prime/
Cargo.toml workspace
wire/ datum-wire: framing, NaCl session, messages, coinbaser v2, BLAKE2b PoW, share verify
tides/ tides: share window, split computation, append-only ledger, block log
primed/ the daemon: sessions, node poller, stats HTTP, CLI
prime.toml.example
scripts/regtest-e2e.sh end-to-end test against a real C datum_gateway on regtest
scripts/regtest-divergence.sh two-node test: gateway template survives a pool node with a different mempool/tip
datum-wire and tides have no async or I/O dependencies beyond std; every protocol and
accounting rule is unit-tested in isolation. primed is the only crate that touches sockets.
cd prime
cargo build --release
cp prime.toml.example prime.toml # edit payout-address, data-dir, rpc, rpc-cookie
target/release/primed -c prime.toml check
target/release/primed -c prime.toml pubkey # give this to gateway operators
target/release/primed -c prime.toml runSubcommands: run (default), check, pubkey, window (dump the window as JSON),
import-ledger <ledger.json> (seed an empty window from the previous Prime's export).
Logging is RUST_LOG (info default; debug prints each share decision).
An existing lazarus-prime.toml loads unchanged (activation-height, verify-shares and
require-split-gateway are accepted and reported as no longer applying). A data dir the old
Prime left behind keeps its identity: lazarus-prime.key (its 160-byte layout) is read when
there is no prime.key, so the pool pubkey every gateway operator pinned stays the same —
primed pubkey prints it to confirm. (P2Block runs a fresh key; the section is kept for operators migrating an old Prime.) Its ledger.json is the whole window; import it before
the first run:
primed -c lazarus-prime.toml import-ledger /path/to/lazarus-prime/ledger.json
primed -c lazarus-prime.toml runThis is how the Lazarus pool was cut over on 2026-09-03: same key, same config, 9,021 credit
rows / 570.5 M work / 18 identities imported, lazarus-gateway reconnected within a second,
and its shares verified against the same window. Expect a short burst of
bad-coinbase-outputs rejects right after any Prime restart: a gateway keeps publishing jobs
built on the coinbaser it got from the previous instance until its next template, and a
coinbase paying a split this instance never issued is refused by design.
In datum_gateway_config.json:
"datum": {
"pool_host": "datum.p2block.com",
"pool_port": 28915,
"pool_pubkey": "<output of primed pubkey>",
"pool_pass_workers": true,
"pool_pass_full_users": true,
"pooled_mining_only": true
}mining.pool_address should be the pool's payout address (a stock gateway sends anything
its template is worth beyond the issued list there). Miners authenticate to the gateway as
<payout address>.<worker>; the address is the identity credited in the window. The
identity ends at the first . or ~, so a ~modifier suffix that a gateway without
stratum_username_mod forwards verbatim still credits the address. Nothing else changes for
the gateway operator.
The pool's own public stratum, lazarus-gateway (in ../lazarus/), is a DATUM client too and
speaks to this Prime as one; two habits of its are recognised as such. It sends the whole
legacy coinbase as coinb1 with an empty coinb2 (a shape no stock gateway produces), and
when its template is worth less than the value a split was issued for it scales every output
down by the same ratio rather than dropping the ones that no longer fit.
Four upstreams, two protocol generations. The refs are what the e2e script builds, and are the versions this Prime is checked against.
| Name | Upstream | Ref tracked | Generation |
|---|---|---|---|
convoy |
CONVOYMining/datum_gateway |
master |
Convoy, configure v3 |
fte |
FlyTheElephant1/datum_gateway |
test/console-collapse-pr14-pr17 (now also master) |
OCEAN, configure v1 |
iohzrd |
iohzrd/datum_gateway |
master (has the blake2b branch and two commits more) |
OCEAN, configure v1 |
startos |
packaged by Retropex/datum-gateway-startos |
the datum_gateway submodule of the newest pow_* release |
OCEAN, configure v1 |
The StartOS package ships the gateway as a submodule, so a release pins one gateway commit
rather than naming a branch. scripts/regtest-e2e.sh startos resolves that commit from the
release and builds it, which keeps the test honest as new packages ship. Its unreleased
pow-convoy branch is Convoy-lineage and reachable as startos-convoy.
Two upstream rules are load-bearing and are enforced here rather than discovered in production:
- RDTS output scripts. Knots activates RDTS (BIP 110) as a flag day at the BLAKE2b fork
height, which limits every output of the generation transaction to a 34-byte scriptPubKey
(83 if it starts with
OP_RETURN). The newest gateways enforce it too: an oversized miner payout is left out of the coinbase, and an oversized pool payout stops them serving work for the block at all.address::to_scripttherefore treats an address whose output script cannot fit — a witness program over 32 bytes is valid but does not — as unpayable, so its share stays in the pool remainder instead of becoming an output a gateway would drop. An unpayablepayout-addressis refused at startup. - The ABW flag. Convoy's configure v3 carries a flags byte, and this pool sets
ABW_DISABLEDbecause it runs no anti-withholding. Convoy builds from 2026-09-02 onward require it: without it the gateway waits for an assignment that never comes and serves no work. Convoy-lineage builds from before that date reject a non-zero flags byte outright, so they need a gateway update rather than a change here. Only the unreleasedpow-convoybranch is still pinned that far back; every published StartOS package is OCEAN-lineage and never sees this byte.
Recovered from the C client; the same bytes any gateway already speaks.
- Frame: 4-byte header, one little-endian
u32:len:22 | reserved:2 | signed | sealed | channel | cmd:5, XOR-obfuscated by a per-direction rolling key (feedback, a Murmur3-style mix reseeded from the hello). Payloads are plaintext, NaCl sealed boxes, orcrypto_boxon the session keys, optionally trailed by an ed25519 signature. - Handshake: client sends a
crypto_box_sealed hello containing its long-term and session keys, user agent, and a seed; Convoy-lineage clients appendDRS\x01and an optional resume token. The Prime replies sealed to the session key with its MOTD and the seed acknowledgement. The generation is detected from the hello, and the matching configure layout is sent: v1 (32-bit prime id) for OCEAN/fte/iohzrd, v3 (64-bit id, resume token,ABW_DISABLED) for Convoy. - Mining command (0x05): coinbaser request → coinbaser v2 reply (
id | (value u64, script len, script)…); pow submit with lazily-attached job (0x01) and coinbase (0x02) sections, the BLAKE2b section (0x03: 64-bit ntime and nonce) and the header time (0x04); share receipts with the client's reject-code vocabulary; block notify; full-block request and the transaction reply. - Lineage quirks handled: 8 vs 256 job slots;
FLAG_BLAKE2Bset (fte/Convoy) or implied by the 0x03 section alone (iohzrd); 12-byte extranonce withb10cf00dmarker;sia_prevhashform of the previous block.
A share is verified by reproducing exactly what Knots hashes:
H1 = tagged_sha256("Bitcoin block header 1",
version‖prev‖height‖merkle_root‖time‖00‖nbits‖txcount‖flags‖clear_bits‖tagged(xor_key))
H2 = tagged_sha256("Merge-mining hook", H1 ‖ 32 zero bytes ‖ rhs) the job commitment
coinb1 = 000000 ‖ H2 ‖ 00000000 39 bytes, Sia layout
root = blake2b256(0x00 ‖ coinb1 ‖ extranonce(12)) what the ASIC calls the merkle root
work = hidden_prev(32) ‖ nonce(8) ‖ ntime(8) ‖ root(32) the 80-byte ASIC pass
hash = reverse(blake2b256(work) XOR mask(xor_key, clear_bits))
hash <= share_target(pot) credits 2^pot work; hash <= nbits_target is a block. The
Bitcoin merkle root folds the coinbase txid over the gateway-supplied branches; the coinbase
is coinb1 ‖ 12 zero bytes ‖ coinb2 with the target byte patched in. A per-connection
JobSlot caches the parsed coinbase and the H2 for each (coinbase id, target byte), so a
repeat share on the same job costs one BLAKE2b, not a coinbase parse, a merkle fold, and two
tagged SHA256s.
The window holds credits (ts, identity, work, height) until its total work reaches
window × network difficulty (converted to difficulty-1 shares), then trims from the oldest
end. A split of value V pays fee = V × fee_bps / 10000 to the pool, then distributes the
rest to identities proportional to their work in the window, dropping outputs below
min-payout (their share stays with the pool and is reported as unpaid). The output list is
capped by count and size so the coinbase fits in a gateway's largest coinbase class, with the
pool's output — fee plus whatever could not be placed — appended last. The list therefore
sums to the requested value; a stock gateway pays it verbatim and only adds a pool output of
its own when the template turns out to be worth more, and lazarus-gateway, which writes
exactly the list it is given, pays the fee instead of burning it.
Stock gateways build several coinbase sizes and hand small miners those with room for only
the first few outputs, or none at all while a coinbaser reply is in flight. The Prime
classifies every share's coinbase as Split (every issued miner output paid), Partial
(a subset, each in full), PoolOnly (only the pool's script), or Foreign (anything
else, rejected). An output is "paid" when it carries at least the issued amount scaled by
actual value / issued value when the template is worth less than the split assumed (never
more than issued) — this covers both a gateway that scales the list and one that drops
outputs. The pool's own output is exempt from any minimum; it takes what is left. A block
found on a Partial or PoolOnly coinbase records owed_sats — what the pool holds on behalf of
the window — in blocks.jsonl.
| File | What |
|---|---|
prime.key |
ed25519 seed ‖ x25519 secret, 0600, generated once (lazarus-prime.key is read instead when present) |
controls.json |
P2Block runtime controls (live fees, per-identity overrides, bans), written by the pool's control plane |
credits.bin |
append-only 24-byte credit rows; replayed at start, compacted when the window trims |
identities.txt |
interned identity table (one address per line, index = row id) |
window.json |
window target work and lifetime counters |
blocks.jsonl |
one JSON line per block event (candidate, submit outcome, settled/orphaned) |
stats.json, ledger.json |
mirrors of the HTTP endpoints, rewritten atomically |
GET /stats.json on stats-listen (also mirrored to data-dir/stats.json) is the document the
pool UI reads: pool (pubkey, fee, window multiple, advertise address, uptime), node
(height, tip hash, difficulty, tip age), window (target/total work, fill percent, per-miner
work, shares, hashrate_ghs, share_percent, payout_sats at the current reward),
clients (per gateway: generation, user agent, accepted/rejected, last reject reason),
blocks, owed, totals, plus P2Block's workers and controls (see above). /ledger.json
is the previous Prime's credits view for the UI's hashrate graph; /healthz returns ok.
cargo test # wire (44), tides (15), primed (16)
cargo test --release -p primed --test replay_e2e -- --ignored --nocapture # hostile gateway vs a real primed
scripts/regtest-e2e.sh convoy # or fte | iohzrd | startos: real C gateway + real Knots on regtest
MINER_CMD='...' scripts/regtest-divergence.sh fte # two nodes with different mempools and tipsreplay_e2e starts a primed, speaks DATUM to it as a gateway would, grinds one genuine
diff-1 share (~2^32 BLAKE2b hashes, 10–60 s across all cores) and replays it fourteen ways —
job section flipped in an unread field, bare, on another slot, after a reconnect, from another
key — asserting it is credited exactly once. PRIMED_BIN=/path/to/primed points the same
attack at another build; against the pre-fix binary the first replay is accepted.
The unit tests pin the frame obfuscation, nonce derivation, hello round trip for both generations, configure v1/v3 byte layouts, coinbaser v2, pow submit parse/encode, tagged hashes, and share verification including grinding real BLAKE2b shares against an easy target.
The end-to-end script builds the named datum_gateway, points it at a primed on a local
BLAKE2b regtest node, and checks that the handshake, configure, coinbaser, shares, block
candidates and submitblock all happen. convoy, fte, iohzrd and startos all pass it at
the heads in the table above, each reaching handshake, configure and coinbaser against a real
Knots regtest node. startos-convoy fails on purpose: its pin predates Convoy's ABW flag, so
it rejects the configure and the script says so. Earlier,
a block found through a stock Convoy gateway paid the two-miner TIDES split on-chain
exactly as issued (65.6% / 34.4% after the 0.5% fee), and the block the Prime assembled from
the gateway's transaction reply was byte-identical to the one the gateway submitted. With the
complete list, a Convoy-found block's coinbase carried the miner's 12.4375 and the pool's
0.0625 (0.5%) once each — no second pool output.
The e2e test runs the gateway and the Prime against the same node, so it cannot tell whether
the block the Prime submits is the gateway's template or something the pool's node would have
built. regtest-divergence.sh separates them: node A is the pool's node (the Prime submits
there), node B is the gateway's node, a fresh datadir synced from A. It cuts the two apart and
gives each transactions the other never sees (raw transactions signed by A's wallet but
broadcast only to B; wallet sends on A), so getblocktemplate differs on the two nodes at the
same height. It then mines through a stock fte gateway and asserts, from getblock on A:
- the block A accepted contains every B-only transaction and none of A's own — the pool node accepted a block it could not have built, so the Prime reassembled the gateway's template byte-for-byte rather than substituting its node's view;
- the coinbase carries the gateway's secondary tag (
Lazarus␏divergence-gw) and the issued split (first block after start ispool-only, as the stock gateway publishes an empty coinbaser job until the first reply lands; the owed amount is recorded); - the gateway's own node B has the same block, so both sides agree without ever talking.
Then it drifts the tips. With B one block behind A (A mines a block B never hears about), the
gateway's next solve is a competing block at A's tip height: the Prime accepts the share inside
stale-grace-secs, A answers inconclusive (valid, not best), the 30 s confirm pass labels the
record orphan:split, the next solve on B's branch reorgs A, and the label clears
(back in the main chain, record split, settled). Finally A and B are reconnected and must
agree. The whole run takes about a minute on regtest with a GPU miner.
Doing this by hand first surfaced three bugs, all fixed:
- a record labelled
orphan:was never re-checked, so a competing block that later won the reorg — exactly the lagging-gateway-node case above — stayed an orphan forever, and the pool UI read orphan state from the wrong field (submitrather thankind); - two block candidates solved on the same job (regtest does it every share; on mainnet it needs two solves of one job seconds apart) overwrote each other in the pending map, so the gateway's single transaction reply assembled and submitted only the later one. Every candidate for a job is now kept and submitted from the one transaction set;
- when the gateway's node is two or more blocks ahead of the pool's node, every share is rejected as stale (correct: the Prime cannot verify work on a chain its node has not seen, and a stock gateway then reconnects every 30 s for lack of accepts). That is a lagging pool node, not a stale gateway, and it is now logged as such once a minute per session.
- Verification is cheap enough that every share is fully rebuilt and checked, always. The
ignored
verify_throughputtest measures both paths on a single core: on a Ryzen 9950X3D, about 376k shares/s for a share carrying fresh job + coinbase sections against a 2000-tx template, and about 536k shares/s for later shares on the same job (cargo test --release -p datum-wire -- --ignored --nocapture verify_throughput). A whole pool's share flow fits on one core with orders of magnitude to spare. - One Tokio task per connection owns both halves of the socket; reads go through a
cancel-safe growable buffer so
select!over reads, tip broadcasts and keepalives can never desynchronise the frame stream. - The ledger is a
Mutexheld only for the microseconds a credit takes; nothing awaits under it. Coinbaser replies compute the split from a snapshot. - Block notify is fanned out with a broadcast channel; a tip change from the node poller or a block candidate from any session reaches every other gateway immediately.
- Idle gateways get a zero-length INFO frame every 20 s (the client's global timeout is 60 s); a gateway silent for 300 s is dropped. Handshake must complete in 15 s.
- Duplicate shares are caught by hash in one set shared by every session and keyed by block
height. The hash commits to the job (prev, merkle, nBits, txcount, version) and the miner's
nonces, so it is unique per height and needs no per-job scoping; nothing a gateway sends —
a re-sent job section, a reconnect, a new key — can empty it. Housekeeping prunes heights
below what the stale check still accepts, and at a hard cap new work is refused rather than
old work forgotten. (Before 2026-09-06 the set was per session and per job and was cleared
whenever the job section changed, which let one share be credited without limit.) Shares one
height behind are accepted for
stale-grace-secsafter the tip moved, matching template refresh latency. - The coinbase check bounds miner outputs from both sides. An issued output may not be paid
less than its share (scaled down when the template is worth less than the split assumed)
and may not be paid more than Prime issued against its script (scaled up by the same ratio
when the template is worth more, which is how
lazarus-gatewayrescales). The upper bound is what stops a gateway paying every miner exactly and sending the pool's remainder — fee, rounding, unplaced dust — to an address of its own choosing; it is held per script, not per identity, because two window identities may resolve to one scriptPubKey. - Identities are folded before interning: a bech32 address is lowercased (BIP 173 forbids mixed case, so this is safe and idempotent), base58 and non-addresses are kept byte-exact. One payout address is one TIDES row, however each rig's config cases it.
- A gateway's txcount convention (does
txn_countinclude the coinbase?) is learned from the first share that verifies on a job and pinned for the slot, soVerifiedShare::commitmentis always the header the miner actually ground. - What a connection can make the Prime hold is bounded:
max-connections(256) andmax-connections-per-ip(8) at accept; a coinbase section over 20 000 bytes or a ninth coinbase id in a slot is refused; only the 16 most recently started job slots keep their sections; andsession-coinbase-budget(4 MiB) caps the total. Coinbaser requests are token-bucketed (32, refilled one per second) because each one is a full split over the window under the ledger lock, and a session with 2 000 rejects or malformed messages in 10 s is dropped. Release builds keepoverflow-checkson. - The Prime submits every candidate block to its own node as well as trusting the gateway to.
duplicatefromsubmitblockis the expected outcome and is recorded as such;inconclusivemeans a valid block that is not (yet) the best tip. Records settle when the node reports positive confirmations; a record calledorphan:is re-checked for 100 blocks and un-labelled if a reorg brings it back.
MIT — see LICENSE, which also records provenance. This is not derived from
Ratum (AGPL-3.0) and contains none of its code. The protocol was recovered from the
MIT-licensed DATUM Gateway trees named there.