Repository navigation
consensus: ASERT (aserti3-2d) difficulty adjustment as an opt-in rule - #2
Wired4ncer wants to merge 3 commits into
Conversation
…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
left a comment
There was a problem hiding this comment.
Read the whole diff. Two things here are right that are easy to get wrong, and worth saying before the criticism:
CalculateASERTuses the referencefactorexpression verbatim, scales the drift byradixbefore dividing by the half-life (the reference's order, not divide-then-scale), and derivesfracasexponent - shifts*radix, which is equivalent to the reference'suint16_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>
|
Thank you — this was a very useful review. Four points changed the code. Pushed as a new §4, stalled chain vs BIP300 — you were right, and it decided the half-life. I checked §3 — half-life is now two days (172800 s). You are right that I was citing BCH's §5 — functional test added, accepted as a blocker. I mutation-tested the two claims rather than just asserting them:
It needs the regtest switch you predicted, §2 — the claim is now machine-checked. One correction to the premise: You were plainly right about the test, though. "Independent exact-arithmetic Python §1 — agreed, and nothing here picks a height. Added, because "the only way an operator
Publishing the height ahead of the release is process, not code, so it is written into On Two things this turned up that are not mine. Still not addressed:
|
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
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.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
2dinaserti3-2dis 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:MAX_FUTURE_BLOCK_TIME) is worth ~6% easier per block at one day, ~3% at two, and it repeats.doc/asert.mdhas 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:
Mainnet's natural anchor is
EcashHeight: the fork block already carriesEcashForkBitsand its parent is the last Bitcoin block. Betanet needs a later one, anddoc/asert.mdexplains 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()assumednBitsonly change at 2016 boundaries and would have dropped ASERT headers during initial sync. At ASERT heights it now enforces onlypowLimit. 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 apow_asertfuzz 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 itsnBitsblock 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:
anchor->pprev→anchorpowLimitwhennHeightDiff == 0-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, andpowLimit(compact0x1e1fffff— under 2^239 forCalculateASERT, 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) buttest/functional/test_framework/util.pystill writesbitcoin.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 aCONF_FILENAMEconstant 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_tests22/22;feature_asert.pypasses. The existingtest_bitcoinfailures onbetanet(net_peer_connection_testsport 8333,bip324_testsmagic,script_tests/transaction_testsNOP opcodes) are pre-existing — baselined onca64033.Left for later
MAX_FUTURE_BLOCK_TIMEstays at 2 h; cutting it is a separate PR, but per the half-life argument above it should not be decided independently.Provenance
Algorithm: the BCH
aserti3-2dspecification (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