diff --git a/src/add/add.ts b/src/add/add.ts index 28a703e3..cbf79b01 100644 --- a/src/add/add.ts +++ b/src/add/add.ts @@ -124,7 +124,7 @@ export async function runAdd(options: AddOptions): Promise { // 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 diff --git a/src/common/upload-flow.ts b/src/common/upload-flow.ts index 63d40c0c..d056a015 100644 --- a/src/common/upload-flow.ts +++ b/src/common/upload-flow.ts @@ -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 { +export async function validatePaymentSetup( + synapse: Synapse, + fileSize: number, + spinner?: Spinner, + options?: { suppressSuggestions?: boolean } +): Promise { const readiness = await checkUploadReadiness({ synapse, fileSize, @@ -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)`) } else { - spinner?.stop(`${pc.green('✓')} Payment capacity verified`) + spinner?.stop(`${pc.green('✓')} Payment capacity verified for ${formatFileSize(fileSize)}`) } } @@ -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:')) diff --git a/src/core/payments/constants.ts b/src/core/payments/constants.ts new file mode 100644 index 00000000..6f044cc1 --- /dev/null +++ b/src/core/payments/constants.ts @@ -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) diff --git a/src/core/payments/floor-pricing.ts b/src/core/payments/floor-pricing.ts new file mode 100644 index 00000000..f698a295 --- /dev/null +++ b/src/core/payments/floor-pricing.ts @@ -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 + } +} diff --git a/src/core/payments/index.ts b/src/core/payments/index.ts index 4250aa13..8c7961f1 100644 --- a/src/core/payments/index.ts +++ b/src/core/payments/index.ts @@ -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 @@ -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. @@ -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 @@ -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 diff --git a/src/test/unit/floor-pricing.test.ts b/src/test/unit/floor-pricing.test.ts new file mode 100644 index 00000000..f316eb9e --- /dev/null +++ b/src/test/unit/floor-pricing.test.ts @@ -0,0 +1,154 @@ +import { SIZE_CONSTANTS, TIME_CONSTANTS } from '@filoz/synapse-sdk' +import { describe, expect, it } from 'vitest' +import { + applyFloorPricing, + BUFFER_DENOMINATOR, + BUFFER_NUMERATOR, + calculatePieceUploadRequirements, + calculateRequiredAllowances, + computeAdjustmentForExactDaysWithPiece, + DEFAULT_LOCKUP_DAYS, + getFloorAllowances, + type PaymentStatus, + type ServiceApprovalStatus, +} from '../../core/payments/index.js' + +function makeStatus(params: { filecoinPayBalance: bigint; lockupUsed?: bigint; rateUsed?: bigint }): PaymentStatus { + const currentAllowances: ServiceApprovalStatus = { + rateAllowance: 0n, + lockupAllowance: 0n, + lockupUsed: params.lockupUsed ?? 0n, + rateUsed: params.rateUsed ?? 0n, + maxLockupPeriod: BigInt(DEFAULT_LOCKUP_DAYS) * TIME_CONSTANTS.EPOCHS_PER_DAY, + } + + return { + network: 'calibration', + address: '0x0000000000000000000000000000000000000000', + filBalance: 0n, + walletUsdfcBalance: 0n, + filecoinPayBalance: params.filecoinPayBalance, + currentAllowances, + } +} + +function getBufferedFloorDeposit(): bigint { + const floor = getFloorAllowances() + return (floor.lockupAllowance * BUFFER_NUMERATOR) / BUFFER_DENOMINATOR +} + +describe('applyFloorPricing', () => { + const mockPricing = 100_000_000_000_000n // Some arbitrary price per TiB per epoch + + const smallCases = [ + { label: 'tiny file (1 byte)', size: 1 }, + { label: 'small file (1 KB)', size: 1024 }, + { label: 'medium file (1 MB)', size: 1024 * 1024 }, + ] + + for (const { label, size } of smallCases) { + it(`applies floor pricing to ${label}`, () => { + const baseAllowances = calculateRequiredAllowances(size, mockPricing) + const floorAdjusted = applyFloorPricing(baseAllowances) + const floor = getFloorAllowances() + + expect(floorAdjusted.rateAllowance).toBe(floor.rateAllowance) + expect(floorAdjusted.lockupAllowance).toBe(floor.lockupAllowance) + }) + } + + it('does not apply floor for large file when base cost exceeds floor', () => { + // Use a very high price to ensure base cost exceeds floor + const highPrice = 1_000_000_000_000_000_000n // 1 USDFC per TiB per epoch + const largeFile = Number(SIZE_CONSTANTS.GiB) * 100 // 100 GiB + const baseAllowances = calculateRequiredAllowances(largeFile, highPrice) + const floorAdjusted = applyFloorPricing(baseAllowances) + const floor = getFloorAllowances() + + // Base cost should exceed floor, so base is returned + expect(floorAdjusted.rateAllowance).toBeGreaterThan(floor.rateAllowance) + expect(floorAdjusted.lockupAllowance).toBeGreaterThan(floor.lockupAllowance) + expect(floorAdjusted.rateAllowance).toBe(baseAllowances.rateAllowance) + }) +}) + +describe('calculatePieceUploadRequirements - Floor Pricing Integration', () => { + const mockPricing = 100_000_000_000_000n + + const floorSizes = [ + { label: '0-byte file', size: 0 }, + { label: '1 KB file', size: 1024 }, + ] + + for (const { label, size } of floorSizes) { + it(`enforces floor price for ${label}`, () => { + const status = makeStatus({ filecoinPayBalance: 0n }) + const requirements = calculatePieceUploadRequirements(status, size, mockPricing) + const floor = getFloorAllowances() + + expect(requirements.required.rateAllowance).toBe(floor.rateAllowance) + expect(requirements.required.lockupAllowance).toBe(floor.lockupAllowance) + }) + } + + it('requires deposit with 10% buffer applied', () => { + const status = makeStatus({ filecoinPayBalance: 0n }) + const requirements = calculatePieceUploadRequirements(status, 0, mockPricing) + const bufferedFloor = getBufferedFloorDeposit() + expect(requirements.totalDepositNeeded).toBe(bufferedFloor) + + // User needs to deposit the buffered amount + expect(requirements.insufficientDeposit).toBe(bufferedFloor) + expect(requirements.canUpload).toBe(false) + }) + + it('allows upload when deposit meets buffered floor price', () => { + const bufferedFloor = getBufferedFloorDeposit() + const status = makeStatus({ filecoinPayBalance: bufferedFloor }) + + const requirements = calculatePieceUploadRequirements(status, 0, mockPricing) + + expect(requirements.canUpload).toBe(true) + expect(requirements.insufficientDeposit).toBe(0n) + }) + + it('blocks upload when deposit is below buffered floor price', () => { + const bufferedFloor = getBufferedFloorDeposit() + const slightlyLess = bufferedFloor - 1n + const status = makeStatus({ filecoinPayBalance: slightlyLess }) + + const requirements = calculatePieceUploadRequirements(status, 0, mockPricing) + + expect(requirements.canUpload).toBe(false) + expect(requirements.insufficientDeposit).toBe(1n) + }) +}) + +describe('computeAdjustmentForExactDaysWithPiece - Floor Pricing Integration', () => { + const mockPricing = 100_000_000_000_000n + + it('applies floor pricing for small file in auto-fund calculation', () => { + const status = makeStatus({ filecoinPayBalance: 0n, lockupUsed: 0n, rateUsed: 0n }) + const adjustment = computeAdjustmentForExactDaysWithPiece(status, 30, 1024, mockPricing) + const floor = getFloorAllowances() + + // Should use floor-adjusted allowances + expect(adjustment.newRateUsed).toBe(floor.rateAllowance) + expect(adjustment.newLockupUsed).toBe(floor.lockupAllowance) + }) + + it('calculates correct deposit delta with floor pricing', () => { + const status = makeStatus({ filecoinPayBalance: 0n, lockupUsed: 0n, rateUsed: 0n }) + const adjustment = computeAdjustmentForExactDaysWithPiece(status, 30, 0, mockPricing) + const floor = getFloorAllowances() + + // Target deposit = buffered lockup + runway + const bufferedLockup = getBufferedFloorDeposit() + const perDay = floor.rateAllowance * TIME_CONSTANTS.EPOCHS_PER_DAY + const safety = perDay > 0n ? perDay / 24n : 1n + const runwayCost = 30n * perDay + safety + + expect(adjustment.delta).toBeGreaterThan(0n) + expect(adjustment.targetDeposit).toBe(bufferedLockup + runwayCost) + }) +})