# Storage Costs & Prepare API — Architecture
## Problem Statement
Users need to know:
1. **How much does it cost?** — What's the rate for storing X bytes?
2. **How much to deposit?** — If I don't have enough, what's the exact shortfall?
3. **Is FWSS approved?** — Has the user approved FWSS as a trusted operator?
4. **What single action gets me ready?** — One transaction to deposit + approve if needed.
The legacy methods (`calculateStorageCost`, `checkAllowanceForStorage`, `prepareStorageUpload`, `preflightUpload`) did not account for floor pricing, CDN fixed lockup, the buffer problem, or proper approval allowance checks. They have been removed and replaced by `getUploadCosts()` + `prepare()`.
## Contract Primer
### Account State (Filecoin Pay)
Every payer has an account in the Payments contract:
```
funds — Total USDFC deposited
lockupCurrent — Locked amount at last settlement
lockupRate — How much lockup grows per epoch (sum of all active rail rates)
lockupLastSettledAt — Epoch when lockup was last settled
```
Available funds at any epoch T:
```
actualLockup = lockupCurrent + lockupRate * (T - lockupLastSettledAt)
availableFunds = max(0, funds - actualLockup)
debt = max(0, actualLockup - funds)
```
Note: `availableFunds` is clamped to 0. When the account is underfunded
(`actualLockup > funds`), the **debt** — how much the user owes — is lost
by the clamping. We need both values for correct deposit calculation.
The contract exposes `getAccountInfoIfSettled(token, owner)` which returns
`fundedUntilEpoch` — the epoch at which the account runs out of funds:
```
fundedUntilEpoch = lockupLastSettledAt + (funds - lockupCurrent) / lockupRate
```
If `lockupRate == 0`, `fundedUntilEpoch = infinity` (no active rails draining funds).
**Important**: `getAccountInfoIfSettled` clamps `availableFunds` to 0 and does
not expose the debt amount. To compute debt, we must read raw account fields
from `accounts(token, owner)` and calculate it ourselves.
### Underfunded Accounts (The Debt Problem)
When an account's lockup obligations exceed its funds (`actualLockup > funds`),
the account is **underfunded**. This has critical implications:
1. **Partial settlement**: `settleAccountLockup` can only settle up to the epoch
where funds ran out (`fundedUntilEpoch`), not to `block.number`. After partial
settlement: `lockupCurrent = funds`, `lockupLastSettledAt = fundedUntilEpoch`.
2. **Rate changes blocked**: `modifyRailPayment` requires `isAccountLockupFullySettled`
(`lockupLastSettledAt == block.number`). If the account can't settle to the
current epoch, rate changes **revert**. This means uploading new data (which
triggers a rate change via `updatePaymentRates`) will fail.
3. **Deposit must cover the debt first**: When the user deposits via
`depositWithPermitAndApproveOperator`, the modifier settles before and after.
The post-deposit settlement will use the new funds to settle further. But if
the deposit doesn't cover the full debt, `lockupLastSettledAt` still won't
reach `block.number`, and the subsequent upload will revert.
Therefore, `depositNeeded` must include the **debt** (unsettled lockup beyond
available funds) in addition to the new upload's lockup:
```
debt = max(0, (lockupCurrent + lockupRate * elapsed) - funds)
depositNeeded = additionalLockup + runwayAmount + buffer + debt
```
Without accounting for debt, the SDK would tell the user to deposit N USDFC,
part of which gets consumed settling the outstanding lockup, leaving insufficient
funds for the new upload's rate change.
### Payment Rails
Each dataset has 1-3 payment rails:
- **PDP rail** — streaming payment at `paymentRate` per epoch for storage
- **CDN rail** — fixed lockup (`lockupFixed = 0.7 USDFC`) for CDN egress credits
- **Cache miss rail** — fixed lockup (`lockupFixed = 0.3 USDFC`) for cache miss egress credits
Rail lockup formula:
```
railLockup = (paymentRate * lockupPeriod) + lockupFixed
```
Where `lockupPeriod = 86,400 epochs` (30 days) by default.
### Floor Pricing
The FWSS contract enforces a minimum rate per dataset (regardless of data size):
```solidity
function _calculateStorageRate(uint256 totalBytes) internal view returns (uint256) {
uint256 naturalRate = calculateStorageSizeBasedRatePerEpoch(totalBytes, storagePricePerTibPerMonth);
uint256 minimumRate = minimumStorageRatePerMonth / EPOCHS_PER_MONTH;
return naturalRate > minimumRate ? naturalRate : minimumRate;
}
```
Current minimum: `0.06 USDFC/month` (= `0.06 / 86400` per epoch).
Small files pay the floor price. The SDK must mirror this logic.
### Rate Precision
The contract stores pricing natively as **per-month** values:
```solidity
uint256 private storagePricePerTibPerMonth; // 2.5 USDFC
uint256 private minimumStorageRatePerMonth; // 0.06 USDFC
```
The per-epoch rate is derived on-chain via integer division:
```solidity
naturalRate = (totalBytes * storagePricePerTibPerMonth) / (TIB_IN_BYTES * EPOCHS_PER_MONTH)
```
This truncates. If the SDK computes `perEpoch` and then multiplies back to
`perMonth`, the result differs from the original per-month value due to
precision loss. To avoid this:
- Compute `perMonth = (bytes * pricePerTiBPerMonth) / TIB_IN_BYTES` first
(full precision, matches the contract's native unit).
- Derive `perEpoch = perMonth / EPOCHS_PER_MONTH` for lockup/deposit math
(matches what the contract stores on the rail).
- **`perMonth ≠ perEpoch * EPOCHS_PER_MONTH`** due to truncation. Use
`perMonth` for display, `perEpoch` for on-chain calculations.
### Dataset Creation Costs
When creating a new dataset, `validatePayerOperatorApprovalAndFunds` checks that
the payer has enough available funds for the minimum case:
| Scenario | Minimum Available Funds Required |
|----------|----------------------------------|
| No CDN | 0.06 USDFC (floor price to cover 30-day lockup) |
| With CDN | 1.06 USDFC (0.06 floor lockup + 0.7 CDN + 0.3 cache miss) |
Note: The 0.06 USDFC floor is a per-month rate, and FWSS enforces a 30-day
(86,400 epoch) lockup period. So the minimum funds required at dataset creation
equals the floor rate for one lockup period — which happens to be one month.
At creation time, the PDP rail starts with rate = 0. The rate is set when
pieces are added (via `updatePaymentRates` inside the `piecesAdded` callback).
### Operator Approval (Simplified — FWSS as Trusted Service)
The FWSS contract is the "operator" that manages payment rails on behalf of
the user (payer). Before creating or modifying rails, the user must approve the
operator.
The contract's approval system has three-dimensional allowances (rateAllowance,
lockupAllowance, maxLockupPeriod) that cap what an operator can commit. However,
**for FWSS we treat it as a trusted service** and always approve with
`maxUint256` for all three. This matches what filecoin-pin does and avoids the
complexity of tracking per-upload allowance headroom — which was a contributing
factor to the difficulty of explaining this system to users.
The approval check is a **simple binary**:
```
Is FWSS approved with maxUint256 allowances?
No → depositWithPermitAndApproveOperator(depositAmount, maxUint256, maxUint256, maxUint256)
Yes, need deposit → depositWithPermit(depositAmount)
Yes, no deposit → null (ready to upload)
```
**Relevant contract functions for our API:**
- `depositWithPermit(amount)` — deposit only, no approval changes.
- `depositWithPermitAndApproveOperator(amount, maxUint256, maxUint256, maxUint256)`
— deposit + approve FWSS with unlimited allowances. Works for first-time
approval AND re-approval (overwrites existing values).
**Custom operators** (non-FWSS) must still provide explicit allowance values —
the SDK enforces this via validation (see PR #616). The maxUint256 default is
only for the FWSS trusted path.
### `fund()` — Composite Routing Action (synapse-core)
The `fund()` action in `@filoz/synapse-core/pay` implements the approval/deposit
routing table above as a single core action. It is **not** a direct contract
call — it routes to the appropriate contract function based on current state:
| needsApproval | amount > 0 | Action |
|---|---|---|
| true | true | `depositAndApprove` (deposit + approve FWSS with maxUint256 allowances) |
| true | false | `setOperatorApproval` (approve FWSS only) |
| false | true | `depositWithPermit` (deposit only via ERC-2612 permit) |
| false | false | no-op, returns `'0x'` |
`depositWithPermit` is a standard write action (also in synapse-core) that signs
an EIP-712 permit and calls the `depositWithPermit` contract function to approve
and deposit in a single transaction.
**Variants:**
- `fund(client, options)` — returns the transaction hash immediately (or `'0x'`
for no-op). Use when the caller manages receipt waiting themselves.
- `fundSync(client, options)` — calls `fund`, fires `onHash` callback once the
tx is submitted, waits for the receipt, returns `{ hash, receipt }`. Handles
the no-op edge case by returning a null receipt.
Both are exported from `@filoz/synapse-core/pay`.
**SDK integration — `prepare().execute()`:**
The SDK's `StorageManager.prepare()` returns a `transaction` object whose
`execute()` method delegates to `fundSync` under the hood. This means `execute`
waits for on-chain confirmation before returning, and supports an `onHash`
callback for UIs that want to show progress as soon as the tx is submitted:
```ts
const { costs, transaction } = await synapse.storage.prepare({
dataSize: 1024n,
})
if (transaction) {
console.log(`Deposit: ${transaction.depositAmount}`)
console.log(`Includes approval: ${transaction.includesApproval}`)
const { hash, receipt } = await transaction.execute({
onHash: (hash) => console.log('Tx submitted:', hash),
})
// receipt is confirmed by the time we reach here
console.log('Confirmed in block:', receipt?.blockNumber)
}
// Account is now funded and approved — ready to upload
await context.upload(data)
```
When `transaction` is `null`, the account is already funded and approved — no
action needed.
### Rate Changes (Adding Data to Existing Dataset)
When pieces are added to a dataset, `modifyRailPayment` is called:
```
payer.lockupRate = payer.lockupRate - oldRate + newRate
payer.lockupCurrent = payer.lockupCurrent - (oldRate * lockupPeriod) + (newRate * lockupPeriod)
```
Post-condition enforced by the contract: `funds >= lockupCurrent`
The net additional lockup for a rate increase is:
```
additionalLockup = (newRate - oldRate) * lockupPeriod
```
### Why `lockupRate * elapsed` Is Always Correct
A natural concern: between `lockupLastSettledAt` and the current epoch, couldn't
rate changes have occurred that make `lockupRate * elapsed` inaccurate?
No. Every function that modifies `lockupRate` (`modifyRailPayment`, `createRail`,
etc.) has the `settleAccountLockupBeforeAndAfterForRail` modifier, which calls
`settleAccountLockup` **before** the rate change takes effect. This means:
1. Settlement happens at the old `lockupRate` up to `block.number`
2. `lockupLastSettledAt` is updated to `block.number`
3. `lockupRate` is then changed atomically
4. Post-settlement check runs with new values
So for any elapsed period `(currentEpoch - lockupLastSettledAt)`, the
`lockupRate` has been constant the entire time. There is no need to track
historical rate changes for account-level lockup calculation.
**Note**: Rate change queues (`RateChangeQueue.Queue`) exist in the contract but
they are a per-**rail** concept for payment settlement distribution between
payer and operator. They do not affect the account-level `lockupRate` calculation.
**Edge case**: `terminateRail` (operator-initiated) can reduce `lockupRate` on
underfunded accounts without requiring full settlement. This is intentional —
it's a mercy (rate decrease on a bottomed-out account) and doesn't invalidate
the `lockupRate * elapsed` formula since the rate only decreases.
## The Buffer Problem
This is the critical edge case for users with active storage (existing rails).
Between the SDK checking the user's balance (epoch T_check) and the transaction
executing on-chain (epoch T_exec), epochs pass. Each epoch, funds are consumed
at the current `lockupRate`. If we don't account for this, the on-chain
transaction can revert with `InsufficientLockupFunds`.
In multi-context uploads, the problem is worse: the first context's upload
creates new rails that start draining at the new rate before later contexts
execute. So the effective drain rate during the buffer window can be as high
as `netRate = lockupRate + rateDeltaPerEpoch` (the full post-upload rate).
```
At T_check:
availableFunds = AF
debt = D (0 if fully funded)
fundedUntilEpoch = FUE (epoch when account runs out of funds)
netRate = lockupRate + rateDeltaPerEpoch
At T_exec (= T_check + bufferEpochs):
// Worst case: all new rails active for the full buffer window
availableFunds_exec = AF - netRate * bufferEpochs
Post-conditions on chain:
1. Account must be fully settled (lockupLastSettledAt == block.number)
— otherwise modifyRailPayment reverts. This requires covering debt.
2. availableFunds_exec >= additionalLockup
```
### When the Buffer Applies
The buffer protects the **deposit amount** from being consumed by epoch drift
between check and execution. But when no deposit is needed (e.g., rate doesn't
change), the buffer should NOT create a phantom deposit requirement.
Consider: a floor-priced dataset (0.06 USDFC/month minimum) storing a tiny
file. The user deposited exactly 0.06 USDFC. `availableFunds ≈ 0` because all
funds are locked, but the account has ~30 days of funded runway. Adding another
small file doesn't change the rate (still at floor). Blindly applying
`buffer = lockupRate * 5` would incorrectly require a tiny deposit even though
the account easily survives the 5-epoch buffer window.
The fix: check whether the account's `fundedUntilEpoch` extends beyond the
buffer window. If the account won't run out of funds before T_exec, no
buffer-related deposit is needed.
```
fundedUntilEpoch = lockupLastSettledAt + (funds - lockupCurrent) / lockupRate
netRate = lockupRate + rateDeltaPerEpoch
skipBuffer = (currentLockupRate == 0) and allNewDatasets
rawDepositNeeded = additionalLockup + runwayAmount + debt - availableFunds
if skipBuffer:
// New user: no existing rails draining, deposit lands before any rail
// is created. The 1 GiB upload limit is well below the ~26 GiB floor
// threshold, so buffer is unnecessary.
depositNeeded = max(0, rawDepositNeeded)
elif rawDepositNeeded > 0:
// Deposit is needed for the new lockup. Add buffer so the deposit
// amount is sufficient at T_exec (epoch drift consumes funds).
depositNeeded = rawDepositNeeded + netRate * bufferEpochs
elif fundedUntilEpoch <= currentEpoch + bufferEpochs:
// No new lockup needed, but the account is about to expire.
// The settlement at T_exec would fail without a deposit.
depositNeeded = max(0, netRate * bufferEpochs - availableFunds)
else:
// No new lockup needed AND the account has plenty of runway.
// The settlement at T_exec will succeed naturally.
depositNeeded = 0
```
The `debt` term is often 0 for healthy accounts but is critical for
underfunded accounts. Without it, the deposit would be insufficient — the
contract would consume part of it settling old obligations, leaving not
enough for the new upload's rate change.
### New Users: Buffer Skipped
The buffer is skipped when **both** conditions hold:
1. `currentLockupRate === 0` — no existing rails draining funds
2. All contexts are new datasets — no existing datasets being updated
This covers the common new-user flow: first deposit + first upload. Between
the SDK balance check and the deposit transaction, nothing is draining because
there are no active rails. The deposit lands before any rail is created, so
the buffer would only add an unnecessary charge.
Note: if a user already has existing rails (`currentLockupRate > 0`) but is
creating new datasets, the buffer still applies — the existing rails drain
funds between check and execution regardless of what the new upload does.
### When the Buffer Applies (Existing Users)
The `bufferEpochs` parameter controls how much safety margin to include.
Filecoin has 30-second epochs, so:
- 5 epochs = 2.5 minutes (default — covers typical tx confirmation)
- 10 epochs = 5 minutes (conservative)
- 1 epoch = 30 seconds (aggressive — only if you're sure about timing)
Users with high lockup rates (lots of stored data) need proportionally larger
buffers because the per-epoch drain is higher.
### Why `netRate` instead of `currentLockupRate`?
The buffer uses `netRate = currentLockupRate + rateDeltaPerEpoch` instead of
just `currentLockupRate`. Between T_check and the deposit tx, only old rails
drain (`currentLockupRate`), so `netRate` slightly overestimates. This
overestimate is a conservative safety margin for edge cases in multi-context
sequential uploads.
For floor-to-floor additions (adding small data to an existing floor-priced
dataset), `rateDeltaPerEpoch = 0` so `netRate = currentLockupRate` — the
buffer is unchanged and the third branch (`fundedUntilEpoch` beyond buffer
window) still returns 0.
## Time Units: "Month" = 30 Days
Throughout this API, a "month" always means exactly 30 days = 86,400 epochs.
This matches the contract constant `EPOCHS_PER_MONTH = 2880 * 30`.
We cannot align to calendar months. Filecoin epochs are block numbers at
30-second intervals. A "storage month" is a fixed 30-day window. Any calendar
alignment is the caller's responsibility.
The `runwayEpochs` parameter is in epochs. Users who want "30 days" pass
`86400n`. We export `EPOCHS_PER_MONTH` and `EPOCHS_PER_DAY` constants for
convenience.
---
## API Design
### Three Methods, Clear Separation
```
getUploadCosts() — Read-only. Takes a viem client + clientAddress + raw parameters.
Returns: rate, deposit needed, approval needed, readiness.
No transactions, no mutations.
Used by UIs to display "this will cost X, you need Y".
Does NOT need a StorageContext — works with primitive values.
Lives in synapse-core — usable standalone with a viem client.
Fetches account state, pricing, and approval via read-only
contract calls using client + clientAddress.
fund() — Smart deposit. Takes an amount and optional needsFwssMaxApproval
override. Picks depositWithPermit or
depositWithPermitAndApproveOperator based on approval state.
When needsFwssMaxApproval is omitted, checks via RPC.
Lives in synapse-sdk (synapse.payments.fund).
prepare() — Action method. Takes a StorageContext and extracts
the raw params from it (dataSetId, withCDN, currentDataSetSize, etc.),
then calls getUploadCosts() internally.
Returns at most one executable transaction (delegates to fund()).
Can also accept pre-computed UploadCosts to skip redundant RPC.
Lives in synapse-sdk (needs StorageContext, wallet signing).
```
`getUploadCosts()` and all pure calculation functions live in `synapse-core`.
`fund()` lives in `synapse-sdk` on `PaymentsService` (`synapse.payments.fund`).
`prepare()` lives in `synapse-sdk` on `StorageManager` (`synapse.storage.prepare`)
and delegates to `fund()` for transaction execution.
---
### `synapse.storage.getUploadCosts(options)`
Takes a viem client and clientAddress alongside raw parameters — no
StorageContext needed. This makes it usable by UIs that just want to display
pricing without having created a context yet, and by external consumers who
have the raw values from other sources. The client is used for read-only
contract calls (account state, pricing params, operator approval).
The `prepare()` method extracts these raw values from a StorageContext and calls
`getUploadCosts()` internally.
```typescript
// getUploadCosts(client: Client<Transport, Chain>, options: GetUploadCostsOptions): Promise<UploadCosts>
interface GetUploadCostsOptions {
// ──────────────────────────────────────────────────────────────────────
// ACCOUNT
// The payer address to check account state and approval for.
// Used to read account info (funds, lockup) and operator approval
// from the payments contract.
// ──────────────────────────────────────────────────────────────────────
clientAddress: Address
// ──────────────────────────────────────────────────────────────────────
// DATASET STATE
// These describe the dataset you're uploading to.
// For a brand new upload (no existing dataset), use the defaults.
// For adding to an existing dataset, provide the current state.
// ──────────────────────────────────────────────────────────────────────
// Whether a new dataset will be created for this upload.
// True = fresh dataset (no existing data, no existing rails).
// False = adding data to an existing dataset with an active PDP rail.
// Affects CDN fixed lockup (only charged at dataset creation) and
// rate increase calculation (new = from 0, existing = from currentRate).
// Default: true
isNewDataSet?: boolean
// Whether CDN is enabled for this dataset.
// Only affects costs when isNewDataSet is true — existing CDN datasets
// already have their fixed lockup (0.7 CDN + 0.3 cache miss = 1.0 USDFC)
// locked at creation time.
// Default: false
withCDN?: boolean
// Current total data size in the existing dataset, in bytes.
// Only meaningful when isNewDataSet is false.
//
// When provided: computes accurate floor-aware rate delta.
// newRate = calculateEffectiveRate(currentDataSetSize + dataSize)
// currentRate = calculateEffectiveRate(currentDataSetSize)
// delta = newRate - currentRate
// This correctly handles floor-to-floor (delta = 0) and
// crossing the floor threshold.
//
// When omitted (and isNewDataSet is false): treats the upload as if
// the rate increase equals calculateEffectiveRate(dataSize) — the full
// rate for just the new data. This is accurate for above-floor datasets
// (natural rate is linear in size) but OVERESTIMATES for floor-priced
// datasets. The overestimate is safe (user deposits more, not less).
//
// prepare() always provides this from the StorageContext, so the
// overestimate only affects direct getUploadCosts() callers who
// don't have the dataset size handy.
//
// NOTE: We don't need a separate currentDataSetRatePerEpoch param.
// The current rate is derived from currentDataSetSize + pricing params.
// Account-level lockupRate (from the payments contract, covering ALL
// rails — not just this dataset) is used for buffer/debt calculations.
currentDataSetSize?: bigint
// ──────────────────────────────────────────────────────────────────────
// UPLOAD PARAMETERS
// ──────────────────────────────────────────────────────────────────────
// Size of new data to upload, in bytes.
// Used to calculate the post-upload rate and additional lockup needed.
dataSize: bigint
// ──────────────────────────────────────────────────────────────────────
// FUNDING PARAMETERS
// ──────────────────────────────────────────────────────────────────────
// Extra runway to ensure beyond the required lockup period.
// This is added ON TOP of the 30-day lockup that the contract requires.
// For example: if the contract needs 2 USDFC lockup and you pass
// runwayEpochs = 86400 (30 days), the deposit calculation ensures you have
// enough for 2 USDFC lockup PLUS 30 more days of payments at the new rate.
// In epochs. Use EPOCHS_PER_MONTH (86400) for 30 days, EPOCHS_PER_DAY (2880)
// for 1 day, etc.
// When omitted, only the required lockup is calculated (no extra runway).
runwayEpochs?: bigint
// Safety margin for the time between checking balances and executing the
// deposit transaction on-chain. See "The Buffer Problem" above.
// In epochs. Each epoch is ~30 seconds on Filecoin.
// Default: 5n (~2.5 minutes — enough for typical tx confirmation).
// Increase if your users tend to delay between seeing costs and executing.
// Set to 0n if you're calling prepare() immediately after getUploadCosts().
bufferEpochs?: bigint
}
```
#### Return Type: `UploadCosts`
```typescript
interface UploadCosts {
// ──────────────────────────────────────────────────────────────────────
// STORAGE RATE
// The effective rate for the dataset AFTER adding dataSize bytes.
// This is max(naturalRate, floorRate) — mirrors the contract's
// _calculateStorageRate function.
//
// NOTE on precision: The contract stores prices natively as per-month
// values (storagePricePerTibPerMonth, minimumStorageRatePerMonth).
// perMonth is computed as (bytes * pricePerTiBPerMonth) / TIB_IN_BYTES
// WITHOUT dividing by EPOCHS_PER_MONTH, so it preserves full precision.
// perEpoch is perMonth / EPOCHS_PER_MONTH (integer division, truncates).
// Therefore: perMonth ≠ perEpoch * EPOCHS_PER_MONTH.
// Use perMonth for display, perEpoch for lockup/deposit math.
// ──────────────────────────────────────────────────────────────────────
rate: {
// Rate per epoch — matches what the contract stores on the PDP rail.
// Used for lockup and deposit calculations.
perEpoch: bigint
// Rate per month — computed from native per-month pricing without
// epoch division, so no precision loss. Use this for display.
// "Month" = 30 days = 86,400 epochs, always.
perMonth: bigint
}
// ──────────────────────────────────────────────────────────────────────
// DEPOSIT NEEDED
// Total USDFC the user needs to deposit into the payments contract.
// 0n if the account already has sufficient available funds.
//
// Computed by calculateDepositNeeded() which orchestrates four components:
//
// 1. additionalLockup — rate increase * lockupPeriod + CDN fixed lockup
// (from calculateAdditionalLockupRequired)
// 2. runwayAmount — netRate * runwayEpochs
// (from calculateRunwayAmount — netRate = currentLockupRate + rateDelta)
// 3. debt — max(0, totalOwed - funds), unsettled lockup beyond funds
// (from calculateAccountDebt in synapse-core/pay)
// 4. buffer — conditional safety margin for epoch drift
// (from calculateBufferAmount)
//
// Formula:
// rawDepositNeeded = additionalLockup + runwayAmount + debt - availableFunds
// depositNeeded = max(0, rawDepositNeeded) + bufferAmount
//
// The debt term is critical: if the account is underfunded, the contract
// will use part of the deposit to settle outstanding lockup BEFORE the
// new lockup is applied. Without it, the rate change reverts because
// the account can't fully settle to the current epoch.
//
// The buffer is conditional: when no deposit is needed AND the
// account's fundedUntilEpoch extends beyond the buffer window, the
// account survives the epoch drift naturally. See "The Buffer Problem".
// ──────────────────────────────────────────────────────────────────────
depositNeeded: bigint
// ──────────────────────────────────────────────────────────────────────
// OPERATOR APPROVAL
// FWSS is treated as a trusted service. The approval check is a simple
// binary: is FWSS approved with maxUint256 allowances?
//
// When needsFwssMaxApproval is true, fund() uses
// depositWithPermitAndApproveOperator with maxUint256 for all allowances
// in the same tx as the deposit. This avoids the complexity of tracking
// per-upload allowance headroom.
// ──────────────────────────────────────────────────────────────────────
// Whether FWSS needs to be approved (or re-approved with maxUint256).
// True when isApproved is false, or any of rateAllowance, lockupAllowance,
// or maxLockupPeriod are not maxUint256. Computed via isFwssMaxApproved().
// When true, fund() will use depositWithPermitAndApproveOperator.
// When false, fund() will use depositWithPermit (or null if ready).
needsFwssMaxApproval: boolean
// ──────────────────────────────────────────────────────────────────────
// READINESS
// True when depositNeeded == 0n and needsFwssMaxApproval == false.
// User can upload immediately without any preparatory transaction.
// ──────────────────────────────────────────────────────────────────────
ready: boolean
}
```
---
### `synapse.storage.prepare(options)`
Takes a StorageContext (the SDK's provider+dataset pair) and prepares the account
for upload. Internally extracts raw values from the context (isNewDataSet, withCDN,
currentRate, currentSize) and calls `getUploadCosts()`, then delegates to
`fund()` for the actual transaction.
```typescript
interface PrepareOptions {
// ──────────────────────────────────────────────────────────────────────
// CONTEXT
// The StorageContext(s) to prepare for upload.
// prepare() reads the context to extract:
// - isNewDataSet: whether context.dataSetId exists on-chain
// - withCDN: from context metadata / CDN rail presence
// - currentDataSetSize: from the dataset's piece data
// This is the bridge between the SDK's context concept and the
// raw calculation in getUploadCosts().
// When multiple contexts are provided, lockup is summed across all
// (they share the same payer account, so one deposit covers all).
// ──────────────────────────────────────────────────────────────────────
context?: StorageContext | StorageContext[]
// Size of new data to upload, in bytes.
dataSize: bigint
// Extra runway in epochs beyond the required 30-day lockup.
// See getUploadCosts() runwayEpochs for details.
runwayEpochs?: bigint
// Safety margin in epochs. See "The Buffer Problem".
// Default: 5n (~2.5 minutes)
bufferEpochs?: bigint
// ──────────────────────────────────────────────────────────────────────
// PRE-COMPUTED COSTS
// When provided, skips the internal getUploadCosts() call entirely.
// Use this when you've already called getUploadCosts() (e.g., to show
// the user a cost breakdown in a UI) and the user has clicked "proceed".
// Avoids redundant RPC calls and ensures the deposit amount matches
// what was displayed.
// When provided, context/dataSize/runwayEpochs/bufferEpochs are ignored
// (all info is already in the costs object).
// ──────────────────────────────────────────────────────────────────────
costs?: UploadCosts
}
```
#### Return Type: `PrepareResult`
```typescript
interface PrepareResult {
// The cost breakdown (either passed in or freshly computed).
costs: UploadCosts
// The single transaction to execute, or null if already ready.
//
// Delegates to synapse.payments.fund() with pre-computed approval state:
//
// costs.ready (no deposit, no approval needed):
// → null (ready to upload)
//
// Otherwise:
// → fund({ amount: costs.depositNeeded, needsFwssMaxApproval: costs.needsFwssMaxApproval })
// fund() picks depositWithPermitAndApproveOperator or depositWithPermit.
// depositAmount can be 0n when only approval is needed.
//
// Throws if the user's wallet USDFC balance cannot cover depositNeeded.
transaction: {
// Amount to deposit into the payments contract.
// 0n when only approval is needed.
depositAmount: bigint
// Whether this transaction includes FWSS operator approval.
// Mirrors costs.needsFwssMaxApproval.
includesApproval: boolean
// Execute the transaction. Returns the transaction hash.
// Calls fund() internally — signs a permit, submits the tx, waits for confirmation.
execute: () => Promise<Hash>
} | null
}
```
---
### `synapse.payments.fund(options)`
Smart deposit method that picks the right contract call based on FWSS approval
state. This is the single primitive for funding — both `prepare()` and direct
callers use it. Lives in `synapse-sdk` on `PaymentsService`.
Handles the approval-check-to-transaction-selection logic in one place:
- Checks if FWSS is approved with maxUint256 allowances (via `isFwssMaxApproved()`)
- If approved → `depositWithPermit(amount)`
- If not approved → `depositWithPermitAndApproveOperator(amount, maxUint256, maxUint256, maxUint256)`
The `needsFwssMaxApproval` override allows callers (like `prepare()`) to skip the
RPC check when they already know the approval state from `getUploadCosts()`.
```typescript
interface FundOptions {
// Amount of USDFC to deposit into the payments contract.
// Can be 0n when only FWSS approval is needed (no deposit shortfall).
amount: bigint
// ──────────────────────────────────────────────────────────────────────
// APPROVAL STATE OVERRIDE
// When provided, skips the isFwssMaxApproved() RPC check.
// true → FWSS needs approval, uses depositWithPermitAndApproveOperator
// false → FWSS already approved, uses depositWithPermit
// When omitted, fund() checks via isFwssMaxApproved() RPC call.
//
// prepare() always provides this from getUploadCosts().needsFwssMaxApproval
// to avoid a redundant RPC call.
// ──────────────────────────────────────────────────────────────────────
needsFwssMaxApproval?: boolean
}
```
Returns: `Promise<Hash>` — the transaction hash.
Throws if the user's wallet USDFC balance cannot cover the amount.
```
fund(options):
needsApproval = options.needsFwssMaxApproval ?? !(await isFwssMaxApproved(client, { clientAddress }))
if needsApproval:
→ depositWithPermitAndApproveOperator(amount, maxUint256, maxUint256, maxUint256)
else:
→ depositWithPermit(amount)
```
---
## Layer 1: Pure Calculation Functions
These are stateless functions that take values and return computed results.
No on-chain calls, no async, fully unit-testable with deterministic inputs.
Pure calculation functions live in `synapse-core`. `getUploadCosts` also lives
in `synapse-core` but is not purely pure — it makes read-only contract calls
(account state, pricing, approval) using the provided client + clientAddress,
then feeds the results into the pure calculation functions below.
`calculateAccountDebt` and `getAccountInfoIfSettled` live in `synapse-core/pay`;
the rest in `synapse-core/warm-storage`.
```typescript
// ─────────────────────────────────────────────────────────────────────────
// RATE CALCULATION
// Mirrors the contract's _calculateStorageRate function.
// ─────────────────────────────────────────────────────────────────────────
interface CalculateEffectiveRateParams {
// Total data size in the dataset (existing + new), in bytes.
sizeInBytes: bigint
// Price per TiB per month from getServicePrice().
// This is the base storage price set by the FWSS contract owner.
pricePerTiBPerMonth: bigint
// Minimum monthly charge from getServicePrice().
// The contract enforces this as a floor — no dataset pays less than this
// per month, regardless of size.
minimumPricePerMonth: bigint
// Epochs per month from getServicePrice() (always 86400).
epochsPerMonth: bigint
}
interface EffectiveRateResult {
// Rate per epoch — matches what the contract stores on the PDP rail.
// = max(naturalRatePerEpoch, floorRatePerEpoch)
// Where:
// naturalRatePerEpoch = (pricePerTiBPerMonth * sizeInBytes) / (TiB * epochsPerMonth)
// floorRatePerEpoch = minimumPricePerMonth / epochsPerMonth
ratePerEpoch: bigint
// Rate per month — computed without epoch division for full precision.
// = max(naturalRatePerMonth, minimumPricePerMonth)
// Where:
// naturalRatePerMonth = (pricePerTiBPerMonth * sizeInBytes) / TiB
ratePerMonth: bigint
}
// calculateEffectiveRate(params: CalculateEffectiveRateParams): EffectiveRateResult
// ─────────────────────────────────────────────────────────────────────────
// LOCKUP CALCULATION
// How much additional lockup this upload requires.
// ─────────────────────────────────────────────────────────────────────────
interface CalculateAdditionalLockupParams {
// Size of new data being uploaded, in bytes.
dataSize: bigint
// Current total data size in the existing dataset, in bytes.
// 0n for new datasets or when not provided.
// Used to compute both the current rate (for delta) and new rate.
// When > 0n, handles floor-to-floor correctly (delta = 0).
currentDataSetSize: bigint
// Pricing params from getServicePrice() — passed through to
// calculateEffectiveRate for both current and new rate computation.
pricePerTiBPerMonth: bigint
minimumPricePerMonth: bigint
epochsPerMonth: bigint
// Lockup period in epochs (default: 86400 = 30 days).
// This is the duration for which payments are guaranteed to the SP.
lockupEpochs: bigint
// Whether a new dataset is being created (vs adding to existing).
// When true AND withCDN is true, includes CDN fixed lockup.
isNewDataset: boolean
// Whether CDN is enabled for this dataset.
// Only relevant when isNewDataset is true (existing CDN datasets
// already have their fixed lockup in place from creation).
withCDN: boolean
}
interface AdditionalLockupResult {
// Per-epoch rate increase from this upload.
// When currentDataSetSize > 0:
// rateDeltaPerEpoch = newRate - currentRate
// When currentDataSetSize == 0 (new dataset or unknown):
// rateDeltaPerEpoch = calculateEffectiveRate(dataSize).ratePerEpoch
// Exposed separately from rateLockupDelta so callers can compute
// netRate = currentLockupRate + rateDeltaPerEpoch without dividing
// back out (avoids precision loss).
rateDeltaPerEpoch: bigint
// Lockup increase from the rate change.
// = rateDeltaPerEpoch * lockupEpochs
rateLockupDelta: bigint
// Fixed CDN lockup (only for new CDN datasets).
// = 0.7 USDFC (CDN) + 0.3 USDFC (cache miss) = 1.0 USDFC
// = 0 for non-CDN or existing datasets.
cdnFixedLockup: bigint
// rateLockupDelta + cdnFixedLockup
total: bigint
}
// calculateAdditionalLockupRequired(params: CalculateAdditionalLockupParams): AdditionalLockupResult
// ─────────────────────────────────────────────────────────────────────────
// RUNWAY CALCULATION
// Extra funds to ensure the account stays funded beyond the lockup period.
// Uses the TOTAL account rate (all rails), not just this upload's delta —
// because the runway needs to keep ALL active rails funded, not just
// the new one.
// ─────────────────────────────────────────────────────────────────────────
interface CalculateRunwayAmountParams {
// Net account rate after this upload:
// = currentLockupRate + calculateAdditionalLockupRequired().rateDeltaPerEpoch
netRate: bigint
// Extra runway epochs beyond the required lockup.
// 0n if no extra runway requested.
runwayEpochs: bigint
}
// calculateRunwayAmount(params: CalculateRunwayAmountParams): bigint
//
// Returns: netRate * runwayEpochs
//
// This ensures the account has enough funds to survive runwayEpochs
// beyond the lockup period, accounting for ALL active rails' drain rate
// (existing + this upload's increase).
// ─────────────────────────────────────────────────────────────────────────
// ACCOUNT DEBT CALCULATION
// Lives in synapse-core/pay (alongside accounts(), operatorApprovals())
// because it operates on raw payment contract state.
// See "Required Changes" section below for full interface.
// ─────────────────────────────────────────────────────────────────────────
// calculateAccountDebt(params): { debt, availableFunds, fundedUntilEpoch }
//
// Pure function in synapse-core/pay. Takes raw account fields from
// accounts() + currentEpoch. Returns debt (the amount on-chain
// getAccountInfoIfSettled hides via clamping), availableFunds, and
// fundedUntilEpoch. See "Required Changes" section for details.
// ─────────────────────────────────────────────────────────────────────────
// BUFFER CALCULATION
// Safety margin for epoch drift between balance check and tx execution.
// See "The Buffer Problem" above for full explanation.
// ─────────────────────────────────────────────────────────────────────────
interface CalculateBufferAmountParams {
// rawDepositNeeded = additionalLockup + runwayAmount + debt - availableFunds
// Computed by the caller (calculateDepositNeeded) before invoking.
rawDepositNeeded: bigint
// Net account rate after this upload:
// = currentLockupRate + rateDeltaPerEpoch
// Uses net rate (not just current) because in multi-context uploads,
// earlier contexts create rails that start ticking before later
// contexts execute, so the drain rate at T_exec includes the delta.
netRate: bigint
// From calculateAccountDebt().fundedUntilEpoch.
fundedUntilEpoch: bigint
// Current epoch (block number).
currentEpoch: bigint
// From calculateAccountDebt().availableFunds.
availableFunds: bigint
// Safety margin in epochs. Each epoch drains netRate.
bufferEpochs: bigint
}
// calculateBufferAmount(params: CalculateBufferAmountParams): bigint
//
// Logic:
// if rawDepositNeeded > 0:
// // Deposit is needed — add buffer so it's sufficient at T_exec.
// return netRate * bufferEpochs
// elif fundedUntilEpoch <= currentEpoch + bufferEpochs:
// // No new lockup needed, but account expires within buffer window.
// return max(0, netRate * bufferEpochs - availableFunds)
// else:
// // Account has sufficient runway — no buffer needed.
// return 0n
// ─────────────────────────────────────────────────────────────────────────
// DEPOSIT CALCULATION (orchestrator)
// Takes raw inputs, internally calls calculateAdditionalLockupRequired,
// calculateRunwayAmount, and calculateBufferAmount. Debt values come
// from calculateAccountDebt (synapse-core/pay) and are passed in since
// it lives in a different package.
// ─────────────────────────────────────────────────────────────────────────
interface CalculateDepositNeededParams {
// ── Upload parameters (passed to calculateAdditionalLockupRequired) ──
dataSize: bigint
currentDataSetSize: bigint
pricePerTiBPerMonth: bigint
minimumPricePerMonth: bigint
epochsPerMonth: bigint
lockupEpochs: bigint
isNewDataset: boolean
withCDN: boolean
// ── Runway parameters (passed to calculateRunwayAmount) ──
// Current account-wide lockup rate (all rails).
currentLockupRate: bigint
// Extra runway epochs beyond the required lockup. 0n if not requested.
runwayEpochs: bigint
// ── Account debt (from calculateAccountDebt in synapse-core/pay) ──
debt: bigint
availableFunds: bigint
fundedUntilEpoch: bigint
// ── Buffer parameters (passed to calculateBufferAmount) ──
currentEpoch: bigint
bufferEpochs: bigint
}
// calculateDepositNeeded(params: CalculateDepositNeededParams): bigint
//
// Internally:
// lockup = calculateAdditionalLockupRequired({ dataSize, currentDataSetSize, ... })
// netRate = currentLockupRate + lockup.rateDeltaPerEpoch
// runway = calculateRunwayAmount({ netRate, runwayEpochs })
// rawDepositNeeded = lockup.total + runway + debt - availableFunds
// skipBuffer = (currentLockupRate == 0) && isNewDataset
// buffer = skipBuffer ? 0 : calculateBufferAmount({ rawDepositNeeded, netRate, ... })
// return max(0, rawDepositNeeded) + buffer
// ─────────────────────────────────────────────────────────────────────────
// APPROVAL CHECK
// Simple binary: is FWSS approved with maxUint256 allowances?
// No per-upload allowance headroom tracking needed — FWSS is trusted.
// ─────────────────────────────────────────────────────────────────────────
// isFwssMaxApproved(client: Client<Transport, Chain>, options: { clientAddress: Address }): Promise<boolean>
//
// Read-only check: fetches operatorApprovals for the given clientAddress
// with FWSS as the operator, then returns true when isApproved is true
// AND all three of rateAllowance, lockupAllowance, and maxLockupPeriod
// are maxUint256. When false, the user needs to call
// depositWithPermitAndApproveOperator with maxUint256 for all allowances.
// ─────────────────────────────────────────────────────────────────────────
// CDN FIXED LOCKUP CONSTANTS
// ─────────────────────────────────────────────────────────────────────────
// CDN egress rail fixed lockup: 0.7 USDFC (7e17 in base units)
// Cache miss egress rail fixed lockup: 0.3 USDFC (3e17 in base units)
// Total: 1.0 USDFC — only charged at dataset creation time.
const CDN_FIXED_LOCKUP = {
cdn: 700000000000000000n,
cacheMiss: 300000000000000000n,
total: 1000000000000000000n,
}
```
---
## Required Changes to Existing Code
### 1. Implement `getAccountInfoIfSettled` and `calculateAccountDebt` in synapse-core/pay
Instead of adding an RPC call for the contract's `getAccountInfoIfSettled`, we
implement it as a **pure TypeScript function** that mirrors the on-chain Solidity
exactly. This uses raw `accounts()` data (already wrapped in synapse-core/pay)
plus `currentEpoch` — no extra RPC call needed.
```typescript
// In synapse-core/pay
// Pure implementation matching the on-chain getAccountInfoIfSettled.
// Takes raw account fields from accounts() + currentEpoch.
// No RPC call — computed entirely client-side.
function getAccountInfoIfSettled(params: {
funds: bigint
lockupCurrent: bigint
lockupRate: bigint
lockupLastSettledAt: bigint
currentEpoch: bigint
}): {
fundedUntilEpoch: bigint // epoch when account runs out (maxUint256 if lockupRate == 0)
currentFunds: bigint // = funds (passthrough)
availableFunds: bigint // funds available after simulated settlement (clamped to 0)
currentLockupRate: bigint // = lockupRate (passthrough)
}
// On-chain logic (mirrored exactly):
// fundedUntilEpoch = lockupRate == 0
// ? type(uint256).max
// : lockupLastSettledAt + (funds - lockupCurrent) / lockupRate
// simulatedSettledAt = min(fundedUntilEpoch, currentEpoch)
// simulatedLockupCurrent = lockupCurrent + lockupRate * (simulatedSettledAt - lockupLastSettledAt)
// availableFunds = funds - simulatedLockupCurrent
// Extends getAccountInfoIfSettled with debt — the amount the on-chain
// version hides via clamping. This is the primary interface used by
// calculateDepositNeeded in synapse-sdk.
function calculateAccountDebt(params: {
funds: bigint
lockupCurrent: bigint
lockupRate: bigint
lockupLastSettledAt: bigint
currentEpoch: bigint
}): {
debt: bigint // max(0, totalOwed - funds)
availableFunds: bigint // max(0, funds - totalOwed)
fundedUntilEpoch: bigint // epoch when account runs out
}
// debt = max(0, (lockupCurrent + lockupRate * (currentEpoch - lockupLastSettledAt)) - funds)
// availableFunds and fundedUntilEpoch match getAccountInfoIfSettled output.
```
The existing `accounts()` wrapper already returns the four raw fields (`funds`,
`lockupCurrent`, `lockupRate`, `lockupLastSettledAt`) plus a clamped
`availableFunds`. Both new functions take these same raw fields as input.
`calculateAccountDebt` is the one used by `calculateDepositNeeded` —
it provides all three values needed: `debt`, `availableFunds`, `fundedUntilEpoch`.
### 2. Removed Legacy Methods
The following methods have been removed entirely, replaced by
`getUploadCosts()` + `prepare()` which handle floor pricing, CDN lockup,
debt, and buffer correctly:
- `WarmStorageService.calculateStorageCost()` — replaced by `getUploadCosts()`
- `WarmStorageService.checkAllowanceForStorage()` — replaced by `getUploadCosts()`
- `WarmStorageService.prepareStorageUpload()` — replaced by `StorageManager.prepare()`
- `StorageManager.preflightUpload()` — replaced by `StorageManager.getUploadCosts()`
- `StorageContext.preflightUpload()` — replaced by `StorageManager.getUploadCosts()`
- `StorageContext.performPreflightCheck()` (static) — internal helper, removed with above
- `PreflightInfo` type — removed with above
---
## Usage Examples
### Display costs for a new upload (no context needed)
```typescript
const costs = await synapse.storage.getUploadCosts({
dataSize: 100n * 1024n * 1024n, // 100 MiB
isNewDataSet: true,
withCDN: true,
})
// costs.rate.perMonth → monthly storage rate
// costs.depositNeeded → USDFC to deposit (includes CDN fixed lockup)
// costs.needsFwssMaxApproval → whether FWSS operator approval is needed
// costs.ready → true if no action needed
```
### Display costs for adding to an existing dataset
```typescript
// With currentDataSetSize — accurate floor-aware delta
const costs = await synapse.storage.getUploadCosts({
isNewDataSet: false,
currentDataSetSize: 50n * 1024n * 1024n,
dataSize: 100n * 1024n * 1024n,
})
// Shows incremental cost (newRate - currentRate), handles floor-to-floor
// Without currentDataSetSize — safe overestimate for floor cases
const costs2 = await synapse.storage.getUploadCosts({
isNewDataSet: false,
dataSize: 100n * 1024n * 1024n,
})
// Treats rate increase as calculateEffectiveRate(dataSize)
// Accurate for above-floor datasets, overestimates for floor-priced ones
```
### Fund directly (golden path — issue #437)
```typescript
const synapse = await Synapse.create({ privateKey, rpcUrl })
await synapse.payments.fund({ amount: parseUnits('10', 18) })
// fund() checks approval state via RPC, picks the right deposit method
await synapse.storage.upload(data)
```
### Prepare with context and execute
```typescript
const ctx = await synapse.storage.createContext({ providerId: 1n })
const { costs, transaction } = await synapse.storage.prepare({
context: ctx,
dataSize: 100n * 1024n * 1024n,
runwayEpochs: 86400n,
})
if (transaction) {
const txHash = await transaction.execute()
// Calls fund() internally — single tx: deposit + approve or deposit only
}
await ctx.upload(data)
```
### Show costs first, then fund (avoid double RPC)
```typescript
// Step 1: Show UI with raw params (no context needed yet)
const costs = await synapse.storage.getUploadCosts({
clientAddress: '0x...',
dataSize: 100n * 1024n * 1024n,
isNewDataSet: true,
})
displayCostsToUser(costs)
// Step 2: User clicks "proceed" — pass pre-computed approval state
if (!costs.ready) {
await synapse.payments.fund({
amount: costs.depositNeeded,
needsFwssMaxApproval: costs.needsFwssMaxApproval,
})
}
const ctx = await synapse.storage.createContext({ providerId: 1n })
await ctx.upload(data)
```
### Show costs first, then prepare (avoid double RPC)
```typescript
// Step 1: Show UI with raw params (no context needed yet)
const costs = await synapse.storage.getUploadCosts({
clientAddress: '0x...',
dataSize: 100n * 1024n * 1024n,
isNewDataSet: true,
})
displayCostsToUser(costs)
// Step 2: User clicks "proceed" — pass pre-computed costs
const ctx = await synapse.storage.createContext({ providerId: 1n })
const { transaction } = await synapse.storage.prepare({ context: ctx, costs })
if (transaction) {
await transaction.execute() // calls fund() internally
}
await ctx.upload(data)
```
### Multi-context (multi-provider upload)
```typescript
const [ctx1, ctx2] = await synapse.storage.createContexts({ count: 2 })
const { transaction } = await synapse.storage.prepare({
context: [ctx1, ctx2],
dataSize: 100n * 1024n * 1024n,
})
// Lockup summed across both contexts (same payer account).
if (transaction) {
await transaction.execute()
}
```
### Multi-Context Deposit Calculation
When `prepare()` receives multiple contexts (or creates default contexts when none are
provided — mirroring the `upload()` flow), it uses `calculateMultiContextCosts()` to
correctly aggregate per-context lockup while keeping account-level values (debt, runway,
buffer) computed once. This avoids the errors that would result from calling
`getUploadCosts()` N times independently:
- **`availableFunds`** is shared across all contexts — subtracting it N times would
undercount the real balance.
- **`debt`** is an account-level property — counting it N times would overestimate the
shortfall.
- **`runway`** depends on `currentLockupRate + totalRateDelta` — prior contexts' rate
increases must be included.
**Algorithm:**
1. **Identify existing datasets** — filter contexts where `ctx.dataSetId != null`.
2. **Fetch all data in parallel** (single RPC batch):
- Account state (`accounts`), pricing (`getServicePrice`), approval
(`isFwssMaxApproved`), current epoch (`getBlockNumber`).
- For each existing dataset: `getDataSetLeafCount` via multicall, then
`leafCount * 32n` to get `currentDataSetSize` (32 bytes per leaf, per FWSS
contract's `updatePaymentRates`).
3. **Per-context loop** — calls `calculateAdditionalLockupRequired()` for each context,
accumulating `totalRateDeltaPerEpoch`, `totalLockup`, `totalRatePerEpoch`, and
`totalRatePerMonth`. `isNewDataSet` is determined per-context by `ctx.dataSetId == null`.
4. **Account-level calculation** (once, with aggregated values):
- `calculateAccountDebt()` → debt, availableFunds, fundedUntilEpoch
- `netRate = currentLockupRate + totalRateDeltaPerEpoch`
- `calculateRunwayAmount()` → with `netRate`
- `rawDepositNeeded = totalLockup + runway + debt - availableFunds`
- `calculateBufferAmount()` → with `netRate`
- `depositNeeded = max(0, rawDepositNeeded) + buffer`
5. **Returns `UploadCosts`** with summed rates and a single deposit/approval check.
**What multi-context currently handles:**
- Per-context lockup aggregation (each context's `isNewDataSet`, `withCDN`, and
`currentDataSetSize` are resolved individually)
- Existing dataset sizes fetched from chain via `getDataSetLeafCount` for accurate
floor-aware rate deltas
- Mixed CDN/non-CDN contexts (each context uses its own `ctx.withCDN`)
- Single account-level debt, runway, and buffer computation
**What multi-context does NOT currently handle:**
- **Per-context `dataSize`** — the same `options.dataSize` is applied to every context.
This matches the replication model (`upload()` sends the same piece to all contexts),
but wouldn't work for uploading different-sized data to different providers.
- **Duplicate context validation** — no dedup. If the caller passes the same context
twice, they get 2× the lockup. This mirrors `upload()` behavior (no validation, just
process each one).
- **CDN usage-based top-up costs** — not modeled (same limitation as single-context,
documented in Open Questions below).
---
## Edge Cases
| Case | Behavior |
|------|----------|
| New dataset (`isNewDataSet: true`) | Full floor lockup + CDN fixed if enabled |
| Existing dataset, `currentDataSetSize` provided | Accurate delta: `newRate(total) - currentRate(existing)`, handles floor correctly |
| Existing dataset, `currentDataSetSize` omitted | Safe overestimate: delta = `effectiveRate(dataSize)`, accurate above floor |
| File smaller than floor price threshold | Rate = minimum regardless of size |
| Adding small file to floor-priced dataset (`currentDataSetSize` provided) | Rate unchanged (floor → floor), `additionalLockup = 0`, buffer skipped if funded runway > bufferEpochs → `ready = true` |
| Adding small file to floor-priced dataset (`currentDataSetSize` omitted) | Overestimates: charges floor lockup as if new. Safe but suboptimal |
| `runwayEpochs` omitted | Deposit covers only required lockup, no extra runway |
| User has enough funds + FWSS approved | `ready = true`, `transaction = null` |
| User wallet can't cover deposit | `prepare()` throws with shortfall amount |
| FWSS not approved (or not with maxUint256) | `needsFwssMaxApproval = true`, `fund()` uses `depositWithPermitAndApproveOperator(amount, maxUint256, maxUint256, maxUint256)` |
| No deposit needed, only approval needed | `fund({ amount: 0n, needsFwssMaxApproval: true })` → `depositWithPermitAndApproveOperator` with `amount = 0n` |
| `bufferEpochs = 0` | No safety margin — only use when executing immediately |
| No active storage (`lockupRate = 0`) | Buffer = `rateDeltaPerEpoch * bufferEpochs` (safe overestimate — new rail hasn't started draining yet, but covers worst-case timing) |
| Underfunded account (debt > 0) | `depositNeeded` includes debt so contract can fully settle before rate change |
| Underfunded + no new upload funds | Deposit covers debt only; rate change succeeds after full settlement |
| Multiple contexts in `prepare()` | Per-context lockup aggregated via `calculateMultiContextCosts()`. Debt, runway, buffer computed once at account level. Same `dataSize` applied to all contexts (replication model). No duplicate context validation. |
| `prepare()` with no context | Creates default contexts via `createContexts()` (same as `upload()` flow), then calculates costs |
---
## Account-Wide Rate & Lockup APIs
Two read-only actions provide account-level cost visibility without requiring
a specific upload context.
### `totalAccountRate` — Aggregate Rate
Returns the account's aggregate rate across all active rails in both
per-epoch and per-month units. The per-epoch rate comes directly from
`accounts().lockupRate`. The per-month rate is `ratePerEpoch * 86400`.
**Core:**
```ts
import { totalAccountRate } from '@filoz/synapse-core/pay'
const rate = await totalAccountRate(client, {
address: '0x...',
})
// rate.ratePerEpoch — bigint (per 30-second epoch)
// rate.ratePerMonth — bigint (ratePerEpoch * 86400)
```
**SDK:**
```ts
const spend = await synapse.payments.totalAccountRate()
```
### `totalAccountLockup` — Total Fixed Lockup
Returns the sum of `lockupFixed` across all rails (including terminated but not
yet finalized). Terminated rails still hold locked funds until `finalizeTerminatedRail()`
zeroes them out, so they must be included for an accurate lockup total.
Fetches rail IDs via `getRailsForPayerAndToken`, then batches `getRail` calls
via multicall to sum `lockupFixed`.
**Core:**
```ts
import { totalAccountLockup } from '@filoz/synapse-core/pay'
const lockup = await totalAccountLockup(client, {
address: '0x...',
})
// lockup.totalFixedLockup — bigint (sum of lockupFixed)
```
**SDK:**
```ts
const lockup = await synapse.payments.totalAccountLockup()
```
### `getDatasetSize` / `getMultiDatasetSize` — Dataset Size Helpers
Convenience wrappers around `getDataSetLeafCount` that convert leaf counts to
bytes (`leafCount * BYTES_PER_LEAF`). `getMultiDatasetSize` batches multiple
lookups into a single multicall for efficiency.
#### `BYTES_PER_LEAF` constant
The PDP merkle tree stores data in 32-byte leaves. The FWSS contract converts
between leaf counts and byte sizes via `totalBytes = leafCount * BYTES_PER_LEAF`
(see `updatePaymentRates()` line 1272 in `FilecoinWarmStorageService.sol`).
This constant is exported from `@filoz/synapse-core/utils` as
`SIZE_CONSTANTS.BYTES_PER_LEAF` (`32n`), so consumers never need to hardcode it.
**Core:**
```ts
import { getDatasetSize, getMultiDatasetSize } from '@filoz/synapse-core/pdp-verifier'
// Single dataset
const sizeInBytes = await getDatasetSize(client, { dataSetId: 1n })
// Multiple datasets (batched multicall)
const sizes = await getMultiDatasetSize(client, { dataSetIds: [1n, 2n, 3n] })
```
Used internally by `StorageManager.calculateMultiContextCosts()` to fetch
existing dataset sizes for accurate floor-aware rate delta calculations.
### `getAccountTotalStorageSize` — Account-Wide Storage Size
Returns the total storage size across all **live** datasets for an account.
Fetches datasets via `getClientDataSets`, checks liveness and leaf counts
via a single batched multicall to PDP Verifier, then sums sizes for live
datasets only.
**Core:**
```ts
import { getAccountTotalStorageSize } from '@filoz/synapse-core/warm-storage'
const { totalSizeBytes, datasetCount } = await getAccountTotalStorageSize(client, {
address: '0x...',
})
// totalSizeBytes — bigint (sum of leafCount * BYTES_PER_LEAF for live datasets)
// datasetCount — number (count of live datasets)
```
### Rate Units
| Source | Unit | Conversion |
|--------|------|------------|
| `accounts().lockupRate` | per epoch | Contract-native |
| `totalAccountRate().ratePerEpoch` | per epoch | Same as lockupRate |
| `totalAccountRate().ratePerMonth` | per month | `ratePerEpoch * 86400` |
| `getRail().paymentRate` | per epoch | Per-rail contract rate |
Note: `ratePerMonth` is an approximation due to integer truncation when the
original per-month rate was divided by `epochsPerMonth` to produce the per-epoch
rail rate stored on-chain.
---
## Open Questions & Caveats
1. **Layer 1 function design is flexible.** The focus of this document is the
public API surface (`getUploadCosts` / `prepare`). The Layer 1 pure functions,
their parameters, and internal structure are subject to change based on how
modular we want the internals to be. They may be split differently, merged,
or restructured during implementation without affecting the public API.
2. **`runwayEpochs` is included but may be misleading.** Filecoin Pay accounts
are shared — funds deposited there are not exclusively for FWSS. Any other
service using Filecoin Pay can draw from the same account. Even within FWSS,
external events can invalidate a runway estimate: for example, FilCDN may
withdraw cached egress credits at any epoch, reducing available funds in ways
the SDK cannot predict. A runway estimate of "30 days" could become incorrect
the next epoch. Consumers should treat `runwayEpochs` as a best-effort
deposit buffer, not a guarantee.
3. **CDN lockup is simplified to the flat $1 fixed amount.** Per Hugo's input,
we only account for the 1.0 USDFC fixed lockup at dataset creation
(0.7 CDN + 0.3 cache miss). We are intentionally not modeling the CDN
top-up mechanics or the complexities that arise when a user exhausts their
1 TiB egress allowance (rate changes, additional lockup, etc.). That is a
separate concern and would significantly complicate this API for an edge case
that most users won't hit during initial uploads.
4. Changed the pricePerTiBPerMonthNoCDN in tests to 2.5 matching the actual pricing.
5. Was looking into the docs but many other things seem to be broken as per current sdk
state, hence have assumed it would be done later.
6. Updated the utils scripts to verify the new features, did correct few previous
issues that were missed (i don't know how)
---
## Open Questions & Caveats
1. **Layer 1 function design is flexible.** The focus of this document is the
public API surface (`getUploadCosts` / `prepare`). The Layer 1 pure functions,
their parameters, and internal structure are subject to change based on how
modular we want the internals to be. They may be split differently, merged,
or restructured during implementation without affecting the public API.
2. **`runwayEpochs` is included but may be misleading.** Filecoin Pay accounts
are shared — funds deposited there are not exclusively for FWSS. Any other
service using Filecoin Pay can draw from the same account. Even within FWSS,
external events can invalidate a runway estimate: for example, FilCDN may
withdraw cached egress credits at any epoch, reducing available funds in ways
the SDK cannot predict. A runway estimate of "30 days" could become incorrect
the next epoch. Consumers should treat `runwayEpochs` as a best-effort
deposit buffer, not a guarantee.
3. **CDN lockup is simplified to the flat $1 fixed amount.** Per Hugo's input,
we only account for the 1.0 USDFC fixed lockup at dataset creation
(0.7 CDN + 0.3 cache miss). We are intentionally not modeling the CDN
top-up mechanics or the complexities that arise when a user exhausts their
1 TiB egress allowance (rate changes, additional lockup, etc.). That is a
separate concern and would significantly complicate this API for an edge case
that most users won't hit during initial uploads.
4. Changed the pricePerTiBPerMonthNoCDN in tests to 2.5 matching the actual pricing.
5. ~~Was looking into the docs but many other things seem to be broken as per current sdk
state, hence have assumed it would be done later.~~ Did this as well cause CI was failing, which I don't know why was passing before as docs were incompatible to code before only, not related to issue but corrected those things as well.
6. Updated the utils scripts to verify the new features, did correct few previous
issues that were missed (i don't know how)
7. Been quite intensive with edge case handling and making creating functions, let me know if things feel unnecessary and having them could be problematic.