diff --git a/docs/src/content/docs/guides/storage.mdx b/docs/src/content/docs/guides/storage.mdx index e0969578..9be2c07c 100644 --- a/docs/src/content/docs/guides/storage.mdx +++ b/docs/src/content/docs/guides/storage.mdx @@ -286,9 +286,11 @@ Deletion of individual pieces is not supported at this time but is on the roadma Get comprehensive information about the storage service: ```typescript -// Get storage service info including pricing and providers -const info = await synapse.getStorageInfo() -console.log('Price per TiB/month:', info.pricing.noCDN.perTiBPerMonth) +// Get service information including pricing and providers +const info = await synapse.storage.getServiceInfo() +console.log('Storage price per TiB/month:', info.pricing.storagePricePerTiBPerMonth) +console.log('CDN egress price per TiB:', info.pricing.cdnEgressPricePerTiB) +console.log('Minimum price per month:', info.pricing.minimumPricePerMonth) console.log('Available providers:', info.providers.length) console.log('Network:', info.serviceParameters.network) diff --git a/docs/src/content/docs/intro/getting-started.mdx b/docs/src/content/docs/intro/getting-started.mdx index 925e9ceb..82cf95c6 100644 --- a/docs/src/content/docs/intro/getting-started.mdx +++ b/docs/src/content/docs/intro/getting-started.mdx @@ -651,9 +651,11 @@ The SDK batches up to 32 uploads by default (configurable via `uploadBatchSize`) Get comprehensive information about the storage service: ```typescript -// Get storage service info including pricing and providers -const info = await synapse.getStorageInfo() -console.log('Price per TiB/month:', info.pricing.noCDN.perTiBPerMonth) +// Get service information including pricing and providers +const info = await synapse.storage.getServiceInfo() +console.log('Storage price per TiB/month:', info.pricing.storagePricePerTiBPerMonth) +console.log('CDN egress price per TiB:', info.pricing.cdnEgressPricePerTiB) +console.log('Minimum price per month:', info.pricing.minimumPricePerMonth) console.log('Available providers:', info.providers.length) console.log('Network:', info.serviceParameters.network) diff --git a/packages/synapse-sdk/src/payments/service.ts b/packages/synapse-sdk/src/payments/service.ts index 12f82545..164c6aea 100644 --- a/packages/synapse-sdk/src/payments/service.ts +++ b/packages/synapse-sdk/src/payments/service.ts @@ -4,7 +4,7 @@ */ import { ethers } from 'ethers' -import type { RailInfo, SettlementResult, TokenAmount, TokenIdentifier } from '../types.ts' +import type { RailInfo, ServiceReadinessCheck, SettlementResult, TokenAmount, TokenIdentifier } from '../types.ts' import { CHAIN_IDS, CONTRACT_ABIS, @@ -602,6 +602,69 @@ export class PaymentsService { } } + /** + * Check if the user is ready to use a service with given requirements + * Performs comprehensive checks including approval status, allowances, and funds + * @param service - Service contract address + * @param requirements - Required rate, lockup, and lockup period + * @param token - The token to check (defaults to USDFC) + * @returns Readiness status with detailed checks, current state, and gaps + */ + async checkServiceReadiness( + service: string, + requirements: { + rateNeeded: bigint + lockupNeeded: bigint + lockupPeriodNeeded: bigint + }, + token: TokenIdentifier = TOKENS.USDFC + ): Promise { + // Fetch approval and account info in parallel + const [approval, accountInfo] = await Promise.all([this.serviceApproval(service, token), this.accountInfo(token)]) + + // Perform all checks + const checks = { + isOperatorApproved: approval.rateAllowance > 0n || approval.lockupAllowance > 0n, + hasSufficientFunds: accountInfo.availableFunds >= requirements.lockupNeeded, + hasRateAllowance: approval.rateAllowance >= approval.rateUsed + requirements.rateNeeded, + hasLockupAllowance: approval.lockupAllowance >= approval.lockupUsed + requirements.lockupNeeded, + hasValidLockupPeriod: approval.maxLockupPeriod >= requirements.lockupPeriodNeeded, + } + + const sufficient = Object.values(checks).every((check) => check) + + // Calculate gaps if not sufficient + const gaps: { + fundsNeeded?: bigint + rateAllowanceNeeded?: bigint + lockupAllowanceNeeded?: bigint + lockupPeriodNeeded?: bigint + } = {} + + if (!checks.hasSufficientFunds) { + gaps.fundsNeeded = requirements.lockupNeeded - accountInfo.availableFunds + } + if (!checks.hasRateAllowance) { + gaps.rateAllowanceNeeded = approval.rateUsed + requirements.rateNeeded - approval.rateAllowance + } + if (!checks.hasLockupAllowance) { + gaps.lockupAllowanceNeeded = approval.lockupUsed + requirements.lockupNeeded - approval.lockupAllowance + } + if (!checks.hasValidLockupPeriod) { + gaps.lockupPeriodNeeded = requirements.lockupPeriodNeeded - approval.maxLockupPeriod + } + + return { + sufficient, + checks, + currentState: { + approval, + accountInfo, + }, + gaps: sufficient ? undefined : gaps, + } + } + async deposit( amount: TokenAmount, token: TokenIdentifier = TOKENS.USDFC, diff --git a/packages/synapse-sdk/src/storage/context.ts b/packages/synapse-sdk/src/storage/context.ts index 5440912c..342edd91 100644 --- a/packages/synapse-sdk/src/storage/context.ts +++ b/packages/synapse-sdk/src/storage/context.ts @@ -54,6 +54,8 @@ import { getCurrentEpoch, METADATA_KEYS, SIZE_CONSTANTS, + TIME_CONSTANTS, + TOKENS, timeUntilEpoch, } from '../utils/index.ts' import { combineMetadata, metadataMatches, objectToEntries, validatePieceMetadata } from '../utils/metadata.ts' @@ -785,34 +787,70 @@ export class StorageContext { /** * Static method to perform preflight checks for an upload - * @param size - The size of data to upload in bytes - * @param withCDN - Whether CDN is enabled + * Composes cost calculation from WarmStorageService and readiness checks from PaymentsService * @param warmStorageService - WarmStorageService instance * @param paymentsService - PaymentsService instance + * @param size - The size of data to upload in bytes * @returns Preflight check results without provider/dataSet specifics */ static async performPreflightCheck( warmStorageService: WarmStorageService, paymentsService: PaymentsService, - size: number, - withCDN: boolean + size: number ): Promise { // Validate size before proceeding StorageContext.validateRawSize(size, 'preflightUpload') - // Check allowances and get costs in a single call - const allowanceCheck = await warmStorageService.checkAllowanceForStorage(size, withCDN, paymentsService) + // Calculate upload cost + const cost = await warmStorageService.calculateUploadCost(size) - // Return preflight info - return { - estimatedCost: { - perEpoch: allowanceCheck.costs.perEpoch, - perDay: allowanceCheck.costs.perDay, - perMonth: allowanceCheck.costs.perMonth, + // Calculate rate per epoch from floor-adjusted monthly price + const pricing = await warmStorageService.getServicePrice() + const ratePerEpoch = cost.withFloorPerMonth / pricing.epochsPerMonth + + // Calculate lockup requirements + const lockupEpochs = BigInt(TIME_CONSTANTS.DEFAULT_LOCKUP_DAYS * TIME_CONSTANTS.EPOCHS_PER_DAY) + const lockupNeeded = ratePerEpoch * lockupEpochs + + // Check payment readiness + const readiness = await paymentsService.checkServiceReadiness( + warmStorageService.getContractAddress(), + { + rateNeeded: ratePerEpoch, + lockupNeeded, + lockupPeriodNeeded: lockupEpochs, }, + TOKENS.USDFC + ) + + // Build error message if not sufficient + let message: string | undefined + if (!readiness.sufficient) { + const issues: string[] = [] + if (!readiness.checks.hasSufficientFunds && readiness.gaps?.fundsNeeded) { + issues.push(`Insufficient funds: need ${readiness.gaps.fundsNeeded} more`) + } + if (!readiness.checks.isOperatorApproved) { + issues.push('Operator not approved for service') + } + if (!readiness.checks.hasRateAllowance && readiness.gaps?.rateAllowanceNeeded) { + issues.push(`Insufficient rate allowance: need ${readiness.gaps.rateAllowanceNeeded} more`) + } + if (!readiness.checks.hasLockupAllowance && readiness.gaps?.lockupAllowanceNeeded) { + issues.push(`Insufficient lockup allowance: need ${readiness.gaps.lockupAllowanceNeeded} more`) + } + if (!readiness.checks.hasValidLockupPeriod && readiness.gaps?.lockupPeriodNeeded) { + issues.push(`Lockup period too short: need ${readiness.gaps.lockupPeriodNeeded} more epochs`) + } + message = issues.join('; ') + } + + return { + estimatedCostPerMonth: cost.withFloorPerMonth, allowanceCheck: { - sufficient: allowanceCheck.sufficient, - message: allowanceCheck.message, + sufficient: readiness.sufficient, + message, + checks: readiness.checks, }, selectedProvider: null, selectedDataSetId: null, @@ -829,8 +867,7 @@ export class StorageContext { const preflightResult = await StorageContext.performPreflightCheck( this._warmStorageService, this._synapse.payments, - size, - this._withCDN + size ) // Return preflight info with provider and dataSet specifics diff --git a/packages/synapse-sdk/src/storage/manager.ts b/packages/synapse-sdk/src/storage/manager.ts index e940f73a..03f46d28 100644 --- a/packages/synapse-sdk/src/storage/manager.ts +++ b/packages/synapse-sdk/src/storage/manager.ts @@ -32,8 +32,8 @@ import type { PieceRetriever, PreflightInfo, ProviderInfo, + ServiceInfo, StorageContextCallbacks, - StorageInfo, StorageServiceOptions, UploadCallbacks, UploadResult, @@ -213,10 +213,8 @@ export class StorageManager { size: number, options?: { withCDN?: boolean; metadata?: Record } ): Promise { - // Determine withCDN from metadata if provided, otherwise use option > manager default - let withCDN = options?.withCDN ?? this._withCDN - - // Check metadata for withCDN key - this takes precedence + // Note: withCDN from metadata is checked here for validation but doesn't affect base storage costs + // CDN pricing is usage-based (egress charges), so base storage cost is the same regardless if (options?.metadata != null && METADATA_KEYS.WITH_CDN in options.metadata) { // The withCDN metadata entry should always have an empty string value by convention, // but the contract only checks for key presence, not value @@ -224,11 +222,10 @@ export class StorageManager { if (value !== '') { console.warn(`Warning: withCDN metadata entry has unexpected value "${value}". Expected empty string.`) } - withCDN = true // Enable CDN when key exists (matches contract behavior) } // Use the static method from StorageContext for core logic - return await StorageContext.performPreflightCheck(this._warmStorageService, this._synapse.payments, size, withCDN) + return await StorageContext.performPreflightCheck(this._warmStorageService, this._synapse.payments, size) } /** @@ -367,14 +364,12 @@ export class StorageManager { } /** - * Get comprehensive information about the storage service including - * approved providers, pricing, contract addresses, and current allowances - * @returns Complete storage service information + * Get service information including pricing, providers, and configuration + * @returns Service information with pricing, approved providers, and contract addresses */ - async getStorageInfo(): Promise { + async getServiceInfo(): Promise { try { - // Helper function to get allowances with error handling - const getOptionalAllowances = async (): Promise => { + const getOptionalAllowances = async (): Promise => { try { const warmStorageAddress = this._synapse.getWarmStorageAddress() const approval = await this._synapse.payments.serviceApproval(warmStorageAddress, TOKENS.USDFC) @@ -388,65 +383,35 @@ export class StorageManager { lockupUsed: approval.lockupUsed, } } catch { - // Return null if wallet not connected or any error occurs return null } } - // Create SPRegistryService to get providers const registryAddress = this._warmStorageService.getServiceProviderRegistryAddress() const spRegistry = new SPRegistryService(this._synapse.getProvider(), registryAddress) - // Fetch all data in parallel for performance const [pricingData, approvedIds, allowances] = await Promise.all([ this._warmStorageService.getServicePrice(), this._warmStorageService.getApprovedProviderIds(), getOptionalAllowances(), ]) - // Get provider details for approved IDs const providers = await spRegistry.getProviders(approvedIds) - - // Calculate pricing per different time units - const epochsPerMonth = BigInt(pricingData.epochsPerMonth) - - // TODO: StorageInfo needs updating to reflect that CDN costs are usage-based - - // Calculate per-epoch pricing (base storage cost) - const noCDNPerEpoch = BigInt(pricingData.pricePerTiBPerMonthNoCDN) / epochsPerMonth - // CDN costs are usage-based (egress charges), so base storage cost is the same - const withCDNPerEpoch = BigInt(pricingData.pricePerTiBPerMonthNoCDN) / epochsPerMonth - - // Calculate per-day pricing (base storage cost) - const noCDNPerDay = BigInt(pricingData.pricePerTiBPerMonthNoCDN) / TIME_CONSTANTS.DAYS_PER_MONTH - // CDN costs are usage-based (egress charges), so base storage cost is the same - const withCDNPerDay = BigInt(pricingData.pricePerTiBPerMonthNoCDN) / TIME_CONSTANTS.DAYS_PER_MONTH - - // Filter out providers with zero addresses const validProviders = providers.filter((p: ProviderInfo) => p.serviceProvider !== ethers.ZeroAddress) - const network = this._synapse.getNetwork() - return { pricing: { - noCDN: { - perTiBPerMonth: BigInt(pricingData.pricePerTiBPerMonthNoCDN), - perTiBPerDay: noCDNPerDay, - perTiBPerEpoch: noCDNPerEpoch, - }, - // CDN costs are usage-based (egress charges), base storage cost is the same - withCDN: { - perTiBPerMonth: BigInt(pricingData.pricePerTiBPerMonthNoCDN), - perTiBPerDay: withCDNPerDay, - perTiBPerEpoch: withCDNPerEpoch, - }, + storagePricePerTiBPerMonth: pricingData.pricePerTiBPerMonthNoCDN, + minimumPricePerMonth: pricingData.minimumPricePerMonth, + cdnEgressPricePerTiB: pricingData.pricePerTiBCdnEgress, + cacheMissEgressPricePerTiB: pricingData.pricePerTiBCacheMissEgress, tokenAddress: pricingData.tokenAddress, - tokenSymbol: 'USDFC', // Hardcoded as we know it's always USDFC + tokenSymbol: 'USDFC', }, providers: validProviders, serviceParameters: { - network, - epochsPerMonth, + network: this._synapse.getNetwork(), + epochsPerMonth: pricingData.epochsPerMonth, epochsPerDay: TIME_CONSTANTS.EPOCHS_PER_DAY, epochDuration: TIME_CONSTANTS.EPOCH_DURATION, minUploadSize: SIZE_CONSTANTS.MIN_UPLOAD_SIZE, @@ -454,13 +419,22 @@ export class StorageManager { warmStorageAddress: this._synapse.getWarmStorageAddress(), paymentsAddress: this._warmStorageService.getPaymentsAddress(), pdpVerifierAddress: this._warmStorageService.getPDPVerifierAddress(), + serviceProviderRegistryAddress: this._warmStorageService.getServiceProviderRegistryAddress(), + sessionKeyRegistryAddress: this._warmStorageService.getSessionKeyRegistryAddress(), }, allowances, } } catch (error) { - throw new Error( - `Failed to get storage service information: ${error instanceof Error ? error.message : String(error)}` - ) + throw new Error(`Failed to get service information: ${error instanceof Error ? error.message : String(error)}`) } } + + /** + * Get service information including pricing, providers, and configuration + * @deprecated Use getServiceInfo() instead + * @returns Service information with pricing, approved providers, and contract addresses + */ + async getStorageInfo(): Promise { + return await this.getServiceInfo() + } } diff --git a/packages/synapse-sdk/src/synapse.ts b/packages/synapse-sdk/src/synapse.ts index cbf6099c..418ea927 100644 --- a/packages/synapse-sdk/src/synapse.ts +++ b/packages/synapse-sdk/src/synapse.ts @@ -17,7 +17,6 @@ import type { PieceCID, PieceRetriever, ProviderInfo, - StorageInfo, StorageServiceOptions, SubgraphConfig, SynapseOptions, @@ -461,15 +460,4 @@ export class Synapse { throw new Error(`Failed to get provider info: ${error instanceof Error ? error.message : String(error)}`) } } - - /** - * Get comprehensive information about the storage service including - * approved providers, pricing, contract addresses, and current allowances - * @deprecated Use synapse.storage.getStorageInfo() instead. This method will be removed in a future version. - * @returns Complete storage service information - */ - async getStorageInfo(): Promise { - console.warn('synapse.getStorageInfo() is deprecated. Use synapse.storage.getStorageInfo() instead.') - return await this._storageManager.getStorageInfo() - } } diff --git a/packages/synapse-sdk/src/test/storage.test.ts b/packages/synapse-sdk/src/test/storage.test.ts index 811da26e..9a52dbbc 100644 --- a/packages/synapse-sdk/src/test/storage.test.ts +++ b/packages/synapse-sdk/src/test/storage.test.ts @@ -38,10 +38,46 @@ const mockSynapse = { payments: { serviceApproval: async () => ({ service: '0x1234567890123456789012345678901234567890', - rateAllowance: BigInt(1000000), - lockupAllowance: BigInt(10000000), + rateAllowance: ethers.parseUnits('10', 18), // 10 USDFC rate allowance + lockupAllowance: ethers.parseUnits('100', 18), // 100 USDFC lockup allowance rateUsed: BigInt(0), lockupUsed: BigInt(0), + maxLockupPeriod: BigInt(86400), // 30 days in epochs + }), + accountInfo: async () => ({ + funds: ethers.parseUnits('1000', 18), + lockupCurrent: BigInt(0), + lockupRate: BigInt(0), + lockupLastSettledAt: BigInt(0), + availableFunds: ethers.parseUnits('1000', 18), // 1000 USDFC available + }), + checkServiceReadiness: async (_service: string, _requirements: any) => ({ + sufficient: true, + checks: { + isOperatorApproved: true, + hasSufficientFunds: true, + hasRateAllowance: true, + hasLockupAllowance: true, + hasValidLockupPeriod: true, + }, + currentState: { + approval: { + isApproved: true, + rateAllowance: ethers.parseUnits('10', 18), + rateUsed: BigInt(0), + lockupAllowance: ethers.parseUnits('100', 18), + lockupUsed: BigInt(0), + maxLockupPeriod: BigInt(86400), + }, + accountInfo: { + funds: ethers.parseUnits('1000', 18), + lockupCurrent: BigInt(0), + lockupRate: BigInt(0), + lockupLastSettledAt: BigInt(0), + availableFunds: ethers.parseUnits('1000', 18), + }, + }, + gaps: undefined, }), }, download: async (_pieceCid: string | PieceCID, _options?: any) => { @@ -1716,21 +1752,21 @@ describe('StorageService', () => { describe('preflightUpload', () => { it('should calculate costs without CDN', async () => { const mockWarmStorageService = { - checkAllowanceForStorage: async () => ({ - rateAllowanceNeeded: BigInt(100), - lockupAllowanceNeeded: BigInt(2880000), - currentRateAllowance: BigInt(1000000), - currentLockupAllowance: BigInt(10000000), - currentRateUsed: BigInt(0), - currentLockupUsed: BigInt(0), - sufficient: true, - message: undefined, - costs: { - perEpoch: BigInt(100), - perDay: BigInt(28800), - perMonth: BigInt(864000), - }, + getServicePrice: async () => ({ + pricePerTiBPerMonthNoCDN: ethers.parseUnits('2', 18), // 2 USDFC per TiB per month + pricePerTiBCdnEgress: ethers.parseUnits('0.05', 18), + pricePerTiBCacheMissEgress: ethers.parseUnits('0.1', 18), + minimumPricePerMonth: ethers.parseUnits('0.06', 18), // 0.06 USDFC floor + epochsPerMonth: BigInt(86400), + tokenAddress: '0xTOKEN', }), + calculateUploadCost: async (sizeInBytes: number) => { + const pricePerEpoch = (ethers.parseUnits('2', 18) * BigInt(sizeInBytes)) / (SIZE_CONSTANTS.TiB * 86400n) + const perMonth = pricePerEpoch * 86400n + const withFloorPerMonth = perMonth > ethers.parseUnits('0.06', 18) ? perMonth : ethers.parseUnits('0.06', 18) + return { perMonth, withFloorPerMonth } + }, + getContractAddress: () => '0x1234567890123456789012345678901234567890', validateDataSet: async (): Promise => { /* no-op */ }, @@ -1750,29 +1786,28 @@ describe('StorageService', () => { const preflight = await service.preflightUpload(Number(SIZE_CONSTANTS.MiB)) // 1 MiB - assert.equal(preflight.estimatedCost.perEpoch, BigInt(100)) - assert.equal(preflight.estimatedCost.perDay, BigInt(28800)) - assert.equal(preflight.estimatedCost.perMonth, BigInt(864000)) + // For small sizes like 1 MiB, the floor price applies + assert.equal(preflight.estimatedCostPerMonth, ethers.parseUnits('0.06', 18)) assert.isTrue(preflight.allowanceCheck.sufficient) }) it('should calculate costs with CDN', async () => { const mockWarmStorageService = { - checkAllowanceForStorage: async (): Promise => ({ - rateAllowanceNeeded: BigInt(200), - lockupAllowanceNeeded: BigInt(5760000), - currentRateAllowance: BigInt(1000000), - currentLockupAllowance: BigInt(10000000), - currentRateUsed: BigInt(0), - currentLockupUsed: BigInt(0), - sufficient: true, - message: undefined, - costs: { - perEpoch: BigInt(200), - perDay: BigInt(57600), - perMonth: BigInt(1728000), - }, + getServicePrice: async () => ({ + pricePerTiBPerMonthNoCDN: ethers.parseUnits('2', 18), // 2 USDFC per TiB per month + pricePerTiBCdnEgress: ethers.parseUnits('0.05', 18), + pricePerTiBCacheMissEgress: ethers.parseUnits('0.1', 18), + minimumPricePerMonth: ethers.parseUnits('0.06', 18), // 0.06 USDFC floor + epochsPerMonth: BigInt(86400), + tokenAddress: '0xTOKEN', }), + calculateUploadCost: async (sizeInBytes: number) => { + const pricePerEpoch = (ethers.parseUnits('2', 18) * BigInt(sizeInBytes)) / (SIZE_CONSTANTS.TiB * 86400n) + const perMonth = pricePerEpoch * 86400n + const withFloorPerMonth = perMonth > ethers.parseUnits('0.06', 18) ? perMonth : ethers.parseUnits('0.06', 18) + return { perMonth, withFloorPerMonth } + }, + getContractAddress: () => '0x1234567890123456789012345678901234567890', validateDataSet: async (): Promise => { /* no-op */ }, @@ -1792,39 +1827,88 @@ describe('StorageService', () => { const preflight = await service.preflightUpload(Number(SIZE_CONSTANTS.MiB)) // 1 MiB - // Should use CDN costs - assert.equal(preflight.estimatedCost.perEpoch, BigInt(200)) - assert.equal(preflight.estimatedCost.perDay, BigInt(57600)) - assert.equal(preflight.estimatedCost.perMonth, BigInt(1728000)) + // Storage cost is the same regardless of CDN (CDN is usage-based) + // For small sizes like 1 MiB, the floor price applies + assert.equal(preflight.estimatedCostPerMonth, ethers.parseUnits('0.06', 18)) assert.isTrue(preflight.allowanceCheck.sufficient) }) it('should handle insufficient allowances', async () => { const mockWarmStorageService = { - checkAllowanceForStorage: async (): Promise => ({ - rateAllowanceNeeded: BigInt(2000000), - lockupAllowanceNeeded: BigInt(20000000), - currentRateAllowance: BigInt(1000000), - currentLockupAllowance: BigInt(10000000), - currentRateUsed: BigInt(0), - currentLockupUsed: BigInt(0), - sufficient: false, - message: - 'Rate allowance insufficient: current 1000000, need 2000000. Lockup allowance insufficient: current 10000000, need 20000000', - costs: { - perEpoch: BigInt(100), - perDay: BigInt(28800), - perMonth: BigInt(864000), - }, + getServicePrice: async () => ({ + pricePerTiBPerMonthNoCDN: ethers.parseUnits('2', 18), + pricePerTiBCdnEgress: ethers.parseUnits('0.05', 18), + pricePerTiBCacheMissEgress: ethers.parseUnits('0.1', 18), + minimumPricePerMonth: ethers.parseUnits('0.06', 18), + epochsPerMonth: BigInt(86400), + tokenAddress: '0xTOKEN', }), + calculateUploadCost: async (sizeInBytes: number) => { + const pricePerEpoch = (ethers.parseUnits('2', 18) * BigInt(sizeInBytes)) / (SIZE_CONSTANTS.TiB * 86400n) + const perMonth = pricePerEpoch * 86400n + const withFloorPerMonth = perMonth > ethers.parseUnits('0.06', 18) ? perMonth : ethers.parseUnits('0.06', 18) + return { perMonth, withFloorPerMonth } + }, + getContractAddress: () => '0x1234567890123456789012345678901234567890', validateDataSet: async (): Promise => { /* no-op */ }, getDataSet: async (): Promise => ({ clientDataSetId: 1n }), getServiceProviderRegistryAddress: () => ADDRESSES.calibration.spRegistry, } as any + const mockSynapseWithLowAllowance = { + ...mockSynapse, + payments: { + serviceApproval: async () => ({ + service: '0x1234567890123456789012345678901234567890', + rateAllowance: ethers.parseUnits('0.00001', 18), // Very low rate allowance + lockupAllowance: ethers.parseUnits('0.0001', 18), // Very low lockup allowance + rateUsed: BigInt(0), + lockupUsed: BigInt(0), + maxLockupPeriod: BigInt(86400), + }), + accountInfo: async () => ({ + funds: ethers.parseUnits('100', 18), + lockupCurrent: BigInt(0), + lockupRate: BigInt(0), + lockupLastSettledAt: BigInt(0), + availableFunds: ethers.parseUnits('100', 18), // Plenty of funds + }), + checkServiceReadiness: async (_service: string, requirements: any) => ({ + sufficient: false, + checks: { + isOperatorApproved: true, + hasSufficientFunds: true, + hasRateAllowance: false, + hasLockupAllowance: false, + hasValidLockupPeriod: true, + }, + currentState: { + approval: { + isApproved: true, + rateAllowance: ethers.parseUnits('0.00001', 18), + rateUsed: BigInt(0), + lockupAllowance: ethers.parseUnits('0.0001', 18), + lockupUsed: BigInt(0), + maxLockupPeriod: BigInt(86400), + }, + accountInfo: { + funds: ethers.parseUnits('100', 18), + lockupCurrent: BigInt(0), + lockupRate: BigInt(0), + lockupLastSettledAt: BigInt(0), + availableFunds: ethers.parseUnits('100', 18), + }, + }, + gaps: { + rateAllowanceNeeded: requirements.rateNeeded, + lockupAllowanceNeeded: requirements.lockupNeeded, + }, + }), + }, + } as any const service = new StorageContext( - mockSynapse, + mockSynapseWithLowAllowance, mockWarmStorageService, mockProvider, 123, @@ -1837,8 +1921,7 @@ describe('StorageService', () => { const preflight = await service.preflightUpload(Number(100n * SIZE_CONSTANTS.MiB)) // 100 MiB assert.isFalse(preflight.allowanceCheck.sufficient) - assert.include(preflight.allowanceCheck.message, 'Rate allowance insufficient') - assert.include(preflight.allowanceCheck.message, 'Lockup allowance insufficient') + assert.include(preflight.allowanceCheck.message, 'allowance') }) it('should enforce minimum size limit in preflightUpload', async () => { diff --git a/packages/synapse-sdk/src/test/synapse.test.ts b/packages/synapse-sdk/src/test/synapse.test.ts index c7cef986..fb15f112 100644 --- a/packages/synapse-sdk/src/test/synapse.test.ts +++ b/packages/synapse-sdk/src/test/synapse.test.ts @@ -277,17 +277,17 @@ describe('Synapse', () => { assert.equal(operator, ADDRESSES.calibration.warmStorage) return [ true, // isApproved - BigInt(127001 * 635000000), // rateAllowance - BigInt(127001 * 635000000), // lockupAllowance + ethers.parseUnits('10', 18), // rateAllowance (10 USDFC) + ethers.parseUnits('100', 18), // lockupAllowance (100 USDFC) BigInt(0), // rateUsage BigInt(0), // lockupUsage - BigInt(28800), // maxLockupPeriod + BigInt(86400), // maxLockupPeriod (30 days) ] }, accounts: ([token, user]) => { assert.equal(user, signerAddress) assert.equal(token, ADDRESSES.calibration.usdfcToken) - return [BigInt(127001 * 635000000), BigInt(0), BigInt(0), BigInt(0)] + return [ethers.parseUnits('1000', 18), BigInt(0), BigInt(0), BigInt(0)] }, }, eth_getTransactionByHash: (params) => { @@ -319,7 +319,7 @@ describe('Synapse', () => { // Payments uses the original signer const accountInfo = await synapse.payments.accountInfo() - assert.equal(accountInfo.funds, BigInt(127001 * 635000000)) + assert.equal(accountInfo.funds, ethers.parseUnits('1000', 18)) }) }) @@ -584,41 +584,43 @@ describe('Synapse', () => { }) }) - describe('getStorageInfo', () => { - it('should return comprehensive storage information', async () => { + describe('getServiceInfo', () => { + it('should return comprehensive service information', async () => { server.use(JSONRPC({ ...presets.basic })) const synapse = await Synapse.create({ signer }) - const storageInfo = await synapse.getStorageInfo() + const serviceInfo = await synapse.storage.getServiceInfo() // Check pricing - assert.exists(storageInfo.pricing) - assert.exists(storageInfo.pricing.noCDN) - assert.exists(storageInfo.pricing.withCDN) + assert.exists(serviceInfo.pricing) + assert.exists(serviceInfo.pricing.storagePricePerTiBPerMonth) + assert.exists(serviceInfo.pricing.minimumPricePerMonth) + assert.exists(serviceInfo.pricing.cdnEgressPricePerTiB) + assert.exists(serviceInfo.pricing.cacheMissEgressPricePerTiB) - // Verify pricing calculations (2 USDFC per TiB per month) - const expectedNoCDNMonthly = parseUnits('2', 18) // 2 USDFC - assert.equal(storageInfo.pricing.noCDN.perTiBPerMonth, expectedNoCDNMonthly) + // Verify pricing (2 USDFC per TiB per month) + const expectedStorageMonthly = parseUnits('2', 18) // 2 USDFC + assert.equal(serviceInfo.pricing.storagePricePerTiBPerMonth, expectedStorageMonthly) // Check providers - assert.equal(storageInfo.providers.length, 2) - assert.equal(storageInfo.providers[0].serviceProvider, ADDRESSES.serviceProvider1) - assert.equal(storageInfo.providers[1].serviceProvider, ADDRESSES.serviceProvider2) + assert.equal(serviceInfo.providers.length, 2) + assert.equal(serviceInfo.providers[0].serviceProvider, ADDRESSES.serviceProvider1) + assert.equal(serviceInfo.providers[1].serviceProvider, ADDRESSES.serviceProvider2) // Check service parameters - assert.equal(storageInfo.serviceParameters.network, 'calibration') - assert.equal(storageInfo.serviceParameters.epochsPerMonth, 86400n) - assert.equal(storageInfo.serviceParameters.epochsPerDay, 2880n) - assert.equal(storageInfo.serviceParameters.epochDuration, 30) - assert.equal(storageInfo.serviceParameters.minUploadSize, 127) - assert.equal(storageInfo.serviceParameters.maxUploadSize, 200 * 1024 * 1024) + assert.equal(serviceInfo.serviceParameters.network, 'calibration') + assert.equal(serviceInfo.serviceParameters.epochsPerMonth, 86400n) + assert.equal(serviceInfo.serviceParameters.epochsPerDay, 2880n) + assert.equal(serviceInfo.serviceParameters.epochDuration, 30) + assert.equal(serviceInfo.serviceParameters.minUploadSize, 127) + assert.equal(serviceInfo.serviceParameters.maxUploadSize, 200 * 1024 * 1024) - // Check allowances (including operator approval flag) - assert.exists(storageInfo.allowances) - assert.equal(storageInfo.allowances?.isApproved, true) - assert.equal(storageInfo.allowances?.service, ADDRESSES.calibration.warmStorage) - assert.equal(storageInfo.allowances?.rateAllowance, 1000000n) - assert.equal(storageInfo.allowances?.lockupAllowance, 10000000n) + // Check allowances + assert.exists(serviceInfo.allowances) + assert.equal(serviceInfo.allowances?.isApproved, true) + assert.equal(serviceInfo.allowances?.service, ADDRESSES.calibration.warmStorage) + assert.equal(serviceInfo.allowances?.rateAllowance, 1000000n) + assert.equal(serviceInfo.allowances?.lockupAllowance, 10000000n) }) it('should handle missing allowances gracefully', async () => { @@ -632,13 +634,13 @@ describe('Synapse', () => { ) const synapse = await Synapse.create({ signer }) - const storageInfo = await synapse.getStorageInfo() + const serviceInfo = await synapse.storage.getServiceInfo() // Should still return data with null allowances - assert.exists(storageInfo.pricing) - assert.exists(storageInfo.providers) - assert.exists(storageInfo.serviceParameters) - assert.deepEqual(storageInfo.allowances, { + assert.exists(serviceInfo.pricing) + assert.exists(serviceInfo.providers) + assert.exists(serviceInfo.serviceParameters) + assert.deepEqual(serviceInfo.allowances, { isApproved: false, service: ADDRESSES.calibration.warmStorage, rateAllowance: 0n, @@ -717,11 +719,11 @@ describe('Synapse', () => { ) const synapse = await Synapse.create({ signer }) - const storageInfo = await synapse.getStorageInfo() + const serviceInfo = await synapse.storage.getServiceInfo() // Should filter out zero address provider - assert.equal(storageInfo.providers.length, 1) - assert.equal(storageInfo.providers[0].serviceProvider, ADDRESSES.serviceProvider1) + assert.equal(serviceInfo.providers.length, 1) + assert.equal(serviceInfo.providers[0].serviceProvider, ADDRESSES.serviceProvider1) }) it('should handle contract call failures', async () => { @@ -738,7 +740,7 @@ describe('Synapse', () => { ) try { const synapse = await Synapse.create({ signer }) - await synapse.getStorageInfo() + await synapse.storage.getServiceInfo() assert.fail('Should have thrown') } catch (error: any) { // The error should bubble up from the contract call diff --git a/packages/synapse-sdk/src/test/warm-storage-service.test.ts b/packages/synapse-sdk/src/test/warm-storage-service.test.ts index 87ebd2de..4508834e 100644 --- a/packages/synapse-sdk/src/test/warm-storage-service.test.ts +++ b/packages/synapse-sdk/src/test/warm-storage-service.test.ts @@ -661,219 +661,6 @@ describe('WarmStorageService', () => { }) describe('Storage Cost Operations', () => { - describe('calculateStorageCost', () => { - it('should calculate storage costs correctly for 1 GiB', async () => { - server.use( - JSONRPC({ - ...presets.basic, - }) - ) - const warmStorageService = await createWarmStorageService() - const sizeInBytes = Number(SIZE_CONSTANTS.GiB) // 1 GiB - const costs = await warmStorageService.calculateStorageCost(sizeInBytes) - - assert.exists(costs.perEpoch) - assert.exists(costs.perDay) - assert.exists(costs.perMonth) - assert.exists(costs.withCDN) - assert.exists(costs.withCDN.perEpoch) - assert.exists(costs.withCDN.perDay) - assert.exists(costs.withCDN.perMonth) - - // Verify costs are reasonable - assert.isTrue(costs.perEpoch > 0n) - assert.isTrue(costs.perDay > costs.perEpoch) - assert.isTrue(costs.perMonth > costs.perDay) - - // CDN costs are usage-based (egress pricing), so withCDN equals base storage cost - assert.equal(costs.withCDN.perEpoch, costs.perEpoch) - assert.equal(costs.withCDN.perDay, costs.perDay) - assert.equal(costs.withCDN.perMonth, costs.perMonth) - }) - - it('should scale costs linearly with size', async () => { - server.use( - JSONRPC({ - ...presets.basic, - }) - ) - const warmStorageService = await createWarmStorageService() - - const costs1GiB = await warmStorageService.calculateStorageCost(Number(SIZE_CONSTANTS.GiB)) - const costs10GiB = await warmStorageService.calculateStorageCost(Number(10n * SIZE_CONSTANTS.GiB)) - - // 10 GiB should cost approximately 10x more than 1 GiB - // Allow for small rounding differences in bigint division - const ratio = Number(costs10GiB.perEpoch) / Number(costs1GiB.perEpoch) - assert.closeTo(ratio, 10, 0.01) - - // Verify the relationship holds for day and month calculations - assert.equal(costs10GiB.perDay.toString(), (costs10GiB.perEpoch * 2880n).toString()) - // For month calculation, allow for rounding errors due to integer division - const expectedMonth = costs10GiB.perEpoch * TIME_CONSTANTS.EPOCHS_PER_MONTH - const monthRatio = Number(costs10GiB.perMonth) / Number(expectedMonth) - assert.closeTo(monthRatio, 1, 0.0001) // Allow 0.01% difference due to rounding - }) - - it('should fetch pricing from WarmStorage contract', async () => { - let getServicePriceCalled = false - server.use( - JSONRPC({ - ...presets.basic, - warmStorage: { - ...presets.basic.warmStorage, - getServicePrice: () => { - getServicePriceCalled = true - return [ - { - pricePerTiBPerMonthNoCDN: parseUnits('2', 18), - pricePerTiBCdnEgress: parseUnits('0.05', 18), - pricePerTiBCacheMissEgress: parseUnits('0.1', 18), - tokenAddress: CONTRACT_ADDRESSES.USDFC.calibration, - epochsPerMonth: TIME_CONSTANTS.EPOCHS_PER_MONTH, - minimumPricePerMonth: parseUnits('0.01', 18), - }, - ] - }, - }, - }) - ) - const warmStorageService = await createWarmStorageService() - await warmStorageService.calculateStorageCost(Number(SIZE_CONSTANTS.GiB)) - assert.isTrue(getServicePriceCalled, 'Should have called getServicePrice on WarmStorage contract') - }) - }) - - describe('checkAllowanceForStorage', () => { - it('should check allowances for storage operations', async () => { - server.use( - JSONRPC({ - ...presets.basic, - }) - ) - const warmStorageService = await createWarmStorageService() - - const check = await warmStorageService.checkAllowanceForStorage( - Number(10n * SIZE_CONSTANTS.GiB), // 10 GiB - false, - paymentsService - ) - - assert.exists(check.rateAllowanceNeeded) - assert.exists(check.lockupAllowanceNeeded) - assert.exists(check.currentRateAllowance) - assert.exists(check.currentLockupAllowance) - assert.exists(check.currentRateUsed) - assert.exists(check.currentLockupUsed) - assert.exists(check.sufficient) - - // Check for new costs field - assert.exists(check.costs) - assert.exists(check.costs.perEpoch) - assert.exists(check.costs.perDay) - assert.exists(check.costs.perMonth) - assert.isAbove(Number(check.costs.perEpoch), 0) - assert.isAbove(Number(check.costs.perDay), 0) - assert.isAbove(Number(check.costs.perMonth), 0) - - // Check for depositAmountNeeded field - assert.exists(check.lockupAllowanceNeeded) - assert.isTrue(check.lockupAllowanceNeeded > 0n) - - // With no current allowances, should not be sufficient - assert.isFalse(check.sufficient) - }) - - it('should return sufficient when allowances are adequate', async () => { - server.use( - JSONRPC({ - ...presets.basic, - payments: { - ...presets.basic.payments, - operatorApprovals: () => [true, parseUnits('100', 18), parseUnits('10000', 18), 0n, 0n, 0n], - }, - }) - ) - const warmStorageService = await createWarmStorageService() - - const check = await warmStorageService.checkAllowanceForStorage( - Number(SIZE_CONSTANTS.MiB), // 1 MiB - small amount - false, - paymentsService - ) - - assert.isTrue(check.sufficient) - - // Verify costs are included - assert.exists(check.costs) - assert.exists(check.costs.perEpoch) - assert.exists(check.costs.perDay) - assert.exists(check.costs.perMonth) - - // When sufficient, no additional allowance is needed - assert.exists(check.lockupAllowanceNeeded) - assert.equal(check.lockupAllowanceNeeded, 0n) - }) - - it('should include depositAmountNeeded in response', async () => { - server.use( - JSONRPC({ - ...presets.basic, - }) - ) - const warmStorageService = await createWarmStorageService() - - const check = await warmStorageService.checkAllowanceForStorage( - Number(SIZE_CONSTANTS.GiB), // 1 GiB - false, - paymentsService - ) - - // Verify lockupAllowanceNeeded and depositAmountNeeded are present and reasonable - assert.exists(check.lockupAllowanceNeeded) - assert.isTrue(check.lockupAllowanceNeeded > 0n) - assert.exists(check.depositAmountNeeded) - assert.isTrue(check.depositAmountNeeded > 0n) - - // depositAmountNeeded should equal 30 days of costs (default lockup) - const expectedDeposit = - check.costs.perEpoch * TIME_CONSTANTS.DEFAULT_LOCKUP_DAYS * TIME_CONSTANTS.EPOCHS_PER_DAY - assert.equal(check.depositAmountNeeded.toString(), expectedDeposit.toString()) - }) - - it('should use custom lockup days when provided', async () => { - server.use( - JSONRPC({ - ...presets.basic, - }) - ) - const warmStorageService = await createWarmStorageService() - - // Test with custom lockup period of 60 days - const customLockupDays = TIME_CONSTANTS.DEFAULT_LOCKUP_DAYS * 2n - const check = await warmStorageService.checkAllowanceForStorage( - Number(SIZE_CONSTANTS.GiB), // 1 GiB - false, - paymentsService, - Number(customLockupDays) - ) - - // Verify depositAmountNeeded uses custom lockup period - const expectedDeposit = check.costs.perEpoch * customLockupDays * TIME_CONSTANTS.EPOCHS_PER_DAY - assert.equal(check.depositAmountNeeded.toString(), expectedDeposit.toString()) - - // Compare with default (30 days) to ensure they're different - const defaultCheck = await warmStorageService.checkAllowanceForStorage( - Number(SIZE_CONSTANTS.GiB), // 1 GiB - false, - paymentsService - ) - - // Custom should be exactly 2x default (60 days vs 30 days) - assert.equal(check.depositAmountNeeded.toString(), (defaultCheck.depositAmountNeeded * 2n).toString()) - }) - }) - describe('prepareStorageUpload', () => { it('should prepare storage upload with required actions', async () => { server.use( @@ -892,6 +679,7 @@ describe('WarmStorageService', () => { lockupAllowance: 0n, rateUsed: 0n, lockupUsed: 0n, + maxLockupPeriod: BigInt(TIME_CONSTANTS.DEFAULT_LOCKUP_DAYS * TIME_CONSTANTS.EPOCHS_PER_DAY), }), accountInfo: async () => ({ funds: parseUnits('10000', 18), @@ -900,6 +688,37 @@ describe('WarmStorageService', () => { lockupLastSettledAt: 1000000, availableFunds: parseUnits('10000', 18), }), + checkServiceReadiness: async (_service: string, requirements: any) => ({ + sufficient: false, + checks: { + isOperatorApproved: false, + hasSufficientFunds: true, + hasRateAllowance: false, + hasLockupAllowance: false, + hasValidLockupPeriod: true, + }, + currentState: { + approval: { + isApproved: false, + rateAllowance: 0n, + rateUsed: 0n, + lockupAllowance: 0n, + lockupUsed: 0n, + maxLockupPeriod: BigInt(TIME_CONSTANTS.DEFAULT_LOCKUP_DAYS * TIME_CONSTANTS.EPOCHS_PER_DAY), + }, + accountInfo: { + funds: ethers.parseUnits('10000', 18), + lockupCurrent: 0n, + lockupRate: 0n, + lockupLastSettledAt: 1000000, + availableFunds: ethers.parseUnits('10000', 18), + }, + }, + gaps: { + rateAllowanceNeeded: requirements.rateNeeded, + lockupAllowanceNeeded: requirements.lockupNeeded, + }, + }), approveService: async (serviceAddress: string, rateAllowance: bigint, lockupAllowance: bigint) => { assert.strictEqual(serviceAddress, ADDRESSES.calibration.warmStorage) assert.isTrue(rateAllowance > 0n) @@ -910,17 +729,12 @@ describe('WarmStorageService', () => { } const prep = await warmStorageService.prepareStorageUpload( - { - dataSize: Number(10n * SIZE_CONSTANTS.GiB), // 10 GiB - withCDN: false, - }, + Number(10n * SIZE_CONSTANTS.GiB), // 10 GiB mockPaymentsService ) - assert.exists(prep.estimatedCost) - assert.exists(prep.estimatedCost.perEpoch) - assert.exists(prep.estimatedCost.perDay) - assert.exists(prep.estimatedCost.perMonth) + assert.exists(prep.estimatedCostPerMonth) + assert.isTrue(prep.estimatedCostPerMonth > 0n) assert.exists(prep.allowanceCheck) assert.isArray(prep.actions) @@ -954,6 +768,7 @@ describe('WarmStorageService', () => { lockupAllowance: 0n, rateUsed: 0n, lockupUsed: 0n, + maxLockupPeriod: BigInt(TIME_CONSTANTS.DEFAULT_LOCKUP_DAYS * TIME_CONSTANTS.EPOCHS_PER_DAY), }), accountInfo: async () => ({ funds: parseUnits('0.001', 18), // Very low balance @@ -962,6 +777,38 @@ describe('WarmStorageService', () => { lockupLastSettledAt: 1000000, availableFunds: parseUnits('0.001', 18), }), + checkServiceReadiness: async (_service: string, requirements: any) => ({ + sufficient: false, + checks: { + isOperatorApproved: false, + hasSufficientFunds: false, + hasRateAllowance: false, + hasLockupAllowance: false, + hasValidLockupPeriod: true, + }, + currentState: { + approval: { + isApproved: false, + rateAllowance: 0n, + rateUsed: 0n, + lockupAllowance: 0n, + lockupUsed: 0n, + maxLockupPeriod: BigInt(TIME_CONSTANTS.DEFAULT_LOCKUP_DAYS * TIME_CONSTANTS.EPOCHS_PER_DAY), + }, + accountInfo: { + funds: ethers.parseUnits('0.001', 18), + lockupCurrent: 0n, + lockupRate: 0n, + lockupLastSettledAt: 1000000, + availableFunds: ethers.parseUnits('0.001', 18), + }, + }, + gaps: { + fundsNeeded: requirements.lockupNeeded - ethers.parseUnits('0.001', 18), + rateAllowanceNeeded: requirements.rateNeeded, + lockupAllowanceNeeded: requirements.lockupNeeded, + }, + }), deposit: async (amount: bigint) => { assert.isTrue(amount > 0n) depositCalled = true @@ -971,10 +818,7 @@ describe('WarmStorageService', () => { } const prep = await warmStorageService.prepareStorageUpload( - { - dataSize: Number(10n * SIZE_CONSTANTS.GiB), // 10 GiB - withCDN: false, - }, + Number(10n * SIZE_CONSTANTS.GiB), // 10 GiB mockPaymentsService ) @@ -1009,6 +853,7 @@ describe('WarmStorageService', () => { lockupAllowance: parseUnits('100000', 18), rateUsed: 0n, lockupUsed: 0n, + maxLockupPeriod: BigInt(TIME_CONSTANTS.DEFAULT_LOCKUP_DAYS * TIME_CONSTANTS.EPOCHS_PER_DAY), }), accountInfo: async () => ({ funds: parseUnits('10000', 18), @@ -1017,13 +862,38 @@ describe('WarmStorageService', () => { lockupLastSettledAt: 1000000, availableFunds: parseUnits('10000', 18), }), + checkServiceReadiness: async (_service: string, _requirements: any) => ({ + sufficient: true, + checks: { + isOperatorApproved: true, + hasSufficientFunds: true, + hasRateAllowance: true, + hasLockupAllowance: true, + hasValidLockupPeriod: true, + }, + currentState: { + approval: { + isApproved: true, + rateAllowance: ethers.parseUnits('1000', 18), + rateUsed: 0n, + lockupAllowance: ethers.parseUnits('100000', 18), + lockupUsed: 0n, + maxLockupPeriod: BigInt(TIME_CONSTANTS.DEFAULT_LOCKUP_DAYS * TIME_CONSTANTS.EPOCHS_PER_DAY), + }, + accountInfo: { + funds: ethers.parseUnits('10000', 18), + lockupCurrent: 0n, + lockupRate: 0n, + lockupLastSettledAt: 1000000, + availableFunds: ethers.parseUnits('10000', 18), + }, + }, + gaps: undefined, + }), } const prep = await warmStorageService.prepareStorageUpload( - { - dataSize: Number(SIZE_CONSTANTS.MiB), // 1 MiB - small amount - withCDN: false, - }, + Number(SIZE_CONSTANTS.MiB), // 1 MiB - small amount mockPaymentsService ) diff --git a/packages/synapse-sdk/src/types.ts b/packages/synapse-sdk/src/types.ts index d037606a..db1bbb89 100644 --- a/packages/synapse-sdk/src/types.ts +++ b/packages/synapse-sdk/src/types.ts @@ -364,20 +364,80 @@ export interface StorageServiceOptions { metadata?: Record } +/** + * Service readiness check result from PaymentsService + * Contains detailed information about whether a service can be used + */ +export interface ServiceReadinessCheck { + /** Overall readiness: all checks passed */ + sufficient: boolean + /** Individual check results */ + checks: { + /** Service operator is approved by payer */ + isOperatorApproved: boolean + /** Payer has sufficient available funds for lockup */ + hasSufficientFunds: boolean + /** Rate allowance is sufficient for required rate */ + hasRateAllowance: boolean + /** Lockup allowance is sufficient for required lockup */ + hasLockupAllowance: boolean + /** Maximum lockup period is sufficient */ + hasValidLockupPeriod: boolean + } + /** Current state of approval and account */ + currentState: { + /** Service approval details */ + approval: { + isApproved: boolean + rateAllowance: bigint + rateUsed: bigint + lockupAllowance: bigint + lockupUsed: bigint + maxLockupPeriod: bigint + } + /** Account information */ + accountInfo: { + funds: bigint + lockupCurrent: bigint + lockupRate: bigint + lockupLastSettledAt: bigint + availableFunds: bigint + } + } + /** Gaps to fill (only present when not sufficient) */ + gaps?: { + fundsNeeded?: bigint + rateAllowanceNeeded?: bigint + lockupAllowanceNeeded?: bigint + lockupPeriodNeeded?: bigint + } +} + /** * Preflight information for storage uploads */ export interface PreflightInfo { - /** Estimated storage costs */ - estimatedCost: { - perEpoch: bigint - perDay: bigint - perMonth: bigint - } + /** Estimated storage cost per month */ + estimatedCostPerMonth: bigint /** Allowance check results */ allowanceCheck: { + /** Overall check: all requirements met */ sufficient: boolean + /** Detailed human-readable message when insufficient */ message?: string + /** Individual check results for programmatic handling */ + checks: { + /** Payer has sufficient available funds for lockup */ + hasSufficientFunds: boolean + /** Service operator is approved by payer */ + isOperatorApproved: boolean + /** Rate allowance is sufficient for required rate */ + hasRateAllowance: boolean + /** Lockup allowance is sufficient for required lockup */ + hasLockupAllowance: boolean + /** Maximum lockup period is sufficient */ + hasValidLockupPeriod: boolean + } } /** Selected service provider (null when no specific provider selected) */ selectedProvider: ProviderInfo | null @@ -437,32 +497,27 @@ export interface UploadResult { } /** - * Comprehensive storage service information + * Service information including pricing, providers, and configuration + * + * Pricing model: + * - Base storage is charged per TiB per month (30-day month, 2880 epochs per day) + * - CDN egress charges only apply to data sets with CDN enabled + * - A minimum monthly charge applies regardless of data size */ -export interface StorageInfo { - /** Pricing information for storage services */ +export interface ServiceInfo { + /** Pricing information from the service contract */ pricing: { - /** Pricing without CDN */ - noCDN: { - /** Cost per TiB per month in token units */ - perTiBPerMonth: bigint - /** Cost per TiB per day in token units */ - perTiBPerDay: bigint - /** Cost per TiB per epoch in token units */ - perTiBPerEpoch: bigint - } - /** Pricing with CDN enabled */ - withCDN: { - /** Cost per TiB per month in token units */ - perTiBPerMonth: bigint - /** Cost per TiB per day in token units */ - perTiBPerDay: bigint - /** Cost per TiB per epoch in token units */ - perTiBPerEpoch: bigint - } - /** Token contract address */ + /** Base storage cost per TiB per month */ + storagePricePerTiBPerMonth: bigint + /** Minimum monthly charge for data sets regardless of size */ + minimumPricePerMonth: bigint + /** CDN egress cost per TiB (usage-based, only applies to data sets with CDN enabled) */ + cdnEgressPricePerTiB: bigint + /** Cache miss egress cost per TiB (usage-based, only applies to data sets with CDN enabled) */ + cacheMissEgressPricePerTiB: bigint + /** Token contract address for payments */ tokenAddress: string - /** Token symbol (always USDFC for now) */ + /** Token symbol */ tokenSymbol: string } @@ -473,11 +528,11 @@ export interface StorageInfo { serviceParameters: { /** Network type (mainnet or calibration) */ network: FilecoinNetworkType - /** Number of epochs in a month */ + /** Number of epochs per month */ epochsPerMonth: bigint - /** Number of epochs in a day */ + /** Number of epochs per day */ epochsPerDay: bigint - /** Duration of each epoch in seconds */ + /** Duration of each epoch in seconds (30) */ epochDuration: number /** Minimum allowed upload size in bytes */ minUploadSize: number @@ -489,6 +544,10 @@ export interface StorageInfo { paymentsAddress: string /** PDP Verifier contract address */ pdpVerifierAddress: string + /** Service Provider Registry contract address */ + serviceProviderRegistryAddress: string + /** Session Key Registry contract address */ + sessionKeyRegistryAddress: string } /** Current user allowances (null if wallet not connected) */ diff --git a/packages/synapse-sdk/src/warm-storage/service.ts b/packages/synapse-sdk/src/warm-storage/service.ts index 2885da7b..9c2578e4 100644 --- a/packages/synapse-sdk/src/warm-storage/service.ts +++ b/packages/synapse-sdk/src/warm-storage/service.ts @@ -227,6 +227,13 @@ export class WarmStorageService { return this._addresses.sessionKeyRegistry } + /** + * Get the Warm Storage contract address + */ + getContractAddress(): string { + return this._warmStorageAddress + } + /** * Get the provider instance * @returns The ethers provider @@ -720,124 +727,28 @@ export class WarmStorageService { } /** - * Calculate storage costs for a given size + * Calculate the monthly storage cost for a given size + * Includes both base pricing and floor-adjusted pricing * @param sizeInBytes - Size of data to store in bytes - * @returns Cost estimates per epoch, day, and month - * @remarks CDN costs are usage-based (egress pricing), so withCDN field reflects base storage cost only + * @returns Monthly costs (base calculation and floor-adjusted) */ - async calculateStorageCost(sizeInBytes: number): Promise<{ - perEpoch: bigint - perDay: bigint + async calculateUploadCost(sizeInBytes: number): Promise<{ perMonth: bigint - withCDN: { - perEpoch: bigint - perDay: bigint - perMonth: bigint - } + withFloorPerMonth: bigint }> { - const servicePriceInfo = await this.getServicePrice() + const pricing = await this.getServicePrice() - // Calculate price per byte per epoch (base storage cost) - const sizeInBytesBigint = BigInt(sizeInBytes) + // Calculate base cost from size and pricing const pricePerEpoch = - (servicePriceInfo.pricePerTiBPerMonthNoCDN * sizeInBytesBigint) / - (SIZE_CONSTANTS.TiB * servicePriceInfo.epochsPerMonth) - - const costs = { - perEpoch: pricePerEpoch, - perDay: pricePerEpoch * BigInt(TIME_CONSTANTS.EPOCHS_PER_DAY), - perMonth: pricePerEpoch * servicePriceInfo.epochsPerMonth, - } - - // CDN costs are usage-based (egress pricing), so withCDN returns base storage cost - // Actual CDN costs will be charged based on egress usage - return { - ...costs, - withCDN: costs, - } - } - - /** - * Check if user has sufficient allowances for a storage operation and calculate costs - * @param sizeInBytes - Size of data to store - * @param withCDN - Whether CDN is enabled - * @param paymentsService - PaymentsService instance to check allowances - * @param lockupDays - Number of days for lockup period (defaults to 10) - * @returns Allowance requirement details and storage costs - */ - async checkAllowanceForStorage( - sizeInBytes: number, - withCDN: boolean, - paymentsService: PaymentsService, - lockupDays?: number - ): Promise<{ - rateAllowanceNeeded: bigint - lockupAllowanceNeeded: bigint - currentRateAllowance: bigint - currentLockupAllowance: bigint - currentRateUsed: bigint - currentLockupUsed: bigint - sufficient: boolean - message?: string - costs: { - perEpoch: bigint - perDay: bigint - perMonth: bigint - } - depositAmountNeeded: bigint - }> { - // Get current allowances and calculate costs in parallel - const [approval, costs] = await Promise.all([ - paymentsService.serviceApproval(this._warmStorageAddress, TOKENS.USDFC), - this.calculateStorageCost(sizeInBytes), - ]) + (pricing.pricePerTiBPerMonthNoCDN * BigInt(sizeInBytes)) / (SIZE_CONSTANTS.TiB * pricing.epochsPerMonth) + const perMonth = pricePerEpoch * pricing.epochsPerMonth - const selectedCosts = withCDN ? costs.withCDN : costs - const rateNeeded = selectedCosts.perEpoch - - // Calculate lockup period based on provided days (default: 10) - const lockupPeriod = - BigInt(lockupDays ?? Number(TIME_CONSTANTS.DEFAULT_LOCKUP_DAYS)) * BigInt(TIME_CONSTANTS.EPOCHS_PER_DAY) - const lockupNeeded = rateNeeded * lockupPeriod - - // Calculate required allowances (current usage + new requirement) - const totalRateNeeded = BigInt(approval.rateUsed) + rateNeeded - const totalLockupNeeded = BigInt(approval.lockupUsed) + lockupNeeded - - // Check if allowances are sufficient - const sufficient = approval.rateAllowance >= totalRateNeeded && approval.lockupAllowance >= totalLockupNeeded - - // Calculate how much more is needed - const rateAllowanceNeeded = totalRateNeeded > approval.rateAllowance ? totalRateNeeded - approval.rateAllowance : 0n - - const lockupAllowanceNeeded = - totalLockupNeeded > approval.lockupAllowance ? totalLockupNeeded - approval.lockupAllowance : 0n - - // Build optional message - let message: string | undefined - if (!sufficient) { - const needsRate = rateAllowanceNeeded > 0n - const needsLockup = lockupAllowanceNeeded > 0n - if (needsRate && needsLockup) { - message = 'Insufficient rate and lockup allowances' - } else if (needsRate) { - message = 'Insufficient rate allowance' - } else if (needsLockup) { - message = 'Insufficient lockup allowance' - } - } + // Apply floor pricing: max(minimum, actual) + const withFloorPerMonth = perMonth > pricing.minimumPricePerMonth ? perMonth : pricing.minimumPricePerMonth return { - rateAllowanceNeeded, - lockupAllowanceNeeded, - currentRateAllowance: approval.rateAllowance, - currentLockupAllowance: approval.lockupAllowance, - currentRateUsed: approval.rateUsed, - currentLockupUsed: approval.lockupUsed, - sufficient, - message, - costs: selectedCosts, - depositAmountNeeded: lockupNeeded, + perMonth, + withFloorPerMonth, } } @@ -848,20 +759,18 @@ export class WarmStorageService { * including verifying sufficient funds and service allowances. It returns a list of * actions that need to be executed before the upload can proceed. * - * @param options - Configuration options for the storage upload - * @param options.dataSize - Size of data to store in bytes - * @param options.withCDN - Whether to enable CDN for faster retrieval (optional, defaults to false) + * @param dataSize - Size of data to store in bytes * @param paymentsService - Instance of PaymentsService for handling payment operations * * @returns Object containing: - * - estimatedCost: Breakdown of storage costs (per epoch, day, and month) + * - estimatedCostPerMonth: Monthly storage cost with floor pricing applied * - allowanceCheck: Status of service allowances with optional message * - actions: Array of required actions (deposit, approveService) that need to be executed * * @example * ```typescript * const prep = await warmStorageService.prepareStorageUpload( - * { dataSize: Number(SIZE_CONSTANTS.GiB), withCDN: true }, + * Number(SIZE_CONSTANTS.GiB), * paymentsService * ) * @@ -874,17 +783,10 @@ export class WarmStorageService { * ``` */ async prepareStorageUpload( - options: { - dataSize: number - withCDN?: boolean - }, + dataSize: number, paymentsService: PaymentsService ): Promise<{ - estimatedCost: { - perEpoch: bigint - perDay: bigint - perMonth: bigint - } + estimatedCostPerMonth: bigint allowanceCheck: { sufficient: boolean message?: string @@ -895,14 +797,25 @@ export class WarmStorageService { execute: () => Promise }> }> { - // Parallelize cost calculation and allowance check - const [costs, allowanceCheck] = await Promise.all([ - this.calculateStorageCost(options.dataSize), - this.checkAllowanceForStorage(options.dataSize, options.withCDN ?? false, paymentsService), - ]) - - // Select the appropriate costs based on CDN option - const selectedCosts = (options.withCDN ?? false) ? costs.withCDN : costs + // Calculate upload cost + const cost = await this.calculateUploadCost(dataSize) + + // Calculate requirements + const pricing = await this.getServicePrice() + const ratePerEpoch = cost.withFloorPerMonth / pricing.epochsPerMonth + const lockupEpochs = BigInt(TIME_CONSTANTS.DEFAULT_LOCKUP_DAYS * TIME_CONSTANTS.EPOCHS_PER_DAY) + const lockupNeeded = ratePerEpoch * lockupEpochs + + // Check service readiness + const readiness = await paymentsService.checkServiceReadiness( + this._warmStorageAddress, + { + rateNeeded: ratePerEpoch, + lockupNeeded, + lockupPeriodNeeded: lockupEpochs, + }, + TOKENS.USDFC + ) const actions: Array<{ type: 'deposit' | 'approve' | 'approveService' @@ -911,45 +824,42 @@ export class WarmStorageService { }> = [] // Check if deposit is needed - const accountInfo = await paymentsService.accountInfo(TOKENS.USDFC) - const requiredBalance = selectedCosts.perMonth // Require at least 1 month of funds - - if (accountInfo.availableFunds < requiredBalance) { - const depositAmount = requiredBalance - accountInfo.availableFunds + if (readiness.gaps?.fundsNeeded) { + const fundsNeeded = readiness.gaps.fundsNeeded actions.push({ type: 'deposit', - description: `Deposit ${depositAmount} USDFC to payments contract`, - execute: async () => await paymentsService.deposit(depositAmount, TOKENS.USDFC), + description: `Deposit ${fundsNeeded} USDFC to payments contract`, + execute: async () => await paymentsService.deposit(fundsNeeded, TOKENS.USDFC), }) } // Check if service approval is needed - if (!allowanceCheck.sufficient) { + if ( + !readiness.checks.isOperatorApproved || + readiness.gaps?.rateAllowanceNeeded || + readiness.gaps?.lockupAllowanceNeeded + ) { + const rateAllowanceNeeded = readiness.gaps?.rateAllowanceNeeded ?? 0n + const lockupAllowanceNeeded = readiness.gaps?.lockupAllowanceNeeded ?? 0n actions.push({ type: 'approveService', - description: `Approve service with rate allowance ${allowanceCheck.rateAllowanceNeeded} and lockup allowance ${allowanceCheck.lockupAllowanceNeeded}`, + description: `Approve service with rate allowance ${rateAllowanceNeeded} and lockup allowance ${lockupAllowanceNeeded}`, execute: async () => await paymentsService.approveService( this._warmStorageAddress, - allowanceCheck.rateAllowanceNeeded, - allowanceCheck.lockupAllowanceNeeded, - TIME_CONSTANTS.EPOCHS_PER_MONTH, // 30 days max lockup period + rateAllowanceNeeded, + lockupAllowanceNeeded, + lockupEpochs, TOKENS.USDFC ), }) } return { - estimatedCost: { - perEpoch: selectedCosts.perEpoch, - perDay: selectedCosts.perDay, - perMonth: selectedCosts.perMonth, - }, + estimatedCostPerMonth: cost.withFloorPerMonth, allowanceCheck: { - sufficient: allowanceCheck.sufficient, - message: allowanceCheck.sufficient - ? undefined - : `Insufficient allowances: rate needed ${allowanceCheck.rateAllowanceNeeded}, lockup needed ${allowanceCheck.lockupAllowanceNeeded}`, + sufficient: readiness.sufficient, + message: readiness.sufficient ? undefined : 'Insufficient payment readiness for storage upload', }, actions, } diff --git a/utils/example-storage-info.js b/utils/example-storage-info.js index f266b04b..f9151617 100755 --- a/utils/example-storage-info.js +++ b/utils/example-storage-info.js @@ -70,31 +70,29 @@ async function main() { const address = await signer.getAddress() console.log(`Wallet address: ${address}`) - // Get storage info + // Get service info console.log('\nFetching storage service information...') - const storageInfo = await synapse.storage.getStorageInfo() + const serviceInfo = await synapse.storage.getServiceInfo() // Display pricing information console.log('\n--- Pricing Information ---') - console.log(`Token: USDFC (${storageInfo.pricing.tokenAddress})`) - console.log('\nWithout CDN:') - console.log(` Per TiB per month: ${formatUSDFC(storageInfo.pricing.noCDN.perTiBPerMonth)}`) - console.log(` Per TiB per day: ${formatUSDFC(storageInfo.pricing.noCDN.perTiBPerDay)}`) - console.log(` Per TiB per epoch: ${formatUSDFC(storageInfo.pricing.noCDN.perTiBPerEpoch)}`) + console.log(`Token: ${serviceInfo.pricing.tokenSymbol} (${serviceInfo.pricing.tokenAddress})`) + console.log('\nBase Storage:') + console.log(` Per TiB per month: ${formatUSDFC(serviceInfo.pricing.storagePricePerTiBPerMonth)}`) + console.log(` Minimum price per month: ${formatUSDFC(serviceInfo.pricing.minimumPricePerMonth)}`) - console.log('\nWith CDN:') - console.log(` Per TiB per month: ${formatUSDFC(storageInfo.pricing.withCDN.perTiBPerMonth)}`) - console.log(` Per TiB per day: ${formatUSDFC(storageInfo.pricing.withCDN.perTiBPerDay)}`) - console.log(` Per TiB per epoch: ${formatUSDFC(storageInfo.pricing.withCDN.perTiBPerEpoch)}`) + console.log('\nCDN Usage-Based Pricing (only for data sets with CDN enabled):') + console.log(` CDN egress per TiB: ${formatUSDFC(serviceInfo.pricing.cdnEgressPricePerTiB)}`) + console.log(` Cache miss egress per TiB: ${formatUSDFC(serviceInfo.pricing.cacheMissEgressPricePerTiB)}`) // Display service providers console.log('\n--- Service Providers ---') - if (storageInfo.providers.length === 0) { + if (serviceInfo.providers.length === 0) { console.log('No approved providers found') } else { - console.log(`Total providers: ${storageInfo.providers.length}`) + console.log(`Total providers: ${serviceInfo.providers.length}`) - for (const [_index, provider] of storageInfo.providers.entries()) { + for (const [_index, provider] of serviceInfo.providers.entries()) { console.log(`\nProvider #${provider.id}:`) console.log(` Name: ${provider.name}`) console.log(` Description: ${provider.description}`) @@ -118,37 +116,123 @@ async function main() { // Display service parameters console.log('\n--- Service Parameters ---') - console.log(`Network: ${storageInfo.serviceParameters.network}`) - console.log(`Epochs per month: ${storageInfo.serviceParameters.epochsPerMonth.toLocaleString()}`) - console.log(`Epochs per day: ${storageInfo.serviceParameters.epochsPerDay.toLocaleString()}`) - console.log(`Epoch duration: ${storageInfo.serviceParameters.epochDuration} seconds`) - console.log(`Min upload size: ${formatBytes(storageInfo.serviceParameters.minUploadSize)}`) - console.log(`Max upload size: ${formatBytes(storageInfo.serviceParameters.maxUploadSize)}`) + console.log(`Network: ${serviceInfo.serviceParameters.network}`) + console.log(`Epochs per month: ${serviceInfo.serviceParameters.epochsPerMonth.toLocaleString()}`) + console.log(`Epochs per day: ${serviceInfo.serviceParameters.epochsPerDay.toLocaleString()}`) + console.log(`Epoch duration: ${serviceInfo.serviceParameters.epochDuration} seconds`) + console.log(`Min upload size: ${formatBytes(serviceInfo.serviceParameters.minUploadSize)}`) + console.log(`Max upload size: ${formatBytes(serviceInfo.serviceParameters.maxUploadSize)}`) console.log('\nContract Addresses:') - console.log(` Warm Storage: ${storageInfo.serviceParameters.warmStorageAddress}`) - console.log(` Payments: ${storageInfo.serviceParameters.paymentsAddress}`) - console.log(` PDP Verifier: ${storageInfo.serviceParameters.pdpVerifierAddress}`) + console.log(` Warm Storage: ${serviceInfo.serviceParameters.warmStorageAddress}`) + console.log(` Payments: ${serviceInfo.serviceParameters.paymentsAddress}`) + console.log(` PDP Verifier: ${serviceInfo.serviceParameters.pdpVerifierAddress}`) + console.log(` Service Provider Registry: ${serviceInfo.serviceParameters.serviceProviderRegistryAddress}`) + console.log(` Session Key Registry: ${serviceInfo.serviceParameters.sessionKeyRegistryAddress}`) // Display current allowances console.log('\n--- Current Allowances ---') - if (storageInfo.allowances) { - console.log(`Service: ${storageInfo.allowances.service}`) + if (serviceInfo.allowances) { + console.log(`Service: ${serviceInfo.allowances.service}`) console.log('\nRate:') - console.log(` Allowance: ${formatUSDFC(storageInfo.allowances.rateAllowance)}`) - console.log(` Used: ${formatUSDFC(storageInfo.allowances.rateUsed)}`) + console.log(` Allowance: ${formatUSDFC(serviceInfo.allowances.rateAllowance)}`) + console.log(` Used: ${formatUSDFC(serviceInfo.allowances.rateUsed)}`) console.log( - ` Available: ${formatUSDFC(storageInfo.allowances.rateAllowance - storageInfo.allowances.rateUsed)}` + ` Available: ${formatUSDFC(serviceInfo.allowances.rateAllowance - serviceInfo.allowances.rateUsed)}` ) console.log('\nLockup:') - console.log(` Allowance: ${formatUSDFC(storageInfo.allowances.lockupAllowance)}`) - console.log(` Used: ${formatUSDFC(storageInfo.allowances.lockupUsed)}`) + console.log(` Allowance: ${formatUSDFC(serviceInfo.allowances.lockupAllowance)}`) + console.log(` Used: ${formatUSDFC(serviceInfo.allowances.lockupUsed)}`) console.log( - ` Available: ${formatUSDFC(storageInfo.allowances.lockupAllowance - storageInfo.allowances.lockupUsed)}` + ` Available: ${formatUSDFC(serviceInfo.allowances.lockupAllowance - serviceInfo.allowances.lockupUsed)}` ) } else { console.log('No allowances found (wallet may not be connected or no approvals set)') } + // Check account balance + console.log('\n--- Account Balance ---') + try { + const accountInfo = await synapse.payments.accountInfo() + console.log(`Total funds: ${formatUSDFC(accountInfo.funds)}`) + console.log(`Available funds: ${formatUSDFC(accountInfo.availableFunds)}`) + console.log(`Locked up: ${formatUSDFC(accountInfo.funds - accountInfo.availableFunds)}`) + } catch (error) { + console.log('Could not fetch account balance:', error.message) + } + + // Show upload cost examples + console.log('\n--- Upload Cost Examples ---') + try { + const provider = synapse.getProvider() + const warmStorageAddress = synapse.getWarmStorageAddress() + const warmStorageService = await WarmStorageService.create(provider, warmStorageAddress) + + const sizes = [ + { name: '1 MiB', bytes: 1024 * 1024 }, + { name: '100 MiB', bytes: 100 * 1024 * 1024 }, + { name: '1 GiB', bytes: 1024 * 1024 * 1024 }, + { name: '10 GiB', bytes: 10 * 1024 * 1024 * 1024 }, + ] + + for (const size of sizes) { + const cost = await warmStorageService.calculateUploadCost(size.bytes) + console.log(`\n${size.name}:`) + console.log(` Base monthly cost: ${formatUSDFC(cost.perMonth)}`) + console.log(` With floor pricing: ${formatUSDFC(cost.withFloorPerMonth)}`) + } + } catch (error) { + console.log('Could not calculate upload costs:', error.message) + } + + // Check wallet readiness for uploads + console.log('\n--- Wallet Readiness Check ---') + try { + const provider = synapse.getProvider() + const warmStorageAddress = synapse.getWarmStorageAddress() + const warmStorageService = await WarmStorageService.create(provider, warmStorageAddress) + + // Check readiness for 1 GiB upload + const testSize = 1024 * 1024 * 1024 // 1 GiB + const cost = await warmStorageService.calculateUploadCost(testSize) + const pricing = await warmStorageService.getServicePrice() + const ratePerEpoch = cost.withFloorPerMonth / pricing.epochsPerMonth + const lockupEpochs = 30n * 2880n // 30 days in epochs + const lockupNeeded = ratePerEpoch * lockupEpochs + + const readiness = await synapse.payments.checkServiceReadiness(warmStorageAddress, { + rateNeeded: ratePerEpoch, + lockupNeeded, + lockupPeriodNeeded: lockupEpochs, + }) + + console.log(`Checking readiness for 1 GiB upload (${formatUSDFC(cost.withFloorPerMonth)}/month)...`) + console.log(`\nReadiness: ${readiness.sufficient ? '✅ READY' : '❌ NOT READY'}`) + console.log('\nChecks:') + console.log(` ✓ Operator approved: ${readiness.checks.isOperatorApproved ? '✅' : '❌'}`) + console.log(` ✓ Sufficient funds: ${readiness.checks.hasSufficientFunds ? '✅' : '❌'}`) + console.log(` ✓ Rate allowance: ${readiness.checks.hasRateAllowance ? '✅' : '❌'}`) + console.log(` ✓ Lockup allowance: ${readiness.checks.hasLockupAllowance ? '✅' : '❌'}`) + console.log(` ✓ Valid lockup period: ${readiness.checks.hasValidLockupPeriod ? '✅' : '❌'}`) + + if (!readiness.sufficient && readiness.gaps) { + console.log('\nRequired actions:') + if (readiness.gaps.fundsNeeded) { + console.log(` - Deposit ${formatUSDFC(readiness.gaps.fundsNeeded)}`) + } + if (readiness.gaps.rateAllowanceNeeded) { + console.log(` - Increase rate allowance by ${formatUSDFC(readiness.gaps.rateAllowanceNeeded)}`) + } + if (readiness.gaps.lockupAllowanceNeeded) { + console.log(` - Increase lockup allowance by ${formatUSDFC(readiness.gaps.lockupAllowanceNeeded)}`) + } + if (readiness.gaps.lockupPeriodNeeded) { + console.log(` - Extend lockup period by ${readiness.gaps.lockupPeriodNeeded} epochs`) + } + } + } catch (error) { + console.log('Could not check wallet readiness:', error.message) + } + // Get client's data sets console.log('\n--- Your Data Sets ---') try {