FilecoinWarmStorageService uses static global pricing. All payment rails use the same price regardless of which provider stores the data. The default storage price is 2.5 USDFC per TiB/month.
All pricing constants (rates, fees, and lockup amounts) are defined in service_contracts/src/lib/PriceListUSDFC.sol. The on-chain entry point is getPriceList() which assembles them into the PriceList struct.
Providers may advertise their own prices in the ServiceProviderRegistry, but these are informational for other services, and does not affect actual payments in FilecoinWarmStorageService.
The payment rate per epoch is calculated from the total data size in bytes:
# Constants
EPOCHS_PER_MONTH = 86400 # 2880 epochs/day × 30 days
TiB = 1099511627776 # bytes
# Default pricing (owner-adjustable)
STORAGE_PRICE_PER_TIB_PER_MONTH = 2.5 USDFC
DATASET_FEE_PER_MONTH = 0.024 USDFC
# Per-epoch rate calculation
sizeBasedRate = totalBytes × STORAGE_PRICE_PER_TIB_PER_MONTH ÷ TiB ÷ EPOCHS_PER_MONTH
DATASET_FEE_PER_EPOCH = DATASET_FEE_PER_MONTH ÷ EPOCHS_PER_MONTH
finalRate = sizeBasedRate + DATASET_FEE_PER_EPOCH
Every dataset with stored data pays a flat 0.024 USDFC/month fee on top of the size-proportional rate. A 1 TiB dataset costs 2.524 USDFC/month. A dataset with no pieces is inactive: no proving is required and no payment accrues.
Precision note: Integer division when computing DATASET_FEE_PER_EPOCH causes minor precision loss. The actual monthly payment (DATASET_FEE_PER_EPOCH × EPOCHS_PER_MONTH) is slightly less than DATASET_FEE_PER_MONTH—under 0.0001%. This is acceptable; see the lockup section below for how pre-flight checks handle this.
In addition to the ongoing storage rate, FWSS charges one-time fees for specific lifecycle operations. These fees are deducted from a lifecycle reserve maintained as fixed lockup on the PDP rail.
| Operation | Fee | Trigger | Recipient |
|---|---|---|---|
| Create dataset | $0.025 | dataSetCreated callback |
SP |
| Add pieces | $0.0005 + $0.0003 × n | piecesAdded callback (n = piece count) |
SP |
| Schedule piece removals | $0.002 | piecesScheduledRemove callback |
SP |
| Terminate service (consent) | $0.00112 | SP-initiated termination with payer EIP-712 signature | SP |
The terminate fee applies only in the consent case: when the SP calls terminateService with a valid payer signature in extraData. Direct termination by the payer or unilateral SP termination does not incur this fee.
The lifecycle reserve is seeded at $0.10 when the dataset is created, stored as lockupFixed on the PDP rail.
LIFECYCLE_RESERVE_TARGET = $0.10
REPLENISH_THRESHOLD = $0.005
Flush mechanism: Operation fees are not paid immediately. Each triggering operation increments pendingOneTimePayments (a local field in DataSetInfo). The accumulated amount is flushed the next time updateStorageRates runs—once per piecesAdded and once per nextProvingPeriod:
modifyRailPayment(pdpRailId, newStorageRate, pendingFees)
lifecycleReserveBalance -= pendingFees
pendingOneTimePayments = 0
This keeps the hot path at one external call per event.
Replenishment: When lifecycleReserveBalance < pending + REPLENISH_THRESHOLD, the reserve is topped back up via modifyRailLockup, setting lockupFixed to LIFECYCLE_RESERVE_TARGET + pending. After the next flush deducts the pending fees, the net balance returns to LIFECYCLE_RESERVE_TARGET. For piecesAdded and nextProvingPeriod, replenishment and flush happen together inside updateStorageRates. For piecesScheduledRemove, replenishment fires immediately at scheduling time—before the fee is deferred to the next flush—so the reserve covers accumulating debt even when piece removals precede the next proving period by a long interval.
Post-termination: Once the PDP rail is terminated, modifyRailLockup can no longer raise lockupFixed. The reserve balance at termination time is the maximum available for wind-down fees. Clients anticipating many post-termination piece removals should pre-fund the reserve before terminating.
Manual top-up: The payer can call topUpLifecycleReserve(dataSetId, amount) at any time before termination. The payer must have sufficient available funds and lockupAllowance to cover the increase; modifyRailLockup enforces this via the standard operator approval checks.
The complete on-chain price catalogue is exposed via getPriceList() on the view contract that FWSS reports through viewContractAddress(): the StateView today, and the FWSS proxy itself after the ERC-8167 dispatcher transition. It returns a single nested PriceList struct grouping the deployment's token address, streaming rates, one-time operation fees, and lockup amounts/periods:
struct PriceList {
IERC20 token;
PriceListRates rates; // storage, dataset fee, CDN egress, cache-miss egress
PriceListFees fees; // create, add-pieces base+per-piece, schedule-removals, terminate
PriceListLockups lockups; // lifecycle reserve, replenish threshold, CDN amounts, lockup periods
}This is the canonical price discovery API for SDKs and dashboards. The struct is defined in service_contracts/src/lib/PriceList.sol and populated from the constants in PriceListUSDFC.sol.
Only the contract owner can update pricing, by upgrading the contract.
Effect on existing datasets: Pricing changes do not immediately update rates for existing datasets. New rates take effect when pieces are next added or removed. This avoids gas-expensive rate recalculations across all active datasets while ensuring new pricing applies to all future storage operations.
Rate recalculation timing differs for additions and deletions due to proving semantics:
-
Adding pieces: The rate and corresponding lockup requirement update immediately when
piecesAdded()is called, even before proving activation. This fail-fast behavior protects providers by rejecting additions the client cannot fund before the provider commits resources. Streaming storage payment becomes eligible only after the firstnextProvingPeriod()activates proving; pre-activation epochs settle with zero payment. One-time lifecycle fees remain payable before activation. -
Removing pieces: Deletions are scheduled and take effect at the next proving boundary (
nextProvingPeriod()). The client continues paying the existing rate until the removal is finalized. This deferral is required because proofs may challenge any portion of the current data set during the proving period—the provider must continue storing and proving all existing data until the period ends.
Why the asymmetry?
During each proving period, proofs are generated over a fixed data set. The prover must maintain the complete data set because challenges can target any leaf:
-
Additions expand the proof space but don't affect existing challenges. Before activation they update the rate and lockup without earning streaming payment. After activation the new rate becomes payable immediately, even though the added pieces won't be challenged until the next period.
-
Deletions would shrink the proof space mid-period, potentially invalidating challenges. The data must remain intact until
nextProvingPeriod()finalizes the removal. Only then does the rate decrease.
This ensures proof integrity while providing fair payment semantics: additions reserve funding immediately and become payable once proving is active, while deletions remain payable until the proving period boundary.
When a service is terminated (by client or provider), the payment rail enters a lockup period during which funds continue flowing to the provider. Rate change behavior differs from active rails:
-
Additions are blocked:
piecesAdded()reverts after termination. No new pieces can be added to a terminated dataset. -
Deletions are allowed: Piece removals can still be scheduled during the lockup window via
piecesScheduledRemove(), and take effect at the next proving boundary. -
Rate can only decrease or stay the same: Since additions are blocked, the only size changes come from deletions. FilecoinPay enforces
newRate <= oldRateon terminated rails—rate increases are rejected withRateChangeNotAllowedOnTerminatedRail.
This design ensures the provider receives payment at or above the rate established before termination. The lockup period guarantees payment for the agreed service level, while still allowing the client to reduce their data footprint (and rate) through deletions.
Clients pay for storage by depositing USDFC into the Filecoin Pay contract. These funds flow to providers over time based on the storage rate.
Lockup: To protect providers from non-payment, FWSS requires clients to maintain a 30-day reserve of funds. This "lockup" guarantees the provider will be paid for at least 30 days even if the client stops adding funds. The lockup is not a pre-payment—funds still flow to the provider gradually—but it cannot be withdrawn while the storage agreement is active.
lockupRequired = finalRate × EPOCHS_PER_MONTH
For an empty dataset this equals DATASET_FEE_PER_MONTH (0.024 USDFC). For datasets with data, the lockup equals one month's total cost (size-based rate plus dataset fee).
Pre-flight check precision: The pre-flight validation uses a multiply-first formula (DATASET_FEE_PER_MONTH × EPOCHS_PER_MONTH) ÷ EPOCHS_PER_MONTH which preserves the exact monthly value. This produces cleaner error messages and is slightly more conservative than the actual rail lockup. The difference is under 0.0001% and always in the user's favor—they are never required to have less than needed.
Storage duration extends as clients deposit additional funds:
storageDuration = availableFunds ÷ finalRate
Deposits extend the duration without changing the rate (unless adding pieces triggers an immediate rate recalculation, or scheduled deletions take effect at the next proving boundary).
Delinquency: When a client's funded epoch falls below the current epoch, the payment rail can no longer be settled—no further payments flow to the provider. The provider may terminate the service to claim payment from the locked funds, guaranteeing up to 30 days of payment from the last funded epoch.
Data set creation does not activate proving. Client tooling typically uses PDPVerifier's combined create-and-add operation. PDPVerifier delivers that operation to FWSS as separate dataSetCreated() and piecesAdded() callbacks. A data set may receive one or more such piece callbacks before the service provider's first nextProvingPeriod() call.
The data set may also be terminated before proving activates. The first nextProvingPeriod() can still activate proving while the PDP rail's termination window remains open. A callback that modifies payment state must execute strictly before pdpEndEpoch; at pdpEndEpoch, activation succeeds only when no pending fee or scheduled removal requires a payment update. Consent-based immediate termination can therefore coincide with activation in the same epoch when no payment update is needed.
The first nextProvingPeriod() sets provingActivationEpoch to the current epoch A and schedules the first deadline. A is a boundary marker, not a billable epoch; the first billable proving epoch is A+1.
If all pieces are removed, PDPVerifier reports NO_CHALLENGE_SCHEDULED and FWSS suspends proving by clearing the current deadline. The original activation epoch remains the lifetime origin for proving-period deadlines, proof bitmap indices, and payment validation. Adding pieces later does not rebase this history.
For an inactive data set with a prior activation, nextPDPChallengeWindowStart() returns a challenge window on the original timeline. Given the current epoch C, it chooses the earliest canonical deadline D = A + n*M with D >= C+M, and returns D-W, where W is the challenge-window size. The subsequent nextProvingPeriod() independently derives the earliest valid deadline at execution, accepts only a challenge epoch in its window, and preserves A.
Periods elapsed while proving is suspended have no proof and settle with zero payment once their deadlines pass. Proof bits recorded before suspension retain their original period IDs and remain payable. Pieces added while proving is suspended update the rate and lockup immediately, but epochs before the selected reactivation period have no proof and therefore earn no streaming payment.
Because the view call and transaction use their respective current epochs, the returned window can become stale before inclusion if the earliest valid deadline advances. A caller receiving InvalidChallengeEpoch must query nextPDPChallengeWindowStart() again and retry. The challenge must also satisfy PDPVerifier's normal minimum and maximum delay rules; the current mainnet and calibnet parameters provide ample headroom.
Before activation, piecesAdded() still updates the Filecoin Pay rate and lockup requirement. This enforces funding before the provider commits storage, but does not make pre-activation epochs payable. When Filecoin Pay later presents a rate segment ending at or before A, validatePayment() advances settleUpto through the segment with zero payment. Segments crossing A exclude their pre-activation epochs from payment. This also lets terminated, never-activated data sets release their streaming lockup cleanly.
One-time lifecycle fees are independent of proving activation and remain payable when their operations occur.
Proving periods use exclusive-inclusive epoch ranges. The activation epoch (set when nextProvingPeriod() is first called) is a boundary marker, not a billable epoch.
With activation epoch A and period length M (maxProvingPeriod, e.g. 2880 for a 1-day proving period):
Period 0: epochs (A, A+M] (billable epochs A+1 through A+M)
Period 1: epochs (A+M, A+2M] (billable epochs A+M+1 through A+2M)
Period N: epochs (A+N*M, A+(N+1)*M]
The deadline for period N is A + (N+1)*M, the last epoch of the period, and the last epoch at which a proof can be submitted.
The formula (epoch - A - 1) / M maps an epoch to its period number. The - 1 shifts from inclusive-exclusive [A, A+M) to exclusive-inclusive (A, A+M] ranges, so the deadline epoch belongs to its own period rather than the next. The activation epoch itself returns an invalid sentinel (type(uint256).max) because epoch < activationEpoch after the subtraction underflows.
Using the same logic, settlement ranges also use exclusive-inclusive (fromEpoch, toEpoch]. The settlement fromEpoch should be the last settled epoch, and the range is treated as exclusive of that epoch. fromEpoch is also clamped up to A, so any fromEpoch <= A results in the first billable epoch being A+1. toEpoch is the last epoch to settle, and the range is treated as inclusive of this epoch.
validatePayment() is called by FilecoinPay during settleRail().
Settlement progress (settledUpTo) tracks the epoch up to which payments have been processed. validatePayment() determines how far settlement can advance and how much payment is due.
Because payment amount is tied to successfully submitted proofs, it's possible for period can advance settleUpTo while contributing zero to the payment.
Each proving period is in one of three states:
- Proven: Period has a valid proof. Settlement advances and payment is proportional to proven epochs.
- Faulted: Deadline has passed with no proof. Settlement advances but payment is zero.
- Open: Deadline has not yet passed, no proof. Settlement is blocked at the period boundary because the provider may still submit a proof.
When provingActivationEpoch == 0 (data set created, possibly populated, but the SP has not yet called nextProvingPeriod), validatePayment() short-circuits with zero payment and settleUpto = toEpoch, so settlement can advance through pre-activation epochs. Without this, settleRail() reverts with NoProgressInSettlement on never-activated data sets.
Where base rail rate changes have occurred (e.g. pieces were added mid-period, changing the payment rate), FilecoinPay settles each rate "segment" independently (see _settleWithRateChanges). Each segment gets its own validatePayment() call with a toEpoch that may fall anywhere within a proving period. So validatePayment() must be able to handle settlement of near-arbitrary ranges (see _findProvenEpochs).
The function iterates through each proving period that overlaps the settlement range (fromEpoch, toEpoch], applying the proven/faulted/open rules uniformly to each. Partial periods at the start and end of the range are handled by clamping: each period contributes epochs from max(periodStart, fromEpoch) to min(toEpoch, deadline). Only the last period in the range can be open — since toEpoch <= block.number, all earlier periods' deadlines have necessarily passed.
validatePayment() signals the final settlement epoch to FilecoinPay, which is recorded as settledUpTo on the rail. The next setllement call uses this as its fromEpoch, so settlement progresses incrementally. The provider is paid proportional to the number of proven epochs within the range requested for settlement by FilecoinPay, whether that range covers multiple periods, or a partial period due to rate change segmentation.
After termination, the payment rail enters a lockup period. Settlement continues normally during this time:
- If the provider proves all periods, they receive full payment
- If the provider fails to prove some periods, those epochs receive zero payment
- If the provider abandons entirely, settlement advances with zero payment once all deadlines pass
The client's locked funds are released proportionally as settlement progresses. Unproven epochs result in funds returning to the client rather than flowing to the provider.
Service is terminated by calling terminateService(dataSetId) on FWSS. Only the payer or the dataset's service provider is authorized.
terminateService calls FilecoinPay.terminateRail(pdpRailId). That call sets the rail's endEpoch and starts the lockup-period countdown to finalization. FilecoinPay then calls railTerminated() back into FWSS, since FWSS is the PDP rail's validator. The callback sets info.pdpEndEpoch, emits PDPPaymentTerminated, and verifies that the terminator is FWSS itself. The effect of that last check is that the PDP rail can only be terminated through terminateService. A direct FilecoinPay.terminateRail call from a client or SP is rejected inside the callback.
Unilateral termination: When the payer or SP terminates without a consent signature, lockupPeriod is left at its existing 30-day value and endEpoch = block.number + lockupPeriod. The payer's funds continue flowing to the provider during that window.
Immediate termination (consent case): When the SP calls terminateService(dataSetId, extraData) with a valid payer EIP-712 signature, FWSS sets lockupPeriod = 0 before calling terminateRail: endEpoch = block.number and the payer's streaming buffer is released immediately, along with the lifecycle reserve. A payer signature implies consent to releasing the lifecycle reserve and paying the terminate fee from pending in one step, so for underfunded payers this reverts (FilecoinPay blocks the lockup-period change) rather than falling back to the standard 30-day window.
CDN rails are not touched by terminateService. In the standard case they remain active through the PDP rail's 30-day lockup window; for well-funded payers in the immediate case the lockup window is zero so CDN settlement can proceed right away. When the dataset is subsequently deleted (dataSetDeleted callback), FWSS performs best-effort termination of CDN rails, giving FilBeam a 5-day settle window from that point. Any unsettled fixed lockup returns to the payer after the window closes.
Client-initiated termination of a zero-rate rail (a CDN rail called directly on FilecoinPay) is permitted because isAccountLockupFullySettled is trivially true when the payment rate is zero. For a non-zero-rate rail like the PDP rail, a client whose account has fallen behind on lockup settlement cannot call FilecoinPay.terminateRail directly. However, terminateService on FWSS still works for the payer, because FWSS is the caller of terminateRail in that path.
Data sets can also reach teardown without terminateService via the abandonment path; see below.
Dataset deletion (dataSetDeleted) requires the payment rail to be fully settled before the dataset can be removed:
require(settledUpTo >= endEpoch, RailNotFullySettled)
Rationale: The validatePayment() callback reads dataset state (proving status, periods proven) to calculate payment amounts. If the dataset is deleted before settlement completes, validatePayment() cannot function, forcing clients to use settleTerminatedRailWithoutValidation() which pays the full amount regardless of proof status.
Implications:
- Providers must wait for settlement to complete before deleting datasets
- Clients can always settle rails (with zero payment for unproven periods) once deadlines pass
- Dataset deletion timing is controlled by proving period deadlines, not just the lockup period
Timing: To delete a dataset after termination:
- Wait for
block.number >= pdpEndEpoch(lockup period elapsed) - Wait for all proving period deadlines within the lockup to pass
- Call
settleRail()to complete settlement (rail may auto-finalize) - Call
deleteDataSet()to remove the dataset
For well-funded payers in the immediate termination case (lockupPeriod = 0, so endEpoch = block.number), steps 1–4 can all be batched into a single transaction: terminateService → settleRail → deleteDataSet.
State cleared: The dataSetDeleted callback removes dataSetInfo, provingDeadlines, provenThisPeriod, provingActivationEpoch, railToDataSet[pdpRailId], the dataset's entry in clientDataSets[payer], and all dataSetMetadata entries. clientNonces[payer][nonce] is not cleared. It is retained to prevent replay of authorization signatures.
CDN rails are not checked: The settled-up-to requirement above and the pdpEndEpoch checks in the timing list both apply to the PDP rail only. FWSS does not verify CDN rail termination or settlement before allowing dataset deletion, because it does not track the CDN rails' endEpoch (there is no validator callback to set it). In the normal flow this is safe: CDN rails are terminated as part of the dataSetDeleted callback itself.
Abandonment path: When pdpEndEpoch == 0 (the SP never called terminateService), the data set can still be deleted once inactive for INACTIVITY_WINDOW (30 days from lastProvenEpoch, or from provingActivationEpoch for activated-but-never-proven data sets). PDPVerifier gates this: SP-only within the window, permissionless after. FWSS layers its own _verifyInactivity check on top so the SP cannot use this path to skip terminateService on an active data set. Inline teardown via LibRails.abandonRails settles the PDP rail (advancing through unproven epochs via the pre-activation short-circuit), releases the lifecycle reserve back to the payer, terminates the rail, and best-efforts the CDN rails. The SP forfeits any pending one-time op-fees; this is intentional, since the SP walked away. For well-funded payers the lockup period is zeroed before termination, releasing the streaming buffer immediately. For underfunded payers the lockup period cannot be zeroed, so the PDP rail retains its default 30-day window and the streaming buffer is released only after that window elapses.
Datasets with CDN support have three payment rails: a PDP rail for storage proving, and two CDN rails for content delivery:
- Cache-miss rail (
cacheMissRailId): Pays to the storage provider (SP) for origin fetches - Bandwidth rail (
cdnRailId): Pays to the FilBeam beneficiary address (immutably set at deployment)
Both CDN rails have paymentRate = 0 and use fixed lockup for one-time payments based on usage. At dataset creation the cache-miss rail is seeded with 0.3 USDFC and the CDN rail with 0.7 USDFC. Both CDN rails use a 5-day lockup period, which sets the settle window FilBeam has after dataset deletion to claim any remaining fixed lockup.
PDP and CDN rails use fundamentally different payment models:
PDP rail: Uses proof-based settlement. FWSS acts as validator, receiving callbacks to verify that storage proofs were submitted before authorizing payment. Settlement amounts depend on proving status.
CDN rails: Use usage-based settlement via one-time payments. No validator is set. The FilBeam controller calculates payment amounts based on actual egress metrics and calls settleFilBeamPaymentRails() on FWSS, which executes one-time payments from the fixed lockup. This decouples CDN payment logic from the proof-based model used for storage.
Top-up: Clients call topUpCDNPaymentRails() on FWSS to increase their CDN fixed lockup, which extends their egress allowance.
Settlement: The FilBeam controller calls settleFilBeamPaymentRails() on FWSS to execute one-time payments based on usage data. This is the intended settlement path.
Termination: The intended path is terminateCDNService() on FWSS, which terminates both CDN rails and clears the withCDN metadata.
Because CDN rails have no validator, FilecoinPay permits the payer to call terminateRail() directly. Since CDN rails have paymentRate = 0, the payer's lockup is effectively always settled, so this is always permitted. Direct settleRail() calls are also permitted but would be a no-op since there's no streaming rate to settle. This is a constraint of FilecoinPay's design, not the intended usage path.
FWSS tracks CDN-enabled datasets using a withCDN metadata key. This metadata is set when CDN rails are created and deleted when CDN service is terminated through FWSS.
If CDN rails are terminated directly via FilecoinPay (bypassing FWSS), the withCDN metadata remains set because FWSS receives no callback. This creates an out-of-sync state where FWSS believes CDN is active but the underlying rails are terminated or finalized. Subsequent CDN operations (topUpCDNPaymentRails, settleFilBeamPaymentRails) will fail when they attempt to interact with the inactive rails.
Note: There is currently no mechanism to clean up orphaned withCDN metadata. The practical impact is limited: terminateService() does not interact with CDN rails at all, so storage termination always succeeds; and CDN rail termination in dataSetDeleted is best-effort, so dataset deletion also succeeds.
terminateService only terminates the PDP rail. CDN rails remain active through the PDP rail's 30-day lockup window, allowing FilBeam to continue settling CDN usage during that period. When the dataset is subsequently deleted (dataSetDeleted callback), FWSS terminates the CDN rails with best-effort error suppression. The CDN rails then enter their 5-day settle window; any remaining fixed lockup not claimed by FilBeam returns to the payer once the window closes.
FWSS is composed of multiple independently-deployed contracts connected by on-chain references. Understanding the coupling between these contracts is essential for reasoning about upgradeability, failure recovery, and migration.
flowchart TB
PDPV["PDPVerifier (UUPS proxy)"]
FWSS["FilecoinWarmStorageService (UUPS proxy)"]
FPV["FilecoinPayV1 (immutable)"]
SPR["ServiceProviderRegistry (UUPS proxy)"]
SKR["SessionKeyRegistry (immutable)"]
SV["StateView (immutable)"]
USDFC["USDFC Token (immutable ERC20)"]
PDPV -->|PDPListener callbacks| FWSS
FWSS -->|createRail, modifyRailPayment| FPV
FPV -->|IValidator: validatePayment, railTerminated| FWSS
FWSS -->|getProviderIdByAddress, getProviderPayee| SPR
FWSS -->|authorizationExpiry| SKR
SV -->|read-only queries| FWSS
FWSS -.->|references| USDFC
FPV -.->|references| USDFC
Solid arrows indicate call direction. Dashed arrows indicate token references. Bidirectional arrows between FWSS and FilecoinPayV1 reflect the callback pattern: FWSS calls FilecoinPayV1 to manage rails, and FilecoinPayV1 calls back into FWSS (as an IValidator) during settlement and termination.
| Contract | Upgradeable? | Pattern | Key Role |
|---|---|---|---|
| FilecoinWarmStorageService | Yes | UUPS proxy | Orchestrator — manages datasets, pricing, proving lifecycle, and payment rail operations |
| PDPVerifier | Yes | UUPS proxy | Proof-of-data-possession verification; drives dataset lifecycle via PDPListener callbacks |
| ServiceProviderRegistry | Yes | UUPS proxy | Registry of providers, their capabilities, and payee addresses |
| FilecoinPayV1 | No | Immutable | Payment rails engine — deposits, lockups, streaming settlement, rate changes |
| SessionKeyRegistry | No | Immutable | Session key authorization; maps (user, signer, permission) → expiry |
| StateView | No | Immutable | Read-only view contract for efficient external state queries against FWSS; after the dispatcher transition the FWSS proxy serves the same methods through FWSSViewModule |
| USDFC Token | No | Immutable ERC20 | External stablecoin; payment token for all storage and CDN settlements |
FWSS, PDPVerifier, and ServiceProviderRegistry follow the Universal Upgradeable Proxy Standard (UUPS, ERC-1822), a proxy pattern where the upgrade logic lives in the implementation contract rather than the proxy. The proxy delegates all calls to the current implementation, and only the implementation can authorize a switch to a new one. See the OpenZeppelin UUPS guide for details.
On top of the standard UUPS pattern, all three contracts implement the same home-grown two-step announce-then-upgrade pattern. The owner first calls announcePlannedUpgrade(PlannedUpgrade) with the address of the new implementation (PlannedUpgrade.nextImplementation) and a future block number (PlannedUpgrade.afterEpoch). The upgrade cannot execute until that block number is reached. The minimum delay is at least 1 block. When upgradeToAndCall is later invoked, the internal _authorizeUpgrade guard verifies the implementation matches the announcement and the delay has elapsed:
function announcePlannedUpgrade(PlannedUpgrade calldata plannedUpgrade) external onlyOwner {
require(plannedUpgrade.nextImplementation.code.length > 3000); // Prevent accidental upgrades to empty or trivially small implementations.
require(plannedUpgrade.afterEpoch > block.number); // plannedUpgrade.afterEpoch must be at least one block later than `block.number`. The delay is intended to give users time to review the announced implementation before it takes effect.
nextUpgrade = plannedUpgrade;
emit UpgradeAnnounced(plannedUpgrade);
}
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {
require(newImplementation == nextUpgrade.nextImplementation);
require(block.number >= nextUpgrade.afterEpoch);
delete nextUpgrade;
}(This is from the v1.4.0 FilecoinWarmStorageService.sol:429-448.)
A successful _authorizeUpgrade call will enable the old implementation to set the newImplementation address on the proxy, effectively completing the upgrade.
The FWSS constructor sets several immutable addresses that are baked into the implementation bytecode at deploy time:
pdpVerifierAddress— PDPVerifier proxypaymentsContractAddress— FilecoinPayV1usdfcTokenAddress— USDFC token (external)filBeamBeneficiaryAddress— CDN beneficiaryserviceProviderRegistry— ServiceProviderRegistry proxysessionKeyRegistry— SessionKeyRegistry
Because these are Solidity immutable variables, they live in the deployed bytecode rather than in proxy storage. In a UUPS proxy, upgrading the implementation contract replaces the bytecode, so a new implementation can carry different immutable values. Changing any reference therefore requires deploying a new implementation and executing the two-step upgrade.
Proxy vs. non-proxy references. References to proxy contracts (PDPVerifier, ServiceProviderRegistry) are resilient: those contracts upgrade their own logic independently without requiring any change to FWSS. The FWSS immutable address points at the proxy, which remains stable across upgrades.
References to non-proxy contracts (FilecoinPayV1, SessionKeyRegistry) are rigid: replacing them requires a new FWSS implementation carrying the new address, plus the two-step upgrade. State held in the old contract (rail balances, lockups, session key authorizations) is orphaned — it cannot be migrated automatically and must be handled explicitly.
StateView reference. Unlike the references above, FWSS holds its StateView address in a storage variable (viewContractAddress), not an immutable. The owner can update it at any time via setViewContract() without an FWSS upgrade. It can also be set during an upgrade via the migrate() function. External callers (e.g., Synapse) discover the current StateView by reading viewContractAddress() from the FWSS proxy. After the ERC-8167 dispatcher transition, viewContractAddress() returns the FWSS proxy itself, which serves the view methods; the storage slot keeps the last StateView address.
The following sequences describe the primary cross-contract call paths.
sequenceDiagram
participant Caller as Any Caller
participant Client
participant SP as Storage Provider
participant PDPVerifier
participant FWSS
participant ServiceProviderRegistry
participant FilecoinPayV1
rect rgba(248, 248, 248, 0.2)
Note over Client: 1. Dataset Creation
Client->>SP: signed EIP-712 authorization (extraData)
SP->>PDPVerifier: createDataSet(listener, extraData)
PDPVerifier->>FWSS: dataSetCreated callback (extraData)
Note over FWSS: verify client EIP-712 signature
FWSS->>ServiceProviderRegistry: getProviderPayee(providerId)
ServiceProviderRegistry-->>FWSS: payee address
FWSS->>FilecoinPayV1: createRail(client, payee, ...)
FilecoinPayV1-->>FWSS: rail created
end
rect rgba(248, 248, 248, 0.2)
Note over Client: 2. Piece Addition
Client->>SP: signed EIP-712 authorization (extraData)
SP->>PDPVerifier: addPieces(setId, pieces, extraData)
PDPVerifier->>FWSS: piecesAdded callback (extraData)
Note over FWSS: verify client EIP-712 signature
Note over FWSS: recalculate rate from new total size
FWSS->>FilecoinPayV1: modifyRailPayment(railId, newRate)
FilecoinPayV1-->>FWSS: rate updated
end
rect rgba(248, 248, 248, 0.2)
Note over Client: 3. Schedule Deletions
Client->>SP: signed EIP-712 authorization (extraData)
SP->>PDPVerifier: schedulePieceDeletions(setId, pieceIds, extraData)
PDPVerifier->>FWSS: piecesScheduledRemove callback (extraData)
Note over FWSS: verify client EIP-712 signature
Note over FWSS: queue removal (deferred to next proving period)
end
rect rgba(248, 248, 248, 0.2)
Note over Client: 4. Settlement
Caller->>FilecoinPayV1: settleRail(railId)
FilecoinPayV1->>FWSS: validatePayment(railId, fromEpoch, toEpoch)
Note over FWSS: find proven periods in range
FWSS-->>FilecoinPayV1: ValidationResult(modifiedAmount, settleUpto)
Note over FilecoinPayV1: pay modifiedAmount, advance settledUpTo
end
rect rgba(248, 248, 248, 0.2)
Note over Client: 5. Proving
SP->>PDPVerifier: provePossession(proof, ...)
Note over PDPVerifier: verify proof
PDPVerifier->>FWSS: possessionProven callback
Note over FWSS: record proof for current proving period
end
rect rgba(248, 248, 248, 0.2)
Note over Client: 6. Next Proving Period
SP->>PDPVerifier: nextProvingPeriod(setId)
PDPVerifier->>FWSS: nextProvingPeriod callback
Note over FWSS: process scheduled deletions
Note over FWSS: recalculate rate from new total size
FWSS->>FilecoinPayV1: modifyRailPayment(railId, newRate)
FilecoinPayV1-->>FWSS: rate updated
end
rect rgba(248, 248, 248, 0.2)
Note over Client: 7. Termination
Client->>FWSS: (unilateral) terminateService(datasetId)
SP->>FWSS: (unilateral) terminateService(datasetId)
SP->>FWSS: (consent) terminateService(datasetId, payerSignature)
FWSS->>FilecoinPayV1: terminateRail(railId)
FilecoinPayV1-->>FWSS: rail terminated
Note over FilecoinPayV1: unilateral enters 30-day lockup<br/>consent sets endEpoch = block.number
end
Each contract in the system could contain a flaw — a logic bug, storage corruption, or an exploitable vulnerability. The impact of a flaw and the available remediation options depend on whether the contract is upgradeable and how tightly it is coupled to the rest of the system. This section frames the analysis for each component; concrete migration mechanisms are marked as TODOs pending further design work.
PDPVerifier is currently a UUPS proxy. The long-term goal is for PDPVerifier to become immutable once the contract has been sufficiently hardened, but at this stage upgradeability is retained to allow correcting security bugs without redeploying the entire system.
Most flaws in PDPVerifier logic can be corrected by deploying a new implementation and executing the two-step upgrade. Because FWSS holds an immutable reference to the PDPVerifier proxy address (not the implementation), the upgrade is transparent to FWSS — no changes to FWSS are required.
When a PDPVerifier fix requires a new FWSS implementation. Not all fixes are transparent. FWSS implements the PDPListener callback interface (defined in PDPVerifier's codebase) and calls IPDPVerifier.getDataSetLeafCount() on PDPVerifier directly. If a fix requires changing the PDPListener callback signatures, adding new required callback methods, or altering shared types like Cids.Cid, FWSS must also be upgraded with a new implementation that matches the updated interface. In these cases, both upgrades must be coordinated.
Impact assessment. A PDPVerifier bug could corrupt proof records or allow invalid proofs, which would propagate incorrect settlement amounts through FWSS. Proxy storage persists across upgrades, so any storage corruption introduced by the flaw survives the fix. If the bug wrote incorrect data to storage slots, a post-upgrade migration step may be needed to repair the corrupted state.
TODO: Define emergency pause procedures for PDPVerifier. Document storage repair patterns for correcting corrupted proof records after an upgrade.
FilecoinPayV1 is not upgradeable. It holds all payment rail state: balances, lockups, rate change queues, and settlement progress. A flaw in FilecoinPayV1 is the hardest to remediate because there is no in-place upgrade path. The response depends on severity:
Logic bug or feature gap (incorrect settlement, rate miscalculation, new capability needed). Deploy FilecoinPayV2, then upgrade FWSS to a dual-reference implementation carrying both FilecoinPayV1 and FilecoinPayV2 as immutable references. The new FWSS routes existing rails to v1 and new rails to v2. Existing v1 rails drain naturally through normal settlement and termination — no forced migration. An opt-in migration path allows clients and SPs to agree (with signed/verified authorization) to move to new rails: on-chain this means creating new datasets and rails in v2 pointing to the same data on the SP, then terminating the old v1 rails. This is more dramatic on-chain than it is to the user — from the client's and SP's perspective, accounting simply moves over. Users must re-approve USDFC spending allowances for the new contract.
Catastrophic bug (funds at risk, exploitable). The worst case. Emergency action may be required on v1 rails before the dual-reference approach can be deployed. Funds locked in existing rails must be settled or recovered from the old contract, potentially under time pressure.
TODO: Define emergency response procedures for the catastrophic case. Key decisions include: whether an emergency pause or withdrawal mechanism should be built into FilecoinPayV1 proactively, how to handle rails in mid-lockup under adversarial conditions, and emergency response timelines.
FWSS is a UUPS proxy. Most flaws in its logic can be corrected by deploying a new implementation and executing the two-step upgrade. Proxy storage (datasets, proving state, metadata) is preserved across upgrades.
Impact assessment. Because the constructor sets immutable references, a new implementation can simultaneously fix logic and change any contract reference (e.g., pointing to a new FilecoinPayV2 or SessionKeyRegistry). This makes FWSS the pivot point for system-wide migrations. However, storage layout must remain compatible between implementations — adding or reordering storage variables incorrectly can corrupt all persisted state.
When a new implementation is not enough. Several scenarios require more than a simple implementation swap:
-
Proxy storage corruption. If a bug corrupts critical proxy storage slots — the ERC1967 implementation slot, the OwnableUpgradeable owner slot, or the Initializable version counter — the proxy can become permanently non-upgradeable. For example, if the owner is set to
address(0), theonlyOwnerguard on_authorizeUpgraderejects all callers, and no further upgrades can be authorized. Recovery would require deploying an entirely new FWSS proxy and migrating state. -
IValidator interface constraint. FilecoinPayV1 is immutable and calls FWSS through the
IValidatorinterface (validatePayment,railTerminated). A new FWSS implementation must remain backwards-compatible with the existingIValidatorsignatures because FilecoinPayV1 cannot be updated. If a fix fundamentally requires changing the validation interface, it cannot be deployed without also replacing FilecoinPayV1. -
Inherited storage corruption. A new implementation inherits the proxy's existing storage. If a bug corrupted FWSS storage (e.g., the
railToDataSetmapping orDataSetInfoentries), the new implementation inherits the corrupted data. Mismatches between FWSS state and FilecoinPayV1 rail state are particularly difficult because the FilecoinPayV1 side cannot be corrected. A new implementation can include migration logic to repair FWSS-side storage, but any state baked into FilecoinPayV1 rails (payee, token, validator address) is permanent.
TODO: Define post-upgrade data consistency checks. Document storage layout compatibility requirements and testing procedures for implementation upgrades.
ServiceProviderRegistry is a UUPS proxy. Most flaws can be corrected via the two-step upgrade, transparent to FWSS. FWSS calls only two view functions on ServiceProviderRegistry — getProviderIdByAddress and getProviderPayee — both with simple signatures returning primitives. As long as these signatures are preserved, a new ServiceProviderRegistry implementation does not require FWSS changes. FWSS does not import any types from ServiceProviderRegistry.
Impact assessment. Provider registration data persists in proxy storage. Existing payment rails are not affected by registry changes because the payee address is baked into each rail at creation time — rails do not re-read the registry after creation. A registry flaw could affect new dataset creation (incorrect payee lookup) but not settlement of existing rails.
When a new implementation is not enough. The same proxy storage corruption risks apply as with any UUPS proxy — corruption of the owner or implementation slot renders the proxy non-upgradeable. Additionally, if a registry bug caused datasets to be created with incorrect payee addresses, fixing the registry only prevents new incorrect rails. Already-created rails have the wrong payee permanently baked into FilecoinPayV1, which is immutable. Those rails cannot be retroactively corrected. If the fix required changing the getProviderIdByAddress or getProviderPayee function signatures, FWSS would also need a new implementation to match.
TODO: Evaluate whether to add a pause mechanism to ServiceProviderRegistry to prevent new registrations during an incident.
SessionKeyRegistry is not upgradeable. It stores session key authorization mappings used for signature delegation.
Impact assessment. The impact of a SessionKeyRegistry flaw is relatively low compared to FilecoinPayV1 because it holds only authorization state, not funds. Replacing it requires a new FWSS implementation pointing to the new registry (plus the two-step upgrade). Users must re-register their session keys in the new contract. Existing datasets and rails are unaffected — session keys are only checked during dataset creation and piece operations, not during settlement or proving.
TODO: Evaluate a dual-registry transition period to allow users to migrate keys without a hard cutover. This could be implemented in FWSS (checking both old and new registries) or in the next SessionKeyRegistry itself (falling back to the previous registry for keys not yet migrated).
This section describes the v1.4.0 monolith. After the dispatcher transition, the FWSS proxy serves the view methods itself.
StateView is an immutable, auto-generated, read-only contract that queries FWSS storage via extsload. It provides ~40 view methods for off-chain callers. FWSS holds a mutable reference to its current StateView in a storage variable (viewContractAddress), which the owner can update at any time via setViewContract() without an FWSS upgrade.
StateView depends on FWSS's storage layout remaining stable — it reads specific storage slots via the auto-generated FilecoinWarmStorageServiceStateInternalLibrary. If FWSS storage layout changes, StateView (and the internal library) must be regenerated and redeployed to match.
Because StateView is a read-only contract that reads state from FWSS, additive deploys are non-breaking: a new StateView with additional view methods still reads the same underlying FWSS state. Consumers using an older StateView continue to work — they simply lack access to newly added methods. Only bug fixes require coercing consumers to switch to the new address, which is one reason FWSS holds the mutable viewContractAddress reference for discoverability. In practice, consumers like Synapse currently hard-wire the StateView address and bump it when a new version is released. This is low-risk given the simplicity of the contract.
Impact assessment. A StateView bug affects only read-only queries — it cannot corrupt FWSS state or affect settlement, proving, or any on-chain operations. The blast radius is limited to off-chain consumers that route queries through StateView.
Remediation. Deploy a new StateView contract pointing at the same FWSS proxy, then call setViewContract() on FWSS to update the reference. This is an owner-only call on the existing proxy and does not require an FWSS implementation upgrade. The repository no longer generates StateView, so build a fix from the v1.4.0 release tag. After the dispatcher transition this is no longer needed, because the proxy serves the view methods itself.
After the transition, the FWSS proxy serves the view methods through FWSSViewModule, generated from FilecoinWarmStorageServiceStateLibrary by tools/generate_view_module.sh, and FWSSViewContractModule makes viewContractAddress() return the proxy, so callers that discover the view contract through FWSS move over without a change. The deployed StateView keeps working, because ExtsloadModule still serves the storage reads it makes, as long as the storage layout it reads is unchanged.
A bug in a view method still affects only read-only queries. The fix is a josuke migration that routes the affected selectors to a corrected module, subject to the usual announce delay.
Several concerns apply to any multi-contract migration, regardless of which component is flawed.
- Atomicity: Multi-contract migrations cannot be atomic on-chain. If FWSS is upgraded to reference a new FilecoinPayV2 but users have not yet migrated their rails, there is a window where the system is partially operational.
- Coordinated announcement epochs: When multiple contracts must be upgraded in sequence, the
afterEpochdelays should be coordinated so that all upgrades can execute in a single maintenance window. - Multisig flow: All upgrades require owner authorization. If the owner is a multisig, the announce and execute steps each require separate multisig transactions with their own signing rounds.
- Rollback constraints: UUPS upgrades are not reversible without another upgrade cycle. If a new implementation introduces a regression, the fix requires deploying yet another implementation and waiting through the announcement delay.
- Pre-upgrade testing: New implementations should be tested against a fork of the live chain state to verify storage compatibility, correct immutable values, and expected behavior with existing data.
- Storage layout violations: Reordering, inserting, or removing storage variables in a new implementation shifts slot assignments, corrupting all persisted proxy state. The codebase mitigates this through an auto-generated
FilecoinWarmStorageServiceLayout.solthat documents every storage slot position usingforge inspect. CI (check-gen) regenerates this file on every run and fails if the output diverges from the checked-in version, making unintentional slot changes visible in review. Additionally, CI runsjosuke check, which compares the storage every ERC-8167 module declares with each deploymentjosuke.jsonrecords, including the v1.4.0 monolith aslegacy. This catches not only slot removals and reorderings but also type changes (e.g.,uint256→uint128), offset repacking within the same slot that would silently corrupt proxy state, and two modules declaring different variables over the same storage. The contract source also includes explicit comments tying each variable to its layout slot. However, these are detection mechanisms — they surface layout drift in CI and pull-request diffs but do not structurally prevent a developer from reordering variables. The codebase does not use OpenZeppelin storage gaps (uint256[50] __gap) or ERC-7201 namespaced storage. - Backwards incompatibility of implementation contracts: Removing or changing the signature of a public/external function in a new implementation breaks callers that depend on the old ABI — most critically FilecoinPayV1, which is immutable and calls FWSS through the
IValidatorinterface. The codebase checks in ABI JSON files underservice_contracts/abi/and CI (check-abi) regenerates them on every run, failing if any ABI has changed. This ensures that any function addition, removal, or signature change is surfaced as a diff during code review. As with storage layout, this is a detection mechanism — it flags changes but does not structurally prevent a breaking removal.
TODO: Define concrete procedures for coordinated multi-contract upgrades. Document a pre-upgrade testing checklist. Specify rollback procedures and emergency response timelines. Evaluate adopting structural storage protections — such as OpenZeppelin storage gaps, ERC-7201 namespaced storage, ERC-8042, or the Ithaca storage domain pattern — for future contracts. Existing contracts will not be migrated to these patterns.