Skip to content

Commit 70e780f

Browse files
authored
fix: add/import validate capacity with floor pricing (#218)
* fix: validatePaymentSetup considers floor pricing * test: add floorPricing tests * Update src/test/unit/floor-pricing.test.ts * Update src/test/unit/floor-pricing.test.ts * chore: fix lint * chore: cleanup add output
1 parent d27e6e6 commit 70e780f

File tree

6 files changed

+330
-33
lines changed

6 files changed

+330
-33
lines changed

src/add/add.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export async function runAdd(options: AddOptions): Promise<AddResult> {
124124
// Check payment setup (may configure permissions if needed)
125125
// Actual CAR size will be checked later
126126
spinner.start('Checking payment setup...')
127-
await validatePaymentSetup(synapse, 0, spinner)
127+
await validatePaymentSetup(synapse, 0, spinner, { suppressSuggestions: true })
128128

129129
// Create CAR from file or directory
130130
const packingMsg = isDirectory

src/common/upload-flow.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,18 @@ export async function performAutoFunding(synapse: Synapse, fileSize: number, spi
104104
* Validate payment setup and capacity for upload
105105
*
106106
* @param synapse - Initialized Synapse instance
107-
* @param fileSize - Size of file to upload in bytes
107+
* @param fileSize - Size of file to upload in bytes (use 0 for minimum setup check)
108108
* @param spinner - Optional spinner for progress
109+
* @param options - Optional configuration
110+
* @param options.suppressSuggestions - If true, don't display suggestion warnings
109111
* @returns true if validation passes, exits process if not
110112
*/
111-
export async function validatePaymentSetup(synapse: Synapse, fileSize: number, spinner?: Spinner): Promise<void> {
113+
export async function validatePaymentSetup(
114+
synapse: Synapse,
115+
fileSize: number,
116+
spinner?: Spinner,
117+
options?: { suppressSuggestions?: boolean }
118+
): Promise<void> {
112119
const readiness = await checkUploadReadiness({
113120
synapse,
114121
fileSize,
@@ -186,16 +193,19 @@ export async function validatePaymentSetup(synapse: Synapse, fileSize: number, s
186193
}
187194

188195
// Show warning if suggestions exist (even if upload is possible)
189-
if (suggestions.length > 0 && capacity?.canUpload) {
196+
if (suggestions.length > 0 && capacity?.canUpload && !options?.suppressSuggestions) {
190197
spinner?.stop(`${pc.yellow('⚠')} Payment capacity check passed with warnings`)
191-
log.line('')
192198
log.line(pc.bold('Suggestions:'))
193199
suggestions.forEach((suggestion) => {
194200
log.indent(`• ${suggestion}`)
195201
})
196202
log.flush()
203+
} else if (fileSize === 0) {
204+
// Different message based on whether this is minimum setup (fileSize=0) or actual capacity check
205+
// Note: 0.06 USDFC is the floor price, but with 10% buffer, ~0.066 USDFC is actually required
206+
spinner?.stop(`${pc.green('✓')} Minimum payment setup verified (~0.066 USDFC required)`)
197207
} else {
198-
spinner?.stop(`${pc.green('✓')} Payment capacity verified`)
208+
spinner?.stop(`${pc.green('✓')} Payment capacity verified for ${formatFileSize(fileSize)}`)
199209
}
200210
}
201211

@@ -204,14 +214,14 @@ export async function validatePaymentSetup(synapse: Synapse, fileSize: number, s
204214
*/
205215
function displayPaymentIssues(capacityCheck: PaymentCapacityCheck, fileSize: number, spinner?: Spinner): void {
206216
spinner?.stop(`${pc.red('✗')} Insufficient deposit for this file`)
207-
log.line('')
208217
log.line(pc.bold('File Requirements:'))
209-
log.indent(`File size: ${formatFileSize(fileSize)} (${capacityCheck.storageTiB.toFixed(4)} TiB)`)
218+
if (fileSize === 0) {
219+
log.indent(`File size: ${formatFileSize(fileSize)} (${capacityCheck.storageTiB.toFixed(4)} TiB)`)
220+
}
210221
log.indent(`Storage cost: ${formatUSDFC(capacityCheck.required.rateAllowance)} USDFC/epoch`)
211222
log.indent(
212-
`Required deposit: ${formatUSDFC(capacityCheck.required.lockupAllowance + capacityCheck.required.lockupAllowance / 10n)} USDFC`
223+
`Required deposit: ${formatUSDFC(capacityCheck.required.lockupAllowance + capacityCheck.required.lockupAllowance / 10n)} USDFC ${pc.gray(`(includes ${DEFAULT_LOCKUP_DAYS}-day safety reserve)`)}`
213224
)
214-
log.indent(pc.gray(`(includes ${DEFAULT_LOCKUP_DAYS}-day safety reserve)`))
215225
log.line('')
216226

217227
log.line(pc.bold('Suggested actions:'))

src/core/payments/constants.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Payment-related constants for Filecoin Onchain Cloud
3+
*
4+
* This module contains all constants used in payment operations including
5+
* decimals, lockup periods, buffer configurations, and pricing minimums.
6+
*/
7+
8+
import { ethers } from 'ethers'
9+
10+
/**
11+
* USDFC token decimals (ERC20 standard)
12+
*/
13+
export const USDFC_DECIMALS = 18
14+
15+
/**
16+
* Minimum FIL balance required for gas fees
17+
*/
18+
export const MIN_FIL_FOR_GAS = ethers.parseEther('0.1')
19+
20+
/**
21+
* Default lockup period required by WarmStorage (in days)
22+
*/
23+
export const DEFAULT_LOCKUP_DAYS = 30
24+
25+
/**
26+
* Floor price per piece for WarmStorage (minimum cost regardless of size)
27+
* This is 0.06 USDFC per 30 days per piece
28+
*/
29+
export const FLOOR_PRICE_PER_30_DAYS = ethers.parseUnits('0.06', USDFC_DECIMALS)
30+
31+
/**
32+
* Number of days the floor price covers
33+
*/
34+
export const FLOOR_PRICE_DAYS = 30
35+
36+
/**
37+
* Maximum allowances for trusted WarmStorage service
38+
* Using MaxUint256 which MetaMask displays as "Unlimited"
39+
*/
40+
export const MAX_RATE_ALLOWANCE = ethers.MaxUint256
41+
export const MAX_LOCKUP_ALLOWANCE = ethers.MaxUint256
42+
43+
/**
44+
* Standard buffer configuration (10%) used across deposit/lockup calculations
45+
*/
46+
export const BUFFER_NUMERATOR = 11n
47+
export const BUFFER_DENOMINATOR = 10n
48+
49+
/**
50+
* Maximum precision scale used when converting small TiB (as a float) to integer(BigInt) math
51+
*/
52+
export const STORAGE_SCALE_MAX = 10_000_000
53+
export const STORAGE_SCALE_MAX_BI = BigInt(STORAGE_SCALE_MAX)

src/core/payments/floor-pricing.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Floor pricing calculations for WarmStorage
3+
*
4+
* This module handles the minimum rate per piece (floor price) logic.
5+
* The floor price ensures that small files meet the minimum cost requirement
6+
* of 0.06 USDFC per 30 days, regardless of their actual size.
7+
*
8+
* Implementation follows the pattern from synapse-sdk PR #375:
9+
* 1. Calculate base cost from piece size
10+
* 2. Calculate floor cost (minimum per piece)
11+
* 3. Return max(base cost, floor cost)
12+
*/
13+
14+
import { TIME_CONSTANTS } from '@filoz/synapse-sdk'
15+
import { DEFAULT_LOCKUP_DAYS, FLOOR_PRICE_DAYS, FLOOR_PRICE_PER_30_DAYS } from './constants.js'
16+
import type { StorageAllowances } from './types.js'
17+
18+
/**
19+
* Calculate floor-adjusted allowances for a piece
20+
*
21+
* This function applies the floor pricing (minimum rate per piece) to ensure
22+
* that small files meet the minimum cost requirement.
23+
*
24+
* Example usage:
25+
* ```typescript
26+
* const storageInfo = await synapse.storage.getStorageInfo()
27+
* const pricing = storageInfo.pricing.noCDN.perTiBPerEpoch
28+
*
29+
* // For a small file (1 KB)
30+
* const allowances = calculateFloorAdjustedAllowances(1024, pricing)
31+
* // Will return floor price allowances (0.06 USDFC per 30 days)
32+
*
33+
* // For a large file (10 GiB)
34+
* const allowances = calculateFloorAdjustedAllowances(10 * 1024 * 1024 * 1024, pricing)
35+
* // Will return calculated allowances based on size (floor doesn't apply)
36+
* ```
37+
*
38+
* @param baseAllowances - Base allowances calculated from piece size
39+
* @returns Floor-adjusted allowances for the piece
40+
*/
41+
export function applyFloorPricing(baseAllowances: StorageAllowances): StorageAllowances {
42+
// Calculate floor rate per epoch
43+
// floor price is per 30 days, so we divide by (30 days * epochs per day)
44+
const epochsInFloorPeriod = BigInt(FLOOR_PRICE_DAYS) * TIME_CONSTANTS.EPOCHS_PER_DAY
45+
const floorRateAllowance = FLOOR_PRICE_PER_30_DAYS / epochsInFloorPeriod
46+
47+
// Calculate floor lockup (floor rate * lockup period)
48+
const epochsInLockupDays = BigInt(DEFAULT_LOCKUP_DAYS) * TIME_CONSTANTS.EPOCHS_PER_DAY
49+
const floorLockupAllowance = floorRateAllowance * epochsInLockupDays
50+
51+
// Apply floor pricing: use max of base and floor
52+
const rateAllowance =
53+
baseAllowances.rateAllowance > floorRateAllowance ? baseAllowances.rateAllowance : floorRateAllowance
54+
55+
const lockupAllowance =
56+
baseAllowances.lockupAllowance > floorLockupAllowance ? baseAllowances.lockupAllowance : floorLockupAllowance
57+
58+
return {
59+
rateAllowance,
60+
lockupAllowance,
61+
storageCapacityTiB: baseAllowances.storageCapacityTiB,
62+
}
63+
}
64+
65+
/**
66+
* Get the floor pricing allowances (minimum cost regardless of size)
67+
*
68+
* @returns Floor price allowances
69+
*/
70+
export function getFloorAllowances(): StorageAllowances {
71+
const epochsInFloorPeriod = BigInt(FLOOR_PRICE_DAYS) * TIME_CONSTANTS.EPOCHS_PER_DAY
72+
const rateAllowance = FLOOR_PRICE_PER_30_DAYS / epochsInFloorPeriod
73+
74+
const epochsInLockupDays = BigInt(DEFAULT_LOCKUP_DAYS) * TIME_CONSTANTS.EPOCHS_PER_DAY
75+
const lockupAllowance = rateAllowance * epochsInLockupDays
76+
77+
return {
78+
rateAllowance,
79+
lockupAllowance,
80+
storageCapacityTiB: 0, // Floor price is not size-based
81+
}
82+
}

src/core/payments/index.ts

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,25 +18,27 @@
1818
import { SIZE_CONSTANTS, type Synapse, TIME_CONSTANTS, TOKENS } from '@filoz/synapse-sdk'
1919
import { ethers } from 'ethers'
2020
import { isSessionKeyMode } from '../synapse/index.js'
21+
import {
22+
BUFFER_DENOMINATOR,
23+
BUFFER_NUMERATOR,
24+
DEFAULT_LOCKUP_DAYS,
25+
MAX_LOCKUP_ALLOWANCE,
26+
MAX_RATE_ALLOWANCE,
27+
MIN_FIL_FOR_GAS,
28+
STORAGE_SCALE_MAX,
29+
STORAGE_SCALE_MAX_BI,
30+
USDFC_DECIMALS,
31+
} from './constants.js'
32+
import { applyFloorPricing } from './floor-pricing.js'
2133
import type { PaymentStatus, ServiceApprovalStatus, StorageAllowances, StorageRunwaySummary } from './types.js'
2234

23-
// Constants
24-
export const USDFC_DECIMALS = 18
25-
const MIN_FIL_FOR_GAS = ethers.parseEther('0.1') // Minimum FIL padding for gas
26-
export const DEFAULT_LOCKUP_DAYS = 30 // WarmStorage requires 30 days lockup
35+
// Re-export all constants
36+
export * from './constants.js'
2737

38+
export * from './floor-pricing.js'
2839
export * from './top-up.js'
2940
export * from './types.js'
3041

31-
// Maximum allowances for trusted WarmStorage service
32-
// Using MaxUint256 which MetaMask displays as "Unlimited"
33-
const MAX_RATE_ALLOWANCE = ethers.MaxUint256
34-
const MAX_LOCKUP_ALLOWANCE = ethers.MaxUint256
35-
36-
// Standard buffer configuration (10%) used across deposit/lockup calculations
37-
const BUFFER_NUMERATOR = 11n
38-
const BUFFER_DENOMINATOR = 10n
39-
4042
// Helper to apply a buffer on top of a base amount
4143
function withBuffer(amount: bigint): bigint {
4244
return (amount * BUFFER_NUMERATOR) / BUFFER_DENOMINATOR
@@ -47,12 +49,6 @@ function withoutBuffer(amount: bigint): bigint {
4749
return (amount * BUFFER_DENOMINATOR) / BUFFER_NUMERATOR
4850
}
4951

50-
/**
51-
* Maximum precision scale used when converting small TiB (as a float) to integer(BigInt) math
52-
*/
53-
export const STORAGE_SCALE_MAX = 10_000_000
54-
const STORAGE_SCALE_MAX_BI = BigInt(STORAGE_SCALE_MAX)
55-
5652
/**
5753
* Compute adaptive integer scaling for a TiB value so that
5854
* Math.floor(storageTiB * scale) stays within Number.MAX_SAFE_INTEGER.
@@ -708,8 +704,9 @@ export function computeAdjustmentForExactDaysWithPiece(
708704
const currentRateUsed = status.currentAllowances.rateUsed ?? 0n
709705
const currentLockupUsed = status.currentAllowances.lockupUsed ?? 0n
710706

711-
// Calculate required allowances for the new file
712-
const newPieceAllowances = calculateRequiredAllowances(pieceSizeBytes, pricePerTiBPerEpoch)
707+
// Calculate required allowances for the new file with floor pricing applied
708+
const baseAllowances = calculateRequiredAllowances(pieceSizeBytes, pricePerTiBPerEpoch)
709+
const newPieceAllowances = applyFloorPricing(baseAllowances)
713710

714711
// Calculate new totals after adding the piece
715712
const newRateUsed = currentRateUsed + newPieceAllowances.rateAllowance
@@ -924,8 +921,9 @@ export function calculatePieceUploadRequirements(
924921
insufficientDeposit: bigint
925922
canUpload: boolean
926923
} {
927-
// Calculate requirements
928-
const required = calculateRequiredAllowances(pieceSizeBytes, pricePerTiBPerEpoch)
924+
// Calculate base requirements and apply floor pricing
925+
const baseRequired = calculateRequiredAllowances(pieceSizeBytes, pricePerTiBPerEpoch)
926+
const required = applyFloorPricing(baseRequired)
929927
const totalDepositNeeded = withBuffer(required.lockupAllowance)
930928

931929
// Check if current deposit can cover the new file's lockup requirement

0 commit comments

Comments
 (0)