Skip to content
Merged
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
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
A client-side web application that simulates how Tari's difficulty adjustment algorithm would have performed under real network conditions. It compares **three designs side by side**:

1. **LWMA + Penalty (Original)** — Tari's per-algorithm LWMA with the initial TIP-004 consecutive-block penalty (per-algo, uncapped, "sticky").
2. **LWMA + Penalty (New)** — the revised proposal from [RFC PR #174](https://github.com/tari-project/rfcs/pull/174) (penalty classes, capped at 32×, transient).
2. **LWMA + Penalty (New)** — the accepted [TIP-RFC-MT-0004](https://github.com/tari-project/rfcs/pull/174) (per-algorithm penalty scope, capped at 32×, transient).
3. **WTEMA** — the research-recommended alternative: Zawy's exponential moving average difficulty ([#76](https://github.com/zawy12/difficulty-algorithms/issues/76)), run independently per algorithm **with no penalty**. This is the primary topic of the [Design Rationale](#wtema-scenario--design-rationale) section below.

Uses actual historical block data extracted from a Tari mainnet node via gRPC.
Expand Down Expand Up @@ -128,15 +128,15 @@ Where `consecutive_count` = number of immediately preceding blocks mined by the

**Sticky interaction with the LWMA:** the penalty is applied by inflating `target_time`, so `LwmaWindow.calculate()` returns an *adjusted* difficulty. That adjusted difficulty is then stored back into the LWMA window. The inflated difficulty therefore lingers in `avg_difficulty` until it ages out — the penalty is "sticky" and decays slowly after a run breaks.

### New Proposal — RFC PR #174 revised (transient)
### New Proposal — TIP-RFC-MT-0004 (accepted, transient)

**Source:** [RFC PR #174](https://github.com/tari-project/rfcs/pull/174) — `TIP-RFC-MT-0004_MinoTari_PoW_Difficulty_changes.md` (Last Modified 2026-07-28). Implemented in `js/lwma_sw.js` (`LwmaWindowSW`) and the `runCompetitionSW` path in `js/simulation.js`.
**Source:** [RFC PR #174](https://github.com/tari-project/rfcs/pull/174) — `TIP-RFC-MT-0004_MinoTari_PoW_Difficulty_changes.md` (Last Modified 2026-08-19, Status: Accepted). Implemented in `js/lwma_sw.js` (`LwmaWindowSW`) and the `runCompetitionSW` path in `js/simulation.js`.

Three changes versus the Original:

1. **Penalty classes.** RxM and RxT are both RandomX variants mined by the same hardware, so they are grouped into one **penalty class**. A run alternating RxM↔RxT does *not* reset the backoff. Algorithms are grouped: `RandomX = {RxM, RxT}`, `Sha3x = {Sha3x}`, `C29 = {C29}`.
1. **Per-algorithm penalty scope.** Each of the four algorithms (RxM, RxT, Sha3x, C29) is its own penalty scope. An RxM block followed by an RxT block **resets** the run and pays no penalty. An earlier draft grouped RxM and RxT into a single RandomX penalty class — both run on the same hardware, so a RandomX farm could otherwise alternate them for free — but the accepted TIP rejects that grouping: it is not neutral between algorithms (a combined class would hold roughly half of all blocks and pay ~1.47x expected penalty against ~1.15x for the others), and it dominates the block-time inflation (~8.4% vs ~1.7% with the algorithms separate). The cost of keeping them apart — RandomX capacity can alternate the two variants with no penalty at all, including on a private chain — is explicitly accepted under the TIP's single-algorithm threat model: the mechanism binds Sha3x and C29 in practice.

2. **Capped at 32×.** `m = min(2^(r-1), 32)` where `r` is the run length within the class (including the block being mined). The cap is a liveness guarantee: if a single class is ever the only active miner, an uncapped modifier would stall the chain indefinitely. The cap bounds that failure to a 32× slowdown (≈4.3 h/block at an 8 min base) and the chain resumes as soon as any other class mines.
2. **Capped at 32×.** `m = min(2^(r-1), 32)` where `r` is the run length within the algorithm (including the block being mined). The cap is a liveness guarantee: if a single algorithm is ever the only active miner, an uncapped modifier would stall the chain indefinitely. The cap bounds that failure to a 32× slowdown (≈4.3 h/block at an 8 min base) and the chain resumes as soon as any other algorithm mines.

| Run length `r` | Modifier `m` |
|----------------|--------------|
Expand All @@ -147,7 +147,7 @@ Three changes versus the Original:
| 5 | 16 |
| 6+ | 32 (capped) |

3. **Transient, not sticky.** The LWMA always uses the **base** `target_time` and returns the **unadjusted** target difficulty. Each sample in the window carries the per-block modifier `m[i]`, and solve times are **normalized by `m[i]`** before entering the weighted sum (`solve_time[i] / m[i]`, normalize-then-clamp to the base bounds, integer-exact via `M_MAX = 32`). The mining target is `unadjusted × m`; the window stores the **unadjusted** difficulty + `m`. Because the inflated difficulty never enters `avg_difficulty`, the penalty affects only the block it applies to and **resets the instant a different class mines** — no lingering inflation.
3. **Transient, not sticky.** The LWMA always uses the **base** `target_time` and returns the **unadjusted** target difficulty. Each sample in the window carries the per-block modifier `m[i]`, and solve times are **normalized by `m[i]`** before entering the weighted sum (`solve_time[i] / m[i]`, normalize-then-clamp to the base bounds, integer-exact via `M_MAX = 32`). The mining target is `unadjusted × m`; the window stores the **unadjusted** difficulty + `m`. Because the inflated difficulty never enters `avg_difficulty`, the penalty affects only the block it applies to and **resets the instant a different algorithm mines** — no lingering inflation.

**Implementation invariant:** with every modifier set to 1, `LwmaWindowSW.calculate()` is numerically identical to `LwmaWindow.calculate()`, so the warm-up phase (actual blocks, no penalty) behaves exactly like the Original. This is verified against actual mainnet difficulties (100% exact match).

Expand All @@ -157,7 +157,7 @@ For both proposals the recorded/displayed `simDifficulty` is the **adjusted** mi

### Effect on mining competition

In the mining competition model, an algo's effective mining target is raised by `m`, which **lowers its mining rate** (`rate = hashRate / targetDiff`) and thus its probability of winning the next block — making it likely that another algo/class mines instead, which is what breaks the consecutive run.
In the mining competition model, an algo's effective mining target is raised by `m`, which **lowers its mining rate** (`rate = hashRate / targetDiff`) and thus its probability of winning the next block — making it likely that another algorithm mines instead, which is what breaks the consecutive run.

---

Expand Down Expand Up @@ -228,8 +228,8 @@ These properties are quantified in [How to read the results](#how-to-read-the-re

The penalty scenarios are kept purely as a comparison baseline. The research — including the [PR #174 review thread](https://github.com/tari-project/rfcs/pull/174), stringhandler's review, and the sequence of m4r1m0's simulations this sim is based on — documents four independent reasons the backoff is the wrong instrument:

1. **It is evadable at linear cost.** A run is defined by penalty *class*, and RxM/RxT share RandomX hardware, so a miner alternating the two RandomX lanes pays nothing while a Sha3x miner pays in full. The threat model in the RFC explicitly concedes this.
2. **It slows emission unless compensated.** `E[solve_time] = E[m]·T`; at balanced share `E[m]≈1.9`, inflating intervals ~87% unless base target times are cut (the RFC's own "Effect on mean block time"). ~25% of main-chain blocks are same-class runs **by chance** — honest miners absorb the tax, which is not a security property.
1. **It is evadable for fractionally diversified miners.** Penalty scope is per algorithm, so a miner holding capacity in any *two* algorithms alternates them and pays nothing; because RxM and RxT run on the same hardware, RandomX capacity is exempt in full — a RandomX farm can alternate the two variants indefinitely with no penalty at all, including while building a private chain. The accepted TIP concedes this explicitly: its threat model names single-algorithm Sha3x/C29 concentration as the target, so the mechanism binds only miners stuck in one lane.
2. **It still slows emission.** `E[solve_time] = E[m]·T`; the TIP's own corrected simulation puts the steady-state penalty at `E[m]≈1.16` with four algorithms live, inflating the mean block interval by ~1.7% (3.5% with three live). ~25% of main-chain blocks follow a same-algo block **by chance** — honest miners absorb the tax, which is not a security property, and the inflation lands on top of an already slow 3-min-realized block time.
3. **The accounting cancels the scheduling.** "Accumulated difficulty per unit time equals hashrate, regardless of the target" — over any window beyond a few blocks, doubling the target makes each block worth twice as much *and* take twice as long, so the work-gain ratio the attacker needs is unchanged. This is the core reason a target-time penalty cannot raise the real cost of selfish mining.
4. **It skews the estimator it depends on.** LWMA regresses solve times against target times over its window; a target jumping by powers of two injects noise the LWMA then reads back. The careful normalization/order-of-operations rules in the RFC are a patch for this interaction.

Expand Down Expand Up @@ -328,7 +328,7 @@ A **"Re-randomize Seeds"** button is provided in the settings bar. Clicking it g
| `RATE_PRECISION` | 10^9 | BigInt precision multiplier for rate computation |
| `PENALTY_BASE` | 2n | Exponential backoff base for both penalties (2^n) |
| `PENALTY_CAP` | 32n | Cap on the New proposal modifier (`min(2^n, 32)`); Original is uncapped |
| `PENALTY_CLASS_MAP` | `{0,2:'randomx', 1:'sha3x', 3:'c29'}` | Penalty classes for the New proposal (RxM+RxT grouped) |
| Penalty scope | per algorithm | New proposal run tracking: RxM, RxT, Sha3x, C29 each independent; an RxM→RxT sequence resets |
| Default window range | 30–60 | LWMA window sizes simulated (step 5) |
| Block height range | 295000–296521 | Analysis range (after warm-up) |

Expand Down Expand Up @@ -372,7 +372,7 @@ Scenarios are generated dynamically by `generateScenarios(minWindow, maxWindow,

1. **Actual (LWMA-90)** — baseline, uses actual historical data (no simulation)
2. **LWMA-{w} + Penalty (Original)** — for each window size `w`, with the Original penalty (per-algo, uncapped, sticky)
3. **LWMA-{w} + Penalty (New)** — for each window size `w`, with the revised RFC #174 penalty (classes, capped 32×, transient)
3. **LWMA-{w} + Penalty (New)** — for each window size `w`, with the accepted TIP-004 penalty (per-algorithm scope, capped 32×, transient)
4. **WTEMA-{w} (EMA)** — for each `w`, Zawy's WTEMA run per algorithm with **no penalty**; the swept value is the EMA smoothing constant `N` (see [WTEMA Scenario — Design Rationale](#wtema-scenario--design-rationale))

Default range: 30–60, step 5 → 7 Original + 7 New + 7 WTEMA scenarios + 1 baseline = 22 total.
Expand Down
12 changes: 6 additions & 6 deletions js/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ const FALLBACK_BLOCK_TIME = 120;
const PERCENT_SCALING = 10000n;
const PERCENT_FACTOR = 100;

// --- "New" penalty proposal (RFC PR #174, 2026-07-28) ---
// Penalty classes: RxM and RxT share RandomX hardware, so a run alternates RxM/RxT
// without resetting the backoff. Capped at 32x for liveness (reached at the 6th
// consecutive class block).
// --- "New" penalty proposal (TIP-RFC-MT-0004, 2026-08-19) ---
// Penalty scope is per algorithm: each of the four algorithms (RxM, RxT, Sha3x,
// C29) is tracked independently, so an RxM block followed by an RxT block resets
// the run. Capped at 32x for liveness (reached at the 6th consecutive block of
// the same algorithm).
const PENALTY_CAP = 32n;
const PENALTY_CLASS_MAP = { 0: 'randomx', 2: 'randomx', 1: 'sha3x', 3: 'c29' };

// --- Scenario generation ---

Expand Down Expand Up @@ -109,7 +109,7 @@ if (typeof window !== 'undefined') {
window.CONFIG = {
HASH_RATE_WINDOW, NUMBER_OF_RUNS, RATE_PRECISION, PENALTY_BASE,
LOG_EPSILON, FALLBACK_BLOCK_TIME, PERCENT_SCALING, PERCENT_FACTOR,
PENALTY_CAP, PENALTY_CLASS_MAP,
PENALTY_CAP,
DEFAULT_MIN_WINDOW, DEFAULT_MAX_WINDOW, DEFAULT_STEP, MIN_STEP, BASELINE_WINDOW,
SCENARIO_PALETTE, BASELINE_COLOR,
ANALYSIS_START_HEIGHT, ANALYSIS_END_HEIGHT, ZOOM_STEP, MIN_ZOOM_DELTA,
Expand Down
4 changes: 2 additions & 2 deletions js/lwma_sw.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use strict';

/**
* LWMA variant for the "New" penalty proposal (RFC PR #174, 2026-07-28).
* LWMA variant for the "New" penalty proposal (TIP-RFC-MT-0004, 2026-08-19).
*
* The validated LwmaWindow (Original proposal) bakes the penalty into target_time,
* so calculate() returns an ADJUSTED difficulty that is then stored back into the
Expand All @@ -15,7 +15,7 @@
* block mined against an inflated target is not read as a hash-rate drop.
* - The caller applies m as a one-shot multiplier on the mining target only
* (miningTarget = unadjusted * m) and stores the UNADJUSTED difficulty in the
* window. The penalty therefore resets the instant a different penalty class
* window. The penalty therefore resets the instant a different algorithm
* mines.
*
* Integer-exact normalization (RFC "Integer arithmetic"): every modifier is a
Expand Down
51 changes: 29 additions & 22 deletions js/simulation.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

const { NUMBER_OF_RUNS, RATE_PRECISION, PENALTY_BASE, LOG_EPSILON, FALLBACK_BLOCK_TIME,
BASELINE_WINDOW, SCENARIO_PALETTE, BASELINE_COLOR, ALGO_IDS,
PENALTY_CAP, PENALTY_CLASS_MAP } = CONFIG;
PENALTY_CAP } = CONFIG;

const { aggregateStatsWithCI, computeStats, findMedianRun } = Statistics;

Expand All @@ -39,9 +39,9 @@ function generateScenarios(minWindow, maxWindow, step) {
baseline: false,
color: SCENARIO_PALETTE[colorIndex % SCENARIO_PALETTE.length],
});
// New proposal (RFC PR #174, 2026-07-28): penalty classes (RxM+RxT grouped),
// New proposal (TIP-RFC-MT-0004, 2026-08-19): per-algorithm penalty scope,
// capped at 32x, transient (window stores unadjusted difficulty + normalized
// solve times, so the penalty resets the instant a different class mines).
// solve times, so the penalty resets the instant a different algorithm mines).
scenarios.push({
id: `lwma${windowSize}pnew`,
label: `LWMA-${windowSize} + Penalty (New)`,
Expand Down Expand Up @@ -299,22 +299,25 @@ function buildResultObject(block, winningAlgo, simulatedDifficulty, simulatedSol
}


// --- "New" proposal (RFC PR #174, 2026-07-28) competition path ---
// --- "New" proposal (TIP-RFC-MT-0004, 2026-08-19) competition path ---
//
// Isolated from the Original path above so the validated Original behaviour is
// unchanged. Reuses the shared helpers (estimateMiningRate, sampleWinningAlgo,
// sampleBlockTime, updateHashRateHistory, buildResultObject).
//
// Differences from the Original penalty:
// * Consecutive counting is per PENALTY CLASS, not per algorithm. RxM and RxT
// share the 'randomx' class, so RxM -> RxT continues the run and incurs the
// penalty (they are mined by the same hardware).
// * Penalty scope is per ALGORITHM, not per class. Each of the four algorithms
// (RxM, RxT, Sha3x, C29) is tracked independently, so RxM -> RxT resets the
// run and pays no penalty. This matches the accepted TIP: grouping RxM+RxT
// into a RandomX class was rejected (neutrality between algorithms; ~8.4%
// vs ~1.7% block-time inflation), and RandomX alternation is an explicitly
// conceded exemption under the TIP's single-algorithm threat model.
// * The modifier is capped: m = min(2^consecutive, PENALTY_CAP) with PENALTY_CAP=32.
// * The penalty is TRANSIENT: the LWMA (LwmaWindowSW) returns the UNADJUSTED
// target using base target_time and solve times normalized by m[i]. The mining
// target is unadjusted * m; the window stores the UNADJUSTED difficulty + m, so
// the penalty does not linger in avg_difficulty and resets immediately when a
// different class mines.
// different algorithm mines.
// * simDifficulty records the ADJUSTED mining target (parity with the Original,
// whose stored difficulty is also the penalized target), so the difficulty
// charts compare like-for-like.
Expand All @@ -340,8 +343,8 @@ function runCompetitionSW(blocks, scenario, seed) {
const windows = initializeLwmaWindowsSW(scenario);
const hashRateHistory = createEmptyHashRateHistory();

let lastClass = null;
let consecutiveClassCount = 0;
let lastWinner = -1;
let consecutiveCount = 0;
let simulatedTimestamp = 0;
const results = [];

Expand All @@ -357,7 +360,7 @@ function runCompetitionSW(blocks, scenario, seed) {
continue;
}

const algoRates = computeAlgoRatesSW(windows, hashRateHistory, lastClass, consecutiveClassCount);
const algoRates = computeAlgoRatesSW(windows, hashRateHistory, lastWinner, consecutiveCount);
const totalRate = algoRates.reduce((sum, entry) => sum + entry.rate, 0);

let winningAlgo, simulatedDifficulty, simulatedSolveTime, winnerModifier, winnerUnadjusted;
Expand All @@ -377,11 +380,13 @@ function runCompetitionSW(blocks, scenario, seed) {
simulatedDifficulty = winnerEntry.miningTarget; // adjusted, for display parity
}

// Update the class-based run length AFTER sampling this block.
const winnerClass = PENALTY_CLASS_MAP[winningAlgo];
if (winnerClass === lastClass) consecutiveClassCount++;
else consecutiveClassCount = 0;
lastClass = winnerClass;
// Update the per-algorithm run length AFTER sampling this block.
// consecutiveCount = run length ending at the last mined block (>= 1), so
// a block extending the run is at position r = consecutiveCount + 1 and pays
// m = 2^(r-1) = 2^consecutiveCount, per TIP m = min(2^(r-1), 32).
if (winningAlgo === lastWinner) consecutiveCount++;
else consecutiveCount = 1; // a fresh winner starts a run of length 1
lastWinner = winningAlgo;

simulatedTimestamp += simulatedSolveTime;
// Store the UNADJUSTED difficulty + the modifier in force for this block, so
Expand All @@ -390,7 +395,7 @@ function runCompetitionSW(blocks, scenario, seed) {

const result = buildResultObject(
block, winningAlgo, simulatedDifficulty, simulatedSolveTime,
Math.floor(simulatedTimestamp), consecutiveClassCount, scenario.window, scenario.penalty
Math.floor(simulatedTimestamp), consecutiveCount, scenario.window, scenario.penalty
);
// Override with the capped modifier (buildResultObject would compute 2^n uncapped).
result.penaltyMultiplier = winnerModifier > 1n ? Number(winnerModifier) : 1;
Expand All @@ -411,15 +416,17 @@ function initializeLwmaWindowsSW(scenario) {
return windows;
}

function computeAlgoRatesSW(windows, hashRateHistory, lastClass, consecutiveClassCount) {
function computeAlgoRatesSW(windows, hashRateHistory, lastWinner, consecutiveCount) {
const algoRates = [];
for (const algoId of ALGO_IDS) {
const window = windows[algoId];

// Penalty applies to every algo in the leading penalty class (so an RxT block
// following an RxM block continues the RandomX run and pays the penalty).
const algoClass = PENALTY_CLASS_MAP[algoId];
const consecutive = (algoClass === lastClass) ? consecutiveClassCount : 0;
// Penalty applies only to the algorithm that mined the previous block, so
// an RxT block following an RxM block pays no penalty (run reset).
// consecutiveCount = run length ending at the previous block; a block
// extending that run is at position r = consecutiveCount + 1 and pays
// m = 2^(r-1) = 2^consecutiveCount (TIP: m = min(2^(r-1), 32)).
const consecutive = (lastWinner === algoId) ? consecutiveCount : 0;

let modifier = 1n;
if (consecutive > 0) {
Expand Down