Hey everyone,
I'm warioishere — some of you might know me from the Bitaxe SV2 firmware work. I've been building a Stratum V2 pool server in TypeScript (Blitzpool) and recently started implementing PPLNS with direct non-custodial coinbase payouts.
The idea: instead of paying miners through a pool wallet, every miner gets their payout directly in the coinbase transaction. Trustless, transparent, no custodial risk. Combined with JDP, miners choose their own transactions while the pool controls the payout distribution — transaction selection stays decentralized, payouts are verifiable on-chain.
The problem
Everything works on the pool side — we send multiple outputs via coinbase_tx_outputs in AllocateMiningJobToken.Success, and the spec already allows this:
"JDS MAY add more 0 value outputs in addition to the pool payout output"
The JDC also correctly parses multiple outputs (thanks to bitcoin::consensus::deserialize). However, the value allocation is hardcoded to put the entire block reward into outputs[0]:
// template_message_handler.rs
coinbase_outputs[0].value = Amount::from_sat(msg.coinbase_tx_value_remaining);
For a single pool payout address this makes sense. But for PPLNS with 50+ miner outputs, we need the reward distributed proportionally across all outputs.
Proposed solutions
I see two clean ways to solve this. Both are backwards compatible.
Option A: Reuse the value field as proportional weights
The pool sends outputs with non-zero values that represent proportional weights:
Output 0 (pool fee): value = 200 → 2%
Output 1 (miner A): value = 4900 → 49%
Output 2 (miner B): value = 4900 → 49%
The JDC distributes coinbase_tx_value_remaining proportionally:
let total_weight: u64 = outputs.iter().map(|o| o.value.to_sat()).sum();
if total_weight > 0 {
let mut remaining = coinbase_tx_value_remaining;
for (i, output) in outputs.iter_mut().enumerate() {
if i == outputs.len() - 1 {
output.value = Amount::from_sat(remaining);
} else {
let allocated = (output.value.to_sat() as u128
* coinbase_tx_value_remaining as u128
/ total_weight as u128) as u64;
output.value = Amount::from_sat(allocated);
remaining -= allocated;
}
}
} else {
// Backwards compatible: all zero → current behavior
outputs[0].value = Amount::from_sat(coinbase_tx_value_remaining);
}
Pros: No spec change needed, no new fields, ~15 lines JDC change.
Cons: Overloads the meaning of the value field — it's supposed to be sats, not weights. Could be confusing.
Option B: New field coinbase_tx_output_weights (cleaner)
Add an optional field to AllocateMiningJobToken.Success:
| Field Name | Data Type | Description |
| ----------------------------- | ---------------- | --------------------------------------------------------------------------- |
| coinbase_tx_output_weights | SEQ0_64K[U32] | Proportional weights for each output in coinbase_tx_outputs. If empty, |
| | | all reward goes to the first output (current behavior). |
The pool sends:
coinbase_tx_outputs: [TxOut(0, script_fee), TxOut(0, script_minerA), TxOut(0, script_minerB)]
coinbase_tx_output_weights: [200, 4900, 4900]
The JDC distributes coinbase_tx_value_remaining based on the weights. If the field is empty or missing, it falls back to the current behavior (everything into output[0]).
Pros: Clean separation of concerns. Value field stays as sats. Explicit intent. Easy for other implementations to understand.
Cons: Requires a spec addition and a new field in the message format.
My recommendation
I'd lean towards Option B — it's cleaner and doesn't overload existing semantics. But Option A works today without any spec change, so it could serve as an interim solution while Option B gets specced out.
What I've built so far (pool side)
The pool side is fully implemented and tested on Blitzpool:
- PPLNS engine with Redis share window (N = 4 × network difficulty)
AllocateMiningJobToken.Success delivers the full PPLNS distribution as multi-output coinbase_tx_outputs
validateCoinbaseOutputs() checks ALL pool output scripts are present in DeclareMiningJob.coinbase_tx_suffix
- Direct coinbase payouts with dust accumulation for small miners
- Coinbase weight validation against
blockreservedweight
- Works on SV1 + SV2 (standard and extended channels)
- 56 tests passing including integration tests with real bitcoinjs-lib coinbase verification
The pool-side implementation is here: warioishere/blitzpool#115
Happy to share more details, do a demo, or open a PR on the JDC side if there's interest. I think this could be valuable for any pool that wants to do non-custodial payouts with JDP.
What do you think? Open to alternative ideas as well.
Cheers,
warioishere
Hey everyone,
I'm warioishere — some of you might know me from the Bitaxe SV2 firmware work. I've been building a Stratum V2 pool server in TypeScript (Blitzpool) and recently started implementing PPLNS with direct non-custodial coinbase payouts.
The idea: instead of paying miners through a pool wallet, every miner gets their payout directly in the coinbase transaction. Trustless, transparent, no custodial risk. Combined with JDP, miners choose their own transactions while the pool controls the payout distribution — transaction selection stays decentralized, payouts are verifiable on-chain.
The problem
Everything works on the pool side — we send multiple outputs via
coinbase_tx_outputsinAllocateMiningJobToken.Success, and the spec already allows this:The JDC also correctly parses multiple outputs (thanks to
bitcoin::consensus::deserialize). However, the value allocation is hardcoded to put the entire block reward intooutputs[0]:For a single pool payout address this makes sense. But for PPLNS with 50+ miner outputs, we need the reward distributed proportionally across all outputs.
Proposed solutions
I see two clean ways to solve this. Both are backwards compatible.
Option A: Reuse the value field as proportional weights
The pool sends outputs with non-zero values that represent proportional weights:
The JDC distributes
coinbase_tx_value_remainingproportionally:Pros: No spec change needed, no new fields, ~15 lines JDC change.
Cons: Overloads the meaning of the value field — it's supposed to be sats, not weights. Could be confusing.
Option B: New field
coinbase_tx_output_weights(cleaner)Add an optional field to
AllocateMiningJobToken.Success:The pool sends:
The JDC distributes
coinbase_tx_value_remainingbased on the weights. If the field is empty or missing, it falls back to the current behavior (everything into output[0]).Pros: Clean separation of concerns. Value field stays as sats. Explicit intent. Easy for other implementations to understand.
Cons: Requires a spec addition and a new field in the message format.
My recommendation
I'd lean towards Option B — it's cleaner and doesn't overload existing semantics. But Option A works today without any spec change, so it could serve as an interim solution while Option B gets specced out.
What I've built so far (pool side)
The pool side is fully implemented and tested on Blitzpool:
AllocateMiningJobToken.Successdelivers the full PPLNS distribution as multi-outputcoinbase_tx_outputsvalidateCoinbaseOutputs()checks ALL pool output scripts are present inDeclareMiningJob.coinbase_tx_suffixblockreservedweightThe pool-side implementation is here: warioishere/blitzpool#115
Happy to share more details, do a demo, or open a PR on the JDC side if there's interest. I think this could be valuable for any pool that wants to do non-custodial payouts with JDP.
What do you think? Open to alternative ideas as well.
Cheers,
warioishere