Skip to content

consensus: ASERT (aserti3-2d) difficulty adjustment as an opt-in rule - #2

Open
Wired4ncer wants to merge 3 commits into
ecash-com:betanetfrom
Wired4ncer:asert-daa
Open

Wired4ncer wants to merge 3 commits into
ecash-com:betanetfrom
Wired4ncer:asert-daa

Conversation

@Wired4ncer

@Wired4ncer Wired4ncer commented Sep 21, 2026 •

Copy link
Copy Markdown

Adds the ASERT difficulty rule (aserti3-2d, what Bitcoin Cash has run since Nov 2020) as a consensus option that is off on every network. As merged it changes no behaviour anywhere.

Follow-up to ecash-com/fast-facts#4: four retargets in a row pinned at the 4× cap while rented hashrate came and went. Revised after review by @mblowes.

The rule

Two consensus parameters:

  • EcashAsertAnchorHeight — the last block under the 2016-block rule. Every block above it gets its target from the anchor and the clock. 0 = off, which is the value on every network here.
  • EcashAsertHalfLife — two days on main.
target(n) = anchor_target × 2^((elapsed_time − 600 × elapsed_blocks) / half_life)

Defining the anchor as the last old block, not the first new one, makes it impossible for the activation block to fail to find its anchor and fall through to minimum difficulty.

Why two days

The 2d in aserti3-2d is the half-life, and BCH's production history is evidence for that value and no other. A shorter one also costs more than it looks:

  • Timestamps. With a per-block rule, 2 h of future-dating (MAX_FUTURE_BLOCK_TIME) is worth ~6% easier per block at one day, ~3% at two, and it repeats.
  • Dormant chains. A stalled chain's target decays at one second per second. Thirty days of silence takes 1e9 difficulty to about 1 at a one-day half-life, or to about 30,000 at two. BIP300 withdrawal and slot-eviction windows are counted in blocks (13,150 of 26,300), not work — so a decayed chain makes the delay that was supposed to be expensive cheap.

doc/asert.md has the full argument, including the staged half-life bch2 used, which is documented but not implemented.

Setting the anchor is a flag day

No signalling, no threshold. A node on a build with a different anchor computes a different target and is on a different chain from that block on. So:

  • this PR picks no height, for any network;
  • a build with a non-zero anchor warns at startup, and warns on every tip within 2016 blocks of the anchor, counting down.

Mainnet's natural anchor is EcashHeight: the fork block already carries EcashForkBits and its parent is the last Bitcoin block. Betanet needs a later one, and doc/asert.md explains the trap — betanet's current target came off the ±4× clamp, a value the chain never settled at, so the first days after such an anchor measure convergence from a bad start rather than oscillation damping.

Headers sync

PermittedDifficultyTransition() assumed nBits only change at 2016 boundaries and would have dropped ASERT headers during initial sync. At ASERT heights it now enforces only powLimit. A 4×-per-block bound is unsound: median-time-past can sit days behind the tip, so one valid timestamp can make the next block many times harder. What holds at these heights is minimum-chain-work plus the proof of work already checked on every presync header.

Tests

  • asert_published_vectors — the aserti3-2d reference vectors, runs 1–12 verbatim (src/test/data/asert_test_vectors.raw), 14,000 rows. This is what turns "bit-for-bit compatible" into a checked claim: the spec's 2^x is a cubic approximation, so an implementation can be accurate against true 2^x and still disagree with the reference on most inputs. Replaces the self-generated vectors in the first version of this PR, whose provenance was described more strongly than it deserved.
  • asert_invariants, asert_chain, asert_mainnet_shape, asert_permitted_difficulty_transition, asert_wide_powlimit_is_total, chainparams sanity checks, and a pow_asert fuzz target.
  • test/functional/feature_asert.py — new, and the one that matters. Unit tests exercise the formula; the formula is not where the bug goes. It drives a real node across the anchor and checks its nBits block by block against an independent Python transcription: the activation block does not take minimum difficulty, the first ASERT block measures from the anchor's parent, 30 fast blocks get harder, 30 stalled blocks get easier and saturate, the target ignores its own block's timestamp, and the chain revalidates under -reindex.

Both headline claims are mutation-tested:

mutation fails at
time base anchor->pprev → anchor "measures against the anchor's parent"
return powLimit when nHeightDiff == 0 "activation block took minimum difficulty"

-testasertanchor=<height> is a debug-only, regtest-only switch. It changes four things, because no regtest default can coexist with a per-block rule: the anchor, fPowNoRetargeting, min-difficulty blocks, and powLimit (compact 0x1e1fffff — under 2^239 for CalculateASERT, and under (2^256-1)/(4*nPowTargetTimespan) so the old rule cannot overflow). The lower limit means the stock regtest genesis no longer passes, so the switch re-grinds it.

One test-framework fix rides along, and can be split out. This fork renamed the config file to ecash.conf (src/common/args.cpp:37) but test/functional/test_framework/util.py still writes bitcoin.conf — so every functional test starts a node with no config file, i.e. on mainnet, syncing real headers until the harness times out. Fixed with a CONF_FILENAME constant at the four framework sites that name the file (util.py ×3, test_node.py ×1). Five test files still hardcode it — feature_config_args.py, feature_settings.py, feature_includeconf.py, rpc_users.py, rpc_whitelist.py — and this PR does not touch them.

pow_tests 22/22; feature_asert.py passes. The existing test_bitcoin failures on betanet (net_peer_connection_tests port 8333, bip324_tests magic, script_tests/transaction_tests NOP opcodes) are pre-existing — baselined on ca64033.

Left for later

  • MAX_FUTURE_BLOCK_TIME stays at 2 h; cutting it is a separate PR, but per the half-life argument above it should not be decided independently.
  • A staged half-life is documented, not implemented.
  • No anchor height, for any network. The mechanism should merge disabled and a height argued separately, published ahead of the release that carries it.

Provenance

Algorithm: the BCH aserti3-2d specification (MIT). Implementation written against that spec for this tree and reviewed independently before filing. Test vectors are the published run files, carried in verbatim.

🤖 Generated with Claude Code

Wired4ncer and others added 2 commits September 21, 2026 14:00
…rule

Two new consensus parameters: EcashAsertAnchorHeight (0 = off, the default
on every network) and EcashAsertHalfLife (one day on main). Above the
anchor the target is anchor_target * 2^((elapsed - blocks*600)/half_life),
computed exactly as the BCH aserti3-2d spec does, so its vectors apply.

PermittedDifficultyTransition learns about ASERT heights: it bounds only
how much harder one block may get (4x); easier is always allowed, since a
stalled chain is a valid chain. Without this, headers sync would drop
every ASERT header.

Tests: 21 reference vectors from an independent exact-arithmetic model,
invariants (on schedule = unchanged, +/-1 half-life = exactly 2x/0.5x,
monotone, saturation at powLimit, floor at 1), a synthetic chain across
the anchor with burst and stall phases, and the transition guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…mainnet-shape test, fuzz target

- CalculateASERT: round-trip check on the 256-bit multiply (drops its carry
  when powLimit >= 2^239), clamp the height term too; chainparams sanity
  requires powLimit < 2^239 and no min-difficulty blocks with an anchor.
- GetNextWorkRequired: fPowNoRetargeting wins over the anchor.
- PermittedDifficultyTransition: no per-block bound is sound under ASERT
  (an MTP+1 timestamp after a slow stretch is valid and can be many times
  harder), so at ASERT heights only powLimit is enforced; headers sync
  relies on per-header PoW and minimum chain work there.
- Tests: mainnet shape (anchor == EcashHeight, target from the fork reset,
  clock from the pre-fork parent), wide-powLimit totality, burst loop range;
  pow_asert fuzz target.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@mblowes mblowes left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the whole diff. Two things here are right that are easy to get wrong, and worth saying before the criticism:

  • CalculateASERT uses the reference factor expression verbatim, scales the drift by radix before dividing by the half-life (the reference's order, not divide-then-scale), and derives frac as exponent - shifts*radix, which is equivalent to the reference's uint16_t(exponent) under two's complement. The arithmetic is faithful.
  • The anchor is defined as the last block under the 2016-block rule, with ASERT applying strictly above it. That makes the anchor's existence a precondition of every evaluation, which structurally rules out a whole class of activation bug. We hit the other version — anchor set at the activation height and resolved by walking back from pindexPrev, which sits at N-1 and can never find N. The activation block fell through to minimum difficulty and reset the curve; ~2,000 blocks to converge again. Your framing cannot do that.

The code is in good shape. Our concerns are almost entirely about the part that isn't in it.

1. Choosing the anchor is the whole risk, and it is the part left out

Setting EcashAsertAnchorHeight = H is a flag day by height. No signalling, no threshold, no grace period: every node still on the old build computes a different target at H+1 and is on a different chain from that block onward. A soft fork degrades to partial participation; this degrades to a split. The consensus code can be flawless and the rollout can still break the network, and nothing in this PR addresses that half.

As written the PR is a no-op on every network, which is exactly the right shape. We'd argue for keeping it that way:

  • Merge the mechanism disabled and argue the height separately, in its own one-line PR, once operators have demonstrably upgraded — rather than shipping a consensus rule and a date together.
  • Give operators something observable. A node with a non-zero anchor should say so loudly at startup and again as H approaches. Today the only way an operator discovers they are on the wrong build is by forking off it.
  • Publish H before the release, not with it, with enough lead time that "did everyone upgrade" is an answerable question rather than a hope.

The anchor's target matters as much as its height, and this bites betanet specifically. ASERT's entire schedule is relative to nBits at the anchor. Betanet's current target came off the ±4× clamp at a retarget — it is not a value the chain ever equilibrated at. Anchor there and the schedule is pinned to a target that was never right: the chain runs slow, accumulates lateness, and ASERT walks the target back down. It self-corrects, and that is the algorithm working — it is a real advantage over waiting 2016 blocks for the same correction. But it means the first days after activation measure convergence from a bad starting point, not oscillation damping. If the plan is to judge ASERT by how betanet behaves in the week after the anchor, that is the wrong week to look at.

Mainnet is cleaner for exactly the same reason, and it is the strongest argument in this PR: EcashAsertAnchorHeight = EcashHeight anchors to EcashForkBits, a deliberate choice rather than a clamp artefact — and if that choice turns out to be wrong, ASERT unwinds it in days instead of holding it for a full epoch. That case isn't currently made and we think it should be.

2. The bit-for-bit claim is not established by the tests, and the method can't establish it

asert_reference_vectors comes from "an independent exact-arithmetic Python implementation of the spec". If that model computes true 2^x, it verifies accuracy and nothing else — the spec's factor is a cubic approximation, so an implementation can match exact arithmetic to ~1.3e-4 and still disagree with the reference on most inputs.

Concretely: we measured an ASERT implementation that used the same polynomial, with coefficients rounded to 16 bits and evaluated in staged shifts. Accuracy against true 2^x was 1.46e-4 against the reference's 1.17e-4 — both inside the documented band — and it produced a different nBits in 81% of sampled cases (heightDiff 1→2016, ±200,000 s off schedule), worst divergence ~44 ppm. Two implementations can both be accurate and still be two chains.

Because this PR uses the reference expression verbatim it will almost certainly pass BCH's published vectors, which is precisely why it is cheap to include one or two. You flag this yourself under "Not in this PR" — we'd move it into the PR. A claim of consensus compatibility should be machine-checked rather than inferred.

3. aserti3-2d with a one-day half-life is not aserti3-2d

The 2d is the half-life. The title says aserti3-2d, EcashAsertHalfLife on main says 86400, and the safety argument — five years of production on BCH — applies only to the two-day variant. Pick one: keep two days and inherit the track record, or argue one day on its own merits and stop citing BCH's run as evidence for it.

It also interacts with the deferred MAX_FUTURE_BLOCK_TIME change. At a one-day half-life, two hours of future-dating is worth ~6% easier per block and is repeatable; at two days it is ~3%. Halving the half-life doubles the leverage of the timestamp lever you are explicitly leaving at 2 h in a separate PR. Those two decisions shouldn't be made independently.

Worth considering instead: bch2 launched with a one-hour half-life transitioning to two days at a fixed height. Aggressive while hashrate is unstable, standard once it isn't — and the transition becomes a parameter rather than a judgement call locked in now.

4. ASERT makes a stalled chain cheap much faster, and BIP300's security is counted in blocks

We haven't seen this raised anywhere.

A stalled chain falls behind schedule at one second per second, so the target decays continuously whether or not anyone is mining. At a one-day half-life, thirty days of silence takes difficulty from 1e9 to roughly 1. At two days the same stall lands near 30,000. Under the 2016-block rule it does not move at all.

That decay is the feature — it is how the chain recovers. But withdrawal approval is 13,150 of 26,300 blocks, and slot replacement the same: counted in blocks, not in work. A chain that has decayed to trivial difficulty is one where an attacker can buy the entire M4 voting window for almost nothing, and the 26,300-block delay meant to make that expensive is denominated in the thing that just became free.

So the half-life is not only a liveness dial — it prices the cost of attacking a dormant chain. That tradeoff belongs in the PR, and it argues for the longer half-life.

5. The functional test is where the bug will be

You offer this conditionally; we'd make it a blocker. Our unit suite was 15 cases — on-schedule, ±one half-life, monotonicity, both saturation limits, polynomial accuracy, path independence — and every one passed while the activation transition was broken. Unit tests exercise the formula. The formula was never the problem.

The test that matters is a node activating mid-chain, asserting that the activation block does not take minimum difficulty and that the first ASERT block measures against the anchor's parent. That needs the -asertanchorheight regtest arg and a regtest powLimit under 2^239 you already identify.

On PermittedDifficultyTransition

Your reasoning holds, and there is a stronger version available. Beyond HasValidProofOfWork on every presync header and the minimum-chain-work gate: an attacker cannot fabricate an arbitrarily-behind-schedule header chain, because timestamps are bounded above by real time plus MAX_FUTURE_BLOCK_TIME. Difficulty decay is therefore bounded by elapsed wall-clock and not by anything the attacker chooses. That is the property carrying the weight, and it belongs in the comment so the next reader doesn't re-litigate the 4× bound.


Context for the numbers above: we added ASERT to a BIP300/301 Bitcoin fork in January and ran it on a five-node SHA-256 testnet — 3,620 blocks after activation it held 597-599 s against a 600 s target with no oscillation. The anchor bug in the opening paragraph is what it cost to get there.

— Michael Blowes & Claude (Opus 5, via Claude Code). The §2 bit-compatibility measurement was run
for this review; the testnet numbers are from our own January deployment.

…nctional test

Answers the review on ecash-com#2.

Half-life becomes two days (172800 s), the value "aserti3-2d" names and the only
one BCH's production run is evidence for. A shorter one is not free: it doubles
the leverage of 2 h of timestamp future-dating, and it makes a dormant chain
cheap far sooner — which matters here because BIP300 withdrawal and eviction
windows are counted in blocks (13,150 of 26,300), not in work, so a chain that
has decayed during a stall has repriced the delay meant to make attacks costly.
doc/asert.md carries that argument, the flag-day rollout guidance and the trap
in anchoring betanet to a target that came off the +-4x clamp.

The bit-for-bit compatibility claim is now machine-checked. The aserti3-2d
reference vectors, runs 1-12 verbatim, are carried in test data and all 14,000
rows are checked. The self-generated vectors they replace were described as
coming from an "independent exact-arithmetic" model, which claimed more
independence than the model had.

feature_asert.py drives a real node across the anchor. Unit tests exercise the
formula; the formula is not where the bug goes. Both of its headline claims are
mutation-tested: moving the time base off the anchor's parent, and returning
powLimit at the activation block, each fail the assertion that names them.
It needs -testasertanchor, a debug-only regtest switch, which also lowers
powLimit (under both 2^239 and the retarget overflow bound) and re-grinds the
regtest genesis to suit.

An operator can no longer discover the flag day by forking off it: a non-zero
anchor warns at startup and counts down on every tip within 2016 blocks.

The PermittedDifficultyTransition comment now states the limit of its own
argument — the wall-clock bound is on time since the anchor, so a fork off an
old anchor can still reach powLimit cheaply.

Also fixes the test framework's config filename. This fork renamed it to
ecash.conf but the framework still wrote bitcoin.conf, so every functional test
started a node with no config file at all — on mainnet, syncing real headers
until the harness timed out. Four framework sites now use a CONF_FILENAME
constant; five test files still hardcode it and are left for a follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Wired4ncer

Copy link
Copy Markdown
Author

Thank you — this was a very useful review. Four points changed the code. Pushed as a new
commit, not a force-push, so you can see what moved.

§4, stalled chain vs BIP300 — you were right, and it decided the half-life. I checked
the numbers: 30 days of silence is 30 half-lives at one day and 15 at two, taking 1e9
difficulty to about 1, or to about 30,000. Withdrawal and eviction windows are 13,150 of
26,300 blocks, so a decayed chain makes the delay that was meant to be expensive cheap.
Nobody had written this down. It is now in doc/asert.md and beside the parameter.

§3 — half-life is now two days (172800 s). You are right that I was citing BCH's
production run as evidence for a configuration it never ran. ChainParams_MAIN_sanity now
asserts the value. Your MAX_FUTURE_BLOCK_TIME point is in the doc: 2 h of future-dating is
worth about 6% easier per block at one day and 3% at two, so the two settings are coupled
and the doc says so. The bch2 staged half-life is documented as an alternative, not
implemented — it is a second flag day.

§5 — functional test added, accepted as a blocker. test/functional/feature_asert.py
drives a real node across the anchor and checks its nBits block by block against an
independent Python transcription of the spec.

I mutation-tested the two claims rather than just asserting them:

mutation test fails at
time base anchor->pprev → anchor "measures against the anchor's parent"
return powLimit when nHeightDiff == 0 "activation block took minimum difficulty"

It needs the regtest switch you predicted, -testasertanchor=<height> (debug-only). It has
to change four things, since no regtest default can coexist with a per-block rule: anchor,
fPowNoRetargeting, min-difficulty blocks, and powLimit (now compact 0x1e1fffff). That
limit sits under 2^239 for CalculateASERT and under (2^256-1)/(4*nPowTargetTimespan)
— my first cut missed the second bound and would have made the chain unmineable at its
first retarget. A new sanity case builds regtest with the flag, so both bounds are
actually checked. The lower limit also means the stock regtest genesis no longer passes, so
the switch re-grinds it. That is ugly; I would rather you call it out than not see it.

§2 — the claim is now machine-checked. src/test/data/asert_test_vectors.raw carries
runs 1–12 verbatim; pow_tests/asert_published_vectors checks all 14,000 rows. They pass
against the code you read, unchanged.

One correction to the premise: factor here uses the reference's coefficients verbatim
(195766423245049, 971821376, 5127, >> 48), so it is the reference expression, not a second
accurate-but-different one. Your 81% figure is what happens when an implementation
re-rounds those coefficients — a real trap, but not this one.

You were plainly right about the test, though. "Independent exact-arithmetic Python
implementation" described its own evidence badly: if that model had computed true 2^x the
test could not have passed. Those vectors and that wording are both gone.

§1 — agreed, and nothing here picks a height. Added, because "the only way an operator
finds out is by forking off it" was fair:

  • a non-zero anchor logs a warning at startup, saying nodes that disagree do not share a chain;
  • every tip within 2016 blocks of the anchor warns with a countdown.

Publishing the height ahead of the release is process, not code, so it is written into
doc/asert.md. Your betanet point is now a section of that doc: anchoring to a target that
came off the ±4× clamp means the first days measure convergence from a bad start, not
oscillation damping.

On PermittedDifficultyTransition — taken, with one qualification. The wall-clock bound
is on time since the anchor, not since pindexPrev, so it weakens as the anchor recedes:
a fork off an old anchor can still reach powLimit cheaply. The comment now says that, and
says minimum-chain-work plus per-header PoW is what carries the weight there.

Two things this turned up that are not mine. getblocktemplate is refused on this fork
(it points at bip300301_enforcer), so the timestamp-independence check mines the same
height twice from one parent instead. More seriously, no functional test in this repo can
start a regtest node
: the config file was renamed to ecash.conf
(src/common/args.cpp:37) but test/functional/test_framework/util.py still writes
bitcoin.conf, so nodes start on mainnet and sync real headers until the harness times
out. Fixed with a CONF_FILENAME constant at the four framework sites that name the file
(util.py ×3, test_node.py ×1). Five test files still hardcode it and this PR leaves them
alone. Happy to split this out.

Still not addressed: MAX_FUTURE_BLOCK_TIME stays at 2 h in its own PR, the staged
half-life is documented but not implemented, and no anchor height is proposed for any
network.

pow_tests 22/22, feature_asert.py passes. The pre-existing test_bitcoin failures on
betanet are unchanged and unrelated.

mblowes added a commit to mbdrivechains/bitcoin that referenced this pull request Oct 14, 2026
The testnet 20-minute rule now ends at the ASERT anchor (block 2016). Up to it nothing changes: the 2016-block rule
still allows a minimum-difficulty block after a 20-minute gap (block 1 of reset 3 is one and stays valid). From
block 2017 every block's target is ASERT's, set by the anchor and the parent alone, never by the block's own time.
That removes the easynet-only branch added in 99ed36c; the ASERT branch is again exactly ecash-com/bitcoin ecash-com#2's.

Why (operator's decision, 2026-10-13): with the rule, a minimum-difficulty block follows every 20-minute gap, so a hash
cut never stalls the chain and ASERT settles with about 1 block in 5 at minimum difficulty. Without it, a cut is a real
stall, as on beta, which is what the DAA experiments are meant to show.

A hard fork from easynet-3 at the first gap of over 20 minutes after block 2016, with the same genesis, magic and
ports. Every easynet node must run this build (easynet-3.1) before then.

Tests: pow_tests (easynet_no_twenty_minute_rule_under_asert replaces easynet_twenty_minute_rule_under_asert: the
rule still holds below the anchor; above it on-time, 20-minutes-late and 2-hours-late blocks all get the ASERT target;
a late parent eases the next target by ASERT, not to powLimit), argsman_tests, validation_tests. A fresh node of this
build synced the live chain (339 blocks) from the public seed and matched miner2's tip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkHgiGyE6G16mCawvNvHTR

This branch has not been deployed

No deployments
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.

2 participants