Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/add/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export async function runAdd(options: AddOptions): Promise<AddResult> {
// Check payment setup (may configure permissions if needed)
// Actual CAR size will be checked later
spinner.start('Checking payment setup...')
await validatePaymentSetup(synapse, 0, spinner)
await validatePaymentSetup(synapse, 0, spinner, { suppressSuggestions: true })

// Create CAR from file or directory
const packingMsg = isDirectory
Expand Down
28 changes: 19 additions & 9 deletions src/common/upload-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,18 @@ export async function performAutoFunding(synapse: Synapse, fileSize: number, spi
* Validate payment setup and capacity for upload
*
* @param synapse - Initialized Synapse instance
* @param fileSize - Size of file to upload in bytes
* @param fileSize - Size of file to upload in bytes (use 0 for minimum setup check)
* @param spinner - Optional spinner for progress
* @param options - Optional configuration
* @param options.suppressSuggestions - If true, don't display suggestion warnings
* @returns true if validation passes, exits process if not
*/
export async function validatePaymentSetup(synapse: Synapse, fileSize: number, spinner?: Spinner): Promise<void> {
export async function validatePaymentSetup(
synapse: Synapse,
fileSize: number,
spinner?: Spinner,
options?: { suppressSuggestions?: boolean }
): Promise<void> {
const readiness = await checkUploadReadiness({
synapse,
fileSize,
Expand Down Expand Up @@ -186,16 +193,19 @@ export async function validatePaymentSetup(synapse: Synapse, fileSize: number, s
}

// Show warning if suggestions exist (even if upload is possible)
if (suggestions.length > 0 && capacity?.canUpload) {
if (suggestions.length > 0 && capacity?.canUpload && !options?.suppressSuggestions) {
spinner?.stop(`${pc.yellow('⚠')} Payment capacity check passed with warnings`)
log.line('')
log.line(pc.bold('Suggestions:'))
suggestions.forEach((suggestion) => {
log.indent(`• ${suggestion}`)
})
log.flush()
} else if (fileSize === 0) {
// Different message based on whether this is minimum setup (fileSize=0) or actual capacity check
// Note: 0.06 USDFC is the floor price, but with 10% buffer, ~0.066 USDFC is actually required
spinner?.stop(`${pc.green('✓')} Minimum payment setup verified (~0.066 USDFC required)`)
Comment on lines +205 to +206
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can remove the buffer if desired, but we apply the buffer elsewhere.. lmk @rvagg if this feels off.

} else {
spinner?.stop(`${pc.green('✓')} Payment capacity verified`)
spinner?.stop(`${pc.green('✓')} Payment capacity verified for ${formatFileSize(fileSize)}`)
}
}

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

log.line(pc.bold('Suggested actions:'))
Expand Down
53 changes: 53 additions & 0 deletions src/core/payments/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Payment-related constants for Filecoin Onchain Cloud
*
* This module contains all constants used in payment operations including
* decimals, lockup periods, buffer configurations, and pricing minimums.
*/

import { ethers } from 'ethers'

/**
* USDFC token decimals (ERC20 standard)
*/
export const USDFC_DECIMALS = 18

/**
* Minimum FIL balance required for gas fees
*/
export const MIN_FIL_FOR_GAS = ethers.parseEther('0.1')

/**
* Default lockup period required by WarmStorage (in days)
*/
export const DEFAULT_LOCKUP_DAYS = 30

/**
* Floor price per piece for WarmStorage (minimum cost regardless of size)
* This is 0.06 USDFC per 30 days per piece
*/
export const FLOOR_PRICE_PER_30_DAYS = ethers.parseUnits('0.06', USDFC_DECIMALS)

/**
* Number of days the floor price covers
*/
export const FLOOR_PRICE_DAYS = 30

/**
* Maximum allowances for trusted WarmStorage service
* Using MaxUint256 which MetaMask displays as "Unlimited"
*/
export const MAX_RATE_ALLOWANCE = ethers.MaxUint256
export const MAX_LOCKUP_ALLOWANCE = ethers.MaxUint256

/**
* Standard buffer configuration (10%) used across deposit/lockup calculations
*/
export const BUFFER_NUMERATOR = 11n
export const BUFFER_DENOMINATOR = 10n

/**
* Maximum precision scale used when converting small TiB (as a float) to integer(BigInt) math
*/
export const STORAGE_SCALE_MAX = 10_000_000
export const STORAGE_SCALE_MAX_BI = BigInt(STORAGE_SCALE_MAX)
82 changes: 82 additions & 0 deletions src/core/payments/floor-pricing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Floor pricing calculations for WarmStorage
*
* This module handles the minimum rate per piece (floor price) logic.
* The floor price ensures that small files meet the minimum cost requirement
* of 0.06 USDFC per 30 days, regardless of their actual size.
*
* Implementation follows the pattern from synapse-sdk PR #375:
* 1. Calculate base cost from piece size
* 2. Calculate floor cost (minimum per piece)
* 3. Return max(base cost, floor cost)
*/

import { TIME_CONSTANTS } from '@filoz/synapse-sdk'
import { DEFAULT_LOCKUP_DAYS, FLOOR_PRICE_DAYS, FLOOR_PRICE_PER_30_DAYS } from './constants.js'
import type { StorageAllowances } from './types.js'

/**
* Calculate floor-adjusted allowances for a piece
*
* This function applies the floor pricing (minimum rate per piece) to ensure
* that small files meet the minimum cost requirement.
*
* Example usage:
* ```typescript
* const storageInfo = await synapse.storage.getStorageInfo()
* const pricing = storageInfo.pricing.noCDN.perTiBPerEpoch
*
* // For a small file (1 KB)
* const allowances = calculateFloorAdjustedAllowances(1024, pricing)
* // Will return floor price allowances (0.06 USDFC per 30 days)
*
* // For a large file (10 GiB)
* const allowances = calculateFloorAdjustedAllowances(10 * 1024 * 1024 * 1024, pricing)
* // Will return calculated allowances based on size (floor doesn't apply)
* ```
*
* @param baseAllowances - Base allowances calculated from piece size
* @returns Floor-adjusted allowances for the piece
*/
export function applyFloorPricing(baseAllowances: StorageAllowances): StorageAllowances {
// Calculate floor rate per epoch
// floor price is per 30 days, so we divide by (30 days * epochs per day)
const epochsInFloorPeriod = BigInt(FLOOR_PRICE_DAYS) * TIME_CONSTANTS.EPOCHS_PER_DAY
const floorRateAllowance = FLOOR_PRICE_PER_30_DAYS / epochsInFloorPeriod

// Calculate floor lockup (floor rate * lockup period)
const epochsInLockupDays = BigInt(DEFAULT_LOCKUP_DAYS) * TIME_CONSTANTS.EPOCHS_PER_DAY
const floorLockupAllowance = floorRateAllowance * epochsInLockupDays

// Apply floor pricing: use max of base and floor
const rateAllowance =
baseAllowances.rateAllowance > floorRateAllowance ? baseAllowances.rateAllowance : floorRateAllowance

const lockupAllowance =
baseAllowances.lockupAllowance > floorLockupAllowance ? baseAllowances.lockupAllowance : floorLockupAllowance

return {
rateAllowance,
lockupAllowance,
storageCapacityTiB: baseAllowances.storageCapacityTiB,
}
}

/**
* Get the floor pricing allowances (minimum cost regardless of size)
*
* @returns Floor price allowances
*/
export function getFloorAllowances(): StorageAllowances {
const epochsInFloorPeriod = BigInt(FLOOR_PRICE_DAYS) * TIME_CONSTANTS.EPOCHS_PER_DAY
const rateAllowance = FLOOR_PRICE_PER_30_DAYS / epochsInFloorPeriod

const epochsInLockupDays = BigInt(DEFAULT_LOCKUP_DAYS) * TIME_CONSTANTS.EPOCHS_PER_DAY
const lockupAllowance = rateAllowance * epochsInLockupDays

return {
rateAllowance,
lockupAllowance,
storageCapacityTiB: 0, // Floor price is not size-based
}
}
44 changes: 21 additions & 23 deletions src/core/payments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,27 @@
import { SIZE_CONSTANTS, type Synapse, TIME_CONSTANTS, TOKENS } from '@filoz/synapse-sdk'
import { ethers } from 'ethers'
import { isSessionKeyMode } from '../synapse/index.js'
import {
BUFFER_DENOMINATOR,
BUFFER_NUMERATOR,
DEFAULT_LOCKUP_DAYS,
MAX_LOCKUP_ALLOWANCE,
MAX_RATE_ALLOWANCE,
MIN_FIL_FOR_GAS,
STORAGE_SCALE_MAX,
STORAGE_SCALE_MAX_BI,
USDFC_DECIMALS,
} from './constants.js'
import { applyFloorPricing } from './floor-pricing.js'
import type { PaymentStatus, ServiceApprovalStatus, StorageAllowances, StorageRunwaySummary } from './types.js'

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

export * from './floor-pricing.js'
export * from './top-up.js'
export * from './types.js'

// Maximum allowances for trusted WarmStorage service
// Using MaxUint256 which MetaMask displays as "Unlimited"
const MAX_RATE_ALLOWANCE = ethers.MaxUint256
const MAX_LOCKUP_ALLOWANCE = ethers.MaxUint256

// Standard buffer configuration (10%) used across deposit/lockup calculations
const BUFFER_NUMERATOR = 11n
const BUFFER_DENOMINATOR = 10n

// Helper to apply a buffer on top of a base amount
function withBuffer(amount: bigint): bigint {
return (amount * BUFFER_NUMERATOR) / BUFFER_DENOMINATOR
Expand All @@ -47,12 +49,6 @@ function withoutBuffer(amount: bigint): bigint {
return (amount * BUFFER_DENOMINATOR) / BUFFER_NUMERATOR
}

/**
* Maximum precision scale used when converting small TiB (as a float) to integer(BigInt) math
*/
export const STORAGE_SCALE_MAX = 10_000_000
const STORAGE_SCALE_MAX_BI = BigInt(STORAGE_SCALE_MAX)

/**
* Compute adaptive integer scaling for a TiB value so that
* Math.floor(storageTiB * scale) stays within Number.MAX_SAFE_INTEGER.
Expand Down Expand Up @@ -708,8 +704,9 @@ export function computeAdjustmentForExactDaysWithPiece(
const currentRateUsed = status.currentAllowances.rateUsed ?? 0n
const currentLockupUsed = status.currentAllowances.lockupUsed ?? 0n

// Calculate required allowances for the new file
const newPieceAllowances = calculateRequiredAllowances(pieceSizeBytes, pricePerTiBPerEpoch)
// Calculate required allowances for the new file with floor pricing applied
const baseAllowances = calculateRequiredAllowances(pieceSizeBytes, pricePerTiBPerEpoch)
const newPieceAllowances = applyFloorPricing(baseAllowances)

// Calculate new totals after adding the piece
const newRateUsed = currentRateUsed + newPieceAllowances.rateAllowance
Expand Down Expand Up @@ -924,8 +921,9 @@ export function calculatePieceUploadRequirements(
insufficientDeposit: bigint
canUpload: boolean
} {
// Calculate requirements
const required = calculateRequiredAllowances(pieceSizeBytes, pricePerTiBPerEpoch)
// Calculate base requirements and apply floor pricing
const baseRequired = calculateRequiredAllowances(pieceSizeBytes, pricePerTiBPerEpoch)
const required = applyFloorPricing(baseRequired)
const totalDepositNeeded = withBuffer(required.lockupAllowance)

// Check if current deposit can cover the new file's lockup requirement
Expand Down
Loading