diff --git a/docs/protocol-economics-p1-status.md b/docs/protocol-economics-p1-status.md new file mode 100644 index 0000000..5667c87 --- /dev/null +++ b/docs/protocol-economics-p1-status.md @@ -0,0 +1,24 @@ +# Protocol Economics P1 Status + +Working paper reference: 19 August 2026, v0.1. + +This tranche is shadow-only and does not activate monetary supply changes. + +Implemented here: + +- dynamic per-work-class ZCU references from verified slot evidence; +- deterministic weighted median, EWMA smoothing and per-epoch rate limiting; +- ZPPI price-relative basket for compute, data availability and storage; +- reliability/coverage gates for thin economic data; +- canonical annual ZPPI factor `1_020_408_163`, derived from a 2% purchasing-power decline; +- tokenomics documentation migrated away from a fixed 2% supply-growth objective. + +The pre-existing supply-growth controller remains a legacy shadow simulator until ZPPI is integrated into the monetary epoch path. + +Next P1 tranche: + +- dormancy histogram; +- ProductiveCoverage hooks; +- capital-lineage metadata; +- fragmentation/state carrying-cost simulation; +- adversarial wallet-splitting scenarios. diff --git a/docs/tokenomics-v2.md b/docs/tokenomics-v2.md index 06fa72a..7f4034f 100644 --- a/docs/tokenomics-v2.md +++ b/docs/tokenomics-v2.md @@ -1,457 +1,205 @@ -# Zephyr v2 Tokenomics — Adaptive Monetary Policy +# Zephyr v2 Tokenomics — Protocol Economics and Purchasing-Power Target Status: **design contract + shadow-mode implementation target**. -This document defines the economic direction for ZPH in Protocol v2. It deliberately separates mechanisms that are already executable in the branch from mechanisms that must remain in shadow/simulation mode until devnet evidence demonstrates stability and manipulation resistance. +This document tracks the executable Protocol v2 economic foundation against the Zephyr Chain *Protocol Economics, Monetary Design, Governance, Lending and Consensus Incentives* working paper v0.1 dated 19 August 2026. -Detailed companion specifications: +The working paper supersedes the earlier assumption that the monetary objective is a fixed annual ZPH supply-growth rate. The canonical objective is now expressed in Zephyr-native purchasing power. Supply remains endogenous and any monetary actuator stays shadow-only until explicit activation gates pass. -- `docs/compute-economics-v2.md` — normalized compute work, ZCPI, ZCSI and A/B/C feedback experiments; -- `docs/economic-state-v2.md` — consensus-stamped coin age, velocity, per-shard epoch accounting and authenticated shadow monetary state. +## 1. Canonical monetary objective -## Goals - -Zephyr does not use a fixed maximum ZPH supply as its long-term monetary rule. The target is a deterministic, oracle-free adaptive policy with a long-run **net supply growth center near 2% per year**. - -The policy must: - -- be computable from consensus state only; -- be independently reproducible by validators, full nodes and Citizen Nodes; -- never depend on USD prices, CPI, exchanges, energy prices or other external oracles; -- distinguish fast fee-market control from slow monetary-policy control; -- use integer/fixed-point arithmetic only; -- rate-limit monetary changes; -- resist wash activity, fake offers and short-lived transaction spam; -- remain observable in shadow mode before it is allowed to mint protocol supply. - -A 2% target refers to **ZPH monetary supply growth**, not real-world purchasing-power inflation. Without an external price oracle Zephyr cannot claim to track CPI or any fiat purchasing-power index. - -## 1. Two independent control loops - -### Fast loop — every block - -The fast loop prices scarce blockchain resources and can burn a base-fee component. - -Conceptually: - -```text -resource use - -> block/shard utilization - -> dynamic base fee - -> fee burn + validator/reward + reserve components -``` - -The reference fee engine can price: - -- transaction base work; -- signature verification; -- witness/proof bytes; -- state reads/writes; -- contract fuel; -- data-availability bytes; -- cross-shard receipts. - -It also supports deterministic burn/validator/reserve splitting with exact value conservation. The current compatibility policy remains full fee burn until authenticated reward/reserve distribution is activated. - -The fast loop may react to congestion quickly. It does **not** directly change the long-term monetary target. - -### Slow loop — every monetary epoch - -The slow loop is the **Zephyr Adaptive Monetary Policy (ZAMP)**. - -It observes smoothed on-chain economic/security metrics and calculates the gross mint that would be required to hit the epoch net-issuance target after burn. - -The branch implements this controller in **shadow mode only**. - -```text -supply -burn -stake ratio -protocol reserve ratio -blockchain resource utilization -age-weighted velocity -finalized operations -compute-market telemetry - | - v -slow bounded controller - | - v -gross mint target - | - - burn already observed - v -net supply target near 2% annualized -``` - -Shadow mode computes and can authenticate the decision but does not mutate live ZPH supply. - -## 2. Net inflation target - -For an epoch, the base target is approximately: - -```text -NetIssuanceTarget = Supply * TargetInflation / EpochsPerYear -``` - -where the default center is 200 basis points (2%). - -If `B` ZPH were burned during the epoch and `N` is the desired positive net issuance, the gross mint target is: - -```text -GrossMintTarget = N + B -``` - -therefore: - -```text -GrossMintTarget - Burn = N -``` - -Burn and mint are separate accounting flows. A high burn rate does not automatically make the currency permanently deflationary if the monetary constitution targets a positive net supply rate. - -## 3. Adaptive band and rate limit - -The target is not intended to jump with short-term activity. The current shadow reference policy uses a center, a bounded range and a maximum movement per epoch. - -The checked-in defaults are simulation parameters, **not public-mainnet constants**: +The intended long-run property is: ```text -center: 2.00% -shadow minimum: 1.50% -shadow maximum: 2.50% -max change: 1 basis point / epoch +PP_t = 1 / ZPPI_t +PP_(t+1) = 0.98 * PP_t +ZPPI_(t+1) / ZPPI_t = 1 / 0.98 ~= 1.020408163 ``` -These numbers exist so the controller can be tested. They require economic simulation before activation. - -## 4. Oracle-free monetary signals - -ZAMP can use only values committed by Zephyr consensus. - -### Supply and burn - -Directly knowable from protocol state/accounting: - -- total ZPH supply; -- circulating supply; -- ZPH burned by the fee mechanism; -- protocol-minted ZPH after future activation; -- protocol reserve. - -### Security - -Directly known: - -- staked/bonded ZPH; -- validator voting power; -- collateral/slashing state. - -### Network utilization - -Do not use raw HTTP requests or mempool ingress. Use finalized consensus resource consumption. - -Blockchain resource utilization and compute-market utilization are separate signals and must not be conflated. - -### Finalized operations - -Operation counts are secondary signals only. They are not sufficient alone because an attacker can generate economically meaningless activity. +Therefore an exact annual **-2.00% purchasing-power target** corresponds to approximately **+2.0408% annual ZPPI inflation**. -### Age-weighted monetary velocity - -Simple transfer volume is wash-tradeable. Zephyr v2 coin objects now carry a consensus-stamped `CreatedHeight`. - -New coin outputs are rewritten by deterministic execution with the candidate block height, so the wallet cannot choose an old timestamp to create fake monetary age. - -The reference velocity accumulator uses: +The protocol stores the canonical Q9 annual factor: ```text -age = spendHeight - CreatedHeight +TargetZPPIAnnualFactorQ9 = 1_020_408_163 ``` -with configurable minimum age, full-weight age and maximum velocity bounds. - -Rapidly recreating and cycling fresh coin objects therefore resets their age and can contribute zero below `MinAgeBlocks`. - -Unknown/genesis age (`CreatedHeight = 0`) is tracked separately and excluded by the current reference policy. - -## 5. Zephyr normalized compute work - -There is no honest universal scalar that makes every CPU, GPU, AI training job, renderer and scientific workload directly equivalent. +This is not a fiat CPI target and does not require an exchange-price oracle. ZPPI is the price, in ZPH, of a versioned basket of competitively priced Zephyr-native services. -Zephyr therefore uses a **resource vector**, not a fake universal FLOP count. +## 2. Separation of control loops -The current model includes: +Zephyr keeps independent control signals so one metric cannot dominate monetary policy, resource pricing and consensus simultaneously. ```text -CPUUnits -GPUFP32Units -GPUFP64Units -TensorUnits -MemoryByteSeconds -VRAMByteSeconds -StorageBytes -NetworkBytes -``` - -A standardized workload definition carries protocol version, workload class, normalized units, workload hash, benchmark/specification hash and resource vector. - -The benchmark hash anchors the meaning of the units. A provider cannot make its hardware appear more valuable merely by self-reporting a larger number. - -## 6. Compute workload registry +finalized native markets + -> dynamic ZCU / service normalization + -> ZCPI + DA/storage observations + -> ZPPI + -> slow ZAMP monetary evaluation -Only standardized work specifications are eligible for monetary telemetry. +compute demand/supply/backlog/utilization + -> ZCSI + -> primarily reward routing / capacity incentives -A registry entry binds: +coin age + productive coverage + concentration + -> IdlePressure / future idle-capital levy -```text -WorkloadHash - -> WorkClass - -> normalized Units - -> WorkVector - -> BenchmarkHash +stake + reliability + diversity + -> consensus SecurityWeight ``` -Conflicting definitions for the same workload hash are rejected. - -The current registry is a reference implementation. Before monetary activation it must become authenticated/governance-controlled state with delayed versioned activation. - -## 7. ZCPI — Zephyr Compute Price Index +The fast blockchain resource-fee loop remains separate from the slow monetary loop. -ZCPI is an internal Zephyr compute-market price index. It is **not** a CPI and is not a claim about real-world inflation. +## 3. Dynamic ZCU reference -It answers: +A fixed FLOP benchmark becomes obsolete as hardware and software improve. Protocol v2 therefore retains the multidimensional `WorkVector` accounting layer and adds a shadow dynamic reference for each compute work class. -> how many atomic ZPH units were actually paid for standardized, verified compute work on Zephyr? - -ZCPI excludes: - -- advertised provider prices; -- unfilled offers; -- self-reported theoretical FLOPS; -- failed/unverified jobs; -- arbitrary unregistered workload units. - -Eligible observations derive from: +A verified compute-slot observation contains benchmark-derived performance plus evidence-weighted availability history. Provider-declared peak performance is not an input. ```text -registered workload spec -+ finalized compute job -+ verification-satisfied result -+ actual on-chain provider payments -``` - -The reference implementation uses fixed-point Q9 arithmetic, per-class medians, EWMA smoothing, basket coverage and an explicit reliability flag. - -## 8. ZCSI — Zephyr Compute Scarcity Index - -ZCPI alone cannot safely drive inflation. A price increase can reflect scarcity, demand growth, ZPH purchasing-power movement or workload-mix changes. +EffectiveSlotWeight = + DeliveredSlotTime + * AvailabilityEWMA + * SuccessEWMA + * Confidence -Zephyr therefore separately computes **ZCSI**, a bounded scarcity score based on: - -```text -escrow-backed standardized demand -verified standardized supply -funded backlog -fulfilled work -compute utilization -reliable ZCPI price trend +ZCU_ref(class, epoch) = weighted_median(verified slot performance) ``` -Only real escrow-backed standardized work counts as demand. Provider-advertised capacity does not become verified supply merely because it is claimed. - -If ZCPI is unreliable, the price component is removed. If demand/supply coverage is too thin, ZCSI itself becomes unreliable. - -An unreliable ZCSI is prohibited from changing either compute reward routing or the monetary target in the shadow evaluator. - -## 9. Compute feedback experiments A/B/C - -The branch implements three **shadow-only** modes. +The reference is smoothed with EWMA and bounded by a maximum per-epoch change. Thin classes fail closed as unreliable rather than inventing a reference. -### A — observe only +Current implementation: -```text -ZCSI -> telemetry only -compute reward share -> unchanged -inflation target -> unchanged -``` +- `internal/v2/economics/zcu_reference.go` +- deterministic weighted median per work class; +- delivered-slot-time, availability, success and confidence weighting; +- Q9 fixed-point reference values; +- minimum verified-slot coverage gate; +- EWMA smoothing; +- per-epoch rate limit; +- no provider self-reported peak-performance field. -### B — reward routing +This is **shadow telemetry only**. It does not change live rewards or supply. -```text -ZCSI -> suggested compute reward share -inflation target -> unchanged -``` +## 4. ZCPI and ZCSI -This is the preferred first candidate if devnet evidence eventually justifies activation. Scarce verified compute can receive a larger share of an already-defined issuance budget without changing total issuance. - -### C — reward routing + narrow monetary band - -```text -ZCSI -> suggested compute reward share -ZCSI -> small bounded shadow inflation correction -``` +Existing Protocol v2 code already reconstructs verified paid compute observations from finalized compute state and builds: -The total-inflation sensitivity is deliberately much smaller than the reward-routing sensitivity. +- ZCPI: finalized paid ZPH per standardized verified compute unit, using per-class medians, EWMA, coverage and reliability; +- ZCSI: bounded compute scarcity from escrow-backed demand, verified supply, backlog, fulfillment, utilization and reliable price trend. -Mode C must demonstrate a material stability/capacity benefit over Mode B before it is considered for activation. +Unreliable price or capacity evidence fails closed. ZCSI is primarily a routing signal for compute incentives and must not become the monetary anchor. -The current active economic boundary remains equivalent to Mode A: **no compute signal changes live supply**. +## 5. ZPPI — Zephyr Purchasing Power Index -## 10. Native ZPH fee accounting +The initial basket emphasizes: -V2 execution requires native ZPH inputs to cover outputs plus the signed fee. +- compute; +- data availability; +- storage. -The reference fee engine now supports: +Gas/base fee should have low or zero basket weight because it is directly influenced by protocol policy and would introduce reflexive feedback. -```text -resource charge - | - +-- burn - +-- validator/reward pool - +-- protocol reserve -``` +The first executable reference uses a version-scoped **chain-weighted price-relative basket**. Each service is normalized to a reference price fixed for that basket version before aggregation, so heterogeneous raw service prices are not treated as equivalent units. -with integer basis-point splits and deterministic rounding that conserves every atomic unit. +Current implementation: -Until state-backed distribution is activated, the compatibility policy preserves the current effective 100% fee burn. +- `internal/v2/economics/zppi.go`; +- Q9 component price relatives; +- compute / data-availability / storage components; +- version-supplied reference prices and weights; +- reliability-aware coverage; +- EWMA smoothing; +- canonical exact purchasing-power/ZPPI target conversion; +- fail-closed behavior when reliable basket coverage is too low. -## 11. Smart-contract gas +A future benchmark/basket governance transition may use geometric aggregation after overlap/calibration. Basket changes must be versioned, delayed and measured across an overlap window. -Contract execution reports deterministic `FuelUsed` and enforces `FuelLimit`. +## 6. ZAMP migration boundary -The reference resource fee model can include: +The branch already contains a deterministic shadow ZAMP evaluator that was built around the earlier 2% annual net-supply-growth center. That controller remains useful as a simulation/actuator baseline, but the **2% supply-growth center is no longer the normative monetary target**. -```text -base transaction charge -+ signature work -+ witness bytes -+ state reads/writes -+ FuelUsed * FuelPrice -+ DA bytes -+ cross-shard receipts -``` +Until the ZPPI path is integrated end-to-end: -Final production prices and the active base-fee controller remain simulation/benchmark decisions. +- the legacy supply-growth evaluator remains shadow-only; +- it must not be activated for live minting; +- new ZCU/ZPPI snapshots are measurement foundations, not supply commands; +- the next controller revision must use ZPPI target error as its primary monetary direction signal; +- ZCSI should mainly affect reward routing rather than broad issuance; +- mint, burn, reserve and reward-routing changes remain deterministic consensus state transitions behind explicit activation gates. -## 12. Compute payment is not blockchain gas +## 7. Idle capital and productive coverage — next P1 tranche -Heavy compute has two independent prices: +The working design defines: ```text -provider payment -+ blockchain settlement fee +AgePressure(a) = 1 - exp(-k * a) +EffectiveIdle = AgePressure * (1 - ProductiveCoverage) +IdleLevyIntensity_i = Base * EffectiveIdle_i * CapitalPressure_i * GlobalIdlePressure ``` -A 100 ZPH AI job does not imply 100 ZPH of gas. Validators settle commitments/proofs/results; they do not replay the expensive workload. +P1 implementation should add shadow-only: -## 13. Native custom-token policy +- dormancy histograms; +- capital-lineage metadata for split objects; +- fragmentation/state carrying-cost simulation; +- productive-coverage accounting hooks; +- adversarial scenarios for 10/100/1,000/10,000-way wallet fragmentation. -Protocol-native custom assets now have explicit supply policies: +No balance levy should activate in this tranche. -```text -FIXED -CAPPED -MINTABLE -``` +## 8. Native lending — P2 -The v2 executor implements: +Native bilateral lending is the productive escape valve for idle capital. A loan becomes productive only after both sides accept terms and funding/collateral locks are atomically satisfied. -- custom-token creation; -- `MintToken` with mint-authority and cap enforcement; -- `BurnToken` with burn-permission enforcement; -- authenticated `TokenDefinition.CurrentSupply` updates; -- `Transferable` enforcement; -- read-only token-definition policy witnesses for parallel normal transfers. +The future market includes: -ZPH itself is excluded from user-authority mint/burn paths. Future ZPH issuance can occur only through an explicitly activated protocol monetary transition. +- lender offers and borrower requests; +- counteroffers; +- duration/rate/collateral constraints; +- objective borrower history; +- negative rates; +- anti-wash credit that accrues over time rather than entirely at origination. -Custom-token cross-shard transfer/mint is deliberately gated until a globally verifiable token-policy proof/registry is available. +Negative rates are intentionally valid when the cost of lending is lower than the expected idle-capital pressure plus risk cost. -## 14. Economic epoch state +## 9. Activation discipline -Zephyr must not create one global monetary object touched by every transaction; that would serialize execution. - -The current foundation therefore uses: +Economic mechanisms progress through: ```text -finalized execution - -> per-shard epoch metrics - -> deterministic epoch aggregate - -> shadow MonetaryEpochState +Observe -> Shadow -> adversarial simulation -> bounded Active ``` -Per-shard metrics cover: - -- charged/burned/validator/reserve fees; -- finalized operations; -- chain resource used/capacity; -- shard circulating ZPH; -- age-weighted velocity; -- escrow-backed compute demand; -- verified compute supply; -- compute backlog/fulfillment. - -Exact fee conservation is validated. - -Global velocity is weighted by per-shard circulating ZPH rather than giving every shard equal influence. - -Chain resource utilization and compute utilization are calculated separately. - -The canonical aggregate hash is committed by a deterministic shadow `MonetaryEpochState` system object. Consecutive states bind `PreviousStateHash`. - -The object can enter the normal Sparse-Merkle state root through consume/recreate semantics, so a future epoch-boundary runtime transition does not require another special consensus root. +No controller should jump directly from a paper design to live monetary state. -The current transition builder returns a state delta for pre-QC simulation; it does not commit a store itself. +Minimum gates include: -## 15. Testing and replay +1. all monetary metrics derived reproducibly from finalized consensus state; +2. authenticated benchmark/availability state for verified compute supply; +3. versioned basket and benchmark-era governance with overlap and timelocks; +4. long-horizon manipulation tests for self-cycling, wash lending, fragmentation and concentration; +5. bounded parameter changes and deterministic emergency fallback; +6. Citizen Node decoding/verification of economic state and history; +7. explicit protocol-version/activation-height transitions for mint, reward, fee split or levy changes. -The branch includes deterministic tests for: +## 10. Implementation order -- normalized work-spec serialization and registry conflicts; -- verified settlement observations; -- ZCPI medians, coverage, reliability and trend bounds; -- ZCSI demand/supply scarcity and reliability fail-closed behavior; -- A/B/C feedback separation; -- burn offset and rate-limited ZAMP target; -- fee split/resource quotation conservation; -- consensus override of wallet-provided coin creation height; -- old versus fresh age-weighted velocity; -- rapid self-cycling suppression; -- per-shard economic accounting and multi-shard aggregation; -- separation of chain and compute utilization; -- canonical shadow monetary state and previous-epoch binding; -- suggested Mode-C mint remaining shadow while `TotalSupply` is unchanged. +The current working-paper roadmap is: -`cmd/zephyr-econ-sim` supports base ZAMP replay and optional compute-market/ZCSI A/B/C inputs. See `docs/examples/zephyr-econ-sim-compute.json`. +- **P0** keep CI/gofmt and Protocol v2 correctness gates green; +- **P1** dynamic ZCU + verified-slot accounting in shadow; +- **P1** ZPPI basket + exact -2% purchasing-power target simulation; +- **P1** dormancy histogram + lineage + fragmentation simulator; +- **P2** bilateral native lending; +- **P2** DeviceCredential abstraction; +- **P2** ZECI concentration telemetry; +- **P3** ContributionScore/Citizen challenge network, VRF committees and governance timelocks; +- **P4** Ethereum collateral light-client adapter and Bitcoin collateral research; +- **P5** incremental economic activation after long-running public simulation. -## 16. Activation gates - -ZAMP remains shadow-only until all of the following are true: - -1. per-shard economic metrics are derived automatically from finalized runtime execution rather than caller-provided summaries; -2. fee distribution is state-backed and conserves supply exactly; -3. verified compute supply comes from authenticated benchmark/availability state; -4. velocity is stress-tested against long-horizon self-cycling and capital-lock attacks; -5. all monetary metrics are reproducible from finalized state; -6. long simulations cover usage shocks, partitions, validator churn, spam, compute booms/busts and oscillating adversarial inputs; -7. parameter sensitivity does not create runaway or oscillatory issuance; -8. governance can change parameters only through bounded delayed transitions; -9. Citizen Nodes can decode and independently verify monetary state/history; -10. ZCPI/ZCSI have sufficient real-market coverage before Mode B or C is considered; -11. an emergency deterministic fallback can zero adaptive corrections when required metrics are unavailable/invalid; -12. live mint/reward/fee distribution has an explicit protocol-version/activation-height transition. - -## 17. Current policy boundary - -The branch implements **measurement, authenticated shadow state and shadow decisions**, not live monetary issuance. - -The design principle remains: +The engineering rule remains: ```text -measure first -simulate second -activate last +measure first -> simulate second -> activate last ``` - -This lets Zephyr develop an adaptive oracle-free economy without turning monetary policy into an untested consensus experiment. diff --git a/internal/v2/economics/idle_capital.go b/internal/v2/economics/idle_capital.go new file mode 100644 index 0000000..eee5474 --- /dev/null +++ b/internal/v2/economics/idle_capital.go @@ -0,0 +1,325 @@ +package economics + +import ( + "bytes" + "errors" + "math" + "math/big" + "sort" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +var ErrIdleCapital = errors.New("invalid Zephyr idle-capital shadow input") + +// CapitalLot is shadow-only lineage metadata. LineageID follows economic +// capital across object splits, while IdleSinceHeight records the last height +// at which that portion of capital was classified as productively deployed. +// Neither field changes ownership, spendability or live monetary state. +type CapitalLot struct { + LineageID types.Hash + Amount uint64 + IdleSinceHeight uint64 +} + +func (l CapitalLot) Validate() error { + if types.IsZero32([32]byte(l.LineageID)) || l.Amount == 0 || l.IdleSinceHeight == 0 { + return ErrIdleCapital + } + return nil +} + +type DormancyBucket struct { + MaxAgeBlocks uint64 + Amount uint64 +} + +type DormancyHistogram struct { + Height uint64 + Total uint64 + Buckets []DormancyBucket +} + +// BuildDormancyHistogram aggregates capital by lineage age rather than by +// wallet/object count. Splitting one object into many therefore cannot change +// the aggregate histogram when the underlying lots are preserved. +func BuildDormancyHistogram(lots []CapitalLot, height uint64, upperBounds []uint64) (DormancyHistogram, error) { + if height == 0 || len(lots) == 0 { + return DormancyHistogram{}, ErrIdleCapital + } + for i, bound := range upperBounds { + if bound == 0 || (i > 0 && bound <= upperBounds[i-1]) { + return DormancyHistogram{}, ErrIdleCapital + } + } + out := DormancyHistogram{Height: height, Buckets: make([]DormancyBucket, len(upperBounds)+1)} + for i, bound := range upperBounds { + out.Buckets[i].MaxAgeBlocks = bound + } + out.Buckets[len(out.Buckets)-1].MaxAgeBlocks = math.MaxUint64 + for _, lot := range lots { + if err := lot.Validate(); err != nil || lot.IdleSinceHeight > height { + return DormancyHistogram{}, ErrIdleCapital + } + age := height - lot.IdleSinceHeight + index := sort.Search(len(upperBounds), func(i int) bool { return age <= upperBounds[i] }) + if math.MaxUint64-out.Buckets[index].Amount < lot.Amount || math.MaxUint64-out.Total < lot.Amount { + return DormancyHistogram{}, ErrIdleCapital + } + out.Buckets[index].Amount += lot.Amount + out.Total += lot.Amount + } + return out, nil +} + +// SplitCapitalLineage deterministically allocates lineage lots to output +// amounts without changing aggregate amount or idle age. It is intentionally +// independent of wallet/account identity so address fragmentation gives no +// age reset. +func SplitCapitalLineage(lots []CapitalLot, outputAmounts []uint64) ([][]CapitalLot, error) { + if len(lots) == 0 || len(outputAmounts) == 0 { + return nil, ErrIdleCapital + } + canonical, err := compactCapitalLots(lots) + if err != nil { + return nil, err + } + totalInput := new(big.Int) + for _, lot := range canonical { + totalInput.Add(totalInput, new(big.Int).SetUint64(lot.Amount)) + } + totalOutput := new(big.Int) + for _, amount := range outputAmounts { + if amount == 0 { + return nil, ErrIdleCapital + } + totalOutput.Add(totalOutput, new(big.Int).SetUint64(amount)) + } + if totalInput.Cmp(totalOutput) != 0 || !totalInput.IsUint64() { + return nil, ErrIdleCapital + } + + out := make([][]CapitalLot, len(outputAmounts)) + lotIndex := 0 + remaining := canonical[0].Amount + for outputIndex, amount := range outputAmounts { + need := amount + for need > 0 { + if lotIndex >= len(canonical) { + return nil, ErrIdleCapital + } + take := remaining + if take > need { + take = need + } + lot := canonical[lotIndex] + lot.Amount = take + out[outputIndex] = append(out[outputIndex], lot) + need -= take + remaining -= take + if remaining == 0 { + lotIndex++ + if lotIndex < len(canonical) { + remaining = canonical[lotIndex].Amount + } + } + } + } + if lotIndex != len(canonical) { + return nil, ErrIdleCapital + } + return out, nil +} + +// MarkProductiveCoverage resets the idle clock for exactly the requested +// fraction of aggregate capital. Lots are compacted and processed oldest-first +// before the split, making rounding independent of wallet fragmentation. +func MarkProductiveCoverage(lots []CapitalLot, height uint64, coverageBps uint32) ([]CapitalLot, error) { + if height == 0 || coverageBps > 10_000 { + return nil, ErrIdleCapital + } + canonical, err := compactCapitalLots(lots) + if err != nil { + return nil, err + } + for _, lot := range canonical { + if lot.IdleSinceHeight > height { + return nil, ErrIdleCapital + } + } + sort.Slice(canonical, func(i, j int) bool { + if canonical[i].IdleSinceHeight != canonical[j].IdleSinceHeight { + return canonical[i].IdleSinceHeight < canonical[j].IdleSinceHeight + } + return bytes.Compare(canonical[i].LineageID[:], canonical[j].LineageID[:]) < 0 + }) + total := new(big.Int) + for _, lot := range canonical { + total.Add(total, new(big.Int).SetUint64(lot.Amount)) + } + target := new(big.Int).Mul(new(big.Int).Set(total), new(big.Int).SetUint64(uint64(coverageBps))) + target.Quo(target, big.NewInt(10_000)) + if !target.IsUint64() { + return nil, ErrIdleCapital + } + remaining := target.Uint64() + out := make([]CapitalLot, 0, len(canonical)+1) + for _, lot := range canonical { + if remaining == 0 { + out = append(out, lot) + continue + } + productive := lot.Amount + if productive > remaining { + productive = remaining + } + if productive < lot.Amount { + idle := lot + idle.Amount -= productive + out = append(out, idle) + } + productiveLot := lot + productiveLot.Amount = productive + productiveLot.IdleSinceHeight = height + out = append(out, productiveLot) + remaining -= productive + } + if remaining != 0 { + return nil, ErrIdleCapital + } + return compactCapitalLots(out) +} + +type ProductiveCoverageAccumulator struct { + observedCapital uint64 + weightedCoverageBps big.Int +} + +type ProductiveCoverageSnapshot struct { + ObservedCapital uint64 + ProductiveCoverageBps uint32 +} + +// Observe accepts a deterministic, consensus-derived coverage classification +// supplied by a concrete productive-use hook. It only aggregates the signal; +// it does not decide that arbitrary transfers are productive. +func (a *ProductiveCoverageAccumulator) Observe(amount uint64, coverageBps uint32) error { + if a == nil || amount == 0 || coverageBps > 10_000 || math.MaxUint64-a.observedCapital < amount { + return ErrIdleCapital + } + a.observedCapital += amount + term := new(big.Int).Mul(new(big.Int).SetUint64(amount), new(big.Int).SetUint64(uint64(coverageBps))) + a.weightedCoverageBps.Add(&a.weightedCoverageBps, term) + return nil +} + +func (a *ProductiveCoverageAccumulator) Snapshot() (ProductiveCoverageSnapshot, error) { + if a == nil || a.observedCapital == 0 { + return ProductiveCoverageSnapshot{}, ErrIdleCapital + } + value := new(big.Int).Quo(new(big.Int).Set(&a.weightedCoverageBps), new(big.Int).SetUint64(a.observedCapital)) + if !value.IsUint64() || value.Uint64() > 10_000 { + return ProductiveCoverageSnapshot{}, ErrIdleCapital + } + return ProductiveCoverageSnapshot{ObservedCapital: a.observedCapital, ProductiveCoverageBps: uint32(value.Uint64())}, nil +} + +type StateCarryingCostPolicy struct { + BaseUnitsPerObject uint64 + UnitsPerKiB uint64 +} + +func EstimateStateCarryingCost(objectCount, payloadBytes uint64, policy StateCarryingCostPolicy) (uint64, error) { + if objectCount == 0 || policy.BaseUnitsPerObject == 0 || policy.UnitsPerKiB == 0 { + return 0, ErrIdleCapital + } + kilobytes := payloadBytes / 1024 + if payloadBytes%1024 != 0 { + kilobytes++ + } + cost := new(big.Int).Mul(new(big.Int).SetUint64(objectCount), new(big.Int).SetUint64(policy.BaseUnitsPerObject)) + cost.Add(cost, new(big.Int).Mul(new(big.Int).SetUint64(kilobytes), new(big.Int).SetUint64(policy.UnitsPerKiB))) + if !cost.IsUint64() { + return 0, ErrIdleCapital + } + return cost.Uint64(), nil +} + +type FragmentationScenario struct { + Fragments uint32 + Histogram DormancyHistogram + CarryingCostUnits uint64 +} + +// SimulateWalletFragmentation is a shadow-only adversarial helper. It splits a +// single capital lot into N object-sized fragments and proves the economic age +// distribution remains invariant while state carrying cost increases. +func SimulateWalletFragmentation(seed CapitalLot, fragments uint32, height, payloadBytesPerObject uint64, bounds []uint64, policy StateCarryingCostPolicy) (FragmentationScenario, error) { + if err := seed.Validate(); err != nil || fragments == 0 || uint64(fragments) > seed.Amount { + return FragmentationScenario{}, ErrIdleCapital + } + amounts := make([]uint64, int(fragments)) + base := seed.Amount / uint64(fragments) + remainder := seed.Amount % uint64(fragments) + for i := range amounts { + amounts[i] = base + if uint64(i) < remainder { + amounts[i]++ + } + } + split, err := SplitCapitalLineage([]CapitalLot{seed}, amounts) + if err != nil { + return FragmentationScenario{}, err + } + flattened := make([]CapitalLot, 0, fragments) + for _, outputLots := range split { + flattened = append(flattened, outputLots...) + } + histogram, err := BuildDormancyHistogram(flattened, height, bounds) + if err != nil { + return FragmentationScenario{}, err + } + payloadTotal := new(big.Int).Mul(new(big.Int).SetUint64(uint64(fragments)), new(big.Int).SetUint64(payloadBytesPerObject)) + if !payloadTotal.IsUint64() { + return FragmentationScenario{}, ErrIdleCapital + } + cost, err := EstimateStateCarryingCost(uint64(fragments), payloadTotal.Uint64(), policy) + if err != nil { + return FragmentationScenario{}, err + } + return FragmentationScenario{Fragments: fragments, Histogram: histogram, CarryingCostUnits: cost}, nil +} + +func compactCapitalLots(lots []CapitalLot) ([]CapitalLot, error) { + if len(lots) == 0 { + return nil, ErrIdleCapital + } + out := append([]CapitalLot(nil), lots...) + for _, lot := range out { + if err := lot.Validate(); err != nil { + return nil, err + } + } + sort.Slice(out, func(i, j int) bool { + cmp := bytes.Compare(out[i].LineageID[:], out[j].LineageID[:]) + if cmp != 0 { + return cmp < 0 + } + return out[i].IdleSinceHeight < out[j].IdleSinceHeight + }) + compacted := make([]CapitalLot, 0, len(out)) + for _, lot := range out { + if len(compacted) > 0 { + last := &compacted[len(compacted)-1] + if last.LineageID == lot.LineageID && last.IdleSinceHeight == lot.IdleSinceHeight { + if math.MaxUint64-last.Amount < lot.Amount { + return nil, ErrIdleCapital + } + last.Amount += lot.Amount + continue + } + } + compacted = append(compacted, lot) + } + return compacted, nil +} diff --git a/internal/v2/economics/idle_capital_test.go b/internal/v2/economics/idle_capital_test.go new file mode 100644 index 0000000..34faf2c --- /dev/null +++ b/internal/v2/economics/idle_capital_test.go @@ -0,0 +1,157 @@ +package economics + +import ( + "reflect" + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/types" +) + +func TestWalletFragmentationPreservesDormancyHistogram(t *testing.T) { + seed := CapitalLot{ + LineageID: types.HashBytes("idle-lineage", []byte("seed")), + Amount: 1_000_000, + IdleSinceHeight: 10, + } + height := uint64(10_010) + bounds := []uint64{100, 1_000, 10_000, 100_000} + policy := StateCarryingCostPolicy{BaseUnitsPerObject: 8, UnitsPerKiB: 3} + + baseline, err := BuildDormancyHistogram([]CapitalLot{seed}, height, bounds) + if err != nil { + t.Fatal(err) + } + var previousCost uint64 + for _, fragments := range []uint32{10, 100, 1_000, 10_000} { + scenario, err := SimulateWalletFragmentation(seed, fragments, height, 96, bounds, policy) + if err != nil { + t.Fatalf("%d fragments: %v", fragments, err) + } + if !reflect.DeepEqual(scenario.Histogram, baseline) { + t.Fatalf("%d-way split changed dormancy histogram: %#v != %#v", fragments, scenario.Histogram, baseline) + } + if scenario.CarryingCostUnits <= previousCost { + t.Fatalf("%d-way split did not increase carrying cost: %d <= %d", fragments, scenario.CarryingCostUnits, previousCost) + } + previousCost = scenario.CarryingCostUnits + } +} + +func TestMarkProductiveCoverageIsFragmentationStable(t *testing.T) { + seed := CapitalLot{ + LineageID: types.HashBytes("idle-lineage", []byte("productive")), + Amount: 100_000, + IdleSinceHeight: 100, + } + height := uint64(1_000) + marked, err := MarkProductiveCoverage([]CapitalLot{seed}, height, 2_500) + if err != nil { + t.Fatal(err) + } + split, err := SplitCapitalLineage([]CapitalLot{seed}, equalAmounts(seed.Amount, 100)) + if err != nil { + t.Fatal(err) + } + var fragmented []CapitalLot + for _, lots := range split { + fragmented = append(fragmented, lots...) + } + markedFragmented, err := MarkProductiveCoverage(fragmented, height, 2_500) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(markedFragmented, marked) { + t.Fatalf("productive marking changed after wallet split: %#v != %#v", markedFragmented, marked) + } + if len(marked) != 2 { + t.Fatalf("marked lots = %d, want 2", len(marked)) + } + var reset, stillIdle uint64 + for _, lot := range marked { + switch lot.IdleSinceHeight { + case height: + reset += lot.Amount + case seed.IdleSinceHeight: + stillIdle += lot.Amount + default: + t.Fatalf("unexpected idle height %d", lot.IdleSinceHeight) + } + } + if reset != 25_000 || stillIdle != 75_000 { + t.Fatalf("productive split = reset %d idle %d", reset, stillIdle) + } +} + +func TestProductiveCoverageAccumulatorUsesCapitalWeight(t *testing.T) { + var acc ProductiveCoverageAccumulator + if err := acc.Observe(1_000, 2_500); err != nil { + t.Fatal(err) + } + if err := acc.Observe(3_000, 5_000); err != nil { + t.Fatal(err) + } + snapshot, err := acc.Snapshot() + if err != nil { + t.Fatal(err) + } + if snapshot.ObservedCapital != 4_000 || snapshot.ProductiveCoverageBps != 4_375 { + t.Fatalf("unexpected productive coverage snapshot: %#v", snapshot) + } +} + +func TestSplitCapitalLineagePreservesMultipleCohorts(t *testing.T) { + lineage := types.HashBytes("idle-lineage", []byte("cohorts")) + lots := []CapitalLot{ + {LineageID: lineage, Amount: 600, IdleSinceHeight: 10}, + {LineageID: lineage, Amount: 400, IdleSinceHeight: 100}, + } + split, err := SplitCapitalLineage(lots, []uint64{250, 250, 250, 250}) + if err != nil { + t.Fatal(err) + } + var flattened []CapitalLot + for _, output := range split { + flattened = append(flattened, output...) + } + before, err := BuildDormancyHistogram(lots, 1_000, []uint64{500, 950}) + if err != nil { + t.Fatal(err) + } + after, err := BuildDormancyHistogram(flattened, 1_000, []uint64{500, 950}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(after, before) { + t.Fatalf("cohort split changed histogram: %#v != %#v", after, before) + } +} + +func TestIdleCapitalRejectsManipulativeInputs(t *testing.T) { + lineage := types.HashBytes("idle-lineage", []byte("invalid")) + seed := CapitalLot{LineageID: lineage, Amount: 100, IdleSinceHeight: 10} + if _, err := SplitCapitalLineage([]CapitalLot{seed}, []uint64{99}); err == nil { + t.Fatal("non-conserving lineage split accepted") + } + if _, err := MarkProductiveCoverage([]CapitalLot{seed}, 100, 10_001); err == nil { + t.Fatal("coverage above 100% accepted") + } + if _, err := BuildDormancyHistogram([]CapitalLot{seed}, 100, []uint64{100, 99}); err == nil { + t.Fatal("non-monotonic dormancy buckets accepted") + } + if _, err := SimulateWalletFragmentation(seed, 101, 100, 64, []uint64{10}, StateCarryingCostPolicy{BaseUnitsPerObject: 1, UnitsPerKiB: 1}); err == nil { + t.Fatal("fragment count above indivisible amount accepted") + } +} + +func equalAmounts(total uint64, count int) []uint64 { + out := make([]uint64, count) + base := total / uint64(count) + remainder := total % uint64(count) + for i := range out { + out[i] = base + if uint64(i) < remainder { + out[i]++ + } + } + return out +} diff --git a/internal/v2/economics/zcu_reference.go b/internal/v2/economics/zcu_reference.go new file mode 100644 index 0000000..63166b7 --- /dev/null +++ b/internal/v2/economics/zcu_reference.go @@ -0,0 +1,164 @@ +package economics + +import ( + "errors" + "math/big" + "sort" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" +) + +const ZCUReferenceScaleQ9 uint64 = 1_000_000_000 + +var ErrZCUReference = errors.New("invalid Zephyr compute unit reference input") + +// VerifiedComputeSlot is an economically weighted observation of concurrently +// usable compute capacity. PerformanceQ9 must come from an authenticated +// benchmark result; provider-declared peak performance is intentionally absent. +type VerifiedComputeSlot struct { + Class compute.WorkClass + PerformanceQ9 uint64 + DeliveredSlotTime uint64 + AvailabilityEWMABps uint32 + SuccessEWMABps uint32 + ConfidenceBps uint32 +} + +type ZCUReferenceConfig struct { + MinSlotsPerClass uint32 + EWMABps uint32 + MaxEpochChangeBps uint32 +} + +type ZCUReferenceSnapshot struct { + Epoch uint64 + ClassReferenceQ9 [compute.WorkClassCount]uint64 + ClassSlots [compute.WorkClassCount]uint32 + ClassWeight [compute.WorkClassCount]uint64 + ClassReliable [compute.WorkClassCount]bool +} + +type weightedSlot struct { + performance uint64 + weight uint64 +} + +func BuildZCUReference(epoch uint64, slots []VerifiedComputeSlot, prior ZCUReferenceSnapshot, cfg ZCUReferenceConfig) (ZCUReferenceSnapshot, error) { + if epoch == 0 || cfg.MinSlotsPerClass == 0 || cfg.EWMABps > BasisPoints || cfg.MaxEpochChangeBps > BasisPoints { + return ZCUReferenceSnapshot{}, ErrZCUReference + } + if prior.Epoch != 0 && prior.Epoch >= epoch { + return ZCUReferenceSnapshot{}, ErrZCUReference + } + + byClass := make([][]weightedSlot, int(compute.WorkClassCount)) + out := ZCUReferenceSnapshot{Epoch: epoch} + for _, slot := range slots { + if slot.Class <= compute.WorkUnknown || slot.Class >= compute.WorkClassCount || slot.PerformanceQ9 == 0 || slot.DeliveredSlotTime == 0 || + slot.AvailabilityEWMABps > BasisPoints || slot.SuccessEWMABps > BasisPoints || slot.ConfidenceBps > BasisPoints { + return ZCUReferenceSnapshot{}, ErrZCUReference + } + weight, err := effectiveSlotWeight(slot) + if err != nil { + return ZCUReferenceSnapshot{}, err + } + if weight == 0 { + continue + } + byClass[slot.Class] = append(byClass[slot.Class], weightedSlot{performance: slot.PerformanceQ9, weight: weight}) + } + + for class := compute.WorkClass(1); class < compute.WorkClassCount; class++ { + classSlots := byClass[class] + out.ClassSlots[class] = uint32(len(classSlots)) + if len(classSlots) == 0 { + continue + } + sort.Slice(classSlots, func(i, j int) bool { + if classSlots[i].performance == classSlots[j].performance { + return classSlots[i].weight < classSlots[j].weight + } + return classSlots[i].performance < classSlots[j].performance + }) + median, totalWeight, err := weightedMedianSlot(classSlots) + if err != nil { + return ZCUReferenceSnapshot{}, err + } + out.ClassWeight[class] = totalWeight + if out.ClassSlots[class] < cfg.MinSlotsPerClass { + continue + } + reference, err := ewma(prior.ClassReferenceQ9[class], median, cfg.EWMABps) + if err != nil { + return ZCUReferenceSnapshot{}, err + } + reference, err = rateLimitQ9(prior.ClassReferenceQ9[class], reference, cfg.MaxEpochChangeBps) + if err != nil { + return ZCUReferenceSnapshot{}, err + } + out.ClassReferenceQ9[class] = reference + out.ClassReliable[class] = reference != 0 + } + return out, nil +} + +func effectiveSlotWeight(slot VerifiedComputeSlot) (uint64, error) { + value := new(big.Int).SetUint64(slot.DeliveredSlotTime) + for _, factor := range []uint32{slot.AvailabilityEWMABps, slot.SuccessEWMABps, slot.ConfidenceBps} { + value.Mul(value, new(big.Int).SetUint64(uint64(factor))) + value.Quo(value, new(big.Int).SetUint64(uint64(BasisPoints))) + } + if !value.IsUint64() { + return 0, ErrZCUReference + } + return value.Uint64(), nil +} + +func weightedMedianSlot(slots []weightedSlot) (uint64, uint64, error) { + if len(slots) == 0 { + return 0, 0, ErrZCUReference + } + total := new(big.Int) + for _, slot := range slots { + if slot.performance == 0 || slot.weight == 0 { + return 0, 0, ErrZCUReference + } + total.Add(total, new(big.Int).SetUint64(slot.weight)) + } + if !total.IsUint64() || total.Sign() == 0 { + return 0, 0, ErrZCUReference + } + totalWeight := total.Uint64() + threshold := totalWeight/2 + totalWeight%2 + var cumulative uint64 + for _, slot := range slots { + if ^uint64(0)-cumulative < slot.weight { + return 0, 0, ErrZCUReference + } + cumulative += slot.weight + if cumulative >= threshold { + return slot.performance, totalWeight, nil + } + } + return 0, 0, ErrZCUReference +} + +func rateLimitQ9(prior, next uint64, maxChangeBps uint32) (uint64, error) { + if prior == 0 || maxChangeBps == 0 || prior == next { + return next, nil + } + upper := new(big.Int).Mul(new(big.Int).SetUint64(prior), new(big.Int).SetUint64(uint64(BasisPoints+maxChangeBps))) + upper.Quo(upper, new(big.Int).SetUint64(uint64(BasisPoints))) + lower := new(big.Int).Mul(new(big.Int).SetUint64(prior), new(big.Int).SetUint64(uint64(BasisPoints-maxChangeBps))) + lower.Quo(lower, new(big.Int).SetUint64(uint64(BasisPoints))) + if !upper.IsUint64() || !lower.IsUint64() { + return 0, ErrZCUReference + } + if next > upper.Uint64() { + return upper.Uint64(), nil + } + if next < lower.Uint64() { + return lower.Uint64(), nil + } + return next, nil +} diff --git a/internal/v2/economics/zcu_reference_test.go b/internal/v2/economics/zcu_reference_test.go new file mode 100644 index 0000000..f49ca71 --- /dev/null +++ b/internal/v2/economics/zcu_reference_test.go @@ -0,0 +1,54 @@ +package economics + +import ( + "testing" + + "github.com/zephyr-chain/zephyr-chain/internal/v2/compute" +) + +func TestBuildZCUReferenceUsesWeightedMedianAndRateLimit(t *testing.T) { + cfg := ZCUReferenceConfig{MinSlotsPerClass: 2, EWMABps: BasisPoints, MaxEpochChangeBps: 500} + prior := ZCUReferenceSnapshot{Epoch: 9} + prior.ClassReferenceQ9[compute.WorkGPUFP32] = ZCUReferenceScaleQ9 + + slots := []VerifiedComputeSlot{ + {Class: compute.WorkGPUFP32, PerformanceQ9: 900_000_000, DeliveredSlotTime: 10, AvailabilityEWMABps: 10_000, SuccessEWMABps: 10_000, ConfidenceBps: 10_000}, + {Class: compute.WorkGPUFP32, PerformanceQ9: 1_100_000_000, DeliveredSlotTime: 100, AvailabilityEWMABps: 10_000, SuccessEWMABps: 10_000, ConfidenceBps: 10_000}, + {Class: compute.WorkGPUFP32, PerformanceQ9: 5_000_000_000, DeliveredSlotTime: 1, AvailabilityEWMABps: 10_000, SuccessEWMABps: 10_000, ConfidenceBps: 1_000}, + } + + snapshot, err := BuildZCUReference(10, slots, prior, cfg) + if err != nil { + t.Fatal(err) + } + if !snapshot.ClassReliable[compute.WorkGPUFP32] { + t.Fatal("expected GPU reference to be reliable") + } + if got, want := snapshot.ClassReferenceQ9[compute.WorkGPUFP32], uint64(1_050_000_000); got != want { + t.Fatalf("reference = %d, want %d", got, want) + } +} + +func TestBuildZCUReferenceRequiresVerifiedCoverage(t *testing.T) { + cfg := ZCUReferenceConfig{MinSlotsPerClass: 2, EWMABps: 5_000, MaxEpochChangeBps: 100} + snapshot, err := BuildZCUReference(1, []VerifiedComputeSlot{{ + Class: compute.WorkCPUGeneral, PerformanceQ9: ZCUReferenceScaleQ9, DeliveredSlotTime: 10, + AvailabilityEWMABps: 10_000, SuccessEWMABps: 10_000, ConfidenceBps: 10_000, + }}, ZCUReferenceSnapshot{}, cfg) + if err != nil { + t.Fatal(err) + } + if snapshot.ClassReliable[compute.WorkCPUGeneral] || snapshot.ClassReferenceQ9[compute.WorkCPUGeneral] != 0 { + t.Fatal("single slot must not establish a reliable reference") + } +} + +func TestBuildZCUReferenceRejectsInvalidEvidence(t *testing.T) { + _, err := BuildZCUReference(1, []VerifiedComputeSlot{{ + Class: compute.WorkGPUFP64, PerformanceQ9: ZCUReferenceScaleQ9, DeliveredSlotTime: 1, + AvailabilityEWMABps: 10_001, SuccessEWMABps: 10_000, ConfidenceBps: 10_000, + }}, ZCUReferenceSnapshot{}, ZCUReferenceConfig{MinSlotsPerClass: 1, EWMABps: 10_000, MaxEpochChangeBps: 100}) + if err == nil { + t.Fatal("expected invalid availability evidence to fail") + } +} diff --git a/internal/v2/economics/zppi.go b/internal/v2/economics/zppi.go new file mode 100644 index 0000000..90b00cb --- /dev/null +++ b/internal/v2/economics/zppi.go @@ -0,0 +1,136 @@ +package economics + +import ( + "errors" + "math/big" +) + +const ( + ZPPIPriceScaleQ9 uint64 = 1_000_000_000 + PurchasingPowerRetentionAnnualQ9 uint64 = 980_000_000 + TargetZPPIAnnualFactorQ9 uint64 = 1_020_408_163 +) + +var ErrZPPI = errors.New("invalid Zephyr purchasing power index input") + +type ZPPIComponent uint8 + +const ( + ZPPIUnknown ZPPIComponent = iota + ZPPICompute + ZPPIDataAvailability + ZPPIStorage + ZPPIComponentCount +) + +type ZPPIBasketConfig struct { + WeightsBps [ZPPIComponentCount]uint32 + ReferencePriceQ9 [ZPPIComponentCount]uint64 + MinCoverageBps uint32 + EWMABps uint32 +} + +type ZPPIObservation struct { + Component ZPPIComponent + PriceQ9 uint64 + Reliable bool +} + +type ZPPISnapshot struct { + Epoch uint64 + ComponentIndexQ9 [ZPPIComponentCount]uint64 + BasketIndexQ9 uint64 + CoverageBps uint32 + Reliable bool +} + +// BuildZPPI constructs a version-scoped, chain-weighted price-relative basket. +// Each component is normalized to the reference price fixed when the basket +// version activates, so heterogeneous services can be combined without treating +// their raw prices as directly interchangeable. +func BuildZPPI(epoch uint64, observations []ZPPIObservation, prior ZPPISnapshot, cfg ZPPIBasketConfig) (ZPPISnapshot, error) { + if epoch == 0 || cfg.MinCoverageBps > BasisPoints || cfg.EWMABps > BasisPoints || (prior.Epoch != 0 && prior.Epoch >= epoch) { + return ZPPISnapshot{}, ErrZPPI + } + var totalWeight uint64 + for component := ZPPIComponent(1); component < ZPPIComponentCount; component++ { + if cfg.WeightsBps[component] > BasisPoints { + return ZPPISnapshot{}, ErrZPPI + } + totalWeight += uint64(cfg.WeightsBps[component]) + if cfg.WeightsBps[component] > 0 && cfg.ReferencePriceQ9[component] == 0 { + return ZPPISnapshot{}, ErrZPPI + } + } + if totalWeight == 0 || totalWeight > uint64(BasisPoints) { + return ZPPISnapshot{}, ErrZPPI + } + + latest := make(map[ZPPIComponent]ZPPIObservation, len(observations)) + for _, observation := range observations { + if observation.Component <= ZPPIUnknown || observation.Component >= ZPPIComponentCount || observation.PriceQ9 == 0 { + return ZPPISnapshot{}, ErrZPPI + } + if _, duplicate := latest[observation.Component]; duplicate { + return ZPPISnapshot{}, ErrZPPI + } + latest[observation.Component] = observation + } + + out := ZPPISnapshot{Epoch: epoch} + basket := new(big.Int) + var activeWeight uint64 + for component := ZPPIComponent(1); component < ZPPIComponentCount; component++ { + weight := uint64(cfg.WeightsBps[component]) + if weight == 0 { + continue + } + observation, ok := latest[component] + if !ok || !observation.Reliable { + continue + } + index, err := scaledRatio(observation.PriceQ9, ZPPIPriceScaleQ9, cfg.ReferencePriceQ9[component]) + if err != nil { + return ZPPISnapshot{}, ErrZPPI + } + index, err = ewma(prior.ComponentIndexQ9[component], index, cfg.EWMABps) + if err != nil { + return ZPPISnapshot{}, ErrZPPI + } + out.ComponentIndexQ9[component] = index + activeWeight += weight + basket.Add(basket, new(big.Int).Mul(new(big.Int).SetUint64(index), new(big.Int).SetUint64(weight))) + } + if activeWeight == 0 { + return out, nil + } + basket.Quo(basket, new(big.Int).SetUint64(activeWeight)) + if !basket.IsUint64() { + return ZPPISnapshot{}, ErrZPPI + } + out.BasketIndexQ9 = basket.Uint64() + out.CoverageBps = uint32(activeWeight * uint64(BasisPoints) / totalWeight) + out.Reliable = out.CoverageBps >= cfg.MinCoverageBps + return out, nil +} + +func PurchasingPowerQ9(zppiQ9 uint64) (uint64, error) { + if zppiQ9 == 0 { + return 0, ErrZPPI + } + return scaledRatio(ZPPIPriceScaleQ9, ZPPIPriceScaleQ9, zppiQ9) +} + +// TargetZPPIFromPrior applies the canonical annual target corresponding to a +// 2% purchasing-power decline: 1 / 0.98 ~= 1.020408163. +func TargetZPPIFromPrior(priorZPPIQ9 uint64) (uint64, error) { + if priorZPPIQ9 == 0 { + return 0, ErrZPPI + } + value := new(big.Int).Mul(new(big.Int).SetUint64(priorZPPIQ9), new(big.Int).SetUint64(TargetZPPIAnnualFactorQ9)) + value.Quo(value, new(big.Int).SetUint64(ZPPIPriceScaleQ9)) + if !value.IsUint64() { + return 0, ErrZPPI + } + return value.Uint64(), nil +} diff --git a/internal/v2/economics/zppi_test.go b/internal/v2/economics/zppi_test.go new file mode 100644 index 0000000..53254ec --- /dev/null +++ b/internal/v2/economics/zppi_test.go @@ -0,0 +1,61 @@ +package economics + +import "testing" + +func TestBuildZPPIChainWeightedBasket(t *testing.T) { + cfg := ZPPIBasketConfig{MinCoverageBps: 8_000, EWMABps: BasisPoints} + cfg.WeightsBps[ZPPICompute] = 5_000 + cfg.WeightsBps[ZPPIDataAvailability] = 3_000 + cfg.WeightsBps[ZPPIStorage] = 2_000 + cfg.ReferencePriceQ9[ZPPICompute] = 100 * ZPPIPriceScaleQ9 + cfg.ReferencePriceQ9[ZPPIDataAvailability] = 20 * ZPPIPriceScaleQ9 + cfg.ReferencePriceQ9[ZPPIStorage] = 10 * ZPPIPriceScaleQ9 + + snapshot, err := BuildZPPI(1, []ZPPIObservation{ + {Component: ZPPICompute, PriceQ9: 102 * ZPPIPriceScaleQ9, Reliable: true}, + {Component: ZPPIDataAvailability, PriceQ9: 20 * ZPPIPriceScaleQ9, Reliable: true}, + {Component: ZPPIStorage, PriceQ9: 10 * ZPPIPriceScaleQ9, Reliable: false}, + }, ZPPISnapshot{}, cfg) + if err != nil { + t.Fatal(err) + } + if !snapshot.Reliable || snapshot.CoverageBps != 8_000 { + t.Fatalf("coverage/reliability = %d/%v", snapshot.CoverageBps, snapshot.Reliable) + } + if got, want := snapshot.BasketIndexQ9, uint64(1_012_500_000); got != want { + t.Fatalf("basket index = %d, want %d", got, want) + } +} + +func TestPurchasingPowerTargetConversion(t *testing.T) { + target, err := TargetZPPIFromPrior(ZPPIPriceScaleQ9) + if err != nil { + t.Fatal(err) + } + if target != TargetZPPIAnnualFactorQ9 { + t.Fatalf("target = %d, want %d", target, TargetZPPIAnnualFactorQ9) + } + power, err := PurchasingPowerQ9(target) + if err != nil { + t.Fatal(err) + } + if power < 979_999_999 || power > 980_000_001 { + t.Fatalf("purchasing power = %d, want approximately 980000000", power) + } +} + +func TestBuildZPPIFailsClosedOnLowCoverage(t *testing.T) { + cfg := ZPPIBasketConfig{MinCoverageBps: 7_500, EWMABps: 5_000} + cfg.WeightsBps[ZPPICompute] = 5_000 + cfg.WeightsBps[ZPPIStorage] = 5_000 + cfg.ReferencePriceQ9[ZPPICompute] = ZPPIPriceScaleQ9 + cfg.ReferencePriceQ9[ZPPIStorage] = ZPPIPriceScaleQ9 + + snapshot, err := BuildZPPI(1, []ZPPIObservation{{Component: ZPPICompute, PriceQ9: ZPPIPriceScaleQ9, Reliable: true}}, ZPPISnapshot{}, cfg) + if err != nil { + t.Fatal(err) + } + if snapshot.Reliable || snapshot.CoverageBps != 5_000 { + t.Fatalf("low coverage must remain unreliable: %d/%v", snapshot.CoverageBps, snapshot.Reliable) + } +}