paritytech / paritytech/web3-storage

RFC: Size provider stake to economic exposure, not just physical bytes

Open
#386 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Rust
Stars
12
Forks
3
Avg merge
2d 2h
Merged PRs (30d)
33

Description

RFC: Size provider stake to economic exposure, not just physical bytes

In plain terms

Current condition. How much stake a provider must lock up is decided
only by how many bytes they claim to store. It has nothing to do with how
much money they're actually holding from clients, or what price they
charged.

Why that's dangerous. A provider can accept many paid storage
agreements, price them however they like, and still only be required to
post a small stake sized to bytes alone. Providers get paid automatically
at expiry with zero proof they ever stored anything (claim_expired_agreement
performs no verification), so a provider can quietly collect payment on many
small, rarely-checked agreements while risking very little. The only real
cost is a one-time registration stake, unrelated to how much money piles up
before anyone catches them.

After the fix. Required stake becomes whichever is larger: the current
bytes-based amount, or a percentage of the money currently locked against
that provider. Holding more client money means locking up more stake to
match. Scaling up the "collect payment, never store anything" strategy now
also grows the capital at risk and the number of chances to get caught,
instead of costing nothing extra as it scales.

Honest limit. This doesn't make any single small, unwatched agreement
perfectly safe on its own; it makes scaling the strategy expensive rather
than free. See the caveat under Design Decision 1 below for the exact
reasoning.

Summary

required_stake is currently MinStakePerByte × committed_bytes, a pure
function of declared storage volume. It has no relationship to price_per_byte,
to the payment_locked a provider is actually holding, or to how many
distinct clients depend on it. Combined with claim_expired_agreement
performing no data verification at all, this makes a specific freeloading
strategy profitable under realistic conditions.

This RFC proposes sizing required_stake off whichever is larger: physical
bytes, or live economic exposure (locked payment across open agreements).

Related: #310 (providers that never store anything) describes this
qualitatively; this RFC gives the precise mechanism and a concrete fix. A
companion follow-up RFC, #387 (forced re-stake deadline + auto-eviction
after a slash), addresses a related but separable gap: what happens to a
provider's other clients once it is slashed. It's filed separately
since it's a bigger behavioral change and shouldn't block this one.


The gap, precisely

establish_storage_agreement (agreements.rs:214-223):

let bytes_as_balance: BalanceOf<T> = new_committed.saturated_into();
let required_stake = T::MinStakePerByte::get()
    .checked_mul(&bytes_as_balance)
    .ok_or(Error::<T>::ArithmeticOverflow)?;
ensure!(
    provider_info.stake >= required_stake,
    Error::<T>::InsufficientStakeForBytes
);

// Pay at the price the provider signed for.
let payment = Self::calculate_payment(terms.price_per_byte, terms.max_bytes, terms.duration)?;

required_stake and payment are computed side by side and never compared.
price_per_byte is entirely provider-chosen (ProviderSettings), with no
protocol floor or ceiling. Two providers with identical committed bytes owe
identical stake, regardless of whether one is pricing bargain, low-duration
storage and the other is pricing premium, long-duration, high-value storage.
The provider holding far more of clients' money carries no more collateral
for it.

Why it's exploitable, not just untidy

claim_expired_agreement (lib.rs:1645-1679) pays the provider in full once an
agreement is past its settlement deadline, gated only on "no pending challenge."
No proof of possession is required. So for a provider willing to never actually
store anything:

profit  = Σ(payment_locked from every agreement that expires uncontested)
        − (storage cost saved, ≈ 0)
risk    = P(ever challenged, on any open agreement) × (entire stake, lost once)

MinProviderStake (registration) is a one-time sunk cost, unrelated to how much
payment_locked accumulates across many small, cheap, rarely-scrutinized
agreements before the first challenge ever lands. Challenging is opt-in and
costs the challenger money (deposit, and for authorized challengers a cost
share), so for low-value or short-duration agreements the realistic probability
of ever being challenged during the term can be low. The stake formula does
nothing to raise the cost of this strategy as accumulated exposure grows.
Only total bytes matters, and cheap agreements can carry small max_bytes
while still collecting real payment over many repeats.

A related implementation gap worth fixing in the same change

The bytes-only check isn't even applied consistently today:

Call path Rechecks stake?
register_provider_internal / update_provider_settings (via validate_settings) Yes, against max_capacity
establish_storage_agreement Yes, against committed_bytes (agreements.rs:220)
establish_replica_agreement Yes, against committed_bytes (agreements.rs:370)
top_up_agreement No. Increases committed_bytes and payment_locked with zero stake check (lib.rs:1687-1760)
extend_agreement No. Can also change price/duration, no recheck
top_up_replica_sync_balance No. Increases a replica's sync_balance, a second pot of reserved payment separate from payment_locked, with zero stake check (lib.rs:2614-2651)

So a provider can already grow both bytes and payment exposure past what their
stake was ever validated against, purely via top-ups and extensions. Any fix
needs one centralized check, called from every mutation site, not four
independent copies of the same formula, which is itself how this gap crept in.

sync_balance matters here because it's real exposure, not incidental
bookkeeping: it's reserved from the agreement owner the same way
payment_locked is, and the provider draws it down over time via
confirm_replica_sync (lib.rs:2504-2609). A replica's true exposure is
payment_locked + sync_balance, not payment_locked alone; a formula that
only looked at payment_locked would undercount every replica agreement.

Proposed change

Introduce a single helper:

/// Stake required to back a provider's current book of business: whichever
/// is larger, physical footprint or live economic exposure.
fn required_stake(committed_bytes: u64, total_exposure: BalanceOf<T>) -> Result<BalanceOf<T>, DispatchError> {
    let by_bytes = T::MinStakePerByte::get()
        .checked_mul(&committed_bytes.saturated_into())
        .ok_or(Error::<T>::ArithmeticOverflow)?;
    let by_exposure = T::ExposureStakeRatio::get()
        .checked_mul(&total_exposure)
        .ok_or(Error::<T>::ArithmeticOverflow)?;
    Ok(by_bytes.max(by_exposure))
}
  • total_exposure = sum of payment_locked across the provider's open
    agreements, plus sync_balance for any open replica agreements. Already
    computable in principle, since both are fields already reserved against
    the owner; this would track total_locked_payment on ProviderInfo
    alongside committed_bytes, incremented at establish/top_up/extend/
    top-up-sync-balance and decremented at agreement end and at each
    confirm_replica_sync payout (mirroring how sync_balance itself
    already decreases there).
  • New config: ExposureStakeRatio (e.g. a Perbill/ratio, tunable via
    governance), the minimum fraction of live exposure that must be covered by
    stake.
  • Call required_stake(...) from all six mutation sites above (register,
    update_settings, establish×2, top_up, extend, top-up-sync-balance),
    replacing the four divergent inline copies.

Design decisions

  1. ExposureStakeRatio starts at 100% (1:1). Proposal: stake must cover
    at least as much as the provider currently holds in locked payment across
    all open agreements, using Perbill::from_percent(100) as the initial
    value, governance-tunable afterward the same way MinStakePerByte
    already is.

    Caveat. A 1:1 ratio does not, by itself, make cheating strictly
    irrational on a single isolated agreement. If a provider cheats and is
    only ever caught with probability p (realistically low for a cheap,
    rarely-scrutinized agreement), the expected value at ratio R is roughly
    payment × (1 − p) − payment × R × p, which is still positive whenever
    p < 1/(1+R). At R=100%, that's p < 50%: a low bar to clear when
    nobody's actively watching a small agreement.

    What the fix actually changes is the aggregate picture, not any single
    agreement in isolation. Today, running the strategy across many small
    agreements costs nothing extra as exposure grows (stake is capped by
    bytes alone, and cheap agreements carry small max_bytes). Under this
    proposal, exposure itself becomes the constraint: scaling the strategy up
    requires proportionally scaling up locked, at-risk capital, and every
    additional agreement is an independent chance to get caught, the same way
    the design doc's own spot-check math compounds (§ "Self-Interested
    Clients as Verification Layer": 0.9³ per week → 98% detection by 3
    months). The mechanism turns "small, individually survivable risk with no
    scaling cost" into "aggregate risk that compounds as the strategy
    scales." It closes the gap in combination with the existing mitigations
    (registration cost, reputation ramp-up, challenging becoming more likely
    as a provider's footprint grows), not as a standalone guarantee on its
    own.

  2. Recheck timing: fail outright, no grace period. top_up_agreement,
    extend_agreement, and top_up_replica_sync_balance should all reject
    the call if resulting exposure would exceed current stake, identical
    behavior to establish_storage_agreement today. These are all
    voluntary actions taken by whoever is choosing to fund more exposure
    onto a given provider (the owner, or, since top_up_replica_sync_balance
    is permissionless, anyone). There is no reason to be lenient, since the
    provider can simply add_stake first if it wants to keep accepting more.
    New error: InsufficientStakeForExposure.

  3. Existing agreements are grandfathered. No retroactive migration. The
    new check only gates new growth (new agreements, top-ups, extensions);
    it never retroactively evicts or flags agreements that were valid when
    created. This mirrors existing precedent in the same pallet:
    CapacityBelowCommitted already only blocks lowering max_capacity
    below what's committed, never evicts existing commitments. No migration
    code needed at all; the new formula simply applies going forward from the
    runtime upgrade.

Touch surface

  • crates/pallets/storage-provider/src/lib.rs:
    • ProviderInfo: new field total_locked_payment (running exposure
      total across payment_locked and, for replicas, sync_balance;
      maintained the same way committed_bytes already is).
    • New Config item: ExposureStakeRatio.
    • New Error variant: InsufficientStakeForExposure.
    • confirm_replica_sync: decrement total_locked_payment in step with
      sync_balance's existing decrement.
    • top_up_replica_sync_balance: add the same stake recheck as
      top_up_agreement/extend_agreement, and increment
      total_locked_payment alongside sync_balance.
  • crates/pallets/storage-provider/src/impls/agreements.rs: centralize stake
    check into required_stake(), call from top_up_agreement/extend_agreement.
  • crates/pallets/storage-provider/src/impls/providers.rs: validate_settings
    updated to use the same helper.
  • crates/pallets/storage-provider/src/benchmarking.rs: new benchmarks for
    the exposure-based checks, including top_up_replica_sync_balance.
  • runtimes/web3-storage-local/, runtimes/web3-storage-paseo/: new
    ExposureStakeRatio config value.
  • docs/design/scalable-web3-storage.md: Economic Model section needs
    updating to document the exposure-based formula, per this repo's
    design-doc-is-canonical rule.

Related

  • #310: qualitative description of the same underlying incentive gap; this
    RFC is the quantitative mechanism and concrete fix.
  • Companion follow-up RFC: #387, forced re-stake deadline + auto-eviction
    after a slash.
  • Noted but not addressed here: confirm_replica_sync's find_matching_root
    check only verifies the submitted root matches one already public on-chain,
    not that the replica actually holds the underlying data. That looks like a
    parallel freeloading vector for replica payment, structurally similar to
    what this RFC found for primaries via claim_expired_agreement, but it's
    a different mechanism and belongs in its own issue.

cc @eskimor, since this touches the Economic Model section of
docs/design/scalable-web3-storage.md you authored. Would appreciate a
sanity check on the exposure-ratio approach before this goes further.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with crates/pallets/storage-provider/src/lib.rs and impls/agreements.rs, then trace establish_storage_agreement, establish_replica_agreement, top_up_agreement, extend_agreement, and top_up_replica_sync_balance. Review how payment_locked, sync_balance, committed_bytes, and ProviderInfo are updated, along with the existing stake checks. Done means the proposed exposure-based check is centralized, applied at every listed mutation site, and covered by tests for growth and arithmetic failures.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
backend, blockchain
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.