diff --git a/src/add/add.ts b/src/add/add.ts index ca5a2080..67c08d0c 100644 --- a/src/add/add.ts +++ b/src/add/add.ts @@ -160,8 +160,21 @@ export async function runAdd(options: AddOptions): Promise { const carSize = carData.length spinner.stop(`${pc.green('✓')} IPFS content loaded (${formatFileSize(carSize)})`) + const autoFundOptions: Parameters[3] = { + ...(dataSetMetadata && { metadata: dataSetMetadata }), + ...(options.copies != null && { copies: options.copies }), + } + if (contextSelection.providerIds) { + autoFundOptions.providerIds = contextSelection.providerIds + autoFundOptions.copies = contextSelection.providerIds.length + } + if (contextSelection.dataSetIds) { + autoFundOptions.dataSetIds = contextSelection.dataSetIds + autoFundOptions.copies = contextSelection.dataSetIds.length + } + if (options.autoFund) { - await performAutoFunding(synapse, carSize, spinner) + await performAutoFunding(synapse, carSize, spinner, autoFundOptions) } else { spinner.start('Checking payment capacity...') await validatePaymentSetup(synapse, carSize, spinner) diff --git a/src/common/upload-flow.ts b/src/common/upload-flow.ts index 1868840a..a8f32c40 100644 --- a/src/common/upload-flow.ts +++ b/src/common/upload-flow.ts @@ -88,14 +88,24 @@ export interface UploadFlowResult extends SynapseUploadResult { * @param synapse - Initialized Synapse instance * @param fileSize - Size of file being uploaded (in bytes) * @param spinner - Optional spinner for progress + * @param options - Optional upload targeting inputs used to estimate new data set fees */ -export async function performAutoFunding(synapse: Synapse, fileSize: number, spinner?: Spinner): Promise { +export async function performAutoFunding( + synapse: Synapse, + fileSize: number, + spinner?: Spinner, + options?: Pick +): Promise { spinner?.start('Checking funding requirements for upload...') try { const fundOptions: AutoFundOptions = { synapse, fileSize, + ...(options?.copies != null ? { copies: options.copies } : {}), + ...(options?.providerIds != null ? { providerIds: options.providerIds } : {}), + ...(options?.dataSetIds != null ? { dataSetIds: options.dataSetIds } : {}), + ...(options?.metadata != null ? { metadata: options.metadata } : {}), } if (spinner !== undefined) { fundOptions.spinner = spinner diff --git a/src/core/payments/constants.ts b/src/core/payments/constants.ts index 4cf77b4b..5b427fcd 100644 --- a/src/core/payments/constants.ts +++ b/src/core/payments/constants.ts @@ -28,6 +28,11 @@ export const DEFAULT_LOCKUP_DAYS = 30 */ export const FLOOR_PRICE_PER_30_DAYS = parseUnits('0.06', USDFC_DECIMALS) +/** + * One-time sybil fee charged when creating a new WarmStorage data set + */ +export const USDFC_SYBIL_FEE = parseUnits('0.1', USDFC_DECIMALS) + /** * Number of days the floor price covers */ diff --git a/src/core/payments/funding.ts b/src/core/payments/funding.ts index f4595d0c..5b0542ef 100644 --- a/src/core/payments/funding.ts +++ b/src/core/payments/funding.ts @@ -8,6 +8,7 @@ import { computeAdjustmentForExactDeposit, depositUSDFC, getPaymentStatus, + USDFC_SYBIL_FEE, validatePaymentRequirements, withdrawUSDFC, } from './index.js' @@ -139,6 +140,7 @@ export function calculateFilecoinPayFundingPlan(options: FilecoinPayFundingPlanO targetDeposit, pieceSizeBytes, pricePerTiBPerEpoch, + newDataSetCount = 0, mode = 'exact', allowWithdraw = true, } = options @@ -155,6 +157,10 @@ export function calculateFilecoinPayFundingPlan(options: FilecoinPayFundingPlanO throw new Error('pricePerTiBPerEpoch is required when pieceSizeBytes is provided') } + if (!Number.isInteger(newDataSetCount) || newDataSetCount < 0) { + throw new Error('newDataSetCount must be a non-negative integer') + } + let delta: bigint let projectedDeposit = status.filecoinPayBalance let projectedRateUsed = status.currentAllowances.rateUsed ?? 0n @@ -175,8 +181,9 @@ export function calculateFilecoinPayFundingPlan(options: FilecoinPayFundingPlanO pieceSizeBytes, pricePerTiBPerEpoch ) - delta = adjustment.delta - resolvedTargetDeposit = adjustment.targetDeposit + const dataSetCreationFees = BigInt(newDataSetCount) * USDFC_SYBIL_FEE + delta = adjustment.delta + dataSetCreationFees + resolvedTargetDeposit = adjustment.targetDeposit + dataSetCreationFees projectedRateUsed = adjustment.newRateUsed projectedLockupUsed = adjustment.newLockupUsed @@ -260,6 +267,7 @@ export function calculateFilecoinPayFundingPlan(options: FilecoinPayFundingPlanO ...(resolvedTargetDeposit != null ? { targetDeposit: resolvedTargetDeposit } : {}), ...(pieceSizeBytes != null ? { pieceSizeBytes } : {}), ...(pricePerTiBPerEpoch != null ? { pricePerTiBPerEpoch } : {}), + ...(newDataSetCount > 0 ? { newDataSetCount } : {}), ...(walletShortfall != null ? { walletShortfall } : {}), } @@ -280,6 +288,7 @@ export interface PlanFilecoinPayFundingOptions { targetDeposit?: bigint | undefined pieceSizeBytes?: number | undefined pricePerTiBPerEpoch?: bigint | undefined + newDataSetCount?: number | undefined mode?: FundingMode | undefined allowWithdraw?: boolean | undefined ensureAllowances?: boolean | undefined @@ -313,6 +322,7 @@ export async function planFilecoinPayFunding(options: PlanFilecoinPayFundingOpti targetDeposit, pieceSizeBytes, pricePerTiBPerEpoch, + newDataSetCount = 0, mode = 'exact', allowWithdraw = true, ensureAllowances = false, @@ -364,6 +374,7 @@ export async function planFilecoinPayFunding(options: PlanFilecoinPayFundingOpti targetDeposit, pieceSizeBytes, pricePerTiBPerEpoch: pricing, + newDataSetCount, mode, allowWithdraw, }) diff --git a/src/core/payments/types.ts b/src/core/payments/types.ts index 32e7e625..5b47c53c 100644 --- a/src/core/payments/types.ts +++ b/src/core/payments/types.ts @@ -106,6 +106,7 @@ export interface FilecoinPayFundingPlanOptions { targetDeposit?: bigint | undefined pieceSizeBytes?: number | undefined pricePerTiBPerEpoch?: bigint | undefined + newDataSetCount?: number | undefined mode?: FundingMode | undefined allowWithdraw?: boolean | undefined } @@ -131,6 +132,7 @@ export interface FilecoinPayFundingPlan { mode: FundingMode pieceSizeBytes?: number pricePerTiBPerEpoch?: bigint + newDataSetCount?: number projectedDeposit: bigint projectedRateUsed: bigint projectedLockupUsed: bigint diff --git a/src/import/import.ts b/src/import/import.ts index 4e83066e..fdb87c80 100644 --- a/src/import/import.ts +++ b/src/import/import.ts @@ -202,7 +202,20 @@ export async function runCarImport(options: ImportOptions): Promise[3] = { + ...(dataSetMetadata && { metadata: dataSetMetadata }), + ...(options.copies != null && { copies: options.copies }), + } + if (contextSelection.providerIds) { + autoFundOptions.providerIds = contextSelection.providerIds + autoFundOptions.copies = contextSelection.providerIds.length + } + if (contextSelection.dataSetIds) { + autoFundOptions.dataSetIds = contextSelection.dataSetIds + autoFundOptions.copies = contextSelection.dataSetIds.length + } + + await performAutoFunding(synapse, fileStat.size, spinner, autoFundOptions) } else { spinner.start('Checking payment capacity...') await validatePaymentSetup(synapse, fileStat.size, spinner) diff --git a/src/payments/fund.ts b/src/payments/fund.ts index 29932d2c..b6fe9ecc 100644 --- a/src/payments/fund.ts +++ b/src/payments/fund.ts @@ -134,14 +134,23 @@ async function printSummary(synapse: Synapse, title = 'Updated'): Promise * @throws Error if adjustment fails or target is unsafe */ export async function autoFund(options: AutoFundOptions): Promise { - const { synapse, fileSize, spinner } = options + const { synapse, fileSize, copies, providerIds, dataSetIds, metadata, spinner } = options spinner?.message('Checking wallet readiness...') + const contexts = await synapse.storage.createContexts({ + ...(copies != null ? { copies } : {}), + ...(providerIds != null ? { providerIds } : {}), + ...(dataSetIds != null ? { dataSetIds } : {}), + ...(metadata != null ? { metadata } : {}), + }) + const newDataSetCount = contexts.filter((context) => context.dataSetId == null).length + const planResult = await planFilecoinPayFunding({ synapse, targetRunwayDays: MIN_RUNWAY_DAYS, pieceSizeBytes: fileSize, + newDataSetCount, ensureAllowances: true, allowWithdraw: false, }) diff --git a/src/payments/types.ts b/src/payments/types.ts index 4f07237a..f253e26c 100644 --- a/src/payments/types.ts +++ b/src/payments/types.ts @@ -23,6 +23,14 @@ export interface AutoFundOptions { synapse: Synapse /** Size of file being uploaded (in bytes) - used to calculate additional funding needed */ fileSize: number + /** Number of storage copies to create */ + copies?: number + /** Specific provider IDs to upload to */ + providerIds?: bigint[] + /** Specific existing data set IDs to target */ + dataSetIds?: bigint[] + /** Data set metadata applied when creating or matching contexts */ + metadata?: Record /** Optional spinner for progress updates */ spinner?: Spinner } diff --git a/src/test/unit/add.test.ts b/src/test/unit/add.test.ts index 83e0559a..7e52f9f6 100644 --- a/src/test/unit/add.test.ts +++ b/src/test/unit/add.test.ts @@ -17,6 +17,7 @@ import { runAdd } from '../../add/add.js' // Mock the external dependencies at module level vi.mock('../../common/upload-flow.js', () => ({ validatePaymentSetup: vi.fn(), + performAutoFunding: vi.fn(), performUpload: vi.fn().mockResolvedValue({ pieceCid: 'bafkzcibtest1234567890', size: 1024, @@ -242,6 +243,30 @@ describe('Add Command', () => { ) }) + it('passes upload targeting options through to auto-funding', async () => { + await runAdd({ + filePath: testFile, + privateKey: 'test-private-key', + rpcUrl: 'wss://test.rpc.url', + autoFund: true, + providerIds: '7,8', + dataSetMetadata: { purpose: 'erc8004' }, + }) + + const { performAutoFunding } = await import('../../common/upload-flow.js') + + expect(vi.mocked(performAutoFunding)).toHaveBeenCalledWith( + expect.anything(), + expect.any(Number), + expect.anything(), + expect.objectContaining({ + providerIds: [7n, 8n], + copies: 2, + metadata: { purpose: 'erc8004' }, + }) + ) + }) + it('should reject when file does not exist', async () => { const mockExit = vi.spyOn(process, 'exit') diff --git a/src/test/unit/import.test.ts b/src/test/unit/import.test.ts index 9af0a899..38c90c38 100644 --- a/src/test/unit/import.test.ts +++ b/src/test/unit/import.test.ts @@ -402,6 +402,33 @@ describe('CAR Import', () => { }) ) }) + + it('passes upload targeting options through to auto-funding', async () => { + const carPath = join(testDir, 'auto-fund.car') + await createTestCarFile(carPath, [], [{ content: 'test content' }]) + + const options: ImportOptions = { + filePath: carPath, + privateKey: testPrivateKey, + autoFund: true, + dataSetIds: '123', + dataSetMetadata: { erc8004Files: '' }, + } + + await runCarImport(options) + + const { performAutoFunding } = await import('../../common/upload-flow.js') + expect(vi.mocked(performAutoFunding)).toHaveBeenCalledWith( + expect.anything(), + expect.any(Number), + expect.anything(), + expect.objectContaining({ + dataSetIds: [123n], + copies: 1, + metadata: { erc8004Files: '' }, + }) + ) + }) }) describe('Upload Result', () => { diff --git a/src/test/unit/payments-funding.test.ts b/src/test/unit/payments-funding.test.ts index 42936487..e352a323 100644 --- a/src/test/unit/payments-funding.test.ts +++ b/src/test/unit/payments-funding.test.ts @@ -2,6 +2,7 @@ import { TIME_CONSTANTS } from '@filoz/synapse-sdk' import { beforeEach, describe, expect, it, vi } from 'vitest' import * as paymentsIndex from '../../core/payments/index.js' import { + calculateFilecoinPayFundingPlan, executeFilecoinPayFunding, getFilecoinPayFundingInsights, type PaymentStatus, @@ -195,6 +196,33 @@ describe('planFilecoinPayFunding', () => { expect(plan.delta).toBeGreaterThan(0n) expect(plan.reasonCode).toBe('runway-with-piece') }) + + it('adds sybil fees for new data sets in the shared funding plan', () => { + const status = makeStatus({ filecoinPayBalance: 0n, wallet: 1_000_000_000_000_000_000n }) + + const basePlan = calculateFilecoinPayFundingPlan({ + status, + targetRunwayDays: 30, + pieceSizeBytes: 1024, + pricePerTiBPerEpoch: 1n, + newDataSetCount: 0, + mode: 'minimum', + allowWithdraw: false, + }) + + const withFeesPlan = calculateFilecoinPayFundingPlan({ + status, + targetRunwayDays: 30, + pieceSizeBytes: 1024, + pricePerTiBPerEpoch: 1n, + newDataSetCount: 2, + mode: 'minimum', + allowWithdraw: false, + }) + + expect(withFeesPlan.delta - basePlan.delta).toBe(200_000_000_000_000_000n) + expect(withFeesPlan.targetDeposit).toBe((basePlan.targetDeposit ?? 0n) + 200_000_000_000_000_000n) + }) }) describe('executeFilecoinPayFunding', () => { diff --git a/upload-action/src/filecoin.js b/upload-action/src/filecoin.js index d4f19c98..33722b60 100644 --- a/upload-action/src/filecoin.js +++ b/upload-action/src/filecoin.js @@ -18,7 +18,7 @@ import { getErrorMessage } from './errors.js' * @typedef {import('./types.js').UploadResult} UploadResult * @typedef {import('./types.js').PaymentStatus} PaymentStatus * @typedef {import('./types.js').SimplifiedPaymentStatus} SimplifiedPaymentStatus - * @typedef {import('./types.js').PaymentConfig} PaymentConfig + * @typedef {import('./types.js').PaymentFundingConfig} PaymentFundingConfig * @typedef {import('./types.js').UploadConfig} UploadConfig * @typedef {import('./types.js').FilecoinPinPaymentStatus} FilecoinPinPaymentStatus * @typedef {import('./types.js').Synapse} Synapse @@ -50,15 +50,22 @@ export async function createCarFile(targetPath, contentPath, logger) { /** * Handle payment setup and top-ups using core payment functions * @param {Synapse} synapse - Synapse service - * @param {PaymentConfig} options - Payment options + * @param {PaymentFundingConfig} options - Payment options * @param {Logger | undefined} logger - Logger instance * @returns {Promise} Updated payment status */ export async function handlePayments(synapse, options, logger) { - const { minStorageDays, filecoinPayBalanceLimit, pieceSizeBytes } = options + const { minStorageDays, filecoinPayBalanceLimit, pieceSizeBytes, withCDN, providerIds } = options console.log('Checking current Filecoin Pay account balance...') - const [rawStatus, storageInfo] = await Promise.all([getPaymentStatus(synapse), synapse.storage.getStorageInfo()]) + const [rawStatus, storageInfo, contexts] = await Promise.all([ + getPaymentStatus(synapse), + synapse.storage.getStorageInfo(), + synapse.storage.createContexts({ + ...(providerIds != null && providerIds.length > 0 ? { providerIds } : {}), + ...(withCDN ? { withCDN } : {}), + }), + ]) const initialFilecoinPayBalance = formatUSDFC(rawStatus.filecoinPayBalance) const initialWalletBalance = formatUSDFC(rawStatus.walletUsdfcBalance) @@ -66,6 +73,8 @@ export async function handlePayments(synapse, options, logger) { console.log(`Current Filecoin Pay balance: ${initialFilecoinPayBalance} USDFC`) console.log(`Wallet USDFC balance: ${initialWalletBalance} USDFC`) + const newDataSetCount = contexts.filter((context) => context.dataSetId == null).length + // Calculate required funding using the comprehensive funding planner const fundingPlan = calculateFilecoinPayFundingPlan({ status: rawStatus, @@ -74,6 +83,7 @@ export async function handlePayments(synapse, options, logger) { targetRunwayDays: minStorageDays, pieceSizeBytes, pricePerTiBPerEpoch: storageInfo.pricing.noCDN.perTiBPerEpoch, + newDataSetCount, }) if (fundingPlan.delta > 0n) { @@ -81,6 +91,13 @@ export async function handlePayments(synapse, options, logger) { console.log(`\n${reasonMessage}: ${formatUSDFC(fundingPlan.delta)} USDFC`) } + if (newDataSetCount > 0) { + console.log( + `Additional funding for ${newDataSetCount} new data set${newDataSetCount === 1 ? '' : 's'} ` + + '(sybil fee) is included in the planned top-up' + ) + } + // Execute top-up with balance limit checking const topUpResult = await executeTopUp(synapse, fundingPlan.delta, { balanceLimit: filecoinPayBalanceLimit, diff --git a/upload-action/src/types.ts b/upload-action/src/types.ts index 14da76df..498a4516 100644 --- a/upload-action/src/types.ts +++ b/upload-action/src/types.ts @@ -88,6 +88,11 @@ export interface PaymentConfig { pieceSizeBytes?: number | undefined } +export interface PaymentFundingConfig extends PaymentConfig { + withCDN: boolean + providerIds?: bigint[] | undefined +} + export interface UploadConfig { withCDN: boolean providerIds?: bigint[] | undefined diff --git a/upload-action/src/upload.js b/upload-action/src/upload.js index 601201d5..ba9c2472 100644 --- a/upload-action/src/upload.js +++ b/upload-action/src/upload.js @@ -165,6 +165,8 @@ export async function runUpload(buildContext = {}) { minStorageDays, filecoinPayBalanceLimit, pieceSizeBytes: context.carSize, + withCDN, + providerIds, }, logger )