Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions doc/asert.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# ASERT (aserti3-2d) difficulty adjustment

This document covers the parts of the ASERT rule that are decisions rather than code: how
the anchor is chosen and rolled out, and why the half-life is what it is. The algorithm
itself is in `src/pow.cpp` (`CalculateASERT`, `GetNextASERTWorkRequired`).

The rule is off unless `EcashAsertAnchorHeight` is non-zero. It is zero on every network.

## The anchor

`EcashAsertAnchorHeight` names the **last** block whose `nBits` come from the 2016-block
rule. Every block strictly above it takes its target from the anchor's `nBits` and the
clock:

target(n) = anchor_target * 2^((time(n-1) - time(anchor-1) - 600 * (n - anchor)) / half_life)

Defining the anchor as the last block under the old rule, rather than the first block under
the new one, makes the anchor's existence a precondition of every ASERT evaluation. The
alternative — anchor at the activation height, found by walking back from `pindexPrev` —
cannot find it, because `pindexPrev` sits one block below; the activation block then falls
through to minimum difficulty and resets the curve.

### Setting it is a flag day

There is no signalling, no threshold and no grace period. A node whose build has a
different `EcashAsertAnchorHeight` computes a different target for the block above it and
follows a different chain from that block onward. A soft fork degrades to partial
participation; this degrades to a split.

The consequences of that are a rollout problem, not a code problem, so:

- **The mechanism merges disabled.** A height is argued separately, in its own change,
once operators have demonstrably upgraded.
- **The height is published before the release that carries it**, with enough lead time
that "has everyone upgraded" is an answerable question.
- **A node says so.** A build with a non-zero anchor logs it at startup, and warns on every
new tip within 2016 blocks of the anchor. Discovering you are on the wrong build by
forking off it is not acceptable as the only signal.

### Mainnet

`EcashAsertAnchorHeight = EcashHeight` is the natural choice. The fork block already
carries a fixed target (`EcashForkBits`) and its parent is the last Bitcoin block, so the
schedule starts exactly at the fork, from a deliberately chosen target. If that choice
turns out to be wrong, ASERT unwinds it over days rather than holding it for a full
2016-block epoch. That is the strongest case for the rule.

### Betanet, and how to read its first week

A chain already past its fork needs a later anchor, and there is a trap in picking one.
ASERT's whole schedule is relative to `nBits` at the anchor. A target that came off the
±4× retarget clamp 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.

That self-correction is the algorithm working, and it is faster than waiting 2016 blocks
for the same correction. But it means the days right after such an anchor measure
**convergence from a bad starting point**, not oscillation damping. Judging the rule by
that week answers a different question than the one being asked.

## The half-life

`EcashAsertHalfLife` is two days (172800 s) — the `2d` in `aserti3-2d`, and the value
Bitcoin Cash has run in production since November 2020. A shorter half-life gives faster
relief when hashrate leaves. It is not free.

**It is the safety argument.** BCH's production history is evidence for the two-day
variant and for nothing else. A one-day half-life is a different algorithm and has to be
argued on its own merits.

**It sets the leverage of the timestamp lever.** With a per-block difficulty rule,
timestamps become a lever on difficulty, bounded above by `MAX_FUTURE_BLOCK_TIME` (2 h).
Two hours of future-dating is worth `2^(7200/half_life)` easier for the next block:
about 6% at one day, about 3% at two, and repeatable. Halving the half-life doubles the
value of a lever that is being left at 2 h. The two decisions are coupled and should not be
made separately.

**It prices the cost of attacking a dormant chain.** A stalled chain falls behind schedule
at one second per second, so its target decays whether or not anyone is mining — that is
how the chain recovers, and under the 2016-block rule it does not happen at all. But the
decay is steep. Thirty days of silence is 30 half-lives at one day and 15 at two: a
difficulty of 1e9 lands near **1** in the first case and near **30,000** in the second.

That matters here more than it would in a chain without BIP300. Withdrawal-bundle approval
and used-slot replacement are counted in **blocks** — 13,150 of 26,300 — not in work. A
chain that has decayed to trivial difficulty is one where an attacker can buy an entire
voting window cheaply, and the 26,300-block delay meant to make that expensive is
denominated in the thing that just became free. The half-life is therefore not only a
liveness dial, and the longer value is the safer one.

A staged half-life — short while hashrate is unstable, transitioning to two days at a fixed
height, as bch2 launched — is a reasonable alternative that turns this into a parameter
rather than a judgement locked in now. It is not implemented here.

## Testing

`src/test/data/asert_test_vectors.raw` holds the aserti3-2d reference vectors, runs 1
through 12, verbatim as published with the specification: 14,000 rows checked by
`pow_tests/asert_published_vectors`.

This is deliberately not an accuracy test. The spec's `2^x` is a **cubic approximation**,
so an implementation can agree with exact arithmetic to within the documented error band
and still produce a different `nBits` on most inputs — two implementations that are both
accurate can still be two chains. Only the reference's own outputs settle compatibility.

`-testasertanchor=<height>` (regtest, debug-only) puts a regtest chain under ASERT. It also
disables no-retargeting and min-difficulty blocks and lowers `powLimit`, because none of
those can coexist with a per-block difficulty rule; lowering `powLimit` changes the regtest
genesis block, which the switch re-grinds. `test/functional/feature_asert.py` uses it to
exercise the activation transition on a real node — the place where the formula is not the
thing that breaks.
11 changes: 11 additions & 0 deletions src/chainparams.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ void ReadRegTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& opti
if (auto value = args.GetBoolArg("-fastprune")) options.fastprune = *value;
if (HasTestOption(args, "bip94")) options.enforce_bip94 = true;

if (args.IsArgSet("-testasertanchor")) {
const auto value{args.GetArg("-testasertanchor", "")};
const auto height{ToIntegral<int32_t>(value)};
if (!height || *height < 0) {
throw std::runtime_error(strprintf("Invalid height value (%s) for -testasertanchor=height; it must be at least 1, or 0 to disable.", value));
}
// 0 disables, so that -notestasertanchor (which sets the value to "0") means what
// negation means everywhere else rather than raising.
options.asert_anchor_height = *height;
}

for (const std::string& arg : args.GetArgs("-testactivationheight")) {
const auto found{arg.find('@')};
if (found == std::string::npos) {
Expand Down
1 change: 1 addition & 0 deletions src/chainparamsbase.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ void SetupChainParamsBaseOptions(ArgsManager& argsman)
argsman.AddArg("-regtest", "Enter regression test mode, which uses a special chain in which blocks can be solved instantly. "
"This is intended for regression testing tools and app development. Equivalent to -chain=regtest.", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS);
argsman.AddArg("-testactivationheight=name@height.", "Set the activation height of 'name' (segwit, bip34, dersig, cltv, csv). (regtest-only)", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::DEBUG_TEST);
argsman.AddArg("-testasertanchor=<height>", "Put the chain under the ASERT difficulty rule with this anchor height (0 disables). Also disables no-retargeting and min-difficulty blocks and lowers powLimit, which changes the genesis block. (regtest-only)", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::DEBUG_TEST);
argsman.AddArg("-testnet", "Use the testnet3 chain. Equivalent to -chain=test. Support for testnet3 is deprecated and will be removed in an upcoming release. Consider moving to testnet4 now by using -testnet4.", ArgsManager::ALLOW_ANY, OptionsCategory::CHAINPARAMS);
argsman.AddArg("-testnet4", "Use the testnet4 chain. Equivalent to -chain=testnet4.", ArgsManager::ALLOW_ANY, OptionsCategory::CHAINPARAMS);
argsman.AddArg("-vbparams=deployment:start:end[:min_activation_height]", "Use given start/end times and min_activation_height for specified version bits deployment (regtest-only)", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS);
Expand Down
10 changes: 10 additions & 0 deletions src/consensus/params.h
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ struct Params {
int EcashHeight{0};
/** Compact target the block at EcashHeight must carry. */
uint32_t EcashForkBits{0};
/**
* ASERT difficulty adjustment (aserti3-2d). The block at this height is
* the anchor: it is the last block whose nBits come from the 2016-block
* rule (on mainnet, the fork block carrying EcashForkBits). Every block
* above it gets its target from the anchor and the clock alone.
* 0 disables ASERT and keeps the 2016-block rule everywhere.
*/
int EcashAsertAnchorHeight{0};
/** ASERT half-life in seconds: the schedule slip that doubles or halves the target. */
int64_t EcashAsertHalfLife{0};
/**
* Hashes of blocks that
* - are known to be consensus valid, and
Expand Down
10 changes: 10 additions & 0 deletions src/init.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,16 @@ bool AppInitParameterInteraction(const ArgsManager& args)
if (chain == ChainType::SIGNET) {
LogInfo("Signet derived magic (message start): %s", HexStr(chainparams.MessageStart()));
}
if (const int anchor{chainparams.GetConsensus().EcashAsertAnchorHeight}; anchor > 0) {
// A non-zero anchor is a consensus rule this build applies and an older build does
// not. Today the only way an operator finds out they are on the wrong side of it is
// by forking off it, so say it plainly at startup, once, in the clear.
LogWarning("This build applies the ASERT difficulty rule to every block above height %d "
"(the anchor; half-life %ds). Those blocks have a target that a build without "
"this rule computes differently: nodes that disagree on this height do not "
"share a chain.",
anchor, chainparams.GetConsensus().EcashAsertHalfLife);
}
bilingual_str errors;
for (const auto& arg : args.GetUnsuitableSectionOnlyArgs()) {
errors += strprintf(_("Config setting for %s only applied on %s network when in [%s] section."), arg, ChainTypeToString(chain), ChainTypeToString(chain)) + Untranslated("\n");
Expand Down
57 changes: 54 additions & 3 deletions src/kernel/chainparams.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

#include <kernel/chainparams.h>

#include <arith_uint256.h>
#include <chainparamsseeds.h>
#include <consensus/amount.h>
#include <consensus/merkle.h>
Expand All @@ -27,6 +28,7 @@
#include <cstdint>
#include <cstring>
#include <iterator>
#include <limits>
#include <map>
#include <span>
#include <utility>
Expand Down Expand Up @@ -81,6 +83,17 @@ class CMainParams : public CChainParams {
m_chain_type = ChainType::MAIN;
consensus.EcashHeight = 967680; // Retarget boundary, 2016 * 480
consensus.EcashForkBits = 0x19044b7e; // Difficulty 1e9 at the fork block
// ASERT is off until an anchor is chosen. Proposed: mainnet anchors at its fork
// block (EcashAsertAnchorHeight = EcashHeight); betanet at a boundary agreed by
// its node operators, since it is already past its fork.
consensus.EcashAsertAnchorHeight = 0;
// Two days, the aserti3-2d value BCH has run since Nov 2020. A shorter half-life
// gives faster relief but is not free: it doubles the leverage of timestamp
// manipulation against MAX_FUTURE_BLOCK_TIME, and it makes a dormant chain cheap
// far sooner. Drivechain withdrawal and slot-eviction windows are counted in
// blocks (13,150 of 26,300), not in work, so difficulty that has decayed during a
// stall prices the cost of buying such a window. See doc/asert.md.
consensus.EcashAsertHalfLife = 2 * 24 * 60 * 60; // two days
consensus.signet_blocks = false;
consensus.signet_challenge.clear();
consensus.nSubsidyHalvingInterval = 210000;
Expand Down Expand Up @@ -629,9 +642,47 @@ class CRegTestParams : public CChainParams
}

genesis = CreateGenesisBlock(1296688602, 2, 0x207fffff, 1, 50 * COIN);
consensus.hashGenesisBlock = genesis.GetHash();
assert(consensus.hashGenesisBlock == uint256{"0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206"});
assert(genesis.hashMerkleRoot == uint256{"4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"});
if (opts.asert_anchor_height > 0) {
// A regtest chain that actually exercises ASERT. The defaults cannot: a chain
// that never retargets never reaches the anchor branch, min-difficulty blocks
// are refused an anchor by the chainparams sanity check, and a powLimit of
// 2^255 would clamp every result (CalculateASERT is exact only below 2^239).
// So this one switch sets all four. Debug-only, regtest-only; -testasertanchor.
//
// The limit is also held below (2^256-1) / (4 * nPowTargetTimespan), because
// turning retargeting back on makes CalculateNextWorkRequired reachable and it
// multiplies the limit by the clamped timespan. ChainParams_REGTEST_ASERT_sanity
// pins both bounds.
//
// Note for anyone combining this with other regtest machinery: the genesis
// block differs from the stock one, so m_assumeutxo_data below -- and anything
// else keyed to the stock regtest chain -- does not apply under this flag.
consensus.powLimit = uint256{"00001fffff000000000000000000000000000000000000000000000000000000"}; // compact 0x1e1fffff
consensus.fPowNoRetargeting = false;
consensus.fPowAllowMinDifficultyBlocks = false;
consensus.EcashAsertAnchorHeight = opts.asert_anchor_height;
consensus.EcashAsertHalfLife = 2 * 24 * 60 * 60; // two days, as on main

// The stock genesis does not satisfy the lower limit, so re-grind it. Only the
// header changes, so the block itself is built once.
const arith_uint256 genesis_target{UintToArith256(consensus.powLimit)};
genesis.nBits = genesis_target.GetCompact();
bool solved{false};
for (uint32_t nonce = 0; nonce != std::numeric_limits<uint32_t>::max(); ++nonce) {
genesis.nNonce = nonce;
if (UintToArith256(genesis.GetHash()) <= genesis_target) {
solved = true;
break;
}
}
assert(solved);
consensus.hashGenesisBlock = genesis.GetHash();
assert(genesis.hashMerkleRoot == uint256{"4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"});
} else {
consensus.hashGenesisBlock = genesis.GetHash();
assert(consensus.hashGenesisBlock == uint256{"0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206"});
assert(genesis.hashMerkleRoot == uint256{"4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"});
}

vFixedSeeds.clear(); //!< Regtest mode doesn't have any fixed seeds.
vSeeds.clear();
Expand Down
7 changes: 7 additions & 0 deletions src/kernel/chainparams.h
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ class CChainParams
std::unordered_map<Consensus::BuriedDeployment, int> activation_heights{};
bool fastprune{false};
bool enforce_bip94{false};
/**
* Debug-only: put this regtest chain under ASERT with the given anchor height.
* Non-zero also turns off fPowNoRetargeting and min-difficulty blocks and lowers
* powLimit (with a matching genesis), because none of them can coexist with a
* per-block difficulty rule. See -testasertanchor.
*/
int asert_anchor_height{0};
};

static std::unique_ptr<const CChainParams> RegTest(const RegTestOptions& options);
Expand Down
Loading