Skip to content

JDC: Support proportional coinbase output value distribution for PPLNS pools #388

Description

@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_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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions