diff --git a/docs/src/content/docs/cookbooks/payments-and-storage.mdx b/docs/src/content/docs/cookbooks/payments-and-storage.mdx index 16a027825..92338d9a6 100644 --- a/docs/src/content/docs/cookbooks/payments-and-storage.mdx +++ b/docs/src/content/docs/cookbooks/payments-and-storage.mdx @@ -113,7 +113,7 @@ Most developers won't need this section. The SDK abstracts these mechanics: `pre ### Rate Precision -Storage prices are stored per-month on-chain but rails operate per-epoch. Integer division during conversion means `perEpoch × EPOCHS_PER_MONTH` is slightly less than the true monthly rate due to truncation. The SDK returns both values: use `rate.perMonth` for display and `rate.perEpoch` for on-chain math. +Storage prices are stored per-month on-chain but rails operate per-epoch. Integer division during conversion means `perEpoch × EPOCHS_PER_MONTH` is slightly less than the true monthly rate due to truncation. The SDK returns both values: use `rates.perMonth` for display and `rates.perEpoch` for on-chain math. ### Deposit Components diff --git a/docs/src/content/docs/developer-guides/index.md b/docs/src/content/docs/developer-guides/index.md index a7c4096e2..0ae0362d9 100644 --- a/docs/src/content/docs/developer-guides/index.md +++ b/docs/src/content/docs/developer-guides/index.md @@ -68,7 +68,7 @@ sequenceDiagram Note over Client,Payments: Step 2: Payment Setup Client->>SDK: Check allowances - SDK->>WarmStorage: getServicePrice() + SDK->>WarmStorage: getPriceList() SDK->>Payments: accountInfo(client) alt Needs setup Client->>Payments: depositWithPermitAndApproveOperator() diff --git a/docs/src/content/docs/developer-guides/migration-guide.md b/docs/src/content/docs/developer-guides/migration-guide.md index 2c284f6be..168a48bd1 100644 --- a/docs/src/content/docs/developer-guides/migration-guide.md +++ b/docs/src/content/docs/developer-guides/migration-guide.md @@ -27,6 +27,54 @@ SessionKey.TerminateServicePermission TypedData.signTerminateService(client, { dataSetId }) ``` +### Action: Read pricing with `getPriceList()` + +`getServicePrice()` was removed from both `@filoz/synapse-core` and `WarmStorageService`. Use `getPriceList()`, which returns the full on-chain price catalogue (`token`, `rates`, `fees`, `lockups`). + +```ts +// before +const price = await warmStorage.getServicePrice() +price.pricePerTiBPerMonthNoCDN +price.pricePerTiBCdnEgress + +// after +const priceList = await warmStorage.getPriceList() +priceList.rates.storagePerTibPerMonth +priceList.rates.cdnEgressPerTib +``` + +The React `useServicePrice()` hook was removed in favor of `usePriceList()`. + +```tsx +// before +import { useServicePrice } from '@filoz/synapse-react' +const { data } = useServicePrice() +data?.pricePerTiBPerMonthNoCDN + +// after +import { usePriceList } from '@filoz/synapse-react' +const { data } = usePriceList() +data?.rates.storagePerTibPerMonth +``` + +### Action: Read upload rates from `costs.rates` + +The `rate` alias on upload-cost results was removed. Use `rates`. + +```ts +// before +const { costs } = await synapse.storage.prepare({ dataSize }) +costs.rate.perMonth + +// after +const { costs } = await synapse.storage.prepare({ dataSize }) +costs.rates.perMonth +``` + +### Action: Replace the `LOCKUP_PERIOD` constant + +The `LOCKUP_PERIOD` export was removed from `@filoz/synapse-core`. The lockup period is now read from the chain; use `getPriceList().lockups.defaultLockupPeriod` if you need the value. + ## 0.37.0 `synapse-sdk` moved to a viem-first API, removed deprecated modules/methods, and standardized method signatures around options objects plus `bigint` identifiers. diff --git a/docs/src/content/docs/developer-guides/storage/storage-costs.mdx b/docs/src/content/docs/developer-guides/storage/storage-costs.mdx index 0b3d427ec..2128a5b94 100644 --- a/docs/src/content/docs/developer-guides/storage/storage-costs.mdx +++ b/docs/src/content/docs/developer-guides/storage/storage-costs.mdx @@ -109,7 +109,7 @@ import { privateKeyToAccount } from 'viem/accounts' const synapse = Synapse.create({ account: privateKeyToAccount('0x...'), source: 'my-app' }) // ---cut--- // With currentDataSetSize — accurate floor-aware delta -const { rate, depositNeeded } = await synapse.storage.getUploadCosts({ +const { rates, depositNeeded } = await synapse.storage.getUploadCosts({ isNewDataSet: false, currentDataSetSize: 50n * SIZE_CONSTANTS.MiB, dataSize: 100n * SIZE_CONSTANTS.MiB, @@ -133,16 +133,16 @@ import { Synapse, formatUnits, SIZE_CONSTANTS } from "@filoz/synapse-sdk" import { privateKeyToAccount } from 'viem/accounts' const synapse = Synapse.create({ account: privateKeyToAccount('0x...'), source: 'my-app' }) // ---cut--- -const { rate, depositNeeded, needsFwssMaxApproval, ready } = await synapse.storage.getUploadCosts({ +const { rates, depositNeeded, needsFwssMaxApproval, ready } = await synapse.storage.getUploadCosts({ dataSize: 100n * SIZE_CONSTANTS.MiB, isNewDataSet: true, withCDN: true, }) // Storage rate per epoch (30 seconds) -console.log("Rate per epoch:", formatUnits(rate.perEpoch), "USDFC") +console.log("Rate per epoch:", formatUnits(rates.perEpoch), "USDFC") // Storage rate per month -console.log("Rate per month:", formatUnits(rate.perMonth), "USDFC") +console.log("Rate per month:", formatUnits(rates.perMonth), "USDFC") // USDFC to deposit console.log("Deposit needed:", formatUnits(depositNeeded), "USDFC") // Whether FWSS needs to be approved @@ -172,8 +172,8 @@ const { costs, transaction } = await synapse.storage.prepare({ }) // Inspect costs -const { rate, depositNeeded } = costs // Upload costs breakdown -console.log("Rate per month:", formatUnits(rate.perMonth)) +const { rates, depositNeeded } = costs // Upload costs breakdown +console.log("Rate per month:", formatUnits(rates.perMonth)) console.log("Deposit needed:", formatUnits(depositNeeded)) // Execute if the account isn't ready @@ -209,8 +209,8 @@ const { costs, transaction } = await synapse.storage.prepare({ extraRunwayEpochs: oneYear, }) -const { rate, depositNeeded, ready } = costs -console.log("Monthly rate (per copy):", formatUnits(rate.perMonth), "USDFC") +const { rates, depositNeeded, ready } = costs +console.log("Monthly rate (per copy):", formatUnits(rates.perMonth), "USDFC") console.log("Total deposit needed:", formatUnits(depositNeeded), "USDFC") console.log("Account ready:", ready) @@ -251,7 +251,7 @@ const { totalSizeBytes, datasetCount } = await getAccountTotalStorageSize(client }) ``` -All rate values come in both per-epoch (contract-native) and per-month (`ratePerEpoch * EPOCHS_PER_MONTH`) units. Note that `ratePerMonth` from `totalAccountRate()` is computed as `ratePerEpoch * EPOCHS_PER_MONTH` — this is slightly less than the full-precision `rate.perMonth` returned by `getUploadCosts()` due to integer truncation. See [Rate Precision](/cookbooks/payments-and-storage/#rate-precision) for details. +All rate values come in both per-epoch (contract-native) and per-month (`ratePerEpoch * EPOCHS_PER_MONTH`) units. Note that `ratePerMonth` from `totalAccountRate()` is computed as `ratePerEpoch * EPOCHS_PER_MONTH` — this is slightly less than the full-precision `rates.perMonth` returned by `getUploadCosts()` due to integer truncation. See [Rate Precision](/cookbooks/payments-and-storage/#rate-precision) for details. :::tip[API Reference] For the full API — including pure calculation functions and lower-level utilities — see the [synapse-core pay reference](/reference/filoz/synapse-core/pay/toc/) and [synapse-core warm-storage reference](/reference/filoz/synapse-core/warm-storage/toc/). diff --git a/docs/src/content/docs/getting-started/index.mdx b/docs/src/content/docs/getting-started/index.mdx index 845a61531..791359ffc 100644 --- a/docs/src/content/docs/getting-started/index.mdx +++ b/docs/src/content/docs/getting-started/index.mdx @@ -169,7 +169,7 @@ Now let's break down each step... }); console.log("Deposit needed:", prep.costs.depositNeeded); - console.log("Rate per month:", prep.costs.rate.perMonth); + console.log("Rate per month:", prep.costs.rates.perMonth); console.log("Ready to upload:", prep.costs.ready); // Execute the transaction if needed (handles deposit + approval in one tx) diff --git a/packages/synapse-core/AGENTS.md b/packages/synapse-core/AGENTS.md index 5384b38af..62c565c91 100644 --- a/packages/synapse-core/AGENTS.md +++ b/packages/synapse-core/AGENTS.md @@ -159,24 +159,22 @@ export type OutputType = ContractFunctionReturnType< When the contract function return type is an array try to convert into an object using the contract source code to choose the best descritive property names. When the return type is already an object inline it with documentation for each property. ```ts - export type ContractOutputType = ContractFunctionReturnType + export type ContractOutputType = ContractFunctionReturnType /** - * The service price for the warm storage. + * The canonical warm storage price list. */ export type OutputType = { - /** Price per TiB per month without CDN (in base units) */ - pricePerTiBPerMonthNoCDN: bigint - /** CDN egress price per TiB (usage-based, in base units) */ - pricePerTiBCdnEgress: bigint - /** Cache miss egress price per TiB (usage-based, in base units) */ - pricePerTiBCacheMissEgress: bigint /** Token address for payments */ - tokenAddress: string - /** Number of epochs per month */ - epochsPerMonth: bigint - /** Minimum monthly charge for any dataset size (in base units) */ - minimumPricePerMonth: bigint + token: Address + rates: { + /** Storage price per TiB per month (in base units) */ + storagePerTibPerMonth: bigint + /** Per-dataset monthly proving service fee (in base units) */ + datasetFeePerMonth: bigint + // ...remaining rate fields documented the same way + } + // ...fees and lockups objects follow the same inline-with-docs pattern } ``` @@ -201,15 +199,15 @@ All read and write action require a call function to enable composition with oth ```ts /** - * Create a call to the {@link getServicePrice} function for use with the Viem multicall, readContract, or simulateContract functions. + * Create a call to the {@link getPriceList} function for use with the Viem multicall, readContract, or simulateContract functions. * - * @param options - {@link getServicePriceCall.OptionsType} - * @returns Call object {@link getServicePriceCall.OutputType} - * @throws Errors {@link getServicePriceCall.ErrorType} + * @param options - {@link getPriceListCall.OptionsType} + * @returns Call object {@link getPriceListCall.OutputType} + * @throws Errors {@link getPriceListCall.ErrorType} * * @example * ```ts - * import { getServicePriceCall } from '@filoz/synapse-core/warm-storage' + * import { getPriceListCall } from '@filoz/synapse-core/warm-storage' * import { createPublicClient, http } from 'viem' * import { multicall } from 'viem/actions' * import { calibration } from '@filoz/synapse-core/chains' @@ -221,21 +219,21 @@ All read and write action require a call function to enable composition with oth * * const results = await multicall(client, { * contracts: [ - * getServicePriceCall({ chain: calibration }), + * getPriceListCall({ chain: calibration }), * ], * }) * * console.log(results[0]) * ``` */ -export function getServicePriceCall(options: getServicePriceCall.OptionsType) { +export function getPriceListCall(options: getPriceListCall.OptionsType) { const chain = asChain(options.chain) return { - abi: chain.contracts.storage.abi, - address: options.address ?? chain.contracts.storage.address, - functionName: 'getServicePrice', + abi: chain.contracts.fwssView.abi, + address: options.contractAddress ?? chain.contracts.fwssView.address, + functionName: 'getPriceList', args: [], - } satisfies getServicePriceCall.OutputType + } satisfies getPriceListCall.OutputType } ``` @@ -243,10 +241,10 @@ export function getServicePriceCall(options: getServicePriceCall.OptionsType) { They should have their own namespaced types ```ts -export namespace getServicePriceCall { - export type OptionsType = Simplify +export namespace getPriceListCall { + export type OptionsType = Simplify export type ErrorType = asChain.ErrorType - export type OutputType = ContractFunctionParameters + export type OutputType = ContractFunctionParameters } ``` @@ -314,7 +312,7 @@ export function extractSetOperatorApprovalEvent(logs: Log[]) { #### Parse function -When the `ContractOutputType` is different from the `OutputType` we need a parse function to transform the contract output into the action output. It should called `parse` .ie `parseGetServicePrice`. +When the `ContractOutputType` is different from the `OutputType` we need a parse function to transform the contract output into the action output. It should called `parse` .ie `parseGetPriceList`. ## Decision-Making @@ -339,6 +337,6 @@ Reference contract source code for expected behavior and always create tests for Use mocks and constants inside `/mocks` to test the actions. -See `test/get-service-price.test.ts` for a comprehensive example of test patterns and structure. +See `test/get-price-list.test.ts` for a comprehensive example of test patterns and structure. -Run the tests with `pnpm exec playwright-test "test/get-service-price.test.ts" --mode node` +Run the tests with `pnpm exec playwright-test "test/get-price-list.test.ts" --mode node` diff --git a/packages/synapse-core/src/abis/generated.ts b/packages/synapse-core/src/abis/generated.ts index dbedf5b44..6ad638ed8 100644 --- a/packages/synapse-core/src/abis/generated.ts +++ b/packages/synapse-core/src/abis/generated.ts @@ -18,7 +18,6 @@ export const errorsAbi = [ ], name: 'AddressAlreadySet', }, - { type: 'error', inputs: [], name: 'AtLeastOnePriceMustBeNonZero' }, { type: 'error', inputs: [{ name: 'dataSetId', internalType: 'uint256', type: 'uint256' }], @@ -85,6 +84,15 @@ export const errorsAbi = [ ], name: 'CommissionExceedsMaximum', }, + { + type: 'error', + inputs: [ + { name: 'dataSetId', internalType: 'uint256', type: 'uint256' }, + { name: 'requiredEpoch', internalType: 'uint256', type: 'uint256' }, + { name: 'currentBlock', internalType: 'uint256', type: 'uint256' }, + ], + name: 'DataSetNotAbandoned', + }, { type: 'error', inputs: [{ name: 'railId', internalType: 'uint256', type: 'uint256' }], @@ -109,7 +117,6 @@ export const errorsAbi = [ ], name: 'DataSetPaymentBeyondEndEpoch', }, - { type: 'error', inputs: [], name: 'DivisionByZero' }, { type: 'error', inputs: [ @@ -150,11 +157,7 @@ export const errorsAbi = [ { name: 'operator', internalType: 'address', type: 'address' }, { name: 'lockupAllowance', internalType: 'uint256', type: 'uint256' }, { name: 'lockupUsage', internalType: 'uint256', type: 'uint256' }, - { - name: 'minimumLockupRequired', - internalType: 'uint256', - type: 'uint256', - }, + { name: 'lockupRequired', internalType: 'uint256', type: 'uint256' }, ], name: 'InsufficientLockupAllowance', }, @@ -162,7 +165,7 @@ export const errorsAbi = [ type: 'error', inputs: [ { name: 'payer', internalType: 'address', type: 'address' }, - { name: 'minimumRequired', internalType: 'uint256', type: 'uint256' }, + { name: 'required', internalType: 'uint256', type: 'uint256' }, { name: 'available', internalType: 'uint256', type: 'uint256' }, ], name: 'InsufficientLockupFunds', @@ -188,7 +191,7 @@ export const errorsAbi = [ { name: 'operator', internalType: 'address', type: 'address' }, { name: 'rateAllowance', internalType: 'uint256', type: 'uint256' }, { name: 'rateUsage', internalType: 'uint256', type: 'uint256' }, - { name: 'minimumRateRequired', internalType: 'uint256', type: 'uint256' }, + { name: 'rateRequired', internalType: 'uint256', type: 'uint256' }, ], name: 'InsufficientRateAllowance', }, @@ -361,19 +364,6 @@ export const errorsAbi = [ ], name: 'PaymentRailsNotFinalized', }, - { - type: 'error', - inputs: [ - { - name: 'priceType', - internalType: 'enum Errors.PriceType', - type: 'uint8', - }, - { name: 'maxAllowed', internalType: 'uint256', type: 'uint256' }, - { name: 'actual', internalType: 'uint256', type: 'uint256' }, - ], - name: 'PriceExceedsMaximum', - }, { type: 'error', inputs: [{ name: 'dataSetId', internalType: 'uint256', type: 'uint256' }], @@ -1805,15 +1795,6 @@ export const filecoinWarmStorageServiceAbi = [ outputs: [], stateMutability: 'nonpayable', }, - { - type: 'function', - inputs: [{ name: 'totalBytes', internalType: 'uint256', type: 'uint256' }], - name: 'calculateRatePerEpoch', - outputs: [ - { name: 'storageRate', internalType: 'uint256', type: 'uint256' }, - ], - stateMutability: 'view', - }, { type: 'function', inputs: [ @@ -1897,7 +1878,7 @@ export const filecoinWarmStorageServiceAbi = [ { name: 'serviceFee', internalType: 'uint256', type: 'uint256' }, { name: 'spPayment', internalType: 'uint256', type: 'uint256' }, ], - stateMutability: 'view', + stateMutability: 'pure', }, { type: 'function', @@ -1941,7 +1922,7 @@ export const filecoinWarmStorageServiceAbi = [ }, { name: 'epochsPerMonth', internalType: 'uint256', type: 'uint256' }, { - name: 'minimumPricePerMonth', + name: 'datasetFeePerMonth', internalType: 'uint256', type: 'uint256', }, @@ -2160,6 +2141,16 @@ export const filecoinWarmStorageServiceAbi = [ outputs: [], stateMutability: 'nonpayable', }, + { + type: 'function', + inputs: [ + { name: 'dataSetId', internalType: 'uint256', type: 'uint256' }, + { name: 'extraData', internalType: 'bytes', type: 'bytes' }, + ], + name: 'terminateService', + outputs: [], + stateMutability: 'nonpayable', + }, { type: 'function', inputs: [ @@ -2178,35 +2169,26 @@ export const filecoinWarmStorageServiceAbi = [ { type: 'function', inputs: [ - { name: 'newController', internalType: 'address', type: 'address' }, + { name: 'dataSetId', internalType: 'uint256', type: 'uint256' }, + { name: 'amount', internalType: 'uint256', type: 'uint256' }, ], - name: 'transferFilBeamController', - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - inputs: [{ name: 'newOwner', internalType: 'address', type: 'address' }], - name: 'transferOwnership', + name: 'topUpLifecycleReserve', outputs: [], stateMutability: 'nonpayable', }, { type: 'function', inputs: [ - { name: 'newStoragePrice', internalType: 'uint256', type: 'uint256' }, - { name: 'newMinimumRate', internalType: 'uint256', type: 'uint256' }, + { name: 'newController', internalType: 'address', type: 'address' }, ], - name: 'updatePricing', + name: 'transferFilBeamController', outputs: [], stateMutability: 'nonpayable', }, { type: 'function', - inputs: [ - { name: 'newCommissionBps', internalType: 'uint256', type: 'uint256' }, - ], - name: 'updateServiceCommission', + inputs: [{ name: 'newOwner', internalType: 'address', type: 'address' }], + name: 'transferOwnership', outputs: [], stateMutability: 'nonpayable', }, @@ -2260,43 +2242,6 @@ export const filecoinWarmStorageServiceAbi = [ outputs: [{ name: '', internalType: 'address', type: 'address' }], stateMutability: 'view', }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'dataSetId', - internalType: 'uint256', - type: 'uint256', - indexed: true, - }, - { - name: 'cdnAmountAdded', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - { - name: 'totalCdnLockup', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - { - name: 'cacheMissAmountAdded', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - { - name: 'totalCacheMissLockup', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'CDNPaymentRailsToppedUp', - }, { type: 'event', anonymous: false, @@ -2328,37 +2273,6 @@ export const filecoinWarmStorageServiceAbi = [ ], name: 'CDNPaymentTerminated', }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'caller', - internalType: 'address', - type: 'address', - indexed: true, - }, - { - name: 'dataSetId', - internalType: 'uint256', - type: 'uint256', - indexed: true, - }, - { - name: 'cacheMissRailId', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - { - name: 'cdnRailId', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'CDNServiceTerminated', - }, { type: 'event', anonymous: false, @@ -2624,25 +2538,6 @@ export const filecoinWarmStorageServiceAbi = [ ], name: 'PieceAdded', }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'storagePrice', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - { - name: 'minimumRate', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'PricingUpdated', - }, { type: 'event', anonymous: false, @@ -2674,32 +2569,7 @@ export const filecoinWarmStorageServiceAbi = [ anonymous: false, inputs: [ { - name: 'dataSetId', - internalType: 'uint256', - type: 'uint256', - indexed: true, - }, - { - name: 'railId', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - { - name: 'newRate', - internalType: 'uint256', - type: 'uint256', - indexed: false, - }, - ], - name: 'RailRateUpdated', - }, - { - type: 'event', - anonymous: false, - inputs: [ - { - name: 'caller', + name: 'approver', internalType: 'address', type: 'address', indexed: true, @@ -2783,17 +2653,6 @@ export const filecoinWarmStorageServiceAbi = [ inputs: [{ name: 'target', internalType: 'address', type: 'address' }], name: 'AddressEmptyCode', }, - { type: 'error', inputs: [], name: 'AtLeastOnePriceMustBeNonZero' }, - { - type: 'error', - inputs: [{ name: 'dataSetId', internalType: 'uint256', type: 'uint256' }], - name: 'CDNPaymentAlreadyTerminated', - }, - { - type: 'error', - inputs: [{ name: 'dataSetId', internalType: 'uint256', type: 'uint256' }], - name: 'CacheMissPaymentAlreadyTerminated', - }, { type: 'error', inputs: [ @@ -2840,15 +2699,11 @@ export const filecoinWarmStorageServiceAbi = [ { type: 'error', inputs: [ - { - name: 'commissionType', - internalType: 'enum Errors.CommissionType', - type: 'uint8', - }, - { name: 'max', internalType: 'uint256', type: 'uint256' }, - { name: 'actual', internalType: 'uint256', type: 'uint256' }, + { name: 'dataSetId', internalType: 'uint256', type: 'uint256' }, + { name: 'requiredEpoch', internalType: 'uint256', type: 'uint256' }, + { name: 'currentBlock', internalType: 'uint256', type: 'uint256' }, ], - name: 'CommissionExceedsMaximum', + name: 'DataSetNotAbandoned', }, { type: 'error', @@ -2874,7 +2729,6 @@ export const filecoinWarmStorageServiceAbi = [ ], name: 'DataSetPaymentBeyondEndEpoch', }, - { type: 'error', inputs: [], name: 'DivisionByZero' }, { type: 'error', inputs: [ @@ -2906,55 +2760,6 @@ export const filecoinWarmStorageServiceAbi = [ inputs: [{ name: 'dataSetId', internalType: 'uint256', type: 'uint256' }], name: 'FilBeamServiceNotConfigured', }, - { - type: 'error', - inputs: [ - { name: 'payer', internalType: 'address', type: 'address' }, - { name: 'operator', internalType: 'address', type: 'address' }, - { name: 'lockupAllowance', internalType: 'uint256', type: 'uint256' }, - { name: 'lockupUsage', internalType: 'uint256', type: 'uint256' }, - { - name: 'minimumLockupRequired', - internalType: 'uint256', - type: 'uint256', - }, - ], - name: 'InsufficientLockupAllowance', - }, - { - type: 'error', - inputs: [ - { name: 'payer', internalType: 'address', type: 'address' }, - { name: 'minimumRequired', internalType: 'uint256', type: 'uint256' }, - { name: 'available', internalType: 'uint256', type: 'uint256' }, - ], - name: 'InsufficientLockupFunds', - }, - { - type: 'error', - inputs: [ - { name: 'payer', internalType: 'address', type: 'address' }, - { name: 'operator', internalType: 'address', type: 'address' }, - { name: 'maxLockupPeriod', internalType: 'uint256', type: 'uint256' }, - { - name: 'requiredLockupPeriod', - internalType: 'uint256', - type: 'uint256', - }, - ], - name: 'InsufficientMaxLockupPeriod', - }, - { - type: 'error', - inputs: [ - { name: 'payer', internalType: 'address', type: 'address' }, - { name: 'operator', internalType: 'address', type: 'address' }, - { name: 'rateAllowance', internalType: 'uint256', type: 'uint256' }, - { name: 'rateUsage', internalType: 'uint256', type: 'uint256' }, - { name: 'minimumRateRequired', internalType: 'uint256', type: 'uint256' }, - ], - name: 'InsufficientRateAllowance', - }, { type: 'error', inputs: [ @@ -3006,11 +2811,6 @@ export const filecoinWarmStorageServiceAbi = [ inputs: [{ name: 'length', internalType: 'uint256', type: 'uint256' }], name: 'InvalidServiceNameLength', }, - { - type: 'error', - inputs: [{ name: 'dataSetId', internalType: 'uint256', type: 'uint256' }], - name: 'InvalidTopUpAmount', - }, { type: 'error', inputs: [], name: 'MaxProvingPeriodZero' }, { type: 'error', @@ -3077,14 +2877,6 @@ export const filecoinWarmStorageServiceAbi = [ ], name: 'OnlyPDPVerifierAllowed', }, - { - type: 'error', - inputs: [ - { name: 'payer', internalType: 'address', type: 'address' }, - { name: 'operator', internalType: 'address', type: 'address' }, - ], - name: 'OperatorNotApproved', - }, { type: 'error', inputs: [{ name: 'owner', internalType: 'address', type: 'address' }], @@ -3103,19 +2895,6 @@ export const filecoinWarmStorageServiceAbi = [ ], name: 'PaymentRailsNotFinalized', }, - { - type: 'error', - inputs: [ - { - name: 'priceType', - internalType: 'enum Errors.PriceType', - type: 'uint8', - }, - { name: 'maxAllowed', internalType: 'uint256', type: 'uint256' }, - { name: 'actual', internalType: 'uint256', type: 'uint256' }, - ], - name: 'PriceExceedsMaximum', - }, { type: 'error', inputs: [{ name: 'dataSetId', internalType: 'uint256', type: 'uint256' }], @@ -3344,6 +3123,16 @@ export const filecoinWarmStorageServiceStateViewAbi = [ { name: 'clientDataSetId', internalType: 'uint256', type: 'uint256' }, { name: 'pdpEndEpoch', internalType: 'uint256', type: 'uint256' }, { name: 'providerId', internalType: 'uint256', type: 'uint256' }, + { + name: 'pendingOneTimePayments', + internalType: 'uint96', + type: 'uint96', + }, + { + name: 'lifecycleReserveBalance', + internalType: 'uint96', + type: 'uint96', + }, { name: 'dataSetId', internalType: 'uint256', type: 'uint256' }, ], }, @@ -3370,6 +3159,16 @@ export const filecoinWarmStorageServiceStateViewAbi = [ { name: 'clientDataSetId', internalType: 'uint256', type: 'uint256' }, { name: 'pdpEndEpoch', internalType: 'uint256', type: 'uint256' }, { name: 'providerId', internalType: 'uint256', type: 'uint256' }, + { + name: 'pendingOneTimePayments', + internalType: 'uint96', + type: 'uint96', + }, + { + name: 'lifecycleReserveBalance', + internalType: 'uint96', + type: 'uint96', + }, { name: 'dataSetId', internalType: 'uint256', type: 'uint256' }, ], }, @@ -3389,7 +3188,7 @@ export const filecoinWarmStorageServiceStateViewAbi = [ name: 'getCurrentPricingRates', outputs: [ { name: 'storagePrice', internalType: 'uint256', type: 'uint256' }, - { name: 'minimumRate', internalType: 'uint256', type: 'uint256' }, + { name: 'datasetFee', internalType: 'uint256', type: 'uint256' }, ], stateMutability: 'view', }, @@ -3413,6 +3212,16 @@ export const filecoinWarmStorageServiceStateViewAbi = [ { name: 'clientDataSetId', internalType: 'uint256', type: 'uint256' }, { name: 'pdpEndEpoch', internalType: 'uint256', type: 'uint256' }, { name: 'providerId', internalType: 'uint256', type: 'uint256' }, + { + name: 'pendingOneTimePayments', + internalType: 'uint96', + type: 'uint96', + }, + { + name: 'lifecycleReserveBalance', + internalType: 'uint96', + type: 'uint96', + }, { name: 'dataSetId', internalType: 'uint256', type: 'uint256' }, ], }, @@ -3482,6 +3291,118 @@ export const filecoinWarmStorageServiceStateViewAbi = [ ], stateMutability: 'view', }, + { + type: 'function', + inputs: [], + name: 'getPriceList', + outputs: [ + { + name: 'list', + internalType: 'struct PriceList', + type: 'tuple', + components: [ + { name: 'token', internalType: 'contract IERC20', type: 'address' }, + { + name: 'rates', + internalType: 'struct PriceListRates', + type: 'tuple', + components: [ + { + name: 'storagePerTibPerMonth', + internalType: 'uint256', + type: 'uint256', + }, + { + name: 'datasetFeePerMonth', + internalType: 'uint256', + type: 'uint256', + }, + { + name: 'cdnEgressPerTib', + internalType: 'uint256', + type: 'uint256', + }, + { + name: 'cacheMissEgressPerTib', + internalType: 'uint256', + type: 'uint256', + }, + ], + }, + { + name: 'fees', + internalType: 'struct PriceListFees', + type: 'tuple', + components: [ + { + name: 'createDataSetFee', + internalType: 'uint256', + type: 'uint256', + }, + { + name: 'addPiecesBaseFee', + internalType: 'uint256', + type: 'uint256', + }, + { + name: 'addPiecesPerPieceFee', + internalType: 'uint256', + type: 'uint256', + }, + { + name: 'schedulePieceRemovalsFee', + internalType: 'uint256', + type: 'uint256', + }, + { + name: 'terminateFee', + internalType: 'uint256', + type: 'uint256', + }, + ], + }, + { + name: 'lockups', + internalType: 'struct PriceListLockups', + type: 'tuple', + components: [ + { + name: 'lifecycleReserveTarget', + internalType: 'uint256', + type: 'uint256', + }, + { + name: 'replenishThreshold', + internalType: 'uint256', + type: 'uint256', + }, + { + name: 'defaultLockupPeriod', + internalType: 'uint256', + type: 'uint256', + }, + { + name: 'cdnLockupAmount', + internalType: 'uint256', + type: 'uint256', + }, + { + name: 'cacheMissLockupAmount', + internalType: 'uint256', + type: 'uint256', + }, + { + name: 'cdnLockupPeriod', + internalType: 'uint256', + type: 'uint256', + }, + ], + }, + ], + }, + ], + stateMutability: 'view', + }, { type: 'function', inputs: [{ name: 'providerId', internalType: 'uint256', type: 'uint256' }], @@ -3602,58 +3523,45 @@ export const pdpVerifierAbi = [ type: 'constructor', inputs: [ { name: '_initializerVersion', internalType: 'uint64', type: 'uint64' }, - { name: '_usdfcTokenAddress', internalType: 'address', type: 'address' }, - { name: '_usdfcSybilFee', internalType: 'uint256', type: 'uint256' }, - { - name: '_paymentsContractAddress', - internalType: 'address', - type: 'address', - }, + { name: '_challengeFinality', internalType: 'uint256', type: 'uint256' }, ], stateMutability: 'nonpayable', }, { type: 'function', inputs: [], - name: 'FIL_SYBIL_FEE', + name: 'FIL_CLEANUP_DEPOSIT', outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], stateMutability: 'pure', }, { type: 'function', inputs: [], - name: 'MAX_ENQUEUED_REMOVALS', + name: 'INACTIVITY_WINDOW', outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], stateMutability: 'view', }, { type: 'function', inputs: [], - name: 'MAX_PIECE_SIZE_LOG2', + name: 'LEGACY_ACTIVITY_EPOCH', outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], stateMutability: 'view', }, { type: 'function', inputs: [], - name: 'NO_CHALLENGE_SCHEDULED', + name: 'MAX_ENQUEUED_REMOVALS', outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], stateMutability: 'view', }, { type: 'function', inputs: [], - name: 'NO_PROVEN_EPOCH', + name: 'MAX_PIECE_SIZE_LOG2', outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], stateMutability: 'view', }, - { - type: 'function', - inputs: [], - name: 'PAYMENTS_CONTRACT_ADDRESS', - outputs: [{ name: '', internalType: 'address', type: 'address' }], - stateMutability: 'view', - }, { type: 'function', inputs: [], @@ -3661,20 +3569,6 @@ export const pdpVerifierAbi = [ outputs: [{ name: '', internalType: 'string', type: 'string' }], stateMutability: 'view', }, - { - type: 'function', - inputs: [], - name: 'USDFC_SYBIL_FEE', - outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - inputs: [], - name: 'USDFC_TOKEN_ADDRESS', - outputs: [{ name: '', internalType: 'address', type: 'address' }], - stateMutability: 'view', - }, { type: 'function', inputs: [], @@ -3744,6 +3638,16 @@ export const pdpVerifierAbi = [ outputs: [], stateMutability: 'nonpayable', }, + { + type: 'function', + inputs: [ + { name: 'setId', internalType: 'uint256', type: 'uint256' }, + { name: 'maxPieces', internalType: 'uint256', type: 'uint256' }, + ], + name: 'cleanupPieces', + outputs: [{ name: 'done', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, { type: 'function', inputs: [ @@ -3982,9 +3886,7 @@ export const pdpVerifierAbi = [ }, { type: 'function', - inputs: [ - { name: '_challengeFinality', internalType: 'uint256', type: 'uint256' }, - ], + inputs: [], name: 'initialize', outputs: [], stateMutability: 'nonpayable', @@ -4428,6 +4330,12 @@ export const pdpVerifierAbi = [ inputs: [{ name: 'target', internalType: 'address', type: 'address' }], name: 'AddressEmptyCode', }, + { type: 'error', inputs: [], name: 'CleanupDepositRequired' }, + { type: 'error', inputs: [], name: 'DataSetAlreadyInCleanup' }, + { type: 'error', inputs: [], name: 'DataSetNotFound' }, + { type: 'error', inputs: [], name: 'DataSetNotInCleanupMode' }, + { type: 'error', inputs: [], name: 'DataSetNotLive' }, + { type: 'error', inputs: [], name: 'DepositTransferFailed' }, { type: 'error', inputs: [ @@ -4436,8 +4344,15 @@ export const pdpVerifierAbi = [ name: 'ERC1967InvalidImplementation', }, { type: 'error', inputs: [], name: 'ERC1967NonPayable' }, + { + type: 'error', + inputs: [ + { name: 'epochs', internalType: 'uint256', type: 'uint256' }, + { name: 'maxDelay', internalType: 'uint256', type: 'uint256' }, + ], + name: 'ExcessiveChallengeDelay', + }, { type: 'error', inputs: [], name: 'FailedCall' }, - { type: 'error', inputs: [], name: 'FilRefundFailed' }, { type: 'error', inputs: [ @@ -4446,8 +4361,19 @@ export const pdpVerifierAbi = [ ], name: 'IndexedError', }, + { + type: 'error', + inputs: [ + { name: 'epochs', internalType: 'uint256', type: 'uint256' }, + { name: 'minDelay', internalType: 'uint256', type: 'uint256' }, + ], + name: 'InsufficientChallengeDelay', + }, { type: 'error', inputs: [], name: 'InvalidInitialization' }, + { type: 'error', inputs: [], name: 'MaxPiecesMustBePositive' }, { type: 'error', inputs: [], name: 'NotInitializing' }, + { type: 'error', inputs: [], name: 'OnlyStorageProviderCanCleanupPieces' }, + { type: 'error', inputs: [], name: 'OnlyStorageProviderCanDelete' }, { type: 'error', inputs: [{ name: 'owner', internalType: 'address', type: 'address' }], @@ -4458,13 +4384,13 @@ export const pdpVerifierAbi = [ inputs: [{ name: 'account', internalType: 'address', type: 'address' }], name: 'OwnableUnauthorizedAccount', }, + { type: 'error', inputs: [], name: 'TransferFailed' }, { type: 'error', inputs: [], name: 'UUPSUnauthorizedCallContext' }, { type: 'error', inputs: [{ name: 'slot', internalType: 'bytes32', type: 'bytes32' }], name: 'UUPSUnsupportedProxiableUUID', }, - { type: 'error', inputs: [], name: 'UsdfcSybilFeeNotMet' }, ] as const /** diff --git a/packages/synapse-core/src/abis/index.ts b/packages/synapse-core/src/abis/index.ts index 3bcf0bb8f..a92798bab 100644 --- a/packages/synapse-core/src/abis/index.ts +++ b/packages/synapse-core/src/abis/index.ts @@ -17,10 +17,10 @@ import * as generated from './generated.ts' // Merge the storage and errors ABIs export const fwss = [...generated.filecoinWarmStorageServiceAbi, ...generated.errorsAbi] as const export const serviceProviderRegistry = [...generated.serviceProviderRegistryAbi, ...generated.errorsAbi] as const +export const fwssView = generated.filecoinWarmStorageServiceStateViewAbi export { filecoinPayV1Abi as filecoinPay, - filecoinWarmStorageServiceStateViewAbi as fwssView, pdpVerifierAbi as pdp, providerIdSetAbi as providerIdSet, sessionKeyRegistryAbi as sessionKeyRegistry, diff --git a/packages/synapse-core/src/mocks/jsonrpc/index.ts b/packages/synapse-core/src/mocks/jsonrpc/index.ts index 74e9daea6..67df3d1e8 100644 --- a/packages/synapse-core/src/mocks/jsonrpc/index.ts +++ b/packages/synapse-core/src/mocks/jsonrpc/index.ts @@ -401,16 +401,6 @@ export const presets = { viewContractAddress: () => [ADDRESSES.calibration.viewContract], serviceProviderRegistry: () => [ADDRESSES.calibration.spRegistry], sessionKeyRegistry: () => [ADDRESSES.calibration.sessionKeyRegistry], - getServicePrice: () => [ - { - pricePerTiBPerMonthNoCDN: parseUnits('2.5', 18), - pricePerTiBCdnEgress: parseUnits('7', 18), - pricePerTiBCacheMissEgress: parseUnits('7', 18), - minimumPricePerMonth: parseUnits('6', 16), - tokenAddress: ADDRESSES.calibration.usdfcToken, - epochsPerMonth: TIME_CONSTANTS.EPOCHS_PER_MONTH, - }, - ], owner: () => [ADDRESSES.client1], terminateService: () => [], topUpCDNPaymentRails: () => [], @@ -431,6 +421,8 @@ export const presets = { clientDataSetId: 0n, pdpEndEpoch: 0n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, cdnEndEpoch: 0n, dataSetId: 1n, }, @@ -453,6 +445,8 @@ export const presets = { pdpEndEpoch: 0n, pdpRailId: 1n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, serviceProvider: ADDRESSES.serviceProvider1, }, ] @@ -469,6 +463,8 @@ export const presets = { pdpEndEpoch: 0n, pdpRailId: 0n, providerId: 0n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, serviceProvider: ADDRESSES.zero, }, ] @@ -517,6 +513,32 @@ export const presets = { getClientDataSetsLength: () => { return [1n] }, + getPriceList: () => [ + { + token: ADDRESSES.calibration.usdfcToken, + rates: { + storagePerTibPerMonth: parseUnits('2.5', 18), + datasetFeePerMonth: parseUnits('0.024', 18), + cdnEgressPerTib: parseUnits('7', 18), + cacheMissEgressPerTib: parseUnits('7', 18), + }, + fees: { + createDataSetFee: parseUnits('0.025', 18), + addPiecesBaseFee: parseUnits('0.0005', 18), + addPiecesPerPieceFee: parseUnits('0.0003', 18), + schedulePieceRemovalsFee: parseUnits('0.002', 18), + terminateFee: parseUnits('0.00112', 18), + }, + lockups: { + lifecycleReserveTarget: parseUnits('0.1', 18), + replenishThreshold: parseUnits('0.005', 18), + defaultLockupPeriod: TIME_CONSTANTS.DEFAULT_LOCKUP_DAYS * TIME_CONSTANTS.EPOCHS_PER_DAY, + cdnLockupAmount: parseUnits('0.7', 18), + cacheMissLockupAmount: parseUnits('0.3', 18), + cdnLockupPeriod: 5n * TIME_CONSTANTS.EPOCHS_PER_DAY, + }, + }, + ], }, pdpVerifier: { dataSetLive: () => [true], diff --git a/packages/synapse-core/src/mocks/jsonrpc/warm-storage.ts b/packages/synapse-core/src/mocks/jsonrpc/warm-storage.ts index ecb4a992c..a8634b632 100644 --- a/packages/synapse-core/src/mocks/jsonrpc/warm-storage.ts +++ b/packages/synapse-core/src/mocks/jsonrpc/warm-storage.ts @@ -21,6 +21,7 @@ type getPieceMetadata = ExtractAbiFunction type getPDPConfig = ExtractAbiFunction type getClientDataSetsLength = ExtractAbiFunction +type getPriceList = ExtractAbiFunction export interface WarmStorageViewOptions { isProviderApproved?: (args: AbiToType) => AbiToType @@ -40,6 +41,7 @@ export interface WarmStorageViewOptions { getClientDataSetsLength?: ( args: AbiToType ) => AbiToType + getPriceList?: (args: AbiToType) => AbiToType } /** @@ -55,7 +57,6 @@ type filBeamBeneficiaryAddress = ExtractAbiFunction type serviceProviderRegistry = ExtractAbiFunction type sessionKeyRegistry = ExtractAbiFunction -type getServicePrice = ExtractAbiFunction type owner = ExtractAbiFunction type terminateService = ExtractAbiFunction type topUpCDNPaymentRails = ExtractAbiFunction @@ -78,7 +79,6 @@ export interface WarmStorageOptions { args: AbiToType ) => AbiToType sessionKeyRegistry?: (args: AbiToType) => AbiToType - getServicePrice?: (args: AbiToType) => AbiToType owner?: (args: AbiToType) => AbiToType terminateService?: (args: AbiToType) => AbiToType topUpCDNPaymentRails?: (args: AbiToType) => AbiToType @@ -181,16 +181,6 @@ export function warmStorageCallHandler(data: Hex, options: JSONRPCOptions): Hex ) } - case 'getServicePrice': { - if (!options.warmStorage?.getServicePrice) { - throw new Error('Warm Storage: getServicePrice is not defined') - } - return encodeAbiParameters( - Abis.fwss.find((abi) => abi.type === 'function' && abi.name === 'getServicePrice')!.outputs, - options.warmStorage.getServicePrice(args) - ) - } - case 'owner': { if (!options.warmStorage?.owner) { throw new Error('Warm Storage: owner is not defined') @@ -359,6 +349,15 @@ export function warmStorageViewCallHandler(data: Hex, options: JSONRPCOptions): options.warmStorageView.getClientDataSetsLength(args) ) } + case 'getPriceList': { + if (!options.warmStorageView?.getPriceList) { + throw new Error('Warm Storage View: getPriceList is not defined') + } + return encodeAbiParameters( + Abis.fwssView.find((abi) => abi.type === 'function' && abi.name === 'getPriceList')!.outputs, + options.warmStorageView.getPriceList(args) + ) + } default: { throw new Error(`Warm Storage View: unknown function: ${functionName} with args: ${args}`) diff --git a/packages/synapse-core/src/pay/fund.ts b/packages/synapse-core/src/pay/fund.ts index 33f49227a..84d612e46 100644 --- a/packages/synapse-core/src/pay/fund.ts +++ b/packages/synapse-core/src/pay/fund.ts @@ -11,7 +11,7 @@ import type { import { maxUint256 } from 'viem' import { waitForTransactionReceipt } from 'viem/actions' import type { ActionSyncCallback } from '../types.ts' -import { LOCKUP_PERIOD } from '../utils/constants.ts' +import { getPriceList } from '../warm-storage/price-list.ts' import { depositWithPermit } from './deposit-with-permit.ts' import { isFwssMaxApproved } from './is-fwss-max-approved.ts' import { depositAndApprove } from './payments.ts' @@ -70,8 +70,15 @@ export namespace fund { * ``` */ export async function fund(client: Client, options: fund.OptionsType): Promise { + // Resolve the approval lockup period from the chain once and reuse it for the + // readiness check and the approval call. + const maxLockupPeriod = (await getPriceList(client)).lockups.defaultLockupPeriod const needsApproval = - options.needsFwssMaxApproval ?? !(await isFwssMaxApproved(client, { clientAddress: client.account.address })) + options.needsFwssMaxApproval ?? + !(await isFwssMaxApproved(client, { + clientAddress: client.account.address, + requiredMaxLockupPeriod: maxLockupPeriod, + })) if (needsApproval) { if (options.amount > 0n) { @@ -79,14 +86,14 @@ export async function fund(client: Client, options: f amount: options.amount, rateAllowance: maxUint256, lockupAllowance: maxUint256, - maxLockupPeriod: LOCKUP_PERIOD, + maxLockupPeriod, }) } else { return setOperatorApproval(client, { approve: true, rateAllowance: maxUint256, lockupAllowance: maxUint256, - maxLockupPeriod: LOCKUP_PERIOD, + maxLockupPeriod, }) } } else if (options.amount > 0n) { diff --git a/packages/synapse-core/src/pay/is-fwss-max-approved.ts b/packages/synapse-core/src/pay/is-fwss-max-approved.ts index 924ae78eb..bad3c6655 100644 --- a/packages/synapse-core/src/pay/is-fwss-max-approved.ts +++ b/packages/synapse-core/src/pay/is-fwss-max-approved.ts @@ -1,21 +1,27 @@ import type { Address, Chain, Client, ReadContractErrorType, Transport } from 'viem' import { maxUint256 } from 'viem' import type { asChain } from '../chains.ts' -import { LOCKUP_PERIOD } from '../utils/constants.ts' +import { getPriceList } from '../warm-storage/price-list.ts' import { operatorApprovals } from './operator-approvals.ts' export namespace isFwssMaxApproved { export type OptionsType = { /** The address of the client to check approval for. */ clientAddress: Address + /** + * The lockup period the approval must cover. Defaults to the chain's + * `getPriceList().lockups.defaultLockupPeriod`. Callers that already hold + * the price list can pass it to skip the extra read. + */ + requiredMaxLockupPeriod?: bigint } - export type ErrorType = asChain.ErrorType | ReadContractErrorType + export type ErrorType = asChain.ErrorType | ReadContractErrorType | getPriceList.ErrorType } /** - * Check whether FWSS is approved with sufficient rate/lockup allowances - * and at least LOCKUP_PERIOD (30 days) for maxLockupPeriod. + * Check whether FWSS is approved with sufficient rate/lockup allowances and a + * `maxLockupPeriod` covering the chain's default lockup period. * * rateAllowance is checked for exact maxUint256 since the contract never * decrements it — it only tracks usage separately via rateUsage. @@ -37,6 +43,9 @@ export async function isFwssMaxApproved( client: Client, options: isFwssMaxApproved.OptionsType ): Promise { + const requiredMaxLockupPeriod = + options.requiredMaxLockupPeriod ?? (await getPriceList(client)).lockups.defaultLockupPeriod + const approval = await operatorApprovals(client, { address: options.clientAddress, }) @@ -45,6 +54,6 @@ export async function isFwssMaxApproved( approval.isApproved && approval.rateAllowance === maxUint256 && approval.lockupAllowance >= maxUint256 / 2n && - approval.maxLockupPeriod >= LOCKUP_PERIOD + approval.maxLockupPeriod >= requiredMaxLockupPeriod ) } diff --git a/packages/synapse-core/src/pay/payments.ts b/packages/synapse-core/src/pay/payments.ts index a298d40c0..f147df898 100644 --- a/packages/synapse-core/src/pay/payments.ts +++ b/packages/synapse-core/src/pay/payments.ts @@ -19,7 +19,8 @@ import * as erc20 from '../erc20/index.ts' import { ValidationError } from '../errors/base.ts' import { DepositAmountError, InsufficientBalanceError } from '../errors/pay.ts' import { signErc20Permit } from '../typed-data/sign-erc20-permit.ts' -import { LOCKUP_PERIOD, TIME_CONSTANTS } from '../utils/constants.ts' +import { TIME_CONSTANTS } from '../utils/constants.ts' +import { getPriceList } from '../warm-storage/price-list.ts' export type DepositAndApproveOptions = { /** @@ -55,7 +56,8 @@ export type DepositAndApproveOptions = { */ lockupAllowance?: bigint /** - * The max lockup period to approve. If not provided, the LOCKUP_PERIOD will be used. + * The max lockup period to approve. If not provided, the chain's + * `getPriceList().lockups.defaultLockupPeriod` will be used. */ maxLockupPeriod?: bigint } @@ -91,7 +93,7 @@ export async function depositAndApprove(client: Client, options: setOperatorApproval.OptionsType ): Promise { + // The synchronous call builder cannot read the chain, so resolve maxLockupPeriod + // from the price list here when approving and pass it down. + const maxLockupPeriod = + options.maxLockupPeriod ?? (options.approve ? (await getPriceList(client)).lockups.defaultLockupPeriod : 0n) + const { request } = await simulateContract( client, setOperatorApprovalCall({ @@ -94,7 +103,7 @@ export async function setOperatorApproval( approve: options.approve, rateAllowance: options.rateAllowance, lockupAllowance: options.lockupAllowance, - maxLockupPeriod: options.maxLockupPeriod, + maxLockupPeriod, contractAddress: options.contractAddress, }) ) @@ -222,10 +231,19 @@ export function setOperatorApprovalCall( } } + // maxLockupPeriod comes from the chain price list + // (getPriceList().lockups.defaultLockupPeriod), which this synchronous builder + // cannot read, so approving callers must resolve and pass it. + if (options.approve && options.maxLockupPeriod === undefined) { + throw new ValidationError( + 'maxLockupPeriod is required when approving; resolve it from getPriceList().lockups.defaultLockupPeriod' + ) + } + // Defaults based on approve flag const rateAllowance = options.rateAllowance ?? (options.approve ? maxUint256 : 0n) const lockupAllowance = options.lockupAllowance ?? (options.approve ? maxUint256 : 0n) - const maxLockupPeriod = options.maxLockupPeriod ?? (options.approve ? LOCKUP_PERIOD : 0n) + const maxLockupPeriod = options.maxLockupPeriod ?? 0n if (rateAllowance < 0n || lockupAllowance < 0n || maxLockupPeriod < 0n) { throw new ValidationError('Allowance or lockup period values cannot be negative') diff --git a/packages/synapse-core/src/utils/constants.ts b/packages/synapse-core/src/utils/constants.ts index 997aeefdb..42f4a2887 100644 --- a/packages/synapse-core/src/utils/constants.ts +++ b/packages/synapse-core/src/utils/constants.ts @@ -106,8 +106,6 @@ export const SIZE_CONSTANTS = { BYTES_PER_LEAF: 32n, } as const -export const LOCKUP_PERIOD = TIME_CONSTANTS.DEFAULT_LOCKUP_DAYS * TIME_CONSTANTS.EPOCHS_PER_DAY - /** * Default safety margin in epochs when calculating deposit amounts. * Accounts for epoch drift between balance check and on-chain execution. @@ -120,26 +118,6 @@ export const DEFAULT_BUFFER_EPOCHS = 5n */ export const DEFAULT_RUNWAY_EPOCHS = 0n -/** - * CDN fixed lockup amounts charged at dataset creation time. - * These are one-time lockups for CDN egress and cache miss egress rails. - */ -export const CDN_FIXED_LOCKUP = { - /** CDN egress rail fixed lockup: 0.7 USDFC */ - cdn: 700_000_000_000_000_000n, - /** Cache miss egress rail fixed lockup: 0.3 USDFC */ - cacheMiss: 300_000_000_000_000_000n, - /** Total: 1.0 USDFC */ - total: 1_000_000_000_000_000_000n, -} as const - -/** - * USDFC sybil fee charged on new dataset creation. - * Extracted from client funds into the payments auction pool to prevent state-growth spam. - * Matches PDPVerifier.USDFC_SYBIL_FEE (immutable, only changes with contract upgrade). - */ -export const USDFC_SYBIL_FEE = 100_000_000_000_000_000n // 0.1 USDFC - export const RETRY_CONSTANTS = { /** The interval in milliseconds between polls. 4 seconds is the default interval between polls. */ POLL_INTERVAL: 4000, diff --git a/packages/synapse-core/src/warm-storage/calculate-additional-lockup-required.ts b/packages/synapse-core/src/warm-storage/calculate-additional-lockup-required.ts index d6d325dff..f7628ad83 100644 --- a/packages/synapse-core/src/warm-storage/calculate-additional-lockup-required.ts +++ b/packages/synapse-core/src/warm-storage/calculate-additional-lockup-required.ts @@ -1,5 +1,6 @@ -import { CDN_FIXED_LOCKUP, LOCKUP_PERIOD, TIME_CONSTANTS, USDFC_SYBIL_FEE } from '../utils/constants.ts' +import { TIME_CONSTANTS } from '../utils/constants.ts' import { calculateEffectiveRate } from './calculate-effective-rate.ts' +import type { getPriceList } from './price-list.ts' export namespace calculateAdditionalLockupRequired { export type ParamsType = { @@ -7,13 +8,11 @@ export namespace calculateAdditionalLockupRequired { dataSize: bigint /** Current total data size in the existing dataset, in bytes. 0n for new datasets. */ currentDataSetSize: bigint - /** Price per TiB per month from getServicePrice(). */ - pricePerTiBPerMonth: bigint - /** Minimum monthly charge from getServicePrice(). */ - minimumPricePerMonth: bigint + /** Canonical warm storage price list. */ + priceList: getPriceList.OutputType /** Epochs per month. Defaults to EPOCHS_PER_MONTH (86400). */ epochsPerMonth?: bigint - /** Lockup period in epochs. Defaults to LOCKUP_PERIOD (30 days). */ + /** Lockup period in epochs. Defaults to priceList.lockups.defaultLockupPeriod. */ lockupEpochs?: bigint /** Whether a new dataset is being created (vs adding to existing). */ isNewDataSet: boolean @@ -25,12 +24,14 @@ export namespace calculateAdditionalLockupRequired { /** Per-epoch rate increase from this upload. */ rateDeltaPerEpoch: bigint /** Lockup increase from the rate change = rateDeltaPerEpoch * lockupEpochs. */ - rateLockupDelta: bigint - /** Fixed CDN lockup (only for new CDN datasets), 0 otherwise. */ - cdnFixedLockup: bigint - /** USDFC sybil fee (only for new datasets), 0 otherwise. */ - sybilFee: bigint - /** rateLockupDelta + cdnFixedLockup + sybilFee */ + streamingLockup: bigint + /** Lifecycle lockup target for new datasets. */ + lifecycleLockup: bigint + /** CDN lockup for new CDN datasets. */ + cdnLockup: bigint + /** Cache-miss lockup for new CDN datasets. */ + cacheMissLockup: bigint + /** streamingLockup + lifecycleLockup + cdnLockup + cacheMissLockup */ total: bigint } } @@ -38,8 +39,8 @@ export namespace calculateAdditionalLockupRequired { /** * Compute how much additional lockup this upload requires. * - * Handles floor-to-floor transitions correctly: when both the current dataset size - * and the new total size are below the floor threshold, the rate delta is 0. + * Existing datasets pay only the incremental rate lockup. New datasets also + * include lifecycle and optional CDN/cache-miss lockups. * * @param params - {@link calculateAdditionalLockupRequired.ParamsType} * @returns {@link calculateAdditionalLockupRequired.OutputType} @@ -50,15 +51,21 @@ export function calculateAdditionalLockupRequired( const { dataSize, currentDataSetSize, - pricePerTiBPerMonth, - minimumPricePerMonth, + priceList, epochsPerMonth = TIME_CONSTANTS.EPOCHS_PER_MONTH, - lockupEpochs = LOCKUP_PERIOD, + lockupEpochs, isNewDataSet, withCDN, } = params - const rateParams = { pricePerTiBPerMonth, minimumPricePerMonth, epochsPerMonth } + // The price list defines the default PDP rail lockup period. + const effectiveLockupEpochs = lockupEpochs ?? priceList.lockups.defaultLockupPeriod + + const rateParams = { + storagePerTibPerMonth: priceList.rates.storagePerTibPerMonth, + datasetFeePerMonth: priceList.rates.datasetFeePerMonth, + epochsPerMonth, + } let rateDeltaPerEpoch: bigint @@ -73,7 +80,8 @@ export function calculateAdditionalLockupRequired( sizeInBytes: currentDataSetSize, }) rateDeltaPerEpoch = newRate.ratePerEpoch - currentRate.ratePerEpoch - // Floor-to-floor: if both sizes are below floor, delta is 0 + // Defensive only: additive storage rate is monotonic in size, so a positive + // size delta never yields a negative rate delta in the current model. if (rateDeltaPerEpoch < 0n) rateDeltaPerEpoch = 0n } else { // New dataset or unknown current size: full rate for new data @@ -84,19 +92,21 @@ export function calculateAdditionalLockupRequired( rateDeltaPerEpoch = newRate.ratePerEpoch } - const rateLockupDelta = rateDeltaPerEpoch * lockupEpochs - - // CDN fixed lockup only applies to new CDN datasets - const cdnFixedLockup = isNewDataSet && withCDN ? CDN_FIXED_LOCKUP.total : 0n - - // Sybil fee applies to all new dataset creations - const sybilFee = isNewDataSet ? USDFC_SYBIL_FEE : 0n + const streamingLockup = rateDeltaPerEpoch * effectiveLockupEpochs + // The lifecycle reserve is seeded once per new dataset (one PDP rail each), so + // it is added per new dataset and summed across contexts by callers. CDN and + // cache-miss lockups are flat fixed amounts on the CDN rail; the lockup periods + // in the price list are rail settle windows, not rate multipliers. + const lifecycleLockup = isNewDataSet ? priceList.lockups.lifecycleReserveTarget : 0n + const cdnLockup = isNewDataSet && withCDN ? priceList.lockups.cdnLockupAmount : 0n + const cacheMissLockup = isNewDataSet && withCDN ? priceList.lockups.cacheMissLockupAmount : 0n return { rateDeltaPerEpoch, - rateLockupDelta, - cdnFixedLockup, - sybilFee, - total: rateLockupDelta + cdnFixedLockup + sybilFee, + streamingLockup, + lifecycleLockup, + cdnLockup, + cacheMissLockup, + total: streamingLockup + lifecycleLockup + cdnLockup + cacheMissLockup, } } diff --git a/packages/synapse-core/src/warm-storage/calculate-deposit-needed.ts b/packages/synapse-core/src/warm-storage/calculate-deposit-needed.ts index 083bef85e..f989e9178 100644 --- a/packages/synapse-core/src/warm-storage/calculate-deposit-needed.ts +++ b/packages/synapse-core/src/warm-storage/calculate-deposit-needed.ts @@ -1,5 +1,7 @@ import { DEFAULT_BUFFER_EPOCHS, DEFAULT_RUNWAY_EPOCHS } from '../utils/constants.ts' import { calculateAdditionalLockupRequired } from './calculate-additional-lockup-required.ts' +import { calculateUploadFees } from './calculate-upload-fees.ts' +import type { getPriceList } from './price-list.ts' export namespace calculateRunwayAmount { export type ParamsType = { @@ -73,14 +75,14 @@ export namespace calculateDepositNeeded { // Upload parameters (passed to calculateAdditionalLockupRequired) dataSize: bigint currentDataSetSize: bigint - pricePerTiBPerMonth: bigint - minimumPricePerMonth: bigint + priceList: getPriceList.OutputType /** Epochs per month. Defaults to EPOCHS_PER_MONTH (86400). */ epochsPerMonth?: bigint - /** Lockup period in epochs. Defaults to LOCKUP_PERIOD (30 days). */ + /** Lockup period in epochs. Defaults to priceList.lockups.defaultLockupPeriod. */ lockupEpochs?: bigint isNewDataSet: boolean withCDN: boolean + pieceCount?: bigint // Runway parameters currentLockupRate: bigint @@ -96,25 +98,41 @@ export namespace calculateDepositNeeded { /** Safety margin in epochs for tx execution delay. Defaults to DEFAULT_BUFFER_EPOCHS (5). */ bufferEpochs?: bigint } + + export type OutputType = { + /** Total deposit needed in token base units (0n if already sufficient). */ + depositNeeded: bigint + /** Lockup breakdown the deposit was computed from. */ + lockup: calculateAdditionalLockupRequired.OutputType + /** Operation fee breakdown the deposit was computed from. */ + fees: calculateUploadFees.OutputType + } } /** * Orchestrate lockup + runway + debt + buffer to compute total deposit needed. * + * Returns the deposit together with the lockup and fee breakdowns it was + * computed from, so callers can reuse them without recomputing. + * * @param params - {@link calculateDepositNeeded.ParamsType} - * @returns The total deposit needed in token base units (0n if already sufficient) + * @returns {@link calculateDepositNeeded.OutputType} */ -export function calculateDepositNeeded(params: calculateDepositNeeded.ParamsType): bigint { +export function calculateDepositNeeded(params: calculateDepositNeeded.ParamsType): calculateDepositNeeded.OutputType { const lockup = calculateAdditionalLockupRequired({ dataSize: params.dataSize, currentDataSetSize: params.currentDataSetSize, - pricePerTiBPerMonth: params.pricePerTiBPerMonth, - minimumPricePerMonth: params.minimumPricePerMonth, + priceList: params.priceList, epochsPerMonth: params.epochsPerMonth, lockupEpochs: params.lockupEpochs, isNewDataSet: params.isNewDataSet, withCDN: params.withCDN, }) + const fees = calculateUploadFees({ + priceList: params.priceList, + isNewDataSet: params.isNewDataSet, + pieceCount: params.pieceCount, + }) const netRateAfterUpload = params.currentLockupRate + lockup.rateDeltaPerEpoch const extraRunwayEpochs = params.extraRunwayEpochs ?? DEFAULT_RUNWAY_EPOCHS @@ -125,7 +143,7 @@ export function calculateDepositNeeded(params: calculateDepositNeeded.ParamsType extraRunwayEpochs, }) - const rawDepositNeeded = lockup.total + runway + params.debt - params.availableFunds + const rawDepositNeeded = lockup.total + fees.total + runway + params.debt - params.availableFunds // Skip buffer when no existing rails are draining and this is a new dataset. // The deposit lands before any rail is created, so nothing consumes funds @@ -143,5 +161,5 @@ export function calculateDepositNeeded(params: calculateDepositNeeded.ParamsType }) const clamped = rawDepositNeeded > 0n ? rawDepositNeeded : 0n - return clamped + buffer + return { depositNeeded: clamped + buffer, lockup, fees } } diff --git a/packages/synapse-core/src/warm-storage/calculate-effective-rate.ts b/packages/synapse-core/src/warm-storage/calculate-effective-rate.ts index 6e14ae3aa..e5f3d7493 100644 --- a/packages/synapse-core/src/warm-storage/calculate-effective-rate.ts +++ b/packages/synapse-core/src/warm-storage/calculate-effective-rate.ts @@ -4,23 +4,26 @@ export namespace calculateEffectiveRate { export type ParamsType = { /** Total data size in the dataset (existing + new), in bytes. */ sizeInBytes: bigint - /** Price per TiB per month from getServicePrice(). */ - pricePerTiBPerMonth: bigint - /** Minimum monthly charge from getServicePrice(). */ - minimumPricePerMonth: bigint - /** Epochs per month from getServicePrice() (always 86400). */ + /** Storage price per TiB per month. */ + storagePerTibPerMonth: bigint + /** + * Per-dataset monthly fee (the contract's `datasetFeePerMonth`), charged as + * a flat additive proving service fee on non-empty datasets. + */ + datasetFeePerMonth: bigint + /** Epochs per month. */ epochsPerMonth: bigint } export type OutputType = { /** - * Rate per epoch — matches the on-chain PDP rail rate. - * - * The contract computes this as a single division: - * `(totalBytes * pricePerTiBPerMonth) / (TiB * EPOCHS_PER_MONTH)` + * Rate per epoch — matches the contract's additive per-epoch rate + * (`calculateStorageSizeBasedRatePerEpoch`): the size-based storage rate plus + * the per-epoch dataset fee, each truncated independently then summed: + * `(totalBytes * storagePerTibPerMonth) / (TiB * EPOCHS_PER_MONTH) + datasetFeePerMonth / EPOCHS_PER_MONTH` * * Because truncation depends on totalBytes, this value is only valid for - * the exact size it was computed for — you cannot scale it linearly to + * the exact size it was computed for; you cannot scale it linearly to * estimate costs for different sizes. * * Use for: lockup calculations, on-chain comparisons. @@ -29,7 +32,7 @@ export namespace calculateEffectiveRate { /** * Rate per month — preserves precision before epoch division. * - * Computed as `(totalBytes * pricePerTiBPerMonth) / TiB` (one fewer + * Computed as `(totalBytes * storagePerTibPerMonth) / TiB` (one fewer * division than ratePerEpoch), so it retains more precision and scales * linearly with size, making it suitable for display and cost estimation. * @@ -44,40 +47,34 @@ export namespace calculateEffectiveRate { } /** - * Mirror the contract's `_calculateStorageRate` with floor pricing. + * Calculate the expected FWSS recurring rate for a dataset size. * * Returns two rates for different use cases: * - `ratePerEpoch` — matches the on-chain rail rate (use for lockup math) * - `ratePerMonth` — higher precision, linearly scalable (use for display) * - * The contract multiplies `totalBytes * pricePerTiBPerMonth` before dividing - * by `TiB * EPOCHS_PER_MONTH` in a single step, so `ratePerEpoch` depends on - * the total size and cannot be scaled to estimate other sizes. `ratePerMonth` - * avoids the epoch division, preserving that scalability. - * - * On-chain reference: - * - `_calculateStorageRate`: {@link https://github.com/FilOzone/filecoin-services/blob/053885eba807ed40a0e834c080606f4286ab4ef2/service_contracts/src/FilecoinWarmStorageService.sol#L1388-L1397} - * - `calculateStorageSizeBasedRatePerEpoch`: {@link https://github.com/FilOzone/filecoin-services/blob/053885eba807ed40a0e834c080606f4286ab4ef2/service_contracts/src/FilecoinWarmStorageService.sol#L1349-L1370} + * Empty datasets have no recurring rate. Non-empty datasets pay the + * size-based storage rate plus the per-dataset proving service fee. * * @param params - {@link calculateEffectiveRate.ParamsType} * @returns {@link calculateEffectiveRate.OutputType} */ export function calculateEffectiveRate(params: calculateEffectiveRate.ParamsType): calculateEffectiveRate.OutputType { - const { sizeInBytes, pricePerTiBPerMonth, minimumPricePerMonth, epochsPerMonth } = params + const { sizeInBytes, storagePerTibPerMonth, datasetFeePerMonth, epochsPerMonth } = params + + if (sizeInBytes === 0n) { + return { ratePerEpoch: 0n, ratePerMonth: 0n } + } // One division (by TiB only) — preserves precision, linearly scalable with size - const naturalPerMonth = (pricePerTiBPerMonth * sizeInBytes) / SIZE_CONSTANTS.TiB + const storagePerMonth = (storagePerTibPerMonth * sizeInBytes) / SIZE_CONSTANTS.TiB // Two-factor division (by TiB * epochs) — matches contract's single-step division, // truncation is size-dependent so this value is only valid for this exact sizeInBytes - const naturalPerEpoch = (pricePerTiBPerMonth * sizeInBytes) / (SIZE_CONSTANTS.TiB * epochsPerMonth) - - // Floor rate per epoch - const minimumPerEpoch = minimumPricePerMonth / epochsPerMonth + const storagePerEpoch = (storagePerTibPerMonth * sizeInBytes) / (SIZE_CONSTANTS.TiB * epochsPerMonth) - // Apply floor pricing - const ratePerMonth = naturalPerMonth > minimumPricePerMonth ? naturalPerMonth : minimumPricePerMonth - const ratePerEpoch = naturalPerEpoch > minimumPerEpoch ? naturalPerEpoch : minimumPerEpoch + const ratePerMonth = storagePerMonth + datasetFeePerMonth + const ratePerEpoch = storagePerEpoch + datasetFeePerMonth / epochsPerMonth return { ratePerEpoch, ratePerMonth } } diff --git a/packages/synapse-core/src/warm-storage/calculate-upload-fees.ts b/packages/synapse-core/src/warm-storage/calculate-upload-fees.ts new file mode 100644 index 000000000..3126adf00 --- /dev/null +++ b/packages/synapse-core/src/warm-storage/calculate-upload-fees.ts @@ -0,0 +1,48 @@ +import { SIZE_CONSTANTS } from '../utils/constants.ts' +import type { getPriceList } from './price-list.ts' + +export namespace calculateUploadFees { + export type ParamsType = { + priceList: getPriceList.OutputType + isNewDataSet: boolean + /** Number of pieces added by this upload. Defaults to 1. */ + pieceCount?: bigint + } + + export type OutputType = { + createDataSetFee: bigint + addPiecesFee: bigint + total: bigint + } +} + +/** + * Compute the one-time fees an upload incurs. + * + * Scope is intentionally limited to upload-time fees: create-data-set (new + * datasets only) and add-pieces. Schedule-removals, terminate, and delete are + * post-upload lifecycle operations and are not part of an upload cost preview. + * + * The number of addPieces operations is derived from `pieceCount` and the + * `MAX_ADD_PIECES_BATCH_SIZE` batch limit: a single addPieces call cannot + * exceed the limit, so `pieceCount` pieces span `ceil(pieceCount / limit)` + * calls, each charged the base fee. + * + * @param params - {@link calculateUploadFees.ParamsType} + * @returns {@link calculateUploadFees.OutputType} + */ +export function calculateUploadFees(params: calculateUploadFees.ParamsType): calculateUploadFees.OutputType { + const pieceCount = params.pieceCount ?? 1n + const maxBatch = BigInt(SIZE_CONSTANTS.MAX_ADD_PIECES_BATCH_SIZE) + const addPiecesOperationCount = (pieceCount + maxBatch - 1n) / maxBatch + const createDataSetFee = params.isNewDataSet ? params.priceList.fees.createDataSetFee : 0n + const addPiecesFee = + params.priceList.fees.addPiecesBaseFee * addPiecesOperationCount + + params.priceList.fees.addPiecesPerPieceFee * pieceCount + + return { + createDataSetFee, + addPiecesFee, + total: createDataSetFee + addPiecesFee, + } +} diff --git a/packages/synapse-core/src/warm-storage/get-service-price.ts b/packages/synapse-core/src/warm-storage/get-service-price.ts deleted file mode 100644 index 7872d5e4a..000000000 --- a/packages/synapse-core/src/warm-storage/get-service-price.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { Simplify } from 'type-fest' -import type { - Address, - Chain, - Client, - ContractFunctionParameters, - ContractFunctionReturnType, - ReadContractErrorType, - Transport, -} from 'viem' -import { readContract } from 'viem/actions' -import type { fwss as storageAbi } from '../abis/index.ts' -import { asChain } from '../chains.ts' -import type { ActionCallChain } from '../types.ts' - -export namespace getServicePrice { - export type OptionsType = { - /** Warm storage contract address. If not provided, the default is the storage contract address for the chain. */ - contractAddress?: Address - } - - export type ContractOutputType = ContractFunctionReturnType - - /** - * The service price for the warm storage. - */ - export type OutputType = { - /** Price per TiB per month without CDN (in base units) */ - pricePerTiBPerMonthNoCDN: bigint - /** CDN egress price per TiB (usage-based, in base units) */ - pricePerTiBCdnEgress: bigint - /** Cache miss egress price per TiB (usage-based, in base units) */ - pricePerTiBCacheMissEgress: bigint - /** Token address for payments */ - tokenAddress: Address - /** Number of epochs per month */ - epochsPerMonth: bigint - /** Minimum monthly charge for any dataset size (in base units) */ - minimumPricePerMonth: bigint - } - - export type ErrorType = asChain.ErrorType | ReadContractErrorType -} - -/** - * Get the service price for the warm storage - * - * @param client - The client to use to get the service price. - * @param options - {@link getServicePrice.OptionsType} - * @returns The service price {@link getServicePrice.OutputType} - * @throws Errors {@link getServicePrice.ErrorType} - * - * @example - * ```ts - * import { getServicePrice } from '@filoz/synapse-core/warm-storage' - * import { createPublicClient, http } from 'viem' - * import { calibration } from '@filoz/synapse-core/chains' - * - * const client = createPublicClient({ - * chain: calibration, - * transport: http(), - * }) - * - * const price = await getServicePrice(client, {}) - * - * console.log(price.pricePerTiBPerMonthNoCDN) - * ``` - */ -export async function getServicePrice( - client: Client, - options: getServicePrice.OptionsType = {} -): Promise { - const data = await readContract( - client, - getServicePriceCall({ - chain: client.chain, - contractAddress: options.contractAddress, - }) - ) - return data -} - -export namespace getServicePriceCall { - export type OptionsType = Simplify - export type ErrorType = asChain.ErrorType - export type OutputType = ContractFunctionParameters -} - -/** - * Create a call to the getServicePrice function - * - * This function is used to create a call to the getServicePrice function for use with the multicall or readContract function. - * - * @param options - {@link getServicePriceCall.OptionsType} - * @returns The call to the getServicePrice function {@link getServicePriceCall.OutputType} - * @throws Errors {@link getServicePriceCall.ErrorType} - * - * @example - * ```ts - * import { getServicePriceCall } from '@filoz/synapse-core/warm-storage' - * import { createPublicClient, http } from 'viem' - * import { multicall } from 'viem/actions' - * import { calibration } from '@filoz/synapse-core/chains' - * - * const client = createPublicClient({ - * chain: calibration, - * transport: http(), - * }) - * - * const results = await multicall(client, { - * contracts: [ - * getServicePriceCall({ chain: calibration }), - * ], - * }) - * - * console.log(results[0]) - * ``` - */ -export function getServicePriceCall(options: getServicePriceCall.OptionsType) { - const chain = asChain(options.chain) - return { - abi: chain.contracts.fwss.abi, - address: options.contractAddress ?? chain.contracts.fwss.address, - functionName: 'getServicePrice', - args: [], - } satisfies getServicePriceCall.OutputType -} diff --git a/packages/synapse-core/src/warm-storage/get-upload-costs.ts b/packages/synapse-core/src/warm-storage/get-upload-costs.ts index fcc0fc95d..0cd4ebe3b 100644 --- a/packages/synapse-core/src/warm-storage/get-upload-costs.ts +++ b/packages/synapse-core/src/warm-storage/get-upload-costs.ts @@ -4,10 +4,11 @@ import { calculateAccountDebt } from '../pay/account-debt.ts' import { accounts } from '../pay/accounts.ts' import { isFwssMaxApproved } from '../pay/is-fwss-max-approved.ts' import { resolveAccountState } from '../pay/resolve-account-state.ts' -import { DEFAULT_BUFFER_EPOCHS, DEFAULT_RUNWAY_EPOCHS, LOCKUP_PERIOD } from '../utils/constants.ts' +import { DEFAULT_BUFFER_EPOCHS, DEFAULT_RUNWAY_EPOCHS, TIME_CONSTANTS } from '../utils/constants.ts' import { calculateDepositNeeded } from './calculate-deposit-needed.ts' import { calculateEffectiveRate } from './calculate-effective-rate.ts' -import { getServicePrice } from './get-service-price.ts' +import type { calculateUploadFees } from './calculate-upload-fees.ts' +import { getPriceList } from './price-list.ts' export namespace getUploadCosts { export type OptionsType = { @@ -23,6 +24,8 @@ export namespace getUploadCosts { /** Size of new data to upload, in bytes. */ dataSize: bigint + /** Number of pieces added by this operation. Default: 1 */ + pieceCount?: bigint /** Extra runway in epochs beyond the required lockup. */ extraRunwayEpochs?: bigint @@ -32,12 +35,20 @@ export namespace getUploadCosts { export type OutputType = { /** Effective rate for the dataset after adding dataSize bytes. */ - rate: { + rates: { /** Rate per epoch — matches on-chain PDP rail rate. */ perEpoch: bigint /** Rate per month — full precision for display. */ perMonth: bigint } + fees: calculateUploadFees.OutputType + lockups: { + lifecycleLockup: bigint + streamingLockup: bigint + cdnLockup: bigint + cacheMissLockup: bigint + total: bigint + } /** Total USDFC to deposit. 0n if sufficient funds available. */ depositNeeded: bigint /** Whether FWSS needs to be approved (or re-approved with maxUint256). */ @@ -64,13 +75,14 @@ export async function getUploadCosts( const isNewDataSet = options.isNewDataSet ?? true const withCDN = options.withCDN ?? false const currentDataSetSize = options.currentDataSetSize ?? 0n + const pieceCount = options.pieceCount ?? 1n const extraRunwayEpochs = options.extraRunwayEpochs ?? DEFAULT_RUNWAY_EPOCHS const bufferEpochs = options.bufferEpochs ?? DEFAULT_BUFFER_EPOCHS // Fetch all needed data in parallel - const [accountInfo, pricing, approved, currentEpoch] = await Promise.all([ + const [accountInfo, priceList, approved, currentEpoch] = await Promise.all([ accounts(client, { address: options.clientAddress }), - getServicePrice(client), + getPriceList(client), isFwssMaxApproved(client, { clientAddress: options.clientAddress }), getBlockNumber(client, { cacheTime: 0 }), ]) @@ -79,9 +91,9 @@ export async function getUploadCosts( const totalSize = currentDataSetSize + options.dataSize const rate = calculateEffectiveRate({ sizeInBytes: totalSize, - pricePerTiBPerMonth: pricing.pricePerTiBPerMonthNoCDN, - minimumPricePerMonth: pricing.minimumPricePerMonth, - epochsPerMonth: pricing.epochsPerMonth, + storagePerTibPerMonth: priceList.rates.storagePerTibPerMonth, + datasetFeePerMonth: priceList.rates.datasetFeePerMonth, + epochsPerMonth: TIME_CONSTANTS.EPOCHS_PER_MONTH, }) const accountParams = { @@ -94,16 +106,15 @@ export async function getUploadCosts( const debt = calculateAccountDebt(accountParams) const { availableFunds, runwayInEpochs } = resolveAccountState(accountParams) - // Calculate deposit needed - const depositNeeded = calculateDepositNeeded({ + // Deposit, plus the lockup and fee breakdowns it was computed from. + const { depositNeeded, lockup, fees } = calculateDepositNeeded({ dataSize: options.dataSize, currentDataSetSize, - pricePerTiBPerMonth: pricing.pricePerTiBPerMonthNoCDN, - minimumPricePerMonth: pricing.minimumPricePerMonth, - epochsPerMonth: pricing.epochsPerMonth, - lockupEpochs: LOCKUP_PERIOD, + priceList, + epochsPerMonth: TIME_CONSTANTS.EPOCHS_PER_MONTH, isNewDataSet, withCDN, + pieceCount, currentLockupRate: accountInfo.lockupRate, extraRunwayEpochs, debt, @@ -113,11 +124,20 @@ export async function getUploadCosts( }) const needsFwssMaxApproval = !approved + const rates = { + perEpoch: rate.ratePerEpoch, + perMonth: rate.ratePerMonth, + } return { - rate: { - perEpoch: rate.ratePerEpoch, - perMonth: rate.ratePerMonth, + rates, + fees, + lockups: { + lifecycleLockup: lockup.lifecycleLockup, + streamingLockup: lockup.streamingLockup, + cdnLockup: lockup.cdnLockup, + cacheMissLockup: lockup.cacheMissLockup, + total: lockup.total, }, depositNeeded, needsFwssMaxApproval, diff --git a/packages/synapse-core/src/warm-storage/index.ts b/packages/synapse-core/src/warm-storage/index.ts index 2b1378342..dc29b5bf6 100644 --- a/packages/synapse-core/src/warm-storage/index.ts +++ b/packages/synapse-core/src/warm-storage/index.ts @@ -13,6 +13,7 @@ export * from './add-approved-provider.ts' export * from './calculate-additional-lockup-required.ts' export * from './calculate-deposit-needed.ts' export * from './calculate-effective-rate.ts' +export * from './calculate-upload-fees.ts' export * from './fetch-provider-selection-input.ts' export * from './find-matching-data-sets.ts' export * from './get-account-total-storage-size.ts' @@ -25,9 +26,9 @@ export * from './get-client-data-sets-length.ts' export * from './get-data-set.ts' export * from './get-pdp-data-set.ts' export * from './get-pdp-data-sets.ts' -export * from './get-service-price.ts' export * from './get-upload-costs.ts' export * from './location-types.ts' +export * from './price-list.ts' export * from './read-addresses.ts' export * from './remove-approved-provider.ts' export * from './select-providers.ts' diff --git a/packages/synapse-core/src/warm-storage/price-list.ts b/packages/synapse-core/src/warm-storage/price-list.ts new file mode 100644 index 000000000..68b900546 --- /dev/null +++ b/packages/synapse-core/src/warm-storage/price-list.ts @@ -0,0 +1,168 @@ +import type { Simplify } from 'type-fest' +import type { + Address, + Chain, + Client, + ContractFunctionParameters, + ContractFunctionReturnType, + ReadContractErrorType, + Transport, +} from 'viem' +import { readContract } from 'viem/actions' +import type { fwssView as fwssViewAbi } from '../abis/index.ts' +import { asChain } from '../chains.ts' +import type { ActionCallChain } from '../types.ts' + +export namespace getPriceList { + export type OptionsType = { + /** Warm storage view contract address. Defaults to the chain's view contract. */ + contractAddress?: Address + } + + export type ContractOutputType = ContractFunctionReturnType + + /** + * The canonical warm storage price list. Matches the on-chain `PriceList` + * struct from `FilecoinWarmStorageServiceStateView.getPriceList()`. + * Amounts are in the token's smallest unit; rates are per-month (divide by + * `EPOCHS_PER_MONTH` for per-epoch values). + */ + export type OutputType = { + token: Address + rates: { + storagePerTibPerMonth: bigint + datasetFeePerMonth: bigint + cdnEgressPerTib: bigint + cacheMissEgressPerTib: bigint + } + fees: { + createDataSetFee: bigint + addPiecesBaseFee: bigint + addPiecesPerPieceFee: bigint + schedulePieceRemovalsFee: bigint + terminateFee: bigint + } + lockups: { + lifecycleReserveTarget: bigint + replenishThreshold: bigint + defaultLockupPeriod: bigint + cdnLockupAmount: bigint + cacheMissLockupAmount: bigint + cdnLockupPeriod: bigint + } + } + + export type ErrorType = asChain.ErrorType | ReadContractErrorType +} + +/** + * Read the warm storage price list. + * + * Reads the `getPriceList()` view on `FilecoinWarmStorageServiceStateView`. + * + * @param client - The client to use to read the price list. + * @param options - {@link getPriceList.OptionsType} + * @returns The price list {@link getPriceList.OutputType} + * @throws Errors {@link getPriceList.ErrorType} + * + * @example + * ```ts + * import { getPriceList } from '@filoz/synapse-core/warm-storage' + * import { createPublicClient, http } from 'viem' + * import { calibration } from '@filoz/synapse-core/chains' + * + * const client = createPublicClient({ + * chain: calibration, + * transport: http(), + * }) + * + * const priceList = await getPriceList(client) + * + * console.log(priceList.rates.storagePerTibPerMonth) + * ``` + */ +export async function getPriceList( + client: Client, + options: getPriceList.OptionsType = {} +): Promise { + const list = await readContract( + client, + getPriceListCall({ + chain: client.chain, + contractAddress: options.contractAddress, + }) + ) + + // Map into a fresh object so callers can't corrupt later reads and the shape + // is pinned to OutputType independent of the generated ABI tuple type. + return { + token: list.token, + rates: { + storagePerTibPerMonth: list.rates.storagePerTibPerMonth, + datasetFeePerMonth: list.rates.datasetFeePerMonth, + cdnEgressPerTib: list.rates.cdnEgressPerTib, + cacheMissEgressPerTib: list.rates.cacheMissEgressPerTib, + }, + fees: { + createDataSetFee: list.fees.createDataSetFee, + addPiecesBaseFee: list.fees.addPiecesBaseFee, + addPiecesPerPieceFee: list.fees.addPiecesPerPieceFee, + schedulePieceRemovalsFee: list.fees.schedulePieceRemovalsFee, + terminateFee: list.fees.terminateFee, + }, + lockups: { + lifecycleReserveTarget: list.lockups.lifecycleReserveTarget, + replenishThreshold: list.lockups.replenishThreshold, + defaultLockupPeriod: list.lockups.defaultLockupPeriod, + cdnLockupAmount: list.lockups.cdnLockupAmount, + cacheMissLockupAmount: list.lockups.cacheMissLockupAmount, + cdnLockupPeriod: list.lockups.cdnLockupPeriod, + }, + } +} + +export namespace getPriceListCall { + export type OptionsType = Simplify + export type ErrorType = asChain.ErrorType + export type OutputType = ContractFunctionParameters +} + +/** + * Create a call to the getPriceList function + * + * This function is used to create a call to the getPriceList function for use with the multicall or readContract function. + * + * @param options - {@link getPriceListCall.OptionsType} + * @returns The call to the getPriceList function {@link getPriceListCall.OutputType} + * @throws Errors {@link getPriceListCall.ErrorType} + * + * @example + * ```ts + * import { getPriceListCall } from '@filoz/synapse-core/warm-storage' + * import { createPublicClient, http } from 'viem' + * import { multicall } from 'viem/actions' + * import { calibration } from '@filoz/synapse-core/chains' + * + * const client = createPublicClient({ + * chain: calibration, + * transport: http(), + * }) + * + * const results = await multicall(client, { + * contracts: [ + * getPriceListCall({ chain: calibration }), + * ], + * }) + * + * console.log(results[0]) + * ``` + */ +export function getPriceListCall(options: getPriceListCall.OptionsType) { + const chain = asChain(options.chain) + return { + abi: chain.contracts.fwssView.abi, + address: options.contractAddress ?? chain.contracts.fwssView.address, + functionName: 'getPriceList', + args: [], + } satisfies getPriceListCall.OutputType +} diff --git a/packages/synapse-core/test/calculate-additional-lockup-required.test.ts b/packages/synapse-core/test/calculate-additional-lockup-required.test.ts index 6a60f11f2..fe949852e 100644 --- a/packages/synapse-core/test/calculate-additional-lockup-required.test.ts +++ b/packages/synapse-core/test/calculate-additional-lockup-required.test.ts @@ -1,92 +1,147 @@ /* globals describe it */ import assert from 'assert' -import { USDFC_SYBIL_FEE } from '../src/utils/constants.ts' import { calculateAdditionalLockupRequired } from '../src/warm-storage/calculate-additional-lockup-required.ts' +import { calculateEffectiveRate } from '../src/warm-storage/calculate-effective-rate.ts' +import type { getPriceList } from '../src/warm-storage/price-list.ts' -const pricing = { - pricePerTiBPerMonth: 2_500_000_000_000_000_000n, // 2.5 USDFC - minimumPricePerMonth: 60_000_000_000_000_000n, // 0.06 USDFC - epochsPerMonth: 86400n, -} +const priceList = { + token: '0x0000000000000000000000000000000000000001', + rates: { + storagePerTibPerMonth: 2_500_000_000_000_000_000n, + datasetFeePerMonth: 24_000_000_000_000_000n, + cdnEgressPerTib: 0n, + cacheMissEgressPerTib: 0n, + }, + fees: { + createDataSetFee: 25_000_000_000_000_000n, + addPiecesBaseFee: 500_000_000_000_000n, + addPiecesPerPieceFee: 300_000_000_000_000n, + schedulePieceRemovalsFee: 2_000_000_000_000_000n, + terminateFee: 1_120_000_000_000_000n, + }, + lockups: { + lifecycleReserveTarget: 100_000_000_000_000_000n, + replenishThreshold: 5_000_000_000_000_000n, + defaultLockupPeriod: 86_400n, + cdnLockupAmount: 700_000_000_000_000_000n, + cacheMissLockupAmount: 300_000_000_000_000_000n, + cdnLockupPeriod: 14_400n, + }, +} satisfies getPriceList.OutputType const lockupEpochs = 86400n // 30 days describe('calculateAdditionalLockupRequired', () => { - it('new dataset without CDN: no CDN fixed lockup', () => { + it('new dataset without CDN: includes lifecycle lockup only', () => { const result = calculateAdditionalLockupRequired({ dataSize: 1000n, currentDataSetSize: 0n, - ...pricing, + priceList, lockupEpochs, isNewDataSet: true, withCDN: false, }) - assert.equal(result.cdnFixedLockup, 0n) - assert.equal(result.sybilFee, USDFC_SYBIL_FEE) - // For a small file, should use floor rate - const minimumPerEpoch = pricing.minimumPricePerMonth / pricing.epochsPerMonth - assert.equal(result.rateDeltaPerEpoch, minimumPerEpoch) - assert.equal(result.rateLockupDelta, minimumPerEpoch * lockupEpochs) - assert.equal(result.total, result.rateLockupDelta + result.sybilFee) + // Additive model: rate delta for a new dataset is the storage rate for the + // added bytes plus the proving service rate. + const expectedRatePerEpoch = calculateEffectiveRate({ + sizeInBytes: 1000n, + storagePerTibPerMonth: priceList.rates.storagePerTibPerMonth, + datasetFeePerMonth: priceList.rates.datasetFeePerMonth, + epochsPerMonth: 86400n, + }).ratePerEpoch + assert.equal(result.lifecycleLockup, priceList.lockups.lifecycleReserveTarget) + assert.equal(result.cdnLockup, 0n) + assert.equal(result.cacheMissLockup, 0n) + assert.equal(result.rateDeltaPerEpoch, expectedRatePerEpoch) + assert.equal(result.streamingLockup, expectedRatePerEpoch * lockupEpochs) + assert.equal(result.total, result.streamingLockup + result.lifecycleLockup) }) - it('new dataset with CDN: includes CDN fixed lockup of 1 USDFC', () => { + it('new dataset with CDN: includes CDN and cache-miss lockups', () => { const result = calculateAdditionalLockupRequired({ dataSize: 1000n, currentDataSetSize: 0n, - ...pricing, + priceList, lockupEpochs, isNewDataSet: true, withCDN: true, }) - const cdnFixedLockup = 1_000_000_000_000_000_000n // 1 USDFC - assert.equal(result.cdnFixedLockup, cdnFixedLockup) - assert.equal(result.sybilFee, USDFC_SYBIL_FEE) - assert.equal(result.total, result.rateLockupDelta + cdnFixedLockup + result.sybilFee) + assert.equal(result.lifecycleLockup, priceList.lockups.lifecycleReserveTarget) + assert.equal(result.cdnLockup, priceList.lockups.cdnLockupAmount) + assert.equal(result.cacheMissLockup, priceList.lockups.cacheMissLockupAmount) + assert.equal( + result.total, + result.streamingLockup + result.lifecycleLockup + result.cdnLockup + result.cacheMissLockup + ) }) - it('existing dataset floor-to-floor: rate delta = 0 when both sizes are below floor', () => { - // Both 100 bytes and 200 bytes are well below floor threshold + it('existing dataset keeps the proving rate and only locks up storage delta', () => { const result = calculateAdditionalLockupRequired({ dataSize: 100n, currentDataSetSize: 100n, - ...pricing, + priceList, lockupEpochs, isNewDataSet: false, withCDN: false, }) - // Both sizes produce floor rate, so delta = 0 - assert.equal(result.rateDeltaPerEpoch, 0n) - assert.equal(result.rateLockupDelta, 0n) - assert.equal(result.cdnFixedLockup, 0n) - assert.equal(result.sybilFee, 0n) - assert.equal(result.total, 0n) + // Proving rate cancels between current and new size; only the storage rate + // delta for the added bytes is locked up. + const rateParams = { + storagePerTibPerMonth: priceList.rates.storagePerTibPerMonth, + datasetFeePerMonth: priceList.rates.datasetFeePerMonth, + epochsPerMonth: 86400n, + } + const expectedDelta = + calculateEffectiveRate({ ...rateParams, sizeInBytes: 200n }).ratePerEpoch - + calculateEffectiveRate({ ...rateParams, sizeInBytes: 100n }).ratePerEpoch + assert.ok(expectedDelta > 0n) + assert.equal(result.rateDeltaPerEpoch, expectedDelta) + assert.equal(result.streamingLockup, expectedDelta * lockupEpochs) + assert.equal(result.lifecycleLockup, 0n) + assert.equal(result.cdnLockup, 0n) + assert.equal(result.cacheMissLockup, 0n) + assert.equal(result.total, result.streamingLockup) }) - it('existing dataset crossing floor threshold: rate delta > 0', () => { + it('existing dataset with added storage has a positive rate delta', () => { const TiB = 1n << 40n - // Start with 0 (treated as new since isNewDataSet=false but currentDataSetSize=0 - // triggers the else branch... actually currentDataSetSize > 0n check fails so it - // goes to the else branch). Use a large currentDataSetSize instead. + // Non-zero existing dataset size so the existing-dataset delta path runs. const result = calculateAdditionalLockupRequired({ dataSize: TiB, - currentDataSetSize: 1n, // tiny existing dataset at floor - ...pricing, + currentDataSetSize: 1n, + priceList, lockupEpochs, isNewDataSet: false, withCDN: false, }) - // Adding 1 TiB to a 1-byte dataset: new rate will be well above floor - // while current rate is at the floor, so delta should be positive assert.ok(result.rateDeltaPerEpoch > 0n) - assert.equal(result.rateLockupDelta, result.rateDeltaPerEpoch * lockupEpochs) - assert.equal(result.cdnFixedLockup, 0n) - assert.equal(result.sybilFee, 0n) - assert.equal(result.total, result.rateLockupDelta) + assert.equal(result.streamingLockup, result.rateDeltaPerEpoch * lockupEpochs) + assert.equal(result.lifecycleLockup, 0n) + assert.equal(result.cdnLockup, 0n) + assert.equal(result.cacheMissLockup, 0n) + assert.equal(result.total, result.streamingLockup) + }) + + it('sources the lockup period from priceList.lockups.defaultLockupPeriod when lockupEpochs is omitted', () => { + const customPeriod = 1234n + const customPriceList = { + ...priceList, + lockups: { ...priceList.lockups, defaultLockupPeriod: customPeriod }, + } + + const result = calculateAdditionalLockupRequired({ + dataSize: 1000n, + currentDataSetSize: 0n, + priceList: customPriceList, + isNewDataSet: true, + withCDN: false, + }) + + assert.equal(result.streamingLockup, result.rateDeltaPerEpoch * customPeriod) }) }) diff --git a/packages/synapse-core/test/calculate-deposit-needed.test.ts b/packages/synapse-core/test/calculate-deposit-needed.test.ts index 05ce1bc41..0e20b3075 100644 --- a/packages/synapse-core/test/calculate-deposit-needed.test.ts +++ b/packages/synapse-core/test/calculate-deposit-needed.test.ts @@ -7,6 +7,32 @@ import { calculateDepositNeeded, calculateRunwayAmount, } from '../src/warm-storage/calculate-deposit-needed.ts' +import type { getPriceList } from '../src/warm-storage/price-list.ts' + +const priceList = { + token: '0x0000000000000000000000000000000000000001', + rates: { + storagePerTibPerMonth: 2_500_000_000_000_000_000n, + datasetFeePerMonth: 24_000_000_000_000_000n, + cdnEgressPerTib: 0n, + cacheMissEgressPerTib: 0n, + }, + fees: { + createDataSetFee: 25_000_000_000_000_000n, + addPiecesBaseFee: 500_000_000_000_000n, + addPiecesPerPieceFee: 300_000_000_000_000n, + schedulePieceRemovalsFee: 2_000_000_000_000_000n, + terminateFee: 1_120_000_000_000_000n, + }, + lockups: { + lifecycleReserveTarget: 100_000_000_000_000_000n, + replenishThreshold: 5_000_000_000_000_000n, + defaultLockupPeriod: 86_400n, + cdnLockupAmount: 700_000_000_000_000_000n, + cacheMissLockupAmount: 300_000_000_000_000_000n, + cdnLockupPeriod: 14_400n, + }, +} satisfies getPriceList.OutputType describe('calculateRunwayAmount', () => { it('computes netRateAfterUpload * extraRunwayEpochs', () => { @@ -90,17 +116,11 @@ describe('calculateBufferAmount', () => { }) describe('calculateDepositNeeded', () => { - const pricing = { - pricePerTiBPerMonth: 2_500_000_000_000_000_000n, - minimumPricePerMonth: 60_000_000_000_000_000n, - epochsPerMonth: 86400n, - } - it('healthy account, no debt, sufficient funds: returns 0', () => { const result = calculateDepositNeeded({ dataSize: 1000n, currentDataSetSize: 0n, - ...pricing, + priceList, lockupEpochs: 86400n, isNewDataSet: true, withCDN: false, @@ -112,14 +132,14 @@ describe('calculateDepositNeeded', () => { bufferEpochs: 10n, }) - assert.equal(result, 0n) + assert.equal(result.depositNeeded, 0n) }) it('new dataset + no existing rails: buffer skipped', () => { const base = { dataSize: 1000n, currentDataSetSize: 0n, - ...pricing, + priceList, lockupEpochs: 86400n, isNewDataSet: true, withCDN: false, @@ -134,15 +154,15 @@ describe('calculateDepositNeeded', () => { const withoutBuffer = calculateDepositNeeded({ ...base, bufferEpochs: 0n }) // No existing rails (currentLockupRate=0) + new dataset, buffer skipped - assert.equal(withBuffer, withoutBuffer) - assert.ok(withBuffer > 0n) // still requires the lockup deposit + assert.equal(withBuffer.depositNeeded, withoutBuffer.depositNeeded) + assert.ok(withBuffer.depositNeeded > 0n) // still requires the lockup deposit }) it('new dataset + existing rails: buffer still applies', () => { const base = { dataSize: 1000n, currentDataSetSize: 0n, - ...pricing, + priceList, lockupEpochs: 86400n, isNewDataSet: true, withCDN: false, @@ -157,7 +177,7 @@ describe('calculateDepositNeeded', () => { const withoutBuffer = calculateDepositNeeded({ ...base, bufferEpochs: 0n }) // Existing rails draining, buffer must apply even for new dataset - assert.ok(withBuffer > withoutBuffer) + assert.ok(withBuffer.depositNeeded > withoutBuffer.depositNeeded) }) it('underfunded account with debt: includes debt in deposit', () => { @@ -165,7 +185,7 @@ describe('calculateDepositNeeded', () => { const result = calculateDepositNeeded({ dataSize: 1000n, currentDataSetSize: 0n, - ...pricing, + priceList, lockupEpochs: 86400n, isNewDataSet: true, withCDN: false, @@ -177,7 +197,8 @@ describe('calculateDepositNeeded', () => { bufferEpochs: 10n, }) - // Result should include the debt - assert.ok(result >= debt) + // Result should include the debt, and the deposit covers debt + fees + lockup. + assert.ok(result.fees.total > 0n) + assert.ok(result.depositNeeded >= debt + result.fees.total + result.lockup.total) }) }) diff --git a/packages/synapse-core/test/calculate-effective-rate.test.ts b/packages/synapse-core/test/calculate-effective-rate.test.ts index 7e4db4314..cb43d5e27 100644 --- a/packages/synapse-core/test/calculate-effective-rate.test.ts +++ b/packages/synapse-core/test/calculate-effective-rate.test.ts @@ -4,42 +4,51 @@ import assert from 'assert' import { calculateEffectiveRate } from '../src/warm-storage/calculate-effective-rate.ts' const TiB = 1n << 40n -const pricePerTiBPerMonth = 2_500_000_000_000_000_000n // 2.5 USDFC -const minimumPricePerMonth = 60_000_000_000_000_000n // 0.06 USDFC +const storagePerTibPerMonth = 2_500_000_000_000_000_000n // 2.5 USDFC +const datasetFeePerMonth = 24_000_000_000_000_000n // 0.024 USDFC const epochsPerMonth = 86400n describe('calculateEffectiveRate', () => { - it('floor pricing: tiny file uses the minimum rate', () => { + it('empty dataset has no recurring rate', () => { + const result = calculateEffectiveRate({ + sizeInBytes: 0n, + storagePerTibPerMonth, + datasetFeePerMonth, + epochsPerMonth, + }) + + assert.equal(result.ratePerEpoch, 0n) + assert.equal(result.ratePerMonth, 0n) + }) + + it('tiny non-empty dataset pays storage plus the proving service rate', () => { const result = calculateEffectiveRate({ sizeInBytes: 1n, - pricePerTiBPerMonth, - minimumPricePerMonth, + storagePerTibPerMonth, + datasetFeePerMonth, epochsPerMonth, }) - // naturalPerEpoch = (2.5e18 * 1) / (TiB * 86400) = 0 (truncated to 0) - // minimumPerEpoch = 60_000_000_000_000_000 / 86400 = 694_444_444_444 - const minimumPerEpoch = minimumPricePerMonth / epochsPerMonth - assert.equal(result.ratePerEpoch, minimumPerEpoch) - assert.equal(result.ratePerMonth, minimumPricePerMonth) + // Additive: even a 1-byte dataset pays a (tiny) storage rate on top of proving. + const storagePerEpoch = (storagePerTibPerMonth * 1n) / (TiB * epochsPerMonth) + const storagePerMonth = (storagePerTibPerMonth * 1n) / TiB + assert.equal(result.ratePerEpoch, storagePerEpoch + datasetFeePerMonth / epochsPerMonth) + assert.equal(result.ratePerMonth, storagePerMonth + datasetFeePerMonth) }) - it('above floor: large file natural rate exceeds minimum', () => { + it('large dataset pays storage plus proving service rate', () => { const result = calculateEffectiveRate({ sizeInBytes: TiB, - pricePerTiBPerMonth, - minimumPricePerMonth, + storagePerTibPerMonth, + datasetFeePerMonth, epochsPerMonth, }) - // naturalPerMonth = (2.5e18 * TiB) / TiB = 2.5e18 - // naturalPerEpoch = (2.5e18 * TiB) / (TiB * 86400) = 2.5e18 / 86400 - const expectedPerMonth = pricePerTiBPerMonth - const expectedPerEpoch = pricePerTiBPerMonth / epochsPerMonth + const expectedPerMonth = storagePerTibPerMonth + datasetFeePerMonth + const expectedPerEpoch = storagePerTibPerMonth / epochsPerMonth + datasetFeePerMonth / epochsPerMonth assert.equal(result.ratePerMonth, expectedPerMonth) assert.equal(result.ratePerEpoch, expectedPerEpoch) - assert.ok(result.ratePerEpoch > minimumPricePerMonth / epochsPerMonth) }) it('precision: perMonth !== perEpoch * epochsPerMonth due to truncation', () => { @@ -48,8 +57,8 @@ describe('calculateEffectiveRate', () => { const result = calculateEffectiveRate({ sizeInBytes, - pricePerTiBPerMonth, - minimumPricePerMonth, + storagePerTibPerMonth, + datasetFeePerMonth, epochsPerMonth, }) diff --git a/packages/synapse-core/test/calculate-upload-fees.test.ts b/packages/synapse-core/test/calculate-upload-fees.test.ts new file mode 100644 index 000000000..73b8356a9 --- /dev/null +++ b/packages/synapse-core/test/calculate-upload-fees.test.ts @@ -0,0 +1,58 @@ +/* globals describe it */ + +import assert from 'assert' +import { SIZE_CONSTANTS } from '../src/utils/constants.ts' +import { calculateUploadFees } from '../src/warm-storage/calculate-upload-fees.ts' + +const priceList = { + token: '0x00000000000000000000000000000000000000aa' as const, + rates: { + storagePerTibPerMonth: 0n, + datasetFeePerMonth: 0n, + cdnEgressPerTib: 0n, + cacheMissEgressPerTib: 0n, + }, + fees: { + createDataSetFee: 100n, + addPiecesBaseFee: 10n, + addPiecesPerPieceFee: 1n, + schedulePieceRemovalsFee: 0n, + terminateFee: 0n, + }, + lockups: { + lifecycleReserveTarget: 0n, + replenishThreshold: 0n, + defaultLockupPeriod: 0n, + cdnLockupAmount: 0n, + cacheMissLockupAmount: 0n, + cdnLockupPeriod: 0n, + }, +} + +const maxBatch = BigInt(SIZE_CONSTANTS.MAX_ADD_PIECES_BATCH_SIZE) + +describe('calculateUploadFees', () => { + it('charges the create fee only for new datasets', () => { + const existing = calculateUploadFees({ priceList, isNewDataSet: false }) + const fresh = calculateUploadFees({ priceList, isNewDataSet: true }) + + assert.equal(existing.createDataSetFee, 0n) + assert.equal(fresh.createDataSetFee, priceList.fees.createDataSetFee) + }) + + it('derives addPieces operation count from the batch limit when not provided', () => { + // One full batch is a single addPieces operation. + const oneBatch = calculateUploadFees({ priceList, isNewDataSet: false, pieceCount: maxBatch }) + assert.equal( + oneBatch.addPiecesFee, + priceList.fees.addPiecesBaseFee + priceList.fees.addPiecesPerPieceFee * maxBatch + ) + + // One piece over the limit spills into a second operation. + const spill = calculateUploadFees({ priceList, isNewDataSet: false, pieceCount: maxBatch + 1n }) + assert.equal( + spill.addPiecesFee, + priceList.fees.addPiecesBaseFee * 2n + priceList.fees.addPiecesPerPieceFee * (maxBatch + 1n) + ) + }) +}) diff --git a/packages/synapse-core/test/fund.test.ts b/packages/synapse-core/test/fund.test.ts index c2773cd40..ae63abbd2 100644 --- a/packages/synapse-core/test/fund.test.ts +++ b/packages/synapse-core/test/fund.test.ts @@ -5,7 +5,10 @@ import { privateKeyToAccount } from 'viem/accounts' import { calibration } from '../src/chains.ts' import { JSONRPC, PRIVATE_KEYS, presets } from '../src/mocks/jsonrpc/index.ts' import { fund } from '../src/pay/fund.ts' -import { LOCKUP_PERIOD } from '../src/utils/constants.ts' +import { TIME_CONSTANTS } from '../src/utils/constants.ts' + +// Matches the lockup period returned by the basic preset's getPriceList mock. +const CHAIN_LOCKUP_PERIOD = TIME_CONSTANTS.DEFAULT_LOCKUP_DAYS * TIME_CONSTANTS.EPOCHS_PER_DAY describe('fund', () => { const server = setup() @@ -107,7 +110,7 @@ describe('fund', () => { }, payments: { ...presets.basic.payments, - operatorApprovals: () => [true, maxUint256, maxUint256, 0n, 0n, LOCKUP_PERIOD], + operatorApprovals: () => [true, maxUint256, maxUint256, 0n, 0n, CHAIN_LOCKUP_PERIOD], depositWithPermit: () => { depositWithPermitCalled = true return [] @@ -136,7 +139,7 @@ describe('fund', () => { ...presets.basic, payments: { ...presets.basic.payments, - operatorApprovals: () => [true, maxUint256, maxUint256, 0n, 0n, LOCKUP_PERIOD], + operatorApprovals: () => [true, maxUint256, maxUint256, 0n, 0n, CHAIN_LOCKUP_PERIOD], }, }) ) @@ -167,7 +170,7 @@ describe('fund', () => { payments: { ...presets.basic.payments, // Even though operatorApprovals says approved, the override forces approval flow - operatorApprovals: () => [true, maxUint256, maxUint256, 0n, 0n, LOCKUP_PERIOD], + operatorApprovals: () => [true, maxUint256, maxUint256, 0n, 0n, CHAIN_LOCKUP_PERIOD], depositWithPermitAndApproveOperator: () => { depositAndApproveCalled = true return [] diff --git a/packages/synapse-core/test/get-account-total-storage-size.test.ts b/packages/synapse-core/test/get-account-total-storage-size.test.ts index c39c3b9fa..4b390c55d 100644 --- a/packages/synapse-core/test/get-account-total-storage-size.test.ts +++ b/packages/synapse-core/test/get-account-total-storage-size.test.ts @@ -144,6 +144,8 @@ function makeDataSet(dataSetId: bigint) { clientDataSetId: 0n, pdpEndEpoch: 0n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, cdnEndEpoch: 0n, dataSetId, } diff --git a/packages/synapse-core/test/get-client-data-sets.test.ts b/packages/synapse-core/test/get-client-data-sets.test.ts index 1488abe1f..6a7e9819c 100644 --- a/packages/synapse-core/test/get-client-data-sets.test.ts +++ b/packages/synapse-core/test/get-client-data-sets.test.ts @@ -321,6 +321,8 @@ function makeDataSet(dataSetId: bigint) { clientDataSetId: 0n, pdpEndEpoch: 0n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, dataSetId, } } diff --git a/packages/synapse-core/test/get-data-set.test.ts b/packages/synapse-core/test/get-data-set.test.ts index 98d2467c3..2b8863d3b 100644 --- a/packages/synapse-core/test/get-data-set.test.ts +++ b/packages/synapse-core/test/get-data-set.test.ts @@ -81,6 +81,8 @@ describe('getDataSet', () => { pdpEndEpoch: 0n, pdpRailId: 1n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, serviceProvider: ADDRESSES.serviceProvider1, }) }) diff --git a/packages/synapse-core/test/get-price-list.test.ts b/packages/synapse-core/test/get-price-list.test.ts new file mode 100644 index 000000000..3f32b0677 --- /dev/null +++ b/packages/synapse-core/test/get-price-list.test.ts @@ -0,0 +1,112 @@ +import assert from 'assert' +import { setup } from 'iso-web/msw' +import { createPublicClient, http, parseUnits } from 'viem' +import { calibration } from '../src/chains.ts' +import { JSONRPC, presets } from '../src/mocks/jsonrpc/index.ts' +import { getPriceList } from '../src/warm-storage/price-list.ts' + +describe('getPriceList', () => { + const server = setup() + + before(async () => { + await server.start() + }) + + after(() => { + server.stop() + }) + + beforeEach(() => { + server.resetHandlers() + }) + + const makeClient = () => createPublicClient({ chain: calibration, transport: http() }) + + // A full PriceList with distinct values per field so a misplumbed field is + // caught by an assertion rather than coinciding with another field's value. + const distinctPriceList = { + token: '0x00000000000000000000000000000000000000aa' as const, + rates: { + storagePerTibPerMonth: parseUnits('9.9', 18), + datasetFeePerMonth: parseUnits('0.123', 18), + cdnEgressPerTib: parseUnits('1.5', 18), + cacheMissEgressPerTib: parseUnits('2.5', 18), + }, + fees: { + createDataSetFee: parseUnits('0.011', 18), + addPiecesBaseFee: parseUnits('0.012', 18), + addPiecesPerPieceFee: parseUnits('0.013', 18), + schedulePieceRemovalsFee: parseUnits('0.014', 18), + terminateFee: parseUnits('0.015', 18), + }, + lockups: { + lifecycleReserveTarget: parseUnits('0.21', 18), + replenishThreshold: parseUnits('0.022', 18), + defaultLockupPeriod: 1234n, + cdnLockupAmount: parseUnits('0.23', 18), + cacheMissLockupAmount: parseUnits('0.24', 18), + cdnLockupPeriod: 5678n, + }, + } + + const withPriceList = (list: typeof distinctPriceList) => + JSONRPC({ + ...presets.basic, + warmStorageView: { + ...presets.basic.warmStorageView, + getPriceList: () => [list], + }, + }) + + it('plumbs every field from the on-chain getPriceList', async () => { + server.use(withPriceList(distinctPriceList)) + + const priceList = await getPriceList(makeClient()) + + assert.equal(priceList.token.toLowerCase(), distinctPriceList.token) + assert.deepEqual(priceList.rates, distinctPriceList.rates) + assert.deepEqual(priceList.fees, distinctPriceList.fees) + assert.deepEqual(priceList.lockups, distinctPriceList.lockups) + }) + + it('returns the on-chain PriceList key shape', async () => { + server.use(JSONRPC(presets.basic)) + + const priceList = await getPriceList(makeClient()) + + assert.deepEqual(Object.keys(priceList).sort(), ['fees', 'lockups', 'rates', 'token']) + assert.deepEqual(Object.keys(priceList.rates).sort(), [ + 'cacheMissEgressPerTib', + 'cdnEgressPerTib', + 'datasetFeePerMonth', + 'storagePerTibPerMonth', + ]) + assert.deepEqual(Object.keys(priceList.fees).sort(), [ + 'addPiecesBaseFee', + 'addPiecesPerPieceFee', + 'createDataSetFee', + 'schedulePieceRemovalsFee', + 'terminateFee', + ]) + assert.deepEqual(Object.keys(priceList.lockups).sort(), [ + 'cacheMissLockupAmount', + 'cdnLockupAmount', + 'cdnLockupPeriod', + 'defaultLockupPeriod', + 'lifecycleReserveTarget', + 'replenishThreshold', + ]) + }) + + it('returns independent fee/lockup objects per call (callers cannot corrupt later reads)', async () => { + server.use(JSONRPC(presets.basic)) + + const first = await getPriceList(makeClient()) + first.fees.createDataSetFee = 0n + first.lockups.lifecycleReserveTarget = 0n + + const second = await getPriceList(makeClient()) + assert.notEqual(second.fees.createDataSetFee, 0n) + assert.notEqual(second.lockups.lifecycleReserveTarget, 0n) + }) +}) diff --git a/packages/synapse-core/test/get-service-price.test.ts b/packages/synapse-core/test/get-service-price.test.ts deleted file mode 100644 index 1cf11d9a2..000000000 --- a/packages/synapse-core/test/get-service-price.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import assert from 'assert' -import { setup } from 'iso-web/msw' -import { createPublicClient, http } from 'viem' -import { calibration, mainnet } from '../src/chains.ts' -import { JSONRPC, presets } from '../src/mocks/jsonrpc/index.ts' -import { getServicePrice, getServicePriceCall } from '../src/warm-storage/get-service-price.ts' - -describe('getServicePrice', () => { - const server = setup() - - before(async () => { - await server.start() - }) - - after(() => { - server.stop() - }) - - beforeEach(() => { - server.resetHandlers() - }) - - describe('getServicePriceCall', () => { - it('should create call with calibration chain defaults', () => { - const call = getServicePriceCall({ - chain: calibration, - }) - - assert.equal(call.functionName, 'getServicePrice') - assert.deepEqual(call.args, []) - assert.equal(call.address, calibration.contracts.fwss.address) - assert.equal(call.abi, calibration.contracts.fwss.abi) - }) - - it('should create call with mainnet chain defaults', () => { - const call = getServicePriceCall({ - chain: mainnet, - }) - - assert.equal(call.functionName, 'getServicePrice') - assert.deepEqual(call.args, []) - assert.equal(call.address, mainnet.contracts.fwss.address) - assert.equal(call.abi, mainnet.contracts.fwss.abi) - }) - - it('should use custom address when provided', () => { - const customAddress = '0x1234567890123456789012345678901234567890' - const call = getServicePriceCall({ - chain: calibration, - contractAddress: customAddress, - }) - - assert.equal(call.address, customAddress) - }) - }) - - describe('getServicePrice (with mocked RPC)', () => { - it('should fetch service price', async () => { - server.use(JSONRPC(presets.basic)) - - const client = createPublicClient({ - chain: calibration, - transport: http(), - }) - - const price = await getServicePrice(client) - - assert.equal(typeof price.pricePerTiBPerMonthNoCDN, 'bigint') - assert.equal(typeof price.pricePerTiBCdnEgress, 'bigint') - assert.equal(typeof price.pricePerTiBCacheMissEgress, 'bigint') - assert.equal(typeof price.minimumPricePerMonth, 'bigint') - assert.equal(typeof price.tokenAddress, 'string') - assert.equal(typeof price.epochsPerMonth, 'bigint') - }) - - it('should fetch service price with empty options', async () => { - server.use(JSONRPC(presets.basic)) - - const client = createPublicClient({ - chain: calibration, - transport: http(), - }) - - const price = await getServicePrice(client, {}) - - assert.ok(price.pricePerTiBPerMonthNoCDN > 0n) - }) - }) -}) diff --git a/packages/synapse-core/test/get-upload-costs.test.ts b/packages/synapse-core/test/get-upload-costs.test.ts index 194f8be69..517037799 100644 --- a/packages/synapse-core/test/get-upload-costs.test.ts +++ b/packages/synapse-core/test/get-upload-costs.test.ts @@ -32,11 +32,13 @@ describe('getUploadCosts', () => { const result = await getUploadCosts(client, { clientAddress: ADDRESSES.client1, - dataSize: 1n, // tiny file → uses floor pricing + dataSize: 1n, }) - assert.equal(typeof result.rate.perEpoch, 'bigint') - assert.equal(typeof result.rate.perMonth, 'bigint') + assert.equal(typeof result.rates.perEpoch, 'bigint') + assert.equal(typeof result.rates.perMonth, 'bigint') + assert.equal(typeof result.fees.total, 'bigint') + assert.equal(typeof result.lockups.total, 'bigint') assert.equal(typeof result.depositNeeded, 'bigint') assert.equal(typeof result.needsFwssMaxApproval, 'boolean') assert.equal(typeof result.ready, 'boolean') @@ -115,7 +117,7 @@ describe('getUploadCosts', () => { assert.equal(result.ready, false) }) - it('should apply floor pricing for tiny files', async () => { + it('should apply proving service rate for tiny files', async () => { server.use( JSONRPC({ ...presets.basic, @@ -136,13 +138,15 @@ describe('getUploadCosts', () => { dataSize: 1n, }) - // Floor: minimumPricePerMonth = 0.06 USDFC - // perMonth should equal minimumPricePerMonth (floor) - const minimumPricePerMonth = parseUnits('6', 16) // 0.06 USDFC - assert.equal(result.rate.perMonth, minimumPricePerMonth) + // Additive: 1-byte dataset pays a tiny storage rate on top of proving. + const storagePerMonth1Byte = parseUnits('2.5', 18) / (1n << 40n) + assert.equal(result.rates.perMonth, parseUnits('0.024', 18) + storagePerMonth1Byte) + assert.equal(result.fees.createDataSetFee, parseUnits('0.025', 18)) + assert.equal(result.fees.addPiecesFee, parseUnits('0.0008', 18)) + assert.equal(result.lockups.lifecycleLockup, parseUnits('0.10', 18)) }) - it('should use natural rate for large files above floor', async () => { + it('should use storage plus proving rate for large files', async () => { server.use( JSONRPC({ ...presets.basic, @@ -158,16 +162,15 @@ describe('getUploadCosts', () => { transport: http(), }) - // 1 TiB should be above floor pricing const onetiB = 1n << 40n const result = await getUploadCosts(client, { clientAddress: ADDRESSES.client1, dataSize: onetiB, }) - // Natural rate for 1 TiB = pricePerTiBPerMonth = 2.5 USDFC + // 1 TiB storage plus proving service rate. const pricePerTiBPerMonth = parseUnits('2.5', 18) - assert.equal(result.rate.perMonth, pricePerTiBPerMonth) + assert.equal(result.rates.perMonth, pricePerTiBPerMonth + parseUnits('0.024', 18)) }) it('should include debt in deposit for account in debt', async () => { @@ -247,10 +250,9 @@ describe('getUploadCosts', () => { ) // runway = (currentLockupRate + rateDeltaPerEpoch) * extraRunwayEpochs - // currentLockupRate = 0, rateDeltaPerEpoch = floor rate = minimumPerEpoch - // minimumPerEpoch = 6e16 / 86400 = 694,444,444,444 (bigint truncation) - // runway = 694,444,444,444 * 10,000 = 6,944,444,444,440,000 - const expectedRunway = 6_944_444_444_440_000n + // currentLockupRate = 0; rateDeltaPerEpoch = storage(1 byte) + proving, per epoch + const ratePerEpoch1Byte = parseUnits('2.5', 18) / ((1n << 40n) * 86400n) + parseUnits('0.024', 18) / 86400n + const expectedRunway = ratePerEpoch1Byte * 10_000n assert.equal( withRunway.depositNeeded - baseline.depositNeeded, expectedRunway, @@ -300,9 +302,8 @@ describe('getUploadCosts', () => { ) // Buffer delta = netRate * bufferEpochs = (currentLockupRate + rateDelta) * 100 - // rateDelta = floor rate for 1-byte file = minimumPricePerMonth / epochsPerMonth - const floorRatePerEpoch = 60_000_000_000_000_000n / 86400n - const netRate = 100_000_000_000_000n + floorRatePerEpoch + const ratePerEpoch1Byte = parseUnits('2.5', 18) / ((1n << 40n) * 86400n) + parseUnits('0.024', 18) / 86400n + const netRate = 100_000_000_000_000n + ratePerEpoch1Byte const expectedBufferDelta = netRate * 100n assert.equal( largeBuffer.depositNeeded - smallBuffer.depositNeeded, @@ -344,11 +345,11 @@ describe('getUploadCosts', () => { isNewDataSet: true, }) - // 1 TiB rate = 2.5 USDFC/month, 0.5 TiB rate < 2.5 USDFC/month - assert.equal(existing.rate.perMonth, parseUnits('2.5', 18)) + // Existing dataset pays storage for 1 TiB plus one proving service rate. + assert.equal(existing.rates.perMonth, parseUnits('2.524', 18)) assert.ok( - existing.rate.perMonth > newDs.rate.perMonth, - `existing dataset rate (${existing.rate.perMonth}) should exceed new dataset rate (${newDs.rate.perMonth})` + existing.rates.perMonth > newDs.rates.perMonth, + `existing dataset rate (${existing.rates.perMonth}) should exceed new dataset rate (${newDs.rates.perMonth})` ) }) @@ -382,12 +383,56 @@ describe('getUploadCosts', () => { withCDN: true, }) - // CDN_FIXED_LOCKUP.total = 1 USDFC (cdn 0.7 + cacheMiss 0.3) const cdnFixedLockupTotal = 1_000_000_000_000_000_000n assert.equal( withCDN.depositNeeded - withoutCDN.depositNeeded, cdnFixedLockupTotal, - 'CDN deposit should exceed non-CDN deposit by exactly CDN_FIXED_LOCKUP.total (1 USDFC)' + 'CDN deposit should exceed non-CDN deposit by the CDN and cache-miss lockups' + ) + }) + + it('includes operation fees in the deposit for a new dataset', async () => { + // Fresh account (no funds, no existing rails) creating a new dataset: with + // default runway/buffer this isolates the deposit to lockups + fees, so it + // proves operation fees are actually counted in depositNeeded. + server.use( + JSONRPC({ + ...presets.basic, + payments: { + ...presets.basic.payments, + accounts: () => [0n, 0n, 0n, 0n], + operatorApprovals: () => [true, maxUint256, maxUint256, 0n, 0n, maxUint256], + }, + }) + ) + + const client = createPublicClient({ + chain: calibration, + transport: http(), + }) + + const result = await getUploadCosts(client, { + clientAddress: ADDRESSES.client1, + dataSize: 1n, + }) + + assert.ok(result.fees.total > 0n) + assert.equal(result.depositNeeded, result.lockups.total + result.fees.total) + }) + + it('derives an extra addPieces operation fee when pieceCount exceeds the batch limit', async () => { + server.use(JSONRPC(presets.basic)) + + const client = createPublicClient({ chain: calibration, transport: http() }) + + const within = await getUploadCosts(client, { clientAddress: ADDRESSES.client1, dataSize: 1n, pieceCount: 40n }) + const spill = await getUploadCosts(client, { clientAddress: ADDRESSES.client1, dataSize: 1n, pieceCount: 41n }) + + // 41 pieces span two addPieces ops (ceil(41/40) = 2), so the 41-piece cost + // adds exactly one extra base fee plus one extra per-piece fee over 40. + assert.equal( + spill.fees.addPiecesFee - within.fees.addPiecesFee, + parseUnits('0.0005', 18) + parseUnits('0.0003', 18) ) }) }) diff --git a/packages/synapse-core/test/is-fwss-max-approved.test.ts b/packages/synapse-core/test/is-fwss-max-approved.test.ts index 29290d0fe..8eebb4a0b 100644 --- a/packages/synapse-core/test/is-fwss-max-approved.test.ts +++ b/packages/synapse-core/test/is-fwss-max-approved.test.ts @@ -6,7 +6,10 @@ import { createPublicClient, http, maxUint256 } from 'viem' import { calibration } from '../src/chains.ts' import { ADDRESSES, JSONRPC, presets } from '../src/mocks/jsonrpc/index.ts' import { isFwssMaxApproved } from '../src/pay/is-fwss-max-approved.ts' -import { LOCKUP_PERIOD } from '../src/utils/constants.ts' +import { TIME_CONSTANTS } from '../src/utils/constants.ts' + +// Matches the lockup period returned by the basic preset's getPriceList mock. +const CHAIN_LOCKUP_PERIOD = TIME_CONSTANTS.DEFAULT_LOCKUP_DAYS * TIME_CONSTANTS.EPOCHS_PER_DAY describe('isFwssMaxApproved', () => { const server = setup() @@ -92,13 +95,13 @@ describe('isFwssMaxApproved', () => { assert.equal(result, false) }) - it('should return false when approved but maxLockupPeriod is below LOCKUP_PERIOD', async () => { + it('should return false when approved but maxLockupPeriod is below the chain lockup period', async () => { server.use( JSONRPC({ ...presets.basic, payments: { ...presets.basic.payments, - operatorApprovals: () => [true, maxUint256, maxUint256, 0n, 0n, LOCKUP_PERIOD - 1n], + operatorApprovals: () => [true, maxUint256, maxUint256, 0n, 0n, CHAIN_LOCKUP_PERIOD - 1n], }, }) ) @@ -115,13 +118,13 @@ describe('isFwssMaxApproved', () => { assert.equal(result, false) }) - it('should return true when maxLockupPeriod equals LOCKUP_PERIOD', async () => { + it('should return true when maxLockupPeriod equals the chain lockup period', async () => { server.use( JSONRPC({ ...presets.basic, payments: { ...presets.basic.payments, - operatorApprovals: () => [true, maxUint256, maxUint256, 0n, 0n, LOCKUP_PERIOD], + operatorApprovals: () => [true, maxUint256, maxUint256, 0n, 0n, CHAIN_LOCKUP_PERIOD], }, }) ) @@ -138,6 +141,37 @@ describe('isFwssMaxApproved', () => { assert.equal(result, true) }) + it('compares against an explicit requiredMaxLockupPeriod when provided', async () => { + // Approval is below the chain default but at/above the explicit requirement, + // proving the override is used instead of the chain read. + server.use( + JSONRPC({ + ...presets.basic, + payments: { + ...presets.basic.payments, + operatorApprovals: () => [true, maxUint256, maxUint256, 0n, 0n, CHAIN_LOCKUP_PERIOD - 10n], + }, + }) + ) + + const client = createPublicClient({ chain: calibration, transport: http() }) + + assert.equal( + await isFwssMaxApproved(client, { + clientAddress: ADDRESSES.client1, + requiredMaxLockupPeriod: CHAIN_LOCKUP_PERIOD - 10n, + }), + true + ) + assert.equal( + await isFwssMaxApproved(client, { + clientAddress: ADDRESSES.client1, + requiredMaxLockupPeriod: CHAIN_LOCKUP_PERIOD, + }), + false + ) + }) + it('should return true when all allowances are maxUint256', async () => { server.use( JSONRPC({ diff --git a/packages/synapse-core/test/resolve-piece-url.test.ts b/packages/synapse-core/test/resolve-piece-url.test.ts index a15bafb88..c56b4ae1f 100644 --- a/packages/synapse-core/test/resolve-piece-url.test.ts +++ b/packages/synapse-core/test/resolve-piece-url.test.ts @@ -319,6 +319,8 @@ describe('resolve-piece-url', () => { clientDataSetId: 0n, pdpEndEpoch: 0n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, cdnEndEpoch: 0n, dataSetId: 1n, }, @@ -333,6 +335,8 @@ describe('resolve-piece-url', () => { clientDataSetId: 1n, pdpEndEpoch: 0n, providerId: 2n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, cdnEndEpoch: 0n, dataSetId: 2n, }, @@ -347,6 +351,8 @@ describe('resolve-piece-url', () => { clientDataSetId: 2n, pdpEndEpoch: 1n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, cdnEndEpoch: 0n, dataSetId: 3n, }, diff --git a/packages/synapse-core/test/set-operator-approval.test.ts b/packages/synapse-core/test/set-operator-approval.test.ts index 21c4178ec..776128478 100644 --- a/packages/synapse-core/test/set-operator-approval.test.ts +++ b/packages/synapse-core/test/set-operator-approval.test.ts @@ -20,7 +20,11 @@ import { setOperatorApprovalCall, setOperatorApprovalSync, } from '../src/pay/set-operator-approval.ts' -import { LOCKUP_PERIOD } from '../src/utils/constants.ts' +import { TIME_CONSTANTS } from '../src/utils/constants.ts' + +// Matches the lockup period returned by the basic preset's getPriceList mock, +// which the async approval path resolves from the chain. +const CHAIN_LOCKUP_PERIOD = TIME_CONSTANTS.DEFAULT_LOCKUP_DAYS * TIME_CONSTANTS.EPOCHS_PER_DAY // Type for captured args from setOperatorApproval mock type SetOperatorApprovalArgs = readonly [Address, Address, boolean, bigint, bigint, bigint] @@ -45,6 +49,7 @@ describe('setOperatorApproval', () => { const call = setOperatorApprovalCall({ chain: calibration, approve: true, + maxLockupPeriod: CHAIN_LOCKUP_PERIOD, }) assert.equal(call.functionName, 'setOperatorApproval') @@ -57,13 +62,14 @@ describe('setOperatorApproval', () => { assert.equal(call.args[2], true) // approved assert.equal(call.args[3], maxUint256) // rateAllowance assert.equal(call.args[4], maxUint256) // lockupAllowance - assert.equal(call.args[5], LOCKUP_PERIOD) // maxLockupPeriod (30 days in epochs) + assert.equal(call.args[5], CHAIN_LOCKUP_PERIOD) // maxLockupPeriod }) it('should create call with mainnet chain defaults when approving', () => { const call = setOperatorApprovalCall({ chain: mainnet, approve: true, + maxLockupPeriod: CHAIN_LOCKUP_PERIOD, }) assert.equal(call.functionName, 'setOperatorApproval') @@ -75,7 +81,16 @@ describe('setOperatorApproval', () => { assert.equal(call.args[2], true) assert.equal(call.args[3], maxUint256) assert.equal(call.args[4], maxUint256) - assert.equal(call.args[5], LOCKUP_PERIOD) + assert.equal(call.args[5], CHAIN_LOCKUP_PERIOD) + }) + + it('should throw when approving without an explicit maxLockupPeriod', () => { + // The synchronous builder has no hardcoded default; approving callers must + // resolve the period from the chain price list and pass it in. + assert.throws( + () => setOperatorApprovalCall({ chain: calibration, approve: true }), + /maxLockupPeriod is required when approving/ + ) }) it('should create call with zero defaults when revoking', () => { @@ -96,6 +111,7 @@ describe('setOperatorApproval', () => { const call = setOperatorApprovalCall({ chain: calibration, approve: true, + maxLockupPeriod: CHAIN_LOCKUP_PERIOD, contractAddress: customAddress, }) @@ -107,6 +123,7 @@ describe('setOperatorApproval', () => { const call = setOperatorApprovalCall({ chain: calibration, approve: true, + maxLockupPeriod: CHAIN_LOCKUP_PERIOD, token: customToken, }) @@ -223,7 +240,7 @@ describe('setOperatorApproval', () => { { name: 'lockupAllowance', type: 'uint256' }, { name: 'maxLockupPeriod', type: 'uint256' }, ], - [true, maxUint256, maxUint256, LOCKUP_PERIOD] + [true, maxUint256, maxUint256, CHAIN_LOCKUP_PERIOD] ) const logs: Log[] = [ @@ -246,7 +263,7 @@ describe('setOperatorApproval', () => { assert.equal(event.args.approved, true) assert.equal(event.args.rateAllowance, maxUint256) assert.equal(event.args.lockupAllowance, maxUint256) - assert.equal(event.args.maxLockupPeriod, LOCKUP_PERIOD) + assert.equal(event.args.maxLockupPeriod, CHAIN_LOCKUP_PERIOD) }) }) @@ -289,7 +306,7 @@ describe('setOperatorApproval', () => { assert.equal(capturedArgs[2], true) // approved assert.equal(capturedArgs[3], maxUint256) // rateAllowance assert.equal(capturedArgs[4], maxUint256) // lockupAllowance - assert.equal(capturedArgs[5], LOCKUP_PERIOD) // maxLockupPeriod + assert.equal(capturedArgs[5], CHAIN_LOCKUP_PERIOD) // maxLockupPeriod }) it('should send revoke transaction with zero defaults', async () => { @@ -426,7 +443,7 @@ describe('setOperatorApproval', () => { { name: 'lockupAllowance', type: 'uint256' }, { name: 'maxLockupPeriod', type: 'uint256' }, ], - [true, maxUint256, maxUint256, LOCKUP_PERIOD] + [true, maxUint256, maxUint256, CHAIN_LOCKUP_PERIOD] ) server.use( @@ -496,7 +513,7 @@ describe('setOperatorApproval', () => { assert.equal(event.args.approved, true) assert.equal(event.args.rateAllowance, maxUint256) assert.equal(event.args.lockupAllowance, maxUint256) - assert.equal(event.args.maxLockupPeriod, LOCKUP_PERIOD) + assert.equal(event.args.maxLockupPeriod, CHAIN_LOCKUP_PERIOD) }) it('should work without onHash callback', async () => { @@ -583,13 +600,4 @@ describe('setOperatorApproval', () => { assert.equal(event.args.maxLockupPeriod, 0n) }) }) - - describe('LOCKUP_PERIOD constant', () => { - it('should be 30 days in epochs', () => { - // 30 days * 24 hours * 60 minutes * 2 epochs per minute = 86400 epochs - const expectedEpochs = 30n * 24n * 60n * 2n - assert.equal(LOCKUP_PERIOD, expectedEpochs) - assert.equal(LOCKUP_PERIOD, 86400n) - }) - }) }) diff --git a/packages/synapse-core/test/terminate-service.test.ts b/packages/synapse-core/test/terminate-service.test.ts index 308578e59..91dc9e41f 100644 --- a/packages/synapse-core/test/terminate-service.test.ts +++ b/packages/synapse-core/test/terminate-service.test.ts @@ -120,7 +120,7 @@ describe('terminateService', () => { abi: Abis.fwss, eventName: 'ServiceTerminated', args: { - caller: ADDRESSES.client1, + approver: ADDRESSES.client1, dataSetId, }, }) @@ -198,9 +198,9 @@ describe('terminateService', () => { assert.ok(event) assert.equal(event.eventName, 'ServiceTerminated') - assert.ok(event.args.caller) + assert.ok(event.args.approver) assert.equal(event.args.dataSetId, dataSetId) - assert.equal(event.args.caller.toLowerCase(), ADDRESSES.client1.toLowerCase()) + assert.equal(event.args.approver.toLowerCase(), ADDRESSES.client1.toLowerCase()) if (event.eventName === 'ServiceTerminated') { assert.equal(event.args.pdpRailId, pdpRailId) } @@ -218,7 +218,7 @@ describe('terminateService', () => { abi: Abis.fwss, eventName: 'ServiceTerminated', args: { - caller: ADDRESSES.client1, + approver: ADDRESSES.client1, dataSetId, }, }) @@ -305,7 +305,7 @@ describe('terminateService', () => { abi: Abis.fwss, eventName: 'ServiceTerminated', args: { - caller: ADDRESSES.client1, + approver: ADDRESSES.client1, dataSetId, }, }) diff --git a/packages/synapse-core/wagmi.config.ts b/packages/synapse-core/wagmi.config.ts index 102827621..f5aef1ee7 100644 --- a/packages/synapse-core/wagmi.config.ts +++ b/packages/synapse-core/wagmi.config.ts @@ -7,7 +7,7 @@ import { ZodValidationError } from './src/errors/base.ts' import { zAddress, zAddressLoose } from './src/utils/schemas.ts' // GIT_REF can be one of: '', '' or 'tags/' -const FILECOIN_SERVICES_GIT_REF = '4e548903095cfb46bc35af029f2ae0f39f18b8e4' // v1.2.1 +const FILECOIN_SERVICES_GIT_REF = '02de64a17847f59262b535ab548cae6be307917f' // main const FILECOIN_SERVICES_REF = FILECOIN_SERVICES_GIT_REF.replace(/^(?![a-f0-9]{40}$)/, 'refs/') const BASE_URL = `https://raw.githubusercontent.com/FilOzone/filecoin-services/${FILECOIN_SERVICES_REF}/service_contracts/abi` const DEPLOYMENTS_URL = `https://raw.githubusercontent.com/FilOzone/filecoin-services/${FILECOIN_SERVICES_REF}/service_contracts/deployments.json` diff --git a/packages/synapse-react/src/warm-storage/index.ts b/packages/synapse-react/src/warm-storage/index.ts index 081817fef..a9cadf42b 100644 --- a/packages/synapse-react/src/warm-storage/index.ts +++ b/packages/synapse-react/src/warm-storage/index.ts @@ -1,7 +1,7 @@ export * from './use-create-data-set.ts' export * from './use-data-sets.ts' export * from './use-delete-piece.ts' +export * from './use-price-list.ts' export * from './use-providers.ts' -export * from './use-service-price.ts' export * from './use-upload.ts' export * from './use-upload-simple.ts' diff --git a/packages/synapse-react/src/warm-storage/use-price-list.ts b/packages/synapse-react/src/warm-storage/use-price-list.ts new file mode 100644 index 000000000..b10e670ae --- /dev/null +++ b/packages/synapse-react/src/warm-storage/use-price-list.ts @@ -0,0 +1,34 @@ +import { getPriceList } from '@filoz/synapse-core/warm-storage' +import { type UseQueryOptions, useQuery } from '@tanstack/react-query' +import { useConfig } from 'wagmi' + +/** + * The result for the usePriceList hook. + */ +export type UsePriceListResult = getPriceList.OutputType + +/** + * The props for the usePriceList hook. + */ +export interface UsePriceListProps { + query?: Omit, 'queryKey' | 'queryFn'> +} + +/** + * Get the warm storage price list. + * + * @param props - The props to use. + * @returns The price list. + */ +export function usePriceList(props?: UsePriceListProps) { + const config = useConfig() + + return useQuery({ + ...props?.query, + queryKey: ['synapse-warm-storage-get-price-list', config.getClient().chain.id], + queryFn: async () => { + const result = await getPriceList(config.getClient()) + return result + }, + }) +} diff --git a/packages/synapse-react/src/warm-storage/use-service-price.ts b/packages/synapse-react/src/warm-storage/use-service-price.ts deleted file mode 100644 index 2ce8d1c49..000000000 --- a/packages/synapse-react/src/warm-storage/use-service-price.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { getServicePrice } from '@filoz/synapse-core/warm-storage' -import { type UseQueryOptions, useQuery } from '@tanstack/react-query' -import { useConfig } from 'wagmi' - -/** - * The result for the useServicePrice hook. - */ -export type UseServicePriceResult = getServicePrice.OutputType - -/** - * The props for the useServicePrice hook. - */ -export interface UseServicePriceProps { - query?: Omit, 'queryKey' | 'queryFn'> -} - -/** - * Get the service price for the warm storage. - * - * @param props - The props to use. - * @returns The service price. - */ -export function useServicePrice(props?: UseServicePriceProps) { - const config = useConfig() - - return useQuery({ - ...props?.query, - queryKey: ['synapse-warm-storage-get-service-price', config.getClient().chain.id], - queryFn: async () => { - const result = await getServicePrice(config.getClient()) - return result - }, - }) -} diff --git a/packages/synapse-sdk/src/filbeam/service.ts b/packages/synapse-sdk/src/filbeam/service.ts index 9f195dcf3..b9c81d7d0 100644 --- a/packages/synapse-sdk/src/filbeam/service.ts +++ b/packages/synapse-sdk/src/filbeam/service.ts @@ -99,7 +99,7 @@ export class FilBeamService { * - **Cache Miss Egress Quota**: Remaining bytes that can be retrieved from storage providers (triggers caching) * * Both types of egress are billed based on volume. Query current pricing via - * {@link WarmStorageService.getServicePrice} or see https://docs.filbeam.com for rates. + * {@link WarmStorageService.getPriceList} or see https://docs.filbeam.com for rates. * * @param dataSetId - The unique identifier of the data set to query * @returns A promise that resolves to the data set statistics with remaining quotas as BigInt values diff --git a/packages/synapse-sdk/src/payments/service.ts b/packages/synapse-sdk/src/payments/service.ts index d49573b00..b75981896 100644 --- a/packages/synapse-sdk/src/payments/service.ts +++ b/packages/synapse-sdk/src/payments/service.ts @@ -254,7 +254,7 @@ export class PaymentsService { * @param options.service - The service contract address to approve (defaults to Warm Storage contract address) * @param options.rateAllowance - Maximum payment rate per epoch the operator can set (defaults to maxUint256) * @param options.lockupAllowance - Maximum lockup amount the operator can set (defaults to maxUint256) - * @param options.maxLockupPeriod - Maximum lockup period in epochs the operator can set (defaults to 30 days in epochs) + * @param options.maxLockupPeriod - Maximum lockup period in epochs the operator can set (defaults to the chain's getPriceList().lockups.defaultLockupPeriod) * @param options.token - The token to approve for (defaults to USDFC) * @returns Transaction hash {@link Hash} * @throws Errors {@link Pay.setOperatorApproval.ErrorType} @@ -522,8 +522,8 @@ export class PaymentsService { /** * Smart deposit method that picks the right contract call based on FWSS approval state. * - * - If FWSS needs approval AND amount > 0: calls `depositWithPermitAndApproveOperator` with maxUint256 rate/lockup allowances and LOCKUP_PERIOD. - * - If FWSS needs approval AND amount === 0: calls `approveService` with maxUint256 rate/lockup allowances and LOCKUP_PERIOD. + * - If FWSS needs approval AND amount > 0: calls `depositWithPermitAndApproveOperator` with maxUint256 rate/lockup allowances and the chain's default lockup period. + * - If FWSS needs approval AND amount === 0: calls `approveService` with maxUint256 rate/lockup allowances and the chain's default lockup period. * - If FWSS is approved AND amount > 0: calls `depositWithPermit`. * - If FWSS is approved AND amount === 0: no-op, returns empty hash. * diff --git a/packages/synapse-sdk/src/storage/manager.ts b/packages/synapse-sdk/src/storage/manager.ts index 3e367f145..818b1c5b9 100644 --- a/packages/synapse-sdk/src/storage/manager.ts +++ b/packages/synapse-sdk/src/storage/manager.ts @@ -30,14 +30,15 @@ import { getDataSetSizes } from '@filoz/synapse-core/pdp-verifier' import * as Piece from '@filoz/synapse-core/piece' import type { UploadPieceStreamingData } from '@filoz/synapse-core/sp' import { getPDPProviderByAddress } from '@filoz/synapse-core/sp-registry' -import { DEFAULT_BUFFER_EPOCHS, DEFAULT_RUNWAY_EPOCHS, LOCKUP_PERIOD } from '@filoz/synapse-core/utils' +import { DEFAULT_BUFFER_EPOCHS, DEFAULT_RUNWAY_EPOCHS } from '@filoz/synapse-core/utils' import { calculateAdditionalLockupRequired, calculateBufferAmount, calculateEffectiveRate, calculateRunwayAmount, + calculateUploadFees, getUploadCosts as coreGetUploadCosts, - getServicePrice, + getPriceList, metadataMatches, } from '@filoz/synapse-core/warm-storage' import { type Address, type Hash, type Hex, UserRejectedRequestError, zeroAddress } from 'viem' @@ -694,7 +695,7 @@ export class StorageManager { * and buffer only once (they are shared across all contexts from the same payer). * * Dataset sizes are fetched from chain for existing datasets to get accurate - * floor-aware rate deltas. + * rate deltas. * * @param contexts - Storage contexts to aggregate costs for * @param options - Upload options (dataSize, extraRunwayEpochs, bufferEpochs) @@ -713,9 +714,9 @@ export class StorageManager { const existingDataSetIds = contexts.filter((ctx) => ctx.dataSetId != null).map((ctx) => ctx.dataSetId as bigint) // Fetch all needed data in parallel - const [accountInfo, pricing, approved, currentEpoch, sizes] = await Promise.all([ + const [accountInfo, priceList, approved, currentEpoch, sizes] = await Promise.all([ payAccounts(client, { address: clientAddress }), - getServicePrice(client), + getPriceList(client), isFwssMaxApproved(client, { clientAddress }), getBlockNumber(client, { cacheTime: 0 }), existingDataSetIds.length > 0 ? getDataSetSizes(client, { dataSetIds: existingDataSetIds }) : [], @@ -730,8 +731,14 @@ export class StorageManager { // Per-context loop: calculate lockup for each context let totalRateDeltaPerEpoch = 0n let totalLockup = 0n + let totalLifecycleLockup = 0n + let totalStreamingLockup = 0n + let totalCdnLockup = 0n + let totalCacheMissLockup = 0n let totalRatePerEpoch = 0n let totalRatePerMonth = 0n + let totalCreateDataSetFee = 0n + let totalAddPiecesFee = 0n for (let i = 0; i < contexts.length; i++) { const ctx = contexts[i] @@ -741,24 +748,34 @@ export class StorageManager { const lockup = calculateAdditionalLockupRequired({ dataSize: options.dataSize, currentDataSetSize, - pricePerTiBPerMonth: pricing.pricePerTiBPerMonthNoCDN, - minimumPricePerMonth: pricing.minimumPricePerMonth, - epochsPerMonth: pricing.epochsPerMonth, - lockupEpochs: LOCKUP_PERIOD, + priceList, + epochsPerMonth: TIME_CONSTANTS.EPOCHS_PER_MONTH, isNewDataSet, withCDN: ctx.withCDN, }) + // Multi-context preview assumes one piece / one addPieces op per context; + // batched multi-piece uploads should price via getUploadCosts with explicit counts. + const fees = calculateUploadFees({ + priceList, + isNewDataSet, + }) totalRateDeltaPerEpoch += lockup.rateDeltaPerEpoch totalLockup += lockup.total + totalLifecycleLockup += lockup.lifecycleLockup + totalStreamingLockup += lockup.streamingLockup + totalCdnLockup += lockup.cdnLockup + totalCacheMissLockup += lockup.cacheMissLockup + totalCreateDataSetFee += fees.createDataSetFee + totalAddPiecesFee += fees.addPiecesFee // Calculate per-context effective rate for the rate output const totalSize = currentDataSetSize + options.dataSize const rate = calculateEffectiveRate({ sizeInBytes: totalSize, - pricePerTiBPerMonth: pricing.pricePerTiBPerMonthNoCDN, - minimumPricePerMonth: pricing.minimumPricePerMonth, - epochsPerMonth: pricing.epochsPerMonth, + storagePerTibPerMonth: priceList.rates.storagePerTibPerMonth, + datasetFeePerMonth: priceList.rates.datasetFeePerMonth, + epochsPerMonth: TIME_CONSTANTS.EPOCHS_PER_MONTH, }) totalRatePerEpoch += rate.ratePerEpoch totalRatePerMonth += rate.ratePerMonth @@ -782,13 +799,12 @@ export class StorageManager { extraRunwayEpochs, }) - const rawDepositNeeded = totalLockup + runway + debt - availableFunds + const totalFees = totalCreateDataSetFee + totalAddPiecesFee + const rawDepositNeeded = totalLockup + totalFees + runway + debt - availableFunds // Skip buffer when no existing rails are draining and all contexts are new datasets. // The deposit lands before any rail is created, so nothing consumes funds // between balance check and tx execution. - // Minimum upload size is 1 GiB, well below the ~26 GiB floor threshold, so buffer is - // not needed for upto 26 contexts as of now which is reasonable. const allNewDatasets = contexts.every((ctx) => ctx.dataSetId == null) const skipBuffer = accountInfo.lockupRate === 0n && allNewDatasets @@ -806,10 +822,24 @@ export class StorageManager { const depositNeeded = clamped + buffer const needsFwssMaxApproval = !approved + const rates = { + perEpoch: totalRatePerEpoch, + perMonth: totalRatePerMonth, + } + return { - rate: { - perEpoch: totalRatePerEpoch, - perMonth: totalRatePerMonth, + rates, + fees: { + createDataSetFee: totalCreateDataSetFee, + addPiecesFee: totalAddPiecesFee, + total: totalFees, + }, + lockups: { + lifecycleLockup: totalLifecycleLockup, + streamingLockup: totalStreamingLockup, + cdnLockup: totalCdnLockup, + cacheMissLockup: totalCacheMissLockup, + total: totalLockup, }, depositNeeded, needsFwssMaxApproval, @@ -1017,7 +1047,7 @@ export class StorageManager { // Fetch all data in parallel for performance const [pricingData, approvedIds, allowances] = await Promise.all([ - this._warmStorageService.getServicePrice(), + this._warmStorageService.getPriceList(), this._warmStorageService.getApprovedProviderIds(), getOptionalAllowances(), ]) @@ -1026,19 +1056,19 @@ export class StorageManager { const providers = await spRegistry.getProviders({ providerIds: approvedIds }) // Calculate pricing per different time units - const epochsPerMonth = BigInt(pricingData.epochsPerMonth) + const epochsPerMonth = TIME_CONSTANTS.EPOCHS_PER_MONTH // 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 + const noCDNPerEpoch = pricingData.rates.storagePerTibPerMonth / epochsPerMonth // CDN costs are usage-based (egress charges), so base storage cost is the same - const withCDNPerEpoch = BigInt(pricingData.pricePerTiBPerMonthNoCDN) / epochsPerMonth + const withCDNPerEpoch = pricingData.rates.storagePerTibPerMonth / epochsPerMonth // Calculate per-day pricing (base storage cost) - const noCDNPerDay = BigInt(pricingData.pricePerTiBPerMonthNoCDN) / TIME_CONSTANTS.DAYS_PER_MONTH + const noCDNPerDay = pricingData.rates.storagePerTibPerMonth / 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 + const withCDNPerDay = pricingData.rates.storagePerTibPerMonth / TIME_CONSTANTS.DAYS_PER_MONTH // Filter out providers with zero addresses const validProviders = providers.filter((p: PDPProvider) => p.serviceProvider !== zeroAddress) @@ -1046,18 +1076,19 @@ export class StorageManager { return { pricing: { noCDN: { - perTiBPerMonth: BigInt(pricingData.pricePerTiBPerMonthNoCDN), + perTiBPerMonth: pricingData.rates.storagePerTibPerMonth, perTiBPerDay: noCDNPerDay, perTiBPerEpoch: noCDNPerEpoch, }, // CDN costs are usage-based (egress charges), base storage cost is the same withCDN: { - perTiBPerMonth: BigInt(pricingData.pricePerTiBPerMonthNoCDN), + perTiBPerMonth: pricingData.rates.storagePerTibPerMonth, perTiBPerDay: withCDNPerDay, perTiBPerEpoch: withCDNPerEpoch, }, - tokenAddress: pricingData.tokenAddress, + tokenAddress: pricingData.token, tokenSymbol: 'USDFC', // Hardcoded as we know it's always USDFC + priceList: pricingData, }, providers: validProviders, serviceParameters: { diff --git a/packages/synapse-sdk/src/test/calculate-multi-context-costs.test.ts b/packages/synapse-sdk/src/test/calculate-multi-context-costs.test.ts index 5f7cc753f..71d7f606d 100644 --- a/packages/synapse-sdk/src/test/calculate-multi-context-costs.test.ts +++ b/packages/synapse-sdk/src/test/calculate-multi-context-costs.test.ts @@ -2,7 +2,7 @@ import { type Chain, calibration } from '@filoz/synapse-core/chains' import * as Mocks from '@filoz/synapse-core/mocks' -import { CDN_FIXED_LOCKUP, SIZE_CONSTANTS } from '@filoz/synapse-core/utils' +import { SIZE_CONSTANTS } from '@filoz/synapse-core/utils' import { assert } from 'chai' import { setup } from 'iso-web/msw' import { @@ -115,8 +115,10 @@ describe('calculateMultiContextCosts', () => { const ctx = makeContext(synapse, warmStorageService, {}) const result = await manager.calculateMultiContextCosts([ctx], { dataSize: 1n }) - assert.equal(typeof result.rate.perEpoch, 'bigint') - assert.equal(typeof result.rate.perMonth, 'bigint') + assert.equal(typeof result.rates.perEpoch, 'bigint') + assert.equal(typeof result.rates.perMonth, 'bigint') + assert.equal(typeof result.fees.total, 'bigint') + assert.equal(typeof result.lockups.total, 'bigint') assert.equal(typeof result.depositNeeded, 'bigint') assert.equal(typeof result.needsFwssMaxApproval, 'boolean') assert.equal(typeof result.ready, 'boolean') @@ -140,9 +142,9 @@ describe('calculateMultiContextCosts', () => { assert.equal(result.needsFwssMaxApproval, false) assert.equal(result.ready, true) - // Floor pricing for tiny file - const minimumPricePerMonth = parseUnits('6', 16) // 0.06 USDFC - assert.equal(result.rate.perMonth, minimumPricePerMonth) + // Additive: 1-byte dataset pays a tiny storage rate on top of proving. + const storagePerMonth1Byte = parseUnits('2.5', 18) / (1n << 40n) + assert.equal(result.rates.perMonth, parseUnits('0.024', 18) + storagePerMonth1Byte) }) it('should aggregate rates across two new contexts', async () => { @@ -166,8 +168,8 @@ describe('calculateMultiContextCosts', () => { const double = await manager.calculateMultiContextCosts([ctxA, ctxB], { dataSize: 1n }) // Rates should be exactly 2x single context - assert.equal(double.rate.perEpoch, single.rate.perEpoch * 2n) - assert.equal(double.rate.perMonth, single.rate.perMonth * 2n) + assert.equal(double.rates.perEpoch, single.rates.perEpoch * 2n) + assert.equal(double.rates.perMonth, single.rates.perMonth * 2n) }) it('should fetch dataset size for existing contexts', async () => { @@ -201,8 +203,8 @@ describe('calculateMultiContextCosts', () => { // Existing 1 TiB + 1 TiB = 2 TiB rate, new 1 TiB = 1 TiB rate // pricePerTiBPerMonth = 2.5 USDFC const pricePerTiBPerMonth = parseUnits('2.5', 18) - assert.equal(resultNew.rate.perMonth, pricePerTiBPerMonth) // 1 TiB = 2.5 USDFC/month - assert.equal(resultExisting.rate.perMonth, pricePerTiBPerMonth * 2n) // 2 TiB = 5 USDFC/month + assert.equal(resultNew.rates.perMonth, pricePerTiBPerMonth + parseUnits('0.024', 18)) + assert.equal(resultExisting.rates.perMonth, pricePerTiBPerMonth * 2n + parseUnits('0.024', 18)) }) it('should handle mixed new + existing contexts', async () => { @@ -236,9 +238,9 @@ describe('calculateMultiContextCosts', () => { dataSize: oneTiB, }) - // Combined rate: 1 TiB (2.5 USDFC) + 2 TiB (5 USDFC) = 7.5 USDFC/month + // Combined rate: storage rates plus one proving service rate per context. const pricePerTiBPerMonth = parseUnits('2.5', 18) - assert.equal(result.rate.perMonth, pricePerTiBPerMonth * 3n) + assert.equal(result.rates.perMonth, pricePerTiBPerMonth * 3n + parseUnits('0.024', 18) * 2n) }) it('should include debt in deposit for account in debt', async () => { @@ -301,11 +303,8 @@ describe('calculateMultiContextCosts', () => { `deposit with runway (${withRunway.depositNeeded}) should exceed baseline (${baseline.depositNeeded})` ) - // runway = (currentLockupRate + totalRateDelta) * extraRunwayEpochs - // currentLockupRate = 0, totalRateDelta = 2 * floor rate per epoch - // floor per epoch = 6e16 / 86400 = 694,444,444,444 - // runway = 2 * 694,444,444,444 * 10,000 = 13,888,888,888,880,000 - const expectedRunway = 13_888_888_888_880_000n + const ratePerEpoch1Byte = parseUnits('2.5', 18) / ((1n << 40n) * 86400n) + parseUnits('0.024', 18) / 86400n + const expectedRunway = 2n * ratePerEpoch1Byte * 10_000n assert.equal( withRunway.depositNeeded - baseline.depositNeeded, expectedRunway, @@ -387,9 +386,8 @@ describe('calculateMultiContextCosts', () => { ) // buffer delta = netRate * bufferEpochs = (currentLockupRate + rateDelta) * 100 - // rateDelta = floor rate for 1-byte file = minimumPricePerMonth / epochsPerMonth - const floorRatePerEpoch = 60_000_000_000_000_000n / 86400n - const netRate = 100_000_000_000_000n + floorRatePerEpoch + const ratePerEpoch1Byte = parseUnits('2.5', 18) / ((1n << 40n) * 86400n) + parseUnits('0.024', 18) / 86400n + const netRate = 100_000_000_000_000n + ratePerEpoch1Byte const expectedDelta = netRate * 100n assert.equal( withBuffer.depositNeeded - noBuffer.depositNeeded, @@ -424,11 +422,11 @@ describe('calculateMultiContextCosts', () => { dataSize: 1n, }) - // Difference should be exactly CDN_FIXED_LOCKUP.total (1 USDFC) + const cdnLockupTotal = parseUnits('1', 18) assert.equal( mixedResult.depositNeeded - baselineResult.depositNeeded, - CDN_FIXED_LOCKUP.total, - `CDN context should add exactly ${CDN_FIXED_LOCKUP.total} to deposit` + cdnLockupTotal, + `CDN context should add exactly ${cdnLockupTotal} to deposit` ) }) @@ -493,6 +491,6 @@ describe('calculateMultiContextCosts', () => { const ctx = makeContext(synapse, warmStorageService, {}) const result = await manager.calculateMultiContextCosts([ctx], { dataSize: oneTiB }) - assert.equal(result.rate.perMonth, pricePerTiBPerMonth) + assert.equal(result.rates.perMonth, pricePerTiBPerMonth + parseUnits('0.024', 18)) }) }) diff --git a/packages/synapse-sdk/src/test/metadata-selection.test.ts b/packages/synapse-sdk/src/test/metadata-selection.test.ts index a732a6b20..ab676300f 100644 --- a/packages/synapse-sdk/src/test/metadata-selection.test.ts +++ b/packages/synapse-sdk/src/test/metadata-selection.test.ts @@ -44,6 +44,8 @@ describe('Metadata-based Data Set Selection', () => { clientDataSetId: 0n, pdpEndEpoch: 0n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, cdnEndEpoch: 0n, dataSetId: 1n, }, @@ -58,6 +60,8 @@ describe('Metadata-based Data Set Selection', () => { clientDataSetId: 1n, pdpEndEpoch: 0n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, cdnEndEpoch: 0n, dataSetId: 2n, }, @@ -72,6 +76,8 @@ describe('Metadata-based Data Set Selection', () => { clientDataSetId: 2n, pdpEndEpoch: 0n, providerId: 2n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, cdnEndEpoch: 0n, dataSetId: 3n, }, diff --git a/packages/synapse-sdk/src/test/storage.test.ts b/packages/synapse-sdk/src/test/storage.test.ts index 3110e2fe6..bc266490b 100644 --- a/packages/synapse-sdk/src/test/storage.test.ts +++ b/packages/synapse-sdk/src/test/storage.test.ts @@ -293,6 +293,8 @@ describe('StorageService', () => { pdpEndEpoch: 0n, pdpRailId: 1n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, serviceProvider: Mocks.ADDRESSES.serviceProvider1, } const expectedDataSets = [ @@ -408,6 +410,8 @@ describe('StorageService', () => { pdpEndEpoch: 0n, pdpRailId: 1n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, serviceProvider: Mocks.ADDRESSES.serviceProvider1, }, ] @@ -424,6 +428,8 @@ describe('StorageService', () => { pdpEndEpoch: 0n, pdpRailId: 2n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, serviceProvider: Mocks.ADDRESSES.serviceProvider1, }, ] @@ -567,6 +573,8 @@ describe('StorageService', () => { pdpEndEpoch: 0n, pdpRailId: 1n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, serviceProvider: Mocks.ADDRESSES.serviceProvider1, }, { @@ -580,6 +588,8 @@ describe('StorageService', () => { pdpEndEpoch: 0n, pdpRailId: 2n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, serviceProvider: Mocks.ADDRESSES.serviceProvider1, }, ], @@ -649,6 +659,8 @@ describe('StorageService', () => { pdpEndEpoch: 0n, pdpRailId: 1n, providerId: 3n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, serviceProvider: Mocks.ADDRESSES.serviceProvider1, }, ] @@ -694,6 +706,8 @@ describe('StorageService', () => { pdpEndEpoch: 0n, pdpRailId: 1n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, serviceProvider: Mocks.ADDRESSES.serviceProvider1, }, ] @@ -733,6 +747,8 @@ describe('StorageService', () => { pdpEndEpoch: 0n, pdpRailId: 1n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, serviceProvider: Mocks.ADDRESSES.serviceProvider1, }, ] @@ -793,6 +809,8 @@ describe('StorageService', () => { pdpEndEpoch: 0n, pdpRailId: 1n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, serviceProvider: Mocks.ADDRESSES.serviceProvider1, }, ] diff --git a/packages/synapse-sdk/src/test/synapse.test.ts b/packages/synapse-sdk/src/test/synapse.test.ts index b5788c271..57d9f2399 100644 --- a/packages/synapse-sdk/src/test/synapse.test.ts +++ b/packages/synapse-sdk/src/test/synapse.test.ts @@ -385,9 +385,9 @@ describe('Synapse', () => { server.use( Mocks.JSONRPC({ ...Mocks.presets.basic, - warmStorage: { - ...Mocks.presets.basic.warmStorage, - getServicePrice: () => { + warmStorageView: { + ...Mocks.presets.basic.warmStorageView, + getPriceList: () => { throw new Error('RPC error') }, }, @@ -600,6 +600,8 @@ describe('Synapse', () => { pdpEndEpoch: 0n, pdpRailId: dataSetId, providerId: 1n, // Same provider for both + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, serviceProvider: Mocks.ADDRESSES.serviceProvider1, cdnEndEpoch: 0n, }, 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 671464231..8f85b2da2 100644 --- a/packages/synapse-sdk/src/test/warm-storage-service.test.ts +++ b/packages/synapse-sdk/src/test/warm-storage-service.test.ts @@ -135,6 +135,8 @@ describe('WarmStorageService', () => { clientDataSetId: 0n, pdpEndEpoch: 0n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, cdnEndEpoch: 0n, dataSetId: 1n, }, @@ -149,6 +151,8 @@ describe('WarmStorageService', () => { clientDataSetId: 1n, pdpEndEpoch: 0n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, cdnEndEpoch: 0n, dataSetId: 2n, }, @@ -204,6 +208,8 @@ describe('WarmStorageService', () => { clientDataSetId: 0n, pdpEndEpoch: 0n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, cdnEndEpoch: 0n, dataSetId: 1n, }, @@ -225,6 +231,8 @@ describe('WarmStorageService', () => { clientDataSetId: 1n, pdpEndEpoch: 0n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, cdnEndEpoch: 0n, dataSetId: 2n, }, @@ -424,6 +432,8 @@ describe('WarmStorageService', () => { clientDataSetId: 0n, pdpEndEpoch: 0n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, dataSetId: 242n, }, ], @@ -472,6 +482,8 @@ describe('WarmStorageService', () => { clientDataSetId: 0n, pdpEndEpoch: 0n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, dataSetId: 242n, }, { @@ -485,6 +497,8 @@ describe('WarmStorageService', () => { clientDataSetId: 1n, pdpEndEpoch: 0n, providerId: 2n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, dataSetId: 243n, }, ], @@ -545,6 +559,8 @@ describe('WarmStorageService', () => { clientDataSetId: 0n, pdpEndEpoch: 0n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, dataSetId: 242n, }, ], @@ -593,6 +609,8 @@ describe('WarmStorageService', () => { clientDataSetId: 0n, pdpEndEpoch: 0n, providerId: 1n, + pendingOneTimePayments: 0n, + lifecycleReserveBalance: 0n, dataSetId: 242n, }, ], diff --git a/packages/synapse-sdk/src/types.ts b/packages/synapse-sdk/src/types.ts index 46b8c710a..7d3a3dff6 100644 --- a/packages/synapse-sdk/src/types.ts +++ b/packages/synapse-sdk/src/types.ts @@ -11,6 +11,7 @@ import type { SessionKey, SessionKeyAccount } from '@filoz/synapse-core/session- import type { pullPiecesApiRequest } from '@filoz/synapse-core/sp' import type { PDPProvider } from '@filoz/synapse-core/sp-registry' import type { MetadataObject } from '@filoz/synapse-core/utils' +import type { getPriceList, getUploadCosts } from '@filoz/synapse-core/warm-storage' import type { Account, Address, Client, Hash, Hex, Transport } from 'viem' import type { Synapse } from './synapse.ts' import type { WarmStorageService } from './warm-storage/service.ts' @@ -27,12 +28,12 @@ export type { RailInfo } from '@filoz/synapse-core/pay' export type { MetadataEntry, MetadataObject } from '@filoz/synapse-core/utils' // Re-export upload cost types from synapse-core -export type { getUploadCosts } from '@filoz/synapse-core/warm-storage' +export type { getPriceList, getUploadCosts } /** Alias for the upload costs return type */ -export type UploadCosts = import('@filoz/synapse-core/warm-storage').getUploadCosts.OutputType +export type UploadCosts = getUploadCosts.OutputType /** Alias for the upload costs options type */ -export type GetUploadCostsOptions = import('@filoz/synapse-core/warm-storage').getUploadCosts.OptionsType +export type GetUploadCostsOptions = getUploadCosts.OptionsType /** * Options for the fund() method on PaymentsService. @@ -648,6 +649,8 @@ export interface StorageInfo { tokenAddress: Address /** Token symbol (always USDFC for now) */ tokenSymbol: string + /** Canonical warm storage price list */ + priceList: getPriceList.OutputType } /** List of approved service providers */ diff --git a/packages/synapse-sdk/src/warm-storage/service.ts b/packages/synapse-sdk/src/warm-storage/service.ts index 9af8d964b..381a44b0a 100644 --- a/packages/synapse-sdk/src/warm-storage/service.ts +++ b/packages/synapse-sdk/src/warm-storage/service.ts @@ -32,7 +32,7 @@ import { getClientDataSets, getClientDataSetsLength, getDataSet, - getServicePrice, + getPriceList, removeApprovedProvider, terminateService, } from '@filoz/synapse-core/warm-storage' @@ -357,11 +357,11 @@ export class WarmStorageService { // ========== Storage Cost Operations ========== /** - * Get the current service price per TiB per month - * @returns Service price information for both CDN and non-CDN options + * Get the current warm storage price list. + * @returns Recurring rates, operation fees, and lockups. */ - async getServicePrice(): Promise { - return getServicePrice(this._client) + async getPriceList(): Promise { + return getPriceList(this._client) } // ========== Data Set Operations ========== diff --git a/utils/example-storage-e2e.js b/utils/example-storage-e2e.js index 7491d8b1c..a2013333d 100644 --- a/utils/example-storage-e2e.js +++ b/utils/example-storage-e2e.js @@ -81,8 +81,8 @@ async function main() { const { costs, transaction } = await synapse.storage.prepare({ dataSize: BigInt(totalSize) }) console.log('Estimated costs:') - console.log(` Per epoch (30s): ${formatUSDFC(costs.rate.perEpoch)}`) - console.log(` Per month: ${formatUSDFC(costs.rate.perMonth)}`) + console.log(` Per epoch (30s): ${formatUSDFC(costs.rates.perEpoch)}`) + console.log(` Per month: ${formatUSDFC(costs.rates.perMonth)}`) console.log(` Deposit needed: ${formatUSDFC(costs.depositNeeded)}`) console.log(` Ready: ${costs.ready}`)