diff --git a/AGENTS.md b/AGENTS.md index 513321ea..3eedb358 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ navigation linked to this foundation when changing architecture. |-----|------|--------| | `js/` | JavaScript/TypeScript SDK (Bostrom chain, CosmJS-based) | active | | `schema/` | canonical wire format definitions | draft | +| `crate/` | the `soft3` crate: the stack facade over `cybernode` (rung 7, github.com/cyberia-to/cybernode) | active | | `cli/` | `soft3` command-line tool (Rust) | scaffold | | `mcp/` | MCP server for AI assistant access | scaffold | | `py/` | Python SDK | scaffold | @@ -28,7 +29,7 @@ navigation linked to this foundation when changing architecture. soft3 SDK depends on: hemera (particle), bbg (state + proofs), lens (verification), cybergraph (signal submission), radio (transport) -do NOT implement stack logic here. call into the stack repos via FFI, WASM, or RPC. the SDK is an adapter, not a reimplementation. +do NOT implement stack logic here. call into the stack repos via FFI, WASM, or RPC. the SDK is an adapter, not a reimplementation. the node — chain kernel, genesis, node, wallet, network, its specs and deploy units — is `cybernode` since 2026-10-27 (proposals/cybernode.md); a `crate/src/chain` reappearing here is a regression. ## do not touch zones @@ -74,4 +75,4 @@ Candidate gates are `origin-checkouts`, `phase1-pins`, `release-notes-source`, ` `simulation-gate` (the chain simulator's gate suite, cyberia-to/simulation), `soft3-tests`, `soft3-release`, and `node-status`. Products add `soft3-dependency` and their application gates. Missing implementations and failed gates remain red evidence. -Owner-only promotion, tags and bump merges follow `~/cyber/AGENTS.md`. +Owner-only promotion, tags and bump merges follow `~/cyber/AGENTS.md`. the `node-status` gate boots the node through the facade; `cybernode` is a sibling in `release/phase1.toml`. diff --git a/README.md b/README.md index fb7d1199..3df3d236 100644 --- a/README.md +++ b/README.md @@ -41,14 +41,15 @@ candidates with source inventories, platform binaries and gate receipts. ## install ```bash -cargo install soft3 # stack CLI + real node +cargo install cybernode # the node (rung 7): chain, mining, genesis, wallet +cargo install soft3 # the stack facade; forwards the node verbs for one release cargo install true-cyber # product face (binary: cyber) ``` ## run a node ```bash -soft3 node --home ~/.spacepussy-test --bind 127.0.0.1:7780 --moniker dev-1 +cybernode node --home ~/.spacepussy-test --bind 127.0.0.1:7780 --moniker dev-1 ``` engine: **cybergraph + bbg**. not a status stub. @@ -59,7 +60,7 @@ curl -sS -X POST http://127.0.0.1:7780/v1/link \ -H 'content-type: application/json' \ -d '{"neuron":"01","from":"0a","to":"0b","amount":1}' -soft3 sync # public edge on cybernode +cybernode sync # public edge on cybernode.ai cyber sync # same via true-cyber ``` diff --git a/audit/chain/conformance-2026-10-16.md b/audit/chain/conformance-2026-10-16.md deleted file mode 100644 index 2bd5fc44..00000000 --- a/audit/chain/conformance-2026-10-16.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -title: chain conformance — kernel reference run 2026-10-16 -tags: cyber, soft3, audit, chain, conformance -crystal-type: audit -crystal-domain: cyber -date: 2026-10-16 ---- - -# chain conformance — kernel reference run 2026-10-16 - -Receipt of the first run of chain-conformance.md on the consensus kernel -(`crate/src/chain/`, branch `feat/chain-kernel`, revision `1243ef2` plus -the engine split committed with this file). Per the audit doctrine: -revision, command, observed result; the requirements stay in the specs. - -## commands - -``` -cd soft3/crate -cargo test # 168 lib + 11 bin tests, 0 failed (212 s debug) -cargo test --lib chain:: # 66 chain tests (19 engine scenarios) -cargo build --release # 0 soft3 warnings (3 pre-existing in bbg's vendored fjall) -soft3 conformance check chain # chain.snap: 9 entries, no drift -``` - -Stand: `~/cyber/.stands/N1` — soft3 `feat/chain-kernel` over main `8525b17` -(wallet #184, genesis #183), siblings at the make.sh integration base -(bbg `1d39975` 0.3.0, cybergraph `feat/durable-applications`, lens -`release/0.2`, nox `release/0.4`, zheng `feat/public-certificate-v3`, mudra -`refactor/spell`, hemera/strata/tade/tru/tok/radio/foculus defaults). -Genesis file: `~/cyber/.cache/genesis/pussy.tade` (3,249,582 bytes) from -`soft3 genesis build`. - -## results by section - -| section | result | where | -|---|---|---| -| §1 hemera anchors | green — empty, `"boot"`, `"pussy"`, 168 zero bytes | `chain::sigs::tests` | -| §2 compact target | green — 7 canonical, 6 rejections | `chain::header::tests` | -| §3 work | green — 2^228/2^232/2^236/1, 3-block sum 50,331,645 | `chain::u256::tests`, `chain::pow::tests` | -| §4 ASERT | green — all 6 rows with `e`, `shifts`, `frac`, `factor`, bits (row 5 pins floor toward −∞ and the cubic; row 6 the clamp); T-spacing holds bits; T/2 for 600 s halves the target; 100× slowdown back at T within 70 min | `chain::daa::tests` | -| §5 header | green — bytes, id `bdfbbff9…`, 167/169 bytes, version 2, wrong chain; mined header `d1eec40d…` valid at 2^232, rejected at 2^228, nonce −1 fails | `chain::header::tests`, `chain::pow::tests` | -| §6 transactions | green — coinbase bytes/txid/leaf/node, transfer statement `c7e4a223…`, signed transfer verifies and fails on every byte flip / 101 / 103 bytes / other chain; cyberlink 257 B, valence 2 and p == q rejected; claim account `28ff5c6d…`, legacy owners `cca6f468…` and `d5e54c49…`; encoding rejections every kind | `chain::sigs::tests`, `chain::tx::tests`, `chain::tree::tests`, `chain::tests::rules` | -| §7 identities | green — `neuron_of`, tokens, topic `1f003a48…` | `chain::sigs::tests` | -| §8 state | green, blessed — empty root, conformance genesis (file, root, block 0), after block 1 and after a cyberlink in the engine, maturity 100 rejected / 101 accepted, conservation at every height of a 200-block random chain through a 3-block reorg | `conformance/chain.snap`, `chain::tests::{rules,simulate}` | -| §9 consensus | green — heaviest chain, first-seen ties, reorg from tip−120 rejected / tip−119 taken, checkpoint vetoes a heavier fork, foreign signer and past-sunset checkpoints dropped, PoW error before the body, wrong state root marks the header invalid, `time == MTP` rejected / `MTP + 1` accepted, `now + 121 s` held then accepted; three stores converge | `chain::tests::forks` | -| §10 genesis | green, blessed — pussy particle `2a83e54f…`, state root `f2296834…`, block-0 id `b863ce28…` reproduced by the kernel's own path (`Genesis::state` → `genesis::state::load`); bostrom stays red (lens commitment limit, audit/genesis-2026-10-15.md) | `chain::genesis::tests`, `conformance/chain.snap` | -| §11 wire | not this kernel — PR #187 (`chain/net`) | — | -| orphan pool | not this kernel — the net layer's pool | — | - -Blessed entries written by `soft3 conformance bless chain` (9): the three -genesis lines unchanged from the hand-written file, plus `chain::tx@v1` -signed transfer / cyberlink fingerprints and `chain::state@v1` empty root, -conformance genesis file / root / block 0. - -## caveats, stated plainly - -- Nonce placement: bbg at the integration base has no `NeuronRecord.nonce`; - the kernel keeps the nonce in a side map beside the state (feature - `bbg-nonce` off) — every rule holds, the nonce is not yet under - `state_root`. bbg branch `feat/chain-dims` (`97c6272`, 56 tests green - against bbg master's siblings) carries the field as the fourth neurons - leaf; when it lands with DIMENSION_VERSION 3, flip the feature default - and re-bless `chain.snap` in the same PR (every root moves). -- Conservation is checked from per-token running sums the state backend - maintains through its own trait (`Σ balances == supply` for every token, - native supply `== S0 + h·SUBSIDY`), not by `tok`: tok's `MintLedger` - owns settle-mint conservation over its own balance map and cannot sum - bbg balances; chain.md §3.4's "tok owns the arithmetic" is not met - literally. -- Chainwork of block 0 is 0 (genesis is not mined); chainwork(h) sums the - work of blocks 1..h. -- The 200-block simulation and the 130-block reorg scenarios run at a test - target of 2^255 through a `cfg(test)`-only override of `POW_LIMIT`; no - release path has that hook. -- `cargo test` of the crate takes ~210 s in debug: the pussy genesis root - (35 s) and the fork scenarios dominate. -- The pool (chain.md §11, in flight on `specs/pool-formats`) will move the - genesis `p` params and every blessed root; rule set 1 rejects every kind - ≥ 4 already and the activation table names no rule set 2 yet. diff --git a/audit/chain/economy-2026-10-17.md b/audit/chain/economy-2026-10-17.md deleted file mode 100644 index e742a9b3..00000000 --- a/audit/chain/economy-2026-10-17.md +++ /dev/null @@ -1,174 +0,0 @@ ---- -title: the focus economy in the kernel — implementation receipt 2026-10-17 -tags: cyber, soft3, audit, chain, economy, focus, staking, settlement, tru, bbg -crystal-type: audit -crystal-domain: cyber -date: 2026-10-17 ---- - -# the focus economy in the kernel — implementation receipt 2026-10-17 - -Receipt of the implementation of [chain-economy](../../specs/chain-economy.md) -(and the amended chain.md, chain-wire.md, chain-conformance.md §14) in -`crate/src/chain/`. Branch soft3 `feat/focus-economy` on `origin/main` -`518c2ae`. Requirements stay in the specs; this file records what was -built, what was run, and what was observed. - -Stand: `~/cyber/soft3/.worktrees/focus-economy/` — soft3 on the branch; -hemera `release/0.4` `7237a23`; bbg master `be5c5db` (bbg#51, dimension -version 4); tru master `1ea853e` (tru#40, the epoch engine); lens -`feat/hemera-0.4` `529adcb`; zheng and nox `release/0.4`; cybergraph -`feat/durable-applications`; foculus `b28f0a2`; mudra, tok master; strata, -honeycrisp, tade, radio, neuron, inf, file, joy, trident default checkouts. -bbg, tru, zheng, nox, cybergraph, foculus, mudra and tok still require -`cyber-hemera = "0.3"` on their default branches and carry a one-line -stand-local bump to `"0.4"` (the hemera 0.4 receipt's open item). Machine: -M4 Max, 48 GB, shared with other agents' builds. - -## what landed - -| spec | where | what | -|---|---|---| -| §0 | `params.rs` | every economy constant; `EpochClock { L, D, MIN_LOCK }` — `SPEC` on the launch chains, a shortened clock only on dev/test nets (`SOFT3_DEV_EPOCH=L:D:MIN_LOCK`, ignored for pussy and bostrom) | -| §3.1 | `wallet/tx.rs`, `economy/mod.rs` | `card_id`, `stake_key`, `focus_key`, payout leaf/node/root, φ digest | -| §3.2 | `bbg_state.rs` | positions as bbg `CardRecord { owner, particle, author, valence, since }` (dimension version 4); stakes and the pool as raw balance keys; zero rows absent | -| §3.3 | `apply.rs` | kind 2 with `amount > 0`: native only (`TxError::StakeToken`), valence fixed while staked (`TxError::Valence`), `amount + fee` spendable, stake to `stake_key`, card created or `since` restarted; `TxError::Box` retired | -| §3.4–§3.5 | `wallet/tx.rs`, `chain/tx.rs`, `net/codec.rs`, `apply.rs` | kind 6 unlock (120 B body, 224 B), kind 7 payout (144 B body, 248 B); NSIG1 over the chain.md §2.1 statement, subject `from`; unlock after `since + MIN_LOCK`, partial or full (card deleted at 0), fee payable from what it returns; kinds 8–10 rejected | -| §2, §5 | `economy/settle.rs`, `economy/run.rs`, `engine.rs` | at every boundary block the state is frozen (`Snapshot`, keyed by block id) and the epoch's settlement starts on a worker thread; the block at `boundary + D` joins it (waits if unfinished, never guesses); results keyed by the two boundary ids, cached under `$home/epochs/` (keyed by engine id and quadrature too) | -| §4.1 | `economy/weights.rs` | genesis weights from the genesis file at load, stored nowhere | -| §4.2–§4.4, §5.1 | `economy/focus.rs` | `FocusEngine` seam; the node runs tru's epoch engine (`Tru`, threads = cores − 1, ≤ 8); A_mid; the gradient integrated over k Gauss–Legendre nodes, k = 1 (the midpoint of §5.1) by default, k a parameter | -| §5.2–§5.6 | `economy/settle.rs` | Δs per card and side (a side switch is a decrease and an increase), active only, new-high rule with marks per axon side, ρ = 1, budget pool/4, 1 % cap, floor rounding, dust stays; payouts to the payout owner of `G_E`; marks rewritten; `time[settle(E)] = hemera(D_EPOCH ‖ record)` (198 B) | -| §6 | `apply.rs` | coinbase `SUBSIDY − cut + fees`, the pool takes `cut`; `focus_bps(E) = min(5,000, isqrt(active · 10^8 / supply))` fixed at the end of each boundary block; conservation counts stakes and the pool; Σ stake keys = running total checked at every settlement block | -| §11 | `economy_api.rs`, `node/chain_routes.rs`, `node/chain_node.rs` | `GET /epoch/{E}`, `/epochs`, `/focus`, `/focus/{particle}`, `/card/{id}`, `/cards/{neuron}`, `/economy`; `/status` gains epoch, next-boundary, last-settled, focus-bps, pool, active-stake, total-stake, stake-keys-match, settlement-ok, focus-engine | -| CLI | `bin/soft3.rs` | `soft3 chain tx --kind link --valence V --amount A`, `--kind unlock`, `--kind payout --payout OWNER` | -| §10 | `economy/vectors.rs`, `conformance.rs` | chain-conformance §14 scenarios driven through `apply::transition` (no PoW), blessed | - -High-water marks and the economy scalars (`focus_bps`, stake totals, -`last_settled`) are kernel state: pure functions of history, journaled with -the block's undo, not committed; their effects reach `state_root` through the -balances (payouts, coinbase) and `time` (the record). - -## tests - -`cargo test --release --no-default-features --features chain-conformance` -(in `crate/`, `__CARGO_TEST_ROOT` at the wrapper): **276 lib passed, 1 -ignored (the cost measurement), 17 bin, 2 adversary, 1 mine e2e — 0 -failed** (lib was 263 on `518c2ae`). 25 of the lib tests are the economy's. - -Economy tests (`chain::economy::*`, `chain::tests::economy::*`): - -- §14 identities and integers exactly as the spec quotes them (card, stake - key, focus key, payout leaf and node, φ digest of `[2^59, 2^59]`; - `focus_bps`, `cut`, mark decay 998,077 / 500,047, budget division - 333×3 and 10,000/250,000, `epoch`/`settle`); -- the reference engine's adjoint against a finite difference; tru's digest - equals hemera over tru's preimage; 1 and 8 threads bit-identical; - quadrature k = 1 and 2; -- new-high rule exact (`x` at mark = height is 0, one epoch of decay gives - `x/100`, the other side's mark does not limit), linearity in stake - (one author 100 vs 60 + 40), passive earns nothing, −1 on an absent axon - does nothing, hedging pays only the side with `a > 0`; -- engine end to end over five epochs: +1 on the dominant edge, a passive - lock, −1 against a spreading edge, a ring, 20 zero-stake spam links, a - payout owner change (paid to the owner of the snapshot), partial and full - unlock, an unlock-and-relock on the same side (`x` of the relock < 1/20 of - the first), the ring paid ≤ 1 % of its added stake; -- determinism: a second node fed the blocks has every epoch record equal; a - restart replays through the settlement cache to the same root and records; -- reorgs: three blocks above the boundary reproduce the record; a fork - below the boundary replaces it and changes the record; roots equal; -- off the block path: with a slow engine, four blocks validated while the - worker ran; `settle(1)` joined it; -- 2,000 random blocks of locks, unlocks and payout changes: conservation and - Σ stake keys at every height, settlements every 10 blocks; -- the node surface: `/epoch`, `/epochs`, `/focus`, `/card`, `/cards`, - `/economy`. - -## settlement cost (§7.2) - -Measured with the ignored test `settlement_cost_on_genesis_files` on the -real genesis files (`~/.cache/cyber/genesis/*.tade`, dimension version 4) -through tru `1ea853e`, epoch 1 = genesis weights plus 2,000 positions (half -+1, half −1, stake ½ of the axon's genesis weight) on genesis axons: - -| graph | axons | particles | φ*_0 (1 thread) | settlement: φ*_E + gradient at A_mid + attribution, 1 thread | same, 8 threads | peak RSS | -|---|---|---|---|---|---|---| -| pussy | 26,805 | 16,270 | 0.036 s | 0.12 s | 0.14 s | — | -| bostrom | 2,990,817 | 3,237,461 | 9.3 s | 27.0 s | 9.6 s | 5.0 GB (incl. the 331 MB file decode) | - -Digests equal across thread counts (bostrom φ*_0 `0b889535…`, pussy -`95606683…`). Against `SETTLE_BUDGET` = 900 s the bostrom settlement is -33× under on one core and 94× under on eight; a node starting at the -boundary has finished long before `settle(E)` an hour later. Genesis -weights: bostrom Σ G 344,014,106,319,158, 282,860 frames at the floor, -digest `d5f6f23c…`; pussy Σ G 41,802,483,829,015,338, 30 at the floor, -digest `218a8775…`. - -## genesis under dimension version 4 - -`soft3 genesis build` rebuilt both files into `/tmp`: byte-identical to the -cached files (pussy sha256 `ec7aee79…`, bostrom `ffa3d6dd…`; the chain ids -do not move). Only the roots move: - -| | state root | block-0 id | -|---|---|---| -| pussy | `d48fee478584d248123c63e2ee6c56224f159c7dadbc625f4261f6af49130371` (v3 `a3607987…`) | `73208ee2d43fbb0420119ceff23eab79e9d96921050e2a2137d0705c461852d4` | -| bostrom | `fa0c2c9404a6eff04737ed7dfe11c63c1992b4820fdf039a55c6d2f103d7508d` (v3 `c50a6d84…`) | `a13f0f627dae47a00fe50f7f9c538148345f52ab6200518e2360798c504fc3d5` | - -Bostrom root: 73 s wall, 91 s user, 7.9 GB max RSS. The reports -(`*.tade.json`) in `~/.cache/cyber/genesis/` were replaced; the files were -not touched. `conformance/chain-genesis.toml` carries the new values; -cyber/launch row 14 owes the same update. - -## conformance - -`soft3 conformance bless chain --genesis ~/.cache/cyber/genesis/pussy.tade`: -54 entries, 28 moved (every state root and block-0 id: dimension version -4), 26 new under `chain::economy@v1` and the pussy genesis entries (genesis -weights, φ*_0 at 1 and 8 threads). `soft3 conformance check chain` green. - -## the fleet - -`nu scripts/chain-fleet.nu --bin crate/target/release/soft3 --threads 1 ---secs 40 --epoch 6:3:9 --http-base 17980 --radio-base 17990 --keep` (the -`--epoch` flag is new: it exports `SOFT3_DEV_EPOCH` for the dev net and adds -an economy step). **GREEN**, every scenario of the hemera receipt plus: - -- the depth-3 fork from 14 (main 17, fork 19) replaced the boundary block - at 18 — a reorg across a boundary on four live nodes; every node moved - to the fork and settled from its snapshot; -- a staked link (`soft3 chain tx --kind link --valence 1 --amount 10^9`) - mined at 21; the records of epochs 4 and 5 equal on all four nodes - (`0d7c586e…`, `199931f0…`; budgets 225 and 675, pool 900 → 2,700; paid 0 - — the dev graph is that one axon, whose weight does not move a - two-particle φ*); `/cards` shows the position, unlock at 30; -- node 3 killed at 33, replayed from disk (through `$home/epochs/`), - synced to 45, conserved. - -Log `/tmp/fe-genesis/fleet-1.log`, artifacts `/tmp/chain-fleet.9K8DTD`. - -## findings for the owner and the spec - -1. **The new-high rule does not bound a quiet-epoch recycler.** Attribution - scales `x(c)` by `N/R`, but `pay = min(cap, ⌊B · x / X⌋)` normalises by - `X`: a relock that is the only earner of its epoch takes `min(cap, B)` - however small its `x`. The blessed new-high scenario shows it: the - first lock was paid 649,257, the relock under a mark two decays below the old - height (99.6 % of it) 1,785,458. The bound left is the 1 % cap per settlement and the - 7-day lock per cycle, not "the mark's decay" of §9. A fix that keeps - everything else: cap on the new height, `cap(c) = ⌊Δs · (N/R) · - R_MAX_PPM / 10^6⌋` (or scale the budget by `Σ x / Σ a⁺`). Not - implemented — a spec decision. -2. **Under J = D_KL(φ ‖ u), most new links lower syntropy.** Stake on a - minor edge spreads focus (∂J/∂A < 0) and earns nothing as +1; it earns - as −1. On the test graphs the earners are +1 on dominant edges and −1 - against minor ones. This is the spec's objective working as written; - the owner should see it before launch (rewards.md §1 "pay for descent"). -3. **The record's `syntropy: i64` overflows on bostrom.** J(φ*_0) is - 9,886,742,015,786,730,065 at 2^60 (8.58 nats) > i64::MAX; the kernel - saturates. tru's agent reports an amendment to i128 is coming; the record - grows to 230 bytes then. -4. **Hedging pays.** +1 and −1 of equal size on one axon leave A unchanged - and the +1 side is paid as if it had added weight (§9 says "capital - doubled for the same credit" — that is the behaviour; the credit is for - no change of the graph). diff --git a/audit/chain/economy-cost-2026-10-17.md b/audit/chain/economy-cost-2026-10-17.md deleted file mode 100644 index 4d2fadf9..00000000 --- a/audit/chain/economy-cost-2026-10-17.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -title: economy cost — φ* over the burial graphs with genesis weights, 2026-10-17 -tags: soft3, chain, audit, economy, tru, benchmark -crystal-type: audit -crystal-domain: cyber -date: 2026-10-17 ---- - -# economy cost — φ* over the burial graphs, 2026-10-17 - -Evidence for [chain-economy](../../specs/chain-economy.md) §7.2 and §8: -the cost of one cold tri-kernel solve over the full burial graphs with the -genesis weights of chain-economy §4.1, and the shape of those weights. - -## setup - -- machine: Apple M4 Max, one core, release build, shared machine; -- engine: tru's legacy fixed-point engine (`FocusingGraph::build` + - `focusing::focusing::iterate` with `derived_steps`, `FocusingParams::default()`, - `Fx` at 2⁻³²) from the checkout `~/cyber/tru` at `44725bd` (behind - origin/master by 47 commits; the focusing operators are the ones the - epoch engine starts from — its own figures replace these when it lands); -- input: `cyberlinks_indexed.csv.gz` of the pussy and bostrom burials - (`~/.cache/cyber/snapshots//`); one tru `Link` per source link, - `amount = max(1, floor(B(n) / L(n)))` with `B(n)` the author's native - holdings at halt (`holdings.jsonl` total, the amount genesis credits) and - `L(n)` the author's link count; `valence 0`, `price = ONE`, - `Context::none()`; particle ids are a stand-in 32-byte hash of the CID - string (the cost does not depend on the id function); -- harness: a 60-line binary in `/tmp/econ-spec/phibench` (source below, abridged by the timing print of the derived-step call). - -## results - -| graph | links | particles | λ_max | steps derived | build | per step | iterate | total | peak RSS | -|---|---|---|---|---|---|---|---|---|---| -| pussy | 29,112 | 15,967 | 10.40 | 24 | 0.141 s | 16.5 ms | 0.395 s | 0.82 s wall | — | -| bostrom | 2,949,732 | 2,914,272 | 2.17 | 19 | 24.3 s | 1.205 s | 22.9 s | 48.1 s wall | 2.78 GB | - -No particle received zero focus in either run. `build` includes the -spectral power iterations that estimate λ_max and λ₂ (180 matrix–vector -products). - -Projection used by chain-economy §7.2: at a fixed `PHI_STEPS = 48`, one -bostrom solve ≈ 24 + 48 × 1.2 ≈ 82 s; a settlement is two forward solves -and one adjoint, estimated at three solves ≈ 4–5 minutes of one core, -against the 3,600 s of `SETTLE_DELAY` blocks. The adjoint's cost is an -estimate until tru's epoch engine measures it. - -## genesis weights - -Computed from the same inputs (`/tmp/econ-spec/gw.py`), rule of -chain-economy §4.1: - -| chain | links | authors | Σ genesis weight | share of native holdings | frames at the floor of 1 | top author | top ten | -|---|---|---|---|---|---|---|---| -| pussy | 29,112 | 114 | 41,802,483,829,015,337 | 3.51 % | 30 | 15.2 % | 78.6 % | -| bostrom | 2,949,732 | 1,246 | 182,135,071,579,137 | 22.79 % | 278,118 | 39.1 % | 76.6 % | - -Concentration of links: the busiest bostrom author made 2,289,526 links -(77.6 %), the top ten 97.0 %; on pussy the top ten made 89.8 %. Resource -tokens: 1,104 of 1,246 bostrom authors and 111 of 114 pussy authors hold -milliampere at halt. Genesis-file counts (bostrom: 2,997,569 `l` frames -including 47,837 passports and 259,435 by legacy authors) differ from the -source-link counts above by the passports; the per-author shares move by -less than a percent. - -## harness - -```rust -// /tmp/econ-spec/phibench/src/main.rs — Cargo dep: tru = { package = "cyber-tru", path = "~/cyber/tru/rs" } -use std::collections::HashMap; -use std::hash::{Hash, Hasher}; -use std::time::Instant; -use tru::{FocusingGraph, FocusingParams, Context, Link}; - -fn id(s: &str) -> [u8; 32] { - let mut out = [0u8; 32]; - for k in 0..4u64 { - let mut h = std::collections::hash_map::DefaultHasher::new(); - k.hash(&mut h); s.hash(&mut h); - out[(k as usize) * 8..(k as usize) * 8 + 8].copy_from_slice(&h.finish().to_le_bytes()); - } - out -} - -fn main() { - let a: Vec = std::env::args().collect(); - let (links_csv, bal_csv, denom) = (&a[1], &a[2], &a[3]); - let mut bal: HashMap = HashMap::new(); - for l in std::fs::read_to_string(bal_csv).unwrap().lines().skip(1) { - let f: Vec<&str> = l.split(',').collect(); - if f[1] == denom { bal.insert(f[0].to_string(), f[2].parse().unwrap()); } - } - let text = std::fs::read_to_string(links_csv).unwrap(); - let mut raw = Vec::new(); - let mut cnt: HashMap = HashMap::new(); - for l in text.lines().skip(1) { - let f: Vec<&str> = l.split(',').collect(); - *cnt.entry(f[2].to_string()).or_default() += 1; - raw.push((f[0], f[1], f[2])); - } - let links: Vec = raw.iter().map(|(p, q, n)| { - let w = (bal.get(*n).copied().unwrap_or(0) / cnt[*n]).max(1); - Link { neuron: [0; 32], from: id(p), to: id(q), amount: w, valence: 0, price: tru::Fx::ONE } - }).collect(); - let t0 = Instant::now(); - let g = FocusingGraph::build(links, &Context::none()); - let tb = t0.elapsed(); - let p = FocusingParams::default(); - let steps = tru::focusing::focusing::derived_steps(&g, &p); - let t2 = Instant::now(); - let r = tru::focusing::focusing::iterate(&g, &p, steps); - let ti = t2.elapsed(); - let zero = r.focus.iter().filter(|x| **x == tru::Fx::ZERO).count(); - println!("links {} nodes {} lambda_max {:?} steps {} build {:?} iterate {:?} zero-focus {}", - raw.len(), g.n(), g.lambda_max(), steps, tb, ti, zero); -} -``` - -Note: the benchmark weights used `balances.csv` (liquid balance) for the -cost run and `holdings.jsonl` totals for the weight table; the cost does not -depend on which, the weight table follows chain-economy §4.1 (the genesis -balance includes returned delegations). - -Commands: - -``` -zcat < ~/.cache/cyber/snapshots/bostrom/cyberlinks_indexed.csv.gz > /tmp/econ-spec/bostrom_links.csv -CARGO_TARGET_DIR=/tmp/econ-spec/target cargo build --release -/usr/bin/time -l /tmp/econ-spec/target/release/phibench /tmp/econ-spec/bostrom_links.csv ~/.cache/cyber/snapshots/bostrom/balances.csv boot -``` diff --git a/audit/chain/hemera-0.4-2026-10-16.md b/audit/chain/hemera-0.4-2026-10-16.md deleted file mode 100644 index 4d8627d2..00000000 --- a/audit/chain/hemera-0.4-2026-10-16.md +++ /dev/null @@ -1,204 +0,0 @@ ---- -title: soft3 on hemera 0.4 — the phase-1 root chain re-derived under profile #15 -tags: cyber, soft3, audit, chain, hemera, genesis, conformance -crystal-type: audit -crystal-domain: cyber ---- - -# soft3 on hemera 0.4 — the phase-1 root chain re-derived under profile #15 - -Date: 2026-10-16. Sweep part 1b of hemera 0.4 (profile #15, merged on -hemera `release/0.4` `7237a23`; receipt `hemera/audit/profile-0.4-2026-10.md` -§5 names what soft3 must move). Branch soft3 `feat/hemera-0.4` on -`origin/main` `2d55fdb`. Contracts: `specs/chain.md`, -`specs/chain-conformance.md`, `specs/genesis.md`. - -Stand `~/cyber/.stands/S1B`, detached worktrees: soft3 `origin/main`, -hemera `origin/release/0.4` `7237a23`, bbg `origin/master` `eb152fa`, zheng -`origin/release/0.4` `09308d7`, lens `origin/release/0.2` `e11eaa8`, nox / -joy / trident `origin/release/0.4`, cybergraph `feat/durable-applications` -`0eae7ae`, mudra `refactor/spell` `4bfe3e8`, foculus -`feat/device-profile-choice` `b28f0a2`, honeycrisp `origin/main` (lens -`gpu/`'s `aruminium` path), tade / strata / tru / tok / radio / neuron / inf -/ file defaults. Machine: M4 Max, 48 GB, shared with other agents' builds -throughout (1-minute load 20–90). Build: `cargo build --release ---no-default-features` — the CPU miner only; the GPU miner follows lens -(agent S1a, lens `feat/hemera-0.4`, not yet on `release/0.2` when this ran). - -## 1. the dependency - -- `crate/Cargo.toml`, `conformance/rs/Cargo.toml`: `cyber-hemera` `0.3` → `0.4` - (path kept); both lockfiles move the path entry to 0.4.0. radio keeps its - own git-pinned `cyber-hemera` 0.3.1 (`d5f0a09`) inside the wire crates — - radio's sweep, not soft3's; the chain's topic ids are computed by soft3's - hemera. -- `release/phase1.toml`: hemera pin `main` `23f3bbc` → `release/0.4` - `7237a23afad38913a143da48010f3912f50d7684`, `updated = "2026-10-16"`. -- `conformance/rs/Cargo.toml` also moves bbg `0.2` → `0.3`, nox `0.2` → - `0.3`, zheng `0.3` → `0.4`, lens `0.1.3` → `0.2`: the committed requirements - did not resolve against any of the pinned siblings. -- **Every sibling still requires `cyber-hemera = "0.3"` with its path dep** - (bbg `rs/Cargo.toml`, cybergraph, mudra, foculus, lens workspace, nox, - joy, trident, tru, tok, zheng). Cargo refuses a 0.4.0 path crate for a - `^0.3` requirement, so each was bumped to `"0.4"` *in the stand only* - (uncommitted). bbg master does not "follow hemera automatically" until - its one-line bump lands; the same line is owed by every repo above - (hemera receipt §5, first paragraph). Nothing in soft3's dependency - graph uses the removed API (`Goldilocks::inv`, `PartialSbox`, - `Profile::new`, `RoundVisitor`); the `.inv()` calls in foculus, nox, lens - and strata are on nebu's field, not hemera's. - -## 2. the vectors (`specs/chain-conformance.md`) - -Every pinned digest in `crate/src/chain/{sigs,header,pow,tree,genesis, -net/codec}.rs`, `genesis/tests.rs`, `wallet/tests.rs`, `mine/{mod,bench, -target}.rs` and §1, §5, §6, §7, §12 of the spec was re-derived through the -chain's own code (a throwaway printer linking the crate; the tests then -assert the same values). The definitions did not move. - -| value | hemera 0.3 | hemera 0.4 | -|---|---|---| -| `hemera("")` | `a67a71b2…563c` | `9e89f2487be432e48c27e24e6945adffe6918c8b5d6a62061e70e0bebd657326` | -| `token("boot")` | `10275cf6…1269` | `1d443e1103abbd0aebe4abaf3fadc203160a471e9accb9b6e0a73804ea785b3b` | -| `token("pussy")` | `8cffbea0…8ffa` | `87a70ff9ed0638fc9fafb1a30f57c5ad2ebe5155c48702c50bdbb2ece87f6a71` | -| `hemera(168 zero bytes)` | `ba560d1f…1754` | `355fd03315a5ceb056cda53d11ff7b9755167182d59ff9e31134d69d33bdf0f6` | -| §5 test header id | `bdfbbff9…b58d` (byte 31 `0x8d`) | `bdd7f2ef3a5a1c573b8d84a7a2aeedc95c6f8aa2139eea5c83946b33c4180c31` (byte 31 `0x31`) | -| coinbase txid / leaf / node | `d7662bdb…` / `c2cb6adb…` / `3685e0a5…` | `6cf0e4d2…c3f9` / `514d5d57…fe9c` / `9d68ac9b…595b` | -| transfer statement | `c7e4a223…4a6f` | `41e14955…12a7` | -| `neuron_of(abandon pubkey)` | `31284576…d741` | `55c76c9f172b850eaa82e4c6defe13b1d80d983c29d81526eb94d2c8199b0fd2` | -| `legacy_owner(pussy, account_id)` / `(pussy, 0x99×20)` | `cca6f468…` / `d5e54c49…` | `4382c7f5…e373` / `5384913f…9f22` | -| `topic(0x44, "headers")` | `1f003a48…8b8b` | `598b5696…d7a8` | -| §12 tags digest (64 B) | `d4ea8f2b…816b` | `0056e480f212c69488b71289b7c3e170f015c6411267b915a1596fae4f9fc4a6` `4faaecefa7d0f0a8b75c0d76902da823b6c45ca46e8358f6578848e415dff0b1` | -| §12 `E_40` (genesis pool anchor) | `9a47bbbc…3aa5` | `3ce6bca0…d1eb` `8bcf4448…b395` | -| `T(token("pussy"))` | `ddd4a6ce…4ae1` | `4b41f4e2…1b05` | - -The full §12 table (owner, rhos, commitments, nullifier, roots, spend id, -vk set, statement id) is in the spec; `chain.snap` carries their -fingerprints. - -### the mined header, re-mined - -The §5 PoW-valid header (`bits = 0x1e010000`, `time = 1793885850`, the test -header's other fields) was re-mined, not replaced: a linear nonce search -from 0 through `Header::id` (= `hemera::hash` over the 168 bytes) and -`valid_pow` — the kernel's own path, no miner backend. - -``` -nonce 934636 = ec420e0000000000 0000000000000000 (0.3: 40165933) -block_id a3dc0cdb7c69e1d4986c388effe1612613b1b71f2c2ca49084bc0730da000000 -``` - -934,637 hashes; one thread 10.4 s = **90.3 kH/s on one core** (0.3: -64.4 kH/s, 623 s for its 40.2 M hashes) — 1.40× for the sponge over 168 -bytes, against 2.2× for the bare permutation (hemera receipt §3: the -sponge's four permutations are latency-bound like the single row). Twelve -threads found the same nonce (least index, 1.52 M hashes, 2.2 s). Byte 28 -is `0xda`, so the harness's rejection at `bits = 0x1d100000` (needs byte 28 -below `0x10`) holds; nonce 934,635 fails. `chain.md` §1.2, §1.3 and §8 -carry the new rate (order 0.9 MH/s per ten cores, floor block ≈ 12 s on -one core); the `mine bench` vector check and the backend-agreement test -search from base 934,000 for index 636. - -The CPU miner (`permute_batch`, 16 lanes) measured 91–104 kH/s per thread -inside the fleet below on the loaded machine; hemera's receipt measures 0.4 -batched at 0.6× of 0.3 batched, so the CPU miner is not faster on 0.4 even -though single hashing is. The miner's sponge view (`mine/job.rs`) is -unchanged: same rate, same limbs, same length lane. - -## 3. genesis - -`soft3 genesis build --chain --snapshot ~/cyber//snapshot ---out ~/cyber/.cache/genesis/.tade` (cache `~/cyber/.cache/snapshots/`, -pinned inputs by sha256; bbg dimension version 3). `derive.rs` re-derives -every particle (`hemera(cid)`, the black hole of launch row 20 — 74,514 -CIDs have no bytes on the cybernode) and every owner id. - -| | pussy | bostrom | -|---|---|---| -| particle (chain id) | `bae85f40d9f508664b5214107551be95d3f188d9585a899178c23e80886720e5` (0.3: `1bb7d6aa…`) | `f78c60edd6aea30af0da261233334e7549e63ece912a82c8d5b9e4df3cadb425` (0.3: `d984145f…`) | -| file | 3,249,657 B, sha256 `ec7aee793fe3019355a5ead95652640731dc9c632b6d400d7288fee7e6ee2d72` | 330,841,520 B, sha256 `ffa3d6dd89839224192f3fa268651fbb87923435f66de872b0f14c833e446443` | -| state root | `a360798987fb821f3bb7e07c66947e91e1eacb4278a834972c32ea8b3054b8b2` (0.3: `93e4660f…`) | `c50a6d8438ed8b0ef72450e7f83cab81e78bfd4e833106add9d62a0831bcdf13` (0.3: `ce3052f4…`) | -| block-0 id | `bcb1e9b0826283172993108dc22c52b457e1582f8148adf035046d976288092f` (0.3: `5669e96e…`) | `9ab9fc49967caa7c5324f877512254969fcb56fada55b57cf0ba0b2187d3aa67` (0.3: `be6f8900…`) | -| S0 / SUBSIDY / time | 1,099,389,810,279,114,036 / 10,458,426,657 / 1793885820 (unchanged) | 482,287,925,778,234 / 4,587,974 / 1793884962 (unchanged) | -| frames | b 1,385, l 29,113, 23 tokens (unchanged) | b 93,472, l 2,997,569, 49 coins (unchanged) | -| particles derived | 16,271 | 3,143,650 | -| wall / user / memory | 4.2 s / 0.85 s / 73 MB RSS | **327 s wall / 110.5 s user / 5.9 GB max RSS (7.35 GB peak footprint)**, root 236 s | -| bbg records | 0 particles, 6,685 / 15,061 axons out/in, 26,805 axons, 547 neurons | 0 particles, 1,701,082 / 1,772,963, 2,990,817 axons, 60,452 neurons | - -The file sizes, counts and sums are byte-for-byte the 0.3 shape; only the -ids moved. Previous run (0.3, same day): `audit/genesis-dimension-v3-2026-10-16.md` -(bostrom 204–268 s, 7.5 GB) — this run was slower on a machine at load -70–90, not from 0.4 (user time 110 s against 189 s). - -## 4. conformance - -- `soft3 conformance bless chain --genesis ~/cyber/.cache/genesis/pussy.tade`: - **30 of 30 entries moved** (every entry is a hemera value); `soft3 - conformance check chain` green against the rebuilt file; - `conformance/chain-genesis.toml` rewritten with both chains' values and - the hemera pin. -- `cargo test --release --no-default-features --features chain-conformance - --locked`: 26 of 215 lib tests failed on the old vectors before the sweep - (every one a pinned digest, none a logic failure); after it **215 lib + - 12 wallet + mine e2e + adversary pass** (sections 1–9, 11, 12; §10 - through the cached pussy file). The chain tests do not depend on zheng - proofs: no `zheng` reference in `crate/`. -- **The stack conformance harness (`conformance/rs`, snapshots - `mechanism.snap` / `encoding.snap` / `manifest.snap` / `provenance.toml`, - `surfaces/hemera.rs`) could not be re-blessed**: it links zheng, whose - `release/0.4` head `09308d7` does not compile against hemera 0.4 - (`RoundVisitor::partial_round` takes `[x², x³]` now — the gadget in - `rs/src/execution/hash.rs`, hemera receipt §5) once its `legacy` surface is - on, and the harness's `surfaces/zheng.rs` uses only that retired API - (`commit`/`open`/`verify_eval`/`row_hash`, gated behind `legacy` in 0.4). - Waits on S2 (zheng `feat/hemera-0.4`), then on a zheng-surface rewrite. - nox `release/0.4`'s hash pattern is still the 24-round 0.3 trace - (`rs/patterns/hash.rs`); its surface entries move again with nox's sweep. - The hemera surface's doc line and `conformance/specs/README.md` name 0.4; - the `.snap` files are untouched (still the 2026-10-10 bless). - -## 5. the fleet - -`nu scripts/chain-fleet.nu --threads 1 --keep --http-base 17980 --radio-base -17990` (the flags are new: the default range 17780/17790 was held by another -session's SSH tunnel and `pussy-rc` node, and the script only kills what it -spawned). Run 1 (20 s window, load 70–90, cargo builds running): peers -connected, 2 blocks in 20 s — RED on the `≥ 3` check: 2^20 expected hashes -per floor block at 91–104 kH/s per thread is ≈ 10 s per miner, 5 s per -block for two miners, and the window was short on a loaded machine (the -script's comment "a block per 4 s" is a 0.3-batched figure). Run 2 (40 s -window, load still 70–90): **GREEN** — peers connected (2 each); heights -agree at 5 across 3 nodes, block 1 `aea953a4…` identical everywhere; the -late joiner IBDs to height 5 by ranges; a tx submitted to node 1 is mined -at height 8 by node 2's miner and bob holds 5 on node 3; a fork of depth 3 -from height 8 (main 11, fork 13) reorgs every node to the heavier chain; -node 3 killed at height 13 restarts from disk, replays, syncs to 17, -conserved. Log: `~/cyber/.stands/S1B/fleet-2.log` (stand deleted after -the sweep; the run is reproducible with the command below). - -## 6. what waits - -- **S1a** (lens `feat/hemera-0.4` on `release/0.2`): the Metal / WebGPU - miner (`gpu/`: `ginv`, `RC[144]`, the shared inversion). This branch - builds `--no-default-features`; `every_backend_agrees_and_verifies` and - `mine bench` run the CPU backend only. When lens lands, the default - features build again and the GPU backends must find index 636 from base - 934,000. -- **S2** (zheng `feat/hemera-0.4`): the stack conformance re-bless (§4), the - zheng proof fixtures under `conformance/fixtures/`, and `surfaces/zheng.rs`. -- **sibling bumps** (`cyber-hemera = "0.4"`): bbg, cybergraph, mudra, - foculus, lens, nox, joy, trident, tru, tok, zheng — one line each; until - bbg's lands, soft3 main does not resolve against hemera `release/0.4` - without a stand-local edit. -- `cyber/launch.md` row 14 carries the new ids (cyber PR). - -## reproduce - -``` -cargo build --release --no-default-features # crate/ -cargo test --release --no-default-features --features chain-conformance --locked -soft3 genesis build --chain pussy --snapshot ~/cyber/space-pussy/snapshot --out ~/cyber/.cache/genesis/pussy.tade -soft3 genesis build --chain bostrom --snapshot ~/cyber/bostrom/snapshot --out ~/cyber/.cache/genesis/bostrom.tade -soft3 conformance check chain --genesis ~/cyber/.cache/genesis/pussy.tade -nu scripts/chain-fleet.nu --threads 1 --secs 40 --http-base 17980 --radio-base 17990 -``` diff --git a/audit/chain/mine-2026-10-15.md b/audit/chain/mine-2026-10-15.md deleted file mode 100644 index 72ba6ace..00000000 --- a/audit/chain/mine-2026-10-15.md +++ /dev/null @@ -1,124 +0,0 @@ ---- -title: chain mine — reference miner backends measured, ASERT step simulated -tags: cyber, soft3, audit, chain, mining, hemera, lens, metal, webgpu, asert -crystal-type: audit -crystal-domain: cyber -status: measured -date: 2026-10-15 ---- - -# chain mine — reference miner backends measured, ASERT step simulated - -Receipt for `soft3 mine` (`crate/src/chain/mine/`, branch `feat/chain-mine`) -against [specs/chain.md](../../specs/chain.md) §1.1–1.3, §1.5, §8 and -[chain-wire](../../specs/chain-wire.md) §7. Measured 2026-10-15 on an Apple -M4 Max (16 cores, macOS 26), release build, stand `~/cyber/.stands/N3` -(soft3 `83e9012` + this branch, lens `release/0.2` + `feat/mine-kernel` -= lens#28, hemera `origin/main`, honeycrisp `origin/main` for aruminium -`contents_ptr`). - -## 1. hashes per second, `soft3 mine bench --seconds 10` - -| backend | hashes | seconds | H/s | conformance §5 nonce | -|---|---|---|---|---| -| cpu (16 threads) | 30,146,560 | 10.1 | 3.00 MH/s | found | -| cpu (1 thread) | 1,392,640 | 5.1 | 275.4 kH/s | found | -| metal (Apple M4 Max) | 163,577,856 | 10.1 | 16.13 MH/s | found | -| webgpu (Apple M4 Max (Metal)) | 71,303,168 | 10.2 | 7.02 MH/s | found | - -One hash = four hemera permutations (168 bytes = 3 rate blocks + pad). -"found" = the backend returns index 933 for base 40,165,000 on the -chain-conformance §5 mined header (nonce 40,165,933) and the header verifies -with `hemera::hash`. - -Readings: - -- the spec's 64.4 kH/s per core (chain.md §1.2) is the scalar `hemera::hash` - path; hemera's 16-lane interleaved batch permutation (`permute_batch`, - one shared inversion per partial round) gives 275 kH/s on one core, 4.3×, - and 3.0 MH/s on the machine — the "0.6 MH/s per machine" estimate of §1.2 - is 5× low for a CPU miner written against the batch path; -- the first competent GPU kernel on the same chip is 16.1 MH/s: 5.4× the - CPU of the same machine, 58× one core. A discrete GPU with a comparable - kernel is expected an order above this (the M4 Max's 13 MH/s WebGPU - `permute2` path is bandwidth-free: everything stays in registers); -- WebGPU through wgpu on Metal reaches 44 % of native Metal (32-bit limb - arithmetic, two states a thread vs four with a shared inversion); it is - the Vulkan/Android/Linux path, good enough that a GTX 1080 (deimos, - chain.md §9.2) will mine on day one without an MSL port. - -## 2. what the genesis target implies - -`GENESIS_BITS` = 2^232 → 2^24 = 16,777,216 expected hashes a block; -T = 30 s → the network rate the target is sized for is **559 kH/s**. One -M4 Max CPU miner is 5.4× that (a block every 5.6 s at genesis), one M4 Max -GPU 29× (0.6 s). ASERT takes over within minutes (§3). The three cybernode -machines are x86 and were not measured; at the x86 batch path's expected -~150–250 kH/s a core they will not out-hash one laptop GPU. - -Consequence for §8's threat table: at launch the network's hash rate is -whatever the owner's machines plus a few volunteers bring, order 10–50 -MH/s; a single rented GPU box with a competent kernel (100+ MH/s) is the -majority miner, as the spec says — the reorg limit and the checkpoints -remain the only defence, and the reference miner shipping its GPU kernels -removes the *secret* kernel advantage, not the majority one. - -## 3. ASERT (H = 600 s, T = 30 s) under a 100× step — integer simulation - -`crate/src/chain/mine/asert_sim.rs` `asert_behaviour_and_hundredfold_step` -over the kernel's integer `daa::asert` (conformance §4 vectors green there, -including the floor-division and clamp rows), a deterministic miner model: a block at -target `t` and rate `r` takes `round(2^256 / (t+1) / r)` seconds, no -randomness, no floats. - -| scenario | result | -|---|---| -| T-spacing, 200 blocks | bits constant `0x1d100000` | -| T/2 spacing for 600 s of block time (40 blocks) | target halved exactly (`0x1d080000`), spec's [1.9, 2.1] | -| 100× step up | interval back to ≥ 27 s from block 192, **31 min** of wall time; 30 s after 1,000 blocks | -| 100× step up at the real anchor 2^232 | first block 0.3 s (rounds to 1), settled from block 212, same 31 min | -| 100× collapse (farm leaves after the step is absorbed) | first block **49 min**; blocks ≤ 60 s again after **62 min** | - -Readings: - -- chain.md §1.5's "a 10× step within about two hours" is conservative for - 100×: the lead the chain needs is `H · log2(100)` = 3,986 s, it grows at - most 30 s a block, so absorption needs ≥ 133 blocks — the blocks are fast - while it happens, so it is 192 blocks and 31 minutes of wall time; -- the collapse side matches the spec's "k = 100 → 66 min": the target - doubles every 600 s of wall time whether or not blocks arrive, so the - first post-collapse block takes 49 min and the chain is back under a - minute per block at 62 min. During that hour a farm that joined, mined - for an hour at 100× and left has taken ~190 blocks' subsidy and leaves - the chain nearly stalled for 50 minutes — the §8 residual, measured; -- H = 600 s therefore handles a 100× step in both directions within the - hour the spec budgets; the price is the 49-minute gap after a collapse. - A shorter half-life would shorten the gap and widen the timestamp-play - window (§1.5); the trade is the owner's and this receipt only measures it. - -## 4. conformance pinned by the miner's own tests - -- `target.rs`: the miner asks the kernel (`chain::header::decode_bits`, - `chain::pow::id_meets_target`, `chain::daa::asert`) and implements no - consensus of its own; on `feat/chain-mine` before the kernel merged the - same questions were answered by a local copy (`local.rs`) pinned to the - §2/§4/§5 vectors (all 13 bits rows, both headers, six ASERT rows), which - the branch `feat/chain-mine-on-kernel` deletes. -- `job.rs`: the lane-wise sponge equals `hemera::hash` for nonces in both - words of the 128-bit nonce. -- `mod.rs` `every_backend_agrees_and_verifies`: CPU, Metal and WebGPU - return the same index for the same dispatch — the conformance nonce, a - base in the high word, a base 20,000 below a 2^64 boundary, and a target - nothing passes. -- `tests/mine_e2e.rs`: two blocks mined against a mock node (a dropped - first request → retry, a stale template → refresh, PoW verified natively - by the mock, nonce salt in the high word). -- lens#28 `tests/differential.rs`: both kernels against lens's CPU - reference and `cyber_hemera::hash`. - -## 5. API added to the spec - -chain-wire §7 gains the JSON of `GET /template?to=`, `POST /submit` -(200/409/422/400) and `GET /tip`, as the reference miner speaks them; -`target` repeats `bits` decoded so an external worker needs no compact -codec. The node side of these routes belongs to the kernel/api work. diff --git a/audit/chain/net-2026-10-15.md b/audit/chain/net-2026-10-15.md deleted file mode 100644 index 1d893271..00000000 --- a/audit/chain/net-2026-10-15.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -title: chain/net — wire and node wiring of the phase-1 root chain, implementation receipt -tags: cyber, soft3, audit, chain, radio, tade, network -crystal-type: audit -crystal-domain: cyber -date: 2026-10-15 ---- - -# chain/net — implementation receipt, 2026-10-15 - -What specs/chain-wire.md asked for, what `crate/src/chain/net/` and the node -wiring deliver, and the evidence: unit/conformance tests, the three-node -harness, the adversarial tests. Branch `feat/chain-net`, PR soft3#187, -built in stand `.stands/N2` against soft3 main at 8525b17 (after #184/#185), -radio main 50600cc (≥ bc8b633), hemera/mudra/tade/bbg stand defaults. - -## modules - -| file | spec | lines | -|---|---|---| -| `chain/net/codec.rs` | chain.md §1–2: 168-byte header, four tx kinds, block id, txid/statement, compact target, work, tx root, topic ids | 640 | -| `chain/net/frames.rs` | chain-wire §2–3: the tade dialect `urn:cyberia:chain:1`, frames `(*,k)` + `(#, letter)`, strict bounded decoders | 400 | -| `chain/net/chain_view.rs` | the operations the wire asks of the kernel (trait; swap point) | 150 | -| `chain/net/devchain.rs` | minimal chain behind the trait for the harness: real PoW/MTP/heaviest chain/reorg bound/tx rules; constant target, digest root (dev only); genesis file codec §4.1; blocks.tade persistence | 760 | -| `chain/net/mempool.rs` | chain-wire §4: admission order, nonce window and per-neuron order, fee floor, 8192/16 MiB eviction by fee per byte, 24 h expiry, revalidation after a tip, templates | 330 | -| `chain/net/peers.rs` | chain-wire §5: token buckets (64 headers/s, 256 txs/s, 4 MiB/s, 16 streams/s; one second of tokens at start, 4× burst), score with 1/min decay, ban ≥ 100 for 1 h, 8 outbound / 64 inbound, /16 diversity (loopback exempt), bootstrap floor, anchors | 300 | -| `chain/net/transport.rs` | chain-wire §1: radio endpoint, ALPN `cyber/chain/1`, `radio.key`, bounded frame IO, one request per stream with 30 s timeout | 200 | -| `chain/net/serve.rs` | chain-wire §3: request streams `s g r b t k`, `x` codes 1–4, 8 streams per direction | 210 | -| `chain/net/gossip.rs` | chain-wire §2: headers/txs/checkpoints topics, relay only after validation (neighbor scope, so the relay is explicit), body on gossip +100, 4,096-byte cap | 220 | -| `chain/net/sync.rs` | chain-wire §3.1–3.2: status exchange, locator `g` loop (≤ 2,000, 64 rounds), bodies by id along the best header chain in ranges of 128, 4 in flight, 512-block buffer, strict height order, `catching-up` | 230 | -| `chain/net/discovery.rs` | chain-wire §6 + deploy/chain peers.toml v1: bootstrap file, anchors, the graph as address book (antenna/socket/follow kind-2 links, `wire.rs` packing), the dialer | 300 | -| `chain/net/mod.rs` | `Net`: one endpoint, three topics, router, the tasks | 290 | -| `node/chain_node.rs` | ChainNode: chain + mempool + net + CPU miner (sync-quorum gate, `--solo`), `/status` cybermark fields of §7 | 220 | -| `node/chain_routes.rs` | chain-wire §7: every route of the table; `/template`, `/submit`, `/tip` in the reference miner's shapes (soft3#186); legacy unsigned ingress → 410; `/dev/*` switches on dev chains only | 330 | -| `bin/soft3.rs` | `soft3 node --chain … --radio --peers`, `soft3 node id`, `soft3 chain genesis-dev|key|tx` | — | -| `scripts/chain-fleet.nu` | the three-node harness (ops skeleton extended) | 300 | -| `tests/chain_adversary.rs` | adversarial wire tests against a live node | 320 | - -Dependencies added to `crate/Cargo.toml`: `cyber-radio` and `iroh-gossip` -(path, radio stand), `tade`, `cyber-mudra` (`bridge`, shared with the wallet), -`tokio`, `n0-future`, `rand`, `toml`, `bytes`; `cyber-hemera` moved to the -path dependency every sibling uses (one copy). - -## conformance vectors (chain-conformance.md), in unit tests - -Sections 1 (hemera anchors), 2 (compact target, all 13 rows), 3 (work, 4 -rows + the 3-block chainwork), 5 (test header `bdfbbff9…`, mined header -`d1eec40d…` with nonce 40,165,933 valid at `GENESIS_BITS` and rejected at -2^228 and at nonce − 1), 6 (coinbase txid `d7662bdb…`, leaf `c2cb6adb…`, -node `3685e0a5…`, transfer statement `c7e4a223…`, cyberlink 257 bytes / -valence 2 / p == q, encoding rejections), 7 (topic id `1f003a48…`), 11 -(status 138 bytes, locator heights for tip 1,000, `count = 129 → x 3`, body -on gossip +100, 65th header dropped, wrong-chain → x 4 + disconnect, -`POST /tx` bad signature → 400, legacy → 410) — all green. Section 4 -(ASERT), 8 (bbg state), 9 (checkpoints/orphan pool) and 10 (genesis -pipeline) are the kernel's. - -``` -cargo test -p soft3 (lib) 133 passed (chain:: 31) -cargo test -p soft3 (bin) 11 passed -cargo test --test chain_adversary 2 passed -``` - -Decoder fuzzing (no external fuzzer; xorshift random and mutated inputs, -60,000 cases per run): `Header::decode`, `Tx::decode`, `decode_body`, -`decode_bits`, `Frame::decode`, `frames::split` never panic. - -## three-node harness — `nu scripts/chain-fleet.nu` - -Dev chain (`soft3 chain genesis-dev`, target 2^252, 1 s block pacing), -four processes on one machine, separate homes, HTTP 17780–17783, radio UDP -17790–17793, `peers.toml` v1 with real endpoint ids from `soft3 node id`. -Only the PIDs the script spawned are killed. - -``` - ok binary speaks the phase-1 flags - ok ports free: http 17780,17781,17782,17783 radio 17790,17791,17792,17793 - ok homes + genesis + peers under /tmp/chain-fleet.anSylo; wallet aaaeb678a… - ok all 3 nodes answer /status - ok peers connected: node-1=2 node-2=2 node-3=2 - ok heights agree at 11 across 3 nodes - ok late joiner synced to height 12 (IBD by ranges) - ok tx 9ec834607… submitted to node-1, mined at 15 by aa6877b76… (node-2 mines, node-1 paused), bob=5 on node-3 - ok fork of depth 3 from 15: main 18 reorged to the heavier chain 20, tip 9efb629c9… on every node - ok node-3 recovered from disk: height 20 before the kill, 20 after restart, conserved -chain-fleet: GREEN -``` - -Scenarios: peers connect (bootstrap dial + inbound), headers gossip and -bodies are fetched by id from the announcer (never pushed), a late-joining -node downloads the bodies by ranges and reports `catching-up: false`, a -transaction signed with the harness wallet crosses node 1 → mempool gossip → -mined by node 2 → indexed on node 3 (`/tx/{txid}`, `/balance`), an isolated -node mines a 5-block fork against the fleet's 3 and the fleet reorgs (depth 3) -to it on reconnection, a SIGKILLed node replays `blocks.tade` and rejoins with -`conserved: true`. - -Bugs the harness found and fixed on the way: the `s` reply rejected as a -request frame; the responder's dialect frame unexpected; gossip-only peer -entries (kept for scoring) blocking chain-level dials on both sides; -loopback peers counted against the /16 diversity cap; body fetch tied to the -announcer's connection; peer chainwork going stale between status exchanges; -a missing parent during a concurrent fork download marked the fork invalid. - -## adversarial — `cargo test --test chain_adversary` - -Against a live `soft3 node --chain dev --mine --solo`, from a radio endpoint -and an iroh-gossip instance in the test: - -| case | result | -|---|---| -| well-formed `s` | answered with the node's status, score 0 | -| `r` with `count = 129` | `x` code 3 | -| stream whose first frame is `e` (no dialect) | closed, +10 | -| malformed request frame (truncated `r`) | +10 (score 20) | -| frame declaring a 10 MiB payload | +100, banned, connection closed; a reconnect is refused | -| `s` with another chain id | `x` code 4 delivered, then +100 and disconnect | -| PoW-invalid header on the headers topic | +100, banned; the malicious server saw **zero** body requests (header-first held) | -| valid-PoW header announced, garbage body served on `b` | body requested, +10, peer kept (one bad block is not a ban) | -| 400 junk transactions in a burst | rate limit drops the excess (+1 each), decode/signature failures +10 → banned; node healthy | -| a body frame pushed on the headers topic | +100, banned | -| after all of it | `/health` ok, `conserved: true`, still mining | - -## open integration points - -1. **Kernel swap.** `feat/chain-kernel` exposes `chain::api::ChainApi` - (`&mut self`: `accept_header`, `accept_block(&Block, now)`, - `accept_checkpoint`, `validate_tx(bytes, pending)`, - `header_template(to, time, mempool)`, `take_events`, `status()`, - `locator`, `headers(from, count)`, `blocks(from, count)`, `balance`, - `nonce`, `supply`, `finalized_height`). The wire codes against - `net::chain_view::ChainView` (`&self`); the swap is one adapter - `Mutex → ChainView` plus: `missing_bodies` (walk the best - header chain back to the first held body), `headers_from_locator` - (locator → `ancestor_at` + `headers`), `spendable`/`balances`/ - `tx_location`/`counts`/`conserved` (kernel state queries or an index kept - by the node), `events()` (poll `take_events` into a broadcast). Mempool - admission can then call `validate_tx` instead of its own balance checks. - `devchain` stays as the harness chain until then. -2. `/v2/history` entries carry `height`, `block`, `txid` and the raw tx as - `chain-tx/v1-hex`; `CGOP-v1-hex` for cyberlink entries needs the kernel's - cybergraph apply. -3. The compiled bootstrap set (three cybernode endpoint ids) is empty until - `soft3 node id` runs on the machines (deploy/chain README step A7); the - peers file is the floor meanwhile. Entries with an empty `id` are skipped - (QUIC needs the id). -4. Address announcement: `discovery::address_links` builds the two - cyberlinks; publishing them from the node's neuron (fee 514 units) is not - automatic yet — it needs a funded neuron key on the node (`--miner` mines - to it, maturity 100 blocks). -5. Checkpoints: verified against `genesis.checkpoint_key`, stored, relayed, - served; the kernel's `accept_checkpoint` (finality veto) is the consumer. -6. Relay-after-validation is implemented with iroh-gossip's neighbor scope - (a validated message is re-broadcast to neighbors; echoes are `Known` and - not charged for 120 s). Swarm-scope plumtree would forward before - validation, which chain-wire §2 forbids. -7. Spec notes for chain-wire.md: the responder's side of a request stream - also opens with `(*, k)`; `s` is both a request and a reply frame; chain - frames are `(#, letter)` since tade frames carry a sigil; the `r` - get-range is served but IBD uses `b` by the validated header ids (exact, - no header-mismatch reassignments); loopback addresses are exempt from the - /16 rule so a fleet on one machine can form. -8. `soft3 mine --backend cpu` (soft3#186) is not merged; the harness mines - with the node's built-in CPU loop (`--mine`), which serves the same - `/template` + `/submit` shapes the reference miner speaks. diff --git a/audit/chain/pool-formats-2026-10-16.md b/audit/chain/pool-formats-2026-10-16.md deleted file mode 100644 index 31b42537..00000000 --- a/audit/chain/pool-formats-2026-10-16.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: chain — pool formats frozen at genesis, re-blessed 2026-10-16 -tags: cyber, soft3, audit, chain, conformance, genesis, privacy -crystal-type: audit -crystal-domain: cyber -date: 2026-10-16 ---- - -# chain — pool formats frozen at genesis, re-blessed 2026-10-16 - -Receipt of the change that writes chain.md §11 (the pool, rule set 2, -formats frozen at genesis), appends the 74-byte pool record to the genesis -`p` frame, and adds chain-conformance §12. Requirements stay in the specs; -this page holds the observed values and the commands. - -## stand - -`~/cyber/.stands/P11`: soft3 `specs/pool-formats` over main `0c010a3`; -siblings at the integration base main is locked against — bbg `1d39975` -(DIMENSION_VERSION 2, no `NeuronRecord.nonce`), cybergraph -`feat/durable-applications` `0eae7ae`, lens `release/0.2`, honeycrisp -`main`, mudra `master` `186c0f2`, hemera `main` `d5f0a09`, strata `main`, -tade `main`. bbg `master` (`ecaebc1`) and cybergraph `master` do not build -with soft3 main (no vendored fjall; no `local-storage` feature), so the -siblings were not moved to their default branches. - -## commands - -``` -cd soft3/crate -cargo test # lib, bin and doc tests -soft3 genesis build --chain pussy --snapshot ~/cyber/space-pussy/snapshot --cache ~/cyber/.cache/snapshots/pussy --out ~/cyber/.cache/genesis/pussy.tade -soft3 genesis build --chain bostrom --snapshot ~/cyber/bostrom/snapshot --cache ~/cyber/.cache/snapshots/bostrom --out ~/cyber/.cache/genesis/bostrom.tade -soft3 genesis verify ~/cyber/.cache/genesis/pussy.tade --root -soft3 conformance bless chain # 30 entries (9 before) -``` - -## observed - -Against hemera 0.3 (`d5f0a09`); to be re-blessed when hemera 0.4 (profile -#15, the profile the chain freezes) lands — every row below moves then, -except the state roots. - -| item | before (2026-10-15) | after | -|---|---|---| -| pussy genesis particle (chain id) | `2a83e54fe395ffbd924fc74a54a5c805eeec5396f97bf4c73fced8631a9e61b5` | `1bb7d6aaf85330e77ba68044b7f9b683ac8a0a5936709cffa3820ce9714826a7` | -| pussy state root | `f2296834490e79d855673b18b0f9eb5c8242e4f48761e85b645d60a8cbeb5827` | unchanged — params do not enter the state | -| pussy block-0 id | `b863ce2877524e718bb51ea6dc7c5aba9540860f08d70162348d8e84b3eba304` | `40f267b58818ef051fb3c1dfe7f9e673e36c85974d17f565cbcbb3556da12d68` | -| pussy file | 3,249,582 bytes | 3,249,657 bytes, sha256 `71747fcfd2be17991e801864187e595ee62f37bd16f114c6544aeeb511e962ce` | -| bostrom genesis particle | `50d829b8d4a9237386645652336269b4e6755ffd53f6c4bdf32f2783f4dc75db` | `d984145f3a4975eb4ebba55a35aa73c6641dfcf0bc5cebdb55d36a0578ea5901` (330,841,520 bytes); root still blocked by the lens limit | -| conformance genesis file / block-0 id | `40cccd00…` / `96c30f7a…` | `f27c1519…` / `aa2120ec…`; its state root unchanged | -| pool tags digest (64 B) | — | `d4ea8f2b…816b` | -| empty tree root `E_40` (64 B) | — | `9a47bbbc…3aa5` | -| `chain.snap` | 9 entries | 30 entries (21 `chain::pool@v1`) | - -The pool digests are 64 bytes, two squeezes of hemera's extendable output, -after the reconciled proposal (main `0c010a3`, §1.3: at least 48 bytes) and -the coordinator's choice of the hemera API over a hand-built node; the tree -node is `W(D_NODE ‖ L ‖ R)`, four permutations at hemera 0.3. Two drafts of -this section existed on the branch for a few hours (32-byte digests; a -full-rate squeeze with a two-permutation element node) and were never -merged. A later change of bbg's roots (DIMENSION_VERSION 3) moves the state -roots and block-0 ids again, not the particles. diff --git a/audit/chain/slice-2026-10-16.md b/audit/chain/slice-2026-10-16.md deleted file mode 100644 index d3dbf099..00000000 --- a/audit/chain/slice-2026-10-16.md +++ /dev/null @@ -1,163 +0,0 @@ -# the vertical slice of the phase-1 root chain — 2026-10-16 - -One block from genesis to a second machine, locally: the consensus kernel -(#189), the reference miner (#186) and the wire (#187) in one binary, three -nodes on one machine, every block mined by `soft3 mine --backend cpu` -against a node's `/template` + `/submit`, every header, body and transaction -crossing the radio wire to the other nodes and validated there by the -kernel. Requirements: `specs/chain.md`, `specs/chain-wire.md`, -`specs/chain-conformance.md`. Earlier receipts: `audit/chain/net-2026-10-15.md` -(the wire on its stand-in), `audit/chain/mine-2026-10-15.md`, -`audit/chain/conformance-2026-10-16.md`. - -## what changed - -- main: #186 merged on the kernel (`4527df7`, merge of - `feat/chain-mine-on-kernel` = #186 + the swap f1f4e94: the miner asks - `chain::{header, pow, daa}`, its local ASERT copy is gone). -- #187 brought onto main as one squashed commit (the branch carried a merge - of main whose own changes a commit-by-commit rebase would drop), then: - - `chain/net/devchain.rs` deleted; `chain/net/kernel.rs` implements the - wire's port `ChainView` over the kernel's `ChainApi`: header-first - acceptance, `NeedBodies` (the wire fetches the named bodies from the - peer and resubmits, `gossip::apply_fetched`; IBD takes the gap by - ranges), `FutureTime` held and retried each sync tick, `DeepFork` / - `Checkpoint` logged and never scored, `ChainError::Block` scores the - serving peer +10, tip-moved events carry the reorged-out bodies, a - main-chain txid index for `GET /tx/{txid}`. Headers and u256 cross as - their wire bytes; a test pins header ids, txids, bodies and tx roots on - both codecs. - - mempool admission asks the kernel (`validate_tx` with the neuron's lower - pending nonces); a nonce gap inside the window is not scored, a nonce - beyond it is (+10). The kernel's `validate_tx` now checks the signature - before nonce, fee and balance — chain-wire §4's order; the adversarial - tx flood (unsigned junk at fee 0) was going unscored without it. - - HTTP §7 on the kernel: `/tip` and `/template` rendered by - `Tip::json` / `Template::json`; `/nonce` `{neuron, nonce, confirmed}`; - `POST /tx` `{txid, kind, size, fee, account, nonce}` (already present = - 200); `GET /tx/{txid}` `{txid, kind, tx, height|"mempool", block, index}`, - 404 `{"error":"unknown"}`; `POST /submit` 200 / 409 stale / 422 with the - kernel's reason. `/status` `genesis` is the chain id (what the wallet - signs for), `genesis-block`, `next-bits` from ASERT, `finalized`. - - the node no longer mines (`--mine/--solo/--miner`, `/dev/mine` gone): - mining is `soft3 mine`. `soft3 chain genesis-dev` writes a real - `GenesisFile` (kernel-checked, launch names refused), `soft3 chain - key|tx` sign with `soft3::wallet::Signer`. - - gossip topic loops survive a lagged receiver instead of ending. - - `[profile.dev.package.cyber-hemera] opt-level = 3`: tests mine real - blocks at the floor target on debug builds. - - `scripts/chain-fleet.nu`: miners are `soft3 mine` processes; every pid - it spawns is recorded in `$work/pids` and killed by pid — the - `pkill -f` is gone. - -Test chain: `dev`, `bits = POW_LIMIT_BITS` (`0x1e100000`, 2^20 expected -hashes, the easiest target the kernel accepts outside `cfg(test)`), genesis -time two hours back so ASERT sits at the floor for the first ~240 blocks. -No launch genesis is touched; `genesis-dev` refuses `pussy`, `bostrom`, -`pussy-rc`. - -Hash: hemera 0.3 (`hemera d5f0a09`). Every hash on the slice goes through -the hemera API (`hemera::hash`, the kernel's `hash32`, the miner's sponge -view); the move to hemera 0.4 (profile #15) re-blesses PoW vectors, the -genesis particle and `chain.snap` in its own sweep. - -## stand - -`~/cyber/.stands/N2`, Apple M4 Max, macOS 26.4.1. soft3 on `slice/net` -over main `4527df7`. Siblings at the kernel receipt's integration base -(bbg `1d39975`, cybergraph `0eae7ae`, foculus `9e32aa6`, mudra `4bfe3e8`, -zheng `0c882e7`, joy `06de02c`), lens `e11eaa8` (release/0.2), nox -`2f09ca3` / trident `a3cef15f` (release/0.4), hemera `d5f0a09`, radio -`50600cc1`, tade `c7d404a`. Deviation from the requested pins: zheng -release/0.4 and bbg master do not build with soft3 main (bbg master lacks -the vendored fjall the chain store uses; bbg `1d39975` needs zheng's -`feat/public-certificate-v3` API), so both stay at the kernel receipt's -base. - -## one block from genesis to a second machine - -``` -cargo build --release --locked --bins # 1 m 34 s incremental -nu scripts/chain-fleet.nu --bin .target/release/soft3 --keep -``` - -``` -chain-fleet: 3 nodes · chain dev · binary …/.target/release/soft3 - ok binary speaks the phase-1 flags - ok ports free: http 17780,17781,17782,17783 radio 17790,17791,17792,17793 - ok homes + genesis + peers under /tmp/chain-fleet.wVWUzs; wallet aaaeb678a… -chain-fleet: launched pids 74396 74398 74400 - ok all 3 nodes answer /status -chain-fleet: soft3 mine --backend cpu --threads 1 on node-1 (pid 74421) and node-2 (pid 74423) - ok peers connected: node-1=1 node-2=1 node-3=2 - ok block 1 f9a96f3fc444d… mined by 111111111… is block 1 on every node - ok heights agree at 7 across 3 nodes -chain-fleet: late joiner node-4 pid 75323 - ok late joiner synced to height 8 (IBD by ranges) -txid 7d26e8969032a03206a0996e1402f2ad1a0280b4fc8c72af2bc0425edf6e0117 - ok tx 7d26e8969… submitted to node-1, mined at 10 by node-2's miner 222222222…, bob=5 on node-3 - ok fork of depth 3 from 10: main 13 reorged to the heavier chain 15, tip 2e253b946… on every node -chain-fleet: node-3 killed and relaunched as pid 76024 - ok node-3 recovered from disk: height 15 before the kill, 22 after restart (replayed, then synced), conserved -chain-fleet: artifacts kept in /tmp/chain-fleet.wVWUzs -chain-fleet: GREEN -``` - -Block 1, end to end (node-1's miner log, then node-2 and node-3): - -``` -soft3 mine · chain dev · node http://127.0.0.1:17780 · cpu (1 threads) · to 1111…1111 -template height 1 prev 50453f910886… bits 0x1e100000 (2^20 expected hashes) -found nonce 0x7d717dafd5726d8b00000000000ff34b after 1048576 hashes, 3.9s -block 1 accepted: f9a96f3fc444da08fdb30839140110d8c38ce8364922fbe31a4f410de3020000 -node-2 net · header f9a96f3f at 1 from 630a8dba -node-3 net · header f9a96f3f at 1 from 630a8dba -``` - -An earlier run the same day (`/tmp/chain-fleet.Q7vBAc`) was GREEN too: -heights 11, late joiner IBD of 11 blocks in one range, tx mined at 13 by -node-2's miner, depth-3 fork from 14 (main 17 → fork 19), node-3 19 → 20 -after restart. After both runs no process of the fleet was left. - -## the gates - -| gate | command | result | -|--|--|--| -| soft3 tests | `cargo test --locked` (crate/) | 208 lib + 12 bin + 2 adversarial + 1 miner e2e, all ok, with `HOME` holding a main-format pussy genesis (below) | -| chain conformance tests | `cargo test --locked --features chain-conformance --lib chain::` | 106 ok | -| adversarial set | `cargo test --locked --test chain_adversary` | 2 ok (7.8 s): oversize, malformed, no-dialect, wrong chain, PoW-invalid header (no body requested), bad block +10 not banned, tx flood banned, body on gossip banned — against a kernel node mined by `soft3 mine` | -| conformance | `soft3 conformance check chain --genesis /tmp/n2-genesis/pussy.tade` | `chain.snap: 9 entries, no drift` | -| `stack-chain-genesis` | `chain_genesis_gate(…, genesis_dir=/tmp/n2-genesis)` from `release/train_gates.py` | exit 0, no drift, recorded **red** — the gate fails on any `warning:` line, and bbg `1d39975`'s vendored fjall prints 3 (`journal_recovery_mode`, `outstanding_flushes`, `write_tx` lifetime); soft3 prints none. Same on main at this bbg pin. | - -The pussy genesis for the conformance rows was rebuilt from the pinned -snapshot with this build into a private directory: -`soft3 genesis build --chain pussy --snapshot ~/cyber/space-pussy/snapshot --out /tmp/n2-genesis/pussy.tade` -(4.4 s) → particle `2a83e54f…61b5`, state root `f2296834…5827`, block 0 -`b863ce28…a304`: the blessed vectors. The shared cache -`~/cyber/.cache/genesis/pussy.tade` was rewritten at 11:07 today by an -unmerged branch with a longer params record (pool formats); main cannot -decode it (`74 trailing bytes in frame`), so with the default `HOME` the two -cache-reading tests fail on main and on this branch alike. It was left as -it is. - -## what the deploy to cyberproxy, deimos and jupiter still needs - -- a release carrying #187 on the kernel (`cargo install soft3 --version - `), built against pins that build: bbg needs its fjall vendoring - on master (or soft3 moves off `bbg/rs/vendor/fjall`), and the - stack-chain-genesis gate needs those 3 warnings gone to turn green. -- the launch genesis files from `soft3 genesis build` with the final - params, the hemera 0.4 re-bless done, and the ids pinned in - `chain::params::CHAINS`; until then `--genesis FILE` on every machine, - byte-identical (step C T−15). -- miners: the nodes do not mine. Someone runs `soft3 mine --node - http://127.0.0.1:7781 --address ` (Metal/WebGPU/CPU) against at - least one node at genesis, or block 1 never comes. -- endpoint ids (`soft3 node id`) collected into `peers.toml` on each box, - ufw for 7791/udp, nginx snippets (README step A6), jupiter's root steps - by the owner (no sudo). -- not exercised here: three real machines over the internet (NAT, relay - off, clock skew), a replay at a real height (restart replays every body; - tens of blocks took seconds here), and `best_header` / `missing_bodies` - scanning the in-memory store on each call — fine at slice size, to index - before long chains. diff --git a/crate/Cargo.toml b/crate/Cargo.toml index 3c3cca13..252ceae3 100644 --- a/crate/Cargo.toml +++ b/crate/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "soft3" -version = "0.10.0" +version = "0.11.0" edition = "2021" -description = "soft3 — type 2 civilization software stack: network + real cybergraph node + CLI" +description = "soft3 — the type 2 civilization software stack: the ladder's contracts under one name; the node is the crate cybernode (rung 7), re-exported here" license-file = "LICENSE" repository = "https://github.com/cyberia-to/soft3" homepage = "https://soft3.org" readme = "README.md" -keywords = ["soft3", "cyber", "cybergraph", "spacepussy-test"] +keywords = ["soft3", "cyber", "cybergraph", "cybernode"] categories = ["command-line-utilities", "science"] rust-version = "1.85" @@ -22,51 +22,18 @@ path = "src/bin/soft3.rs" [workspace] [features] -# the reference miner ships with its GPU backends (specs/chain.md §8: the -# first competent GPU kernel is the majority miner, so it must not be a secret) +# the facade carries the node's features through, so `cargo install soft3` still mines on the GPU default = ["metal", "webgpu"] +metal = ["cybernode/metal"] +webgpu = ["cybernode/webgpu"] +chain-conformance = ["cybernode/chain-conformance"] +sim = ["cybernode/sim"] # full stack re-exports (heavy) stack = ["dep:cyb"] -# `soft3 mine --backend metal`: hemera_mine on the Apple GPU (Apple Silicon macOS; a no-op elsewhere, Intel macs included) -metal = ["dep:cyber-lens-gpu", "cyber-lens-gpu/metal"] -# `soft3 mine --backend webgpu`: hemera_mine through wgpu (Vulkan, DX12, Metal, GL) -webgpu = ["dep:cyber-lens-gpu", "cyber-lens-gpu/webgpu"] -# chain-conformance.md sections 1-9: vectors as tests (`cargo test --features chain-conformance`) -chain-conformance = [] -# out-of-tree simulators (cyberia-to/simulation): `params::sim_pow`, a -# process-wide relaxed POW_LIMIT for private test genesis files. Never in a node. -sim = [] [dependencies] -# real node engine -cybergraph = { version = "0.1.1", path = "../../cybergraph", features = ["local-storage"] } -foculus = { version = "0.1.1", path = "../../foculus", default-features = false } -hemera = { package = "cyber-hemera", version = "0.4", path = "../../hemera/rs" } -# header mining kernels (Metal, WebGPU) — lens release/0.2 `gpu/` -cyber-lens-gpu = { version = "0.1.1", path = "../../lens/gpu", optional = true } -# φ*, J and the syntropy gradient of the focus economy (chain-economy §4–§5; -# tru specs/epoch.md): the epoch engine every full node runs at settlement -tru = { package = "cyber-tru", version = "0.1", path = "../../tru/rs" } -# genesis pipeline: snapshot → manifest → bbg state root (specs/genesis.md) -bbg = { version = "0.3", path = "../../bbg/rs" } -# mudra: genesis reads legacy keys; wallet signs NSIG1 and derives keys from a spell (`bridge`); the chain verifies NSIG1 -mudra = { package = "cyber-mudra", version = "0.1.0", path = "../../mudra", default-features = false, features = ["bridge"] } -sha2 = "0.10" -bech32 = "0.11" -base64 = "0.22" -flate2 = "1" -# tade framing: the genesis file and the chain wire dialect -tade = { version = "0.1", path = "../../tade/impl/rust" } -# chain store (specs/chain.md §3.6): headers, bodies, undo journals -fjall = { version = "2", path = "../../bbg/rs/vendor/fjall" } -# phase-1 root chain wire (specs/chain-wire.md): radio transport, gossip -radio = { package = "cyber-radio", version = "0.1", path = "../../radio/iroh" } -iroh-gossip = { version = "0.96", path = "../../radio/iroh-gossip" } -tokio = { version = "1.44", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util"] } -n0-future = "0.3" -rand = "0.9" -toml = "0.8" -bytes = "1" +# rung 7, the node: the chain kernel, genesis, node, wallet, network — soft3/proposals/cybernode.md +cybernode = { version = "0.1", path = "../../cybernode/rs", default-features = false } # optional facade cyb = { version = "0.2.1", optional = true } # probe + http client for sync @@ -88,9 +55,14 @@ opt-level = 3 # node and a release node cannot disagree on an overflowing value # (plan P2a, decision D14). Scope: the node and the epoch engine it runs at # settlement; measured green with the flag proven on rustc, diagnostic -# 41192950342. `bench` inherits from `release`. +# 41192950342. `bench` inherits from `release`. Since 0.11 the node's code is +# the crate cybernode, and cargo reads profiles from this root manifest only, +# so the flag names cybernode here too. [profile.release.package.soft3] overflow-checks = true +[profile.release.package.cybernode] +overflow-checks = true + [profile.release.package.cyber-tru] overflow-checks = true diff --git a/crate/src/bin/soft3.rs b/crate/src/bin/soft3.rs index 67a51096..32c8678b 100644 --- a/crate/src/bin/soft3.rs +++ b/crate/src/bin/soft3.rs @@ -1,1091 +1,39 @@ -//! soft3 CLI — default network is **spacepussy-test** (soft3 chaosnet). +//! soft3 CLI — the stack facade. The operational verbs belong to the node: +//! `soft3 node|run|mine|sync|status|genesis|chain|conformance|network` forward +//! to `cybernode` for this release (0.11) and go away in the next +//! (soft3/proposals/cybernode.md § 3.7). `cargo install cybernode` is the +//! direct way. -use soft3::chain::mine; -use soft3::network::{self, Network}; -use soft3::node; -use std::path::PathBuf; +const FORWARDED: &[&str] = &[ + "node", "run", "mine", "sync", "status", "genesis", "chain", "conformance", "network", "net", +]; fn main() { - let mut args: Vec = std::env::args().skip(1).collect(); - if args.is_empty() { - print_help(); - return; - } - - let (names, rest) = extract_network_flags(&args); - let mut net = Network::DEFAULT; - for name in &names { - net = parse_net(name); - } - args = rest; - - let cmd = args.first().map(|s| s.as_str()).unwrap_or("help"); - match cmd { - "-V" | "--version" | "version" => { - println!("soft3 {}", env!("CARGO_PKG_VERSION")); - } + let args: Vec = std::env::args().skip(1).collect(); + let verb = args + .iter() + .find(|a| !a.starts_with('-') && a.as_str() != "-n") + .map(|s| s.as_str()) + .unwrap_or("help"); + match verb { + "-V" | "--version" | "version" => println!("soft3 {} (node: cybernode {})", env!("CARGO_PKG_VERSION"), cybernode::VERSION), "manifesto" => { for line in soft3::manifesto() { println!("{line}"); } } - "network" | "net" => { - let n = args.get(1).map(|s| parse_net(s)).unwrap_or(net); - print_network(n); - } - "status" | "sync" => cmd_sync(net), - "node" => cmd_node(&args[1..]), - "mine" => cmd_mine(&args[1..]), - "chain" => cmd_chain(&args[1..]), - "genesis" => cmd_genesis(&args[1..]), - "conformance" => cmd_conformance(&args[1..]), - "help" | "-h" | "--help" => print_help(), - other => { - eprintln!("unknown command `{other}`"); - print_help(); - std::process::exit(2); - } - } -} - -/// Split `--network`/`-n ` flags out of argv. Returns every requested -/// value in the order given (an empty string if a flag had no following -/// argument) alongside the remaining arguments, unvalidated — each name is -/// checked by [`parse_net`] in `main`, last one wins, matching argv order. -fn extract_network_flags(args: &[String]) -> (Vec, Vec) { - let mut names = Vec::new(); - let mut rest = Vec::new(); - let mut i = 0; - while i < args.len() { - if args[i] == "--network" || args[i] == "-n" { - i += 1; - names.push(args.get(i).cloned().unwrap_or_default()); - } else { - rest.push(args[i].clone()); - } - i += 1; - } - (names, rest) -} - -fn parse_net(name: &str) -> Network { - if let Some(n) = Network::parse(name) { - return n; - } - if Network::is_bootloader_name(name) { - eprintln!("`{name}` is a cosmos bootloader chain on cybernode — not a soft3 network."); - eprintln!( - "product default: {} ({})", - Network::DEFAULT, - Network::DEFAULT.role() - ); - eprintln!("use: soft3 sync # spacepussy-test"); - std::process::exit(2); - } - eprintln!("unknown network `{name}` (use spacepussy-test|test|soft3)"); - std::process::exit(2); -} - -fn cmd_sync(net: Network) { - println!("soft3 sync · {}", net.chain_id()); - println!(" role {}", net.role()); - println!(" rpc {}", net.rpc()); - match network::probe(net) { - Ok(s) => { - println!( - " reachable {}", - if s.reachable { "yes" } else { "no" } - ); - if !s.chain_id.is_empty() { - println!(" chain_id {}", s.chain_id); - } - if !s.moniker.is_empty() { - println!(" moniker {}", s.moniker); - } - if !s.engine.is_empty() { - println!(" engine {}", s.engine); - } - println!(" latest_height {}", s.latest_height); - if s.earliest_height > 0 { - println!(" earliest_height {}", s.earliest_height); - } - if !s.bbg_root.is_empty() { - println!(" bbg_root {}", s.bbg_root); - } - if s.signals > 0 || s.particles > 0 { - println!(" signals {}", s.signals); - println!(" particles {}", s.particles); - println!(" axons {}", s.axons); - } - println!( - " catching_up {}", - if s.catching_up { "yes" } else { "no" } - ); - } - Err(e) => { - println!(" reachable no"); - println!(" detail {e}"); - std::process::exit(1); - } - } -} - -/// Parsed `soft3 node` flags, before `--home`/`--bind`/`--moniker` defaults -/// are resolved into a running node. -struct NodeArgs { - home: PathBuf, - bind: String, - moniker: String, - help: bool, - // phase-1 root chain (specs/chain.md); `--chain` selects it over spacepussy-test - chain: Option, - home_set: bool, - bind_set: bool, - radio: Option, - peers: Option, - genesis: Option, - neuron: Option, - relay: bool, - log_net: bool, - corpus: Option, -} - -/// Why [`parse_node_args`] could not resolve a flag set. -enum NodeArgError { - /// A flag that takes a value (named by its usage string) was the last - /// argument, with nothing after it. - MissingValue(&'static str), - /// An argument matched none of the known flags. - Unknown(String), -} - -/// Parse `soft3 node`'s argv into [`NodeArgs`], starting from the caller's -/// defaults. Pure and side-effect free — every error is returned, never a -/// panic or a process exit, so the operator sees the same clean message a -/// mistyped `--home`/`--bind`/`--moniker` already got before this refactor, -/// instead of a panic unwind. -fn parse_node_args( - args: &[String], - default_home: PathBuf, - default_bind: String, - default_moniker: String, -) -> Result { - let mut out = NodeArgs { - home: default_home, bind: default_bind, moniker: default_moniker, help: false, chain: None, home_set: false, bind_set: false, - radio: None, peers: None, genesis: None, neuron: None, relay: false, log_net: false, corpus: None, - }; - let mut i = 0; - while i < args.len() { - let value = |i: &mut usize, what: &'static str| -> Result { - *i += 1; - args.get(*i).cloned().ok_or(NodeArgError::MissingValue(what)) - }; - match args[i].as_str() { - "--home" => { - out.home = PathBuf::from(value(&mut i, "--home needs a path")?); - out.home_set = true; - } - "--bind" => { - out.bind = value(&mut i, "--bind needs host:port")?; - out.bind_set = true; - } - "--moniker" => out.moniker = value(&mut i, "--moniker needs a name")?, - "--chain" => out.chain = Some(value(&mut i, "--chain needs a name (pussy|bostrom|dev…)")?), - "--radio" => out.radio = Some(value(&mut i, "--radio needs host:udp-port")?), - "--peers" => out.peers = Some(PathBuf::from(value(&mut i, "--peers needs a file")?)), - "--genesis" => out.genesis = Some(PathBuf::from(value(&mut i, "--genesis needs a file")?)), - "--neuron" => out.neuron = Some(value(&mut i, "--neuron needs a 64-hex neuron")?), - "--relay" => out.relay = true, - "--log-net" => out.log_net = true, - "--corpus" => out.corpus = Some(PathBuf::from(value(&mut i, "--corpus needs a directory")?)), - "-h" | "--help" => { - out.help = true; - return Ok(out); - } - other => return Err(NodeArgError::Unknown(other.to_string())), - } - i += 1; - } - Ok(out) -} - -/// `soft3 node id --home DIR` — the radio endpoint id (ed25519 public key, -/// 64 lowercase hex), created on first call; what peers.toml wants. -fn cmd_node_id(args: &[String]) { - let mut home = None; - let mut i = 0; - while i < args.len() { - if args[i] == "--home" { - i += 1; - home = args.get(i).map(PathBuf::from); - } else { - eprintln!("usage: soft3 node id --home DIR"); - std::process::exit(2); - } - i += 1; - } - let Some(home) = home else { - eprintln!("usage: soft3 node id --home DIR"); - std::process::exit(2); - }; - match node::chain_node::endpoint_id(&home) { - Ok(id) => println!("{id}"), - Err(e) => { - eprintln!("soft3 node id: {e}"); - std::process::exit(1); - } - } -} - -fn cmd_node(args: &[String]) { - if args.first().map(|s| s.as_str()) == Some("id") { - return cmd_node_id(&args[1..]); - } - let parsed = parse_node_args( - args, - node::default_home(), - Network::DEFAULT.local_bind().to_string(), - hostname_fallback(), - ); - let node_args = match parsed { - Ok(a) => a, - Err(NodeArgError::MissingValue(msg)) => { - eprintln!("{msg}"); - std::process::exit(2); - } - Err(NodeArgError::Unknown(flag)) => { - eprintln!("unknown node flag `{flag}`"); - std::process::exit(2); - } - }; - if node_args.help { - println!("soft3 node — run spacepussy-test (cybergraph + bbg) or a phase-1 root chain"); - println!(); - println!(" soft3 node [--home DIR] [--bind HOST:PORT] [--moniker NAME]"); - println!(" soft3 node --chain --home DIR --bind HOST:PORT --radio HOST:UDP --peers FILE --moniker NAME"); - println!(" [--genesis FILE] [--neuron NEURON] [--relay] [--log-net]"); - println!(" [--corpus DIR] # record every block given, with its verdict and state root (blocks.jsonl)"); - println!(" (mining is `soft3 mine --node http://BIND --address NEURON`: /template + /submit)"); - println!(" soft3 node id --home DIR # radio endpoint id (64 hex) for peers.toml"); - println!(" soft3 genesis build --chain pussy|bostrom|pussy-rc --snapshot DIR --out FILE"); - println!(" [--cache DIR] [--export DIR] [--no-root] [--time UNIX]"); - println!(" soft3 genesis verify FILE [--root]"); - println!(" soft3 conformance bless|check chain [--genesis FILE]"); - println!(); - println!("engine: cybergraph processor + authenticated bbg state; chain: PoW root chain (specs/chain.md, chain-wire.md)"); - println!("API: GET /status /stats /root POST /v1/link (chaosnet) · chain: GET /tip /block /tx /peers … POST /tx /submit"); - println!(); - println!("defaults:"); - println!(" --home ~/.spacepussy-test · chain: ~/.soft3/"); - println!(" --bind {} · pussy 127.0.0.1:7781 · bostrom 127.0.0.1:7782 · dev 127.0.0.1:17781", Network::DEFAULT.local_bind()); - println!(" --radio pussy 0.0.0.0:7791 · bostrom 0.0.0.0:7792 · dev 127.0.0.1:0"); - println!(" --moniker "); - return; - } - if let Some(chain) = node_args.chain { - let (http, radio) = match chain.as_str() { - "pussy" => ("127.0.0.1:7781", "0.0.0.0:7791"), - "bostrom" => ("127.0.0.1:7782", "0.0.0.0:7792"), - "pussy-rc" => ("127.0.0.1:7783", "0.0.0.0:7793"), - _ => ("127.0.0.1:17781", "127.0.0.1:0"), - }; - let bind = if node_args.bind_set { node_args.bind.clone() } else { http.to_string() }; - let radio = node_args.radio.clone().unwrap_or_else(|| radio.to_string()).parse().unwrap_or_else(|e| { - eprintln!("--radio: {e}"); - std::process::exit(2); - }); - let neuron = node_args.neuron.as_deref().map(|m| soft3::chain::net::codec::unhex32(m).unwrap_or_else(|| { - eprintln!("--neuron needs a 64-hex neuron id"); - std::process::exit(2); - })); - let opts = node::chain_node::ChainOpts { - home: if node_args.home_set { node_args.home } else { node::chain_node::default_home(&chain) }, - chain, genesis: node_args.genesis, radio: Some(radio), peers: node_args.peers, moniker: node_args.moniker, - neuron, - relay: node_args.relay, log_net: node_args.log_net, corpus: node_args.corpus, - }; - if let Err(e) = node::run_chain(opts, &bind) { - eprintln!("soft3 node failed: {e}"); - std::process::exit(1); - } - return; - } - if let Err(e) = node::run(node_args.home, &node_args.bind, &node_args.moniker) { - eprintln!("soft3 node failed: {e}"); - std::process::exit(1); - } -} - -/// Parsed `soft3 mine` flags. -#[derive(Debug, PartialEq, Eq)] -struct MineArgs { - bench: bool, - chain: String, - node: Option, - address: Option, - backend: String, - threads: usize, - seconds: u64, - help: bool, -} - -/// Parse `soft3 mine`'s argv. Pure: every error is returned. -fn parse_mine_args(args: &[String]) -> Result { - let mut a = MineArgs { - bench: false, - chain: "pussy".into(), - node: None, - address: None, - backend: "auto".into(), - threads: 0, - seconds: 5, - help: false, - }; - let mut i = 0; - let value = |args: &[String], i: usize, what: &'static str| -> Result { - args.get(i).cloned().ok_or(NodeArgError::MissingValue(what)) - }; - while i < args.len() { - match args[i].as_str() { - "bench" if i == 0 => a.bench = true, - "--chain" => { - i += 1; - a.chain = value(args, i, "--chain needs pussy|bostrom")?; - } - "--node" => { - i += 1; - a.node = Some(value(args, i, "--node needs a URL")?); - } - "--address" | "--to" => { - i += 1; - a.address = Some(value(args, i, "--address needs a neuron (64 hex)")?); - } - "--backend" => { - i += 1; - a.backend = value(args, i, "--backend needs auto|cpu|metal|webgpu")?; - } - "--threads" => { - i += 1; - a.threads = value(args, i, "--threads needs a number")? - .parse() - .map_err(|_| NodeArgError::Unknown("--threads: not a number".into()))?; - } - "--seconds" => { - i += 1; - a.seconds = value(args, i, "--seconds needs a number")? - .parse() - .map_err(|_| NodeArgError::Unknown("--seconds: not a number".into()))?; - } - "-h" | "--help" => a.help = true, - other => return Err(NodeArgError::Unknown(other.to_string())), - } - i += 1; - } - Ok(a) -} - -/// The node of a chain when `--node` is absent (chain-wire §7 binds). -fn default_node(chain: &str) -> Option { - match chain { - "pussy" => Some("http://127.0.0.1:7781".into()), - "bostrom" => Some("http://127.0.0.1:7782".into()), - _ => None, - } -} - -fn cmd_mine(args: &[String]) { - let a = match parse_mine_args(args) { - Ok(a) => a, - Err(NodeArgError::MissingValue(msg)) => { - eprintln!("{msg}"); - std::process::exit(2); - } - Err(NodeArgError::Unknown(flag)) => { - eprintln!("unknown mine flag `{flag}`"); - std::process::exit(2); - } - }; - if a.help { - println!("soft3 mine — reference miner of the phase-1 root chain (specs/chain.md)"); - println!(); - println!(" soft3 mine --address NEURON [--chain pussy|bostrom] [--node URL]"); - println!(" [--backend auto|cpu|metal|webgpu] [--threads N]"); - println!( - " soft3 mine bench [--backend auto|cpu|metal|webgpu] [--threads N] [--seconds S]" - ); - println!(); - println!( - "grinds hemera(header) < target over the 16-byte nonce; GET /template, POST /submit" - ); - println!("(chain-wire §7); the template is refreshed when GET /tip moves."); - println!(); - println!("defaults:"); - println!(" --chain pussy"); - println!(" --node http://127.0.0.1:7781 (pussy) · :7782 (bostrom)"); - println!(" --backend auto (metal, else webgpu, else cpu)"); - println!(" --threads every core (cpu)"); - println!(" --seconds 5 (bench)"); - return; - } - let choice: mine::Choice = match a.backend.parse() { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(2); - } - }; - if a.bench { - let backends = match choice { - mine::Choice::Auto => mine::all(a.threads), - c => match mine::select(c, a.threads) { - Ok(b) => vec![b], - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }, - }; - let mut rows = Vec::new(); - for b in &backends { - eprintln!("measuring {} for {}s…", b.name(), a.seconds); - match mine::bench::measure(b.as_ref(), a.seconds as f64) { - Ok(r) => rows.push(r), - Err(e) => eprintln!("{}: {e}", b.name()), - } - } - print!("{}", mine::bench::table(&rows)); - if rows.iter().any(|r| !r.vector) { - std::process::exit(1); - } - return; - } - let Some(address) = a.address else { - eprintln!("--address NEURON is required (the coinbase goes to it)"); - std::process::exit(2); - }; - if address.len() != 64 || !address.bytes().all(|b| b.is_ascii_hexdigit()) { - eprintln!("--address must be a neuron id: 64 hex characters"); - std::process::exit(2); - } - let Some(node) = a.node.or_else(|| default_node(&a.chain)) else { - eprintln!( - "unknown chain `{}` (pussy|bostrom), or pass --node", - a.chain - ); - std::process::exit(2); - }; - let backend = match mine::select(choice, a.threads) { - Ok(b) => b, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - let settings = mine::run::Settings { - chain: a.chain, - node, - address: address.to_lowercase(), - tip_poll: std::time::Duration::from_secs(2), - refresh: std::time::Duration::from_secs(30), - report: std::time::Duration::from_secs(5), - }; - let stop = std::sync::atomic::AtomicBool::new(false); - if let Err(e) = mine::run::run(backend.as_ref(), &settings, &stop, &|m| println!("{m}")) { - eprintln!("soft3 mine failed: {e}"); - std::process::exit(1); - } -} - -/// `soft3 chain …` — helpers for test chains and the local harness: a test -/// genesis (any name but a launch chain's, the easiest target the kernel -/// accepts), a key's neuron, a signed transfer or cyberlink (the wallet's -/// codec, `soft3::wallet`). -fn cmd_chain(args: &[String]) { - use soft3::chain::net::codec::{hex, unhex32}; - use soft3::genesis::file::{Balance, GenesisFile, Params}; - let mut flags = std::collections::HashMap::new(); - let mut funds = Vec::new(); - let mut i = 1; - while i < args.len() { - let key = args[i].trim_start_matches("--").to_string(); - let value = args.get(i + 1).cloned().unwrap_or_default(); - if key == "fund" { funds.push(value); } else { flags.insert(key, value); } - i += 2; - } - let die = |msg: String| -> ! { - eprintln!("{msg}"); - std::process::exit(2); - }; - let wallet = |flags: &std::collections::HashMap| -> soft3::wallet::Signer { - let seed = flags.get("seed").and_then(|s| unhex32(s)).unwrap_or_else(|| die("--seed <64 hex> required".into())); - soft3::wallet::Signer::from_secret(&seed).unwrap_or_else(|e| die(format!("seed: {e}"))) - }; - let num = |flags: &std::collections::HashMap, k: &str, d: u64| -> u64 { - flags.get(k).map(|v| v.parse().unwrap_or_else(|_| die(format!("--{k}: not a number")))).unwrap_or(d) - }; - match args.first().map(|s| s.as_str()) { - Some("genesis-dev") => { - let name = flags.get("name").cloned().unwrap_or_else(|| "dev".into()); - if node::chain_node::LAUNCH_CHAINS.contains(&name.as_str()) { - if name == "pussy-rc" { - die("`pussy-rc` is a launch chain; its genesis comes from `soft3 genesis build --chain pussy-rc --time T`".into()); - } - die(format!("`{name}` is a launch chain; its genesis comes from `soft3 genesis build`")); - } - let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - // two hours of ASERT slack: the first ~240 blocks stay at the floor target - let time = num(&flags, "time", now.saturating_sub(7200)); - let bits = flags.get("bits").map(|b| u32::from_str_radix(b.trim_start_matches("0x"), 16).unwrap_or_else(|_| die("--bits: 8 hex".into()))) - .unwrap_or(soft3::chain::params::POW_LIMIT_BITS); - let native = soft3::chain::sigs::token(&name); - let mut balances: Vec = funds.iter().map(|f| { - let (n, a) = f.split_once('=').unwrap_or_else(|| die(format!("--fund {f}: NEURON=AMOUNT"))); - Balance { owner: unhex32(n).unwrap_or_else(|| die(format!("--fund {f}: neuron is 64 hex"))), token: native, amount: a.parse().unwrap_or_else(|_| die(format!("--fund {f}: amount"))) } - }).collect(); - balances.sort(); - let s0: u64 = balances.iter().map(|b| b.amount).sum(); - let file = GenesisFile { - params: Params { - version: 1, name: name.clone(), hrp: name.clone(), denom: name.clone(), time, bits, - subsidy: soft3::chain::params::subsidy(s0), checkpoint_key: [0; 33], checkpoint_sunset: soft3::chain::params::CHECKPOINT_SUNSET, - pool: soft3::chain::pool::Pool::genesis(), - }, - balances, - links: vec![], - }; - let genesis = soft3::chain::genesis::Genesis::from_file(&file).unwrap_or_else(|e| die(format!("genesis: {e}"))); - let out = flags.get("out").cloned().unwrap_or_else(|| "genesis.tade".into()); - std::fs::write(&out, &genesis.bytes).unwrap_or_else(|e| die(format!("{out}: {e}"))); - println!("{}", hex(&genesis.chain_id)); - } - Some("key") => println!("{}", wallet(&flags).neuron_hex()), - Some("tx") => { - let w = wallet(&flags); - let bytes = flags.get("genesis").and_then(|g| std::fs::read(g).ok()).unwrap_or_else(|| die("--genesis required (the chain id is its particle)".into())); - let genesis = soft3::chain::genesis::Genesis::decode(&bytes).unwrap_or_else(|e| die(format!("genesis: {e}"))); - let token = genesis.native_token(); - let nonce = num(&flags, "nonce", 0); - let amount = num(&flags, "amount", 0); - let fee = flags.get("fee").map(|_| num(&flags, "fee", 0)); - let signed = match flags.get("kind").map(|k| k.as_str()).unwrap_or("transfer") { - // chain-economy §3: amount > 0 locks native stake with the valence - "link" => { - let p = flags.get("p").and_then(|p| unhex32(p)).unwrap_or([1; 32]); - let q = flags.get("q").and_then(|q| unhex32(q)).unwrap_or([2; 32]); - let valence: i8 = flags.get("valence").map(|v| v.parse().unwrap_or_else(|_| die("--valence: -1, 0 or 1".into()))).unwrap_or(0); - w.cyberlink(&genesis.chain_id, nonce, p, q, valence, token, amount, fee) - } - "unlock" => { - let p = flags.get("p").and_then(|p| unhex32(p)).unwrap_or([1; 32]); - let q = flags.get("q").and_then(|q| unhex32(q)).unwrap_or([2; 32]); - w.unlock(&genesis.chain_id, nonce, p, q, amount, fee) - } - "payout" => { - let p = flags.get("p").and_then(|p| unhex32(p)).unwrap_or([1; 32]); - let q = flags.get("q").and_then(|q| unhex32(q)).unwrap_or([2; 32]); - let to = flags.get("payout").and_then(|t| unhex32(t)).unwrap_or_else(|| die("--payout <64 hex> required".into())); - w.payout(&genesis.chain_id, nonce, p, q, to, fee) - } - _ => { - let to = flags.get("to").and_then(|t| unhex32(t)).unwrap_or([7; 32]); - w.transfer(&genesis.chain_id, nonce, token, amount, to, fee) - } - } - .unwrap_or_else(|e| die(format!("sign: {e}"))); - println!("{}", hex(&signed.bytes)); - eprintln!("txid {}", signed.txid_hex()); - } - _ => { - eprintln!("usage: soft3 chain genesis-dev [--name dev] [--time T] [--bits HEX] [--fund NEURON=AMOUNT]… --out FILE"); - eprintln!(" soft3 chain key --seed HEX32"); - eprintln!(" soft3 chain tx --seed HEX32 --genesis FILE --nonce N [--kind transfer|link|unlock|payout] [--to N] [--amount A] [--fee F] [--p P --q Q] [--valence V] [--payout OWNER]"); - std::process::exit(2); - } - } -} - -/// Real-environment hostname fallback: `$HOSTNAME`, else `$HOST`, else the -/// literal default. Thin wrapper over [`hostname_fallback_from`] so the -/// fallback chain itself is testable without mutating process-wide env vars. -fn hostname_fallback() -> String { - hostname_fallback_from(|k: &str| std::env::var(k)) -} - -fn hostname_fallback_from Result>(get: F) -> String { - get("HOSTNAME") - .or_else(|_| get("HOST")) - .unwrap_or_else(|_| "soft3-node".into()) -} - -fn print_network(n: Network) { - println!("network {}", n.chain_id()); - println!(" role {}", n.role()); - println!(" prefix {}", n.bech32_prefix()); - println!(" denom {}", n.denom()); - println!(" rpc {}", n.rpc()); - println!(" lcd {}", n.lcd()); - println!(" index {}", n.index()); - println!(" ws {}", n.websocket()); - println!(" bind {}", n.local_bind()); - if n == Network::DEFAULT { - println!(" (product default)"); - } -} - -fn print_help() { - println!( - "soft3 {} — type 2 civilization software", - env!("CARGO_PKG_VERSION") - ); - println!(); - println!( - "default network: {} ({})", - Network::DEFAULT, - Network::DEFAULT.rpc() - ); - println!(" {}", Network::DEFAULT.role()); - println!(); - println!("usage:"); - println!(" soft3 sync [--network spacepussy-test]"); - println!(" soft3 status # alias of sync"); - println!(" soft3 network [name] # print endpoints"); - println!(" soft3 node [--home DIR] [--bind HOST:PORT] [--moniker NAME]"); - println!(" soft3 mine --address NEURON [--chain pussy|bostrom] [--node URL]"); - println!(" [--backend auto|cpu|metal|webgpu] [--threads N]"); - println!(" soft3 mine bench [--backend ...] [--threads N] [--seconds S]"); - println!(" soft3 node --chain --home DIR --bind HOST:PORT --radio HOST:UDP --peers FILE --moniker NAME"); - println!(" soft3 node id --home DIR"); - println!(" soft3 chain genesis-dev|key|tx … # dev helpers"); - println!(" soft3 genesis build --chain pussy|bostrom|pussy-rc --snapshot DIR --out FILE"); - println!(" [--cache DIR] [--export DIR] [--no-root] [--time UNIX]"); - println!(" soft3 genesis verify FILE [--root]"); - println!(" soft3 manifesto"); - println!(" soft3 version"); - println!(); - println!("not soft3 networks (cosmos bootloader on cybernode):"); - println!(" space-pussy · bostrom"); - println!(); - println!("docs https://cyber.page/soft3/docs/launch"); - println!("site https://soft3.org"); -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - - fn defaults() -> (PathBuf, String, String) { - ( - PathBuf::from("/default/home"), - "127.0.0.1:1717".to_string(), - "default-moniker".to_string(), - ) - } - - #[test] - fn node_args_no_flags_keeps_every_default() { - let (home, bind, moniker) = defaults(); - let parsed = parse_node_args(&[], home.clone(), bind.clone(), moniker.clone()) - .ok() - .unwrap(); - assert_eq!(parsed.home, home); - assert_eq!(parsed.bind, bind); - assert_eq!(parsed.moniker, moniker); - assert!(!parsed.help); - } - - #[test] - fn node_args_all_three_flags_override_defaults() { - let (home, bind, moniker) = defaults(); - let args = vec![ - "--home".to_string(), - "/tmp/x".to_string(), - "--bind".to_string(), - "0.0.0.0:9000".to_string(), - "--moniker".to_string(), - "node-a".to_string(), - ]; - let parsed = parse_node_args(&args, home, bind, moniker).ok().unwrap(); - assert_eq!(parsed.home, PathBuf::from("/tmp/x")); - assert_eq!(parsed.bind, "0.0.0.0:9000"); - assert_eq!(parsed.moniker, "node-a"); - assert!(!parsed.help); - } - - #[test] - fn node_args_help_flag_short_circuits() { - let (home, bind, moniker) = defaults(); - let args = vec!["--help".to_string()]; - let parsed = parse_node_args(&args, home, bind, moniker).ok().unwrap(); - assert!(parsed.help); - } - - #[test] - fn node_args_home_missing_value_errors_instead_of_panicking() { - let (home, bind, moniker) = defaults(); - let args = vec!["--home".to_string()]; - let err = parse_node_args(&args, home, bind, moniker).err().unwrap(); - assert!(matches!( - err, - NodeArgError::MissingValue("--home needs a path") - )); - } - - #[test] - fn node_args_bind_missing_value_errors_instead_of_panicking() { - let (home, bind, moniker) = defaults(); - let args = vec!["--bind".to_string()]; - let err = parse_node_args(&args, home, bind, moniker).err().unwrap(); - assert!(matches!( - err, - NodeArgError::MissingValue("--bind needs host:port") - )); - } - - #[test] - fn node_args_moniker_missing_value_errors_instead_of_panicking() { - let (home, bind, moniker) = defaults(); - let args = vec!["--moniker".to_string()]; - let err = parse_node_args(&args, home, bind, moniker).err().unwrap(); - assert!(matches!( - err, - NodeArgError::MissingValue("--moniker needs a name") - )); - } - - #[test] - fn node_args_unknown_flag_is_rejected() { - let (home, bind, moniker) = defaults(); - let args = vec!["--bogus".to_string()]; - let err = parse_node_args(&args, home, bind, moniker).err().unwrap(); - match err { - NodeArgError::Unknown(flag) => assert_eq!(flag, "--bogus"), - _ => panic!("expected Unknown"), - } - } - - #[test] - fn node_args_last_flag_wins_on_repeats() { - let (home, bind, moniker) = defaults(); - let args = vec![ - "--moniker".to_string(), - "first".to_string(), - "--moniker".to_string(), - "second".to_string(), - ]; - let parsed = parse_node_args(&args, home, bind, moniker).ok().unwrap(); - assert_eq!(parsed.moniker, "second"); - } - - #[test] - fn mine_args_parse() { - let a = parse_mine_args(&[]).ok().unwrap(); - assert_eq!(a.chain, "pussy"); - assert_eq!(a.backend, "auto"); - assert!(!a.bench); - let args: Vec = [ - "bench", - "--backend", - "cpu", - "--threads", - "3", - "--seconds", - "2", - ] - .iter() - .map(|s| s.to_string()) - .collect(); - let a = parse_mine_args(&args).ok().unwrap(); - assert!(a.bench && a.backend == "cpu" && a.threads == 3 && a.seconds == 2); - let args: Vec = ["--address"].iter().map(|s| s.to_string()).collect(); - assert!(matches!( - parse_mine_args(&args), - Err(NodeArgError::MissingValue(_)) - )); - let args: Vec = ["--bogus"].iter().map(|s| s.to_string()).collect(); - assert!(matches!( - parse_mine_args(&args), - Err(NodeArgError::Unknown(_)) - )); - assert_eq!( - default_node("bostrom").as_deref(), - Some("http://127.0.0.1:7782") - ); - assert_eq!(default_node("x"), None); - } - - #[test] - fn hostname_fallback_prefers_hostname_env() { - let mut env = HashMap::new(); - env.insert("HOSTNAME", "from-hostname"); - env.insert("HOST", "from-host"); - let got = hostname_fallback_from(|k| { - env.get(k) - .map(|v| v.to_string()) - .ok_or(std::env::VarError::NotPresent) - }); - assert_eq!(got, "from-hostname"); - } - - #[test] - fn hostname_fallback_falls_back_to_host_env() { - let mut env = HashMap::new(); - env.insert("HOST", "from-host"); - let got = hostname_fallback_from(|k| { - env.get(k) - .map(|v| v.to_string()) - .ok_or(std::env::VarError::NotPresent) - }); - assert_eq!(got, "from-host"); - } - - #[test] - fn hostname_fallback_defaults_when_neither_env_is_set() { - let got = hostname_fallback_from(|_: &str| Err(std::env::VarError::NotPresent)); - assert_eq!(got, "soft3-node"); - } - - fn v(items: &[&str]) -> Vec { - items.iter().map(|s| s.to_string()).collect() - } - - #[test] - fn no_flag_leaves_all_args_as_rest() { - let args = v(&["node", "--home", "/tmp/x"]); - let (names, rest) = extract_network_flags(&args); - assert_eq!(names, Vec::::new()); - assert_eq!(rest, args); - } - - #[test] - fn long_flag_is_extracted_from_the_middle() { - let args = v(&["sync", "--network", "spacepussy-test", "--verbose"]); - let (names, rest) = extract_network_flags(&args); - assert_eq!(names, v(&["spacepussy-test"])); - assert_eq!(rest, v(&["sync", "--verbose"])); - } - - #[test] - fn short_flag_form_is_equivalent() { - let args = v(&["sync", "-n", "pussy"]); - let (names, rest) = extract_network_flags(&args); - assert_eq!(names, v(&["pussy"])); - assert_eq!(rest, v(&["sync"])); - } - - #[test] - fn flag_with_no_following_argument_yields_an_empty_name() { - let args = v(&["sync", "--network"]); - let (names, rest) = extract_network_flags(&args); - assert_eq!(names, v(&[""])); - assert_eq!(rest, v(&["sync"])); - } - - #[test] - fn repeated_flags_are_all_returned_in_argv_order() { - let args = v(&["--network", "bostrom", "sync", "-n", "pussy"]); - let (names, rest) = extract_network_flags(&args); - assert_eq!(names, v(&["bostrom", "pussy"])); - assert_eq!(rest, v(&["sync"])); - } -} - -/// `soft3 conformance bless|check chain [--genesis FILE]` — the blessed -/// vectors of chain-conformance.md in `conformance/chain.snap` -/// (`chain::conformance`). `bless` writes, `check` compares; both take the -/// pinned genesis file for the §10 entries (default -/// `~/.cache/cyber/genesis/pussy.tade`, kept as committed when absent). -fn cmd_conformance(args: &[String]) { - use soft3::chain::conformance; - let sub = args.first().map(|s| s.as_str()).unwrap_or(""); - let target = args.get(1).map(|s| s.as_str()).unwrap_or(""); - if !matches!(sub, "bless" | "check") || target != "chain" { - eprintln!("usage: soft3 conformance bless|check chain [--genesis FILE] [--snap FILE]"); - std::process::exit(2); - } - let value = |flag: &str| -> Option { args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned() }; - let snap = value("--snap").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../conformance/chain.snap"))); - let genesis = value("--genesis").map(PathBuf::from).unwrap_or_else(|| { - let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); - PathBuf::from(home).join(".cache/cyber/genesis/pussy.tade") - }); - let committed = std::fs::read_to_string(&snap).map(|s| conformance::parse(&s)).unwrap_or_default(); - let mut current = conformance::entries(); - let mut pins_ok = true; - match std::fs::read(&genesis) { - Ok(bytes) => match conformance::genesis_entries("pussy", &bytes) { - Ok(e) => { - // the binary's pins must equal the file the gate built from the burial - if let Some(pin) = soft3::chain::params::embedded("pussy").filter(|c| c.pinned()) { - for (scenario, pinned) in [("pussy particle", pin.chain_id), ("pussy block0_id", pin.genesis_block)] { - let file = e.iter().find(|x| x.scenario == scenario).map(|x| x.value); - if file != Some(pinned) { - let file = file.map(|v| soft3::wallet::hex(&v)).unwrap_or_else(|| "(absent)".into()); - eprintln!("pins: {scenario} is {} in this build but {file} in {}", soft3::wallet::hex(&pinned), genesis.display()); - pins_ok = false; - } - } - if pins_ok { - println!("pins: pussy particle and block0_id match this build"); - } - } - current.extend(e); - } - Err(e) => { - eprintln!("conformance: {} is not a genesis: {e}", genesis.display()); - std::process::exit(1); - } - }, - Err(_) => eprintln!("conformance: no genesis file at {} — §10 entries kept as committed", genesis.display()), - } - let drift = conformance::drift(&committed, ¤t); - for (label, was, now) in &drift { - let was = was.map(|w| soft3::wallet::hex(&w)).unwrap_or_else(|| "(new)".into()); - println!(" {label}: {was} -> {}", soft3::wallet::hex(now)); - } - match sub { - "check" => { - if !pins_ok { - eprintln!("chain.snap: the pins in crate/src/chain/params.rs do not match the genesis file"); - std::process::exit(1); - } - if drift.is_empty() { - println!("chain.snap: {} entries, no drift", current.len()); - } else { - eprintln!("chain.snap: {} entries moved; re-bless and review", drift.len()); - std::process::exit(1); - } - } - _ => { - let merged = conformance::merged(&committed, ¤t); - let date = conformance::utc_date(); - if let Err(e) = std::fs::write(&snap, conformance::render(&merged, &date)) { - eprintln!("conformance: cannot write {}: {e}", snap.display()); - std::process::exit(1); - } - println!("blessed {} entries into {} ({} moved)", merged.len(), snap.display(), drift.len()); - } - } -} - -/// `soft3 genesis build|verify` — burial snapshot → genesis file (tade -/// stream, `specs/chain.md` §4) → block-0 state root; `specs/genesis.md`. -fn cmd_genesis(args: &[String]) { - use soft3::genesis::{self, Chain, Options}; - let sub = args.first().map(|s| s.as_str()).unwrap_or(""); - let value = |flag: &str| -> Option { - args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned() - }; - let has = |flag: &str| args.iter().any(|a| a == flag); - let print_root = |root: &Option, block0: &Option, blocked: &Option>| { - match (root, blocked) { - (Some(r), _) => { - println!(" state_root {r}"); - println!(" block0_id {}", block0.as_deref().unwrap_or("-")); - } - (None, Some(blocked)) => { - println!(" state_root BLOCKED — dimensions over the lens commitment limit:"); - for (d, why) in blocked { - println!(" {d:<12} {why}"); - } - } - (None, None) => println!(" state_root skipped (--no-root)"), - } - }; - match sub { - "build" => { - let Some(chain) = value("--chain").and_then(|c| Chain::parse(&c)) else { - eprintln!("genesis build: --chain pussy|bostrom|pussy-rc is required"); - std::process::exit(2); - }; - let Some(snapshot) = value("--snapshot") else { - eprintln!("genesis build: --snapshot DIR (holding manifest.json) is required"); - std::process::exit(2); - }; - let Some(out) = value("--out") else { - eprintln!("genesis build: --out FILE (the genesis .tade) is required"); - std::process::exit(2); - }; - let time = match value("--time") { - Some(t) => match t.parse::() { - Ok(t) => Some(t), - Err(_) => { - eprintln!("genesis build: --time must be unix seconds"); - std::process::exit(2); - } - }, - None => None, - }; - let options = Options { - cache: value("--cache").map(PathBuf::from).unwrap_or_else(|| Options::default_cache(&chain)), - root: !has("--no-root"), - export: value("--export").map(PathBuf::from), - time, - }; - let started = std::time::Instant::now(); - match genesis::build(chain, &PathBuf::from(snapshot), &PathBuf::from(&out), &options) { - Ok(r) => { - println!("genesis {} · particle {}", r.chain, r.particle); - println!(" file {out} ({} bytes, sha256 {})", r.file_bytes, r.file_sha256); - println!(" time {}", r.time); - println!(" S0 {} subsidy {}", r.s0, r.subsidy); - println!(" pool format {} depth {} anchor_window {} digest {} tags {}", r.pool.format, r.pool.depth, r.pool.anchor_window, r.pool.digest, r.pool.tags); - print_root(&r.state_root, &r.block0_id, &r.root_blocked); - for (k, v) in &r.counts { - println!(" {k:<34} {v}"); - } - for (denom, (token, sum)) in &r.tokens { - println!(" token:{denom:<28} {sum} {token}"); - } - if let Some(c) = &r.state_counts { - for (k, v) in c { - println!(" bbg:{k:<30} {v}"); - } - } - if let Some(f) = &r.state_fields { - for (k, v) in f { - println!(" fields:{k:<27} {v}"); - } - } - for (k, v) in &r.gaps { - println!(" gap:{k} {v}"); - } - println!(" elapsed {} s", started.elapsed().as_secs()); - } - Err(e) => { - eprintln!("genesis build failed: {e}"); - std::process::exit(1); - } - } - } - "verify" => { - let Some(path) = args.get(1).filter(|a| !a.starts_with("--")) else { - eprintln!("genesis verify: FILE is required"); - std::process::exit(2); - }; - match genesis::verify(&PathBuf::from(path), has("--root")) { - Ok(v) => { - println!("genesis particle {}", v.particle); - println!(" S0 {} subsidy {} b {} l {}", v.s0, v.subsidy, v.b_frames, v.l_frames); - println!( - " pool format {} depth {} anchor_window {} digest {} tags {}", - v.pool.format, v.pool.depth, v.pool.anchor_window, v.pool.digest, v.pool.tags - ); - println!(" pool empty_root {}", v.pool.empty_root); - if has("--root") { - print_root(&v.state_root, &v.block0_id, &v.root_blocked); - } - } - Err(e) => { - eprintln!("genesis verify failed: {e}"); - std::process::exit(1); - } - } + v if FORWARDED.contains(&v) || args.iter().any(|a| a == "--network" || a == "-n") => { + eprintln!("note: `soft3 {v}` is `cybernode {v}` since soft3 0.11; this forwarding goes away in 0.12"); + cybernode::cli::run(args); } _ => { - eprintln!("usage: soft3 genesis build --chain pussy|bostrom|pussy-rc --snapshot DIR --out FILE [--cache DIR] [--export DIR] [--no-root] [--time UNIX]"); - eprintln!(" soft3 genesis verify FILE [--root]"); - std::process::exit(2); + println!("soft3 {} — the type 2 civilization software stack", env!("CARGO_PKG_VERSION")); + println!(); + println!("the node's verbs are cybernode's: cybernode node|run|mine|sync|status|genesis|chain|conformance|network"); + println!("soft3 forwards them for this release. `cargo install cybernode` for the node alone."); + println!(); + println!(" soft3 manifesto"); + println!(" soft3 version"); } } } diff --git a/crate/src/chain/api.rs b/crate/src/chain/api.rs deleted file mode 100644 index 3a3cdc7e..00000000 --- a/crate/src/chain/api.rs +++ /dev/null @@ -1,263 +0,0 @@ -//! The chain API: the surface the network layer (`chain/net/`), the miner -//! (`chain/mine/`) and the HTTP routes code against. Stable; additions are -//! fine, changes to existing signatures are a coordinated change. -//! -//! Clock discipline: nothing here reads the wall clock. Every entry point -//! that applies the future-drift rule takes `now` (unix seconds) so tests -//! and the net layer's hold-and-retry are deterministic. - -use super::apply::{BlockError, TxError}; -use super::genesis::Genesis; -use super::header::{BlockId, Header, HeaderError}; -use super::select::{Checkpoint, CheckpointError}; -use super::state::Id; -use super::tx::Tx; -use super::u256::U256; - -/// The canonical tip. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Tip { - pub height: u64, - pub id: BlockId, - pub chainwork: U256, - pub header: Header, -} - -/// A header with its body (`count ‖ tx…`, §1.6). Block 0 has an empty body. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Block { - pub header: Header, - pub body: Vec, -} - -impl Block { - pub fn id(&self) -> BlockId { - self.header.id() - } - - /// `header[168] ‖ body` — the `B` frame payload of chain-wire §3. - pub fn encode(&self) -> Vec { - let mut out = self.header.encode().to_vec(); - out.extend_from_slice(&self.body); - out - } - - pub fn decode(b: &[u8]) -> Result { - let header = Header::decode_raw(b.get(..168).ok_or(HeaderError::Length(b.len()))?)?; - Ok(Block { header, body: b[168..].to_vec() }) - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum HeaderVerdict { - /// passed steps 1–5, stored; relay it - Accepted, - /// already in the store - Known, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum BlockVerdict { - /// the block extended the tip - Extended, - /// the tip moved to this block's chain, undoing `depth` blocks from `old_tip` - Reorganized { old_tip: BlockId, depth: u64 }, - /// valid as far as checked (steps 1–6) and stored; not heavier than the - /// tip, so steps 7–8 run if it ever becomes the heaviest chain - Stored, - /// heavier than the tip, but these ancestors have no body yet; fetch - /// them (chain-wire §3.1) and resubmit any one of the chain - NeedBodies(Vec), - /// already applied - Known, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ChainError { - /// step 1 - Header(HeaderError), - /// step 2: `prev` not in the store (the net layer's orphan pool takes it) - UnknownParent(BlockId), - /// step 2: the parent is known and invalid - InvalidParent(BlockId), - /// step 2 - Height { expected: u64, actual: u64 }, - /// step 3: `time <= MTP(prev)` - Time { mtp: u64, actual: u64 }, - /// step 3: ahead of the clock; hold and re-evaluate, never score - FutureTime { time: u64, now: u64 }, - /// step 4 - Bits { expected: u32, actual: u32 }, - /// step 5 - Pow, - /// §7.1: the activation table ends before this height - RuleSetUnknown(u64), - /// §5.2: the fork point is at or below `tip − FINALITY_DEPTH` - DeepFork { fork_height: u64, tip_height: u64 }, - /// §5.3: the chain excludes a held checkpoint - Checkpoint { height: u64, expected: BlockId }, - /// §5.3 - CheckpointRejected(CheckpointError), - /// steps 6–8; the header is now marked invalid with every descendant - Block(BlockError), - /// an id in the rejected set (§5.4) - Rejected(BlockId), - /// genesis block mismatch on a block claiming height 0 - Genesis, - /// the store - Store(String), -} - -impl std::fmt::Display for ChainError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ChainError::Header(e) => write!(f, "header: {e}"), - ChainError::UnknownParent(_) => write!(f, "unknown parent"), - ChainError::InvalidParent(_) => write!(f, "parent is invalid"), - ChainError::Height { expected, actual } => write!(f, "height {actual}, expected {expected}"), - ChainError::Time { mtp, actual } => write!(f, "time {actual} is not above the median time past {mtp}"), - ChainError::FutureTime { time, now } => write!(f, "time {time} is more than the drift ahead of {now}"), - ChainError::Bits { expected, actual } => write!(f, "bits {actual:#010x}, ASERT expects {expected:#010x}"), - ChainError::Pow => write!(f, "block id does not meet the target"), - ChainError::RuleSetUnknown(h) => write!(f, "no rule set is known for height {h}; this release stops here"), - ChainError::DeepFork { fork_height, tip_height } => write!(f, "fork at {fork_height} is beyond MAX_REORG from the tip {tip_height}"), - ChainError::Checkpoint { height, .. } => write!(f, "chain excludes the checkpoint at {height}"), - ChainError::CheckpointRejected(e) => write!(f, "checkpoint: {e}"), - ChainError::Block(e) => write!(f, "block: {e}"), - ChainError::Rejected(_) => write!(f, "previously rejected block"), - ChainError::Genesis => write!(f, "genesis block mismatch"), - ChainError::Store(e) => write!(f, "store: {e}"), - } - } -} - -impl std::error::Error for ChainError {} - -/// What a mempool learns from a validated transaction. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TxInfo { - pub txid: Id, - pub tx: Tx, - pub size: usize, - pub fee: u64, - /// the account whose nonce the tx consumes (the claim's key-derived neuron) - pub account: Id, - pub nonce: u64, -} - -/// A mining template (chain-wire §7 `/template`): the header with zero -/// nonce and the ASERT target, plus the body it commits to. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Template { - pub header: Header, - pub target: U256, - pub body: Vec, - pub txids: Vec, - pub fees: u64, -} - -fn hex(b: &[u8]) -> String { - b.iter().map(|x| format!("{x:02x}")).collect() -} - -impl Template { - /// The `GET /template` answer of chain-wire §7, exactly. - pub fn json(&self, chain_name: &str, expires: u64) -> String { - format!( - "{{\"chain\":{},\"height\":{},\"prev\":\"{}\",\"header\":\"{}\",\"bits\":\"{:08x}\",\"target\":\"{}\",\"body\":\"{}\",\"expires\":{}}}", - serde_json::to_string(chain_name).unwrap_or_else(|_| "\"\"".into()), - self.header.height, - hex(&self.header.prev), - hex(&self.header.encode()), - self.header.bits, - hex(&self.target.to_le_bytes()), - hex(&self.body), - expires - ) - } -} - -impl Tip { - /// The `GET /tip` answer of chain-wire §7, exactly. - pub fn json(&self) -> String { - format!("{{\"height\":{},\"id\":\"{}\",\"chainwork\":\"{}\"}}", self.height, hex(&self.id), self.chainwork.to_decimal()) - } -} - -/// Finalized block stream for the shadow indexers (§7.3): emitted once a -/// block is `FINALITY_DEPTH` deep, never reorged. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Event { - Block { header: Header, body: Vec, state_root: Id }, -} - -/// `/status` fields the chain owns (chain-wire §7). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Status { - pub chain: Id, - pub name: String, - pub rule_set: u16, - pub height: u64, - pub tip: BlockId, - pub chainwork: U256, - pub bits: u32, - pub next_bits: u32, - pub genesis_block: BlockId, - pub genesis_time: u64, - pub state_root: Id, - pub supply: u64, - pub conserved: bool, - pub subsidy: u64, - pub neurons: u64, - pub particles: u64, - pub axons: u64, - pub checkpoint: Option, - pub finalized: u64, -} - -/// The chain as the other layers see it. -pub trait ChainApi { - fn chain_id(&self) -> Id; - fn genesis(&self) -> &Genesis; - fn tip(&self) -> Tip; - fn status(&self) -> Status; - - // ── reads ─────────────────────────────────────────────────────── - fn header(&self, id: &BlockId) -> Option
; - /// main-chain header at a height - fn header_at(&self, height: u64) -> Option
; - /// main-chain headers `[from, from + count)`, ascending, truncated at the tip - fn headers(&self, from: u64, count: usize) -> Vec
; - fn block(&self, id: &BlockId) -> Option; - /// main-chain blocks `[from, from + count)`, ascending - fn blocks(&self, from: u64, count: usize) -> Vec; - fn chainwork(&self, id: &BlockId) -> Option; - /// main-chain ids for a get-headers locator (chain-wire §3) - fn locator(&self) -> Vec; - /// the main-chain id at `height` on the branch ending at `id` - fn ancestor_at(&self, id: &BlockId, height: u64) -> Option; - fn balance(&self, owner: &Id, token: &Id) -> u64; - fn nonce(&self, neuron: &Id) -> u64; - fn supply(&self, token: &Id) -> u64; - fn finalized_height(&self) -> u64; - - // ── writes ────────────────────────────────────────────────────── - /// steps 1–5; stores a valid header - fn accept_header(&mut self, header: &Header, now: u64) -> Result; - /// steps 1–8 and fork choice; the header is accepted first - fn accept_block(&mut self, block: &Block, now: u64) -> Result; - /// §5.3; `Ok(true)` when new - fn accept_checkpoint(&mut self, cp: &Checkpoint) -> Result; - fn checkpoints(&self) -> Vec<(u64, BlockId)>; - /// mempool admission (chain-wire §4): decode, signature, nonce within - /// `NONCE_WINDOW` given `pending` (the mempool's txs of the same - /// account, applied first in nonce order), fee floor, balances and - /// maturity on the tip state. Never mutates the chain. - fn validate_tx(&mut self, tx_bytes: &[u8], pending: &[Tx]) -> Result; - /// a template on the tip for a miner paying `to`, at `time` (raised to - /// MTP + 1 if needed), from candidate transactions already admitted - /// (chain-wire §4: fee per byte descending, nonce order kept, budgets) - fn header_template(&mut self, to: Id, time: u64, mempool: &[Vec]) -> Result; - /// drain the finalized block events - fn take_events(&mut self) -> Vec; -} diff --git a/crate/src/chain/apply.rs b/crate/src/chain/apply.rs deleted file mode 100644 index c155c733..00000000 --- a/crate/src/chain/apply.rs +++ /dev/null @@ -1,521 +0,0 @@ -//! Block transition (§3.4) and the per-transaction rules (§2), steps 6–8 of -//! the validation order (§5.5). Pure over a [`StateBackend`]: the caller -//! supplies the parent state, the immature-coinbase map and the clock-free -//! context; this module mutates under a journal and returns it, or reverts -//! and returns the first error. - -use std::collections::{BTreeMap, BTreeSet}; - -use super::economy::settle::{self as econ_settle, Outcome, SettleResult}; -use super::economy::{self, Card, card_id, focus_key, stake_key}; -use super::header::Header; -use super::params::{COINBASE_MATURITY, EpochClock, HEADER_BYTES, MAX_BLOCK_BYTES, MAX_BLOCK_TXS, MAX_TX_BYTES, MIN_FEE_PER_BYTE}; -use super::sigs::{self, legacy_owner, neuron_of, sig_pubkey, txid}; -use super::state::{Id, StateBackend, StateError, Undo}; -use super::tree::tx_root; -use super::tx::{self, Body, DecodeError, Tx}; - -/// The chain-level facts a transition needs (§0, §4). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Rules { - pub chain: Id, - pub native: Id, - pub hrp: String, - pub subsidy: u64, - pub s0: u64, - /// the epoch clock: `EpochClock::SPEC` on the launch chains - pub clock: EpochClock, -} - -impl Rules { - /// `S0 + h · SUBSIDY`: the native supply after block `h` (§3.5). - pub fn supply_at(&self, h: u64) -> u64 { - self.s0 + h * self.subsidy - } -} - -/// Native units per owner still inside `COINBASE_MATURITY` at the height -/// being validated (§2.2): spendable = balance − locked. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct Locked(pub BTreeMap); - -impl Locked { - /// From the coinbases `(height, to, amount)` of recent blocks, the map - /// for a block at `at`: a coinbase at `c` is locked while `at < c + 100`. - pub fn at(coinbases: &[(u64, Id, u64)], at: u64) -> Locked { - let mut m = BTreeMap::new(); - for (c, to, amount) in coinbases { - if at < c.saturating_add(COINBASE_MATURITY) { - let e = m.entry(*to).or_insert(0u64); - *e = e.saturating_add(*amount); - } - } - Locked(m) - } - - pub fn of(&self, owner: &Id) -> u64 { - self.0.get(owner).copied().unwrap_or(0) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum TxError { - Decode(DecodeError), - TooLarge(usize), - CoinbaseInBody, - Signature, - Nonce { expected: u64, actual: u64 }, - Fee { min: u64, actual: u64 }, - Insufficient { have: u64, need: u64 }, - Immature { spendable: u64, need: u64 }, - SelfLink, - /// a staked cyberlink names a token other than the native coin (chain-economy §3.3) - StakeToken, - /// the card exists with the other valence: unlock to zero first (§3.3) - Valence { card: i8, tx: i8 }, - /// kinds 6 and 7 need `card(from, p, q)` to exist (§3.4–§3.5) - NoCard, - /// an unlock before `since + MIN_LOCK` (§3.4) - Locked { until: u64 }, - /// an unlock of 0 or of more than the position's stake (§3.4) - UnlockAmount { stake: u64, amount: u64 }, - ClaimKey, - Overflow, -} - -impl std::fmt::Display for TxError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TxError::Decode(e) => write!(f, "{e}"), - TxError::TooLarge(n) => write!(f, "{n} bytes exceeds MAX_TX_BYTES"), - TxError::CoinbaseInBody => write!(f, "coinbase outside position 0"), - TxError::Signature => write!(f, "signature does not verify"), - TxError::Nonce { expected, actual } => write!(f, "nonce {actual}, expected {expected}"), - TxError::Fee { min, actual } => write!(f, "fee {actual} below floor {min}"), - TxError::Insufficient { have, need } => write!(f, "insufficient balance: have {have}, need {need}"), - TxError::Immature { spendable, need } => write!(f, "coinbase immature: spendable {spendable}, need {need}"), - TxError::SelfLink => write!(f, "cyberlink p == q"), - TxError::StakeToken => write!(f, "stake must be the native coin"), - TxError::Valence { card, tx } => write!(f, "the position holds valence {card}; unlock it to zero before locking {tx}"), - TxError::NoCard => write!(f, "no position card(from, p, q)"), - TxError::Locked { until } => write!(f, "stake locked until height {until}"), - TxError::UnlockAmount { stake, amount } => write!(f, "unlock {amount} of a position holding {stake}"), - TxError::ClaimKey => write!(f, "claim key does not derive the account id"), - TxError::Overflow => write!(f, "arithmetic overflow"), - } - } -} - -impl From for TxError { - fn from(e: StateError) -> Self { - match e { - StateError::Insufficient { have, need } => TxError::Insufficient { have, need }, - StateError::Overflow => TxError::Overflow, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum BlockError { - Body(DecodeError), - Empty, - TooManyTxs(usize), - TooLarge(usize), - TxRoot, - CoinbasePosition, - CoinbaseHeight { expected: u64, actual: u64 }, - DuplicateTx(usize), - CoinbaseAmount { expected: u64, actual: u64 }, - Tx { index: usize, error: TxError }, - StateRoot { expected: Id, actual: Id }, - Conservation { token: Id, sum: u64, supply: u64 }, - /// the settlement of `epoch` is due at this height and the node has no - /// result for it (chain-economy §2.5: it waits, never guesses) - Settlement { epoch: u64 }, - /// Σ stake keys differs from the running stake total (§6.3) - StakeTotal { cards: u128, total: u64 }, - Overflow, -} - -impl std::fmt::Display for BlockError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - BlockError::Body(e) => write!(f, "body: {e}"), - BlockError::Empty => write!(f, "body has no coinbase"), - BlockError::TooManyTxs(n) => write!(f, "{n} transactions exceed MAX_BLOCK_TXS"), - BlockError::TooLarge(n) => write!(f, "{n} bytes exceed MAX_BLOCK_BYTES"), - BlockError::TxRoot => write!(f, "tx_root mismatch"), - BlockError::CoinbasePosition => write!(f, "tx[0] is not a coinbase"), - BlockError::CoinbaseHeight { expected, actual } => write!(f, "coinbase height {actual}, block height {expected}"), - BlockError::DuplicateTx(i) => write!(f, "tx {i} duplicates an earlier txid"), - BlockError::CoinbaseAmount { expected, actual } => write!(f, "coinbase amount {actual}, expected {expected}"), - BlockError::Tx { index, error } => write!(f, "tx {index}: {error}"), - BlockError::StateRoot { .. } => write!(f, "state_root mismatch"), - BlockError::Conservation { sum, supply, .. } => write!(f, "conservation: balances sum {sum}, supply {supply}"), - BlockError::Settlement { epoch } => write!(f, "settlement of epoch {epoch} is not computed"), - BlockError::StakeTotal { cards, total } => write!(f, "stake keys sum {cards}, running total {total}"), - BlockError::Overflow => write!(f, "arithmetic overflow"), - } - } -} - -/// One decoded body transaction. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Decoded { - pub raw: Vec, - pub tx: Tx, - pub txid: Id, -} - -/// Step 6: budgets, `tx_root`, coinbase position and height, unique txids. -/// Stateless; a block passing this may still fail steps 7–8. -pub fn decode_body(rules: &Rules, header: &Header, body: &[u8]) -> Result, BlockError> { - if HEADER_BYTES + body.len() > MAX_BLOCK_BYTES { - return Err(BlockError::TooLarge(HEADER_BYTES + body.len())); - } - let raws = tx::split_body(body).map_err(BlockError::Body)?; - if raws.is_empty() { - return Err(BlockError::Empty); - } - if raws.len() > MAX_BLOCK_TXS { - return Err(BlockError::TooManyTxs(raws.len())); - } - if tx_root(&raws) != header.tx_root { - return Err(BlockError::TxRoot); - } - let mut seen = BTreeSet::new(); - let mut out = Vec::with_capacity(raws.len()); - for (i, raw) in raws.into_iter().enumerate() { - if raw.len() > MAX_TX_BYTES { - return Err(BlockError::Tx { index: i, error: TxError::TooLarge(raw.len()) }); - } - let tx = Tx::decode(&raw).map_err(|e| BlockError::Tx { index: i, error: TxError::Decode(e) })?; - match (&tx.body, i) { - (Body::Coinbase { height, .. }, 0) => { - if *height != header.height { - return Err(BlockError::CoinbaseHeight { expected: header.height, actual: *height }); - } - } - (Body::Coinbase { .. }, _) => return Err(BlockError::Tx { index: i, error: TxError::CoinbaseInBody }), - (_, 0) => return Err(BlockError::CoinbasePosition), - _ => {} - } - let id = txid(&rules.chain, &raw); - if !seen.insert(id) { - return Err(BlockError::DuplicateTx(i)); - } - out.push(Decoded { raw, tx, txid: id }); - } - Ok(out) -} - -fn spendable(state: &impl StateBackend, locked: &Locked, owner: &Id, native: &Id, need: u64) -> Result<(), TxError> { - let have = state.balance(owner, native); - let lock = locked.of(owner); - if have < need { - return Err(TxError::Insufficient { have, need }); - } - if have - lock < need { - return Err(TxError::Immature { spendable: have - lock, need }); - } - Ok(()) -} - -/// Step 7 for one non-coinbase transaction at its exact nonce: every check, -/// then the mutation (§2.3–§2.8). Returns the fee paid. Nothing is -/// mutated if any check fails. -pub fn execute_tx(rules: &Rules, tx: &Tx, raw_len: usize, state: &mut S, locked: &Locked, height: u64) -> Result { - let min_fee = (raw_len as u64).saturating_mul(MIN_FEE_PER_BYTE); - let fee = tx.fee(); - if fee < min_fee { - return Err(TxError::Fee { min: min_fee, actual: fee }); - } - match &tx.body { - Body::Coinbase { .. } => Err(TxError::CoinbaseInBody), - Body::Transfer { from, nonce, token, amount, to, .. } => { - if !sigs::verify(&rules.chain, tx) { - return Err(TxError::Signature); - } - let expected = state.nonce(from); - if *nonce != expected { - return Err(TxError::Nonce { expected, actual: *nonce }); - } - if *token == rules.native { - let need = amount.checked_add(fee).ok_or(TxError::Overflow)?; - spendable(state, locked, from, &rules.native, need)?; - } else { - spendable(state, locked, from, &rules.native, fee)?; - let have = state.balance(from, token); - if have < *amount { - return Err(TxError::Insufficient { have, need: *amount }); - } - } - state.debit(from, &rules.native, fee)?; - state.debit(from, token, *amount)?; - state.credit(to, token, *amount)?; - state.bump_nonce(from)?; - Ok(fee) - } - Body::Cyberlink { from, nonce, token, amount, p, q, valence, .. } => { - if p == q { - return Err(TxError::SelfLink); - } - if *amount > 0 && *token != rules.native { - return Err(TxError::StakeToken); - } - if !sigs::verify(&rules.chain, tx) { - return Err(TxError::Signature); - } - let expected = state.nonce(from); - if *nonce != expected { - return Err(TxError::Nonce { expected, actual: *nonce }); - } - let card = (*amount > 0).then(|| card_id(from, p, q)); - let existing = card.and_then(|c| state.card(&c)); - if let Some(c) = &existing { - if c.valence != *valence { - return Err(TxError::Valence { card: c.valence, tx: *valence }); - } - } - let need = amount.checked_add(fee).ok_or(TxError::Overflow)?; - spendable(state, locked, from, &rules.native, need)?; - state.debit(from, &rules.native, need)?; - if let Some(c) = card { - // chain-economy §3.3: the stake moves to the unreachable key - // of the position; any increase restarts the lock - state.credit_raw(&stake_key(&c), &rules.native, *amount)?; - let next = match existing { - Some(old) => Card { since: height, ..old }, - None => Card { owner: *from, particle: economy::axon_of(p, q), author: *from, valence: *valence, since: height }, - }; - state.set_card(&c, Some(next)); - let mut sc = state.scalars(); - sc.total_stake = sc.total_stake.checked_add(*amount).ok_or(TxError::Overflow)?; - if *valence != 0 { - sc.active_stake = sc.active_stake.checked_add(*amount).ok_or(TxError::Overflow)?; - } - state.set_scalars(sc); - } - state.link(from, p, q, token, 0, *valence, height)?; - state.bump_nonce(from)?; - Ok(fee) - } - Body::Unlock { from, nonce, p, q, amount, .. } => { - if !sigs::verify(&rules.chain, tx) { - return Err(TxError::Signature); - } - let expected = state.nonce(from); - if *nonce != expected { - return Err(TxError::Nonce { expected, actual: *nonce }); - } - let c = card_id(from, p, q); - let card = state.card(&c).ok_or(TxError::NoCard)?; - let key = stake_key(&c); - let stake = state.raw_balance(&key); - if *amount == 0 || *amount > stake { - return Err(TxError::UnlockAmount { stake, amount: *amount }); - } - let until = card.since.saturating_add(rules.clock.min_lock); - if height < until { - return Err(TxError::Locked { until }); - } - // the fee may come out of what the unlock returns (§3.4) - let have = state.balance(from, &rules.native).checked_add(*amount).ok_or(TxError::Overflow)?; - let spendable_after = have - locked.of(from).min(have); - if spendable_after < fee { - return Err(TxError::Immature { spendable: spendable_after, need: fee }); - } - state.debit_raw(&key, &rules.native, *amount)?; - state.credit(from, &rules.native, *amount)?; - state.debit(from, &rules.native, fee)?; - if stake == *amount { - state.set_card(&c, None); - } - let mut sc = state.scalars(); - sc.total_stake -= *amount; - if card.valence != 0 { - sc.active_stake -= *amount; - } - state.set_scalars(sc); - state.bump_nonce(from)?; - Ok(fee) - } - Body::Payout { from, nonce, p, q, payout, .. } => { - if !sigs::verify(&rules.chain, tx) { - return Err(TxError::Signature); - } - let expected = state.nonce(from); - if *nonce != expected { - return Err(TxError::Nonce { expected, actual: *nonce }); - } - let c = card_id(from, p, q); - let card = state.card(&c).ok_or(TxError::NoCard)?; - spendable(state, locked, from, &rules.native, fee)?; - state.debit(from, &rules.native, fee)?; - state.set_card(&c, Some(Card { owner: *payout, ..card })); - state.bump_nonce(from)?; - Ok(fee) - } - Body::Claim { account_id, nonce, .. } => { - let Some(pubkey) = sig_pubkey(&tx.sig) else { - return Err(TxError::Signature); - }; - if mudra::cosmos::account_id(&pubkey) != *account_id { - return Err(TxError::ClaimKey); - } - if !sigs::verify(&rules.chain, tx) { - return Err(TxError::Signature); - } - let subject = neuron_of(&pubkey); - let expected = state.nonce(&subject); - if *nonce != expected { - return Err(TxError::Nonce { expected, actual: *nonce }); - } - let dormant = legacy_owner(&rules.hrp, account_id); - spendable(state, locked, &dormant, &rules.native, fee)?; - state.debit(&dormant, &rules.native, fee)?; - for token in state.tokens() { - let bal = state.balance(&dormant, &token); - if bal > 0 { - state.debit(&dormant, &token, bal)?; - state.credit(&subject, &token, bal)?; - } - } - state.ensure_neuron(&subject); - state.bump_nonce(&subject)?; - Ok(fee) - } - } -} - -/// What a successful transition produced. -#[derive(Clone, Debug)] -pub struct Applied { - pub root: Id, - pub undo: Undo, - /// the settlement this block carried (chain-economy §5.6), if any - pub settled: Option, -} - -/// Steps 7–8 on the parent state: coinbase exactness, the settlement step -/// when due, every transaction in order, height, `state_root`, -/// conservation. On success the state is the block's and the journal is -/// returned; on failure the state is the parent's again. -pub fn apply_block( - rules: &Rules, - header: &Header, - txs: &[Decoded], - state: &mut S, - locked: &Locked, - settle: Option<&SettleResult>, -) -> Result { - let applied = transition(rules, header.height, txs, state, locked, settle)?; - if applied.root != header.state_root { - state.revert(&applied.undo); - return Err(BlockError::StateRoot { expected: header.state_root, actual: applied.root }); - } - Ok(applied) -} - -/// `(miner share, pool cut)` of one block's subsidy under the split in -/// force on `state` (chain-economy §6.1–§6.2). -pub fn subsidy_split(rules: &Rules, state: &impl StateBackend) -> (u64, u64) { - let cut = economy::cut(rules.subsidy, state.scalars().focus_bps); - (rules.subsidy - cut, cut) -} - -/// The transition without the root comparison: what a template builder -/// runs to learn the root its header must carry. Same journal discipline. -/// `settle` is the precomputed settlement of the epoch due at `height` -/// (chain-economy §2.5); a settlement height without it is an error. -pub fn transition( - rules: &Rules, - height: u64, - txs: &[Decoded], - state: &mut S, - locked: &Locked, - settle: Option<&SettleResult>, -) -> Result { - let Body::Coinbase { to: cb_to, amount: cb_amount, .. } = &txs.first().ok_or(BlockError::Empty)?.tx.body else { - return Err(BlockError::CoinbasePosition); - }; - let due = rules.clock.settles_at(height); - if let Some(epoch) = due { - if settle.map(|r| r.epoch) != Some(epoch) { - return Err(BlockError::Settlement { epoch }); - } - } - let mut fees = 0u64; - for d in &txs[1..] { - fees = fees.checked_add(d.tx.fee()).ok_or(BlockError::Overflow)?; - } - let (miner, cut) = subsidy_split(rules, state); - let expected = miner.checked_add(fees).ok_or(BlockError::Overflow)?; - if *cb_amount != expected { - return Err(BlockError::CoinbaseAmount { expected, actual: *cb_amount }); - } - state.begin(); - let mut settled = None; - let result = (|| { - // 1. coinbase: the miner's share and the pool's cut (§5.7, §6.2) - state.credit(cb_to, &rules.native, *cb_amount).map_err(|_| BlockError::Overflow)?; - state.credit_raw(&focus_key(&rules.native), &rules.native, cut).map_err(|_| BlockError::Overflow)?; - state.mint(&rules.native, rules.subsidy).map_err(|_| BlockError::Overflow)?; - // 2. the settlement of the epoch due at this height - if let (Some(_), Some(r)) = (due, settle) { - settled = Some(econ_settle::apply(state, &rules.native, height, r).map_err(|_| BlockError::Overflow)?); - } - // 3. the transactions in body order - for (i, d) in txs.iter().enumerate().skip(1) { - execute_tx(rules, &d.tx, d.raw.len(), state, locked, height).map_err(|error| BlockError::Tx { index: i, error })?; - } - state.set_height(height); - // a boundary fixes the split for the next epoch's blocks (§6.1) - if rules.clock.is_boundary(height) { - let mut sc = state.scalars(); - sc.focus_bps = economy::focus_bps(sc.active_stake, rules.supply_at(height)); - state.set_scalars(sc); - } - // 4. conservation and the root - check_conservation(rules, state, height)?; - if due.is_some() { - check_stake_total(state)?; - } - Ok(state.root()) - })(); - let undo = state.commit(); - match result { - Ok(root) => Ok(Applied { root, undo, settled }), - Err(e) => { - state.revert(&undo); - Err(e) - } - } -} - -/// §6.3: Σ stake keys over the cards equals the running stake total. -pub fn check_stake_total(state: &impl StateBackend) -> Result<(), BlockError> { - let cards = econ_settle::stake_total(state); - let total = state.scalars().total_stake; - if cards != total as u128 { - return Err(BlockError::StakeTotal { cards, total }); - } - Ok(()) -} - -/// §3.4 invariant: every token's balances sum to its supply, and the native -/// supply is `S0 + h · SUBSIDY`. -pub fn check_conservation(rules: &Rules, state: &impl StateBackend, height: u64) -> Result<(), BlockError> { - for token in state.tokens() { - let (sum, supply) = (state.token_sum(&token), state.supply(&token)); - if sum != supply { - return Err(BlockError::Conservation { token, sum, supply }); - } - } - let expected = height.checked_mul(rules.subsidy).and_then(|m| m.checked_add(rules.s0)).ok_or(BlockError::Overflow)?; - let supply = state.supply(&rules.native); - if supply != expected { - return Err(BlockError::Conservation { token: rules.native, sum: expected, supply }); - } - Ok(()) -} diff --git a/crate/src/chain/bbg_state.rs b/crate/src/chain/bbg_state.rs deleted file mode 100644 index df6ad033..00000000 --- a/crate/src/chain/bbg_state.rs +++ /dev/null @@ -1,525 +0,0 @@ -//! [`StateBackend`] over `bbg::BbgState` (§3.2–§3.3). -//! -//! Balances, nonces, coins and the graph live in the bbg dimensions and -//! therefore under `state_root`. The per-token running sums are derived -//! bookkeeping for the conservation check (§3.4), never committed. -//! -//! The nonce is `NeuronRecord.nonce` (bbg dimension version 3, the change -//! §3.2 requested), so every nonce is under `state_root`. Positions are -//! `CardRecord { owner, particle, author, valence, since }` in the cards -//! dimension (version 4, chain-economy §3.2); stakes and the focus pool are -//! raw keys of the balances dimension. The high-water marks and the economy -//! scalars are kernel state: pure functions of the chain's history, -//! journaled here for reorgs, not committed (the epoch records reach the -//! root through `time`, the payouts through the balances). - -use std::collections::BTreeMap; - -use bbg::state::{axon_id, balance_key}; -use bbg::types::{CardRecord, CoinRecord, NeuronRecord, ParticleRecord, Valence}; -use bbg::{BbgState, Cyberlink, Signal}; - -use super::state::{Card, Id, Mark, Scalars, StateBackend, StateError, Undo, UndoEntry}; - -pub struct BbgBacked { - pub bbg: BbgState, - sums: BTreeMap, - /// high-water marks per axon (chain-economy §5.3): node-side, a pure - /// function of the chain's history, journaled for reorgs - marks: BTreeMap, - scalars: Scalars, - journal: Vec, - open: bool, -} - -impl Default for BbgBacked { - fn default() -> Self { - Self::new() - } -} - -fn neuron_image(r: &NeuronRecord) -> [u64; 4] { - [r.focus, r.karma, r.stake, r.nonce] -} - -fn neuron_from(img: [u64; 4]) -> NeuronRecord { - NeuronRecord { focus: img[0], karma: img[1], stake: img[2], nonce: img[3] } -} - -fn particle_image(r: &ParticleRecord) -> [u64; 6] { - [r.energy, r.pi_star, r.weight, r.s_yes, r.s_no, r.meta_score] -} - -fn particle_from(i: [u64; 6]) -> ParticleRecord { - ParticleRecord { energy: i[0], pi_star: i[1], weight: i[2], s_yes: i[3], s_no: i[4], meta_score: i[5] } -} - -impl BbgBacked { - pub fn new() -> Self { - Self { - bbg: BbgState::new(), - sums: BTreeMap::new(), - marks: BTreeMap::new(), - scalars: Scalars::default(), - journal: Vec::new(), - open: false, - } - } - - /// The position as bbg's dimension-version-4 record - /// (`CardRecord { owner, particle, author, valence, since }`, chain-economy §3.2). - fn card_record(c: &Card) -> CardRecord { - CardRecord { - owner: c.owner, - particle: c.particle, - author: c.author, - valence: Valence::from_i8(c.valence).expect("valence checked on decode"), - since: c.since, - } - } - - fn card_from(r: &CardRecord) -> Card { - Card { owner: r.owner, particle: r.particle, author: r.author, valence: r.valence.to_i8(), since: r.since } - } - - fn write_card(&mut self, id: Id, card: Option) { - match card { - None => { - self.bbg.cards.remove(&id); - } - Some(c) => { - self.bbg.cards.insert(id, Self::card_record(&c)); - } - } - } - - fn write_time(&mut self, height: u64, value: Option) { - match value { - None => { - self.bbg.time.remove(&height); - } - Some(v) => { - self.bbg.time.insert(height, v); - } - } - } - - /// Wrap a loaded state (the genesis loader's) with its per-token sums, - /// given as `(token, amount)` contributions in any order. - pub fn from_bbg(bbg: BbgState, contributions: impl IntoIterator) -> Self { - let mut s = Self::new(); - s.bbg = bbg; - for (token, amount) in contributions { - let e = s.sums.entry(token).or_insert(0); - *e = e.checked_add(amount).expect("genesis sums fit u64 (checked by the pipeline)"); - } - s - } - - fn note(&mut self, e: UndoEntry) { - if self.open { - self.journal.push(e); - } - } - - fn note_balance(&mut self, key: Id) { - let before = self.bbg.balances.get(&key).copied(); - self.note(UndoEntry::Balance { key, before }); - } - - fn note_neuron(&mut self, id: Id) { - let before = self.full_neuron(&id); - self.note(UndoEntry::Neuron { id, before }); - } - - fn note_particle(&mut self, id: Id) { - let before = self.bbg.particles.get(&id).map(particle_image); - self.note(UndoEntry::Particle { id, before }); - } - - fn note_sum(&mut self, token: Id) { - let before = self.sums.get(&token).copied(); - self.note(UndoEntry::TokenSum { token, before }); - } - - /// the neuron record image, nonce included - fn full_neuron(&self, id: &Id) -> Option<[u64; 4]> { - self.bbg.neurons.get(id).map(neuron_image) - } - - fn write_neuron(&mut self, id: Id, img: Option<[u64; 4]>) { - match img { - None => { - self.bbg.neurons.remove(&id); - } - Some(img) => { - self.bbg.neurons.insert(id, neuron_from(img)); - } - } - } - - fn set_balance(&mut self, key: Id, value: Option) { - match value { - None => self.bbg.balances.remove(&key), - Some(v) => self.bbg.balances.insert(key, v), - }; - } - - fn dirty(&mut self) { - // every write goes through bbg's committed maps, which keep the - // dimension commitments current; nothing to mark - } -} - -impl StateBackend for BbgBacked { - fn height(&self) -> u64 { - self.bbg.height - } - - fn balance(&self, owner: &Id, token: &Id) -> u64 { - self.bbg.balances.get(&balance_key(owner, token)).copied().unwrap_or(0) - } - - fn nonce(&self, id: &Id) -> u64 { - self.full_neuron(id).map_or(0, |i| i[3]) - } - - fn has_neuron(&self, id: &Id) -> bool { - self.bbg.neurons.contains_key(id) - } - - fn supply(&self, token: &Id) -> u64 { - self.bbg.coins.get(token).map_or(0, |c| c.total_supply) - } - - fn tokens(&self) -> Vec { - self.bbg.coins.keys().copied().collect() - } - - fn token_sum(&self, token: &Id) -> u64 { - self.sums.get(token).copied().unwrap_or(0) - } - - fn neuron_count(&self) -> u64 { - self.bbg.neurons.len() as u64 - } - - fn particle_count(&self) -> u64 { - self.bbg.particles.len() as u64 - } - - fn axon_count(&self) -> u64 { - self.bbg.axon_edges.len() as u64 - } - - fn root(&mut self) -> Id { - self.bbg.refresh_root() - } - - fn set_height(&mut self, height: u64) { - let before = self.bbg.height; - self.note(UndoEntry::Height { before }); - self.bbg.height = height; - } - - fn credit(&mut self, owner: &Id, token: &Id, amount: u64) -> Result<(), StateError> { - let key = balance_key(owner, token); - let have = self.bbg.balances.get(&key).copied().unwrap_or(0); - let next = have.checked_add(amount).ok_or(StateError::Overflow)?; - let sum = self.sums.get(token).copied().unwrap_or(0).checked_add(amount).ok_or(StateError::Overflow)?; - self.note_balance(key); - self.note_sum(*token); - // a zero balance is an absent row (bbg dimension versions 3–4) - self.set_balance(key, (next > 0).then_some(next)); - self.sums.insert(*token, sum); - self.dirty(); - Ok(()) - } - - fn debit(&mut self, owner: &Id, token: &Id, amount: u64) -> Result<(), StateError> { - let key = balance_key(owner, token); - let have = self.bbg.balances.get(&key).copied().unwrap_or(0); - if have < amount { - return Err(StateError::Insufficient { have, need: amount }); - } - self.note_balance(key); - self.note_sum(*token); - self.set_balance(key, (have > amount).then_some(have - amount)); - let sum = self.sums.get(token).copied().unwrap_or(0); - self.sums.insert(*token, sum - amount); - self.dirty(); - Ok(()) - } - - fn ensure_neuron(&mut self, id: &Id) { - if self.full_neuron(id).is_some() { - return; - } - self.note_neuron(*id); - self.write_neuron(*id, Some([0; 4])); - self.dirty(); - } - - fn bump_nonce(&mut self, id: &Id) -> Result<(), StateError> { - let mut img = self.full_neuron(id).unwrap_or([0; 4]); - img[3] = img[3].checked_add(1).ok_or(StateError::Overflow)?; - let before = self.full_neuron(id); - self.note(UndoEntry::Neuron { id: *id, before }); - self.write_neuron(*id, Some(img)); - self.dirty(); - Ok(()) - } - - fn mint(&mut self, token: &Id, amount: u64) -> Result<(), StateError> { - let before = self.bbg.coins.get(token).map(|c| c.total_supply); - let next = before.unwrap_or(0).checked_add(amount).ok_or(StateError::Overflow)?; - self.note(UndoEntry::Coin { token: *token, before }); - self.bbg.coins.insert(*token, CoinRecord { total_supply: next }); - self.dirty(); - Ok(()) - } - - fn link(&mut self, from: &Id, p: &Id, q: &Id, token: &Id, amount: u64, valence: i8, height: u64) -> Result<(), StateError> { - assert_eq!(amount, 0, "rule set 1 carries no boxes (chain.md §10 item 2)"); - let axon = axon_id(p, q); - // before-images of everything bbg's insert touches for one link - self.note_particle(axon); - self.note_particle(*q); - let out_before = self.bbg.axons_out.get(p).map(|v| v.len() as u64); - self.note(UndoEntry::AxonsOut { p: *p, before: out_before }); - let in_before = self.bbg.axons_in.get(q).map(|v| v.len() as u64); - self.note(UndoEntry::AxonsIn { q: *q, before: in_before }); - let existed = self.bbg.axon_edges.contains_key(&axon); - self.note(UndoEntry::AxonEdge { axon, existed }); - self.note_balance(balance_key(q, token)); - self.note_balance(balance_key(p, token)); - self.note_neuron(*from); - let signal = Signal { - neuron: *from, - links: vec![Cyberlink { from: *p, to: *q, token: *token, amount, valence }], - box_moves: Vec::new(), - height, - }; - self.bbg.insert(&signal).map_err(|_| StateError::Overflow)?; - Ok(()) - } - - fn raw_balance(&self, key: &Id) -> u64 { - self.bbg.balances.get(key).copied().unwrap_or(0) - } - - fn credit_raw(&mut self, key: &Id, token: &Id, amount: u64) -> Result<(), StateError> { - if amount == 0 { - return Ok(()); - } - let have = self.raw_balance(key); - let next = have.checked_add(amount).ok_or(StateError::Overflow)?; - let sum = self.sums.get(token).copied().unwrap_or(0).checked_add(amount).ok_or(StateError::Overflow)?; - self.note_balance(*key); - self.note_sum(*token); - self.set_balance(*key, Some(next)); - self.sums.insert(*token, sum); - Ok(()) - } - - fn debit_raw(&mut self, key: &Id, token: &Id, amount: u64) -> Result<(), StateError> { - if amount == 0 { - return Ok(()); - } - let have = self.raw_balance(key); - if have < amount { - return Err(StateError::Insufficient { have, need: amount }); - } - self.note_balance(*key); - self.note_sum(*token); - self.set_balance(*key, (have > amount).then_some(have - amount)); - let sum = self.sums.get(token).copied().unwrap_or(0); - self.sums.insert(*token, sum - amount); - Ok(()) - } - - fn card(&self, id: &Id) -> Option { - self.bbg.cards.get(id).map(Self::card_from) - } - - fn set_card(&mut self, id: &Id, card: Option) { - let before = self.card(id); - self.note(UndoEntry::Card { id: *id, before }); - self.write_card(*id, card); - } - - fn cards(&self) -> Vec<(Id, Card)> { - self.bbg.cards.iter().map(|(id, r)| (*id, Self::card_from(r))).collect() - } - - fn axon_edge(&self, axon: &Id) -> Option<(Id, Id)> { - self.bbg.axon_edges.get(axon).copied() - } - - fn marks(&self) -> Vec<(Id, Mark)> { - self.marks.iter().map(|(a, m)| (*a, *m)).collect() - } - - fn set_mark(&mut self, axon: &Id, mark: Option) { - let before = self.marks.get(axon).copied(); - if before == mark { - return; - } - self.note(UndoEntry::Mark { axon: *axon, before }); - match mark { - None => self.marks.remove(axon), - Some(m) => self.marks.insert(*axon, m), - }; - } - - fn scalars(&self) -> Scalars { - self.scalars - } - - fn set_scalars(&mut self, s: Scalars) { - if s == self.scalars { - return; - } - self.note(UndoEntry::Scalars { before: self.scalars }); - self.scalars = s; - } - - fn time(&self, height: u64) -> Option { - self.bbg.time.get(&height).copied() - } - - fn set_time(&mut self, height: u64, value: Id) { - let before = self.time(height); - self.note(UndoEntry::Time { height, before }); - self.write_time(height, Some(value)); - } - - fn begin(&mut self) { - self.journal.clear(); - self.open = true; - } - - fn commit(&mut self) -> Undo { - self.open = false; - Undo(std::mem::take(&mut self.journal)) - } - - fn revert(&mut self, undo: &Undo) { - for e in undo.0.iter().rev() { - match e { - UndoEntry::Balance { key, before } => self.set_balance(*key, *before), - UndoEntry::Neuron { id, before } => self.write_neuron(*id, *before), - UndoEntry::Coin { token, before } => match before { - None => { - self.bbg.coins.remove(token); - } - Some(v) => { - self.bbg.coins.insert(*token, CoinRecord { total_supply: *v }); - } - }, - UndoEntry::Particle { id, before } => match before { - None => { - self.bbg.particles.remove(id); - } - Some(img) => { - self.bbg.particles.insert(*id, particle_from(*img)); - } - }, - UndoEntry::AxonsOut { p, before } => { - // lists are append-only within a block: drop the appends - self.bbg.axons_out.truncate(p, before.unwrap_or(0) as usize); - } - UndoEntry::AxonsIn { q, before } => { - // lists are append-only within a block: drop the appends - self.bbg.axons_in.truncate(q, before.unwrap_or(0) as usize); - } - UndoEntry::AxonEdge { axon, existed } => { - if !existed { - self.bbg.axon_edges.remove(axon); - } - } - UndoEntry::TokenSum { token, before } => match before { - None => { - self.sums.remove(token); - } - Some(v) => { - self.sums.insert(*token, *v); - } - }, - UndoEntry::Height { before } => self.bbg.height = *before, - UndoEntry::Card { id, before } => self.write_card(*id, *before), - UndoEntry::Mark { axon, before } => match before { - None => { - self.marks.remove(axon); - } - Some(m) => { - self.marks.insert(*axon, *m); - } - }, - UndoEntry::Scalars { before } => self.scalars = *before, - UndoEntry::Time { height, before } => self.write_time(*height, *before), - } - } - // the reverts above go through bbg's committed maps: the root is - // current without a refresh - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn journal_reverts_every_dimension_exactly() { - let mut s = BbgBacked::new(); - let (a, b, t) = ([1u8; 32], [2u8; 32], [9u8; 32]); - s.credit(&a, &t, 100).unwrap(); - s.mint(&t, 100).unwrap(); - s.ensure_neuron(&a); - s.link(&a, &[5; 32], &[6; 32], &t, 0, 1, 0).unwrap(); - let root0 = s.root(); - let count0 = (s.bbg.particles.len(), s.bbg.axons_out.len(), s.bbg.balances.len(), s.bbg.neurons.len()); - - s.begin(); - s.set_height(1); - s.debit(&a, &t, 30).unwrap(); - s.credit(&b, &t, 30).unwrap(); - s.bump_nonce(&a).unwrap(); - s.bump_nonce(&b).unwrap(); - s.mint(&t, 5).unwrap(); - s.mint(&[8; 32], 5).unwrap(); - s.link(&b, &[5; 32], &[7; 32], &t, 0, -1, 1).unwrap(); - s.link(&b, &[5; 32], &[6; 32], &t, 0, 0, 1).unwrap(); - let undo = s.commit(); - assert_eq!(s.balance(&a, &t), 70); - assert_eq!(s.nonce(&a), 1); - assert_eq!(s.nonce(&b), 1); - assert_eq!(s.token_sum(&t), 100); - assert_eq!(s.supply(&t), 105); - assert_eq!(s.height(), 1); - assert_ne!(s.root(), root0); - - s.revert(&undo); - assert_eq!(s.root(), root0); - assert_eq!((s.bbg.particles.len(), s.bbg.axons_out.len(), s.bbg.balances.len(), s.bbg.neurons.len()), count0); - assert_eq!(s.balance(&a, &t), 100); - assert_eq!(s.balance(&b, &t), 0); - assert_eq!(s.nonce(&a), 0); - assert!(!s.has_neuron(&b)); - assert_eq!(s.tokens(), vec![t]); - assert_eq!(s.height(), 0); - assert_eq!(Undo::decode(&undo.encode()).unwrap(), undo); - } - - #[test] - fn debit_never_goes_negative_and_sums_track() { - let mut s = BbgBacked::new(); - let (a, t) = ([1u8; 32], [9u8; 32]); - assert_eq!(s.debit(&a, &t, 1), Err(StateError::Insufficient { have: 0, need: 1 })); - s.credit(&a, &t, u64::MAX).unwrap(); - assert_eq!(s.credit(&a, &t, 1), Err(StateError::Overflow)); - assert_eq!(s.token_sum(&t), u64::MAX); - s.debit(&a, &t, u64::MAX).unwrap(); - assert_eq!(s.token_sum(&t), 0); - } -} diff --git a/crate/src/chain/conformance.rs b/crate/src/chain/conformance.rs deleted file mode 100644 index 4e2f4bbb..00000000 --- a/crate/src/chain/conformance.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! The blessed vectors of chain-conformance.md (§6 signed transactions, §8 -//! state, §10 genesis, §12 the pool) as `conformance/chain.snap` entries, generated from -//! the reference implementation. `soft3 conformance bless chain` writes -//! the file; `soft3 conformance check chain` regenerates and compares. -//! -//! Entries are `hemera` fingerprints of canonical bytes where the stack's -//! snapshot discipline wants fingerprints, and the 32-byte value itself -//! where the spec names an id or a root (a root is already a hash). Each -//! line: ` :: "" tier=gamma h`, keys padded to the -//! longest, sorted by key — the `.snap` shape of conformance/specs/README.md. - -use std::collections::BTreeMap; - -use crate::genesis::file::{Balance, GenesisFile}; - -use super::api::ChainApi; -use super::bbg_state::BbgBacked; -use super::engine::Chain; -use super::genesis::{Genesis, Params}; -use super::header::hash32; -use super::params::GENESIS_BITS; -use super::sigs::{self, neuron_of, token}; -use super::state::StateBackend; -use super::tx::{Body, Tx}; - -/// One snapshot line. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Entry { - pub key: String, - pub scenario: String, - pub value: [u8; 32], -} - -fn hex(b: &[u8]) -> String { - b.iter().map(|x| format!("{x:02x}")).collect() -} - -fn unhex32(s: &str) -> Option<[u8; 32]> { - if s.len() != 64 { - return None; - } - let mut out = [0u8; 32]; - for (i, b) in out.iter_mut().enumerate() { - *b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?; - } - Some(out) -} - -/// The known-answer key of chain-conformance §6 (the all-`abandon` BIP-39 -/// phrase at `m/44'/118'/0'/0/0`), as a raw secret so the `bridge` feature -/// is not needed: the secret the phrase derives to. -pub const KNOWN_ANSWER_SECRET: [u8; 32] = [ - 0xc4, 0xa4, 0x8e, 0x2f, 0xce, 0x14, 0x81, 0xcd, 0x32, 0x94, 0xb4, 0x49, 0x0f, 0x66, 0x78, 0x09, 0x0e, 0xa9, 0x8d, 0x3d, 0x0e, 0x5c, 0xd9, - 0x84, 0x55, 0x8a, 0xb0, 0x96, 0x87, 0x41, 0xb1, 0x04, -]; - -/// The §6 key, from the pinned secret. -pub fn known_answer_key() -> mudra::SigningKey { - mudra::SigningKey::from_bytes((&KNOWN_ANSWER_SECRET).into()).expect("pinned secret is a valid scalar") -} - -/// The conformance net of §8: one `b` frame (`0x66 × 32`, `token("pussy")`, -/// 1,000,000), no links, genesis time 0, `GENESIS_BITS`, no checkpoints. -pub fn conformance_genesis() -> Genesis { - Genesis::from_file(&GenesisFile { - params: Params { - version: 1, - name: "conformance".into(), - hrp: "pussy".into(), - denom: "pussy".into(), - time: 0, - bits: GENESIS_BITS, - subsidy: 0, - checkpoint_key: [0; 33], - checkpoint_sunset: 0, - pool: super::pool::Pool::genesis(), - }, - balances: vec![Balance { owner: [0x66; 32], token: token("pussy"), amount: 1_000_000 }], - links: vec![], - }) - .expect("the conformance genesis is canonical") -} - -/// §6 blessed signed transactions: the transfer and the cyberlink signed -/// by the known-answer key under `chain = 0x44 × 32`. ECDSA here is -/// RFC 6979 deterministic, so the bytes are stable. -pub fn signed_vectors() -> Vec<(String, Vec)> { - let chain = [0x44u8; 32]; - let key = known_answer_key(); - let from = neuron_of(&mudra::cosmos::compressed(key.verifying_key())); - let mut transfer = Tx { body: Body::Transfer { from, nonce: 0, token: token("pussy"), amount: 5, to: [0x77; 32], fee: 224 }, sig: vec![] }; - sigs::sign(&chain, &mut transfer, &key).expect("own subject"); - let mut link = Tx { - body: Body::Cyberlink { from, nonce: 0, token: token("pussy"), amount: 0, p: token("boot"), q: token("pussy"), valence: 1, fee: 257 }, - sig: vec![], - }; - sigs::sign(&chain, &mut link, &key).expect("own subject"); - vec![("signed transfer".into(), transfer.encode()), ("signed cyberlink".into(), link.encode())] -} - -/// The §8 state scenarios on the conformance net, run on the engine. -fn state_entries() -> Vec { - let mut out = Vec::new(); - let push = |out: &mut Vec, scenario: &str, value: [u8; 32]| { - out.push(Entry { key: "chain::state@v1".into(), scenario: scenario.into(), value }); - }; - let mut empty = BbgBacked::new(); - push(&mut out, "empty state root", empty.root()); - let g = conformance_genesis(); - push(&mut out, "conformance genesis file", hash32(&g.bytes)); - let mut s = g.state(); - let root0 = s.root(); - push(&mut out, "conformance genesis state_root", root0); - push(&mut out, "conformance block0 id", g.block0(root0).id()); - out -} - -/// §10 genesis entries from a genesis file (the pussy file), or the -/// committed values when no file is given (`None` keeps them as they are). -pub fn genesis_entries(name: &str, bytes: &[u8]) -> Result, String> { - use super::economy::focus::{FocusEngine, FocusGraph, Tru}; - use super::economy::weights::GenesisWeights; - let g = Genesis::decode(bytes).map_err(|e| e.to_string())?; - let mut s = g.state(); - let root = s.root(); - // chain-economy §10: the genesis weights and φ*_0 of the file - let w = GenesisWeights::from_file(&g.file); - let adjacency: Vec<([u8; 32], [u8; 32], u128)> = w.axons.iter().map(|(p, q, g)| (*p, *q, *g as u128)).collect(); - let graph = FocusGraph::build(&adjacency); - let f0 = Tru::new(1).focus(&graph); - let f8 = Tru::new(8).focus(&graph); - if f8 != f0 { - return Err(format!("{name}: φ*_0 differs between 1 and 8 threads")); - } - let scalars = |v: &[u64]| { - let mut out = [0u8; 32]; - for (i, x) in v.iter().enumerate() { - out[i * 8..i * 8 + 8].copy_from_slice(&x.to_le_bytes()); - } - out - }; - let e = |scenario: String, value: [u8; 32]| Entry { key: "chain::genesis@v1".into(), scenario, value }; - Ok(vec![ - e(format!("{name} particle"), g.chain_id), - e(format!("{name} state_root"), root), - e(format!("{name} block0_id"), g.block0(root).id()), - e(format!("{name} genesis weights digest"), w.digest()), - e(format!("{name} genesis weights total, frames at floor"), scalars(&[w.total as u64, (w.total >> 64) as u64, w.at_floor])), - e(format!("{name} phi*_0 digest (1 and 8 threads)"), super::economy::phi_digest(&f0.phi)), - e(format!("{name} phi*_0 syntropy"), scalars(&[f0.syntropy as u64, (f0.syntropy >> 64) as u64])), - ]) -} - -/// Every entry the reference implementation can produce without the -/// pinned genesis file. -pub fn entries() -> Vec { - let mut out = Vec::new(); - for (scenario, bytes) in signed_vectors() { - out.push(Entry { key: "chain::tx@v1".into(), scenario, value: hash32(&bytes) }); - } - out.extend(state_entries()); - for (scenario, value) in super::pool::vectors() { - out.push(Entry { key: "chain::pool@v1".into(), scenario: scenario.into(), value }); - } - for (scenario, value) in super::economy::vectors::entries() { - out.push(Entry { key: "chain::economy@v1".into(), scenario, value }); - } - out -} - -/// Parse a committed `chain.snap`. -pub fn parse(src: &str) -> Vec { - let mut out = Vec::new(); - for line in src.lines() { - let line = line.trim_end(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let Some((key, rest)) = line.split_once(" :: \"") else { continue }; - let Some((scenario, rest)) = rest.split_once('"') else { continue }; - let Some(h) = rest.split_whitespace().find_map(|w| w.strip_prefix('h')) else { continue }; - if let Some(value) = unhex32(h) { - out.push(Entry { key: key.trim().into(), scenario: scenario.into(), value }); - } - } - out -} - -/// Render entries in the `.snap` shape: sorted by `(key, scenario)`, keys -/// padded to the longest. -pub fn render(entries: &[Entry], date: &str) -> String { - let mut sorted: Vec<&Entry> = entries.iter().collect(); - sorted.sort_by(|a, b| (&a.key, &a.scenario).cmp(&(&b.key, &b.scenario))); - let label = |e: &Entry| format!("{} :: \"{}\"", e.key, e.scenario); - let width = sorted.iter().map(|e| label(e).len()).max().unwrap_or(0); - let mut out = String::new(); - out.push_str(&format!("# generated by `soft3 conformance bless chain` ({date}); do not edit by hand\n")); - out.push_str("# chain.snap — chain-conformance.md blessed vectors: §6 signed txs (fingerprints), §8 state roots and ids, §10 genesis\n"); - out.push_str("# (particle = chain id, block-0 root and id, genesis weights, phi*_0), §12 pool formats (frozen at genesis), §14 the focus economy (tru epoch engine e660d1e).\n"); - out.push_str("# Roots move with bbg's DIMENSION_VERSION, economy entries with tru's engine; a moved entry is a consensus change.\n"); - for e in sorted { - out.push_str(&format!("{: Vec { - let mut map: BTreeMap<(String, String), [u8; 32]> = committed.iter().map(|e| ((e.key.clone(), e.scenario.clone()), e.value)).collect(); - for e in current { - map.insert((e.key.clone(), e.scenario.clone()), e.value); - } - map.into_iter().map(|((key, scenario), value)| Entry { key, scenario, value }).collect() -} - -/// Differences between committed and current entries: `(label, committed, current)`. -pub fn drift(committed: &[Entry], current: &[Entry]) -> Vec<(String, Option<[u8; 32]>, [u8; 32])> { - let map: BTreeMap<(String, String), [u8; 32]> = committed.iter().map(|e| ((e.key.clone(), e.scenario.clone()), e.value)).collect(); - let mut out = Vec::new(); - for e in current { - let was = map.get(&(e.key.clone(), e.scenario.clone())).copied(); - if was != Some(e.value) { - out.push((format!("{} :: \"{}\"", e.key, e.scenario), was, e.value)); - } - } - out -} - -/// Today's UTC date as `YYYY-MM-DD` (civil-from-days, no time crate). -pub fn utc_date() -> String { - let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - let z = (secs / 86_400) as i64 + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - format!("{y:04}-{m:02}-{d:02}") -} - -/// The 200-block simulation of §8 ("conservation") and the §9 checks run -/// as engine tests (`chain::tests`); this smoke check runs the conformance -/// net one block forward and confirms the state root the engine commits -/// equals a fresh transition — the §8 "after block 1" row in miniature. -pub fn block1_root() -> [u8; 32] { - let g = conformance_genesis(); - let mut dir = std::env::temp_dir(); - dir.push(format!("soft3-chain-conformance-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let mut chain = Chain::open(&dir, &g.bytes, true).expect("conformance net opens"); - let tpl = chain.header_template([0x55; 32], 1, &[]).expect("template"); - let _ = std::fs::remove_dir_all(&dir); - tpl.header.state_root -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn known_answer_secret_is_the_abandon_key() { - let key = known_answer_key(); - let pk = mudra::cosmos::compressed(key.verifying_key()); - assert_eq!(hex(&pk), "024f4e2ad99c34d60b9ba6283c9431a8418af8673212961f97a77b6377fcd05b62"); - let derived = mudra::spell::cosmos_key(super::super::sigs::tests::ABANDON, "").unwrap(); - assert_eq!(&derived.to_bytes()[..], &KNOWN_ANSWER_SECRET, "the pinned secret is the phrase's key"); - assert!(utc_date().len() == 10); - } - - #[test] - fn signed_vectors_verify_and_are_deterministic() { - let a = signed_vectors(); - let b = signed_vectors(); - assert_eq!(a, b, "RFC 6979 signatures are deterministic"); - for (_, bytes) in &a { - let tx = Tx::decode(bytes).unwrap(); - assert!(sigs::verify(&[0x44; 32], &tx)); - assert!(!sigs::verify(&[0x45; 32], &tx)); - } - assert_eq!(a[0].1.len(), 224); - assert_eq!(a[1].1.len(), 257); - } - - #[test] - fn render_parse_round_trip_and_drift() { - let e = entries(); - let text = render(&e, "2026-10-15"); - let back = parse(&text); - let mut sorted = e.clone(); - sorted.sort_by(|a, b| (&a.key, &a.scenario).cmp(&(&b.key, &b.scenario))); - assert_eq!(back, sorted); - assert!(drift(&back, &e).is_empty()); - let mut moved = e.clone(); - moved[0].value[0] ^= 1; - assert_eq!(drift(&back, &moved).len(), 1); - let committed = parse("chain::genesis@v1 :: \"pussy particle\" tier=gamma h2a83e54fe395ffbd924fc74a54a5c805eeec5396f97bf4c73fced8631a9e61b5\n"); - assert_eq!(committed.len(), 1); - assert_eq!(merged(&committed, &e).len(), e.len() + 1); - } - - /// The committed snapshot must match this build (the `chain-conformance` - /// feature's acceptance): every regenerated entry equals its committed - /// line, and the pussy genesis entries when the file is cached. - #[test] - fn committed_chain_snap_matches_this_build() { - let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../conformance/chain.snap"); - let committed = parse(&std::fs::read_to_string(path).expect("conformance/chain.snap")); - let mut current = entries(); - let home = std::env::var("HOME").unwrap_or_default(); - let pussy = std::path::Path::new(&home).join(".cache/cyber/genesis/pussy.tade"); - if let Ok(bytes) = std::fs::read(&pussy) { - current.extend(genesis_entries("pussy", &bytes).unwrap()); - } - let d = drift(&committed, ¤t); - assert!(d.is_empty(), "chain.snap drift (re-bless with `soft3 conformance bless chain` and review): {d:?}"); - assert!(committed.iter().any(|e| e.scenario == "pussy particle")); - } -} - diff --git a/crate/src/chain/corpus.rs b/crate/src/chain/corpus.rs deleted file mode 100644 index e8824d7f..00000000 --- a/crate/src/chain/corpus.rs +++ /dev/null @@ -1,460 +0,0 @@ -//! The conformance corpus writer (proposals/proven-present.md row S0): every -//! block a node is given, in arrival order, with its native verdict and `R` -//! (the bbg state root after the call), plus a generator of invalid blocks. -//! -//! Format: JSON Lines, `/blocks.jsonl`, one record per line, append-only, -//! flushed per record. Fields: -//! -//! - `height` the block's header height -//! - `id` hex of the block id -//! - `block` hex of [`Block::encode`] (`header[168] ‖ body`, the `B` frame payload) -//! - `verdict` [`verdict_code`], or `"genesis"` for record 0 -//! - `root` hex of `R` (the tip's `state_root`) after the call when the verdict -//! accepts; `null` for a rejection -//! - `mutation` `null` for a block as received/mined, else the generator's name -//! -//! Record 0 is block 0 with verdict `"genesis"` and `R(G)`. - -use std::collections::HashSet; -use std::fs::{File, OpenOptions}; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; - -use super::api::{Block, BlockVerdict, ChainApi, ChainError}; -use super::apply::{BlockError, TxError}; -use super::header::{self, BlockId, Header, HeaderError}; -use super::pow::valid_pow; -use super::state::Id; -use super::tree; -use super::tx::{self, Body, Tx}; - -/// The corpus file inside the corpus directory. -pub const FILE: &str = "blocks.jsonl"; - -/// Longest nonce search the generator runs for one block. -const GRIND_LIMIT: u128 = 1 << 20; - -// ── verdict codes ─────────────────────────────────────────────────────── - -/// Stable snake_case code of an `accept_block` result. Every `match` here is -/// exhaustive on purpose: a new variant must fail the build, not be mis-coded. -pub fn verdict_code(r: &Result) -> String { - match r { - Ok(BlockVerdict::Extended) => "extended".into(), - Ok(BlockVerdict::Reorganized { .. }) => "reorganized".into(), - Ok(BlockVerdict::Stored) => "stored".into(), - Ok(BlockVerdict::NeedBodies(_)) => "need_bodies".into(), - Ok(BlockVerdict::Known) => "known".into(), - Err(e) => format!("reject:{}", chain_error_code(e)), - } -} - -fn chain_error_code(e: &ChainError) -> String { - match e { - ChainError::Header(h) => format!("header:{}", header_error_code(h)), - ChainError::UnknownParent(_) => "unknown_parent".into(), - ChainError::InvalidParent(_) => "invalid_parent".into(), - ChainError::Height { .. } => "height".into(), - ChainError::Time { .. } => "time".into(), - ChainError::FutureTime { .. } => "future_time".into(), - ChainError::Bits { .. } => "bits".into(), - ChainError::Pow => "pow".into(), - ChainError::RuleSetUnknown(_) => "rule_set_unknown".into(), - ChainError::DeepFork { .. } => "deep_fork".into(), - ChainError::Checkpoint { .. } => "checkpoint".into(), - ChainError::CheckpointRejected(_) => "checkpoint_rejected".into(), - ChainError::Block(b) => format!("block:{}", block_error_code(b)), - ChainError::Rejected(_) => "rejected".into(), - ChainError::Genesis => "genesis".into(), - ChainError::Store(_) => "store".into(), - } -} - -fn header_error_code(e: &HeaderError) -> &'static str { - match e { - HeaderError::Length(_) => "length", - HeaderError::RuleSet(_) => "rule_set", - HeaderError::WrongChain => "wrong_chain", - HeaderError::Bits(_) => "bits", - } -} - -fn block_error_code(e: &BlockError) -> String { - match e { - BlockError::Body(_) => "body".into(), - BlockError::Empty => "empty".into(), - BlockError::TooManyTxs(_) => "too_many_txs".into(), - BlockError::TooLarge(_) => "too_large".into(), - BlockError::TxRoot => "tx_root".into(), - BlockError::CoinbasePosition => "coinbase_position".into(), - BlockError::CoinbaseHeight { .. } => "coinbase_height".into(), - BlockError::DuplicateTx(_) => "duplicate_tx".into(), - BlockError::CoinbaseAmount { .. } => "coinbase_amount".into(), - BlockError::Tx { error, .. } => format!("tx:{}", tx_error_code(error)), - BlockError::StateRoot { .. } => "state_root".into(), - BlockError::Conservation { .. } => "conservation".into(), - BlockError::Settlement { .. } => "settlement".into(), - BlockError::StakeTotal { .. } => "stake_total".into(), - BlockError::Overflow => "overflow".into(), - } -} - -fn tx_error_code(e: &TxError) -> &'static str { - match e { - TxError::Decode(_) => "decode", - TxError::TooLarge(_) => "too_large", - TxError::CoinbaseInBody => "coinbase_in_body", - TxError::Signature => "signature", - TxError::Nonce { .. } => "nonce", - TxError::Fee { .. } => "fee", - TxError::Insufficient { .. } => "insufficient", - TxError::Immature { .. } => "immature", - TxError::SelfLink => "self_link", - TxError::StakeToken => "stake_token", - TxError::Valence { .. } => "valence", - TxError::NoCard => "no_card", - TxError::Locked { .. } => "locked", - TxError::UnlockAmount { .. } => "unlock_amount", - TxError::ClaimKey => "claim_key", - TxError::Overflow => "overflow", - } -} - -// ── the file ──────────────────────────────────────────────────────────── - -#[derive(Serialize, Deserialize)] -struct Record { - height: u64, - id: String, - block: String, - verdict: String, - root: Option, - mutation: Option, -} - -fn hex(b: &[u8]) -> String { - b.iter().map(|x| format!("{x:02x}")).collect() -} - -fn unhex(s: &str) -> Option> { - if s.len() % 2 != 0 || !s.is_ascii() { - return None; - } - (0..s.len() / 2).map(|i| u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()).collect() -} - -/// Append-only writer of `/blocks.jsonl`. -pub struct CorpusWriter { - file: File, - path: PathBuf, - records: usize, -} - -impl CorpusWriter { - /// Open (creating the directory and file if missing) for append. - pub fn open(dir: &Path) -> io::Result { - std::fs::create_dir_all(dir)?; - let path = dir.join(FILE); - let records = match std::fs::read_to_string(&path) { - Ok(text) => text.lines().filter(|l| !l.trim().is_empty()).count(), - Err(e) if e.kind() == io::ErrorKind::NotFound => 0, - Err(e) => return Err(e), - }; - let file = OpenOptions::new().create(true).append(true).open(&path)?; - Ok(CorpusWriter { file, path, records }) - } - - pub fn path(&self) -> &Path { - &self.path - } - - /// Records in the file, including ones from earlier runs. - pub fn len(&self) -> usize { - self.records - } - - pub fn is_empty(&self) -> bool { - self.records == 0 - } - - fn write(&mut self, rec: &Record) -> io::Result<()> { - let mut line = serde_json::to_string(rec).map_err(io::Error::other)?; - line.push('\n'); - self.file.write_all(line.as_bytes())?; - self.file.flush()?; - self.records += 1; - Ok(()) - } - - /// Record 0: block 0 with verdict `"genesis"` and `R(G)`. Does nothing - /// when the file already has records. - pub fn record_genesis(&mut self, chain: &impl ChainApi) -> io::Result<()> { - if !self.is_empty() { - return Ok(()); - } - let g = chain.header_at(0).and_then(|h| chain.block(&h.id())).ok_or_else(|| io::Error::other("chain has no block 0"))?; - self.write(&Record { - height: 0, - id: hex(&g.id()), - block: hex(&g.encode()), - verdict: "genesis".into(), - root: Some(hex(&g.header.state_root)), - mutation: None, - }) - } - - /// One record: `root` is `R` after the call; it is written only when the - /// result accepts. - pub fn record(&mut self, block: &Block, result: &Result, root: &Id, mutation: Option<&str>) -> io::Result<()> { - self.write(&Record { - height: block.header.height, - id: hex(&block.id()), - block: hex(&block.encode()), - verdict: verdict_code(result), - root: result.is_ok().then(|| hex(root)), - mutation: mutation.map(str::to_string), - }) - } -} - -/// `accept_block`, then the record. Writes record 0 first if the file is -/// empty. The outer error is the file's; the inner is the chain's verdict. -pub fn record_accept( - chain: &mut C, - writer: &mut CorpusWriter, - block: &Block, - now: u64, - mutation: Option<&str>, -) -> io::Result> { - writer.record_genesis(&*chain)?; - let result = chain.accept_block(block, now); - let root = chain.tip().header.state_root; - writer.record(block, &result, &root, mutation)?; - Ok(result) -} - -// ── replay ────────────────────────────────────────────────────────────── - -/// The first record whose replay differs from the file. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ReplayMismatch { - /// 1-based line in `blocks.jsonl` (0: the file could not be read) - pub line: usize, - pub reason: String, -} - -impl std::fmt::Display for ReplayMismatch { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{FILE}:{}: {}", self.line, self.reason) - } -} - -impl std::error::Error for ReplayMismatch {} - -/// Replay `/blocks.jsonl` on a fresh `chain`: record 0 must be the -/// chain's genesis and `R(G)`; every later block goes through `accept_block` -/// in file order and must reproduce the recorded verdict code and root. -/// Returns the number of records after genesis. -pub fn replay(dir: &Path, chain: &mut C, now: u64) -> Result { - let text = std::fs::read_to_string(dir.join(FILE)).map_err(|e| ReplayMismatch { line: 0, reason: e.to_string() })?; - let mut count = 0usize; - let mut seen_genesis = false; - for (i, raw) in text.lines().enumerate() { - let line = i + 1; - if raw.trim().is_empty() { - continue; - } - let bad = |reason: String| ReplayMismatch { line, reason }; - let rec: Record = serde_json::from_str(raw).map_err(|e| bad(format!("not a record: {e}")))?; - let bytes = unhex(&rec.block).ok_or_else(|| bad("block is not hex".into()))?; - let block = Block::decode(&bytes).map_err(|e| bad(format!("block does not decode: {e}")))?; - if hex(&block.id()) != rec.id || block.header.height != rec.height { - return Err(bad("id/height do not match the block bytes".into())); - } - if !seen_genesis { - seen_genesis = true; - let g = chain.header_at(0).and_then(|h| chain.block(&h.id())).ok_or_else(|| bad("chain has no block 0".into()))?; - if rec.verdict != "genesis" || rec.height != 0 { - return Err(bad(format!("record 0 must be the genesis, found {:?} at height {}", rec.verdict, rec.height))); - } - if g.encode() != bytes { - return Err(bad(format!("genesis block differs: file {}, chain {}", rec.id, hex(&g.id())))); - } - let want = hex(&g.header.state_root); - if rec.root.as_deref() != Some(want.as_str()) { - return Err(bad(format!("R(G) is {want}, file has {:?}", rec.root))); - } - continue; - } - let result = chain.accept_block(&block, now); - let code = verdict_code(&result); - if code != rec.verdict { - return Err(bad(format!("height {}: verdict {code}, file has {}", rec.height, rec.verdict))); - } - let root = result.is_ok().then(|| hex(&chain.tip().header.state_root)); - if root != rec.root { - return Err(bad(format!("height {}: R is {root:?}, file has {:?}", rec.height, rec.root))); - } - count += 1; - } - if !seen_genesis { - return Err(ReplayMismatch { line: 0, reason: "empty corpus".into() }); - } - Ok(count) -} - -// ── the invalid-block generator ───────────────────────────────────────── - -/// One mutated block and the verdict code it must draw. -#[derive(Clone, Debug)] -pub struct Invalid { - pub name: &'static str, - pub block: Block, - pub expected: String, -} - -/// First nonce (from 0) whose header does (`want = true`) or does not meet -/// its target and whose id is not in `skip`; bounded, so a hard target yields -/// `None` for `want = true`. `skip` holds the valid block's id and the ids of -/// the mutations already generated: each mutation must get an id of its own, -/// because a body mutation that reused the valid header would put the valid -/// id in the rejected set (§5.4) and every later one would draw `rejected` -/// instead of its own code. -fn grind(mut h: Header, want: bool, skip: &HashSet) -> Option
{ - for n in 0..GRIND_LIMIT { - h.nonce = n.to_le_bytes(); - if valid_pow(&h) == want && !skip.contains(&h.id()) { - return Some(h); - } - } - None -} - -/// The mutations so far and every block id taken (the valid block's included). -struct Mutations { - out: Vec, - used: HashSet, -} - -impl Mutations { - fn add(&mut self, name: &'static str, header: Header, body: &[u8], expected: &str) { - self.used.insert(header.id()); - self.out.push(Invalid { name, block: Block { header, body: body.to_vec() }, expected: expected.into() }); - } - - /// Re-grind the nonce so proof of work passes and the id is unused. - fn sealed(&mut self, name: &'static str, header: Header, body: Vec, expected: &str) { - if let Some(g) = grind(header, true, &self.used) { - self.add(name, g, &body, expected); - } - } -} - -fn coinbase_bytes(height: u64, to: Id, amount: u64, extra: [u8; 32]) -> Vec { - Tx { body: Body::Coinbase { height, to, amount, extra }, sig: vec![] }.encode() -} - -/// For the valid next block of `chain`'s tip, one mutated block per -/// validation step (chain.md §5.5) that can be reached without a key. Each -/// fails at exactly its step: those targeting steps 6–8 get their nonce -/// re-ground so proof of work passes, and recompute the tx root unless that -/// is what they break. Every mutation's block id is distinct from `valid`'s -/// and from every other mutation's (a body mutation reusing the valid header -/// would put the valid id in the rejected set, §5.4, and every later one would -/// draw `rejected`); the nonce search skips ids already used. Deterministic. -/// A mutation whose nonce search runs out (hard target) is left out. Mutations needing a transaction (`bad_signature`, -/// `duplicate_tx`, `coinbase_position`) appear when `valid` carries one; -/// `replayed_tx` when the last 64 blocks of the main chain carry one. -pub fn invalid_blocks(chain: &C, valid: &Block) -> Vec { - let h = valid.header; - let mut m = Mutations { out: Vec::new(), used: HashSet::from([valid.id()]) }; - - // steps 1–5: header only (everything before PoW fails before PoW is read) - m.add("wrong_chain", Header { chain: [0xEE; 32], ..h }, &valid.body, "reject:header:wrong_chain"); - m.add("rule_set", Header { version: (h.version & !0xffff) | 2, ..h }, &valid.body, "reject:header:rule_set"); - m.add("bits_zero", Header { bits: 0, ..h }, &valid.body, "reject:header:bits"); - m.add("second_genesis", Header { height: 0, ..h }, &valid.body, "reject:genesis"); - m.add("unknown_parent", Header { prev: [0xAB; 32], ..h }, &valid.body, "reject:unknown_parent"); - m.add("wrong_height", Header { height: h.height + 1, ..h }, &valid.body, "reject:height"); - m.add("time_not_after_mtp", Header { time: 0, ..h }, &valid.body, "reject:time"); - if let Ok(target) = header::decode_bits(h.bits) { - let harder = target >> 1; - if !harder.is_zero() { - m.add("wrong_bits", Header { bits: header::encode_bits(harder), ..h }, &valid.body, "reject:bits"); - } - } - if let Some(bad) = grind(h, false, &m.used) { - m.add("bad_pow", bad, &valid.body, "reject:pow"); - } - - // steps 6–8: body and state rules, the nonce re-ground - let rooted = |raws: &[Vec]| Header { tx_root: tree::tx_root(raws), ..h }; - - m.sealed("tx_root", Header { tx_root: flip(h.tx_root), ..h }, valid.body.clone(), "reject:block:tx_root"); - m.sealed("state_root", Header { state_root: flip(h.state_root), ..h }, valid.body.clone(), "reject:block:state_root"); - m.sealed("empty_body", h, tx::join_body(&[]), "reject:block:empty"); - if let Some(cut) = valid.body.len().checked_sub(1) { - m.sealed("truncated_body", h, valid.body[..cut].to_vec(), "reject:block:body"); - } - - let raws = tx::split_body(&valid.body).unwrap_or_default(); - let cb = raws.first().and_then(|r| Tx::decode(r).ok()).and_then(|t| match t.body { - Body::Coinbase { height, to, amount, extra } => Some((height, to, amount, extra)), - _ => None, - }); - if let Some((height, to, amount, extra)) = cb { - let cb_raw = &raws[0]; - let with = |first: Vec| -> Vec> { - let mut v = raws.clone(); - v[0] = first; - v - }; - let r = with(coinbase_bytes(height + 1, to, amount, extra)); - m.sealed("coinbase_height", rooted(&r), tx::join_body(&r), "reject:block:coinbase_height"); - let r = with(coinbase_bytes(height, to, amount + 1, extra)); - m.sealed("coinbase_amount", rooted(&r), tx::join_body(&r), "reject:block:coinbase_amount"); - let r = vec![cb_raw.clone(), cb_raw.clone()]; - m.sealed("coinbase_in_body", rooted(&r), tx::join_body(&r), "reject:block:tx:coinbase_in_body"); - - if let Some(t) = raws.get(1) { - let r = vec![cb_raw.clone(), t.clone(), t.clone()]; - m.sealed("duplicate_tx", rooted(&r), tx::join_body(&r), "reject:block:duplicate_tx"); - let mut bad = t.clone(); - let last = bad.len() - 1; - bad[last] ^= 1; - let r = with_tx(&raws, 1, bad); - m.sealed("bad_signature", rooted(&r), tx::join_body(&r), "reject:block:tx:signature"); - let r = vec![t.clone(), cb_raw.clone()]; - m.sealed("coinbase_position", rooted(&r), tx::join_body(&r), "reject:block:coinbase_position"); - } - - // a transaction an earlier block spent: its nonce is behind the account's - let tip = chain.tip().height; - let old = (1..=tip).rev().take(64).find_map(|n| { - let b = chain.blocks(n, 1).into_iter().next()?; - tx::split_body(&b.body).ok()?.into_iter().skip(1).last() - }); - let fees = raws.iter().skip(1).try_fold(0u64, |a, r| a.checked_add(Tx::decode(r).ok()?.fee())); - if let (Some(old), Some(fees)) = (old, fees) { - let old_fee = Tx::decode(&old).map(|t| t.fee()).unwrap_or(0); - if let Some(amount) = amount.checked_sub(fees).and_then(|share| share.checked_add(old_fee)) { - let r = vec![coinbase_bytes(height, to, amount, extra), old]; - m.sealed("replayed_tx", rooted(&r), tx::join_body(&r), "reject:block:tx:nonce"); - } - } - } - m.out -} - -fn with_tx(raws: &[Vec], at: usize, replacement: Vec) -> Vec> { - let mut v = raws.to_vec(); - v[at] = replacement; - v -} - -fn flip(mut b: [u8; 32]) -> [u8; 32] { - b[0] ^= 1; - b -} diff --git a/crate/src/chain/daa.rs b/crate/src/chain/daa.rs deleted file mode 100644 index 5e9657b4..00000000 --- a/crate/src/chain/daa.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Difficulty adjustment: integer ASERT (§1.5) and median time past (§1.4). -//! -//! The target of a block at height `h ≥ 1` is a pure function of the genesis -//! anchor and the parent; the candidate's own time never enters. A -//! floating-point `pow` anywhere here is a consensus bug. - -use super::header::{Header, clamp_bits}; -use super::params::{BLOCK_TIME, HALF_LIFE, MTP_WINDOW}; -use super::u256::U256; - -/// What the formula needs from the genesis block. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Anchor { - pub target: U256, - pub time: u64, -} - -/// The intermediate values conformance §4 pins, so a test can compare each. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Asert { - pub e: i64, - pub shifts: i64, - pub frac: u64, - pub factor: u64, - pub target: U256, - pub bits: u32, -} - -/// Floor division toward −∞ for i64 (Rust's `/` truncates toward zero). -pub fn floor_div(a: i64, b: i64) -> i64 { - let q = a / b; - if (a % b != 0) && ((a < 0) != (b < 0)) { q - 1 } else { q } -} - -fn floor_div_i128(a: i128, b: i128) -> i128 { - let q = a / b; - if (a % b != 0) && ((a < 0) != (b < 0)) { q - 1 } else { q } -} - -/// The cubic approximation of `2^(frac/65536)` in 16.16 fixed point -/// (aserti3-2d, Bitcoin Cash, MIT), error below 0.01 %. -pub fn factor(frac: u64) -> u64 { - debug_assert!(frac < 65_536); - let f = frac as u128; - let poly = 195_766_423_245_049u128 * f + 971_821_376u128 * f * f + 5_127u128 * f * f * f + (1u128 << 47); - 65_536 + (poly >> 48) as u64 -} - -/// ASERT for the block after `parent` (§1.5), with every intermediate. -pub fn asert(anchor: Anchor, parent_height: u64, parent_time: u64) -> Asert { - // i128 so no realistic or hostile height/time can overflow; `e` itself - // is saturated into i64, far beyond where the clamp already binds. - let dt = parent_time as i128 - anchor.time as i128; - let ideal = parent_height as i128 * BLOCK_TIME as i128; - let e = floor_div_i128((dt - ideal) * 65_536, HALF_LIFE as i128); - let e = e.clamp(i64::MIN as i128, i64::MAX as i128) as i64; - let shifts = e >> 16; - let frac = (e & 0xffff) as u64; - let factor = factor(frac); - // scaled < 2^(256+17); a left shift of 240 or more lands above 2^256 - // after the >> 16 and is clamped to POW_LIMIT anyway, so saturate there - // and never shift past 512 bits ("shifts beyond 256 saturate at the clamp"). - let scaled = anchor.target.to_u512().mul_u64(factor); - let shifted = if shifts < 0 { - scaled.shr((-shifts).min(512) as u32) - } else if shifts >= 240 { - super::u256::U512([u64::MAX; 8]) - } else { - scaled.shl(shifts as u32) - }; - let target = shifted.shr(16).saturating_u256(); - let bits = clamp_bits(target); - Asert { e, shifts, frac, factor, target: super::header::decode_bits(bits).expect("clamped"), bits } -} - -/// Expected `bits` of the block after `parent` (§5.5 step 4). -pub fn next_bits(anchor: Anchor, parent: &Header) -> u32 { - asert(anchor, parent.height, parent.time).bits -} - -/// Median time past of the last `MTP_WINDOW` headers (§1.4): `times` holds -/// the parent's and its ancestors' times, any order, at most the window; -/// fewer near genesis. The median of an even count is the lower middle. -pub fn median_time_past(times: &[u64]) -> u64 { - assert!(!times.is_empty() && times.len() <= MTP_WINDOW); - let mut sorted = times.to_vec(); - sorted.sort_unstable(); - sorted[(sorted.len() - 1) / 2] -} - -#[cfg(test)] -mod tests { - use super::*; - - fn anchor() -> Anchor { - Anchor { target: U256::ONE << 228, time: 0 } - } - - #[test] - fn floor_div_rounds_toward_negative_infinity() { - assert_eq!(floor_div(-37_355_520, 600), -62_260); - assert_eq!(floor_div(37_355_520, 600), 62_259); - assert_eq!(floor_div(-600, 600), -1); - assert_eq!(floor_div(-1, 600), -1); - assert_eq!(floor_div(0, 600), 0); - } - - #[test] - fn conformance_section_4_rows() { - let rows: [(u64, u64, i64, i64, u64, u64, u32); 6] = [ - (0, 0, 0, 0, 0, 65_536, 0x1d10_0000), - (19, 570, 0, 0, 0, 65_536, 0x1d10_0000), - (19, 1_170, 65_536, 1, 0, 65_536, 0x1d20_0000), - (100, 2_400, -65_536, -1, 0, 65_536, 0x1d08_0000), - (19, 0, -62_260, -1, 3_276, 67_852, 0x1d08_4860), - (5, 1_000_000, 109_210_282, 1_666, 27_306, 87_473, 0x1e10_0000), - ]; - for (height, time, e, shifts, frac, factor, bits) in rows { - let a = asert(anchor(), height, time); - assert_eq!((a.e, a.shifts, a.frac, a.factor, a.bits), (e, shifts, frac, factor, bits), "parent {height}@{time}"); - } - assert_eq!(asert(anchor(), 19, 0).target, U256::from_u64(0x08_4860) << (8 * 26)); - assert_eq!(asert(anchor(), 5, 1_000_000).target, U256::ONE << 236); - } - - #[test] - fn far_below_schedule_clamps_to_one() { - let a = asert(anchor(), 1_000_000, 0); - assert_eq!(a.bits, 0x0101_0000); - assert_eq!(a.target, U256::ONE); - } - - #[test] - fn factor_endpoints() { - assert_eq!(factor(0), 65_536); - let top = factor(65_535); - assert!(top > 131_060 && top <= 131_072, "{top}"); - } - - #[test] - fn mtp_lower_middle() { - assert_eq!(median_time_past(&[5]), 5); - assert_eq!(median_time_past(&[1, 2]), 1); - assert_eq!(median_time_past(&[3, 1, 2]), 2); - assert_eq!(median_time_past(&[4, 1, 3, 2]), 2); - assert_eq!(median_time_past(&[11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]), 6); - } - - #[test] - fn t_spacing_holds_bits_and_half_spacing_roughly_doubles_difficulty() { - let a = anchor(); - for h in 1..200u64 { - assert_eq!(asert(a, h, h * 30).bits, 0x1d10_0000); - } - // blocks at T/2 for 600 s of block time (40 blocks): parent 40 at time - // 600 is 600 s ahead of schedule → the target halves exactly (ratio - // 2.0, inside [1.9, 2.1]) - let x = asert(a, 40, 600); - assert_eq!(x.target, U256::ONE << 227); - // 100× slowdown: the target doubles per 600 s of wall time whether or - // not blocks arrive; after 4,200 s (70 min) it has grown 2^7 = 128 ≥ 100, - // so the interval is back at T — and still under POW_LIMIT - let slow = asert(a, 1, 30 + 4_200); - assert_eq!(slow.target, U256::ONE << 235); - assert!(slow.target <= U256::ONE << 236); - // 2 × step: 90 % absorbed within 50 min — parent 100 blocks at T/2 - // spacing is 1,500 s ahead → factor 2^-2.5; the drift stops growing - // once the target has fallen by 2 (checked at the 600 s mark above) - assert!(asert(a, 100, 1_500).target < U256::ONE << 226); - } - - #[test] - fn huge_positive_shift_saturates_at_limit() { - let a = anchor(); - assert_eq!(asert(a, 0, u64::MAX / 4).bits, 0x1e10_0000); - assert_eq!(asert(Anchor { target: U256::ONE, time: 0 }, 0, 600 * 300).target, U256::ONE << 236); - } -} diff --git a/crate/src/chain/economy/focus.rs b/crate/src/chain/economy/focus.rs deleted file mode 100644 index fd787f43..00000000 --- a/crate/src/chain/economy/focus.rs +++ /dev/null @@ -1,336 +0,0 @@ -//! The focus graph (chain-economy §4.3) and the φ* / gradient engine seam -//! (§4.4, §5.1). -//! -//! The chain hands the engine one canonical graph: present axons -//! `(p, q, A_pq)` ascending by `p ‖ q`, particles indexed by ascending id. -//! The engine returns φ* (tru's raw fixed point, 2^60 = one unit of mass), -//! the syntropy J(φ*) at the same scale, and for the gradient solve one -//! signed fixed-point `∂J/∂A_pq` per present axon. -//! -//! [`FocusEngine`] is the seam tru's epoch entry point plugs into -//! (tru branch `feat/epoch-settlement`). Until it lands the chain runs -//! [`Reference`]: the same contract — cold start from uniform, exactly -//! `PHI_STEPS` steps, integers only, thread-count independent, gradient by -//! one adjoint solve — over the diffusion term of the tri-kernel alone. Its -//! bits are not tru's: swapping engines is a consensus change and moves the -//! epoch vectors of conformance §14, which is why the engine's -//! [`FocusEngine::id`] is part of every cached result's key. - -use super::super::state::Id; -use super::weights::Axon; - -/// One unit of focus mass: φ is at scale 2^60 (§4.4). -pub const PHI_BITS: u32 = 60; -/// The scale of `ln` inside the solves. -pub const LN_BITS: u32 = 40; -/// The scale of the reference engine's gradient: `∂J/∂A` in nats per -/// token at 2^100 (φ at 2^60 times λ at 2^40); tru's is 2^112. Attribution -/// multiplies it by a stake change and divides it out only as a ratio -/// (§5.3, §5.5), so the scale cancels in every payout. -pub const GRAD_BITS: u32 = PHI_BITS + LN_BITS; - -/// The canonical input of one solve (§4.3). -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct FocusGraph { - /// every endpoint of a present axon, ascending - pub particles: Vec, - /// edges ascending by `(src, dst)` = by `p ‖ q` - pub src: Vec, - pub dst: Vec, - pub weight: Vec, -} - -impl FocusGraph { - /// From present axons ascending by `p ‖ q` (every weight > 0). - pub fn build(axons: &[Axon]) -> FocusGraph { - let mut particles: Vec = Vec::with_capacity(axons.len() * 2); - for (p, q, _) in axons { - particles.push(*p); - particles.push(*q); - } - particles.sort_unstable(); - particles.dedup(); - let index = |x: &Id| particles.binary_search(x).expect("endpoint indexed") as u32; - let mut g = FocusGraph { - src: Vec::with_capacity(axons.len()), - dst: Vec::with_capacity(axons.len()), - weight: Vec::with_capacity(axons.len()), - particles: Vec::new(), - }; - for (p, q, w) in axons { - debug_assert!(*w > 0); - g.src.push(index(p)); - g.dst.push(index(q)); - g.weight.push(*w); - } - g.particles = particles; - g - } - - pub fn n(&self) -> usize { - self.particles.len() - } - - pub fn edges(&self) -> usize { - self.src.len() - } -} - -/// φ* of one graph. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct Focus { - /// φ*, indexed as [`FocusGraph::particles`], at 2^60 - pub phi: Vec, - /// `J(φ*) = Σ φ ln(n φ)`, nats at 2^60 - pub syntropy: i128, -} - -/// The φ* / gradient engine (§4.4, §5.1). Implementations must be pure -/// functions of the graph: same bits on every machine and thread count. -pub trait FocusEngine: Send + Sync { - /// A short version tag; results cached on disk are keyed by it. - fn id(&self) -> &'static str; - - /// φ*(A): cold start, `PHI_STEPS` steps. - fn focus(&self, g: &FocusGraph) -> Focus; - - /// `∂J(φ*(A))/∂A_pq` for every edge of `g` in edge order, at scale - /// 2^[`GRAD_BITS`]: one forward solve to the fixed point of `g`, one - /// adjoint solve, one read per edge (§5.1). - fn gradient(&self, g: &FocusGraph) -> Vec; -} - -/// tru's epoch engine (tru `specs/epoch.md`, `tru::focusing::epoch`): the -/// tri-kernel over the canonical axon list, `PHI_STEPS` steps cold, φ at -/// 2^60, the adjoint gradient at 2^112. Thread count never changes a bit. -pub struct Tru { - pub params: tru::focusing::epoch::EpochParams, -} - -impl Tru { - /// The consensus parameters with `threads` workers. - pub fn new(threads: usize) -> Tru { - let mut params = tru::focusing::epoch::EpochParams::default(); - params.steps = super::super::params::PHI_STEPS; - params.threads = threads.max(1); - Tru { params } - } - - /// Worker threads for a node: the cores less one for the block path, - /// at most 8. - pub fn for_node() -> Tru { - let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); - Tru::new(cores.saturating_sub(1).clamp(1, 8)) - } - - fn axons(g: &FocusGraph) -> Vec { - (0..g.edges()) - .map(|e| tru::focusing::epoch::Axon { from: g.particles[g.src[e] as usize], to: g.particles[g.dst[e] as usize], weight: g.weight[e] }) - .collect() - } -} - -impl Default for Tru { - fn default() -> Self { - Tru::new(1) - } -} - -impl FocusEngine for Tru { - fn id(&self) -> &'static str { - // the pinned tru revision: a new pin is a new id (and new vectors) - "tru-epoch-e660d1e" - } - - fn focus(&self, g: &FocusGraph) -> Focus { - let f = tru::focusing::epoch::phi(&Self::axons(g), &self.params); - debug_assert_eq!(f.particles, g.particles, "both index particles by ascending id"); - Focus { phi: f.phi, syntropy: f.syntropy } - } - - fn gradient(&self, g: &FocusGraph) -> Vec { - tru::focusing::epoch::gradient(&Self::axons(g), &self.params).by_axon - } -} - -/// `ln 2` at 2^40. -const LN2_Q40: i128 = 762_123_384_786; - -/// `log2(v / 2^frac_bits)` at 2^40 for `v > 0`: integer part from the bit -/// length, 40 fraction bits by repeated squaring (no table, no float). -pub fn log2_q40(v: u128, frac_bits: u32) -> i128 { - debug_assert!(v > 0); - let k = 127 - v.leading_zeros(); // floor(log2 v) - // y ∈ [1, 2) at 2^62 - let mut y: u128 = if k >= 62 { v >> (k - 62) } else { v << (62 - k) }; - let mut frac: i128 = 0; - for i in 1..=LN_BITS { - y = (y * y) >> 62; - if y >= 1u128 << 63 { - y >>= 1; - frac |= 1 << (LN_BITS - i); - } - } - ((k as i128 - frac_bits as i128) << LN_BITS) + frac -} - -/// `ln(v / 2^frac_bits)` at 2^40. -pub fn ln_q40(v: u128, frac_bits: u32) -> i128 { - (log2_q40(v, frac_bits) * LN2_Q40) >> LN_BITS -} - -/// The integer reference engine: the diffusion term of the tri-kernel as -/// a random walk with teleport α = 15/100 to the stake prior -/// `π = deg / Σ deg` (weighted total degree — tru's epoch engine makes the -/// same choice, so a particle's focus follows the stake on its axons), -/// dangling mass spread by π, `PHI_STEPS` steps from uniform, -/// `J = Σ φ ln(n φ)`, and the exact adjoint of that map. -pub struct Reference { - pub steps: usize, -} - -impl Default for Reference { - fn default() -> Self { - Reference { steps: super::super::params::PHI_STEPS } - } -} - -const ALPHA_NUM: u128 = 15; -const ALPHA_DEN: u128 = 100; -const KEEP_NUM: u128 = ALPHA_DEN - ALPHA_NUM; -/// transition probabilities at 2^62 -const T_BITS: u32 = 62; - -struct Walk { - /// out-weight of each particle - out: Vec, - /// `floor(A_e · 2^62 / out(src))` per edge - t: Vec, - /// `Σ deg = 2 Σ A` - w2: u128, - /// the stake prior at 2^60 - prior: Vec, -} - -impl Walk { - fn new(g: &FocusGraph) -> Walk { - let mut out = vec![0u128; g.n()]; - let mut deg = vec![0u128; g.n()]; - for e in 0..g.edges() { - let w = g.weight[e]; - out[g.src[e] as usize] += w; - deg[g.src[e] as usize] += w; - deg[g.dst[e] as usize] += w; - } - let t = g - .src - .iter() - .zip(&g.weight) - .map(|(s, w)| { - // w ≤ d; both shifted by the same k so d fits 64 bits and - // (w << 62) fits u128 — a fixed rule, so every node agrees - let d = out[*s as usize]; - let k = (128 - d.leading_zeros()).saturating_sub(64); - (((*w >> k) << T_BITS) / (d >> k)) as u64 - }) - .collect(); - let w2: u128 = deg.iter().sum(); - // deg < 2^67, so deg << 60 fits u128 - let prior = deg.iter().map(|d| (d << PHI_BITS) / w2).collect(); - Walk { out, t, w2, prior } - } - - /// `⟨π, v⟩` at v's scale. - fn prior_dot(&self, v: &[i128]) -> i128 { - self.prior.iter().zip(v).map(|(p, x)| (*p as i128 * x) >> PHI_BITS).sum() - } -} - -impl Reference { - fn forward(&self, g: &FocusGraph, w: &Walk) -> Vec { - let n = g.n() as u128; - let one = 1u128 << PHI_BITS; - let mut phi = vec![(one / n) as u64; g.n()]; - let mut inflow = vec![0u128; g.n()]; - for _ in 0..self.steps { - inflow.iter_mut().for_each(|x| *x = 0); - let dangling: u128 = w.out.iter().zip(&phi).filter(|(d, _)| **d == 0).map(|(_, f)| *f as u128).sum(); - for e in 0..g.edges() { - inflow[g.dst[e] as usize] += (phi[g.src[e] as usize] as u128 * w.t[e] as u128) >> T_BITS; - } - for (j, f) in phi.iter_mut().enumerate() { - let pi = w.prior[j]; - *f = ((ALPHA_NUM * pi + KEEP_NUM * (inflow[j] + ((dangling * pi) >> PHI_BITS))) / ALPHA_DEN) as u64; - } - } - phi - } - - fn syntropy(phi: &[u64]) -> i128 { - let n = phi.len() as u128; - phi.iter().filter(|f| **f > 0).map(|f| (*f as i128 * ln_q40(n * *f as u128, PHI_BITS)) >> LN_BITS).sum() - } - - /// `(Pλ)_i`: the walk's expectation of λ one step from `i` (a dangling - /// particle jumps by the prior). - fn expect(g: &FocusGraph, w: &Walk, lambda: &[i128]) -> Vec { - let jump = w.prior_dot(lambda); - let mut out: Vec = w.out.iter().map(|d| if *d == 0 { jump } else { 0 }).collect(); - for e in 0..g.edges() { - out[g.src[e] as usize] += (lambda[g.dst[e] as usize] * w.t[e] as i128) >> T_BITS; - } - out - } -} - -impl FocusEngine for Reference { - fn id(&self) -> &'static str { - "soft3-reference-prior-walk-v1" - } - - fn focus(&self, g: &FocusGraph) -> Focus { - if g.n() == 0 { - return Focus::default(); - } - let w = Walk::new(g); - let phi = self.forward(g, &w); - let syntropy = Self::syntropy(&phi); - Focus { phi, syntropy } - } - - fn gradient(&self, g: &FocusGraph) -> Vec { - if g.n() == 0 { - return Vec::new(); - } - let w = Walk::new(g); - let phi = self.forward(g, &w); - let n = g.n() as u128; - // ∇_φ J = ln(n φ) + 1 at 2^40 - let grad_j: Vec = phi.iter().map(|f| ln_q40(n * (*f).max(1) as u128, PHI_BITS) + (1 << LN_BITS)).collect(); - // λ ← ∇J + (∂F/∂φ)ᵀ λ from λ = 0, with (∂F/∂φ)ᵀ λ = (1 − α) P λ - let mut lambda = vec![0i128; g.n()]; - for _ in 0..self.steps { - let pl = Self::expect(g, &w, &lambda); - for (i, l) in lambda.iter_mut().enumerate() { - *l = grad_j[i] + (KEEP_NUM as i128 * pl[i]).div_euclid(ALPHA_DEN as i128); - } - } - let pl = Self::expect(g, &w, &lambda); - // F = α π + (1 − α)(Pᵀ φ + D π), D the dangling mass. Through P: - // (1 − α) φ_p (λ_q − (Pλ)_p) / out_p - // through π (∂π_j/∂A_pq = (δ_jp + δ_jq − 2 π_j) / Σdeg): - // (α + (1 − α) D) (λ_p + λ_q − 2 ⟨π, λ⟩) / Σdeg - let one = 1i128 << PHI_BITS; - let dangling: i128 = w.out.iter().zip(&phi).filter(|(d, _)| **d == 0).map(|(_, f)| *f as i128).sum(); - let c = (ALPHA_NUM as i128 * one + KEEP_NUM as i128 * dangling).div_euclid(ALPHA_DEN as i128); // 2^60 - let mean = w.prior_dot(&lambda); - (0..g.edges()) - .map(|e| { - let (s, d) = (g.src[e] as usize, g.dst[e] as usize); - let walk = (KEEP_NUM as i128 * phi[s] as i128 * (lambda[d] - pl[s])).div_euclid(ALPHA_DEN as i128 * w.out[s] as i128); - let prior = (c * (lambda[s] + lambda[d] - 2 * mean)).div_euclid(w.w2 as i128); - walk + prior - }) - .collect() - } -} diff --git a/crate/src/chain/economy/mod.rs b/crate/src/chain/economy/mod.rs deleted file mode 100644 index 5f2dfdef..00000000 --- a/crate/src/chain/economy/mod.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! The focus economy of the root chain (specs/chain-economy.md): staked -//! cyberlinks with valence, epochs of a fixed number of blocks, φ* of the -//! staked graph at every boundary, settlement of Δφ⁺ to the stake that -//! caused it, and the subsidy split between miners and the focus pool. -//! -//! Module map (chain-economy §13): -//! -//! - this file: positions, identities (§3.1), the emission split (§6.1), -//! the mark decay (§5.3) — pure integer functions; -//! - [`wide`]: the 512-bit integers attribution needs (§5.2–§5.5); -//! - [`weights`]: genesis weights (§4.1) and the axon weight `A_pq` (§4.2); -//! - [`focus`]: the canonical focus graph (§4.3) and the φ*/gradient engine -//! seam (§4.4, §5.1) with the integer reference engine; -//! - [`settle`]: snapshots (§2.4), the three solves and attribution -//! (§5.1–§5.4), and the settlement step of a block (§5.5–§5.6); -//! - [`run`]: the off-block-path settlement worker and its caches. -//! -//! Integers and fixed point only; nothing here reads a float or a clock. - -pub mod focus; -pub mod run; -pub mod vectors; -pub mod settle; -pub mod weights; -pub mod wide; - -#[cfg(test)] -mod tests; - -use super::params::{DOMAIN_PAYOUT_LEAF, DOMAIN_PAYOUT_NODE, DOMAIN_PHI, FOCUS_MAX_BPS, HWM_KEEP_DEN, HWM_KEEP_NUM}; -use super::state::Id; - -pub use crate::wallet::tx::{card_id, focus_key, stake_key}; - -/// `axon(p, q) = hemera(p ‖ q)`, bbg's `axon_id`: a card's `particle`. -pub fn axon_of(p: &Id, q: &Id) -> Id { - crate::wallet::tx::axon(p, q) -} - -/// A position (chain-economy §3.2): one author's stake on one axon. The -/// stake itself is the balance at `stake_key(card)`, not a field. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Card { - /// payout owner: the account credited with the position's rewards - pub owner: Id, - /// the axon-particle `axon(p, q) = hemera(p ‖ q)` - pub particle: Id, - /// the neuron that locked the stake; only it may unlock - pub author: Id, - /// `v ∈ {−1, 0, +1}`; fixed while the stake is positive - pub valence: i8, - /// height of the last stake increase - pub since: u64, -} - -impl Card { - /// `σ(c)`: +1 for `v ∈ {0, +1}`, −1 for `v = −1` (§5.2). - pub fn side(&self) -> i8 { - side_of(self.valence) - } - - /// The bbg byte form of the valence, `v + 1 ∈ {0, 1, 2}` (§3.2). - pub fn valence_byte(&self) -> u8 { - (self.valence + 1) as u8 - } -} - -/// `σ(v)`: the side of the edge a valence pushes. -pub fn side_of(valence: i8) -> i8 { - if valence < 0 { -1 } else { 1 } -} - -/// The high-water marks of one axon, `H⁺` and `H⁻` (§5.3). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct Mark { - pub pos: u64, - pub neg: u64, -} - -impl Mark { - pub fn side(&self, sigma: i8) -> u64 { - if sigma < 0 { self.neg } else { self.pos } - } -} - -/// Running economy scalars, journaled with the state: what the coinbase -/// of every block and `/status` read without walking the cards. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct Scalars { - /// `focus_bps(epoch(h) − 1)`: the split in force for the next block (§6.1) - pub focus_bps: u64, - /// Σ stake over every card - pub total_stake: u64, - /// Σ stake over cards with `v ≠ 0` — `active` of §6.1 - pub active_stake: u64, - /// the last epoch settled on this chain (0 before the first settlement) - pub last_settled: u64, -} - -impl Scalars { - pub fn encode(&self) -> [u64; 4] { - [self.focus_bps, self.total_stake, self.active_stake, self.last_settled] - } - - pub fn decode(v: [u64; 4]) -> Scalars { - Scalars { focus_bps: v[0], total_stake: v[1], active_stake: v[2], last_settled: v[3] } - } -} - -fn tagged(tag: &[u8], parts: &[&[u8]]) -> Id { - let mut buf = Vec::with_capacity(tag.len() + parts.iter().map(|p| p.len()).sum::()); - buf.extend_from_slice(tag); - for p in parts { - buf.extend_from_slice(p); - } - *hemera::hash(&buf).as_bytes() -} - -/// `hemera(D_PAYOUT_LEAF ‖ card ‖ owner ‖ amount: u64)` (§5.6). -pub fn payout_leaf(card: &Id, owner: &Id, amount: u64) -> Id { - tagged(DOMAIN_PAYOUT_LEAF, &[card, owner, &amount.to_le_bytes()]) -} - -/// `hemera(D_PAYOUT_NODE ‖ L ‖ R)` (§5.6). -pub fn payout_node(l: &Id, r: &Id) -> Id { - tagged(DOMAIN_PAYOUT_NODE, &[l, r]) -} - -/// Merkle root over payout leaves in the given order (cards ascending, -/// amounts > 0), odd node promoted unchanged; 32 zero bytes when empty. -pub fn payouts_root(leaves: &[Id]) -> Id { - if leaves.is_empty() { - return [0; 32]; - } - let mut level = leaves.to_vec(); - while level.len() > 1 { - level = level - .chunks(2) - .map(|pair| match pair { - [l, r] => payout_node(l, r), - [one] => *one, - _ => unreachable!(), - }) - .collect(); - } - level[0] -} - -/// `hemera(D_PHI ‖ n: u32 ‖ φ[0]: u64 ‖ … ‖ φ[n−1]: u64)` (§5.6), particles -/// ascending, tru's raw fixed point. -pub fn phi_digest(phi: &[u64]) -> Id { - let mut buf = Vec::with_capacity(DOMAIN_PHI.len() + 4 + 8 * phi.len()); - buf.extend_from_slice(DOMAIN_PHI); - buf.extend_from_slice(&(phi.len() as u32).to_le_bytes()); - for v in phi { - buf.extend_from_slice(&v.to_le_bytes()); - } - *hemera::hash(&buf).as_bytes() -} - -/// Floor integer square root of a u128. -pub fn isqrt(n: u128) -> u128 { - if n < 2 { - return n; - } - // Newton from a power of two at or above the root: monotone decreasing - let mut x = 1u128 << (128 - n.leading_zeros()).div_ceil(2); - loop { - let y = (x + n / x) / 2; - if y >= x { - return x; - } - x = y; - } -} - -/// `focus_bps(E) = min(FOCUS_MAX_BPS, isqrt(floor(active · 10^8 / supply)))` -/// (§6.1), u128 inside. `supply = S0 + boundary(E) · SUBSIDY`. -pub fn focus_bps(active: u64, supply: u64) -> u64 { - if supply == 0 { - return 0; - } - let theta = (active as u128 * 100_000_000) / supply as u128; - (isqrt(theta) as u64).min(FOCUS_MAX_BPS) -} - -/// `cut(h) = floor(SUBSIDY · focus_bps / 10,000)`: the focus pool's share -/// of one block's subsidy (§6.1); the miner keeps `SUBSIDY − cut`. -pub fn cut(subsidy: u64, bps: u64) -> u64 { - ((subsidy as u128 * bps as u128) / 10_000) as u64 -} - -/// One epoch of the high-water mark (§5.3): with `M = max(H, S)`, -/// `H' = S + floor((M − S) · 65,410 / 65,536)`. -pub fn decay_mark(h: u64, s: u64) -> u64 { - let m = h.max(s); - s + (((m - s) as u128 * HWM_KEEP_NUM as u128) / HWM_KEEP_DEN as u128) as u64 -} diff --git a/crate/src/chain/economy/run.rs b/crate/src/chain/economy/run.rs deleted file mode 100644 index a96d5b64..00000000 --- a/crate/src/chain/economy/run.rs +++ /dev/null @@ -1,346 +0,0 @@ -//! Settlement off the block path (chain-economy §2.5, §7.2). -//! -//! At every boundary block the node freezes the boundary state -//! ([`Snapshot`]) and starts the epoch's three solves on a worker thread; -//! the D blocks until `settle(E)` are produced and validated meanwhile. The -//! block at `settle(E)` takes the finished result — joining the worker if -//! it is still running, which is the liveness bound of §2.5: a node that -//! has not finished waits, it never guesses. -//! -//! Everything is keyed by block ids: a snapshot by its boundary block, a -//! result by the pair of boundary blocks it reads. A reorganization that -//! replaces a boundary simply asks for another key; a reorganization above -//! it finds the same result. Results are also kept on disk under -//! `$home/epochs/` (the cache, keyed by the engine id too), so a restart -//! replays the chain without recomputing every epoch. - -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use std::thread::JoinHandle; -use std::time::Instant; - -use super::super::params::EpochClock; -use super::super::state::{Id, StateBackend}; -use super::focus::{FocusEngine, FocusGraph}; -use super::settle::{Earner, FocusOut, Outcome, SettleResult, Snapshot, compute_with}; -use super::weights::GenesisWeights; -use super::wide::W; -use super::Mark; - -type Key = (Id, Id); - -/// Timing of one computed settlement (informational, never consensus). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct Timing { - pub epoch: u64, - pub millis: u64, - pub particles: u32, - pub axons: u32, -} - -pub struct Economy { - pub weights: Arc, - pub engine: Arc, - pub clock: EpochClock, - /// compute on worker threads (the node); `false` computes inline - pub background: bool, - /// gradient nodes on the epoch's path (1 = the midpoint, chain-economy - /// §5.1); part of every cached result's key - pub quadrature: usize, - snapshots: HashMap>, - jobs: HashMap>, - done: HashMap>, - /// `J(φ*)` per boundary block id, for the next epoch's Δ (§5.6) - syntropy: Arc>>, - /// the settlement outcomes applied, by the settlement block's id - outcomes: HashMap>, - timings: Arc>>, - dir: Option, -} - -impl Economy { - pub fn new(weights: GenesisWeights, engine: Arc, clock: EpochClock, dir: Option) -> Economy { - if let Some(d) = &dir { - let _ = std::fs::create_dir_all(d); - } - Economy { - weights: Arc::new(weights), - engine, - clock, - background: true, - quadrature: super::super::params::ATTR_NODES, - snapshots: HashMap::new(), - jobs: HashMap::new(), - done: HashMap::new(), - syntropy: Arc::new(Mutex::new(HashMap::new())), - outcomes: HashMap::new(), - timings: Arc::new(Mutex::new(Vec::new())), - dir, - } - } - - pub fn snapshot(&self, block: &Id) -> Option> { - self.snapshots.get(block).cloned() - } - - /// Freeze the state after the boundary block `block` of `epoch` and, - /// when the previous boundary's snapshot is held, start the epoch's - /// settlement. `prev` is the previous boundary's block id (none at - /// genesis). - pub fn on_boundary(&mut self, state: &impl StateBackend, epoch: u64, block: Id, root: Id, supply: u64, prev: Option) { - if !self.snapshots.contains_key(&block) { - let snap = Snapshot::capture(state, epoch, block, root, supply); - self.snapshots.insert(block, Arc::new(snap)); - } - if let Some(prev) = prev { - self.start(prev, block); - } - } - - fn cache_path(&self, key: &Key) -> Option { - let dir = self.dir.as_ref()?; - let mut buf = self.engine.id().as_bytes().to_vec(); - buf.extend_from_slice(&(self.quadrature as u32).to_le_bytes()); - buf.extend_from_slice(&key.0); - buf.extend_from_slice(&key.1); - Some(dir.join(format!("{}.settle", hex(hemera::hash(&buf).as_bytes())))) - } - - fn load(&mut self, key: &Key) -> Option> { - let path = self.cache_path(key)?; - let bytes = std::fs::read(path).ok()?; - let r = Arc::new(decode(&bytes)?); - self.syntropy.lock().expect("syntropy map").insert(key.1, r.syntropy); - self.done.insert(*key, r.clone()); - Some(r) - } - - /// Start the settlement reading boundaries `prev` and `cur`, unless it - /// is done, running or cached. - pub fn start(&mut self, prev: Id, cur: Id) { - let key = (prev, cur); - if self.done.contains_key(&key) || self.jobs.contains_key(&key) || self.load(&key).is_some() { - return; - } - let (Some(a), Some(b)) = (self.snapshot(&prev), self.snapshot(&cur)) else { return }; - let (weights, engine, syntropy, timings) = (self.weights.clone(), self.engine.clone(), self.syntropy.clone(), self.timings.clone()); - let path = self.cache_path(&key); - let epoch = b.epoch; - let settle_height = self.clock.settle(epoch); - let quadrature = self.quadrature; - let job = move || { - let t0 = Instant::now(); - // The compute runs outside every lock guard, so a panic in the - // engine poisons nothing; it is re-raised naming the epoch and - // the height at which the settlement is due. - let computed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let prev_j = syntropy.lock().expect("syntropy map").get(&a.block_id).copied(); - let prev_j = prev_j.unwrap_or_else(|| { - let j = engine.focus(&FocusGraph::build(&a.adjacency(&weights))).syntropy; - syntropy.lock().expect("syntropy map").insert(a.block_id, j); - j - }); - compute_with(&a, &b, &weights, engine.as_ref(), Some(prev_j), quadrature) - })); - let r = match computed { - Ok(r) => r, - Err(payload) => { - let original = payload - .downcast_ref::<&str>() - .map(|s| s.to_string()) - .or_else(|| payload.downcast_ref::().cloned()) - .unwrap_or_else(|| "".to_string()); - let line = format!("settlement of epoch {epoch} at height {settle_height} panicked: {original}"); - eprintln!("{line}"); - panic!("{line}"); - } - }; - let millis = t0.elapsed().as_millis() as u64; - syntropy.lock().expect("syntropy map").insert(b.block_id, r.syntropy); - timings.lock().expect("timings").push(Timing { epoch: r.epoch, millis, particles: r.particles, axons: r.axons }); - if let Some(p) = path { - let tmp = p.with_extension("tmp"); - if std::fs::write(&tmp, encode(&r)).is_ok() { - let _ = std::fs::rename(&tmp, &p); - } - } - (r, millis) - }; - if self.background { - let handle = std::thread::Builder::new().name(format!("settle-{epoch}")).spawn(job).expect("spawn the settlement worker"); - self.jobs.insert(key, handle); - } else { - let (r, _) = job(); - self.done.insert(key, Arc::new(r)); - } - } - - /// Is the settlement of `(prev, cur)` finished (or cached)? - pub fn ready(&self, prev: &Id, cur: &Id) -> bool { - let key = (*prev, *cur); - self.done.contains_key(&key) || self.jobs.get(&key).is_some_and(|j| j.is_finished()) - } - - /// The settlement reading boundaries `prev` and `cur`: finished, joined - /// (waiting for the worker), or computed now. `None` only when a - /// snapshot is missing — the boundary was never applied on this node. - /// - /// A panic in the settlement is a halt by design (chain-economy §2.5 - /// liveness; plan P2, decision D14): it is re-raised with the epoch and - /// height named, nothing is recorded, a replay halts at the same block. - pub fn result(&mut self, prev: &Id, cur: &Id) -> Option> { - let key = (*prev, *cur); - if let Some(r) = self.done.get(&key) { - return Some(r.clone()); - } - if let Some(job) = self.jobs.remove(&key) { - let (r, _) = match job.join() { - Ok(done) => done, - Err(payload) => std::panic::resume_unwind(payload), - }; - let r = Arc::new(r); - self.done.insert(key, r.clone()); - return Some(r); - } - if let Some(r) = self.load(&key) { - return Some(r); - } - let background = std::mem::replace(&mut self.background, false); - self.start(*prev, *cur); - self.background = background; - self.done.get(&key).cloned() - } - - pub fn record_outcome(&mut self, block: Id, outcome: Outcome) { - self.outcomes.insert(block, Arc::new(outcome)); - } - - pub fn outcome(&self, block: &Id) -> Option> { - self.outcomes.get(block).cloned() - } - - pub fn timings(&self) -> Vec { - self.timings.lock().expect("timings").clone() - } - - /// Drop snapshots, results and outcomes of boundaries more than three - /// epochs below `tip` (a reorg never reaches them, §5.2). The genesis - /// snapshot stays. - pub fn prune(&mut self, tip: u64) { - let floor = tip.saturating_sub(3 * self.clock.length + self.clock.delay); - self.snapshots.retain(|_, s| s.height == 0 || s.height >= floor); - self.done.retain(|_, r| r.boundary >= floor); - // records and payout lists stay (served by /epoch); φ* vectors of - // all but the recent epochs go - for o in self.outcomes.values_mut() { - if o.record.boundary < floor && !o.focus.phi.is_empty() { - let mut slim = (**o).clone(); - slim.focus = Arc::new(FocusOut { particles: Vec::new(), phi: Vec::new(), syntropy: o.focus.syntropy, digest: o.focus.digest }); - *o = Arc::new(slim); - } - } - let keep: std::collections::HashSet = self.snapshots.keys().copied().collect(); - self.syntropy.lock().expect("syntropy map").retain(|id, _| keep.contains(id)); - } -} - -fn hex(b: &[u8]) -> String { - b.iter().map(|x| format!("{x:02x}")).collect() -} - -// ── the cache file: one settlement result without φ*'s vector ─────────── - -const MAGIC: &[u8; 8] = b"S3SETL01"; - -fn encode(r: &SettleResult) -> Vec { - let mut out = MAGIC.to_vec(); - for v in [r.epoch, r.boundary, r.focus_bps] { - out.extend_from_slice(&v.to_le_bytes()); - } - for id in [&r.prev_block, &r.beacon, &r.boundary_root, &r.phi] { - out.extend_from_slice(id); - } - out.extend_from_slice(&r.particles.to_le_bytes()); - out.extend_from_slice(&r.axons.to_le_bytes()); - out.extend_from_slice(&r.syntropy.to_le_bytes()); - out.extend_from_slice(&r.prev_syntropy.to_le_bytes()); - out.extend_from_slice(&(r.earners.len() as u64).to_le_bytes()); - for e in &r.earners { - out.extend_from_slice(&e.card); - out.extend_from_slice(&e.owner); - for l in e.x.0 { - out.extend_from_slice(&l.to_le_bytes()); - } - out.extend_from_slice(&e.cap.to_le_bytes()); - } - out.extend_from_slice(&(r.marks.len() as u64).to_le_bytes()); - for (a, m) in &r.marks { - out.extend_from_slice(a); - out.extend_from_slice(&m.pos.to_le_bytes()); - out.extend_from_slice(&m.neg.to_le_bytes()); - } - out -} - -fn decode(b: &[u8]) -> Option { - let mut at = 0usize; - let mut take = |n: usize| -> Option<&[u8]> { - let s = b.get(at..at + n)?; - at += n; - Some(s) - }; - if take(8)? != MAGIC { - return None; - } - let u64_ = |s: &[u8]| u64::from_le_bytes(s.try_into().expect("8")); - let (epoch, boundary, focus_bps) = (u64_(take(8)?), u64_(take(8)?), u64_(take(8)?)); - let id = |s: &[u8]| -> Id { s.try_into().expect("32") }; - let (prev_block, beacon, boundary_root, phi) = (id(take(32)?), id(take(32)?), id(take(32)?), id(take(32)?)); - let particles = u32::from_le_bytes(take(4)?.try_into().ok()?); - let axons = u32::from_le_bytes(take(4)?.try_into().ok()?); - let syntropy = i128::from_le_bytes(take(16)?.try_into().ok()?); - let prev_syntropy = i128::from_le_bytes(take(16)?.try_into().ok()?); - let n = u64_(take(8)?) as usize; - let mut earners = Vec::with_capacity(n.min(1 << 20)); - for _ in 0..n { - let card = id(take(32)?); - let owner = id(take(32)?); - let mut x = [0u64; 8]; - for l in x.iter_mut() { - *l = u64_(take(8)?); - } - let cap = u64_(take(8)?); - earners.push(Earner { card, owner, x: W(x), cap }); - } - let m = u64_(take(8)?) as usize; - let mut marks = Vec::with_capacity(m.min(1 << 20)); - for _ in 0..m { - let a = id(take(32)?); - marks.push((a, Mark { pos: u64_(take(8)?), neg: u64_(take(8)?) })); - } - if at != b.len() { - return None; - } - Some(SettleResult { - epoch, - prev_block, - boundary, - beacon, - boundary_root, - particles, - axons, - phi, - syntropy, - prev_syntropy, - focus_bps, - earners, - marks, - focus: Arc::new(FocusOut { particles: Vec::new(), phi: Vec::new(), syntropy, digest: phi }), - }) -} - -#[cfg(test)] -pub(crate) fn round_trip(r: &SettleResult) -> Option { - decode(&encode(r)) -} diff --git a/crate/src/chain/economy/settle.rs b/crate/src/chain/economy/settle.rs deleted file mode 100644 index 0ce79201..00000000 --- a/crate/src/chain/economy/settle.rs +++ /dev/null @@ -1,458 +0,0 @@ -//! Epoch settlement (chain-economy §2.4, §5): the boundary snapshot, the -//! three solves and the attribution per position — a pure function of two -//! snapshots, run off the block path ([`compute`]) — and the settlement -//! step of the block at `settle(E)` ([`apply`]), which only divides the -//! pool's budget by the precomputed weights and writes the result. - -use std::collections::BTreeMap; -use std::sync::Arc; - -use super::super::params::{DOMAIN_EPOCH, POOL_RELEASE, R_MAX_PPM, RHO_LAUNCH}; -use super::super::state::{Id, StateBackend, StateError}; -use super::focus::{FocusEngine, FocusGraph}; -use super::weights::{GenesisWeights, adjacency, midpoint}; -use super::wide::{W, mul_div}; -use super::{Mark, decay_mark, focus_bps, focus_key, payout_leaf, payouts_root, phi_digest, side_of, stake_key}; - -/// One position as the boundary state holds it (§2.4). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Position { - pub card: Id, - /// the axon-particle `hemera(p ‖ q)` - pub axon: Id, - pub p: Id, - pub q: Id, - pub owner: Id, - pub valence: i8, - pub stake: u64, -} - -/// The frozen boundary state `G_E` (§2.4): what settlement reads and -/// nothing else. Genesis weights are the chain's constant, not copied here. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Snapshot { - pub epoch: u64, - pub height: u64, - pub block_id: Id, - pub state_root: Id, - /// ascending by card id - pub positions: Vec, - /// `H⁺, H⁻` per axon as left by the previous settlement, by axon id, - /// with the axon's endpoints - pub marks: BTreeMap, - /// Σ stake over cards with `v ≠ 0` - pub active: u64, - /// `S0 + boundary(E) · SUBSIDY` - pub supply: u64, -} - -impl Snapshot { - /// Freeze the state after a boundary block. - pub fn capture(state: &impl StateBackend, epoch: u64, block_id: Id, state_root: Id, supply: u64) -> Snapshot { - let mut positions = Vec::new(); - let mut active = 0u64; - for (card, c) in state.cards() { - let (p, q) = state.axon_edge(&c.particle).expect("a card's axon is in axon_edges"); - let stake = state.raw_balance(&stake_key(&card)); - if c.valence != 0 { - active += stake; - } - positions.push(Position { card, axon: c.particle, p, q, owner: c.owner, valence: c.valence, stake }); - } - let marks = state - .marks() - .into_iter() - .map(|(axon, m)| { - let (p, q) = state.axon_edge(&axon).expect("a marked axon is in axon_edges"); - (axon, (p, q, m)) - }) - .collect(); - Snapshot { epoch, height: state.height(), block_id, state_root, positions, marks, active, supply } - } - - /// `(S⁺, S⁻)` per axon, by axon id, with the endpoints (§4.2). - pub fn sides(&self) -> BTreeMap { - let mut out: BTreeMap = BTreeMap::new(); - for pos in &self.positions { - let e = out.entry(pos.axon).or_insert((pos.p, pos.q, 0, 0)); - if pos.valence < 0 { - e.3 += pos.stake; - } else { - e.2 += pos.stake; - } - } - out - } - - /// `A_pq` over every present axon of this snapshot (§4.2), ascending. - pub fn adjacency(&self, weights: &GenesisWeights) -> Vec<(Id, Id, u128)> { - let by_pq: BTreeMap<(Id, Id), (u64, u64)> = self.sides().values().map(|(p, q, pos, neg)| ((*p, *q), (*pos, *neg))).collect(); - adjacency(&weights.axons, &by_pq) - } -} - -/// φ*_E as the node keeps it for `/focus` (§11). -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct FocusOut { - pub particles: Vec, - pub phi: Vec, - pub syntropy: i128, - pub digest: Id, -} - -/// One position that earns this epoch (§5.4): its weight `x(c)` and cap. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Earner { - pub card: Id, - pub owner: Id, - pub x: W, - pub cap: u64, -} - -/// Everything settlement of epoch E needs except the pool balance, which -/// is read at `settle(E)` (§5.5): a pure function of `G_{E−1}`, `G_E`, the -/// genesis weights and the engine. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SettleResult { - pub epoch: u64, - pub prev_block: Id, - pub boundary: u64, - pub beacon: Id, - pub boundary_root: Id, - pub particles: u32, - pub axons: u32, - pub phi: Id, - pub syntropy: i128, - pub prev_syntropy: i128, - /// `focus_bps(E)`, the split in force for the blocks of epoch E + 1 - pub focus_bps: u64, - /// ascending by card; `x > 0` only - pub earners: Vec, - /// the full mark set `H_E`, ascending by axon id - pub marks: Vec<(Id, Mark)>, - pub focus: Arc, -} - -/// The three solves and the attribution (§5.1–§5.4). `prev_focus` is -/// `(J(φ*_{E−1}))` when the node still holds it; otherwise it is solved. -pub fn compute(prev: &Snapshot, cur: &Snapshot, weights: &GenesisWeights, engine: &dyn FocusEngine, prev_syntropy: Option) -> SettleResult { - compute_with(prev, cur, weights, engine, prev_syntropy, super::super::params::ATTR_NODES) -} - -/// [`compute`] with `quadrature` gradient nodes on the path from `A_prev` -/// to `A_cur` (1 = the midpoint of chain-economy §5.1). -pub fn compute_with( - prev: &Snapshot, - cur: &Snapshot, - weights: &GenesisWeights, - engine: &dyn FocusEngine, - prev_syntropy: Option, - quadrature: usize, -) -> SettleResult { - let a_prev = prev.adjacency(weights); - let a_cur = cur.adjacency(weights); - let a_mid = midpoint(&a_prev, &a_cur); - - // solve 1: φ*_E (published); J(φ*_{E−1}) from the previous epoch or solved - let g_cur = FocusGraph::build(&a_cur); - let focus = engine.focus(&g_cur); - let prev_syntropy = prev_syntropy.unwrap_or_else(|| engine.focus(&FocusGraph::build(&a_prev)).syntropy); - // solves 2 and 3: the fixed point of A_mid and the adjoint gradient - // there (one quadrature node); k > 1 nodes integrate the gradient - // along the straight path, Gauss–Legendre, weights at 2^32 - let path = gradient_path(&a_prev, &a_cur, &a_mid, quadrature, engine); - let grad_of = |p: &Id, q: &Id| -> i128 { - match path.binary_search_by(|(x, y, _)| (x, y).cmp(&(p, q))) { - Ok(i) => path[i].2, - Err(_) => 0, // absent at every node ([A_mid_pq > 0] fails): no credit - } - }; - - // Δs per card and side (§5.2): a card that changed side is a decrease on - // the old side and an increase on the new one - let before: BTreeMap = prev.positions.iter().map(|p| (p.card, p)).collect(); - let mut delta: Vec<(&Position, i8, i128)> = Vec::new(); // (position in G_E or G_{E−1}, side, Δs) - let mut raw_increase: BTreeMap<(Id, i8), u128> = BTreeMap::new(); // R^σ per axon side - let mut seen = std::collections::BTreeSet::new(); - for pos in &cur.positions { - seen.insert(pos.card); - let sigma = side_of(pos.valence); - match before.get(&pos.card) { - Some(old) if side_of(old.valence) == sigma => delta.push((pos, sigma, pos.stake as i128 - old.stake as i128)), - Some(old) => { - delta.push((old, side_of(old.valence), -(old.stake as i128))); - delta.push((pos, sigma, pos.stake as i128)); - } - None => delta.push((pos, sigma, pos.stake as i128)), - } - } - for old in prev.positions.iter().filter(|p| !seen.contains(&p.card)) { - delta.push((old, side_of(old.valence), -(old.stake as i128))); - } - for (pos, sigma, d) in &delta { - if *d > 0 { - *raw_increase.entry((pos.axon, *sigma)).or_insert(0) += *d as u128; - } - } - - // the new height of each side (§5.3) - let sides = cur.sides(); - let side_stake = |axon: &Id, sigma: i8| -> u64 { - sides.get(axon).map(|(_, _, pos, neg)| if sigma < 0 { *neg } else { *pos }).unwrap_or(0) - }; - let mark_of = |axon: &Id| -> Mark { cur.marks.get(axon).map(|(_, _, m)| *m).unwrap_or_default() }; - - let current: BTreeMap = cur.positions.iter().map(|p| (p.card, p)).collect(); - let mut earners = Vec::new(); - for (pos, sigma, d) in &delta { - // gates (§5.4): the position is in G_E on this side, active, increased - let Some(now) = current.get(&pos.card) else { continue }; - if side_of(now.valence) != *sigma || now.valence == 0 || *d <= 0 { - continue; - } - let g = grad_of(&now.p, &now.q); - // a(c) = Δs · σ · g; only its positive part earns - let signed_g = if *sigma < 0 { -g } else { g }; - if signed_g <= 0 { - continue; - } - let new_height = side_stake(&now.axon, *sigma).saturating_sub(mark_of(&now.axon).side(*sigma)); - if new_height == 0 { - continue; - } - let r = raw_increase[&(now.axon, *sigma)]; - let a = W::from_u128(*d as u128).checked_mul(&W::from_u128(signed_g as u128)).expect("u64 · i128 fits 256 bits"); - let x = mul_div(&a, &W::from_u64(new_height.saturating_mul(RHO_LAUNCH)), &W::from_u128(r)); - if x.is_zero() { - continue; - } - let cap = ((*d as u128 * R_MAX_PPM as u128) / 1_000_000) as u64; - earners.push(Earner { card: now.card, owner: now.owner, x, cap }); - } - earners.sort_by(|a, b| a.card.cmp(&b.card)); - - // H_E for every axon that ever held a card (§5.3) - let mut marks = Vec::new(); - let mut axons: std::collections::BTreeSet = cur.marks.keys().copied().collect(); - axons.extend(sides.keys().copied()); - for axon in axons { - let (s_pos, s_neg) = (side_stake(&axon, 1), side_stake(&axon, -1)); - let h = mark_of(&axon); - let next = Mark { pos: decay_mark(h.pos, s_pos), neg: decay_mark(h.neg, s_neg) }; - if s_pos == 0 && s_neg == 0 && next.pos == 0 && next.neg == 0 { - continue; - } - marks.push((axon, next)); - } - - let digest = phi_digest(&focus.phi); - SettleResult { - epoch: cur.epoch, - prev_block: prev.block_id, - boundary: cur.height, - beacon: cur.block_id, - boundary_root: cur.state_root, - particles: g_cur.n() as u32, - axons: g_cur.edges() as u32, - phi: digest, - syntropy: focus.syntropy, - prev_syntropy, - focus_bps: focus_bps(cur.active, cur.supply), - earners, - marks, - focus: Arc::new(FocusOut { particles: g_cur.particles, phi: focus.phi, syntropy: focus.syntropy, digest }), - } -} - -/// Gauss–Legendre nodes `t` and weights `w` on `[0, 1]` at 2^32 (weights -/// sum to 2^32) — the same table as tru's `integrated::gauss_legendre`. -pub fn gauss_legendre(k: usize) -> Vec<(u64, u64)> { - tru::focusing::epoch::integrated::gauss_legendre(k) -} - -/// The path gradient per axon present at some node, ascending by `p ‖ q`: -/// `g(A_mid)` for one node, `Σ_t w_t · g(A_prev + t·ΔA) / 2^32` for k. -fn gradient_path(a_prev: &[(Id, Id, u128)], a_cur: &[(Id, Id, u128)], a_mid: &[(Id, Id, u128)], k: usize, engine: &dyn FocusEngine) -> Vec<(Id, Id, i128)> { - if k <= 1 { - let g = engine.gradient(&FocusGraph::build(a_mid)); - return a_mid.iter().zip(g).map(|((p, q, _), g)| (*p, *q, g)).collect(); - } - // the union of both snapshots with (A_prev, A_cur) - let mut union: BTreeMap<(Id, Id), (u128, u128)> = BTreeMap::new(); - for (p, q, w) in a_prev { - union.entry((*p, *q)).or_default().0 = *w; - } - for (p, q, w) in a_cur { - union.entry((*p, *q)).or_default().1 = *w; - } - let mut total: BTreeMap<(Id, Id), i128> = BTreeMap::new(); - for (t, w) in gauss_legendre(k) { - let at: Vec<(Id, Id, u128)> = union - .iter() - .map(|((p, q), (a, b))| { - let d = *b as i128 - *a as i128; - (*p, *q, (*a as i128 + ((t as i128 * d) >> 32)).max(0) as u128) - }) - .filter(|x| x.2 > 0) - .collect(); - let g = engine.gradient(&FocusGraph::build(&at)); - for ((p, q, _), gk) in at.iter().zip(g) { - // ⌊w·g / 2^32⌋, 256-bit wide (tru/specs/epoch.md §4) - let wg = tru::focusing::epoch::fixed::mul_wide_floor(w as i128, gk, 32); - let t = total.entry((*p, *q)).or_insert(0); - *t = t.saturating_add(wg); - } - } - total.into_iter().map(|((p, q), g)| (p, q, g)).collect() -} - -/// The epoch record of §5.6. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct Record { - pub epoch: u64, - pub boundary: u64, - pub beacon: Id, - pub boundary_root: Id, - pub particles: u32, - pub axons: u32, - pub phi: Id, - pub syntropy: i64, - pub delta_syntropy: i64, - pub focus_bps: u16, - pub budget: u64, - pub paid: u64, - pub pool_after: u64, - pub rewarded: u32, - pub payouts: Id, -} - -/// Record bytes (§5.6). -pub const RECORD_BYTES: usize = 198; - -impl Record { - pub fn encode(&self) -> Vec { - let mut out = Vec::with_capacity(RECORD_BYTES); - out.extend_from_slice(&self.epoch.to_le_bytes()); - out.extend_from_slice(&self.boundary.to_le_bytes()); - out.extend_from_slice(&self.beacon); - out.extend_from_slice(&self.boundary_root); - out.extend_from_slice(&self.particles.to_le_bytes()); - out.extend_from_slice(&self.axons.to_le_bytes()); - out.extend_from_slice(&self.phi); - out.extend_from_slice(&self.syntropy.to_le_bytes()); - out.extend_from_slice(&self.delta_syntropy.to_le_bytes()); - out.extend_from_slice(&self.focus_bps.to_le_bytes()); - out.extend_from_slice(&self.budget.to_le_bytes()); - out.extend_from_slice(&self.paid.to_le_bytes()); - out.extend_from_slice(&self.pool_after.to_le_bytes()); - out.extend_from_slice(&self.rewarded.to_le_bytes()); - out.extend_from_slice(&self.payouts); - debug_assert_eq!(out.len(), RECORD_BYTES); - out - } - - /// `time[settle(E)] = hemera(D_EPOCH ‖ record)`. - pub fn digest(&self) -> Id { - let mut buf = DOMAIN_EPOCH.to_vec(); - buf.extend_from_slice(&self.encode()); - *hemera::hash(&buf).as_bytes() - } -} - -/// i128 syntropy into the record's i64 (saturating: J ≤ ln n nats, which -/// at 2^60 exceeds i64 only past e^8 ≈ 3,000 particles of full concentration). -fn sat64(v: i128) -> i64 { - v.clamp(i64::MIN as i128, i64::MAX as i128) as i64 -} - -/// What the settlement step did, for the node's epoch store and `/epoch`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Outcome { - pub record: Record, - /// `(card, owner, amount)`, cards ascending, amount > 0 - pub payouts: Vec<(Id, Id, u64)>, - pub focus: Arc, -} - -/// Floor of `B · x / X` capped per position (§5.5). -pub fn divide(budget: u64, earners: &[Earner]) -> Vec { - let mut total = W::ZERO; - for e in earners { - total = total.checked_add(&e.x).expect("Σ x fits 512 bits"); - } - if total.is_zero() { - return vec![0; earners.len()]; - } - let b = W::from_u64(budget); - earners - .iter() - .map(|e| { - let share = mul_div(&b, &e.x, &total).to_u64().expect("share ≤ budget"); - share.min(e.cap) - }) - .collect() -} - -/// The settlement step of the block at `settle(E)` (§5.5–§5.7): after the -/// coinbase, before the transactions. Pays from the pool, rewrites the -/// high-water marks, writes `time[height]`. -pub fn apply(state: &mut S, native: &Id, height: u64, r: &SettleResult) -> Result { - let pool = focus_key(native); - let f = state.raw_balance(&pool); - let budget = f / POOL_RELEASE; - let pays = divide(budget, &r.earners); - let mut payouts = Vec::new(); - let mut paid = 0u64; - for (e, pay) in r.earners.iter().zip(pays) { - if pay == 0 { - continue; - } - state.credit(&e.owner, native, pay)?; - paid += pay; - payouts.push((e.card, e.owner, pay)); - } - if paid > budget || budget > f { - return Err(StateError::Insufficient { have: budget, need: paid }); - } - state.debit_raw(&pool, native, paid)?; - - let keep: std::collections::BTreeSet = r.marks.iter().map(|(a, _)| *a).collect(); - for (axon, _) in state.marks() { - if !keep.contains(&axon) { - state.set_mark(&axon, None); - } - } - for (axon, m) in &r.marks { - state.set_mark(axon, Some(*m)); - } - - let leaves: Vec = payouts.iter().map(|(c, o, a)| payout_leaf(c, o, *a)).collect(); - let record = Record { - epoch: r.epoch, - boundary: r.boundary, - beacon: r.beacon, - boundary_root: r.boundary_root, - particles: r.particles, - axons: r.axons, - phi: r.phi, - syntropy: sat64(r.syntropy), - delta_syntropy: sat64(r.syntropy - r.prev_syntropy), - focus_bps: r.focus_bps as u16, - budget, - paid, - pool_after: f - paid, - rewarded: payouts.len() as u32, - payouts: payouts_root(&leaves), - }; - state.set_time(height, record.digest()); - let mut s = state.scalars(); - s.last_settled = r.epoch; - state.set_scalars(s); - Ok(Outcome { record, payouts, focus: r.focus.clone() }) -} - -/// Σ stake keys over the cards of a state, the §6.3 check against the -/// running total. -pub fn stake_total(state: &impl StateBackend) -> u128 { - state.cards().iter().map(|(c, _)| state.raw_balance(&stake_key(c)) as u128).sum() -} diff --git a/crate/src/chain/economy/tests.rs b/crate/src/chain/economy/tests.rs deleted file mode 100644 index 980fe2f8..00000000 --- a/crate/src/chain/economy/tests.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! chain-conformance §14 / chain-economy §10: the identities and integer -//! vectors, and the reference engine's contract. - -use super::focus::{FocusEngine, FocusGraph, LN_BITS, PHI_BITS, Reference, ln_q40, log2_q40}; -use super::settle::{Earner, Record, RECORD_BYTES, divide}; -use super::wide::W; -use super::*; -use crate::chain::sigs::token; - -fn hex(b: &[u8]) -> String { - b.iter().map(|x| format!("{x:02x}")).collect() -} - -const PUSSY_S0: u64 = 1_099_389_810_279_114_036; -const PUSSY_SUBSIDY: u64 = 10_458_426_657; - -#[test] -fn identities_of_section_14() { - let card = card_id(&[0x66; 32], &token("boot"), &token("pussy")); - assert_eq!(hex(&card), "74f0a211d177f290e9c95a6e7edb65cd8705ea96d9713a841d5a086f6476b5b4"); - assert_eq!(hex(&stake_key(&card)), "f7e0cbf8e5cfc501b7e3098b238bce8451b3b0a7fa2e42b69724801115c6b08a"); - assert_eq!(hex(&focus_key(&token("pussy"))), "0fae8671376d879515b2736c2d6d51eafdd9ff40055aa84aaff6e17a0beed4b9"); - let leaf = payout_leaf(&card, &[0x77; 32], 1_000); - assert_eq!(hex(&leaf), "63c3867a76af023aa6417e5a945612289906e8352ea69fe4aea259ce5b152115"); - assert_eq!(hex(&payout_node(&leaf, &leaf)), "240a852c1bd8f641f65d6b3746a72a08c0930e5d1bd1feafe4b9cdab914a6ec2"); - assert_eq!(hex(&phi_digest(&[1 << 59, 1 << 59])), "3351bfa40d6eaa24c8e2b8755d900dbcd3c3015730a5efcfdf9b6e14c7019a8a"); - // the payout tree: odd node promoted, empty is zeros - assert_eq!(payouts_root(&[]), [0; 32]); - assert_eq!(payouts_root(&[leaf]), leaf); - assert_eq!(payouts_root(&[leaf, leaf, leaf]), payout_node(&payout_node(&leaf, &leaf), &leaf)); - // raw keys are 53-byte preimages: no owner ‖ token (64 bytes) collides - assert_ne!(stake_key(&card), crate::wallet::tx::balance_key(&card, &token("pussy"))); -} - -#[test] -fn integers_of_section_14() { - assert_eq!( - [0, PUSSY_S0 / 10_000, PUSSY_S0 / 100, PUSSY_S0 / 4, PUSSY_S0].map(|a| focus_bps(a, PUSSY_S0)), - [0, 99, 999, 5_000, 5_000] - ); - assert_eq!(cut(PUSSY_SUBSIDY, 1_000), 1_045_842_665); - assert_eq!(PUSSY_SUBSIDY - cut(PUSSY_SUBSIDY, 1_000), 9_412_583_992); - assert_eq!(cut(PUSSY_SUBSIDY, 5_000), 5_229_213_328); - assert_eq!(PUSSY_SUBSIDY - cut(PUSSY_SUBSIDY, 5_000), 5_229_213_329); - assert_eq!(decay_mark(1_000_000, 0), 998_077); - let mut h = 1_000_000; - for _ in 0..360 { - h = decay_mark(h, 0); - } - assert_eq!(h, 500_047); - assert_eq!(decay_mark(10, 25), 25, "a new high replaces the mark"); - let e = |x: u64, cap: u64| Earner { card: [0; 32], owner: [0; 32], x: W::from_u64(x), cap }; - let pays = divide(1_000, &[e(1, u64::MAX), e(1, u64::MAX), e(1, u64::MAX)]); - assert_eq!(pays, vec![333, 333, 333], "1 stays in the pool"); - let caps = [1_000_000u64, 1_000_000_000].map(|ds| ((ds as u128 * crate::chain::params::R_MAX_PPM as u128) / 1_000_000) as u64); - assert_eq!(caps, [10_000, 10_000_000]); - let pays = divide(1_000_000, &[e(3, caps[0]), e(1, caps[1])]); - assert_eq!(pays, vec![10_000, 250_000], "740,000 stays"); - assert_eq!(divide(5, &[e(0, 9)]), vec![0], "X = 0 pays nothing"); -} - -#[test] -fn isqrt_is_the_floor_root() { - for n in [0u128, 1, 2, 3, 4, 8, 9, 99, 100, 10_000, 99_999_999, 100_000_000, u64::MAX as u128, u128::MAX] { - let r = isqrt(n); - assert!(r * r <= n, "{n}"); - assert!((r + 1).checked_mul(r + 1).is_none_or(|sq| sq > n), "{n}"); - } -} - -#[test] -fn record_is_198_bytes() { - let r = Record { syntropy: -5, delta_syntropy: 7, focus_bps: 999, ..Default::default() }; - assert_eq!(r.encode().len(), RECORD_BYTES); - assert_ne!(r.digest(), Record::default().digest()); -} - -#[test] -fn fixed_point_logarithms() { - let one = 1u128 << PHI_BITS; - assert_eq!(log2_q40(one, PHI_BITS), 0); - assert_eq!(log2_q40(2 * one, PHI_BITS), 1 << LN_BITS); - assert_eq!(log2_q40(one / 4, PHI_BITS), -(2 << LN_BITS)); - // ln 2 at 2^40 ≈ 762,123,384,786 - assert!((ln_q40(2 * one, PHI_BITS) - 762_123_384_786).abs() <= 1); - // log2(3) = 1.584962500721156… - let l3 = log2_q40(3 * one, PHI_BITS); - assert!((l3 - 1_742_684_699_131).abs() <= 2, "{l3}"); -} - -fn star(n: u8, w: u128) -> Vec<(Id, Id, u128)> { - // every leaf links to the hub [0; 32] - let mut v: Vec<(Id, Id, u128)> = (1..=n).map(|i| ([i; 32], [0; 32], w)).collect(); - v.sort(); - v -} - -#[test] -fn reference_engine_is_a_distribution_and_ranks_the_hub() { - let g = FocusGraph::build(&star(5, 10)); - assert_eq!(g.n(), 6); - assert_eq!(g.particles[0], [0; 32]); - let f = Reference::default().focus(&g); - let mass: u128 = f.phi.iter().map(|x| *x as u128).sum(); - let one = 1u128 << PHI_BITS; - assert!(mass <= one && one - mass < 1 << 20, "mass {mass}"); - assert!(f.phi[0] > 3 * f.phi[1], "the hub holds the focus"); - assert!(f.syntropy > 0, "a star is more concentrated than uniform"); - // determinism: the same bits twice - assert_eq!(Reference::default().focus(&g), f); - assert_eq!(Reference::default().focus(&FocusGraph::default()).phi, Vec::::new()); -} - -#[test] -fn reference_gradient_agrees_with_a_finite_difference() { - // a chain 1 → 2 → 3 plus 4 → 1: which edge raises syntropy? - let mut axons: Vec<(Id, Id, u128)> = vec![([1; 32], [2; 32], 1_000), ([2; 32], [3; 32], 1_000), ([4; 32], [1; 32], 1_000), ([3; 32], [4; 32], 1_000), ([1; 32], [3; 32], 1_000)]; - axons.sort(); - let e = Reference::default(); - let base = FocusGraph::build(&axons); - let grad = e.gradient(&base); - let j0 = e.focus(&base).syntropy; - for (i, g) in grad.iter().enumerate() { - let mut bumped = axons.clone(); - bumped[i].2 += 100; - let dj = e.focus(&FocusGraph::build(&bumped)).syntropy - j0; - // g is ∂J/∂A at 2^100 per token; J at 2^60 → predicted ΔJ = g · 100 / 2^40 - let predicted = (g * 100) >> 40; - assert_eq!(dj.signum(), predicted.signum(), "edge {i}: finite difference {dj}, gradient {predicted}"); - let err = (dj - predicted).abs(); - assert!(err * 10 <= dj.abs().max(1 << 20), "edge {i}: finite difference {dj}, gradient {predicted}"); - } -} - -#[test] -fn settle_result_round_trips_through_the_cache_file() { - use super::settle::{FocusOut, SettleResult}; - let r = SettleResult { - epoch: 3, - prev_block: [1; 32], - boundary: 720, - beacon: [2; 32], - boundary_root: [3; 32], - particles: 9, - axons: 8, - phi: [4; 32], - syntropy: -77, - prev_syntropy: 1 << 100, - focus_bps: 999, - earners: vec![Earner { card: [5; 32], owner: [6; 32], x: W([1, 2, 3, 4, 5, 6, 7, 8]), cap: 10 }], - marks: vec![([7; 32], Mark { pos: 1, neg: 2 })], - focus: std::sync::Arc::new(FocusOut { particles: vec![], phi: vec![], syntropy: -77, digest: [4; 32] }), - }; - assert_eq!(super::run::round_trip(&r), Some(r)); -} - -#[test] -fn the_new_high_rule_pays_only_above_the_decayed_mark() { - use super::settle::{Position, Snapshot, compute}; - use super::weights::GenesisWeights; - use std::collections::BTreeMap; - // a fixed graph: genesis weight on a → b and b → c; a +1 stake of 100 - // on b → c (which concentrates focus on c: ∂J/∂A > 0) appears in the epoch - let (a, b, cc) = ([1u8; 32], [2u8; 32], [3u8; 32]); - let weights = GenesisWeights { axons: vec![(a, b, 1_000), (b, cc, 1_000)], total: 2_000, at_floor: 0 }; - let axon = axon_of(&b, &cc); - let card = card_id(&[9; 32], &b, &cc); - let snap = |epoch: u64, stake: u64, mark: Option| Snapshot { - epoch, - height: epoch * 10, - block_id: [epoch as u8; 32], - state_root: [0; 32], - positions: if stake > 0 { vec![Position { card, axon, p: b, q: cc, owner: [9; 32], valence: 1, stake }] } else { vec![] }, - marks: mark.map(|m| BTreeMap::from([(axon, (b, cc, m))])).unwrap_or_default(), - active: stake, - supply: 1_000_000, - }; - let e = Reference::default(); - let x_of = |mark: Option| -> W { - let r = compute(&snap(1, 0, None), &snap(2, 100, mark), &weights, &e, None); - r.earners.iter().find(|x| x.card == card).map(|x| x.x).unwrap_or(W::ZERO) - }; - let fresh = x_of(None); - assert!(!fresh.is_zero(), "weight on b → c concentrates focus on c"); - // the mark at the old height: nothing new, nothing paid - assert!(x_of(Some(Mark { pos: 100, neg: 0 })).is_zero()); - // one epoch of decay: 99 of 100 is old height, 1 is new - let decayed = decay_mark(100, 0); - assert_eq!(decayed, 99); - assert_eq!(x_of(Some(Mark { pos: decayed, neg: 0 })), fresh.div_u64(100)); - // a mark on the other side does not limit this one - assert_eq!(x_of(Some(Mark { pos: 0, neg: 1_000 })), fresh); - // the marks the settlement leaves: the new height, decayed from there - let r = compute(&snap(1, 0, None), &snap(2, 100, Some(Mark { pos: 250, neg: 0 })), &weights, &e, None); - assert_eq!(r.marks, vec![(axon, Mark { pos: decay_mark(250, 100), neg: 0 })]); - // an axon whose sides are empty and marks spent is dropped - let r = compute(&snap(1, 0, None), &snap(2, 0, Some(Mark { pos: 1, neg: 0 })), &weights, &e, None); - assert!(r.marks.is_empty()); -} - -#[test] -fn attribution_is_linear_in_stake_and_valence_cancels() { - use super::settle::{Position, Snapshot, compute}; - use super::weights::GenesisWeights; - let (a, b, cc) = ([1u8; 32], [2u8; 32], [3u8; 32]); - let weights = GenesisWeights { axons: vec![(a, b, 1_000), (b, cc, 1_000)], total: 2_000, at_floor: 0 }; - let axon = axon_of(&b, &cc); - let pos = |author: u8, stake: u64, valence: i8| Position { card: card_id(&[author; 32], &b, &cc), axon, p: b, q: cc, owner: [author; 32], valence, stake }; - let pos_on = |author: u8, p: Id, q: Id, stake: u64, valence: i8| Position { card: card_id(&[author; 32], &p, &q), axon: axon_of(&p, &q), p, q, owner: [author; 32], valence, stake }; - let snap = |epoch: u64, positions: Vec| { - let mut positions = positions; - positions.sort_by(|x, y| x.card.cmp(&y.card)); - let active = positions.iter().filter(|p| p.valence != 0).map(|p| p.stake).sum(); - Snapshot { epoch, height: epoch * 10, block_id: [epoch as u8; 32], state_root: [0; 32], positions, marks: Default::default(), active, supply: 1_000_000 } - }; - let e = Reference::default(); - // one author with 100, or two with 60 and 40: the same total weight (Sybil-neutral) - let one = compute(&snap(1, vec![]), &snap(2, vec![pos(7, 100, 1)]), &weights, &e, None); - let two = compute(&snap(1, vec![]), &snap(2, vec![pos(7, 60, 1), pos(8, 40, 1)]), &weights, &e, None); - let sum_two = two.earners.iter().fold(W::ZERO, |acc, x| acc.checked_add(&x.x).unwrap()); - let one_x = one.earners[0].x; - let (hi, lo) = if one_x > sum_two { (one_x, sum_two) } else { (sum_two, one_x) }; - assert!(hi <= lo.checked_add(&W::from_u64(2)).unwrap(), "split changes only rounding: {one_x:?} vs {sum_two:?}"); - // passive stake moves the graph, earns nothing - let passive = compute(&snap(1, vec![]), &snap(2, vec![pos(7, 100, 0)]), &weights, &e, None); - assert!(passive.earners.is_empty()); - assert_ne!(passive.phi, compute(&snap(1, vec![]), &snap(2, vec![]), &weights, &e, None).phi, "passive stake moves φ*"); - // −1 on an axon with no weight does nothing: A is clipped at zero - let against = compute(&snap(1, vec![]), &snap(2, vec![pos_on(7, cc, a, 100, -1)]), &weights, &e, None); - assert!(against.earners.is_empty(), "nothing to subtract from: zero gradient"); - // −1 against a → b (whose weight spreads focus, ∂J/∂A < 0) earns - let minus = compute(&snap(1, vec![]), &snap(2, vec![pos_on(7, a, b, 100, -1)]), &weights, &e, None); - assert_eq!(minus.earners.len(), 1, "subtracting a spreading edge raises syntropy"); - // +1 on the same edge earns nothing: its gradient is negative - let plus = compute(&snap(1, vec![]), &snap(2, vec![pos_on(7, a, b, 100, 1)]), &weights, &e, None); - assert!(plus.earners.is_empty()); - // +1 and −1 of equal size on one axon (§9 hedging): A is unchanged, the - // attributions are opposite; only the side whose a(c) > 0 earns, so the - // pair costs twice the capital for one side's credit - let hedge = compute(&snap(1, vec![]), &snap(2, vec![pos(7, 100, 1), pos(8, 100, -1)]), &weights, &e, None); - assert_eq!(hedge.earners.len(), 1, "{:?}", hedge.earners); - assert_eq!(hedge.earners[0].card, card_id(&[7; 32], &b, &cc)); -} - - -/// A deterministic random graph of `n` particles and `m` axons. -fn random_axons(n: u64, m: u64, seed: u64) -> Vec<(Id, Id, u128)> { - let mut s = seed; - let mut next = || { - s = s.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = s; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) - }; - let id = |i: u64| -> Id { *hemera::hash(&i.to_le_bytes()).as_bytes() }; - let mut map = std::collections::BTreeMap::new(); - for _ in 0..m { - let (a, b) = (next() % n, next() % n); - if a != b { - *map.entry((id(a), id(b))).or_insert(0u128) += 1 + (next() % 1_000_000) as u128; - } - } - map.into_iter().map(|((p, q), w)| (p, q, w)).collect() -} - -#[test] -fn tru_engine_digest_threads_and_scale() { - use super::focus::Tru; - let axons = random_axons(300, 1_500, 7); - let g = FocusGraph::build(&axons); - let one = Tru::new(1); - let f = one.focus(&g); - // the chain's digest is hemera over tru's preimage - let tru_axons: Vec = axons.iter().map(|(p, q, w)| tru::focusing::epoch::Axon { from: *p, to: *q, weight: *w }).collect(); - let t = tru::focusing::epoch::phi(&tru_axons, &one.params); - assert_eq!(t.particles, g.particles); - assert_eq!(phi_digest(&f.phi), *hemera::hash(&t.preimage()).as_bytes()); - let mass: u128 = f.phi.iter().map(|x| *x as u128).sum(); - assert!(mass.abs_diff(1 << 60) < 1 << 40, "φ is a distribution at 2^60: {mass}"); - // thread count never changes a bit - let eight = Tru::new(8); - assert_eq!(eight.focus(&g), f); - assert_eq!(eight.gradient(&g), one.gradient(&g)); -} - -#[test] -fn quadrature_is_a_parameter_and_one_node_is_the_midpoint() { - use super::focus::Tru; - use super::settle::{Position, Snapshot, compute_with}; - use super::weights::GenesisWeights; - let axons = random_axons(60, 200, 11); - let weights = GenesisWeights { axons: axons.iter().map(|(p, q, w)| (*p, *q, *w as u64)).collect(), total: 0, at_floor: 0 }; - let snap = |epoch: u64, positions: Vec| Snapshot { epoch, height: epoch * 10, block_id: [epoch as u8; 32], state_root: [0; 32], active: positions.iter().map(|p| p.stake).sum(), positions, marks: Default::default(), supply: 1 << 40 }; - let positions: Vec = { - let mut v: Vec = axons - .iter() - .take(20) - .enumerate() - .map(|(i, (p, q, _))| Position { card: card_id(&[i as u8; 32], p, q), axon: axon_of(p, q), p: *p, q: *q, owner: [i as u8; 32], valence: if i % 2 == 0 { 1 } else { -1 }, stake: 500_000 }) - .collect(); - v.sort_by(|a, b| a.card.cmp(&b.card)); - v - }; - let e = Tru::new(2); - let one = compute_with(&snap(1, vec![]), &snap(2, positions.clone()), &weights, &e, None, 1); - let two = compute_with(&snap(1, vec![]), &snap(2, positions.clone()), &weights, &e, None, 2); - assert!(!one.earners.is_empty()); - assert_eq!(one.phi, two.phi, "φ*_E does not depend on the quadrature"); - // the two rules agree on who earns for most positions on a smooth path - let who = |r: &super::settle::SettleResult| r.earners.iter().map(|x| x.card).collect::>(); - let common = who(&one).intersection(&who(&two)).count(); - assert!(common * 4 >= who(&one).len() * 3, "{} of {}", common, who(&one).len()); -} - -/// chain-economy §7.2: settlement cost on the real genesis graphs. Run with -/// `SOFT3_COST_CHAINS=pussy,bostrom SOFT3_COST_THREADS=1,7 cargo test --release -/// --lib settlement_cost -- --ignored --nocapture`; reads -/// `~/.cache/cyber/genesis/.tade`. -#[test] -#[ignore] -fn settlement_cost_on_genesis_files() { - use super::focus::Tru; - use super::settle::{Position, Snapshot, compute}; - use super::weights::GenesisWeights; - use std::time::Instant; - let home = std::env::var("HOME").unwrap(); - let chains = std::env::var("SOFT3_COST_CHAINS").unwrap_or_else(|_| "pussy".into()); - let threads: Vec = std::env::var("SOFT3_COST_THREADS").unwrap_or_else(|_| "1".into()).split(',').map(|t| t.parse().unwrap()).collect(); - for chain in chains.split(',') { - let path = std::path::Path::new(&home).join(format!(".cache/cyber/genesis/{chain}.tade")); - let t = Instant::now(); - let g = crate::chain::genesis::Genesis::decode(&std::fs::read(&path).unwrap()).unwrap(); - let load = t.elapsed(); - let t = Instant::now(); - let weights = GenesisWeights::from_file(&g.file); - let wt = t.elapsed(); - eprintln!( - "{chain}: decode {load:?}, genesis weights {wt:?}: {} axons, Σ G {}, {} frames at the floor, digest {}", - weights.axons.len(), - weights.total, - weights.at_floor, - hex(&weights.digest()) - ); - // epoch 1: 1,000 +1 positions and 1,000 −1 positions on genesis axons - let mut positions: Vec = weights - .axons - .iter() - .step_by((weights.axons.len() / 2_000).max(1)) - .take(2_000) - .enumerate() - .map(|(i, (p, q, g))| Position { - card: card_id(&[(i % 251) as u8; 32], p, q), - axon: axon_of(p, q), - p: *p, - q: *q, - owner: [1; 32], - valence: if i % 2 == 0 { 1 } else { -1 }, - stake: (*g / 2).max(1), - }) - .collect(); - positions.sort_by(|a, b| a.card.cmp(&b.card)); - let snap = |epoch: u64, positions: Vec| Snapshot { - epoch, - height: epoch * 240, - block_id: [epoch as u8; 32], - state_root: [0; 32], - active: positions.iter().map(|p| p.stake).sum(), - positions, - marks: Default::default(), - supply: g.s0(), - }; - let (prev, cur) = (snap(0, vec![]), snap(1, positions)); - for &th in &threads { - let e = Tru::new(th); - let t = Instant::now(); - let f0 = e.focus(&FocusGraph::build(&prev.adjacency(&weights))); - let phi0 = t.elapsed(); - let t = Instant::now(); - let r = compute(&prev, &cur, &weights, &e, Some(f0.syntropy)); - let settle = t.elapsed(); - eprintln!( - "{chain} threads {th}: φ*_0 {phi0:?} (digest {}, J {}), settlement (φ*_E + gradient at A_mid + attribution) {settle:?}: {} particles, {} axons, {} earners", - hex(&phi_digest(&f0.phi)), - f0.syntropy, - r.particles, - r.axons, - r.earners.len() - ); - } - } -} diff --git a/crate/src/chain/economy/vectors.rs b/crate/src/chain/economy/vectors.rs deleted file mode 100644 index a58bb6a0..00000000 --- a/crate/src/chain/economy/vectors.rs +++ /dev/null @@ -1,388 +0,0 @@ -//! chain-conformance §14 blessed scenarios (chain-economy §10), produced -//! by the reference implementation for `conformance/chain.snap`. -//! -//! The scenarios run the block transition itself (`apply::transition`), -//! not the miner: block ids are `hemera(D_BLOCK ‖ height ‖ prev ‖ root)`, -//! there is no header and no proof of work, so the binary can bless them -//! (`soft3 conformance bless chain`) without the test-only PoW limit. Every -//! economy rule on the path — coinbase split, settlement at -//! `boundary + D`, snapshots keyed by boundary id, the journal that a -//! reorganization reverts — is the kernel's own code. - -use std::sync::Arc; - -use crate::genesis::file::{Balance, GenesisFile, Link}; - -use super::super::apply::{self, Decoded, Locked, Rules}; -use super::super::bbg_state::BbgBacked; -use super::super::genesis::{Genesis, Params}; -use super::super::params::{EpochClock, GENESIS_BITS}; -use super::super::sigs::{self, neuron_of, token, txid}; -use super::super::state::{Id, StateBackend, Undo}; -use super::super::tx::{Body, Tx}; -use super::focus::{FocusEngine, FocusGraph, Tru}; -use super::run::Economy; -use super::settle::Outcome; -use super::weights::GenesisWeights; -use super::{card_id, focus_key, phi_digest}; - -/// The economy scenarios' clock: L = 10, D = 5, MIN_LOCK = 15. -pub const CLOCK: EpochClock = EpochClock { length: 10, delay: 5, min_lock: 15 }; -const D_BLOCK: &[u8] = b"soft3/conformance/block/v1\0"; -const FUNDS: u64 = 1_000_000_000_000_000; -const FEE: u64 = 257; - -/// The three signers: the §6 known-answer key and two fixed secrets. -pub fn keys() -> Vec { - vec![ - super::super::conformance::known_answer_key(), - mudra::SigningKey::from_bytes((&[2u8; 32]).into()).expect("scalar"), - mudra::SigningKey::from_bytes((&[3u8; 32]).into()).expect("scalar"), - ] -} - -pub fn neuron(k: &mudra::SigningKey) -> Id { - neuron_of(&mudra::cosmos::compressed(k.verifying_key())) -} - -/// The economy conformance net: the three signers funded with 10^15 pussy, -/// and four genesis links among `boot`, `pussy` and `cyber` (genesis -/// weights: each author's balance spread over its links). -pub fn genesis() -> Genesis { - let ks = keys(); - let n: Vec = ks.iter().map(neuron).collect(); - let (boot, pussy, cyber) = (token("boot"), token("pussy"), token("cyber")); - let mut balances: Vec = n.iter().map(|o| Balance { owner: *o, token: pussy, amount: FUNDS }).collect(); - balances.sort(); - let s0 = FUNDS * 3; - Genesis::from_file(&GenesisFile { - params: Params { - version: 1, - name: "conformance-economy".into(), - hrp: "pussy".into(), - denom: "pussy".into(), - time: 0, - bits: GENESIS_BITS, - subsidy: super::super::params::subsidy(s0), - checkpoint_key: [0; 33], - checkpoint_sunset: 0, - pool: super::super::pool::Pool::genesis(), - }, - balances, - links: vec![ - Link { neuron: n[0], p: boot, q: pussy, height: 1 }, - Link { neuron: n[1], p: pussy, q: cyber, height: 2 }, - Link { neuron: n[1], p: pussy, q: boot, height: 3 }, - Link { neuron: n[2], p: cyber, q: boot, height: 4 }, - ], - }) - .expect("the economy conformance genesis is canonical") -} - -/// A chain driven block by block through the transition. -pub struct Driver { - pub genesis: Genesis, - pub rules: Rules, - pub state: BbgBacked, - pub economy: Economy, - /// block id per height, 0 = genesis - pub ids: Vec, - pub roots: Vec, - undos: Vec, - pub outcomes: Vec>, - keys: Vec, - pub nonces: Vec, -} - -impl Driver { - pub fn new(engine: Arc) -> Driver { - let genesis = genesis(); - let rules = Rules { - chain: genesis.chain_id, - native: genesis.native_token(), - hrp: genesis.params().hrp.clone(), - subsidy: genesis.subsidy(), - s0: genesis.s0(), - clock: CLOCK, - }; - let mut state = genesis.state(); - let root = state.root(); - let id0 = genesis.block0(root).id(); - let mut economy = Economy::new(GenesisWeights::from_file(&genesis.file), engine, CLOCK, None); - economy.background = false; - economy.on_boundary(&state, 0, id0, root, rules.s0, None); - Driver { genesis, rules, state, economy, ids: vec![id0], roots: vec![root], undos: vec![], outcomes: vec![None], keys: keys(), nonces: vec![0; 3] } - } - - pub fn height(&self) -> u64 { - self.ids.len() as u64 - 1 - } - - pub fn neuron(&self, i: usize) -> Id { - neuron(&self.keys[i]) - } - - fn sign(&mut self, i: usize, body: Body) -> Vec { - let mut tx = Tx { body, sig: vec![] }; - sigs::sign(&self.rules.chain, &mut tx, &self.keys[i]).expect("own key"); - self.nonces[i] += 1; - tx.encode() - } - - pub fn lock(&mut self, i: usize, p: Id, q: Id, amount: u64, valence: i8) -> Vec { - let (from, nonce, token) = (self.neuron(i), self.nonces[i], self.rules.native); - self.sign(i, Body::Cyberlink { from, nonce, token, amount, p, q, valence, fee: FEE }) - } - - pub fn unlock(&mut self, i: usize, p: Id, q: Id, amount: u64) -> Vec { - let (from, nonce) = (self.neuron(i), self.nonces[i]); - self.sign(i, Body::Unlock { from, nonce, p, q, amount, fee: 224 }) - } - - pub fn payout(&mut self, i: usize, p: Id, q: Id, payout: Id) -> Vec { - let (from, nonce) = (self.neuron(i), self.nonces[i]); - self.sign(i, Body::Payout { from, nonce, p, q, payout, fee: 248 }) - } - - pub fn transfer(&mut self, i: usize, to: Id, amount: u64) -> Vec { - let (from, nonce, token) = (self.neuron(i), self.nonces[i], self.rules.native); - self.sign(i, Body::Transfer { from, nonce, token, amount, to, fee: 224 }) - } - - /// Does `raw` execute on the current state (as the next block would see it)? - pub fn valid(&mut self, raw: &[u8]) -> bool { - let Ok(tx) = Tx::decode(raw) else { return false }; - let h = self.height() + 1; - self.state.begin(); - let ok = apply::execute_tx(&self.rules, &tx, raw.len(), &mut self.state, &Locked::default(), h).is_ok(); - let undo = self.state.commit(); - self.state.revert(&undo); - ok - } - - /// Apply the next block with `txs` (coinbase to `0x55 × 32` prepended). - pub fn block(&mut self, txs: &[Vec]) -> Result<(), apply::BlockError> { - let h = self.height() + 1; - let fees: u64 = txs.iter().map(|t| Tx::decode(t).map(|t| t.fee()).unwrap_or(0)).sum(); - let (miner, _) = apply::subsidy_split(&self.rules, &self.state); - let cb = Tx { body: Body::Coinbase { height: h, to: [0x55; 32], amount: miner + fees, extra: [0; 32] }, sig: vec![] }.encode(); - let decoded: Vec = std::iter::once(cb) - .chain(txs.iter().cloned()) - .map(|raw| Decoded { tx: Tx::decode(&raw).expect("well-formed"), txid: txid(&self.rules.chain, &raw), raw }) - .collect(); - let settle = self.rules.clock.settles_at(h).and_then(|e| { - let (prev, cur) = (self.ids[CLOCK.boundary(e - 1) as usize], self.ids[CLOCK.boundary(e) as usize]); - self.economy.result(&prev, &cur) - }); - let applied = apply::transition(&self.rules, h, &decoded, &mut self.state, &Locked::default(), settle.as_deref())?; - let mut buf = D_BLOCK.to_vec(); - buf.extend_from_slice(&h.to_le_bytes()); - buf.extend_from_slice(self.ids.last().expect("genesis")); - buf.extend_from_slice(&applied.root); - let id = *hemera::hash(&buf).as_bytes(); - self.ids.push(id); - self.roots.push(applied.root); - self.undos.push(applied.undo); - self.outcomes.push(applied.settled); - if CLOCK.is_boundary(h) { - let e = CLOCK.epoch(h); - let prev = self.ids[CLOCK.boundary(e - 1) as usize]; - let supply = self.rules.supply_at(h); - self.economy.on_boundary(&self.state, e, id, applied.root, supply, Some(prev)); - } - Ok(()) - } - - pub fn empty_to(&mut self, h: u64) { - while self.height() < h { - self.block(&[]).expect("an empty block applies"); - } - } - - /// Undo the tip block (a reorganization's first half). - pub fn retract(&mut self) { - let undo = self.undos.pop().expect("above genesis"); - self.state.revert(&undo); - self.ids.pop(); - self.roots.pop(); - self.outcomes.pop(); - } - - pub fn outcome(&self, h: u64) -> &Outcome { - self.outcomes[h as usize].as_ref().expect("a settlement block") - } -} - -fn h256(b: &[u8]) -> Id { - *hemera::hash(b).as_bytes() -} - -fn u64s(vals: &[u64]) -> Id { - let mut out = [0u8; 32]; - for (i, v) in vals.iter().take(4).enumerate() { - out[i * 8..i * 8 + 8].copy_from_slice(&v.to_le_bytes()); - } - out -} - -/// `(scenario, value)` pairs for `chain::economy@v1`. -pub fn entries() -> Vec<(String, Id)> { - let mut out: Vec<(String, Id)> = Vec::new(); - let (boot, pussy, cyber) = (token("boot"), token("pussy"), token("cyber")); - let tru: Arc = Arc::new(Tru::new(1)); - - // transactions of kinds 2 (with stake), 6 and 7, §6 key, chain 0x44 × 32 - let chain = [0x44u8; 32]; - let key = super::super::conformance::known_answer_key(); - let from = neuron(&key); - let signed = |body: Body| { - let mut tx = Tx { body, sig: vec![] }; - sigs::sign(&chain, &mut tx, &key).expect("own key"); - tx.encode() - }; - let stake = signed(Body::Cyberlink { from, nonce: 0, token: pussy, amount: 7, p: boot, q: pussy, valence: 1, fee: 257 }); - let unlock = signed(Body::Unlock { from, nonce: 1, p: boot, q: pussy, amount: 7, fee: 224 }); - let payout = signed(Body::Payout { from, nonce: 2, p: boot, q: pussy, payout: [0x77; 32], fee: 248 }); - assert_eq!((stake.len(), unlock.len(), payout.len()), (257, 224, 248)); - out.push(("signed stake cyberlink (amount 7)".into(), h256(&stake))); - out.push(("signed unlock (kind 6)".into(), h256(&unlock))); - out.push(("signed payout (kind 7)".into(), h256(&payout))); - - // genesis weights and φ*_0 of the economy conformance net - let g = genesis(); - let w = GenesisWeights::from_file(&g.file); - out.push(("conformance genesis weights digest".into(), w.digest())); - out.push(("conformance genesis weights total, at floor".into(), u64s(&[w.total as u64, w.at_floor]))); - let adjacency: Vec<(Id, Id, u128)> = w.axons.iter().map(|(p, q, g)| (*p, *q, *g as u128)).collect(); - let f0 = tru.focus(&FocusGraph::build(&adjacency)); - out.push(("conformance phi*_0 digest".into(), phi_digest(&f0.phi))); - out.push(("conformance phi*_0 syntropy".into(), u64s(&[f0.syntropy as u64, (f0.syntropy >> 64) as u64]))); - - // a 2-epoch chain: +1, 0 and −1 locks in epoch 1 - let mut d = Driver::new(tru.clone()); - let txs = vec![d.lock(0, boot, pussy, FUNDS / 1_000, 1), d.lock(1, pussy, cyber, FUNDS / 1_000, 0), d.lock(2, pussy, boot, FUNDS / 4_000, -1)]; - d.block(&txs).expect("locks apply"); - d.empty_to(25); - for e in [1u64, 2] { - let o = d.outcome(CLOCK.settle(e)).clone(); - out.push((format!("two epochs: epoch {e} record"), o.record.digest())); - out.push((format!("two epochs: epoch {e} payouts"), o.record.payouts)); - out.push((format!("two epochs: epoch {e} budget, paid, pool after"), u64s(&[o.record.budget, o.record.paid, o.record.pool_after]))); - } - out.push(("two epochs: state root at 25".into(), d.roots[25])); - let passive = card_id(&d.neuron(1), &pussy, &cyber); - assert!(d.outcome(15).payouts.iter().all(|(c, _, _)| *c != passive), "the passive position earns nothing"); - - // reorganizations: three blocks above the boundary reproduce epoch 1's - // record; replacing the boundary block changes it - let main = d.outcome(15).record.clone(); - let mut r = Driver::new(tru.clone()); - let txs = vec![r.lock(0, boot, pussy, FUNDS / 1_000, 1), r.lock(1, pussy, cyber, FUNDS / 1_000, 0), r.lock(2, pussy, boot, FUNDS / 4_000, -1)]; - r.block(&txs).expect("locks apply"); - r.empty_to(15); - for _ in 0..3 { - r.retract(); - } - let t = r.transfer(0, [0x77; 32], 5); - r.block(&[t]).expect("a transfer applies"); - r.empty_to(15); - assert_eq!(r.outcome(15).record, main, "a reorg above the boundary reproduces the record"); - out.push(("reorg above the boundary: epoch 1 record".into(), r.outcome(15).record.digest())); - while r.height() > 9 { - r.retract(); - } - let extra = r.lock(1, cyber, boot, FUNDS / 2_000, 1); - r.block(&[extra]).expect("a lock in the new boundary block"); - r.empty_to(15); - assert_ne!(r.outcome(15).record, main, "a new boundary block is a new snapshot"); - out.push(("reorg of the boundary block: epoch 1 record".into(), r.outcome(15).record.digest())); - - // the new-high rule: lock, unlock after MIN_LOCK, relock one epoch later - let mut n = Driver::new(tru.clone()); - let amount = FUNDS / 1_000; - let t = n.lock(0, boot, pussy, amount, 1); - n.block(&[t]).expect("lock"); - n.empty_to(1 + CLOCK.min_lock - 1); - let t = n.unlock(0, boot, pussy, amount); - n.block(&[t]).expect("unlock at since + MIN_LOCK"); - n.empty_to(30); - let t = n.lock(0, boot, pussy, amount, 1); - n.block(&[t]).expect("relock"); - n.empty_to(45); - let card = card_id(&n.neuron(0), &boot, &pussy); - let first = n.outcome(15).payouts.iter().find(|x| x.0 == card).map(|x| x.2).unwrap_or(0); - let again = n.outcome(45).payouts.iter().find(|x| x.0 == card).map(|x| x.2).unwrap_or(0); - out.push(("new high: first lock and relock paid".into(), u64s(&[first, again]))); - out.push(("new high: epoch 4 record".into(), n.outcome(45).record.digest())); - - // conservation over 2,000 blocks of random locks, unlocks and payout changes - let mut c = Driver::new(tru); - let mut s = 0x2000_u64; - let mut next = || { - s = s.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = s; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) - }; - let tokens = [boot, pussy, cyber, token("tru"), token("hemera")]; - for _ in 1..=2_000u64 { - let mut txs = Vec::new(); - for _ in 0..(next() % 3) { - let i = (next() % 3) as usize; - let (p, q) = (tokens[(next() % 5) as usize], tokens[(next() % 5) as usize]); - if p == q { - continue; - } - let held = c.state.card(&card_id(&c.neuron(i), &p, &q)); - let raw = match next() % 4 { - 0 | 1 => { - let v = held.map(|x| x.valence).unwrap_or((next() % 3) as i8 - 1); - c.lock(i, p, q, 1 + next() % (FUNDS / 10_000), v) - } - 2 => c.unlock(i, p, q, 1 + next() % (FUNDS / 10_000)), - _ => { - let to = c.neuron((next() % 3) as usize); - c.payout(i, p, q, to) - } - }; - // a tx that would fail is dropped and its nonce given back - let mut trial = txs.clone(); - trial.push(raw.clone()); - if c.valid_sequence(&trial) { - txs.push(raw); - } else { - c.nonces[i] -= 1; - } - } - c.block(&txs).expect("only valid transactions"); - apply::check_conservation(&c.rules, &c.state, c.height()).expect("conserved at every height"); - apply::check_stake_total(&c.state).expect("stake keys equal the running total"); - } - let pool = c.state.raw_balance(&focus_key(&c.rules.native)); - out.push(("random 2,000 blocks: state root".into(), c.roots[2_000])); - out.push(("random 2,000 blocks: pool, total stake".into(), u64s(&[pool, c.state.scalars().total_stake]))); - out -} - -impl Driver { - /// Do these transactions execute in order on the current state? - pub fn valid_sequence(&mut self, raws: &[Vec]) -> bool { - let h = self.height() + 1; - self.state.begin(); - let mut ok = true; - for raw in raws { - let Ok(tx) = Tx::decode(raw) else { - ok = false; - break; - }; - if apply::execute_tx(&self.rules, &tx, raw.len(), &mut self.state, &Locked::default(), h).is_err() { - ok = false; - break; - } - } - let undo = self.state.commit(); - self.state.revert(&undo); - ok - } -} diff --git a/crate/src/chain/economy/weights.rs b/crate/src/chain/economy/weights.rs deleted file mode 100644 index 2646288c..00000000 --- a/crate/src/chain/economy/weights.rs +++ /dev/null @@ -1,150 +0,0 @@ -//! Genesis weights (chain-economy §4.1) and the axon weight `A_pq` (§4.2). -//! -//! ```text -//! B(n) = native balance of n in the genesis file (0 if none) -//! L(n) = number of l frames authored by n -//! g(f) = max(1, floor(B(author(f)) / L(author(f)))) per l frame f -//! G_pq = Σ g(f) over the l frames f with (p, q) -//! A_pq = max(0, G_pq + S⁺_pq − S⁻_pq) -//! ``` -//! -//! Genesis weights are a pure function of the genesis file, which the chain -//! id commits to; they are computed at load and stored nowhere. - -use std::collections::{BTreeMap, HashMap}; - -use crate::genesis::file::GenesisFile; - -use super::super::sigs::token; -use super::super::state::Id; - -/// One axon with a weight, `(p, q, w)`. -pub type Axon = (Id, Id, W); - -/// The genesis weights of a chain, ascending by `p ‖ q`, one entry per axon. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct GenesisWeights { - pub axons: Vec>, - /// Σ G over every axon - pub total: u128, - /// l frames whose weight is the floor of 1 (authors with too little balance) - pub at_floor: u64, -} - -impl GenesisWeights { - pub fn from_file(file: &GenesisFile) -> GenesisWeights { - let native = token(&file.params.denom); - let mut balance: HashMap = HashMap::new(); - for b in file.balances.iter().filter(|b| b.token == native) { - *balance.entry(b.owner).or_insert(0) += b.amount; - } - let mut frames: HashMap = HashMap::new(); - for l in &file.links { - *frames.entry(l.neuron).or_insert(0) += 1; - } - let mut g_of: HashMap = HashMap::with_capacity(frames.len()); - let mut at_floor = 0u64; - for (author, count) in &frames { - let b = balance.get(author).copied().unwrap_or(0); - let g = (b / count).max(1); - if b / count == 0 { - at_floor += count; - } - g_of.insert(*author, g); - } - let mut sums: BTreeMap<(Id, Id), u128> = BTreeMap::new(); - for l in &file.links { - *sums.entry((l.p, l.q)).or_insert(0) += g_of[&l.neuron] as u128; - } - let mut total = 0u128; - let axons = sums - .into_iter() - .map(|((p, q), g)| { - total += g; - (p, q, u64::try_from(g).expect("Σ g ≤ S0 + frames < 2^64")) - }) - .collect(); - GenesisWeights { axons, total, at_floor } - } - - /// `hemera` over `(p ‖ q ‖ G_pq: u64)` sorted — the digest conformance - /// §14 pins for a genesis file. - pub fn digest(&self) -> Id { - let mut buf = Vec::with_capacity(self.axons.len() * 72); - for (p, q, g) in &self.axons { - buf.extend_from_slice(p); - buf.extend_from_slice(q); - buf.extend_from_slice(&g.to_le_bytes()); - } - *hemera::hash(&buf).as_bytes() - } -} - -/// `A_pq = max(0, G_pq + S⁺_pq − S⁻_pq)` over the union of genesis axons -/// and staked axons, ascending by `p ‖ q`, present axons only (`A > 0`). -/// `sides` maps an axon to its `(S⁺, S⁻)`. -pub fn adjacency(genesis: &[Axon], sides: &BTreeMap<(Id, Id), (u64, u64)>) -> Vec> { - let mut out = Vec::with_capacity(genesis.len() + sides.len()); - let mut s = sides.iter().peekable(); - let a = |g: u64, (pos, neg): (u64, u64)| -> u128 { (g as i128 + pos as i128 - neg as i128).max(0) as u128 }; - let mut push = |p: Id, q: Id, w: u128| { - if w > 0 { - out.push((p, q, w)); - } - }; - for (p, q, g) in genesis { - while let Some((&(sp, sq), &v)) = s.peek() { - if (sp, sq) < (*p, *q) { - push(sp, sq, a(0, v)); - s.next(); - } else { - break; - } - } - match s.peek() { - Some((&(sp, sq), &v)) if (sp, sq) == (*p, *q) => { - push(*p, *q, a(*g, v)); - s.next(); - } - _ => push(*p, *q, a(*g, (0, 0))), - } - } - for (&(sp, sq), &v) in s { - push(sp, sq, a(0, v)); - } - out -} - -/// `A_mid = floor((A_prev + A_cur) / 2)` per axon present in either, kept -/// where positive (§5.1). Both inputs ascending by `p ‖ q`. -pub fn midpoint(prev: &[Axon], cur: &[Axon]) -> Vec> { - let mut out = Vec::with_capacity(prev.len().max(cur.len())); - let (mut i, mut j) = (0, 0); - while i < prev.len() || j < cur.len() { - let (p, q, a, b) = match (prev.get(i), cur.get(j)) { - (Some(x), Some(y)) if (x.0, x.1) == (y.0, y.1) => { - i += 1; - j += 1; - (x.0, x.1, x.2, y.2) - } - (Some(x), Some(y)) if (x.0, x.1) < (y.0, y.1) => { - i += 1; - (x.0, x.1, x.2, 0) - } - (Some(x), None) => { - i += 1; - (x.0, x.1, x.2, 0) - } - (_, Some(y)) => { - j += 1; - (y.0, y.1, 0, y.2) - } - (None, None) => unreachable!(), - }; - let m = (a + b) / 2; - if m > 0 { - out.push((p, q, m)); - } - } - out -} diff --git a/crate/src/chain/economy/wide.rs b/crate/src/chain/economy/wide.rs deleted file mode 100644 index 9193211f..00000000 --- a/crate/src/chain/economy/wide.rs +++ /dev/null @@ -1,206 +0,0 @@ -//! Unsigned 512-bit integers for attribution (chain-economy §5.2–§5.5). -//! -//! `a(c) = Δs · σ · g` is a 256-bit signed quantity (a u64 stake times an -//! i128 gradient); `x(c) = floor(a⁺ · N / R)` and `pay = floor(B · x / X)` -//! need one more u64 factor before the division. Every product here fits -//! 512 bits by construction, and every division floors. Little-endian -//! limbs; no allocation; nothing platform-dependent. - -use std::cmp::Ordering; - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub struct W(pub [u64; 8]); - -impl W { - pub const ZERO: W = W([0; 8]); - - pub fn from_u64(v: u64) -> W { - let mut w = [0u64; 8]; - w[0] = v; - W(w) - } - - pub fn from_u128(v: u128) -> W { - let mut w = [0u64; 8]; - w[0] = v as u64; - w[1] = (v >> 64) as u64; - W(w) - } - - pub fn is_zero(&self) -> bool { - self.0.iter().all(|l| *l == 0) - } - - /// Bits needed to write the value (0 for zero). - pub fn bits(&self) -> u32 { - for i in (0..8).rev() { - if self.0[i] != 0 { - return 64 * i as u32 + 64 - self.0[i].leading_zeros(); - } - } - 0 - } - - /// The value if it fits a u64. - pub fn to_u64(&self) -> Option { - self.0[1..].iter().all(|l| *l == 0).then_some(self.0[0]) - } - - pub fn checked_add(&self, o: &W) -> Option { - let mut out = [0u64; 8]; - let mut carry = 0u128; - for (i, slot) in out.iter_mut().enumerate() { - let s = self.0[i] as u128 + o.0[i] as u128 + carry; - *slot = s as u64; - carry = s >> 64; - } - (carry == 0).then_some(W(out)) - } - - fn sub_assign(&mut self, o: &W) { - let mut borrow = 0i128; - for i in 0..8 { - let d = self.0[i] as i128 - o.0[i] as i128 - borrow; - if d < 0 { - self.0[i] = (d + (1i128 << 64)) as u64; - borrow = 1; - } else { - self.0[i] = d as u64; - borrow = 0; - } - } - debug_assert_eq!(borrow, 0, "sub underflow"); - } - - /// Full product; `None` if it exceeds 512 bits. - pub fn checked_mul(&self, o: &W) -> Option { - let mut out = [0u64; 16]; - for i in 0..8 { - if self.0[i] == 0 { - continue; - } - let mut carry = 0u128; - for j in 0..8 { - let cur = out[i + j] as u128 + self.0[i] as u128 * o.0[j] as u128 + carry; - out[i + j] = cur as u64; - carry = cur >> 64; - } - let mut k = i + 8; - while carry > 0 { - let cur = out[k] as u128 + carry; - out[k] = cur as u64; - carry = cur >> 64; - k += 1; - } - } - if out[8..].iter().any(|l| *l != 0) { - return None; - } - let mut w = [0u64; 8]; - w.copy_from_slice(&out[..8]); - Some(W(w)) - } - - fn bit(&self, i: u32) -> bool { - (self.0[(i / 64) as usize] >> (i % 64)) & 1 == 1 - } - - fn shl1_or(&mut self, b: bool) { - let mut carry = b as u64; - for l in self.0.iter_mut() { - let next = *l >> 63; - *l = (*l << 1) | carry; - carry = next; - } - } - - /// `floor(self / d)` for a u64 divisor (limb-wise, fast). - pub fn div_u64(&self, d: u64) -> W { - assert!(d != 0, "division by zero"); - let mut out = [0u64; 8]; - let mut rem = 0u128; - for i in (0..8).rev() { - let cur = (rem << 64) | self.0[i] as u128; - out[i] = (cur / d as u128) as u64; - rem = cur % d as u128; - } - W(out) - } - - /// `floor(self / d)`, bit-serial over the numerator's bits. - pub fn div(&self, d: &W) -> W { - assert!(!d.is_zero(), "division by zero"); - if let Some(small) = d.to_u64() { - return self.div_u64(small); - } - let mut q = W::ZERO; - let mut r = W::ZERO; - for i in (0..self.bits()).rev() { - r.shl1_or(self.bit(i)); - q.shl1_or(false); - if r >= *d { - r.sub_assign(d); - q.0[0] |= 1; - } - } - q - } -} - -impl Ord for W { - fn cmp(&self, o: &W) -> Ordering { - for i in (0..8).rev() { - match self.0[i].cmp(&o.0[i]) { - Ordering::Equal => continue, - other => return other, - } - } - Ordering::Equal - } -} - -impl PartialOrd for W { - fn partial_cmp(&self, o: &W) -> Option { - Some(self.cmp(o)) - } -} - -/// `floor(a · b / c)` with every operand up to 512 bits and the product -/// within 512 bits (true for every caller: ≤ 2^256 · 2^64). -pub fn mul_div(a: &W, b: &W, c: &W) -> W { - a.checked_mul(b).expect("attribution products fit 512 bits").div(c) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn arithmetic_agrees_with_u128_where_it_fits() { - let cases: [(u128, u128, u128); 5] = - [(0, 5, 3), (7, 9, 4), (u64::MAX as u128, u64::MAX as u128, 3), (1 << 100, 1 << 20, (1 << 64) + 7), ((1 << 120) + 99, 255, 1 << 70)]; - for (a, b, c) in cases { - let got = mul_div(&W::from_u128(a), &W::from_u128(b), &W::from_u128(c)); - assert_eq!(got, W::from_u128(a * b / c), "{a} * {b} / {c}"); - } - } - - #[test] - fn wide_products_and_divisions() { - // 2^255 · (2^64 − 1) / 2^255 = 2^64 − 1 - let mut big = W::ZERO; - big.0[3] = 1 << 63; - let got = mul_div(&big, &W::from_u64(u64::MAX), &big); - assert_eq!(got.to_u64(), Some(u64::MAX)); - // (2^200 + 1) · 3 / (2^199) = 6 (floor) - let mut a = W::ZERO; - a.0[3] = 1 << 8; - a.0[0] = 1; - let mut c = W::ZERO; - c.0[3] = 1 << 7; - assert_eq!(mul_div(&a, &W::from_u64(3), &c).to_u64(), Some(6)); - assert_eq!(a.bits(), 201); - assert!(W::from_u64(1).checked_add(&W([u64::MAX; 8])).is_none()); - assert!(W([u64::MAX; 8]).checked_mul(&W::from_u64(2)).is_none()); - } -} diff --git a/crate/src/chain/economy_api.rs b/crate/src/chain/economy_api.rs deleted file mode 100644 index e95f6725..00000000 --- a/crate/src/chain/economy_api.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! The economy's read surface on the engine (chain-economy §11): epoch -//! records and payout lists, φ* of the last settled epoch, positions, and -//! the `/status` fields with the per-settlement checks of §6.3. JSON -//! values, so the HTTP layer only routes. - -use serde_json::{Value, json}; - -use super::economy::{focus_key, stake_key}; -use super::engine::Chain; -use super::state::{Id, StateBackend}; - -fn hex(b: &[u8]) -> String { - b.iter().map(|x| format!("{x:02x}")).collect() -} - -impl Chain { - /// The record of epoch `e` on the main chain, its digest and payouts. - pub fn epoch_json(&self, e: u64) -> Option { - let h = self.rules.clock.settle(e); - let id = self.store.main_at(h)?; - let o = self.economy.outcome(&id)?; - let r = &o.record; - Some(json!({ - "epoch": r.epoch, "boundary": r.boundary, "settled_at": h, "beacon": hex(&r.beacon), "boundary_root": hex(&r.boundary_root), - "particles": r.particles, "axons": r.axons, "phi": hex(&r.phi), "syntropy": r.syntropy, "delta_syntropy": r.delta_syntropy, - "focus_bps": r.focus_bps, "budget": r.budget, "paid": r.paid, "pool_after": r.pool_after, "rewarded": r.rewarded, - "payouts_root": hex(&r.payouts), "digest": hex(&r.digest()), "record": hex(&r.encode()), - "payouts": o.payouts.iter().map(|(c, owner, a)| json!({"card": hex(c), "owner": hex(owner), "amount": a})).collect::>(), - })) - } - - /// Up to `limit` (≤ 500) records after epoch `after`. - pub fn epochs_json(&self, after: u64, limit: usize) -> Value { - let last = self.state.scalars().last_settled; - let records: Vec = (after + 1..=last).take(limit.min(500)).filter_map(|e| self.epoch_json(e)).collect(); - json!({ "last_settled": last, "epochs": records }) - } - - /// φ* of the last settled epoch this node computed with its vector. - fn last_focus(&self) -> Option<(u64, std::sync::Arc)> { - let last = self.state.scalars().last_settled; - (1..=last).rev().take(3).find_map(|e| { - let id = self.store.main_at(self.rules.clock.settle(e))?; - self.economy.outcome(&id).filter(|o| !o.focus.phi.is_empty()).map(|o| (e, o)) - }) - } - - /// φ*_E of `particle` at the last settled epoch: raw fixed point, the - /// scale, and its rank (1 = highest). - pub fn focus_json(&self, particle: &Id) -> Option { - let (e, o) = self.last_focus()?; - let f = &o.focus; - let i = f.particles.binary_search(particle).ok()?; - let v = f.phi[i]; - let rank = 1 + f.phi.iter().filter(|x| **x > v).count(); - Some(json!({"epoch": e, "particle": hex(particle), "phi": v, "scale_bits": 60, "rank": rank, "particles": f.particles.len()})) - } - - /// The `n` particles of highest focus at the last settled epoch. - pub fn focus_top_json(&self, n: usize) -> Option { - let (e, o) = self.last_focus()?; - let f = &o.focus; - let mut idx: Vec = (0..f.phi.len()).collect(); - idx.sort_by(|a, b| f.phi[*b].cmp(&f.phi[*a]).then(f.particles[*a].cmp(&f.particles[*b]))); - let top: Vec = idx.into_iter().take(n.min(1_000)).map(|i| json!({"particle": hex(&f.particles[i]), "phi": f.phi[i]})).collect(); - Some(json!({"epoch": e, "scale_bits": 60, "syntropy": f.syntropy.to_string(), "digest": hex(&f.digest), "top": top})) - } - - fn card_value(&self, id: &Id) -> Option { - let c = self.state.card(id)?; - let (p, q) = self.state.axon_edge(&c.particle).unwrap_or(([0; 32], [0; 32])); - Some(json!({ - "card": hex(id), "owner": hex(&c.owner), "author": hex(&c.author), "axon": hex(&c.particle), "p": hex(&p), "q": hex(&q), - "valence": c.valence, "since": c.since, "unlock_at": c.since + self.rules.clock.min_lock, "stake": self.state.raw_balance(&stake_key(id)), - })) - } - - pub fn card_json(&self, id: &Id) -> Option { - self.card_value(id) - } - - /// Every position `neuron` authored (a scan; node-side, not consensus). - pub fn cards_json(&self, neuron: &Id) -> Value { - let cards: Vec = self.state.cards().into_iter().filter(|(_, c)| c.author == *neuron).filter_map(|(id, _)| self.card_value(&id)).collect(); - json!({"neuron": hex(neuron), "cards": cards}) - } - - /// `/status` economy fields and the §6.3 checks. - pub fn economy_status(&self) -> Value { - let clock = self.rules.clock; - let tip = self.store.tip_height(); - let s = self.state.scalars(); - let pool = self.state.raw_balance(&focus_key(&self.rules.native)); - let stake_keys_match = super::apply::check_stake_total(&self.state).is_ok(); - let last = (s.last_settled > 0).then(|| self.store.main_at(clock.settle(s.last_settled)).and_then(|id| self.economy.outcome(&id))).flatten(); - let settlement_ok = last.as_ref().is_none_or(|o| o.record.paid <= o.record.budget && o.record.budget <= o.record.paid + o.record.pool_after); - json!({ - "epoch": clock.epoch(tip), "epoch_length": clock.length, "settle_delay": clock.delay, "min_lock": clock.min_lock, - "next_boundary": (tip / clock.length + 1) * clock.length, - "last_settled": s.last_settled, "focus_bps": s.focus_bps, "pool": pool, "active_stake": s.active_stake, "total_stake": s.total_stake, - "stake_keys_match": stake_keys_match, "settlement_ok": settlement_ok, "engine": self.economy.engine.id(), - "timings": self.economy.timings().iter().rev().take(4).map(|t| json!({"epoch": t.epoch, "millis": t.millis, "particles": t.particles, "axons": t.axons})).collect::>(), - }) - } -} diff --git a/crate/src/chain/engine.rs b/crate/src/chain/engine.rs deleted file mode 100644 index fedacb42..00000000 --- a/crate/src/chain/engine.rs +++ /dev/null @@ -1,576 +0,0 @@ -//! The chain engine: [`Chain`] implements [`ChainApi`] over the store, the -//! bbg-backed state, the checkpoints and the validation order of §5.5. -//! Headers are validated in steps 1–5 and stored; bodies run steps 6–8 on -//! the parent state; fork choice (§5.1–§5.3) moves the tip with the undo -//! journals. The [`ChainApi`](super::api::ChainApi) impl is in -//! [`super::engine_api`]; mempool and template entry points in -//! [`super::engine_tx`]. - -use std::collections::{HashSet, VecDeque}; -use std::path::Path; -use std::sync::Arc; - -use super::api::{BlockVerdict, ChainError, Event}; -use super::apply::{self, Decoded, Locked, Rules}; -use super::bbg_state::BbgBacked; -use super::daa::{median_time_past, next_bits}; -use super::economy::focus::{FocusEngine, Tru}; -use super::economy::run::Economy; -use super::economy::settle::SettleResult; -use super::economy::weights::GenesisWeights; -use super::genesis::Genesis; -use super::header::{BlockId, Header}; -use super::params::{COINBASE_MATURITY, EpochClock, FINALITY_DEPTH, MAX_FUTURE_DRIFT, MTP_WINDOW, RULE_SET, rule_set_at}; -use super::pow::{chainwork, valid_pow}; -use super::select::{self, Checkpoints}; -use super::state::{Id, StateBackend}; -use super::store::{self, Store}; -use super::tx::Body; -use super::u256::U256; - -const REJECTED_CAPACITY: usize = 4_096; - -pub struct Chain { - pub(super) genesis: Genesis, - pub(super) rules: Rules, - pub(super) store: Store, - pub(super) state: BbgBacked, - pub(super) checkpoints: Checkpoints, - /// main-chain coinbases `(height, to, amount)` still inside maturity - pub(super) coinbases: VecDeque<(u64, Id, u64)>, - pub(super) rejected: HashSet, - pub(super) rejected_order: VecDeque, - /// blocks invalid only for contradicting a held checkpoint (§5.3): a - /// peer relaying them or their children is on the other branch, not - /// misbehaving — refused as `Checkpoint`, never scored - pub(super) vetoed: HashSet, - /// the most work any non-invalid header carries; when the tip has it, - /// there is nothing to consider - pub(super) heaviest: U256, - pub(super) finalized: u64, - pub(super) events: Vec, - /// the focus economy's snapshots, settlement workers and outcomes - pub(super) economy: Economy, -} - -/// The launch chains: they run the spec's epoch clock and nothing else. -pub const LAUNCH_CHAINS: &[&str] = &["pussy", "bostrom"]; - -/// How a chain is opened beyond its genesis file. -#[derive(Clone, Default)] -pub struct OpenOptions { - pub checkpoints: bool, - /// a shortened epoch clock for a test or dev net; ignored (the spec's - /// clock is used) for the launch chains - pub clock: Option, - /// the φ* engine; tru's epoch engine when `None` - pub engine: Option>, - /// gradient nodes on the epoch path; `ATTR_NODES` (the protocol) when 0 - pub quadrature: usize, - /// compute settlements inline instead of on worker threads - pub inline_settlement: bool, -} - -impl Chain { - /// Open or create the chain under `home` (`home/blocks` is the store). - /// A store whose block 0 differs from the genesis file refuses to open. - pub fn open(home: &Path, genesis_bytes: &[u8], checkpoints_enabled: bool) -> Result { - let clock = EpochClock::from_env(); - Chain::open_with(home, genesis_bytes, OpenOptions { checkpoints: checkpoints_enabled, clock, ..Default::default() }) - } - - pub fn open_with(home: &Path, genesis_bytes: &[u8], opts: OpenOptions) -> Result { - let genesis = Genesis::decode(genesis_bytes).map_err(|e| ChainError::Store(e.to_string()))?; - let launch = LAUNCH_CHAINS.contains(&genesis.params().name.as_str()); - let clock = match opts.clock { - Some(c) if !launch && c.valid() => c, - _ => EpochClock::SPEC, - }; - let rules = Rules { - chain: genesis.chain_id, - native: genesis.native_token(), - hrp: genesis.params().hrp.clone(), - subsidy: genesis.subsidy(), - s0: genesis.s0(), - clock, - }; - std::fs::create_dir_all(home).map_err(|e| ChainError::Store(e.to_string()))?; - let mut store = Store::open(&home.join("blocks")).map_err(ChainError::Store)?; - let mut state = genesis.state(); - let root0 = state.root(); - let block0 = genesis.block0(root0); - let checkpoints = Checkpoints::new(genesis.params().checkpoint_key, opts.checkpoints); - if store.is_empty() { - store.put_header(block0, U256::ZERO, store::Status::Valid).map_err(ChainError::Store)?; - store.push_main(block0.id()).map_err(ChainError::Store)?; - store.persist().map_err(ChainError::Store)?; - } else if store.main_at(0) != Some(block0.id()) { - return Err(ChainError::Genesis); - } - let engine: Arc = opts.engine.unwrap_or_else(|| Arc::new(Tru::for_node())); - let mut economy = Economy::new(GenesisWeights::from_file(&genesis.file), engine, clock, Some(home.join("epochs"))); - economy.background = !opts.inline_settlement; - economy.quadrature = if opts.quadrature == 0 { super::params::ATTR_NODES } else { opts.quadrature }; - economy.on_boundary(&state, 0, block0.id(), root0, rules.s0, None); - let mut chain = Chain { - genesis, - rules, - store, - state, - checkpoints, - coinbases: VecDeque::new(), - rejected: HashSet::new(), - rejected_order: VecDeque::new(), - vetoed: HashSet::new(), - heaviest: U256::ZERO, - finalized: 0, - events: Vec::new(), - economy, - }; - chain.replay()?; - chain.heaviest = chain.store.iter().filter(|(_, e)| e.status != store::Status::Invalid).map(|(_, e)| e.chainwork).max().unwrap_or(U256::ZERO); - chain.finalized = chain.store.tip_height().saturating_sub(FINALITY_DEPTH); - Ok(chain) - } - - /// Recovery: replay every main-chain body on the genesis state and - /// verify the stored roots before readiness (§3.6). - fn replay(&mut self) -> Result<(), ChainError> { - let tip = self.store.tip_height(); - for h in 1..=tip { - let id = self.store.main_at(h).ok_or_else(|| ChainError::Store(format!("main chain gap at {h}")))?; - let header = self.store.get(&id).map(|e| e.header).ok_or_else(|| ChainError::Store("missing header".into()))?; - let body = self.store.body(&id).ok_or_else(|| ChainError::Store(format!("missing body at {h}")))?; - let txs = apply::decode_body(&self.rules, &header, &body).map_err(ChainError::Block)?; - let locked = Locked::at(self.coinbases.make_contiguous(), h); - self.apply_on_tip(&header, &txs, &locked).map_err(ChainError::Block)?; - self.push_coinbase(h, &txs); - } - self.economy.prune(tip); - Ok(()) - } - - /// The settlement the block at `height` (on the main tip) must carry, - /// if one is due: the result reading the main chain's boundaries of - /// epochs E − 1 and E (chain-economy §2.5). - pub(super) fn settlement_for(&mut self, height: u64) -> Option> { - let e = self.rules.clock.settles_at(height)?; - let prev = self.store.main_at(self.rules.clock.boundary(e - 1))?; - let cur = self.store.main_at(self.rules.clock.boundary(e))?; - self.economy.result(&prev, &cur) - } - - /// Steps 7–8 for a block whose parent is the main tip, with the - /// economy's side effects: the settlement result in, the outcome kept, - /// the boundary snapshot taken and its settlement started. - fn apply_on_tip(&mut self, header: &Header, txs: &[Decoded], locked: &Locked) -> Result { - let settle = self.settlement_for(header.height); - let applied = apply::apply_block(&self.rules, header, txs, &mut self.state, locked, settle.as_deref())?; - let id = header.id(); - if let Some(outcome) = applied.settled { - self.economy.record_outcome(id, outcome); - } - let clock = self.rules.clock; - if clock.is_boundary(header.height) { - let epoch = clock.epoch(header.height); - let prev = self.store.main_at(clock.boundary(epoch - 1)); - let supply = self.rules.supply_at(header.height); - self.economy.on_boundary(&self.state, epoch, id, header.state_root, supply, prev); - } - Ok(applied.undo) - } - - pub fn rules(&self) -> &Rules { - &self.rules - } - - pub fn store(&self) -> &Store { - &self.store - } - - pub fn state(&self) -> &BbgBacked { - &self.state - } - - pub(super) fn entry(&self, id: &BlockId) -> Option<&store::Entry> { - self.store.get(id) - } - - pub(super) fn tip_id(&self) -> BlockId { - self.store.tip().expect("genesis is always present").1 - } - - fn push_coinbase(&mut self, height: u64, txs: &[Decoded]) { - if let Some(Body::Coinbase { to, amount, .. }) = txs.first().map(|d| &d.tx.body) { - self.coinbases.push_back((height, *to, *amount)); - } - while self.coinbases.front().is_some_and(|(h, _, _)| h.saturating_add(COINBASE_MATURITY) <= height.saturating_add(1)) { - self.coinbases.pop_front(); - } - } - - pub(super) fn locked_at(&mut self, height: u64) -> Locked { - Locked::at(self.coinbases.make_contiguous(), height) - } - - fn remember_rejected(&mut self, id: BlockId) { - if self.rejected.insert(id) { - self.rejected_order.push_back(id); - if self.rejected_order.len() > REJECTED_CAPACITY { - if let Some(old) = self.rejected_order.pop_front() { - self.rejected.remove(&old); - } - } - } - } - - pub(super) fn invalidate(&mut self, id: BlockId) { - let _ = self.store.set_status(&id, store::Status::Invalid); - self.remember_rejected(id); - } - - /// Invalid for contradicting a held checkpoint (see `vetoed`). - pub(super) fn veto(&mut self, id: BlockId) { - self.invalidate(id); - if self.vetoed.len() >= REJECTED_CAPACITY { - self.vetoed.clear(); - } - self.vetoed.insert(id); - } - - /// The error for a header that is, or descends from, a vetoed block. - pub(super) fn vetoed_error(&self, height: u64) -> ChainError { - let expected = self.checkpoints.held().filter(|(h, _)| **h <= height).last().map(|(_, id)| *id).unwrap_or([0; 32]); - ChainError::Checkpoint { height, expected } - } - - /// Non-invalid headers heavier than the tip, heaviest first, ties in - /// first-seen order (§5.1). - fn candidates(&self) -> Vec { - let tip_work = self.entry(&self.tip_id()).map(|e| e.chainwork).unwrap_or(U256::ZERO); - let mut v: Vec<(U256, u64, BlockId)> = self - .store - .iter() - .filter(|(_, e)| e.status != store::Status::Invalid && e.chainwork > tip_work) - .map(|(id, e)| (e.chainwork, e.seq, *id)) - .collect(); - v.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); - v.into_iter().map(|(_, _, id)| id).collect() - } - - /// Median time past of the parent and its ancestors (§1.4). - fn mtp(&self, parent: &BlockId) -> u64 { - let mut times = Vec::with_capacity(MTP_WINDOW); - let mut cur = *parent; - while times.len() < MTP_WINDOW { - let Some(e) = self.entry(&cur) else { break }; - times.push(e.header.time); - if e.header.height == 0 { - break; - } - cur = e.header.prev; - } - median_time_past(×) - } - - /// Steps 1–5 without storing. - pub(super) fn check_header(&self, header: &Header, now: u64) -> Result { - header.check_syntax(&self.rules.chain).map_err(ChainError::Header)?; - match rule_set_at(header.height) { - Some(rs) if rs == RULE_SET => {} - Some(_) | None => return Err(ChainError::RuleSetUnknown(header.height)), - } - if header.height == 0 { - return Err(ChainError::Genesis); - } - let parent = self.entry(&header.prev).ok_or(ChainError::UnknownParent(header.prev))?; - if parent.status == store::Status::Invalid { - if self.vetoed.contains(&header.prev) { - return Err(self.vetoed_error(header.height)); - } - return Err(ChainError::InvalidParent(header.prev)); - } - if header.height != parent.header.height + 1 { - return Err(ChainError::Height { expected: parent.header.height + 1, actual: header.height }); - } - let mtp = self.mtp(&header.prev); - if header.time <= mtp { - return Err(ChainError::Time { mtp, actual: header.time }); - } - if header.time > now.saturating_add(MAX_FUTURE_DRIFT) { - return Err(ChainError::FutureTime { time: header.time, now }); - } - let expected = next_bits(self.genesis.anchor(), &parent.header); - if header.bits != expected { - return Err(ChainError::Bits { expected, actual: header.bits }); - } - if !valid_pow(header) { - return Err(ChainError::Pow); - } - // §5.3: a chain not containing a held checkpoint is rejected — at the - // checkpointed height itself, whatever path the block arrives by - if let Some(expected) = self.checkpoints.get(header.height) { - if header.id() != expected { - return Err(ChainError::Checkpoint { height: header.height, expected }); - } - } - Ok(chainwork(parent.chainwork, header)) - } - - /// Apply a block whose parent is the tip (steps 7–8), then extend. - pub(super) fn extend(&mut self, header: &Header, txs: &[Decoded]) -> Result<(), ChainError> { - let id = header.id(); - let locked = self.locked_at(header.height); - let undo = match self.apply_on_tip(header, txs, &locked) { - Ok(undo) => undo, - // a settlement this node cannot compute is its own condition, - // never the block's fault (chain-economy §2.5: it waits) - Err(e @ apply::BlockError::Settlement { .. }) => return Err(ChainError::Block(e)), - Err(e) => { - self.invalidate(id); - return Err(ChainError::Block(e)); - } - }; - self.store.put_undo(&id, &undo).map_err(ChainError::Store)?; - self.store.set_status(&id, store::Status::Valid).map_err(ChainError::Store)?; - self.store.push_main(id).map_err(ChainError::Store)?; - self.push_coinbase(header.height, txs); - Ok(()) - } - - /// Undo the tip block (its journal is in the store). - fn retract(&mut self) -> Result { - let (height, id) = self.store.tip().ok_or(ChainError::Store("empty chain".into()))?; - if height == 0 { - return Err(ChainError::Store("cannot retract genesis".into())); - } - let undo = self.store.undo(&id).ok_or(ChainError::Store("missing undo".into()))?; - self.state.revert(&undo); - self.store.pop_main().map_err(ChainError::Store)?; - self.rebuild_coinbases(); - Ok(id) - } - - fn rebuild_coinbases(&mut self) { - self.coinbases.clear(); - let tip = self.store.tip_height(); - let from = tip.saturating_sub(COINBASE_MATURITY).max(1); - for h in from..=tip { - let Some(id) = self.store.main_at(h) else { continue }; - let Some(body) = self.store.body(&id) else { continue }; - if let Ok(raws) = super::tx::split_body(&body) { - if let Some(Ok(tx)) = raws.first().map(|r| super::tx::Tx::decode(r)) { - if let Body::Coinbase { to, amount, .. } = tx.body { - self.coinbases.push_back((h, to, amount)); - } - } - } - } - } - - fn decoded(&self, id: &BlockId) -> Result<(Header, Vec), ChainError> { - let header = self.entry(id).map(|e| e.header).ok_or(ChainError::Store("missing header".into()))?; - let body = self.store.body(id).ok_or(ChainError::Store("missing body".into()))?; - let txs = apply::decode_body(&self.rules, &header, &body).map_err(ChainError::Block)?; - Ok((header, txs)) - } - - /// The path from the fork point (exclusive) to `id` (inclusive), oldest - /// first, and the fork height. - fn path_from_main(&self, id: &BlockId) -> Result<(u64, Vec), ChainError> { - let mut path = Vec::new(); - let mut cur = *id; - loop { - if self.store.on_main(&cur) { - let h = self.entry(&cur).map(|e| e.header.height).unwrap_or(0); - path.reverse(); - return Ok((h, path)); - } - let e = self.entry(&cur).ok_or(ChainError::Store("broken chain".into()))?; - path.push(cur); - cur = e.header.prev; - } - } - - /// Fork choice after a header or body arrives: move to the heaviest - /// chain whose bodies are all present (§5.1–§5.3), or say what is - /// missing. A heavier chain the reorg limit or a checkpoint forbids is - /// reported and skipped in favour of the next candidate. - pub fn try_advance(&mut self) -> Result { - let tip = self.tip_id(); - let tip_work = self.entry(&tip).map(|e| e.chainwork).unwrap_or(U256::ZERO); - if !select::heavier(self.heaviest, tip_work) { - return Ok(BlockVerdict::Known); - } - let mut first_error = None; - for best in self.candidates() { - let (fork_height, path) = self.path_from_main(&best)?; - let tip_height = self.store.tip_height(); - if !select::reorg_allowed(tip_height, fork_height) { - first_error.get_or_insert(ChainError::DeepFork { fork_height, tip_height }); - continue; - } - if let Some(floor) = self.checkpoints.floor() { - // reorganizing below the floor is what a checkpoint forbids; - // a tip still under it moves up through it (a chain that - // excludes it is caught by `violation` below) - if fork_height < floor && tip_height >= floor { - let expected = self.checkpoints.get(floor).unwrap_or([0; 32]); - first_error.get_or_insert(ChainError::Checkpoint { height: floor, expected }); - continue; - } - } - if let Some((height, expected)) = self.checkpoints.violation(|h| self.store.ancestor_at(&best, h)) { - // the chain excludes a held checkpoint: not a candidate, nor its descendants - self.veto(best); - first_error.get_or_insert(ChainError::Checkpoint { height, expected }); - continue; - } - let missing: Vec = path.iter().filter(|id| !self.store.has_body(id)).copied().collect(); - if !missing.is_empty() { - return Ok(BlockVerdict::NeedBodies(missing)); - } - return self.reorg(fork_height, &path); - } - match first_error { - Some(e) => Err(e), - None => Ok(BlockVerdict::Stored), - } - } - - /// The heaviest non-invalid header whose chain the node may still move - /// to — the target of header-first body download (chain-wire §3.2) — - /// or the tip when there is none. A heavier chain that the reorg limit - /// or a checkpoint forbids is skipped: its bodies can never be applied, - /// and following it would starve the bodies of the best chain the node - /// can take (a released private fork deeper than `MAX_REORG` otherwise - /// strands every honest node that sits on a stale branch). - pub fn best_reachable(&self) -> (u64, BlockId, U256) { - let tip = self.tip_id(); - let (tip_height, tip_work) = self.entry(&tip).map(|e| (e.header.height, e.chainwork)).unwrap_or((0, U256::ZERO)); - if !select::heavier(self.heaviest, tip_work) { - return (tip_height, tip, tip_work); - } - for id in self.candidates() { - if !self.own_chain_blocked(&id) { - if let Some(e) = self.entry(&id) { - return (e.header.height, id, e.chainwork); - } - } - } - (tip_height, tip, tip_work) - } - - /// A held checkpoint contradicts our main chain at `height` (chain.md - /// §5.3: a chain not containing it is rejected — our own too): retract - /// below `height`, mark the retracted blocks invalid, and move to the - /// heaviest chain left (the checkpointed one when its blocks are known; - /// sync fetches them otherwise). Bounded by the reorg limit like any - /// reorganization: deeper than `MAX_REORG`, the node logs and keeps its - /// chain — the operator resyncs (§5.2). - pub(super) fn reject_main_from(&mut self, height: u64, ours: BlockId) -> Result<(), ChainError> { - let tip_height = self.store.tip_height(); - let fork = height.saturating_sub(1); - if !select::reorg_allowed(tip_height, fork) { - eprintln!("chain: checkpoint at {height} contradicts the main chain {} blocks deep, beyond MAX_REORG: resync from a checkpoint", tip_height - fork); - return Ok(()); - } - self.veto(ours); - while self.store.tip_height() > fork { - let id = self.retract()?; - self.veto(id); - } - self.heaviest = self.store.iter().filter(|(_, e)| e.status != store::Status::Invalid).map(|(_, e)| e.chainwork).max().unwrap_or(U256::ZERO); - self.after_tip_move()?; - let _ = self.try_advance(); - Ok(()) - } - - /// Does the reorg limit or a checkpoint floor forbid switching to `id`? - pub(super) fn own_chain_blocked(&self, id: &BlockId) -> bool { - let Ok((fork_height, _)) = self.path_from_main(id) else { return false }; - let tip_height = self.store.tip_height(); - let deep = !select::reorg_allowed(tip_height, fork_height); - let below_floor = self.checkpoints.floor().is_some_and(|f| fork_height < f && tip_height >= f); - let vetoed = self.entry(id).is_some_and(|e| e.status == store::Status::Invalid); - deep || below_floor || vetoed - } - - fn reorg(&mut self, fork_height: u64, path: &[BlockId]) -> Result { - let old_tip = self.tip_id(); - let depth = self.store.tip_height() - fork_height; - let mut retracted = Vec::new(); - while self.store.tip_height() > fork_height { - retracted.push(self.retract()?); - } - let mut applied = 0usize; - let mut failure = None; - for id in path { - let (header, txs) = match self.decoded(id) { - Ok(d) => d, - Err(e) => { - self.invalidate(*id); - failure = Some(e); - break; - } - }; - if let Err(e) = self.extend(&header, &txs) { - failure = Some(e); - break; - } - applied += 1; - } - if let Some(e) = failure { - // every descendant of the failed block is invalid too - for id in &path[applied + 1..] { - self.invalidate(*id); - } - for _ in 0..applied { - self.retract()?; - } - for id in retracted.iter().rev() { - let (header, txs) = self.decoded(id)?; - self.extend(&header, &txs)?; - } - self.store.persist().map_err(ChainError::Store)?; - return Err(e); - } - self.after_tip_move()?; - Ok(BlockVerdict::Reorganized { old_tip, depth }) - } - - pub(super) fn after_tip_move(&mut self) -> Result<(), ChainError> { - self.store.persist().map_err(ChainError::Store)?; - self.economy.prune(self.store.tip_height()); - let new_final = self.store.tip_height().saturating_sub(FINALITY_DEPTH); - while self.finalized < new_final { - let h = self.finalized + 1; - if let Some(id) = self.store.main_at(h) { - if let (Some(e), Some(body)) = (self.entry(&id), self.store.body(&id)) { - self.events.push(Event::Block { header: e.header, body, state_root: e.header.state_root }); - } - } - self.finalized = h; - } - Ok(()) - } -} - -impl Chain { - pub(super) fn state_mut(&mut self) -> &mut BbgBacked { - &mut self.state - } - - pub(super) fn mtp_of_tip(&self) -> u64 { - self.mtp(&self.tip_id()) - } - - pub(super) fn tip_header(&self) -> Header { - let id = self.tip_id(); - self.entry(&id).expect("tip stored").header - } - - pub(super) fn genesis_anchor(&self) -> super::daa::Anchor { - self.genesis.anchor() - } -} diff --git a/crate/src/chain/engine_api.rs b/crate/src/chain/engine_api.rs deleted file mode 100644 index 598359fa..00000000 --- a/crate/src/chain/engine_api.rs +++ /dev/null @@ -1,187 +0,0 @@ -//! `impl ChainApi for Chain`: the reads, the accept entry points and the -//! checkpoint/mempool/template delegations, over the engine's internals. - -use super::api::{Block, BlockVerdict, ChainApi, ChainError, Event, HeaderVerdict, Status, Template, Tip, TxInfo}; -use super::apply::{self, TxError}; -use super::daa::next_bits; -use super::engine::Chain; -use super::genesis::Genesis; -use super::header::{BlockId, Header}; -use super::params::RULE_SET; -use super::select::{self, Checkpoint}; -use super::state::{Id, StateBackend}; -use super::store; -use super::tx::Tx; -use super::u256::U256; - -impl ChainApi for Chain { - fn chain_id(&self) -> Id { - self.rules.chain - } - - fn genesis(&self) -> &Genesis { - &self.genesis - } - - fn tip(&self) -> Tip { - let (height, id) = self.store.tip().expect("genesis present"); - let e = self.entry(&id).expect("tip stored"); - Tip { height, id, chainwork: e.chainwork, header: e.header } - } - - fn status(&self) -> Status { - let tip = self.tip(); - let native = self.rules.native; - let conserved = apply::check_conservation(&self.rules, &self.state, tip.height).is_ok(); - Status { - chain: self.rules.chain, - name: self.genesis.params().name.clone(), - rule_set: RULE_SET, - height: tip.height, - tip: tip.id, - chainwork: tip.chainwork, - bits: tip.header.bits, - next_bits: next_bits(self.genesis.anchor(), &tip.header), - genesis_block: self.store.main_at(0).unwrap_or([0; 32]), - genesis_time: self.genesis.params().time, - state_root: tip.header.state_root, - supply: self.state.supply(&native), - conserved, - subsidy: self.rules.subsidy, - neurons: self.state.neuron_count(), - particles: self.state.particle_count(), - axons: self.state.axon_count(), - checkpoint: self.checkpoints.floor(), - finalized: self.finalized, - } - } - - fn header(&self, id: &BlockId) -> Option
{ - self.entry(id).map(|e| e.header) - } - - fn header_at(&self, height: u64) -> Option
{ - self.store.main_at(height).and_then(|id| self.header(&id)) - } - - fn headers(&self, from: u64, count: usize) -> Vec
{ - self.store.main_range(from, count).iter().filter_map(|id| self.header(id)).collect() - } - - fn block(&self, id: &BlockId) -> Option { - let header = self.header(id)?; - let body = if header.height == 0 { Vec::new() } else { self.store.body(id)? }; - Some(Block { header, body }) - } - - fn blocks(&self, from: u64, count: usize) -> Vec { - self.store.main_range(from, count).iter().filter_map(|id| self.block(id)).collect() - } - - fn chainwork(&self, id: &BlockId) -> Option { - self.entry(id).map(|e| e.chainwork) - } - - fn locator(&self) -> Vec { - select::locator_heights(self.store.tip_height()).iter().filter_map(|h| self.store.main_at(*h)).collect() - } - - fn ancestor_at(&self, id: &BlockId, height: u64) -> Option { - self.store.ancestor_at(id, height) - } - - fn balance(&self, owner: &Id, token: &Id) -> u64 { - self.state.balance(owner, token) - } - - fn nonce(&self, neuron: &Id) -> u64 { - self.state.nonce(neuron) - } - - fn supply(&self, token: &Id) -> u64 { - self.state.supply(token) - } - - fn finalized_height(&self) -> u64 { - self.finalized - } - - fn accept_header(&mut self, header: &Header, now: u64) -> Result { - let id = header.id(); - if self.vetoed.contains(&id) { - return Err(self.vetoed_error(header.height)); - } - if self.rejected.contains(&id) { - return Err(ChainError::Rejected(id)); - } - if let Some(e) = self.entry(&id) { - return if e.status == store::Status::Invalid { Err(ChainError::Rejected(id)) } else { Ok(HeaderVerdict::Known) }; - } - let work = self.check_header(header, now)?; - self.store.put_header(*header, work, store::Status::Header).map_err(ChainError::Store)?; - self.heaviest = self.heaviest.max(work); - Ok(HeaderVerdict::Accepted) - } - - fn accept_block(&mut self, block: &Block, now: u64) -> Result { - let id = block.id(); - self.accept_header(&block.header, now)?; - if self.entry(&id).is_some_and(|e| e.status == store::Status::Valid) { - return self.try_advance().map(|v| if v == BlockVerdict::Stored { BlockVerdict::Known } else { v }); - } - let mut stored_new = false; - if !self.store.has_body(&id) { - stored_new = true; - let txs = match apply::decode_body(&self.rules, &block.header, &block.body) { - Ok(txs) => txs, - Err(e) => { - self.invalidate(id); - return Err(ChainError::Block(e)); - } - }; - self.store.put_body(&id, &block.body).map_err(ChainError::Store)?; - self.store.set_status(&id, store::Status::Body).map_err(ChainError::Store)?; - if block.header.prev == self.tip_id() { - self.extend(&block.header, &txs)?; - self.after_tip_move()?; - let _ = self.try_advance(); - return Ok(BlockVerdict::Extended); - } - } - match self.try_advance() { - Ok(BlockVerdict::Known) if stored_new => Ok(BlockVerdict::Stored), - Ok(v) => Ok(v), - // a blocked candidate elsewhere is not this block's fault: only - // report the error when this block's own chain is the blocked one - Err(e) if self.own_chain_blocked(&id) => Err(e), - Err(_) => Ok(BlockVerdict::Stored), - } - } - - fn accept_checkpoint(&mut self, cp: &Checkpoint) -> Result { - let new = self.checkpoints.accept(&self.rules.chain, cp).map_err(ChainError::CheckpointRejected)?; - if new { - if let Some(ours) = self.store.main_at(cp.height).filter(|id| *id != cp.block_id) { - self.reject_main_from(cp.height, ours)?; - } - } - Ok(new) - } - - fn checkpoints(&self) -> Vec<(u64, BlockId)> { - self.checkpoints.held().map(|(h, id)| (*h, *id)).collect() - } - - fn validate_tx(&mut self, tx_bytes: &[u8], pending: &[Tx]) -> Result { - super::engine_tx::validate_tx(self, tx_bytes, pending) - } - - fn header_template(&mut self, to: Id, time: u64, mempool: &[Vec]) -> Result { - super::engine_tx::header_template(self, to, time, mempool) - } - - fn take_events(&mut self) -> Vec { - std::mem::take(&mut self.events) - } -} - diff --git a/crate/src/chain/engine_tx.rs b/crate/src/chain/engine_tx.rs deleted file mode 100644 index df2ccd05..00000000 --- a/crate/src/chain/engine_tx.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! Mempool admission and block templates on the tip state (chain-wire §4, -//! §7 `/template`). Both run scratch transitions under the state journal -//! and revert, so the chain is never mutated by a query. - -use std::collections::BTreeMap; - -use super::api::{ChainApi, ChainError, Template, TxInfo}; -use super::apply::{self, Decoded, TxError}; -use super::engine::Chain; -use super::header::Header; -use super::params::{HEADER_BYTES, MAX_BLOCK_BYTES, MAX_BLOCK_TXS, MAX_TX_BYTES, NONCE_WINDOW}; -use super::sigs::{subject, txid}; -use super::state::{Id, StateBackend}; -use super::tree::tx_root; -use super::tx::{self, Body, Tx}; - -/// Admission on the tip state assuming the `pending` txs of the same -/// account with lower nonces apply first; nonce must be within -/// `[current, current + NONCE_WINDOW)`; balances and maturity as in a block. -pub fn validate_tx(chain: &mut Chain, tx_bytes: &[u8], pending: &[Tx]) -> Result { - if tx_bytes.len() > MAX_TX_BYTES { - return Err(TxError::TooLarge(tx_bytes.len())); - } - let tx = Tx::decode(tx_bytes).map_err(TxError::Decode)?; - if tx.is_coinbase() { - return Err(TxError::CoinbaseInBody); - } - let account = subject(&tx).ok_or(TxError::Signature)?; - // chain-wire §4 admission order: the signature before nonce, fee and - // balance (a block checks all of them; a mempool scores the peer by - // which one failed first) - if !super::sigs::verify(&chain.rules().chain, &tx) { - return Err(TxError::Signature); - } - let nonce = tx.nonce().ok_or(TxError::CoinbaseInBody)?; - let current = chain.nonce(&account); - if nonce < current || nonce >= current.saturating_add(NONCE_WINDOW) { - return Err(TxError::Nonce { expected: current, actual: nonce }); - } - let height = chain.tip().height + 1; - let locked = chain.locked_at(height); - let rules = chain.rules().clone(); - let mut earlier: Vec<&Tx> = pending.iter().filter(|p| subject(p) == Some(account) && p.nonce().is_some_and(|n| n < nonce)).collect(); - earlier.sort_by_key(|p| p.nonce()); - let state = chain.state_mut(); - state.begin(); - let result = (|| { - for p in earlier { - apply::execute_tx(&rules, p, p.encode().len(), state, &locked, height)?; - } - apply::execute_tx(&rules, &tx, tx_bytes.len(), state, &locked, height) - })(); - let undo = state.commit(); - state.revert(&undo); - let fee = result?; - Ok(TxInfo { txid: txid(&rules.chain, tx_bytes), tx, size: tx_bytes.len(), fee, account, nonce }) -} - -/// One account's transactions in nonce order, each with the same checks as -/// [`validate_tx`] and its predecessors in the list applied first — in one -/// scratch pass, so a run of k transactions executes k times, not -/// k(k+1)/2 (chain-wire §4: re-validation after every tip re-admits the -/// whole pool; with full 16-nonce windows the per-transaction replay made -/// it seconds a block, simulation audit/network-2026-10.md `flood`). After -/// the first failure every later entry repeats that error: its -/// predecessor is missing. -pub fn validate_run(chain: &mut Chain, txs: &[Vec]) -> Vec> { - let height = chain.tip().height + 1; - let locked = chain.locked_at(height); - let rules = chain.rules().clone(); - // the checks that do not need the scratch state, against the tip - let checked: Vec> = txs - .iter() - .map(|raw| { - if raw.len() > MAX_TX_BYTES { - return Err(TxError::TooLarge(raw.len())); - } - let tx = Tx::decode(raw).map_err(TxError::Decode)?; - if tx.is_coinbase() { - return Err(TxError::CoinbaseInBody); - } - let account = subject(&tx).ok_or(TxError::Signature)?; - if !super::sigs::verify(&rules.chain, &tx) { - return Err(TxError::Signature); - } - let nonce = tx.nonce().ok_or(TxError::CoinbaseInBody)?; - let current = chain.nonce(&account); - if nonce < current || nonce >= current.saturating_add(NONCE_WINDOW) { - return Err(TxError::Nonce { expected: current, actual: nonce }); - } - Ok((tx, account, nonce)) - }) - .collect(); - let state = chain.state_mut(); - state.begin(); - let mut out: Vec> = Vec::with_capacity(txs.len()); - let mut failure: Option = None; - for (raw, c) in txs.iter().zip(checked) { - if let Some(e) = &failure { - out.push(Err(e.clone())); - continue; - } - let r = c.and_then(|(tx, account, nonce)| { - let fee = apply::execute_tx(&rules, &tx, raw.len(), state, &locked, height)?; - Ok(TxInfo { txid: txid(&rules.chain, raw), tx, size: raw.len(), fee, account, nonce }) - }); - if let Err(e) = &r { - failure = Some(e.clone()); - } - out.push(r); - } - let undo = state.commit(); - state.revert(&undo); - out -} - -/// Fee per byte ordering: `a` before `b` when `fee_a / len_a > fee_b / len_b`, -/// compared as cross products (no division, no floats). -fn fee_rate_desc(a: &(Vec, Tx), b: &(Vec, Tx)) -> std::cmp::Ordering { - let (fa, la) = (a.1.fee() as u128, a.0.len() as u128); - let (fb, lb) = (b.1.fee() as u128, b.0.len() as u128); - (fb * la).cmp(&(fa * lb)) -} - -/// Build a template on the tip: transactions by fee per byte descending -/// with per-account nonce order kept and the §1.6 budgets, then the -/// coinbase from `SUBSIDY − cut + Σ fees`, the tx root and the state root. -pub fn header_template(chain: &mut Chain, to: Id, time: u64, mempool: &[Vec]) -> Result { - let rules = chain.rules().clone(); - let parent = chain.tip_header(); - let height = parent.height + 1; - let locked = chain.locked_at(height); - let mtp = chain.mtp_of_tip(); - let time = time.max(mtp + 1); - - let mut candidates: Vec<(Vec, Tx)> = mempool - .iter() - .filter(|raw| raw.len() <= MAX_TX_BYTES) - .filter_map(|raw| Tx::decode(raw).ok().filter(|t| !t.is_coinbase()).map(|t| (raw.clone(), t))) - .collect(); - candidates.sort_by(fee_rate_desc); - - // selection runs on the same state the block will see: coinbase first - // (its amount is unknown yet, so credit nothing — only the mint), then - // transactions in the order chosen, each kept only if it executes - let state = chain.state_mut(); - state.begin(); - let mut chosen: Vec<(Vec, Tx)> = Vec::new(); - let mut bytes = HEADER_BYTES + 4 + tx::COINBASE_BYTES; - let mut fees = 0u64; - let mut next_nonce: BTreeMap = BTreeMap::new(); - let mut done = vec![false; candidates.len()]; - let mut progress = true; - while progress && chosen.len() + 1 < MAX_BLOCK_TXS { - progress = false; - for (i, (raw, tx)) in candidates.iter().enumerate() { - if done[i] || chosen.len() + 1 >= MAX_BLOCK_TXS { - continue; - } - let Some(account) = subject(tx) else { - done[i] = true; - continue; - }; - let expected = *next_nonce.entry(account).or_insert_with(|| state.nonce(&account)); - let Some(nonce) = tx.nonce() else { - done[i] = true; - continue; - }; - if nonce < expected { - done[i] = true; - continue; - } - if nonce > expected { - continue; // wait for the lower nonce - } - if bytes + raw.len() > MAX_BLOCK_BYTES { - done[i] = true; - continue; - } - match apply::execute_tx(&rules, tx, raw.len(), state, &locked, height) { - Ok(fee) => { - fees = fees.saturating_add(fee); - bytes += raw.len(); - next_nonce.insert(account, expected + 1); - chosen.push((raw.clone(), tx.clone())); - progress = true; - } - Err(_) => {} - } - done[i] = true; - } - } - let undo = state.commit(); - state.revert(&undo); - - // the miner's share of the subsidy: SUBSIDY − cut (chain-economy §6.2) - let (miner, _cut) = apply::subsidy_split(&rules, chain.state_mut()); - let coinbase = Tx { - body: Body::Coinbase { height, to, amount: miner.saturating_add(fees), extra: [0; 32] }, - sig: Vec::new(), - }; - let mut raws: Vec> = vec![coinbase.encode()]; - raws.extend(chosen.iter().map(|(raw, _)| raw.clone())); - let txs: Vec = raws - .iter() - .map(|raw| Decoded { raw: raw.clone(), tx: Tx::decode(raw).expect("re-encoded"), txid: txid(&rules.chain, raw) }) - .collect(); - let settle = chain.settlement_for(height); - let applied = apply::transition(&rules, height, &txs, chain.state_mut(), &locked, settle.as_deref()).map_err(ChainError::Block)?; - let state_root = applied.root; - chain.state_mut().revert(&applied.undo); - - let bits = super::daa::next_bits(chain.genesis_anchor(), &parent); - let header = Header { - version: 1, - height, - time, - bits, - nonce: [0; 16], - prev: parent.id(), - tx_root: tx_root(&raws), - state_root, - chain: rules.chain, - }; - Ok(Template { header, target: super::header::decode_bits(bits).expect("ASERT output is canonical"), body: tx::join_body(&raws), txids: txs.iter().map(|d| d.txid).collect(), fees }) -} diff --git a/crate/src/chain/genesis.rs b/crate/src/chain/genesis.rs deleted file mode 100644 index 0aee1d6e..00000000 --- a/crate/src/chain/genesis.rs +++ /dev/null @@ -1,223 +0,0 @@ -//! Genesis on the consensus side (§4): the file is `soft3::genesis`'s tade -//! stream (`GenesisFile`, `specs/genesis.md`), applied through -//! `soft3::genesis::state::load`; this module adds what the chain needs — -//! the chain id (= file particle), `S0`, the ASERT anchor, the state -//! backend with its conservation sums, and block 0 (§4.3). - -use crate::genesis::file::GenesisFile; -use crate::genesis::state::load; - -use super::bbg_state::BbgBacked; -use super::daa::Anchor; -use super::header::{Header, decode_bits, hash32}; -use super::params::subsidy; -use super::sigs::token; -use super::state::Id; - -pub use crate::genesis::file::{Balance, Link, Params}; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Genesis { - pub file: GenesisFile, - /// the file bytes; `hemera(bytes)` is the chain id - pub bytes: Vec, - pub chain_id: Id, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum GenesisError { - /// the tade stream is not a canonical genesis (`soft3::genesis::file`) - File(String), - /// `params.bits` is not a canonical target within limits - Bits, - /// the native supply does not fit u64 - Supply, - /// `params.subsidy != floor(S0 / 105,120,000)` - Subsidy { written: u64, derived: u64 }, -} - -impl std::fmt::Display for GenesisError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - GenesisError::File(e) => write!(f, "genesis file: {e}"), - GenesisError::Bits => write!(f, "genesis bits are not a canonical target"), - GenesisError::Supply => write!(f, "native supply exceeds u64"), - GenesisError::Subsidy { written, derived } => write!(f, "subsidy {written} written, {derived} derived from S0"), - } - } -} - -impl Genesis { - /// Decode and check a genesis file; the chain id is its particle. - pub fn decode(bytes: &[u8]) -> Result { - let file = GenesisFile::decode(bytes).map_err(|e| GenesisError::File(e.to_string()))?; - decode_bits(file.params.bits).map_err(|_| GenesisError::Bits)?; - let s0 = u64::try_from(file.native_supply()).map_err(|_| GenesisError::Supply)?; - let derived = subsidy(s0); - if file.params.subsidy != derived { - return Err(GenesisError::Subsidy { written: file.params.subsidy, derived }); - } - Ok(Genesis { file, bytes: bytes.to_vec(), chain_id: hash32(bytes) }) - } - - /// Canonical bytes of a file, then decode (test nets, rehearsals). - pub fn from_file(file: &GenesisFile) -> Result { - let bytes = file.encode().map_err(|e| GenesisError::File(e.to_string()))?; - Genesis::decode(&bytes) - } - - pub fn params(&self) -> &Params { - &self.file.params - } - - pub fn native_token(&self) -> Id { - token(&self.file.params.denom) - } - - /// `S0`: the genesis supply of the native coin. - pub fn s0(&self) -> u64 { - u64::try_from(self.file.native_supply()).expect("checked on decode") - } - - pub fn subsidy(&self) -> u64 { - self.file.params.subsidy - } - - /// The ASERT anchor (§1.5). - pub fn anchor(&self) -> Anchor { - Anchor { target: decode_bits(self.file.params.bits).expect("checked on decode"), time: self.file.params.time } - } - - /// The state after genesis (§4.1), with the per-token sums the - /// conservation check needs, through the pipeline's own loader. - pub fn state(&self) -> BbgBacked { - let bbg = load(&self.file); - let sums = self.file.balances.iter().map(|b| (b.token, b.amount)); - BbgBacked::from_bbg(bbg, sums) - } - - /// Block 0 (§4.3): `bits = GENESIS_BITS` (the params field, which the - /// pipeline writes as `GENESIS_BITS`; a test net may anchor elsewhere). - pub fn block0(&self, state_root: Id) -> Header { - Header { - version: 1, - height: 0, - time: self.file.params.time, - bits: self.file.params.bits, - nonce: [0; 16], - prev: [0; 32], - tx_root: self.chain_id, - state_root, - chain: self.chain_id, - } - } -} - -#[cfg(test)] -pub(crate) mod tests { - use super::*; - use super::super::params::GENESIS_BITS; - use super::super::state::StateBackend; - - /// The conformance net of chain-conformance §8: one balance, no links. - pub(crate) fn conformance_params() -> Params { - Params { - version: 1, - name: "conformance".into(), - hrp: "pussy".into(), - denom: "pussy".into(), - time: 0, - bits: GENESIS_BITS, - subsidy: 0, - checkpoint_key: [0; 33], - checkpoint_sunset: 0, - pool: crate::chain::pool::Pool::genesis(), - } - } - - pub(crate) fn conformance_genesis() -> Genesis { - Genesis::from_file(&GenesisFile { - params: conformance_params(), - balances: vec![Balance { owner: [0x66; 32], token: token("pussy"), amount: 1_000_000 }], - links: vec![], - }) - .unwrap() - } - - #[test] - fn conformance_net_applies_through_the_pipeline_loader() { - let g = conformance_genesis(); - assert_eq!(g.s0(), 1_000_000); - assert_eq!(g.subsidy(), 0, "1,000,000 / 105,120,000 floors to 0"); - assert_eq!(g.chain_id, hash32(&g.bytes)); - let mut s = g.state(); - assert_eq!(s.balance(&[0x66; 32], &token("pussy")), 1_000_000); - assert_eq!(s.supply(&token("pussy")), 1_000_000); - assert_eq!(s.token_sum(&token("pussy")), 1_000_000); - assert_eq!(s.nonce(&[0x66; 32]), 0); - assert!(s.has_neuron(&[0x66; 32])); - assert_eq!(s.height(), 0); - let b0 = g.block0(s.root()); - assert_eq!(b0.tx_root, g.chain_id); - assert_eq!(b0.encode(), crate::genesis::file::block0_header(0, &b0.state_root, &g.chain_id)); - assert_eq!(Genesis::decode(&g.bytes).unwrap(), g); - } - - #[test] - fn subsidy_and_bits_are_checked() { - let mut p = conformance_params(); - p.subsidy = 1; - let file = GenesisFile { params: p.clone(), balances: vec![], links: vec![] }; - assert_eq!(Genesis::from_file(&file), Err(GenesisError::Subsidy { written: 1, derived: 0 })); - p.subsidy = 0; - p.bits = 0x1e00_1000; - let file = GenesisFile { params: p, balances: vec![], links: vec![] }; - assert_eq!(Genesis::from_file(&file), Err(GenesisError::Bits)); - assert!(matches!(Genesis::decode(b"not a genesis"), Err(GenesisError::File(_)))); - } - - #[test] - fn links_apply_with_zero_amount_and_authors_get_records() { - let links = vec![ - Link { neuron: [1; 32], p: [2; 32], q: [3; 32], height: 5 }, - Link { neuron: [1; 32], p: [2; 32], q: [3; 32], height: 6 }, - ]; - let g = Genesis::from_file(&GenesisFile { - params: conformance_params(), - balances: vec![Balance { owner: [0x66; 32], token: token("pussy"), amount: 7 }], - links: links.clone(), - }) - .unwrap(); - assert_eq!(g.file.links, links, "duplicates kept"); - let s = g.state(); - assert!(s.has_neuron(&[1; 32])); - assert_eq!(s.axon_count(), 1); - assert_eq!(s.token_sum(&token("pussy")), 7); - } - - /// chain-conformance §10 against the real pussy file when it is cached - /// (`soft3 genesis build` writes ~/.cache/cyber/genesis/pussy.tade); - /// the release gate `stack-chain-genesis` runs the same comparison. - #[test] - fn pussy_genesis_matches_the_blessed_vectors_when_cached() { - let home = std::env::var("HOME").unwrap_or_default(); - let path = std::path::Path::new(&home).join(".cache/cyber/genesis/pussy.tade"); - let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skipped: {} not cached", path.display()); - return; - }; - let g = Genesis::decode(&bytes).unwrap(); - let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::(); - assert_eq!(hex(&g.chain_id), "bae85f40d9f508664b5214107551be95d3f188d9585a899178c23e80886720e5"); - assert_eq!(g.s0(), 1_099_389_810_279_114_036); - assert_eq!(g.subsidy(), 10_458_426_657); - assert_eq!(g.params().time, 1_793_885_820); - let mut s = g.state(); - let root = s.root(); - assert_eq!(hex(&root), "d48fee478584d248123c63e2ee6c56224f159c7dadbc625f4261f6af49130371"); - assert_eq!(hex(&g.block0(root).id()), "73208ee2d43fbb0420119ceff23eab79e9d96921050e2a2137d0705c461852d4"); - for t in s.tokens() { - assert_eq!(s.token_sum(&t), s.supply(&t)); - } - } -} diff --git a/crate/src/chain/header.rs b/crate/src/chain/header.rs deleted file mode 100644 index 86a39530..00000000 --- a/crate/src/chain/header.rs +++ /dev/null @@ -1,265 +0,0 @@ -//! The 168-byte header (§1.1), its id, and the canonical compact target (§1.3). - -use super::params::{HEADER_BYTES, RULE_SET, pow_limit}; -use super::u256::U256; - -pub type BlockId = [u8; 32]; - -/// One header, exactly as on the wire: little-endian, no varints (§1.1). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Header { - pub version: u32, - pub height: u64, - pub time: u64, - pub bits: u32, - pub nonce: [u8; 16], - pub prev: BlockId, - pub tx_root: [u8; 32], - pub state_root: [u8; 32], - pub chain: [u8; 32], -} - -/// Why a header fails syntax (§5.5 step 1). `WrongChain` is distinct from -/// malformed so the wire can score it differently (chain-wire §5). -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum HeaderError { - Length(usize), - RuleSet(u16), - WrongChain, - Bits(BitsError), -} - -impl std::fmt::Display for HeaderError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - HeaderError::Length(n) => write!(f, "header is {n} bytes, expected {HEADER_BYTES}"), - HeaderError::RuleSet(r) => write!(f, "header claims rule set {r}"), - HeaderError::WrongChain => write!(f, "header belongs to another chain"), - HeaderError::Bits(e) => write!(f, "bits: {e}"), - } - } -} - -impl Header { - pub fn encode(&self) -> [u8; HEADER_BYTES] { - let mut out = [0u8; HEADER_BYTES]; - out[0..4].copy_from_slice(&self.version.to_le_bytes()); - out[4..12].copy_from_slice(&self.height.to_le_bytes()); - out[12..20].copy_from_slice(&self.time.to_le_bytes()); - out[20..24].copy_from_slice(&self.bits.to_le_bytes()); - out[24..40].copy_from_slice(&self.nonce); - out[40..72].copy_from_slice(&self.prev); - out[72..104].copy_from_slice(&self.tx_root); - out[104..136].copy_from_slice(&self.state_root); - out[136..168].copy_from_slice(&self.chain); - out - } - - /// Decode without any rule check (the store reads its own bytes this way). - pub fn decode_raw(b: &[u8]) -> Result { - if b.len() != HEADER_BYTES { - return Err(HeaderError::Length(b.len())); - } - let u32_at = |o: usize| u32::from_le_bytes(b[o..o + 4].try_into().expect("4 bytes")); - let u64_at = |o: usize| u64::from_le_bytes(b[o..o + 8].try_into().expect("8 bytes")); - let arr32 = |o: usize| -> [u8; 32] { b[o..o + 32].try_into().expect("32 bytes") }; - Ok(Header { - version: u32_at(0), - height: u64_at(4), - time: u64_at(12), - bits: u32_at(20), - nonce: b[24..40].try_into().expect("16 bytes"), - prev: arr32(40), - tx_root: arr32(72), - state_root: arr32(104), - chain: arr32(136), - }) - } - - /// Decode and run §5.5 step 1: length, rule set, chain, canonical bits. - pub fn decode(b: &[u8], chain: &[u8; 32]) -> Result { - let h = Header::decode_raw(b)?; - h.check_syntax(chain)?; - Ok(h) - } - - /// §5.5 step 1 on a decoded header. - pub fn check_syntax(&self, chain: &[u8; 32]) -> Result<(), HeaderError> { - if self.rule_set() != RULE_SET { - return Err(HeaderError::RuleSet(self.rule_set())); - } - if &self.chain != chain { - return Err(HeaderError::WrongChain); - } - decode_bits(self.bits).map_err(HeaderError::Bits)?; - Ok(()) - } - - /// `version & 0xffff` (§7.1). - pub fn rule_set(&self) -> u16 { - (self.version & 0xffff) as u16 - } - - /// `version >> 16`: miner signals, informational (§7.1). - pub fn signals(&self) -> u16 { - (self.version >> 16) as u16 - } - - /// `block_id = hemera(header_bytes)`, flat sponge, no domain prefix (§1.1). - pub fn id(&self) -> BlockId { - hash32(&self.encode()) - } - - /// The target this header claims; valid only after `check_syntax`. - pub fn target(&self) -> U256 { - decode_bits(self.bits).expect("checked bits") - } -} - -/// hemera over bytes, 32 bytes out. -pub fn hash32(bytes: &[u8]) -> [u8; 32] { - let digest = hemera::hash(bytes); - let mut out = [0u8; 32]; - out.copy_from_slice(&digest.as_bytes()[..32]); - out -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BitsError { - Sign, - Zero, - NonCanonical { canonical: u32 }, - AboveLimit, -} - -impl std::fmt::Display for BitsError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - BitsError::Sign => write!(f, "sign bit set"), - BitsError::Zero => write!(f, "target 0"), - BitsError::NonCanonical { canonical } => write!(f, "non-canonical (canon {canonical:#010x})"), - BitsError::AboveLimit => write!(f, "target above POW_LIMIT"), - } - } -} - -/// Compact → target with no checks beyond the sign bit (§1.3). -pub fn decode_bits_raw(bits: u32) -> Result { - if bits & 0x0080_0000 != 0 { - return Err(BitsError::Sign); - } - let exp = bits >> 24; - let mant = U256::from_u64((bits & 0x007f_ffff) as u64); - Ok(if exp >= 3 { mant << (8 * (exp - 3)) } else { mant >> (8 * (3 - exp)) }) -} - -/// Canonical encode of a target (§1.3). -pub fn encode_bits(target: U256) -> u32 { - let mut size = target.byte_length(); - let mut mant = if size <= 3 { - (target << (8 * (3 - size))).low_u64() as u32 - } else { - (target >> (8 * (size - 3))).low_u64() as u32 - }; - if mant & 0x0080_0000 != 0 { - mant >>= 8; - size += 1; - } - (size << 24) | mant -} - -/// Compact → target, rejecting sign, zero, non-canonical and above-limit -/// encodings (§1.3): the only decoder consensus code may use. -pub fn decode_bits(bits: u32) -> Result { - let target = decode_bits_raw(bits)?; - if target.is_zero() { - return Err(BitsError::Zero); - } - let canonical = encode_bits(target); - if canonical != bits { - return Err(BitsError::NonCanonical { canonical }); - } - if target > pow_limit() { - return Err(BitsError::AboveLimit); - } - Ok(target) -} - -/// Target → canonical bits after the clamp to `[1, POW_LIMIT]` (§1.5). -pub fn clamp_bits(target: U256) -> u32 { - let limit = pow_limit(); - let t = if target.is_zero() { U256::ONE } else if target > limit { limit } else { target }; - encode_bits(t) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn hex(b: &[u8]) -> String { - b.iter().map(|x| format!("{x:02x}")).collect() - } - - pub fn test_header() -> Header { - Header { - version: 1, - height: 1, - time: 1_762_349_000, - bits: 0x1d10_0000, - nonce: [0; 16], - prev: [0x11; 32], - tx_root: [0x22; 32], - state_root: [0x33; 32], - chain: [0x44; 32], - } - } - - #[test] - fn conformance_section_5_header_bytes_and_id() { - let h = test_header(); - let bytes = h.encode(); - assert_eq!(&hex(&bytes[..24]), "010000000100000000000000c84f0b69000000000000101d"); - assert_eq!(&bytes[24..40], &[0u8; 16]); - assert_eq!(&bytes[40..72], &[0x11u8; 32]); - assert_eq!(&bytes[136..], &[0x44u8; 32]); - assert_eq!(hex(&h.id()), "bdd7f2ef3a5a1c573b8d84a7a2aeedc95c6f8aa2139eea5c83946b33c4180c31"); - assert_eq!(Header::decode(&bytes, &[0x44; 32]).unwrap(), h); - assert_eq!(Header::decode(&bytes[..167], &[0x44; 32]), Err(HeaderError::Length(167))); - let mut long = bytes.to_vec(); - long.push(0); - assert_eq!(Header::decode(&long, &[0x44; 32]), Err(HeaderError::Length(169))); - assert_eq!(Header::decode(&bytes, &[0x45; 32]), Err(HeaderError::WrongChain)); - let mut v2 = h; - v2.version = 2; - assert_eq!(Header::decode(&v2.encode(), &[0x44; 32]), Err(HeaderError::RuleSet(2))); - let mut signalled = h; - signalled.version = 1 | (0xabcd << 16); - assert!(Header::decode(&signalled.encode(), &[0x44; 32]).is_ok()); - assert_eq!(signalled.signals(), 0xabcd); - } - - #[test] - fn conformance_section_2_compact_target() { - let canonical = [ - (0x1e01_0000u32, U256::ONE << 232), - (0x1e10_0000, U256::ONE << 236), - (0x1d10_0000, U256::ONE << 228), - (0x1d20_0000, U256::ONE << 229), - (0x1d08_0000, U256::ONE << 227), - (0x1d08_4860, U256::from_u64(0x08_4860) << (8 * 26)), - (0x0312_3456, U256::from_u64(0x12_3456)), - ]; - for (bits, target) in canonical { - assert_eq!(decode_bits(bits), Ok(target), "{bits:#x}"); - assert_eq!(encode_bits(target), bits, "{bits:#x}"); - } - assert_eq!(decode_bits(0x1e00_1000), Err(BitsError::NonCanonical { canonical: 0x1d10_0000 })); - assert_eq!(decode_bits(0x0400_1234), Err(BitsError::NonCanonical { canonical: 0x0312_3400 })); - assert_eq!(decode_bits(0x0100_3456), Err(BitsError::Zero)); - assert_eq!(decode_bits(0x1d80_0000), Err(BitsError::Sign)); - assert_eq!(decode_bits(0x0000_0000), Err(BitsError::Zero)); - assert_eq!(decode_bits(0x1f01_0000), Err(BitsError::AboveLimit)); - assert_eq!(clamp_bits(U256::ZERO), 0x0101_0000); - assert_eq!(clamp_bits(U256::ONE << 240), super::super::params::POW_LIMIT_BITS); - } -} diff --git a/crate/src/chain/mine/api.rs b/crate/src/chain/mine/api.rs deleted file mode 100644 index 8f753c43..00000000 --- a/crate/src/chain/mine/api.rs +++ /dev/null @@ -1,237 +0,0 @@ -//! The node's mining API (chain-wire.md §7): `GET /template?to={neuron}`, -//! `POST /submit`, `GET /tip` — the JSON shapes and a blocking client with -//! retry. - -use super::target::{from_bytes, Words}; -use crate::chain::params::HEADER_BYTES; -use serde::{Deserialize, Serialize}; -use std::time::Duration; - -/// `GET /template?to={neuron}` (chain-wire §7). Hex is lowercase, bytes in -/// wire order; `target` is the 32 little-endian bytes `decode(bits)` -/// (redundant with the header's `bits`, so an external worker needs no -/// compact-bits decoder); `expires` is the unix second after which the -/// node will answer `409` to a submit of this template (the template also -/// expires the moment the tip moves — poll `GET /tip`). -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Template { - pub chain: String, - pub height: u64, - pub prev: String, - pub header: String, - pub bits: String, - pub target: String, - pub body: String, - pub expires: u64, -} - -/// `POST /submit` body: the solved header. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Submit { - pub header: String, -} - -/// `POST /submit` → `200`: the accepted block. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Accepted { - pub id: String, - pub height: u64, -} - -/// `GET /tip`. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Tip { - pub height: u64, - pub id: String, - pub chainwork: String, -} - -/// What a submit came back with. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Verdict { - Accepted(Accepted), - /// `409`: the template's tip is gone — refresh. - Stale, - /// `400`/`422`: the node rejected the header, with its reason. - Rejected(String), -} - -pub fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() -} - -pub fn unhex(s: &str) -> Result, String> { - if s.len() % 2 != 0 { - return Err("odd hex length".into()); - } - (0..s.len() / 2) - .map(|i| u8::from_str_radix(&s[2 * i..2 * i + 2], 16).map_err(|e| e.to_string())) - .collect() -} - -impl Template { - pub fn header_bytes(&self) -> Result<[u8; HEADER_BYTES], String> { - unhex(&self.header)?.try_into().map_err(|v: Vec| { - format!("template header is {} bytes, not {HEADER_BYTES}", v.len()) - }) - } - - pub fn target_words(&self) -> Result { - let b: [u8; 32] = unhex(&self.target)? - .try_into() - .map_err(|v: Vec| format!("template target is {} bytes, not 32", v.len()))?; - Ok(from_bytes(&b)) - } - - pub fn bits_u32(&self) -> Result { - u32::from_str_radix(self.bits.trim_start_matches("0x"), 16) - .map_err(|e| format!("template bits: {e}")) - } -} - -fn json(r: ureq::Response, what: &str) -> Result { - let text = r.into_string().map_err(|e| format!("{what}: {e}"))?; - serde_json::from_str(&text).map_err(|e| format!("{what}: {e}")) -} - -/// A blocking client of one node. -#[derive(Clone, Debug)] -pub struct Client { - pub node: String, - pub address: String, - agent: ureq::Agent, -} - -impl Client { - pub fn new(node: &str, address: &str) -> Self { - Client { - node: node.trim_end_matches('/').to_string(), - address: address.to_string(), - agent: ureq::AgentBuilder::new() - .timeout_connect(Duration::from_secs(5)) - .timeout(Duration::from_secs(15)) - .build(), - } - } - - pub fn template(&self) -> Result { - let url = format!("{}/template?to={}", self.node, self.address); - let r = self - .agent - .get(&url) - .call() - .map_err(|e| format!("GET /template: {e}"))?; - json(r, "GET /template") - } - - pub fn tip(&self) -> Result { - let url = format!("{}/tip", self.node); - let r = self - .agent - .get(&url) - .call() - .map_err(|e| format!("GET /tip: {e}"))?; - json(r, "GET /tip") - } - - pub fn submit(&self, header: &[u8; HEADER_BYTES]) -> Result { - let url = format!("{}/submit", self.node); - let body = serde_json::to_string(&Submit { - header: hex(header), - }) - .expect("json"); - match self - .agent - .post(&url) - .set("Content-Type", "application/json") - .send_string(&body) - { - Ok(r) => Ok(Verdict::Accepted(json(r, "POST /submit")?)), - Err(ureq::Error::Status(409, _)) => Ok(Verdict::Stale), - Err(ureq::Error::Status(code @ (400 | 422), r)) => { - let reason = r.into_string().unwrap_or_default(); - Ok(Verdict::Rejected(format!("{code}: {}", reason.trim()))) - } - Err(e) => Err(format!("POST /submit: {e}")), - } - } - - /// `f` until it succeeds, with backoff 1 s, 2 s, … capped at `cap`, - /// at most `tries` attempts (0: forever); the last error otherwise. - pub fn retry( - mut f: impl FnMut() -> Result, - tries: u32, - cap: Duration, - log: impl Fn(&str), - ) -> Result { - let mut wait = Duration::from_secs(1); - let mut n = 0; - loop { - match f() { - Ok(v) => return Ok(v), - Err(e) => { - n += 1; - if tries != 0 && n >= tries { - return Err(e); - } - log(&format!("{e}; retry in {}s", wait.as_secs())); - std::thread::sleep(wait); - wait = (wait * 2).min(cap); - } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn template_json_round_trip() { - let t = Template { - chain: "pussy".into(), - height: 7, - prev: "11".repeat(32), - header: "00".repeat(168), - bits: "1e010000".into(), - target: hex(&crate::chain::mine::target::to_bytes( - &crate::chain::mine::target::pow2(232), - )), - body: "0100000001000700".into(), - expires: 1_793_885_850, - }; - let s = serde_json::to_string(&t).unwrap(); - assert_eq!(serde_json::from_str::