paritytech / paritytech/web3-storage
RFC: Size provider stake to economic exposure, not just physical bytes
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 ofpayment_lockedacross the provider's open
agreements, plussync_balancefor any open replica agreements. Already
computable in principle, since both are fields already reserved against
the owner; this would tracktotal_locked_paymentonProviderInfo
alongsidecommitted_bytes, incremented at establish/top_up/extend/
top-up-sync-balance and decremented at agreement end and at each
confirm_replica_syncpayout (mirroring howsync_balanceitself
already decreases there).- New config:
ExposureStakeRatio(e.g. aPerbill/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
-
ExposureStakeRatiostarts at 100% (1:1). Proposal: stake must cover
at least as much as the provider currently holds in locked payment across
all open agreements, usingPerbill::from_percent(100)as the initial
value, governance-tunable afterward the same wayMinStakePerByte
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 probabilityp(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'sp < 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 smallmax_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. -
Recheck timing: fail outright, no grace period.
top_up_agreement,
extend_agreement, andtop_up_replica_sync_balanceshould all reject
the call if resulting exposure would exceed current stake, identical
behavior toestablish_storage_agreementtoday. These are all
voluntary actions taken by whoever is choosing to fund more exposure
onto a given provider (the owner, or, sincetop_up_replica_sync_balance
is permissionless, anyone). There is no reason to be lenient, since the
provider can simplyadd_stakefirst if it wants to keep accepting more.
New error:InsufficientStakeForExposure. -
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:
CapacityBelowCommittedalready only blocks loweringmax_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 fieldtotal_locked_payment(running exposure
total acrosspayment_lockedand, for replicas,sync_balance;
maintained the same waycommitted_bytesalready is).- New
Configitem:ExposureStakeRatio. - New
Errorvariant:InsufficientStakeForExposure. confirm_replica_sync: decrementtotal_locked_paymentin 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_paymentalongsidesync_balance.
crates/pallets/storage-provider/src/impls/agreements.rs: centralize stake
check intorequired_stake(), call fromtop_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, includingtop_up_replica_sync_balance.runtimes/web3-storage-local/,runtimes/web3-storage-paseo/: new
ExposureStakeRatioconfig 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'sfind_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 viaclaim_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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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