Skip to content
Draft
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
8 changes: 5 additions & 3 deletions docs/src/content/docs/guides/storage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 5 additions & 3 deletions docs/src/content/docs/intro/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
65 changes: 64 additions & 1 deletion packages/synapse-sdk/src/payments/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

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

do we need this as an option ?

isnt it a constant BigInt(TIME_CONSTANTS.DEFAULT_LOCKUP_DAYS * TIME_CONSTANTS.EPOCHS_PER_DAY) ?

},
token: TokenIdentifier = TOKENS.USDFC
): Promise<ServiceReadinessCheck> {
// 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,
Expand Down
69 changes: 53 additions & 16 deletions packages/synapse-sdk/src/storage/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<PreflightInfo> {
// 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
Comment on lines +808 to +813
Copy link
Member

Choose a reason for hiding this comment

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

really dont like this per month thing its inaccurate and forces us to do this back and forth stuff epoch<>month exchange.

also we just called getServicePrice in calculateUploadCost and we call it again just to get

https://github.com/FilOzone/filecoin-services/blob/980e25aeb920bb894eab71a3dba9e8c9bccddcae/service_contracts/src/FilecoinWarmStorageService.sol#L200

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

point taken on the double call to getServicePrice, could just do that once.

really dont like this per month thing

but the problem is twofold:

  1. the contract defines pricing per month
  2. downscaling to per-epoch loses a lot of precision (a surprising amount since we're dealing with fairly low numbers in the first place)

the logic here is intentionally keeping things at per-month for as long as possible to avoid the early precision loss of going per-epoch and then back again.

Copy link
Member

Choose a reason for hiding this comment

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

ok makes sense the per month thing, but isnt the precision off already like, which month? the amount of 30s in a month varies a lot in different months ?


// 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('; ')
}
Comment on lines +826 to +846
Copy link
Member

Choose a reason for hiding this comment

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

imo preflight and readiness can be merged into one function and throw and good error with most of this stuff in it.


return {
estimatedCostPerMonth: cost.withFloorPerMonth,
allowanceCheck: {
sufficient: allowanceCheck.sufficient,
message: allowanceCheck.message,
sufficient: readiness.sufficient,
message,
checks: readiness.checks,
},
selectedProvider: null,
selectedDataSetId: null,
Expand All @@ -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
Expand Down
80 changes: 27 additions & 53 deletions packages/synapse-sdk/src/storage/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ import type {
PieceRetriever,
PreflightInfo,
ProviderInfo,
ServiceInfo,
StorageContextCallbacks,
StorageInfo,
StorageServiceOptions,
UploadCallbacks,
UploadResult,
Expand Down Expand Up @@ -213,22 +213,19 @@ export class StorageManager {
size: number,
options?: { withCDN?: boolean; metadata?: Record<string, string> }
): Promise<PreflightInfo> {
// 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
const value = options.metadata[METADATA_KEYS.WITH_CDN]
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)
}

/**
Expand Down Expand Up @@ -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<StorageInfo> {
async getServiceInfo(): Promise<ServiceInfo> {
try {
// Helper function to get allowances with error handling
const getOptionalAllowances = async (): Promise<StorageInfo['allowances']> => {
const getOptionalAllowances = async (): Promise<ServiceInfo['allowances']> => {
try {
const warmStorageAddress = this._synapse.getWarmStorageAddress()
const approval = await this._synapse.payments.serviceApproval(warmStorageAddress, TOKENS.USDFC)
Expand All @@ -388,79 +383,58 @@ 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,
maxUploadSize: SIZE_CONSTANTS.MAX_UPLOAD_SIZE,
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<ServiceInfo> {
return await this.getServiceInfo()
}
}
12 changes: 0 additions & 12 deletions packages/synapse-sdk/src/synapse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import type {
PieceCID,
PieceRetriever,
ProviderInfo,
StorageInfo,
StorageServiceOptions,
SubgraphConfig,
SynapseOptions,
Expand Down Expand Up @@ -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<StorageInfo> {
console.warn('synapse.getStorageInfo() is deprecated. Use synapse.storage.getStorageInfo() instead.')
return await this._storageManager.getStorageInfo()
}
}
Loading