filecoin-pin
Version:
Bridge IPFS content to Filecoin Onchain Cloud using familiar tools
400 lines (345 loc) • 14.5 kB
text/typescript
/**
* Payment status display command
*
* Shows current payment configuration and balances for Filecoin Onchain Cloud.
* This provides a quick overview of the user's payment setup without making changes.
*/
import { SIZE_CONSTANTS } from '@filoz/synapse-core/utils'
import type { Synapse } from '@filoz/synapse-sdk'
import { TIME_CONSTANTS } from '@filoz/synapse-sdk'
import { ethers } from 'ethers'
import pc from 'picocolors'
import { TELEMETRY_CLI_APP_NAME } from '../common/constants.js'
import { type ActualStorageResult, calculateActualStorage, listDataSets } from '../core/data-set/index.js'
import {
calculateDepositCapacity,
calculateStorageRunway,
checkFILBalance,
checkUSDFCBalance,
FLOOR_PRICE_DAYS,
FLOOR_PRICE_PER_30_DAYS,
getPaymentStatus,
} from '../core/payments/index.js'
import { cleanupSynapseService, initializeSynapse } from '../core/synapse/index.js'
import { formatFIL, formatUSDFC } from '../core/utils/format.js'
import { formatRunwaySummary } from '../core/utils/index.js'
import { type CLIAuthOptions, getCLILogger, parseCLIAuth } from '../utils/cli-auth.js'
import { cancel, createSpinner, formatFileSize, intro, outro } from '../utils/cli-helpers.js'
import { log } from '../utils/cli-logger.js'
import { displayDepositWarning } from './setup.js'
interface StatusOptions extends CLIAuthOptions {
includeRails?: boolean
}
const STORAGE_DISPLAY_PRECISION_DIGITS = 6
const STORAGE_DISPLAY_PRECISION = 10n ** BigInt(STORAGE_DISPLAY_PRECISION_DIGITS)
const { TiB } = SIZE_CONSTANTS
/**
* Convert a payment rate (USDFC per epoch) to storage bytes using the provider's pricing.
*
* This calculates: "How much storage does this payment rate cover?"
*
* Formula: storageBytes = (rate / pricePerTiBPerEpoch) * TiB
*
* NOTE: This calculation assumes a linear relationship between payment rate and
* storage size, which breaks down when floor pricing is applied to small files.
* The result represents "storage equivalent" at the given rate, not actual bytes stored.
* Use calculateActualStorage() from core/data-set for accurate byte counts.
*
* @param ratePerEpoch - Payment rate in USDFC per epoch
* @param pricePerTiBPerEpoch - Provider's price for 1 TiB per epoch in USDFC
* @returns Storage bytes that the rate covers, or null if invalid inputs
*/
function convertRateToStorageBytes(ratePerEpoch: bigint, pricePerTiBPerEpoch: bigint): bigint | null {
if (ratePerEpoch <= 0n || pricePerTiBPerEpoch <= 0n) {
return null
}
// storageTiBScaled preserves fractional precision using STORAGE_DISPLAY_PRECISION scaling
const storageTiBScaled = (ratePerEpoch * STORAGE_DISPLAY_PRECISION) / pricePerTiBPerEpoch
if (storageTiBScaled <= 0n) {
return null
}
// Convert scaled TiB to bytes: TiB * 1024^4 bytes, then unscale
return (storageTiBScaled * TiB) / STORAGE_DISPLAY_PRECISION
}
/**
* Display current payment status
*
* @param options - Options from command line
*/
export async function showPaymentStatus(options: StatusOptions): Promise<void> {
intro(pc.bold('Filecoin Onchain Cloud Payment Status'))
const spinner = createSpinner()
spinner.start('Fetching current configuration...')
try {
// Parse and validate authentication
const authConfig = parseCLIAuth(options)
const logger = getCLILogger()
const synapse = await initializeSynapse(
{ ...authConfig, telemetry: { sentrySetTags: { appName: TELEMETRY_CLI_APP_NAME } } },
logger
)
const network = synapse.getNetwork()
const client = synapse.getClient()
const address = await client.getAddress()
// Check balances and status
const filStatus = await checkFILBalance(synapse)
// Early exit if account has no funds
if (filStatus.balance === 0n) {
spinner.stop('━━━ Current Status ━━━')
log.line(`Address: ${address}`)
log.line(`Network: ${network}`)
log.line('')
log.line(`${pc.red('✗')} Account has no FIL balance`)
log.line('')
log.line(
`Get test FIL from: ${filStatus.isCalibnet ? 'https://faucet.calibnet.chainsafe-fil.io/' : 'Purchase FIL from an exchange'}`
)
log.flush()
cancel('Account not funded')
throw new Error('Account has no FIL balance')
}
const walletUsdfcBalance = await checkUSDFCBalance(synapse)
// Check if we have USDFC tokens before continuing
if (walletUsdfcBalance === 0n) {
spinner.stop('━━━ Current Status ━━━')
log.line(`Address: ${address}`)
log.line(`Network: ${network}`)
log.line('')
log.line(`${pc.red('✗')} No USDFC tokens found`)
log.line('')
const helpMessage = filStatus.isCalibnet
? 'Get test USDFC from: https://docs.secured.finance/usdfc-stablecoin/getting-started/getting-test-usdfc-on-testnet'
: 'Mint USDFC with FIL: https://docs.secured.finance/usdfc-stablecoin/getting-started/minting-usdfc-step-by-step'
log.line(` ${pc.cyan(helpMessage)}`)
log.flush()
cancel('USDFC required to use Filecoin Onchain Cloud')
throw new Error('No USDFC tokens found')
}
const status = await getPaymentStatus(synapse)
// Get storage pricing for capacity calculation and spend summaries
const storageInfo = await synapse.storage.getStorageInfo()
const pricePerTiBPerEpoch = storageInfo.pricing.noCDN.perTiBPerEpoch
let paymentRailsData: PaymentRailsData | null = null
if (options.includeRails === true) {
paymentRailsData = await fetchPaymentRailsData(synapse)
}
spinner.stop(`${pc.green('✓')} Configuration loaded`)
// Display all status information
log.line('━━━ Current Status ━━━')
// Show wallet balances
log.line(pc.bold('Wallet'))
log.indent(`Owner address: ${address}`)
log.indent(`Network: ${network}`)
log.indent(`FIL: ${formatFIL(filStatus.balance, filStatus.isCalibnet)}`)
log.indent(`USDFC: ${formatUSDFC(walletUsdfcBalance)} USDFC`)
log.line('')
// Show deposit and capacity
const lockupUsed = status.currentAllowances.lockupUsed ?? 0n
const rateUsed = status.currentAllowances.rateUsed ?? 0n
const availableDeposit = status.filecoinPayBalance > lockupUsed ? status.filecoinPayBalance - lockupUsed : 0n
const capacity = calculateDepositCapacity(status.filecoinPayBalance, pricePerTiBPerEpoch)
const runway = calculateStorageRunway(status)
const runwayDisplay = formatRunwaySummary(runway)
const dailyCost = runway.perDay
const monthlyCost = dailyCost * TIME_CONSTANTS.DAYS_PER_MONTH
log.line(pc.bold('Filecoin Pay'))
log.indent(`Balance: ${formatUSDFC(status.filecoinPayBalance)} USDFC`)
log.indent(`Locked: ${formatUSDFC(lockupUsed)} USDFC (30-day reserve)`)
log.indent(`Available: ${formatUSDFC(availableDeposit)} USDFC`)
if (rateUsed > 0n) {
log.indent(`Epoch cost: ${formatUSDFC(rateUsed)} USDFC`)
log.indent(`Daily cost: ${formatUSDFC(dailyCost)} USDFC`)
log.indent(`Monthly cost: ${formatUSDFC(monthlyCost)} USDFC`)
} else {
log.indent(`Epoch cost: ${pc.gray('0 USDFC')}`)
log.indent(`Daily cost: ${pc.gray('0 USDFC')}`)
log.indent(`Monthly cost: ${pc.gray('0 USDFC')}`)
}
if (paymentRailsData != null) {
displayPaymentRailsSummary(paymentRailsData, 1)
}
log.line('')
// Show storage usage details
log.line(pc.bold('WarmStorage Usage'))
let actualStorageResult: ActualStorageResult | null = null
try {
spinner.start('Fetching data sets...')
// Get all active data sets for this address
const dataSets = await listDataSets(synapse, {
withProviderDetails: false,
address,
filter: (ds) => ds.isLive, // Only count active/live data sets
logger,
})
spinner.stop(`${pc.green('✓')} Data sets fetched`)
spinner.start('Calculating actual storage from data sets...')
actualStorageResult = await calculateActualStorage(synapse, dataSets, {
logger,
onProgress: (progress) => {
if (progress.type === 'actual-storage:progress') {
spinner.message(
`Calculating actual storage from data sets (${progress.data.dataSetsProcessed}/${progress.data.dataSetCount})`
)
}
},
})
if (actualStorageResult.timedOut) {
spinner.stop(`${pc.yellow('⚠')} Calculation timed out`)
} else if (actualStorageResult.warnings.length > 0) {
spinner.stop(
`${pc.yellow('⚠')} Actual storage calculated with ${actualStorageResult.warnings.length} warning(s)`
)
} else {
spinner.stop(`${pc.green('✓')} Actual storage calculated`)
}
if (actualStorageResult.warnings.length > 0) {
for (const warning of actualStorageResult.warnings) {
log.indent(pc.yellow(`⚠ ${warning.message}`))
}
}
if (actualStorageResult.totalBytes > 0n) {
const formattedSize = formatFileSize(actualStorageResult.totalBytes)
log.indent(`Stored: ${formattedSize}`)
} else {
log.indent(pc.gray('Stored: 0 B'))
}
} catch (error) {
spinner.stop(`${pc.yellow('⚠')} Could not calculate actual storage`)
log.indent(pc.gray(` Error: ${error instanceof Error ? error.message : String(error)}`))
}
if (runway.state === 'active') {
log.indent(`Runway: ~${runwayDisplay}`)
} else {
log.indent(pc.gray(`Runway: ${runwayDisplay}`))
}
const capacityTibPerMonth = ethers.parseUnits(capacity.tibPerMonth.toString(), 18)
const capacityBytes = (capacityTibPerMonth * TiB) / 10n ** 18n
const capacityLine = `Funding could cover ~${formatFileSize(capacityBytes)} for one month`
log.indent(capacityLine)
log.flush()
const billedBytes = convertRateToStorageBytes(rateUsed, pricePerTiBPerEpoch)
if (billedBytes != null) {
// Calculate what storage the floor price represents at current pricing
const epochsInFloorPeriod = BigInt(FLOOR_PRICE_DAYS) * TIME_CONSTANTS.EPOCHS_PER_DAY
const floorRatePerEpoch = FLOOR_PRICE_PER_30_DAYS / epochsInFloorPeriod
const floorEquivalentBytes = convertRateToStorageBytes(floorRatePerEpoch, pricePerTiBPerEpoch)
const floorEquivalentFormatted = floorEquivalentBytes ? formatFileSize(floorEquivalentBytes) : '~24.6 GiB'
const sectionContent = [
pc.gray('Filecoin Onchain Cloud uses floor pricing for DataSets.'),
pc.gray(`Each DataSet is billed a minimum of ${formatUSDFC(FLOOR_PRICE_PER_30_DAYS, 2)} USDFC per 30 days.`),
pc.gray(`This is equivalent to ~${floorEquivalentFormatted} per month.`),
`Billed capacity: ~${formatFileSize(billedBytes)}`,
]
if (actualStorageResult != null && billedBytes > actualStorageResult.totalBytes) {
const additionalStorage = billedBytes - actualStorageResult.totalBytes
sectionContent.push(`Storage remaining: ~${formatFileSize(additionalStorage)}`)
}
log.indent(pc.bold('Storage usage details:'))
for (const content of sectionContent) {
log.indent(content, 2)
}
}
// Show deposit warning if needed
displayDepositWarning(status.filecoinPayBalance, status.currentAllowances.lockupUsed)
log.flush()
// Show success outro
outro('Status check complete')
} catch (error) {
spinner.stop(`${pc.red('✗')} Status check failed`)
log.line('')
log.line(`${pc.red('Error:')} ${error instanceof Error ? error.message : String(error)}`)
log.flush()
cancel('Status check failed')
process.exitCode = 1
} finally {
await cleanupSynapseService()
}
}
interface PaymentRailsData {
activeRails: number
terminatedRails: number
totalActiveRate: bigint
totalPendingSettlements: bigint
railsNeedingSettlement: number
error?: string
}
/**
* Fetch payment rails data without displaying anything
*/
async function fetchPaymentRailsData(synapse: Synapse): Promise<PaymentRailsData> {
try {
// Get rails as payer
const payerRails = await synapse.payments.getRailsAsPayer()
if (payerRails.length === 0) {
return {
activeRails: 0,
terminatedRails: 0,
totalActiveRate: 0n,
totalPendingSettlements: 0n,
railsNeedingSettlement: 0,
}
}
// Analyze rails for summary
let totalPendingSettlements = 0n
let totalActiveRate = 0n
let activeRails = 0
let terminatedRails = 0
let railsNeedingSettlement = 0
for (const rail of payerRails) {
try {
const railDetails = await synapse.payments.getRail(rail.railId)
const settlementPreview = await synapse.payments.getSettlementAmounts(rail.railId)
if (rail.isTerminated) {
terminatedRails++
} else {
activeRails++
totalActiveRate += railDetails.paymentRate
}
// Check for pending settlements
if (settlementPreview.totalSettledAmount > 0n) {
totalPendingSettlements += settlementPreview.totalSettledAmount
railsNeedingSettlement++
}
} catch (error) {
log.warn(`Could not analyze rail ${rail.railId}: ${error instanceof Error ? error.message : String(error)}`)
}
}
return {
activeRails,
terminatedRails,
totalActiveRate,
totalPendingSettlements,
railsNeedingSettlement,
}
} catch {
return {
activeRails: 0,
terminatedRails: 0,
totalActiveRate: 0n,
totalPendingSettlements: 0n,
railsNeedingSettlement: 0,
error: 'Unable to fetch rail information',
}
}
}
/**
* Display payment rails summary
*/
function displayPaymentRailsSummary(data: PaymentRailsData, indentLevel: number = 1): void {
log.indent(pc.bold('Payment Rails'), indentLevel)
if (data.error) {
log.indent(pc.gray(data.error), indentLevel + 1)
return
}
if (data.activeRails === 0 && data.terminatedRails === 0) {
log.indent(pc.gray('No active payment rails'), indentLevel + 1)
return
}
log.indent(`${data.activeRails} active, ${data.terminatedRails} terminated`, indentLevel + 1)
if (data.totalPendingSettlements > 0n) {
log.indent(`Pending settlement: ${formatUSDFC(data.totalPendingSettlements)} USDFC`, indentLevel + 1)
}
if (data.railsNeedingSettlement > 0) {
log.indent(`${data.railsNeedingSettlement} rail(s) need settlement`, indentLevel + 1)
}
}