Skip to content

Consensus: Temporary extended generation maturity for newly mined outputs - #410

Closed
kwsantiago wants to merge 4 commits into
bitcoinknots:29.x-knotsfrom
privkeyio:coinbase-maturity-45d
Closed

kwsantiago wants to merge 4 commits into
bitcoinknots:29.x-knotsfrom
privkeyio:coinbase-maturity-45d

Conversation

@kwsantiago

@kwsantiago kwsantiago commented Sep 12, 2026 •

Copy link
Copy Markdown

A generation output created at a height inside the window must be 6480 blocks deep instead of 100. Coverage is fixed when the output is created: outputs mined before the start height keep the ordinary rule, and outputs mined inside the window serve the full period even where that runs past the end height.

Nothing already spendable is immobilized when the window opens, and nothing unlocks in a batch when it closes. Both ends are block heights, so the requirement is a pure function of the height an output was created at and no median-time-past is involved.

6480 blocks is 45 days at the 600s target spacing. It is a block count, not a duration, so the wall-clock period it delivers depends on how fast blocks are actually found. The end height is intended to land near the RDTS expiry.

Mainnet, testnet3, testnet4 and signet unscheduled. Regtest: -coinbasematuritylong=<start>:<end>:<depth>.

Known gap, needed before any start height is set: the wallet and GUI still hardcode the ordinary maturity, so they will offer covered outputs and the resulting sends will sit unbroadcast until the depth is reached.

@kwsantiago
kwsantiago force-pushed the coinbase-maturity-45d branch 2 times, most recently from 642d8f2 to d2f1509 Compare September 12, 2026 14:31
@kwsantiago kwsantiago changed the title Consensus: Rolling generation maturity of 45 days (expires with RDTS) Consensus: Temporary extended generation maturity Sep 12, 2026
@4Ethos

4Ethos commented Sep 12, 2026

Copy link
Copy Markdown

Concept ACK

@kwsantiago kwsantiago changed the title Consensus: Temporary extended generation maturity Consensus: Temporary extended generation maturity, expiring with RDTS Sep 12, 2026
@kwsantiago
kwsantiago force-pushed the coinbase-maturity-45d branch 2 times, most recently from 11ba7b4 to 58c72b5 Compare September 12, 2026 16:28
@kwsantiago kwsantiago changed the title Consensus: Temporary extended generation maturity, expiring with RDTS Consensus: Temporary extended generation maturity for newly mined outputs Sep 12, 2026
@kwsantiago
kwsantiago force-pushed the coinbase-maturity-45d branch from 58c72b5 to c479db0 Compare September 12, 2026 16:59
Comment thread src/test/coinbase_maturity_tests.cpp Resolved
Comment thread src/consensus/params.h Resolved
Comment thread src/test/coinbase_maturity_tests.cpp Resolved
@kwsantiago
kwsantiago force-pushed the coinbase-maturity-45d branch 3 times, most recently from c4e8b26 to 8dff2d3 Compare September 12, 2026 19:27
@kwsantiago
kwsantiago force-pushed the coinbase-maturity-45d branch 2 times, most recently from aa52c4b to 210f338 Compare September 12, 2026 21:42
@jasonsopko

Copy link
Copy Markdown

Tested 210f338, the 21:42Z push, on Ubuntu 24.04, gcc, wallet on. feature_coinbase_maturity_long.py passes, and coinbase_maturity_tests, validation_tests, txvalidation_tests, txvalidationcache_tests, mempool_tests and miner_tests are clean.

Of the maturity branches open right now this is the one that needs no chain context to answer the question, and that is the part worth keeping whatever else changes. Coverage is a function of the height an output was created at, so CoinbaseMaturity is three ints passed by value, a default-constructed one is the ordinary rule, and a caller with no chain -- the mempool self-check, the fuzz harness -- cannot end up with a different answer than a caller that has one. #409 takes a block index walk per coinbase input to answer the same question, which is why it has to thread a tip through CheckTxInputs at all. Required() also clamps up to COINBASE_MATURITY rather than trusting the parameter, and the regtest option refuses ten different ways of writing a schedule that would weaken or silently disable the rule, which is the kind of thing that usually gets left to the operator.

The end height cannot be derived from the target spacing, and the body's plan for it needs a number.

"The end height is intended to land near the RDTS expiry." The expiry is a median-time-past -- 1819756800, 2027-09-01 00:00 UTC -- and the window ends at a height, so somebody has to convert one to the other. At the tip as I write this, median-time-past 2026-09-12 21:32 UTC, that is 353.1 days away:

  at target 600 s            50847 blocks -> end height 1022593
  at measured 168.8 s       180735 blocks -> end height 1152481

130,000 blocks between the two assumptions. 168.8 s is the mean median-time-past step over blocks 971171-971370; the chain has run at 3-4x the target rate through every retarget epoch since the fork, and all four hit the 4x clamp. Pick the end height at 600 s and the window closes in early spring while RDTS runs to September, which puts every coinbase mined in that gap back on the ordinary rule.

The same arithmetic applies to the depth. The body is straight about this -- "it is a block count, not a duration, so the wall-clock period it delivers depends on how fast blocks are actually found" -- and the number is 12.7 days, not 45:

  6480 blocks at target 600 s      45.0 days
  6480 blocks at measured 168.8 s  12.7 days

So as written this is a 12-day lock described as a 45-day one. That may still be the right lock, but it should be chosen knowing which of the two it is. This is the same basis point I raised on #402 and #404; #403 is the branch that answers it by measuring the duration in median-time-past seconds instead of blocks, and nothing in this design stops the depth being expressed that way, since neither end of the window would need a tip for it.

The wallet gap, with a shape.

You state it up front so this is not a finding, but the transcript is worth having in the thread because it is the first thing an operator meets. On regtest with -coinbasematuritylong=200:260:300, a covered output 153 blocks deep:

tip 350: the covered output at height 200 is 151 deep, consensus wants 300
  getbalances: trusted 25.00000000, immature 0E-8
  listunspent: 25.00000000 at 151 confirmations, spendable=True, safe=True
  testmempoolaccept: allowed=False reason=bad-txns-premature-spend-of-coinbase
  sendtoaddress returned 2e548f42325fba565b84d231e0190d2851f71ca53c873fb68c71cf53547dec6d
  wallet: confirmations 0, in node mempool: False
  control wallet, height 199, 152 deep: allowed=True

It sits in the spendable balance, listunspent offers it, and the rejection arrives only when the transaction reaches the mempool. Script in the gist.

Two smaller things:

CTxMemPool::check() now asserts only the ordinary rule, with a comment saying acceptance already applied the extended one. That is true of the path that put the entry there, and the self-check is the thing that would catch a path that did not. @Kilombino raised the same point on #404. Is there a reason not to pass the real schedule here? It costs no tip in this design, which is the advantage of it.

The coins_view harness passes CoinbaseMaturity{}, so the fuzzer never sees a live window and the new branch in CheckTxInputs is never taken. The struct is three ints and needs no chain, so fuzzing the bounds is as cheap as hardcoding them.

I have also left a comment on #404 with measured numbers on whose coins these locks actually land on, which applies here unchanged.

Scripts and raw output: https://gist.github.com/jasonsopko/75e2f3e69cefcfa19911378aa4c5a54d

@kwsantiago
kwsantiago force-pushed the coinbase-maturity-45d branch from 210f338 to f0be007 Compare September 12, 2026 22:38
@f4u57x

f4u57x commented Sep 12, 2026

Copy link
Copy Markdown

@jasonsopko there's a counting bug in payout-lag.py:55, linked in your last comment. It adds the full coinbase value for every matching input instead of just the output being spent.

I reproduced it with a synthetic 25 BTC coinbase split into 10 and 15 BTC, each output spent once. The script reports 50 BTC spent.

Use vin['vout'] to select the actual output value and rerun the totals. The per-input age calculation is unaffected by this counting bug.

@kwsantiago
kwsantiago force-pushed the coinbase-maturity-45d branch from f0be007 to 9f5b42d Compare September 12, 2026 23:15
@jasonsopko

jasonsopko commented Sep 12, 2026 •

Copy link
Copy Markdown

@f4u57ox is right, and it is worse than your synthetic case: 11.5x on the window I posted, not 2x. Your repro is exact: I get 50 BTC against a true 25, both when the two outputs are spent by separate transactions and when one transaction takes both.

Line 55 added the whole coinbase's value for every matching input, so a hasher spending a 0.01 BTC payout out of a 116-output coinbase was credited the entire block reward. 39 of the 400 transactions in that window take two outputs of the same coinbase, counting it twice again. Selecting vin['vout'] as you say:

  total spent generation value   11194.1 BTC posted  ->  975.2 BTC

Both bugs here are mine, and I only found the second because you made me go back through the loop. The value and the ages of an entire transaction were attributed to the first pool whose coinbase it touched, and 130 of the 400 transactions spend coinbases of more than one pool, 17.2 % of the value. Counted per input, against the pool that created each output:

  Lazarus        184.5 ->  124.9
  Bitcoin Xor     41.8 ->   75.5
  Catbus          52.4 ->   30.1
  Sherpa           0.0 ->   43.8

Sherpa is the one that bothers me: 43.8 BTC, 4.5 % of the window, in no row at all, because the one transaction spending it also took an older coinbase belonging to another pool.

That walks back what I said about the ages. They are untouched by your bug, which is what your comment says and it is right, but the misattribution moved medians that were not yours to move: Catbus 258 to 165, RIPTIDE 570 to 422, Bitcoin Xor 308 to 265. AlphaPool, the row the argument actually rests on, does not move: 465.8 BTC, median 102, p90 105, 98.7 % of its outputs spent by block 110.

Verification, since one wrong number in a thread is enough. I rebuilt the table from the node alone, taking each parent transaction from /rest/tx, the node's own word for whether it is a coinbase, vout[n].value for the amount and block data for both heights, with the index supplying only which transactions to look at and the pool names. 26 rows, six columns each, zero mismatches. Separately: the index's output map matches the node exactly on 250 sampled coinbases, it holds all 99 coinbase-spending transactions in blocks 971700-971741, no reorg overlaps the window, and the pool labels check out against the coinbase tags in the blocks themselves.

coinbase-incidence.py in the same gist is not affected by either bug. It sums each coinbase's outputs once per coinbase, which is the generation value, and a coinbase has exactly one creating pool so there is nothing to misattribute. That is where the expression was right; carrying it into the join is where it stopped being right.

Fixed script, regenerated output, and a --max-height so a run can be repeated against a moving index, are in the gist. Corrected table is on #404.

@f4u57x

f4u57x commented Sep 13, 2026

Copy link
Copy Markdown

Closing boundary — src/consensus/consensus.h:38

The batch unlock is gone, but the closing boundary still has a sharp payout difference. The reward at end - 1 matures 6,379 blocks after the reward at end. For a long enough window, covered and post-window rewards also mature together for 6,380 blocks. Document that transition and decide whether the abrupt drop is intentional. If smoothing it is the goal, a one-for-one taper is not a fix: it makes many rewards mature at the same height.

Schedule changes — src/consensus/params.h:127

The body already explains that blocks do not guarantee a duration. It should also state that the end height cannot be kept aligned with RDTS by adjusting it after deployment. With [1000,2000) and depth 6480, a height-2000 reward can be spent at 2100. Extending the end to 3000 after that would invalidate the accepted spend. Future changes need to preserve existing cohorts, and historical validation must retain their original schedules after the window closes.

@kwsantiago

Copy link
Copy Markdown
Author

Closing in favor of #419, which lands the same shape: coverage fixed at the creation height, plain height params, no median-time-past. It also carries the wallet and GUI work this deliberately left out.

Thanks for the reviews here, the transcripts in particular.

@kwsantiago kwsantiago closed this Sep 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants