Reward detection: refuse dust coinbase outputs so a block builder cannot force an epoch rotation #38

Open
opened 2026-10-09 18:31:43 +01:00 by vnprc-agent · 1 comment
Collaborator

Make the mint's reward detection refuse rewards that the pool did not actually earn, so that nobody can force an epoch rotation by putting a dust output to the mint's receive script in a coinbase they built, which would close the current epoch against a worthless reward and strand the value of every miner's shares in it.

Primary goals:

  • A dust coinbase output is not a reward — a matching output counts only when it is the block's real payout: at least the subsidy at that height (or a configured fraction of it) and the largest output of the coinbase. Below that it is logged and ignored, and no epoch opens.
  • The floor is explicit config — the threshold is a [hashpool_mint] setting with a safe default for a pool that mines whole blocks, so a proxy deployment that legitimately receives a fraction can lower it deliberately rather than the code guessing.
  • Authenticated rewards are the end state — explore an authenticated coinbase tag for aggregator pools and a signed reward attestation from the upstream pool for proxy pools, so a reward is accepted because it is provably the pool's, not merely because it is large.
  • It lands before ehash has settlement value — the trigger is the settlement milestone: once old-epoch ehash redeems for bitcoin, a forced rotation is theft rather than an annoyance, so this must be merged before any mint that settles runs the automatic trigger.

Current state and specifics

On the reward-trigger branch (vnprc/hashpool#37, implementing vnprc/hashpool#18) the watcher in roles/mint/src/lib/epoch/mod.rs reads only the coinbase of each new block (coinbase_reward fetches tx[0]), sums every output whose script equals the configured receive_address script, and treats any positive sum as a reward: process_block opens a provisional epoch at that height. An ordinary transaction to the receive address therefore cannot trigger a rotation. A coinbase output can, and any block builder controls their own coinbase outputs: a competing pool, a solo miner, or a miner on this pool who also builds blocks elsewhere can add a 1-sat output to the mint's receive script. The mint then closes the current epoch, opens hash_<pool>_<height> with reward_sats = 1, and every quote created afterwards stamps into the new unit. Under the settlement model, the closed epoch's ehash is backed by a 1-sat reward, so the work behind it is worth nothing, while whoever holds shares in the next epoch captures the pool's next real reward undiluted. The attacker's cost is the dust plus nothing else. Dissolve does not help, since the dust block is canonical.

The economics of the floor: with the floor at the block subsidy, forcing a rotation means paying the mint at least a full subsidy, which is a real reward that the closed epoch's miners are paid from. The attack becomes a donation. Regtest subsidy is 50 BTC >> (height / 150); mainnet is the usual 210,000-block schedule; the mint already knows the height and the network from the node. A largest output rule on top closes the case where a builder splits the coinbase to put the floor amount on the mint and keep fees elsewhere, which is harmless economically but keeps the rule simple: the mint's output is the payout, or it is not a reward.

What the floor does not do: it cannot tell the pool's own block from another pool's block that happens to pay the mint a full reward, and it does not apply to a proxy pool paid a fraction by an upstream. Two exploratory directions, each its own follow-up once this issue lands:

  • Aggregator pools, authenticated coinbase tag. The pool already writes pool_signature into the coinbase scriptSig, but a plain string is forgeable. A tag the mint can verify, for example a signature by the pool identity key (the pool_pubkey that namespaces epoch units) over the height, or an HMAC under a pool-mint shared secret, in an OP_RETURN output or the scriptSig, would make a forged reward impossible. This overlaps with the deferred "keyset id in the coinbase" item in docs/EPOCH_DESIGN.md and with the template-validation gate of vnprc/hashpool#20, which already plans to gate payment on "boundary final AND template valid"; one commitment in the coinbase can serve both.
  • Proxy pools, signed reward attestation. When the reward arrives from an upstream pool over lightning or as an ordinary payout, there is no coinbase to inspect. The reward event abstraction in the watcher already anticipates a payer-supplied height and pool identity; the missing piece is a signature by the upstream pool's identity key over (height, block hash, amount, recipient), verified against a configured upstream key before the event opens an epoch. Until that exists, a proxy deployment is protected only by the amount floor.

Scope

In scope here: the amount floor and largest-output rule in coinbase_reward/process_block, with a [hashpool_mint] setting (default: the subsidy at that height; a fraction knob for proxy deployments), a unit test for a dust output, a split coinbase and a full reward, and a regtest witness: mine a block whose coinbase pays the mint 1 sat (generateblock with a custom coinbase, or a second regtest wallet building the block) and confirm no epoch opens and the log says why, then generatetoaddress 1 <mint> and confirm the normal rotation. Exploratory, tracked as follow-ups once filed: the authenticated coinbase tag and the upstream attestation, which both need a design decision on key material before implementation.

Part of vnprc/hashpool#13. Depends on vnprc/hashpool#37 landing. Related: vnprc/hashpool#20.

Make the mint's reward detection refuse rewards that the pool did not actually earn, so that nobody can force an epoch rotation by putting a dust output to the mint's receive script in a coinbase they built, which would close the current epoch against a worthless reward and strand the value of every miner's shares in it. Primary goals: - **A dust coinbase output is not a reward** — a matching output counts only when it is the block's real payout: at least the subsidy at that height (or a configured fraction of it) and the largest output of the coinbase. Below that it is logged and ignored, and no epoch opens. - **The floor is explicit config** — the threshold is a `[hashpool_mint]` setting with a safe default for a pool that mines whole blocks, so a proxy deployment that legitimately receives a fraction can lower it deliberately rather than the code guessing. - **Authenticated rewards are the end state** — explore an authenticated coinbase tag for aggregator pools and a signed reward attestation from the upstream pool for proxy pools, so a reward is accepted because it is provably the pool's, not merely because it is large. - **It lands before ehash has settlement value** — the trigger is the settlement milestone: once old-epoch ehash redeems for bitcoin, a forced rotation is theft rather than an annoyance, so this must be merged before any mint that settles runs the automatic trigger. ### Current state and specifics On the reward-trigger branch (vnprc/hashpool#37, implementing vnprc/hashpool#18) the watcher in `roles/mint/src/lib/epoch/mod.rs` reads only the coinbase of each new block (`coinbase_reward` fetches `tx[0]`), sums every output whose script equals the configured `receive_address` script, and treats any positive sum as a reward: `process_block` opens a provisional epoch at that height. An ordinary transaction to the receive address therefore cannot trigger a rotation. A coinbase output can, and any block builder controls their own coinbase outputs: a competing pool, a solo miner, or a miner on this pool who also builds blocks elsewhere can add a 1-sat output to the mint's receive script. The mint then closes the current epoch, opens `hash_<pool>_<height>` with `reward_sats = 1`, and every quote created afterwards stamps into the new unit. Under the settlement model, the closed epoch's ehash is backed by a 1-sat reward, so the work behind it is worth nothing, while whoever holds shares in the next epoch captures the pool's next real reward undiluted. The attacker's cost is the dust plus nothing else. Dissolve does not help, since the dust block is canonical. The economics of the floor: with the floor at the block subsidy, forcing a rotation means paying the mint at least a full subsidy, which is a real reward that the closed epoch's miners are paid from. The attack becomes a donation. Regtest subsidy is `50 BTC >> (height / 150)`; mainnet is the usual 210,000-block schedule; the mint already knows the height and the network from the node. A `largest output` rule on top closes the case where a builder splits the coinbase to put the floor amount on the mint and keep fees elsewhere, which is harmless economically but keeps the rule simple: the mint's output is the payout, or it is not a reward. What the floor does not do: it cannot tell the pool's own block from another pool's block that happens to pay the mint a full reward, and it does not apply to a proxy pool paid a fraction by an upstream. Two exploratory directions, each its own follow-up once this issue lands: - **Aggregator pools, authenticated coinbase tag.** The pool already writes `pool_signature` into the coinbase scriptSig, but a plain string is forgeable. A tag the mint can verify, for example a signature by the pool identity key (the `pool_pubkey` that namespaces epoch units) over the height, or an HMAC under a pool-mint shared secret, in an OP_RETURN output or the scriptSig, would make a forged reward impossible. This overlaps with the deferred "keyset id in the coinbase" item in `docs/EPOCH_DESIGN.md` and with the template-validation gate of vnprc/hashpool#20, which already plans to gate payment on "boundary final AND template valid"; one commitment in the coinbase can serve both. - **Proxy pools, signed reward attestation.** When the reward arrives from an upstream pool over lightning or as an ordinary payout, there is no coinbase to inspect. The reward event abstraction in the watcher already anticipates a payer-supplied height and pool identity; the missing piece is a signature by the upstream pool's identity key over (height, block hash, amount, recipient), verified against a configured upstream key before the event opens an epoch. Until that exists, a proxy deployment is protected only by the amount floor. ### Scope In scope here: the amount floor and largest-output rule in `coinbase_reward`/`process_block`, with a `[hashpool_mint]` setting (default: the subsidy at that height; a fraction knob for proxy deployments), a unit test for a dust output, a split coinbase and a full reward, and a regtest witness: mine a block whose coinbase pays the mint 1 sat (`generateblock` with a custom coinbase, or a second regtest wallet building the block) and confirm no epoch opens and the log says why, then `generatetoaddress 1 <mint>` and confirm the normal rotation. Exploratory, tracked as follow-ups once filed: the authenticated coinbase tag and the upstream attestation, which both need a design decision on key material before implementation. Part of vnprc/hashpool#13. Depends on vnprc/hashpool#37 landing. Related: vnprc/hashpool#20.
Author
Collaborator

Scoping decision from the owner, 2026-10-09: the minimum viable pool runs without job declaration, so miners mine only on templates the pool builds. That is tracked as vnprc/hashpool#39 (the pool refuses SetCustomMiningJob, and the JD server and client leave the stack).

What that changes here: the "is this reward block ours" question becomes a lookup against the pool's own template log, since the pool built every coinbase its miners could have found, so the exploratory authenticated-coinbase-tag direction above is deferred, and the proxy-pool attestation is out of the MVP (no upstream exists). What it does not change: the dust attack from block builders who are not on this pool at all, which is what the amount floor and largest-output rule in the implementation-ready part of this issue close. The floor stays the deliverable here; the pool-side template match is a follow-up once vnprc/hashpool#39 lands.

Scoping decision from the owner, 2026-10-09: the minimum viable pool runs without job declaration, so miners mine only on templates the pool builds. That is tracked as vnprc/hashpool#39 (the pool refuses `SetCustomMiningJob`, and the JD server and client leave the stack). What that changes here: the "is this reward block ours" question becomes a lookup against the pool's own template log, since the pool built every coinbase its miners could have found, so the exploratory authenticated-coinbase-tag direction above is deferred, and the proxy-pool attestation is out of the MVP (no upstream exists). What it does not change: the dust attack from block builders who are not on this pool at all, which is what the amount floor and largest-output rule in the implementation-ready part of this issue close. The floor stays the deliverable here; the pool-side template match is a follow-up once vnprc/hashpool#39 lands.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
vnprc/hashpool#38
No description provided.