26 KiB
Pool vote-key commitment: the member's key stays with the member, at protocol level
Design document, Igneum 2.0, "Pools, software and participation", first bullet. Written 8 October 2026 (evening, UK)
by the adversarial seat, from the code and the specification as they stand on the box mirror's master (d6bee526) and
the pool lane's branches (pool-finish-22 bee1f5f2, the fork's pool-tags-node 7455b8d5). Design only: no consensus
code changes here, and the pool lane's 0.3.20 branches are not touched. Status labels follow the specification's:
Implemented (in code on a named branch), Designed (written, not run), Measured (a number with its log).
The pin this document serves, in one sentence: a miner's finality vote key is committed into every share and every block the miner's hardware produces, whichever pool distributes the work and collects the pay, so a pool's share of hashrate never becomes a pool's share of votes unless the miner hands over the key on purpose, and that hand-over is visible on chain.
1. The current state, from the code
1.1 Where the vote key is bound today
| Binding | Where | What it says | Status |
|---|---|---|---|
| The header carries the key | vendor/igneum-node/consensus/core/src/header.rs:174, pub vote_key_hash: Hash |
32 bytes, BLAKE2b under the domain IgneumVoteKeyHash of the 48-byte compressed G1 BLS key (spec 03 W1, table row W1). Zero only in genesis; any other header without it is RuleError::MissingVoteKeyHash |
Implemented |
| Proof of work commits the key | consensus/core/src/hashing/header.rs:28, .update(header.vote_key_hash) in the header hash |
The pre-PoW hash (the prehash every job carries) is a hash over every header field but the nonce, vote_key_hash among them (spec 02 fork point a5). A nonce that solves one prehash solves no other, so a share or a block is work on exactly one key |
Implemented |
| The key is revealed once | consensus/core/src/finality.rs:31 (IGNK), :259 (KeyReveal, W1) |
The first block under a key carries `IGNK | |
| Weight reads the header | spec 03 W2 to W4; the node's finality module (docs/fork-divergence.md, "Finality v2") |
A key's 30-day weight is the blue blocks whose header names it. Nothing in weight reads the coinbase beyond the reveal | Implemented |
| The miner sets it | vendor/igneum-node/igneum/miner/src/main.rs:480, raw.header.vote_key_hash = id.key_hash; :74, the reveal into extra data |
The solo miner writes its own identity into every template it hashes | Implemented |
| A placeholder survives | header.rs:257, placeholder_vote_key_hash(payout_script) |
Devnet v0's stand-in, a hash of the payout script; any non-zero value was accepted "until the finality layer lands". The finality layer landed; the function remains callable | Implemented, to retire (5.3) |
1.2 Where the pay is bound today
| Binding | Where | What it says | Status |
|---|---|---|---|
| The coinbase names the payee | consensus/core/src/evm.rs, miner_address_extra_data (IGNA + 20 bytes) |
The execution layer credits 80 percent of a blue block's subsidy to the coinbase's IGNA address (pool/src/payout.rs:1), the other 20 percent to the proving pool escrow |
Implemented |
| The pool pays the pool | pool/src/node.rs:32, extra_data(member, pool_address): the member's reveal plus the pool's IGNA; :42, get_block_template(pool.pay_address, ...); :47, raw.header.vote_key_hash = member.key_hash |
Pool v0 builds one template per member: the member's key in the header, the pool's address in the coinbase. The chain pays the pool; the pool's ledger (pool/src/state.rs, pplns.rs) splits it by PPLNS and pays balances at or above min_payout in rounds of at most 16 transfers (payout.rs:198) from the pool's own key (payout-key.json, payout.rs:20) |
Implemented (pool-0, pool v0) |
| The open pool pays by a split in the block | fork pool-tags-node 7455b8d5: evm.rs IGNH (the share chain's parent) and IGNW (the window's split, count x (address 20 bytes, weight 4 bytes)), split_producer, the switch pool_split_activation_daa (never by default); pool/src/sidechain.rs, open.rs, p2p.rs |
A share is a real template at the share chain's target with the member's own key and the member's own IGNA; a block pays the PPLNS window (2,160 shares) by IGNW, computed by every node from the block alone; nobody holds a balance or an operator key (pool.md 10.4) |
Implemented on the branch, gated on Devnet 3 (pool.md 10.5: 100 members on 10 daemons, 90 of 90 honest members paid, 0 of 864 honest blocks paying a withheld address, drop proof by verify-share); the switch never set on any live chain |
1.3 Where the pool's identity is bound today, and the member's defences
| Item | Where | Status |
|---|---|---|
| The member names itself by its key | pool/src/protocol.rs:42, authorize {pubkey, pop, label, payout, binding}; spec 9.3, the binding over the TLS exporter (O-9.7, closed on pool-finish-22 10.2) |
Implemented |
| The member refuses a template that names another key | spec 9.4.1 item 1; job_refused code vote_key (protocol.rs, pool.md section 2 "Member checks") |
Implemented on the member side of the fork (igneum/miner/src/pool.rs) |
| The member refuses a coinbase that pays a third party or lacks its reveal | spec 9.4.1 item 2; codes payout, reveal |
Implemented |
| The pool holds no key | spec 9.6 item 1; pool.md section 5 "Votes": every member voted through its own node, the pool held no key, 3 keys under one payout address on chain | Implemented, Measured on the devnet |
| Custodial mode exists and is disclosed | spec 9.6 item 5, welcome.vote_mode in {member, pool}; a client defaults to refusing pool |
Designed; pool v0 does not offer it (pool.md section 3) |
| The litepaper says the opposite | site/litepaper.html:702, Governance: "Pools can decline, and vote keys stay with the pool"; ledger G6 (conceded 5 October 2026), O-9.1 |
A stated line about the custodial case, written before pool v0 ran with member keys. Under 2.0 it flips (5.2) |
So the first half of the pin is already true in code: the header commits the key, proof of work commits the header, and the only pool that exists names its members' keys. What 2.0 adds is the second half (payment aggregation separate and verifiable by any node, the pool unable to substitute without the member's share failing even against a member who does not check), and the public words.
2. The design
2.1 Where the commitment lives: the header, not the coinbase
The member's vote key is committed in the header field vote_key_hash, as today, and nowhere else is required. Why
the header and not a coinbase commitment:
- Proof of work is over the header. A share is a nonce whose lane hash under the job's prehash is at or below the
share target (spec 9.8 item 1), and the prehash is the header hash with the key in it. A coinbase commitment is
also under proof of work, through
hash_merkle_root, but only transitively: to check it the member needs the coinbase bytes and a Merkle path (mode B) or the whole body (mode A), while the header field is one 32-byte comparison on bytes the member hashes anyway. The cheapest check is the one every client will run. - Consensus already enforces the field (non-zero, revealed once, equal to the reveal's hash) and weight already reads it. A coinbase commitment would be a second place to keep in step with the first.
- The key is per block, the coinbase is per body. Mode C (declared templates) and the open pool build the body themselves; the header field is the same in every mode, so the binding does not depend on which side built the body.
The coinbase keeps what is not needed per share: the one-time reveal (IGNK), the finality section (IGNF, the votes
carried), the payee (IGNA), and on the open pool the share chain's parent (IGNH) and the window's split (IGNW).
2.2 The share is the commitment, and the pool cannot forge it
A share the pool accepts is work on a prehash. The prehash commits to vote_key_hash. So for a pool to credit a
member's work while naming any other key in the block, the pool would need a nonce that solves a prehash the member
never hashed, which is a fresh block's worth of work per block. There is nothing to add here for a conforming member.
The gap is the non-conforming member: a client that hashes whatever prehash it is given. Against it the pool can put its own key in the header and the member's work becomes the pool's weight. The design closes this at the protocol's own level rather than the client's:
- The share carries the key. The
sharemessage gainsvote_key_hash(32 bytes hex) besidejob_id,nonce,hash, and the pool MUST verify the share against a prehash whose header carries that key: the pool rebuilds the prehash from the template it issued, and a template whosevote_key_hashis not the member's authorised key is a template the pool cannot issue a job on at all (jobMUST carryvote_key_hash, and a conforming member checks it before any other field). Designed. Cost: 32 bytes on a line that is under 120 bytes today, one comparison. - The authorised key is the only key. A session is one key (
authorize); every job and every share of the session names it; a pool that issues a job under another key to that session is non-conforming on its face, and the member's log says so with the codevote_key. Designed (spec 9.4.1 item 1 already; the job field makes it checkable without the template). - On the open pool there is no pool to forge: every share is a full template the member built on its own node with
its own key and its own
IGNA; the daemons check structure, seeds and proof of work (open.rs), and a share whose header names a key other than the one that signed the daemon session is refused before it enters the chain. Implemented onpool-finish-22; the session-key check is to confirm there (5.3).
2.3 Payment aggregation stays at the pool, separate and verifiable
Two shapes, both kept:
- The operated pool (pool-0, pool v0): the chain pays the pool's
IGNA; the pool pays members by PPLNS from its own balance. The key is the member's, the money is the pool's until it pays. This is the Bitcoin pool shape; it is allowed and it stays, because a member without a node can join nothing else (pool.md 10.5, "A pool user without a node cannot join the open pool"). What makes it verifiable: every block on chain carries(vote_key_hash k, IGNA A), so any node computes, per pool address A, the set of keys that found its blocks and each key's count, which is exactly each member's share of the pool's income under PPLNS at the block level. A pool that underpays a member by blocks is caught from the chain; a pool that underpays by shares is caught only by the member's own share ledger (spec 9.2, "Underpay shares"), which is the operated pool's known limit and the open pool's reason to exist. - The open pool (no operator): the block's coinbase carries the window's split (
IGNW) and the chain pays the split directly to every member's ownIGNAfrompool_split_activation_daaon. Aggregation is the split; verification is every node's execution of the block; no balance exists anywhere. Implemented and gated on Devnet 3; the switch is never set until the P2 mechanism or the override sets it, as the fee switch was.
In both shapes the vote key and the payee are different fields written by different parties on purpose: the key is the member's and only the member's client writes it into a template it will hash; the payee is the pool's (operated) or the member's own (open). A pool that writes a member's key into its own hashers' templates gains nothing (the member's weight rises, not the pool's) and loses the blocks' weight for itself, which is why no pool does it.
2.4 The share sidechain's role
The share sidechain (pool.md 10.4; spec 09 section 9.12 on the branch) is the open pool: shares at a 10-second target, one parent each, heaviest work wins, a PPLNS window of 2,160 shares, the window's split stamped into every coinbase so a found block pays the window. Its role for this pin is twofold:
- It is the only shape in which payment is verifiable from the chain alone at the share level: a member can prove
a dropped share with
verify-share(pool.md 10.5, "Drop proof"), and a block's split is computed by every node. - It removes the custodian of money as well as of keys, so a home miner's whole relationship with pooling is its own node, its own key, its own address and a gossip socket.
What it is not: it is not required for the vote-key pin. The operated pool satisfies the pin with 2.2 alone. The sidechain is the stronger payment story, with a measured cost: every block's coinbase grew by 48 bytes per window entry (pool.md 10.5, "The network"), the stale rate was 9.6 percent at 2 shares a second with ten daemons on one host, and a public chain across the internet loses more to forks (the uncles row, 10.6, open).
2.5 What Stratum V2 job declaration supplies and what it does not
Stratum V2's Job Declaration Protocol (Braiins and the Stratum V2 working group; the lineage line of spec 09) lets a hasher build its own block template, declare it to the pool, and be paid for shares on it, so the pool no longer chooses the transactions. That is Igneum's mode C (spec 9.4.2) and it is in pool v0 as a required mode.
It supplies: transaction choice, and with it the hasher's own coinbase extra data (so on Igneum a declared template carries the member's reveal and votes by construction).
It does not supply: an identity in the header. Stratum V2 has no header field for a voter, because Bitcoin has no voter; the pool still sets the payout and, in the standard mining protocol without declaration, the whole template. Job declaration is optional for pools, pools can decline (ledger G6 is right about that), and a pool that declines puts the hasher back on the pool's template. On Igneum that template still carries the hasher's key in the header (2.2), so declining job declaration changes transaction choice and nothing about votes. The sentence for the site is in 5.2.
3. The attack list, as the adversarial seat tried it
Each row: the attack, what stops it in the design above, how it shows, and whether it survives.
| # | Attack | What stops it | How it shows | Survives? |
|---|---|---|---|---|
| A1 | Pool substitution: the pool names its own key in the member's header | The prehash commits the key; a conforming member refuses the job (vote_key); under 2.2 the job and the share name the key and a share on another key is unverifiable against the issued template |
Every block under the pool's IGNA names one key; the explorer's concentration page (ledger X14) shows one key with the pool's whole hashrate |
No, against a conforming member. Yes, against a non-conforming client by the client's consent: see A5 |
| A2 | Key reuse across members: one key authorised by many sessions, so one voter holds many members' weight | Authorisation needs the proof of possession and the TLS-exporter binding, so only the secret's holder opens a session; several sessions under one secret are one operator's rigs (spec 9.6 item 3), which is the design. A pool cannot reuse a member's key without the secret | Nothing wrong on chain: one key, its own blocks | Not an attack: weight follows the secret's holder, as intended |
| A3 | Split identities: an operator or a pool spreads its hashrate over many keys | W6: keys are free and weight is blocks, so splitting moves no weight; dust (W3, 100 blocks in 30 days) silences the small keys. A pool splitting its own rigs over many keys to look like many members fools a member count, not a weight table | Many keys with the same IGNA and the same uptime pattern; cosmetic |
Not an attack on votes; a presentation issue for pool pages |
| A4 | Withheld votes: the pool drops a member's votes from relay and carriage | Three roads (spec 9.7 item 5): the member's own node, submitFinalityVote on any node, and the pool's template; a member with a verifier signs from it, not from the pool's checkpoint; a member without a verifier does not vote at all |
The carriage ratio per key against the pool's blocks (9.7 item 6, O-9.6, threshold open) | Survives only for a member whose only road is the pool, which the specification forbids from voting in the first place |
| A5 | Custody by terms of service: a pool whose terms require the member's key, or a modified client that accepts vote_mode: pool |
Nothing at protocol level stops a holder giving a key away, and the specification allows the disclosed custodial mode (9.6 item 5). What the protocol does: the custodial pool's blocks all name the pool's key, so its vote share equals its hash share and both are public; the official client refuses the mode by default and shows it before the first share | One key under the pool's IGNA with the pool's whole hashrate; the ledger's "pools hold their hashers' votes" (spec 03 3.7 item 3) is then true of that pool |
YES. This is the cheapest surviving attack: it costs the pool a terms line and a client fork, and it is bounded only by members' willingness and by the public reading of it |
| A6 | Block withholding by the pool: the pool drops a block a member found | The member holds the full block (mode A or C) and submits it to its own node before or beside solution (spec 9.5) |
The block is on the chain whatever the pool did | Survives for a member with no node (mode A, no verifier): that member loses the block's weight and the pool loses the income, so the pool has no motive beyond harming the member |
| A7 | Pay-to-third-party: the pool's template names an IGNA that is not the pool's announced address |
Spec 9.4.1 item 2, code payout |
A refusal in the member's log | No |
| A8 | Induced equivocation: the pool feeds two checkpoint hashes at one index | The member signs only a hash its own verifier reports (9.7 item 2); a verifier-less member does not sign | None | No |
| A9 | Session replay: a captured authorize replayed to open a session under a member's key |
The binding over the TLS exporter (O-9.7, closed on pool-finish-22 10.2); a replayed authorize on another connection is refused |
A refusal in the pool's log | No, since TLS landed; the test that a replay is refused is the one to keep green (5.3) |
| A10 | Share theft between members: a member submits another member's share under its own session | The share names the key and the pool verifies it against the job it issued to that session; a nonce on another member's prehash hashes to a different value (verify.rs, "the same nonce on another template hashes differently") |
wrong_hash |
No |
| A11 | The pool mines its own rigs under a member's key to inflate that member | The pool gives weight away and keeps no income advantage; the member's IGNA is not paid (the pool's is), so the member sees blocks under its key paid to the pool, which is the normal case |
Nothing abnormal | Not an attack: a gift of weight |
| A12 | Open pool: a daemon stamps a share chain parent or a split that favours itself | Every member checks the coinbase before hashing (IGNH the chain's tip, IGNW the window the member computes itself); a block with a wrong split is a block the honest members never hashed |
The withholder's branch on Devnet 3: 0 of 864 honest blocks paid it | No |
The cheapest one that survives is A5, custody by consent. It is not defeated by cryptography because it is not a forgery; it is defeated by defaults (the client refuses), by visibility (one key per pool address on the explorer's concentration page), and by the pool market (a non-custodial pool offers the same income). The design makes custody a public, deliberate choice rather than the silent default it is on every chain with pooled voting; that is the whole of what "at protocol level" can mean for a key its owner may give away.
A note on what was not found: no path by which a pool gains weight from a member's work without that member's client consenting, before or after 2.2. The weight table reads headers, headers are under proof of work, and proof of work is per key.
4. The cost on the honest side
| Cost | Value | Status |
|---|---|---|
| Header bytes | 0 new: vote_key_hash is 32 bytes in the header today |
Implemented |
| Coinbase bytes | the reveal 148 bytes once per key (IGNK 4 + 48 + 96); IGNA 24 bytes per block; the finality section per block as today; on the open pool IGNH 36 bytes and IGNW 4 + 24 per window entry (48 bytes per entry measured on Devnet 3 with the hex framing) |
Implemented, Measured for the open pool |
| Share and job bytes (2.2) | 32 bytes hex (64 characters) on each, so a share of under 120 bytes becomes under 190 and a job of under 300 under 370 |
Designed |
| Verification per block | one 32-byte comparison on every block (W1), one BLS proof-of-possession check on a reveal block (once per key); weight accounting as today | Implemented |
| Verification per share, pool side | one 32-lane group evaluation, 0.441 ms per group on one M5 Max core (spec 9.8 item 5, Measured), 1.35 ms in isolation and 2.1 ms under load on a rented 2 vCPU box (pool.md section 5); 2.2 adds a comparison | Measured |
| The home miner's bandwidth, operated pool | one share per 10 s and one job per template: under 0.1 kbit/s for shares; the template is the cost, a few KB to a few hundred KB per second at 1 block per second in mode A (spec 9.5 sizes, Designed, O-9.5); mode B carries the header, the coinbase and a Merkle path; mode C carries nothing down and one template up per declaration |
Designed, unmeasured at a public pool |
| The home miner's bandwidth, open pool | one share per 10 s per member gossiped to every daemon it peers with: at 100 members, 10 shares a second of about one template each; the Devnet 3 gate ran 100 members on 10 daemons on one host, so the internet cost is unmeasured (10.6) | Measured on one host only |
| The accepted-work penalty of home internet against a datacentre link | the second bullet of the 2.0 Pools section; unmeasured here; it is the stale rate's dependence on round-trip time at stale_grace_ms 2,000 ms and two block times, to be measured with a member behind a home connection against one beside the pool |
Open (6) |
5. Migration note for the pool lane's branches
5.1 Rebase first
The pool lane's branches on the box mirror against release-2.0.0 (b891444f, the version bump, 16:29 BST today):
| Branch | Ahead | Behind | Carries |
|---|---|---|---|
pool-finish-22 |
1 | 112 | the open pool's Devnet 3 hour and the daemon's reconnect |
pool-mf-row-2 |
3 | 102 | the pool page rows |
pool-finish |
11 | 572 | pool-0, TLS, the share sidechain |
pool-v0-rebase |
4 | 925 | the pool-mode miner on the fork (the three commits the shipper needed for 0.3.20) |
pool-v0 |
3 | 1,691 | the original v0 |
release-0.3.20 |
0 | 539 | the release line the pool lane landed on |
The fork side: pool-tags-node 7455b8d5 (IGNH, IGNW, split_producer, the switch) and pool-finish-node's own
commits (the binding in finality.rs, the params field, the executor's split, the miner's TLS and rung). Every one of
these is rebased onto release-2.0.0 (and the fork's 2.0 line) before any of this document's items is applied; the
version bump is rule 15's and a branch that carries 0.3.x strings is not landed on 2.0.
5.2 The words
site/litepaper.htmlGovernance, "Pools can decline, and vote keys stay with the pool" becomes: "Pools can decline job declaration; the vote key stays with the miner in every mode, named in the header of every block its hardware finds, and a pool that asks for custody says so and is shown as one key." Closes O-9.1 and the G6 cross-reference in the ledger.- spec 03, 3.7 item 3 ("Pools hold their hashers' votes") is rewritten as the custodial case only, with 9.6 item 5 named.
- spec 09, 9.5:
jobandsharegainvote_key_hash; 9.4.1 item 1 reads "the job'svote_key_hashand the template's are the member's own key"; 9.8 item 5 adds the comparison.
5.3 The code, in order
- The pool daemon:
vote_key_hashonjobandshare(pool/src/protocol.rs), checked inverify::checkagainst the job's template; the member side (igneum/miner/src/pool.rs) checks the job field before the template. A test with a known-pass (the member's key) and a known-fail (another key on the job) per the standing rule. - The open pool: confirm the daemon refuses a share whose header key is not the session's key (
open.rs), with the same pair of tests. - Retire
placeholder_vote_key_hashfrom the fork's template path once no caller remains (today the miner writes the real hash over it); keep the consensus rule that a zero hash is aMissingVoteKeyHash. - The explorer's concentration page: keys per pool
IGNAand the share of blocks each holds, so A5 is visible as the design intends (ledger X14). - The replay test of A9 and the drop proof of A12 stay in the pool crate's tests on 2.0.
6. Open questions, named for main
| Id | Question | What closes it |
|---|---|---|
| Q1 | Does the share carry vote_key_hash (2.2 item 1, 64 more characters per share) or does the job alone (item 2) suffice, the share being bound through job_id? The share form lets a pool's log stand on its own as evidence; the job form is cheaper |
A decision; the adversarial seat's preference is the share, for the evidence |
| Q2 | The accepted-work penalty for home internet against a datacentre connection (the 2.0 bullet) has no measurement: the stale rate at 2,000 ms grace against round-trip time | One rented member behind a home-class link (a residential proxy or a PC on home broadband) against one beside the pool, an hour each, the stale rates per member |
| Q3 | Mode A template bandwidth per member at 1 block per second on a public pool (O-9.5) | The same hour's bytes on the member's socket |
| Q4 | The open pool across the internet: stale rate and uncles (pool.md 10.6) | The Devnet 3 gate re-run with daemons on three regions |
| Q5 | Whether the custodial mode (vote_mode: pool) remains allowed at all under 2.0, or is removed from the specification so that A5 requires a non-conforming pool as well as a non-conforming client |
A ruling; removing it does not stop A5 (a fork of the pool is as cheap as a fork of the client) but changes who is non-conforming |
| Q6 | Minimum payouts on the operated pool: min_payout_ign is the pool's parameter; the 2.0 bullet asks for "practical" ones, which needs the fee-per-transfer at 2.0's fee level |
The execution lane's fee number, then a floor in share_scheme |