igneum/docs/design/pool-vote-key-commitment.md

245 lines
26 KiB
Markdown

# 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 || pubkey (48) || pop (96)` in the coinbase extra data; the node checks the proof of possession and that its hash equals the header's `vote_key_hash` | Implemented |
| 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:
1. 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.
2. 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.
3. 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:
1. The share carries the key. The `share` message gains `vote_key_hash` (32 bytes hex) beside `job_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 whose `vote_key_hash` is not the member's authorised key is a
template the pool cannot issue a job on at all (`job` MUST carry `vote_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.
2. 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 code `vote_key`. Designed (spec 9.4.1 item 1 already; the job field makes it
checkable without the template).
3. 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 on `pool-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 own `IGNA` from `pool_split_activation_daa` on. 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:
1. 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.
2. 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.html` Governance, "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: `job` and `share` gain `vote_key_hash`; 9.4.1 item 1 reads "the job's `vote_key_hash` and the
template's are the member's own key"; 9.8 item 5 adds the comparison.
### 5.3 The code, in order
1. The pool daemon: `vote_key_hash` on `job` and `share` (`pool/src/protocol.rs`), checked in `verify::check`
against 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.
2. 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.
3. Retire `placeholder_vote_key_hash` from 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 a `MissingVoteKeyHash`.
4. The explorer's concentration page: keys per pool `IGNA` and the share of blocks each holds, so A5 is visible as the
design intends (ledger X14).
5. 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` |