Skip to content

filestorage: storage economics — weights, Ω, reward split, and creation fee - #441

Open
adamkrellenstein wants to merge 12 commits into
mainfrom
feat/storage-economics
Open

filestorage: storage economics — weights, Ω, reward split, and creation fee#441
adamkrellenstein wants to merge 12 commits into
mainfrom
feat/storage-economics

Conversation

@adamkrellenstein

@adamkrellenstein adamkrellenstein commented May 30, 2026

Copy link
Copy Markdown
Contributor

The storage-protocol economic layer in the filestorage contract, as one reviewable PR.

Emission weights (per agreement, fixed at creation)

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 suffices and only k_f's outer natural log needs log10·LN10. All math on the host's deterministic fixed-point Decimal.

Ω accumulator + reward split

  • Ω starts at Ω_genesis and grows by ω_f when a file activates (reaches min-replication); |F| tracks active files.
  • 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_f

  • Charged at create_agreement and burned from the creator's spendable balance (filestorage now imports token; burn is CEI-last, so insufficient balance rolls the creation back). Returned in CreateAgreementResult.fee.
  • χ_fee defaults to 30 bps (0.3% of k_f, v1-parameters) and is admin-tunable.
  • Kontor NFTs are storage-backednft::mint creates a backing filestorage agreement — so minting an NFT now also pays υ_f. Intended: the backing file is a genuine storage agreement. nft.wasm.br rebuilt (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 Log10 trait on the contract Decimal, wrapping the host's fastnum log10-decimal (was in the numbers WIT, unwrapped in stdlib), generated alongside CheckedArithmetics.

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), admin storage_params, and distribute_storage_rewards to 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 Log10 on contract Decimal (stdlib + macro impls) powers compute_agreement_economics; challenge generation sorts eligible agreements and active nodes before RNG indexing. get_failed_challenges and get_lambda_slash expose 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.

let omega_f = econ.omega_f();
model.try_update_omega(|o| o.add(omega_f))?;
}
model.update_active_file_count(|c| c + 1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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()));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 896a24c. Configure here.

@adamkrellenstein
adamkrellenstein force-pushed the feat/storage-economics branch from 896a24c to 8eb1f45 Compare May 31, 2026 01:08
@adamkrellenstein adamkrellenstein changed the title filestorage: storage emission weights (ω_f, k_f), Ω accumulator, and reward split filestorage: storage economics — weights, Ω, reward split, and creation fee May 31, 2026
Comment thread native-contracts/filestorage/src/lib.rs
let s_bytes = descriptor.original_size;
if s_bytes == 0 {
return Err(Error::Message("original_size must be positive".to_string()));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8417893. Configure here.

Comment thread native-contracts/filestorage/src/lib.rs
@adamkrellenstein

Copy link
Copy Markdown
Contributor Author

Note for reviewers: beyond the storage-economics body above, this PR's later commits also include (1) the challenge-selection determinism fix (sorting Map::keys() before RNG selection — a pre-existing consensus-fork risk surfaced by the economic-math audit; PoR fixtures unaffected) and (2) the calibrated DEFAULT_C_STAKE = 1,000,000 (from Documentation #40), replacing the placeholder 1.

Comment thread native-contracts/filestorage/src/lib.rs
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.
@adamkrellenstein
adamkrellenstein force-pushed the feat/storage-economics branch from bcda98f to 8efe3ec Compare June 2, 2026 07:11
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

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

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Fix All in Cursor

❌ 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)?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f9763da. Configure here.

@adamkrellenstein adamkrellenstein added enhancement New capability or improvement area: economics Token / staking / storage economics area: storage Filestorage / PoR labels Jun 3, 2026
@adamkrellenstein

Copy link
Copy Markdown
Contributor Author

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: economics Token / staking / storage economics area: storage Filestorage / PoR enhancement New capability or improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant