diff --git a/packages/synapse-sdk/src/storage/context.ts b/packages/synapse-sdk/src/storage/context.ts index 67e6c39..c34a8bf 100644 --- a/packages/synapse-sdk/src/storage/context.ts +++ b/packages/synapse-sdk/src/storage/context.ts @@ -30,6 +30,7 @@ import { SPRegistryService } from '../sp-registry/index.ts' import type { ProviderInfo } from '../sp-registry/types.ts' import type { Synapse } from '../synapse.ts' import type { + CreateContextsOptions, DownloadOptions, EnhancedDataSetInfo, MetadataEntry, @@ -172,6 +173,56 @@ export class StorageContext { this._pdpServer = new PDPServer(authHelper, provider.products.PDP.data.serviceURL) } + static async createContexts( + synapse: Synapse, + warmStorageService: WarmStorageService, + options: CreateContextsOptions = {} + ): Promise { + const count = options?.count ?? 2 + const resolutions: ProviderSelectionResult[] = [] + const clientAddress = await synapse.getClient().getAddress() + const registryAddress = warmStorageService.getServiceProviderRegistryAddress() + const spRegistry = new SPRegistryService(synapse.getProvider(), registryAddress) + const providerResolver = new ProviderResolver(warmStorageService, spRegistry) + if (options?.providerIds) { + for (const providerId of options.providerIds) { + const resolution = await StorageContext.resolveByProviderId( + clientAddress, + providerId, + options.metadata ?? {}, + warmStorageService, + providerResolver, + options.forceCreateDataSets + ) + resolutions.push(resolution) + if (resolutions.length >= count) { + break + } + } + } else if (options?.providerAddresses) { + for (const providerAddress of options.providerAddresses) { + const resolution = await StorageContext.resolveByProviderAddress( + providerAddress, + warmStorageService, + providerResolver, + clientAddress, + options.metadata ?? {}, + options.forceCreateDataSets + ) + resolutions.push(resolution) + if (resolutions.length >= count) { + break + } + } + } + return await Promise.all( + resolutions.map( + async (resolution) => + await StorageContext.createWithSelectedProvider(resolution, synapse, warmStorageService, options) + ) + ) + } + /** * Static factory method to create a StorageContext * Handles provider selection and data set selection/creation @@ -194,6 +245,15 @@ export class StorageContext { options ) + return await StorageContext.createWithSelectedProvider(resolution, synapse, warmStorageService, options) + } + + static async createWithSelectedProvider( + resolution: ProviderSelectionResult, + synapse: Synapse, + warmStorageService: WarmStorageService, + options: StorageServiceOptions = {} + ): Promise { // Notify callback about provider selection try { options.callbacks?.onProviderSelected?.(resolution.provider) diff --git a/packages/synapse-sdk/src/storage/manager.ts b/packages/synapse-sdk/src/storage/manager.ts index bb4f826..6f569c1 100644 --- a/packages/synapse-sdk/src/storage/manager.ts +++ b/packages/synapse-sdk/src/storage/manager.ts @@ -25,6 +25,7 @@ import { asPieceCID, downloadAndValidate } from '../piece/index.ts' import { SPRegistryService } from '../sp-registry/index.ts' import type { Synapse } from '../synapse.ts' import type { + CreateContextsOptions, DownloadOptions, EnhancedDataSetInfo, PieceCID, @@ -231,6 +232,15 @@ export class StorageManager { return await StorageContext.performPreflightCheck(this._warmStorageService, this._synapse.payments, size, withCDN) } + async createContexts(options?: CreateContextsOptions): Promise { + return await StorageContext.createContexts(this._synapse, this._warmStorageService, { + ...options, + withCDN: options?.withCDN ?? this._withCDN, + withIpni: options?.withIpni ?? this._withIpni, + dev: options?.dev ?? this._dev, + }) + } + /** * Create a new storage context with specified options */ diff --git a/packages/synapse-sdk/src/test/mocks/jsonrpc/service-registry.ts b/packages/synapse-sdk/src/test/mocks/jsonrpc/service-registry.ts index 6c7801a..dc4317b 100644 --- a/packages/synapse-sdk/src/test/mocks/jsonrpc/service-registry.ts +++ b/packages/synapse-sdk/src/test/mocks/jsonrpc/service-registry.ts @@ -1,6 +1,7 @@ /** biome-ignore-all lint/style/noNonNullAssertion: testing */ import type { ExtractAbiFunction } from 'abitype' +import { assert } from 'chai' import { decodeFunctionData, encodeAbiParameters, type Hex } from 'viem' import { CONTRACT_ABIS } from '../../../utils/constants.ts' import type { AbiToType, JSONRPCOptions } from './types.ts' @@ -28,6 +29,87 @@ export interface ServiceRegistryOptions { getProvider?: (args: AbiToType) => AbiToType } +export type ServiceProviderInfoView = AbiToType[0] +export type PDPServiceInfoView = AbiToType + +const EMPTY_PROVIDER: ServiceProviderInfoView = { + providerId: 0n, + info: { + serviceProvider: '0x0000000000000000000000000000000000000000', + payee: '0x0000000000000000000000000000000000000000', + name: '', + description: '', + isActive: false, + }, +} + +const EMPTY_PDP_SERVICE: PDPServiceInfoView = [ + { + serviceURL: '', + minPieceSizeInBytes: 0n, + maxPieceSizeInBytes: 0n, + ipniPiece: false, + ipniIpfs: false, + storagePricePerTibPerMonth: 0n, + minProvingPeriodInEpochs: 0n, + location: '', + paymentTokenAddress: '0x0000000000000000000000000000000000000000', + }, + [], // capabilityKeys + false, // isActive +] + +export function mockServiceProviderRegistry( + providers: ServiceProviderInfoView[], + services?: (PDPServiceInfoView | null)[] +): ServiceRegistryOptions { + assert.isAtMost(services?.length ?? 0, providers.length) + return { + getProvider: ([providerId]) => { + if (providerId < 0n || providerId > providers.length) { + throw new Error('Provider does not exist') + } + for (const provider of providers) { + if (providerId === provider.providerId) { + return [provider] + } + } + throw new Error('Provider not found') + }, + getPDPService: ([providerId]) => { + if (!services) { + return EMPTY_PDP_SERVICE + } + for (let i = 0; i < services.length; i++) { + if (providers[i].providerId === providerId) { + const service = services[i] + if (service == null) { + return EMPTY_PDP_SERVICE + } + return service + } + } + return EMPTY_PDP_SERVICE + }, + getProviderByAddress: ([address]) => { + for (const provider of providers) { + if (address === provider.info.serviceProvider) { + return [provider] + } + } + return [EMPTY_PROVIDER] + }, + getProviderIdByAddress: ([address]) => { + for (const provider of providers) { + if (address === provider.info.serviceProvider) { + return [provider.providerId] + } + } + return [0n] + }, + } +} + /** * Handle service provider registry calls */ diff --git a/packages/synapse-sdk/src/test/sp-registry-service.test.ts b/packages/synapse-sdk/src/test/sp-registry-service.test.ts index 6540fe1..c3664fb 100644 --- a/packages/synapse-sdk/src/test/sp-registry-service.test.ts +++ b/packages/synapse-sdk/src/test/sp-registry-service.test.ts @@ -53,7 +53,6 @@ describe('SPRegistryService', () => { return { providerId: 1, info: { - id: BigInt(1), serviceProvider: mockProviderAddress, payee: mockProviderAddress, name: 'Test Provider', @@ -64,26 +63,13 @@ describe('SPRegistryService', () => { } throw new Error('Provider not found') }, - getProviderProducts: async (id: number) => { - if (id === 1) { - return [ - { - productType: 0, // PDP - isActive: true, - capabilityKeys: [], - productData: '0x', // Encoded PDP offering - }, - ] - } - return [] - }, providerHasProduct: async (id: number, productType: number) => { return id === 1 && productType === 0 }, getPDPService: async (id: number) => { if (id === 1) { return { - offering: { + pdpOffering: { serviceURL: 'https://provider.example.com', minPieceSizeInBytes: SIZE_CONSTANTS.KiB, maxPieceSizeInBytes: SIZE_CONSTANTS.GiB, @@ -415,7 +401,20 @@ describe('SPRegistryService', () => { // Override to return provider without products ;(service as any)._getRegistryContract = () => ({ ...createMockContract(), - getProviderProducts: async () => [], + getPDPService: async () => ({ + pdpOffering: { + serviceURL: '', + minPieceSizeInBytes: 0, + maxPieceSizeInBytes: 0, + ipniPiece: false, + ipniIpfs: false, + minProvingPeriodInEpochs: 0, + storagePricePerTibPerMonth: 0, + location: '', + }, + capabilityKeys: [], + isActive: false, + }), }) const provider = await service.getProvider(1) diff --git a/packages/synapse-sdk/src/test/storage.test.ts b/packages/synapse-sdk/src/test/storage.test.ts index 0fa1cd9..7b8ea3d 100644 --- a/packages/synapse-sdk/src/test/storage.test.ts +++ b/packages/synapse-sdk/src/test/storage.test.ts @@ -5,6 +5,7 @@ import { StorageContext } from '../storage/context.ts' import type { Synapse } from '../synapse.ts' import type { PieceCID, ProviderInfo, UploadResult } from '../types.ts' import { SIZE_CONSTANTS } from '../utils/constants.ts' +import { ADDRESSES } from './mocks/jsonrpc/index.ts' import { createMockProviderInfo, createSimpleProvider, setupProviderRegistryMocks } from './test-utils.ts' // Create a mock Ethereum provider that doesn't try to connect @@ -143,7 +144,7 @@ function createMockWarmStorageService(providers: ProviderInfo[], dataSets: any[] return provider?.id ?? 0 }, getApprovedProvider: async (id: number) => providers.find((p) => p.id === id) ?? null, - getServiceProviderRegistryAddress: () => '0x0000000000000000000000000000000000000001', + getServiceProviderRegistryAddress: () => ADDRESSES.calibration.spRegistry, getApprovedProviderIds: async () => providers.map((p) => p.id), isProviderIdApproved: async (id: number) => providers.some((p) => p.id === id), getDataSetMetadata: async (dataSetId: number) => { diff --git a/packages/synapse-sdk/src/test/synapse.test.ts b/packages/synapse-sdk/src/test/synapse.test.ts index 31fec3e..6fa11c4 100644 --- a/packages/synapse-sdk/src/test/synapse.test.ts +++ b/packages/synapse-sdk/src/test/synapse.test.ts @@ -15,6 +15,8 @@ import { PDP_PERMISSIONS } from '../session/key.ts' import { Synapse } from '../synapse.ts' import { makeDataSetCreatedLog } from './mocks/events.ts' import { ADDRESSES, JSONRPC, PRIVATE_KEYS, presets } from './mocks/jsonrpc/index.ts' +import type { PDPServiceInfoView, ServiceProviderInfoView } from './mocks/jsonrpc/service-registry.ts' +import { mockServiceProviderRegistry } from './mocks/jsonrpc/service-registry.ts' import { createDataSetHandler, dataSetCreationStatusHandler, type PDPMockOptions } from './mocks/pdp/handlers.ts' import { PING } from './mocks/ping.ts' @@ -327,10 +329,7 @@ describe('Synapse', () => { }, payments: { ...presets.basic.payments, - operatorApprovals: (args) => { - const token = args[0] - const client = args[1] - const operator = args[2] + operatorApprovals: ([token, client, operator]) => { assert.equal(token, ADDRESSES.calibration.usdfcToken) assert.equal(client, signerAddress) assert.equal(operator, ADDRESSES.calibration.warmStorage) @@ -343,9 +342,7 @@ describe('Synapse', () => { BigInt(28800), // maxLockupPeriod ] }, - accounts: (args) => { - const token = args[0] - const user = args[1] + accounts: ([token, user]) => { assert.equal(user, signerAddress) assert.equal(token, ADDRESSES.calibration.usdfcToken) return [BigInt(127001 * 635000000), BigInt(0), BigInt(0), BigInt(0)] @@ -768,4 +765,154 @@ describe('Synapse', () => { } }) }) + + describe('createContexts', () => { + let synapse: Synapse + const mockProviders: ServiceProviderInfoView[] = [ + { + providerId: 1n, + info: { + serviceProvider: ADDRESSES.serviceProvider1, + payee: ADDRESSES.serviceProvider1, + name: 'serviceProvider1', + description: 'mockProviders[0]', + isActive: true, + }, + }, + { + providerId: 2n, + info: { + serviceProvider: ADDRESSES.serviceProvider2, + payee: ADDRESSES.serviceProvider2, + name: 'serviceProvider2', + description: 'mockProviders[1]', + isActive: true, + }, + }, + ] + const mockPDPProducts: PDPServiceInfoView[] = [ + [ + { + serviceURL: 'http://serviceProvider1.com', + minPieceSizeInBytes: 0n, + maxPieceSizeInBytes: 1n << 32n, + ipniPiece: false, + ipniIpfs: false, + storagePricePerTibPerMonth: 1000n, + minProvingPeriodInEpochs: 3n, + location: 'narnia', + paymentTokenAddress: ADDRESSES.calibration.usdfcToken, + }, + [], // capabilityKeys + true, // isActive + ], + [ + { + serviceURL: 'http://serviceProvider2.org', + minPieceSizeInBytes: 0n, + maxPieceSizeInBytes: 1n << 32n, + ipniPiece: false, + ipniIpfs: false, + storagePricePerTibPerMonth: 1000n, + minProvingPeriodInEpochs: 3n, + location: 'krypton', + paymentTokenAddress: ADDRESSES.calibration.usdfcToken, + }, + [], // capabilityKeys + true, // isActive + ], + ] + + const DATA_SET_ID = 7 + + const FAKE_TX_HASH = '0x3816d82cb7a6f5cde23f4d63c0763050d13c6b6dc659d0a7e6eba80b0ec76a18' + + const FAKE_TX = { + hash: FAKE_TX_HASH, + from: ADDRESSES.serviceProvider1, + gas: '0x5208', + value: '0x0', + nonce: '0x444', + input: '0x', + v: '0x01', + r: '0x4e2eef88cc6f2dc311aa3b1c8729b6485bd606960e6ae01522298278932c333a', + s: '0x5d0e08d8ecd6ed8034aa956ff593de9dc1d392e73909ef0c0f828918b58327c9', + } + + const FAKE_RECEIPT = { + ...FAKE_TX, + transactionHash: FAKE_TX_HASH, + transactionIndex: '0x10', + blockHash: '0xb91b7314248aaae06f080ad427dbae78b8c5daf72b2446cf843739aef80c6417', + status: '0x1', + blockNumber: '0x127001', + cumulativeGasUsed: '0x52080', + gasUsed: '0x5208', + logs: [makeDataSetCreatedLog(DATA_SET_ID, 1)], + } + + beforeEach(async () => { + server.use( + JSONRPC({ + ...presets.basic, + serviceRegistry: mockServiceProviderRegistry(mockProviders, mockPDPProducts), + eth_getTransactionByHash: (params) => { + const hash = params[0] + assert.equal(hash, FAKE_TX_HASH) + return FAKE_TX + }, + eth_getTransactionReceipt: (params) => { + const hash = params[0] + assert.equal(hash, FAKE_TX_HASH) + return FAKE_RECEIPT + }, + }) + ) + synapse = await Synapse.create({ signer }) + for (const [mockProduct] of mockPDPProducts) { + const pdpOptions: PDPMockOptions = { + baseUrl: mockProduct.serviceURL, + } + server.use(createDataSetHandler(FAKE_TX_HASH, pdpOptions)) + server.use( + dataSetCreationStatusHandler( + FAKE_TX_HASH, + { + ok: true, + dataSetId: DATA_SET_ID, + createMessageHash: '', + dataSetCreated: true, + service: '', + txStatus: '', + }, + pdpOptions + ) + ) + } + }) + + it('selects specified providerIds', async () => { + const contexts = await synapse.storage.createContexts({ + providerIds: [mockProviders[0].providerId, mockProviders[1].providerId].map(Number), + }) + assert.equal(contexts.length, 2) + assert.equal(BigInt(contexts[0].provider.id), mockProviders[0].providerId) + assert.equal(BigInt(contexts[1].provider.id), mockProviders[1].providerId) + // created new data sets; got the mocked data set id + assert.equal((contexts[0] as any)._dataSetId, DATA_SET_ID) + assert.equal((contexts[1] as any)._dataSetId, DATA_SET_ID) + }) + + it('selects providers specified by address', async () => { + const contexts = await synapse.storage.createContexts({ + providerAddresses: [mockProviders[1].info.serviceProvider, mockProviders[0].info.serviceProvider], + }) + assert.equal(contexts.length, 2) + assert.equal(BigInt(contexts[1].provider.id), mockProviders[0].providerId) + assert.equal(BigInt(contexts[0].provider.id), mockProviders[1].providerId) + // created new data sets; got the mocked data set id + assert.equal((contexts[1] as any)._dataSetId, DATA_SET_ID) + assert.equal((contexts[0] as any)._dataSetId, DATA_SET_ID) + }) + }) }) diff --git a/packages/synapse-sdk/src/test/test-utils.ts b/packages/synapse-sdk/src/test/test-utils.ts index 0f2ebc8..0487ec4 100644 --- a/packages/synapse-sdk/src/test/test-utils.ts +++ b/packages/synapse-sdk/src/test/test-utils.ts @@ -20,6 +20,7 @@ import type { ProviderInfo } from '../sp-registry/types.ts' import { CONTRACT_ABIS, CONTRACT_ADDRESSES, SIZE_CONSTANTS, TIME_CONSTANTS } from '../utils/constants.ts' import { ProviderResolver } from '../utils/provider-resolver.ts' import type { WarmStorageService } from '../warm-storage/index.ts' +import { ADDRESSES } from './mocks/jsonrpc/index.ts' /** * Addresses used by testing @@ -151,7 +152,7 @@ export function createMockProvider(chainId: number = 314159): ethers.Provider { } // serviceProviderRegistry() - function selector: 0x05f892ec if (data?.startsWith('0x05f892ec') === true) { - return ethers.AbiCoder.defaultAbiCoder().encode(['address'], ['0x0000000000000000000000000000000000000001']) + return ethers.AbiCoder.defaultAbiCoder().encode(['address'], [ADDRESSES.calibration.spRegistry]) } // sessionKeyRegistry() - function selector: 0x9f6aa572 if (data?.startsWith('0x9f6aa572') === true) { @@ -459,7 +460,7 @@ export function createCustomMulticall3Mock( customAddresses?.usdfcToken ?? CONTRACT_ADDRESSES.USDFC.calibration, // usdfcToken customAddresses?.filCDN ?? '0x0000000000000000000000000000000000000000', // filCDN (not used) customAddresses?.viewContract ?? MOCK_ADDRESSES.WARM_STORAGE_VIEW, // viewContract - customAddresses?.spRegistry ?? '0x0000000000000000000000000000000000000001', // spRegistry + customAddresses?.spRegistry ?? ADDRESSES.calibration.spRegistry, // spRegistry customAddresses?.sessionKeyRegistry ?? MOCK_ADDRESSES.SESSION_KEY_REGISTRY, // sessionKeyRegistry ] @@ -639,10 +640,7 @@ export function setupProviderRegistryMocks( if (callData.startsWith('0xab2b3ae5')) { return { success: true, - returnData: ethers.AbiCoder.defaultAbiCoder().encode( - ['address'], - ['0x0000000000000000000000000000000000000001'] - ), + returnData: ethers.AbiCoder.defaultAbiCoder().encode(['address'], [ADDRESSES.calibration.spRegistry]), } } } @@ -660,7 +658,7 @@ export function setupProviderRegistryMocks( // Mock getProvider(uint256) calls to SPRegistry // Check if it's to the SPRegistry address - if (callData.startsWith('0x5c42d079') && target === '0x0000000000000000000000000000000000000001') { + if (callData.startsWith('0x5c42d079') && target === ADDRESSES.calibration.spRegistry) { const providerId = parseInt(callData.slice(10, 74), 16) const provider = providers.find((p) => p.id === providerId) if (provider) { @@ -694,7 +692,7 @@ export function setupProviderRegistryMocks( } // Mock getPDPService(uint256) calls - if (callData.startsWith('0xc439fd57') && target === '0x0000000000000000000000000000000000000001') { + if (callData.startsWith('0xc439fd57') && target === ADDRESSES.calibration.spRegistry) { const providerId = parseInt(callData.slice(10, 74), 16) const provider = providers.find((p) => p.id === providerId) if (provider?.products?.PDP) { @@ -824,47 +822,6 @@ export function setupProviderRegistryMocks( ) } - // Mock getProviderProducts(uint256) - returns products for provider - if (data?.startsWith('0xb5eb46e1')) { - const providerId = parseInt(data.slice(10, 74), 16) - const provider = providers.find((p) => p.id === providerId) - if (provider?.products?.PDP) { - const pdp = provider.products.PDP - - // Encode PDP product data (simplified for testing) - const encodedPDP = ethers.AbiCoder.defaultAbiCoder().encode( - ['string', 'uint256', 'uint256', 'bool', 'bool', 'uint256', 'uint256', 'string', 'address'], - [ - pdp.data.serviceURL, - pdp.data.minPieceSizeInBytes, - pdp.data.maxPieceSizeInBytes, - pdp.data.ipniPiece, - pdp.data.ipniIpfs, - pdp.data.storagePricePerTibPerMonth, - pdp.data.minProvingPeriodInEpochs, - pdp.data.location || '', - pdp.data.paymentTokenAddress, - ] - ) - - return ethers.AbiCoder.defaultAbiCoder().encode( - ['tuple(uint8,bool,bytes32[],bytes)[]'], - [ - [ - [ - 0, // productType: PDP - pdp.isActive, - pdp.capabilities ?? [], // capabilityKeys (empty for simplicity) - encodedPDP, - ], - ], - ] - ) - } - // Return empty products array - return ethers.AbiCoder.defaultAbiCoder().encode(['tuple(uint8,bool,bytes32[],bytes)[]'], [[]]) - } - // Mock decodePDPOffering(bytes) - decode PDP product data if (data?.startsWith('0xdeb0e462')) { // For simplicity, return a default PDP offering diff --git a/packages/synapse-sdk/src/types.ts b/packages/synapse-sdk/src/types.ts index bf7cb97..fd2398b 100644 --- a/packages/synapse-sdk/src/types.ts +++ b/packages/synapse-sdk/src/types.ts @@ -320,6 +320,42 @@ export interface StorageCreationCallbacks { }) => void } +export interface CreateContextsOptions { + /** Number of contexts to create (optional, defaults to 2) */ + count?: number + /** + * Specific provider IDs to use (if not using providerAddresses) + * Must be no longer than count + */ + providerIds?: number[] + /** + * Specific provider addresses to use (if not using providerIds) + * Must be no longer than count + */ + providerAddresses?: string[] + /** + * Specific data set IDs to use + * Cannot be used with provider options + * Must be no longer than count + */ + dataSetIds?: number[] + /** Whether to enable CDN services */ + withCDN?: boolean + withIpni?: boolean + dev?: boolean + /** + * Custom metadata for the data sets (key-value pairs) + * When smart-selecting data sets, this metadata will be used to match. + */ + metadata?: Record + /** Create new data sets, even if candidates exist */ + forceCreateDataSets?: boolean + /** Callbacks for creation process (will need to change to handle multiples) */ + callbacks?: StorageCreationCallbacks + /** Maximum number of uploads to process in a single batch (default: 32, minimum: 1) */ + uploadBatchSize?: number +} + /** * Options for creating or selecting a storage context * @@ -553,8 +589,6 @@ export interface ProviderSelectionResult { provider: ProviderInfo /** Selected data set ID */ dataSetId: number - /** Whether this is a new data set that was created */ - isNewDataSet?: boolean /** Whether this is an existing data set */ isExisting?: boolean /** Data set metadata */