filestorage: storage economics — weights, Ω, reward split, and creation fee - #441
filestorage: storage economics — weights, Ω, reward split, and creation fee#441adamkrellenstein wants to merge 12 commits into
Conversation
| let omega_f = econ.omega_f(); | ||
| model.try_update_omega(|o| o.add(omega_f))?; | ||
| } | ||
| model.update_active_file_count(|c| c + 1); |
There was a problem hiding this comment.
Omega and active-file-count updates are decoupled on activation
Medium Severity
In join_agreement, update_active_file_count is placed outside the if let Some(econ) guard. When agreement_economics returns None (e.g. for pre-existing agreements from before this feature was deployed), omega is silently not updated but active_file_count still increments. This desynchronizes the two accumulators — active_file_count will be inflated relative to the omega sum, causing future k_f calculations (which depend on active_file_count) to use an incorrect value.
Reviewed by Cursor Bugbot for commit 896a24c. Configure here.
| let s_bytes = descriptor.original_size; | ||
| if s_bytes == 0 { | ||
| return Err(Error::Message("original_size must be positive".to_string())); | ||
| } |
There was a problem hiding this comment.
Validation of original_size occurs after state mutations
Low Severity
The original_size == 0 check is placed after model.agreements().set() and model.agreement_nodes().set() have already mutated state. This breaks the validate-before-mutate pattern established by the existing checks at the top of create_agreement. The runtime does roll back on error, so this is functionally safe today, but placing the validation with the other input checks (before any state writes) would be more robust and consistent.
Reviewed by Cursor Bugbot for commit 896a24c. Configure here.
896a24c to
8eb1f45
Compare
| let s_bytes = descriptor.original_size; | ||
| if s_bytes == 0 { | ||
| return Err(Error::Message("original_size must be positive".to_string())); | ||
| } |
There was a problem hiding this comment.
Late original_size validation after state mutations in create_agreement
Low Severity
The s_bytes == 0 validation at line 181 runs after the agreement has been stored and the file descriptor registered (lines 161–174). Other input validations (file_id, padded_len) are grouped at the top of create_agreement. Moving the original_size check to join the existing input validation block avoids unnecessary state mutations and host calls that must be rolled back on failure, and keeps validation logic consistent.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 8417893. Configure here.
|
Note for reviewers: beyond the storage-economics body above, this PR's later commits also include (1) the challenge-selection determinism fix (sorting |
Wraps the host's fastnum-backed log10-decimal (already in the numbers WIT) as a Log10 trait on the contract Decimal, generated alongside CheckedArithmetics in the impls macro. Deterministic across indexers (unlike f64::log10), which makes it safe for consensus-affecting math. Natural log is log10(x)·ln(10). Enables the storage emission-weight (ω_f) and base-stake (k_f) formulas.
Implements the storage-protocol economic layer in the filestorage contract. At create_agreement, each file's emission weight and per-node base stake are fixed from the network state captured before the file activates: rank_f = total_files_ever_created + r_offset + 1 ω_f = log10(s_bytes) / log10(1 + rank_f) k_f = (ω_f / Ω) · c_stake · ln(1 + (|F| + 1) / F_scale) ω_f is ln(s)/ln(1+rank); the ln(10) factors cancel, so base-10 log alone suffices and only k_f's outer natural log needs log10(x)·ln(10). All math runs on the host's deterministic fixed-point Decimal (consensus-safe, unlike f64). Ω starts at Ω_genesis and grows by ω_f each time a file reaches min-replication and activates; |F| tracks the active-file count. distribute_storage_rewards splits a per-block pool across active files (weighted by ω_f/Ω) and equally among each file's active nodes, leaving the genesis dilution mass undistributed and letting the last allocation absorb the rounding remainder for exact conservation. Crediting stays with the reactor (cross-contract seam). Parameters Ω_genesis=1000 and r_offset=1000 are pinned (v1-parameters §5); c_stake and F_scale are post-launch tuning with placeholder defaults. All four are admin-tunable via set_storage_params. Deactivation is not yet modeled (matches the existing leave_agreement behavior), so Ω and |F| are currently monotonic; both must be decremented once agreements can deactivate. Covered by a local lite test exercising weight monotonicity, rank/Ω/|F| tracking, reward positivity and sub-pool bounds, and the param round-trip.
Implements the storage-protocol creation fee. At create_agreement, after k_f is fixed, the fee υ_f = χ_fee · k_f is burned from the creator's spendable balance via the token contract (filestorage now imports token). The fee is returned in CreateAgreementResult.fee. The burn is the last step (CEI) — an insufficient balance rolls the whole agreement creation back. χ_fee defaults to 30 bps (0.3% of k_f, per v1-parameters) and is admin-tunable through the storage-params surface. At genesis k_f (and thus υ_f) is tiny, so the fee is a spam/rank-slot deterrent rather than meaningful revenue until the network grows. Because a Kontor NFT is storage-backed — nft::mint creates a backing filestorage agreement for the NFT's file — minting an NFT now also pays υ_f. This is intended: the backing file is a genuine storage agreement (rank slot, ω_f/k_f, emissions, challenges) and should pay the creation fee like any other. nft.wasm.br is rebuilt accordingly (the new fee field is additive; nft only reads agreement_id). Covered by the storage economics lite test (fee charged, 0 < fee < k_f, params round-trip) and all existing filestorage/nft create paths still pass (test identities are funded).
…c_stake Two fixes on the storage contract: 1. Determinism (consensus-critical). generate_challenges_for_block selected the challenged agreement and prover node by RNG-indexing into Vecs built from Map::keys(), which has no defined order — so two indexers could pick different (file, node) pairs from the same per-block seed and fork. Both eligible_agreement_ids and active_nodes are now sorted before indexing, the same discipline the reward-distribution paths already use. (Pre-existing on main; surfaced by the economic-math audit. PoR fixtures unaffected — the deterministic e2e path uses create_challenge_for_agreement, and the lucky-hash test doesn't assert prover identity.) 2. c_stake default is now the calibrated 1,000,000 (Documentation modeling/analyses/12, rounded), replacing the placeholder 1. At genesis k_f (and thus the υ_f fee) stays small, so the lite/e2e tests still pass. Also drops the "admin-tunable" param comments: during the beta nearly every parameter is admin-tunable, so annotating individual ones is misleading noise.
…ment Implements the Leave-Agreement economics. For an ACTIVE agreement, voluntary departure is forbidden when it would take the agreement at or below minimum replication (|N_f| <= n_min), and otherwise charges φ_leave = k_f·(n_min/|N_f|)² — quadratic, escalating as replication nears n_min — burned from the signer's spendable balance (CEI: effects before the burn; insufficient balance rolls the departure back). The fee is returned in LeaveAgreementResult.fee. The guard and fee apply only to active agreements: a node may leave an inactive agreement (one that never reached n_min, so no file is being stored or challenged) freely and fee-free. This matches the spec intent (the rule protects a stored file's replication) and keeps join-then-leave on not-yet-active agreements working. Replaces the long-standing TODO. nft.wasm.br rebuilt (additive fee field on the shared result record). Lite tests: active leave charges the fee, the guard rejects at minimum replication then permits after a node is added, and inactive leaves stay free.
Add the Phase 2 slashing affordances to the storage contract: - `get_failed_challenges()` view returning challenges whose proof was rejected (`ChallengeStatus::Failed`) — the slashable set a future slasher enumerates to penalise non-retrievable storage. - `λ_slash` (DEFAULT_LAMBDA_SLASH = 30) as a filestorage protocol parameter with a `get_lambda_slash()` getter. λ_slash lives here, not in staking, by design: the *magnitude* of a storage slash (λ_slash · k_f) is storage-domain policy, while staking's `slash(amount)` stays generic — it applies an explicit amount plus the consensus-domain τ_slash burn/redistribute split. filestorage will compute the amount and call staking::slash. The challenge→slash wiring itself is deferred to Phase 2 (needs node-id ↔ staking-identity coupling); this lands the read-side affordance and the magnitude parameter that wiring will consume. Extends `filestorage_defaults` to assert the λ_slash default and an empty failed-challenge set on a fresh runtime.
bcda98f to
8efe3ec
Compare
set_storage_params wrote every field unchecked — the one tunable-param setter in the storage stack without validation. A core/governance call with f_scale = 0 would make every subsequent create_agreement div-by-zero (permanent DoS until re-tuned); chi_fee_bps > 10000 would push the creation fee above k_f; c_stake / omega_genesis of 0 are degenerate. Reject all four before writing, matching how staking/token/congestion guard their params.
| model.update_c_stake(|_| params.c_stake); | ||
| model.update_f_scale(|_| params.f_scale); | ||
| model.update_chi_fee_bps(|_| params.chi_fee_bps); | ||
| Ok(params) |
There was a problem hiding this comment.
Missing r_offset validation in set_storage_params enables overflow
Medium Severity
set_storage_params validates f_scale, omega_genesis, chi_fee_bps, and c_stake but performs no bounds check on r_offset. A large r_offset (near u64::MAX) causes total_files_ever_created + r_offset + 1 to overflow in compute_agreement_economics, which would panic or wrap — bricking create_agreement for all future callers until the parameter is corrected.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 8e472e3. Configure here.
Add property invariants for the storage emission weights and reward split, mirroring the modeling `test_storage.py` oracle: - storage_economics_invariants_over_random_sizes: over 12 random file sizes — ω_f/k_f strictly positive; rank_f increments by exactly 1 per creation; inactive creates leave Ω unchanged; each activation strictly increases Ω and increments |F|; distribute_storage_rewards conserves (Σ allocations ≤ pool, each positive). Self-relative (captures starting Ω/|F|). - storage_omega_f_decreases_with_rank_at_equal_size: equal size + higher rank ⇒ strictly smaller ω_f (ω_f = log10(s)/log10(1+rank)). Wired as a fresh-runtime `test_file_storage_storage_properties` so it isn't polluted by the smoke test's state. Design decision: asserts structural invariants (positivity, monotonicity, Σ≤pool) via comparisons, not re-derived exact ω_f/k_f values — robust to fixed-point rounding; exact values stay covered by the smoke test and the Python oracle. Test-only.
The comment referenced a "leave_agreement TODO" that no longer exists — leave_agreement already enforces the n_min voluntary-departure floor and charges φ_leave. Reworded to state the real invariant: Ω and |F| are monotonic by design (Activation Permanence — an active agreement never leaves F), so there is deliberately no decrement counterpart. The stale reference was misleading enough to read as "n_min enforcement is incomplete," which it is not.
The storage tests asserted the creation fee υ_f was *charged* (fee > 0) but not that it was actually *burned from total supply* through the execution path. Added a supply-conservation assertion to filestorage_create_and_get: total supply strictly decreases across create_agreement, by at least the returned fee (the remainder is gas, also burned). filestorage_create_and_get runs in both modes, so this is verified in-process by the _local variant and end-to-end — mined Bitcoin block → indexer → contract → asserted supply change — by the regtest variant in CI. Closes part of the economic-e2e gap: an economic state transition asserted through block processing, not just an in-process property loop. Required adding a token import! to the module to read total_supply.
tests: storage-economics formula + conservation property tests
test: assert storage creation-fee burn through the e2e path
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 5 total unresolved issues (including 4 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f9763da. Configure here.
| let n = slots.len(); | ||
| for (i, (agreement_id, node_id, weight)) in slots.into_iter().enumerate() { | ||
| let amount = if i + 1 == n { | ||
| distributed_total.sub(allocated)? |
There was a problem hiding this comment.
Reward split remainder underflow
Medium Severity
In distribute_storage_rewards, per-slot amounts use quantized pool * weight / Ω while the last slot takes distributed_total - allocated. Independent rounding on earlier slots can make allocated exceed distributed_total, so the final subtraction errors and the whole distribution fails despite a valid pool and active files.
Reviewed by Cursor Bugbot for commit f9763da. Configure here.
|
Heads-up for whenever this lands: it's built on the filestorage contract surface from before #483 (in-contract registry on append-only kontor-crypto 0.3.0). Once #483 merges, this branch needs a rebase onto the updated filestorage contract (the file-registry / verify-proof WIT and the slot-bearing agreement record changed). |


The storage-protocol economic layer in the filestorage contract, as one reviewable PR.
Emission weights (per agreement, fixed at creation)
ω_f is ln(s)/ln(1+rank); the ln(10) factors cancel, so base-10 log suffices and only k_f's outer natural log needs
log10·LN10. All math on the host's deterministic fixed-pointDecimal.Ω accumulator + reward split
distribute_storage_rewards(pool)splits a per-block pool across active files (weighted by ω_f/Ω) and equally among each file's active nodes, leaving the genesis dilution mass undistributed; last allocation absorbs the rounding remainder (exact conservation).Storage creation fee
υ_f = χ_fee · k_fcreate_agreementand burned from the creator's spendable balance (filestorage now imports token; burn is CEI-last, so insufficient balance rolls the creation back). Returned inCreateAgreementResult.fee.χ_feedefaults to 30 bps (0.3% of k_f, v1-parameters) and is admin-tunable.nft::mintcreates a backing filestorage agreement — so minting an NFT now also paysυ_f. Intended: the backing file is a genuine storage agreement.nft.wasm.brrebuilt (additive result field).Params
Ω_genesis=1000, r_offset=1000 pinned (v1-parameters §5); c_stake, F_scale, χ_fee admin-tunable via
set_storage_params.Enabler
First commit adds a deterministic
Log10trait on the contractDecimal, wrapping the host's fastnumlog10-decimal(was in the numbers WIT, unwrapped in stdlib), generated alongsideCheckedArithmetics.Testing
Lite test covers weight monotonicity, rank/Ω/|F| tracking, reward positivity + sub-pool bounds, the creation fee (charged, 0 < fee < k_f), and param round-trip. All existing filestorage + nft create paths pass. fmt/clippy clean.
Known follow-up
Deactivation isn't modeled yet (matches
leave_agreement), so Ω and |F| are monotonic; both must decrement once agreements can deactivate. Leave fee φ_leave and the per-block reactor wiring (mint → distribute) are separate.Note
High Risk
Changes consensus-critical economics (token burns, fixed-point log math, Ω/reward formulas) and challenge-selection determinism via sorting; mistakes could fork indexers or misallocate supply.
Overview
Adds the filestorage storage-economics layer: per-agreement weights ω_f / base stake k_f (fixed at
create_agreement), global Ω and active file count |F| (Ω grows on activation), adminstorage_params, anddistribute_storage_rewardsto split a pool by ω_f/Ω across active nodes (genesis dilution left in the pool; last slot takes rounding remainder).Token burns: creation fee υ_f = χ_fee · k_f (CEI-last via
token::burn;CreateAgreementResult.fee) and active leave fee φ_leave = k_f · (n_min/|N_f|)² (LeaveAgreementResult.fee). Leave now blocks when active replication is at n_min (replaces the old “leave doesn’t deactivate” test).Consensus: deterministic
Log10on contractDecimal(stdlib + macro impls) powerscompute_agreement_economics; challenge generation sorts eligible agreements and active nodes before RNG indexing.get_failed_challengesandget_lambda_slashexpose slash-prep state (λ_slash magnitude only; slashing wiring deferred).Tests cover supply drop on create fee, economics smoke/properties, and updated leave behavior.
Reviewed by Cursor Bugbot for commit f9763da. Bugbot is set up for automated code reviews on this repo. Configure here.