filecoin-pin
Version:
Bridge IPFS content to Filecoin Onchain Cloud using familiar tools
382 lines (346 loc) • 14.5 kB
text/typescript
import type { ProviderInfo } from '@filoz/synapse-sdk'
import type { CID } from 'multiformats/cid'
import type { Logger } from 'pino'
import { getErrorMessage } from './errors.js'
import type { ProgressEvent, ProgressEventHandler } from './types.js'
/**
* Response structure from an IPNI indexer.
*
* The indexer returns provider records corresponding with each SP that advertised
* a given CID to IPNI.
* Each provider includes their peer ID and multiaddrs.
*/
interface IpniIndexerResponse {
MultihashResults?: Array<{
Multihash?: string
ProviderResults?: ProviderResult[]
}>
}
/**
* A single provider's provider record from IPNI.
*
* Contains the provider's libp2p peer ID and an array of multiaddrs where
* the content can be retrieved. These multiaddrs typically include the
* provider's PDP service endpoint (e.g., /dns/provider.example.com/tcp/443/https).
*
* Note: this format matches what IPNI indexers return (see https://cid.contact/cid/bafybeigvgzoolc3drupxhlevdp2ugqcrbcsqfmcek2zxiw5wctk3xjpjwy for an example)
*/
interface ProviderResult {
Provider?: {
/** Libp2p peer ID of the storage provider */
ID?: string
/** Multiaddrs where this provider can serve the content */
Addrs?: string[]
}
}
export type ValidateIPNIProgressEvents =
| ProgressEvent<'ipniProviderResults.retryUpdate', { retryCount: number }>
| ProgressEvent<'ipniProviderResults.complete', { result: true; retryCount: number }>
| ProgressEvent<'ipniProviderResults.failed', { error: Error }>
export interface WaitForIpniProviderResultsOptions {
/**
* maximum number of attempts
*
* @default: 20
*/
maxAttempts?: number | undefined
/**
* delay between attempts in milliseconds
*
* @default: 5000
*/
delayMs?: number | undefined
/**
* Abort signal
*
* @default: undefined
*/
signal?: AbortSignal | undefined
/**
* Logger instance
*
* @default: undefined
*/
logger?: Logger | undefined
/**
* Providers that are expected to appear in the IPNI provider results. All
* providers supplied here must be present in the response for the validation
* to succeed. When omitted or empty, the validation when the IPNI
* response is non-empty.
*
* @default: []
*/
expectedProviders?: ProviderInfo[] | undefined
/**
* Callback for progress updates
*
* @default: undefined
*/
onProgress?: ProgressEventHandler<ValidateIPNIProgressEvents>
/**
* IPNI indexer URL to query for provider records to confirm that advertisements were processed.
*
* @default 'https://filecoinpin.contact'
*/
ipniIndexerUrl?: string | undefined
}
/**
* Check if the IPNI Indexer has the provided ProviderResults for the provided ipfsRootCid.
* This effectively verifies the entire SP<->IPNI flow, including:
* - The SP announced the advertisement chain to the IPNI indexer(s)
* - The IPNI indexer(s) pulled the advertisement chain from the SP
* - The IPNI indexer(s) updated their index
* This doesn't check individual steps, but rather the end ProviderResults reponse from the IPNI indexer.
* If the IPNI indexer ProviderResults have the expected providers, then the steps abomove must have completed.
* This doesn't actually do any IPFS Mainnet retrieval checks of the ipfsRootCid.
*
* This should not be called until you receive confirmation from the SP that the piece has been parked, i.e. `onPieceAdded` in the `synapse.storage.upload` callbacks.
*
* @param ipfsRootCid - The IPFS root CID to check
* @param options - Options for the check
* @returns True if the IPNI announce succeeded, false otherwise
*/
export async function waitForIpniProviderResults(
ipfsRootCid: CID,
options?: WaitForIpniProviderResultsOptions
): Promise<boolean> {
const delayMs = options?.delayMs ?? 5000
const maxAttempts = options?.maxAttempts ?? 20
const ipniIndexerUrl = options?.ipniIndexerUrl ?? 'https://filecoinpin.contact'
const expectedProviders = options?.expectedProviders?.filter((provider) => provider != null) ?? []
const { expectedMultiaddrs, skippedProviderCount } = deriveExpectedMultiaddrs(expectedProviders, options?.logger)
const expectedMultiaddrsSet = new Set(expectedMultiaddrs)
const hasProviderExpectations = expectedMultiaddrs.size > 0
// Log a warning if we expected providers but couldn't derive their multiaddrs
// In this case, we fall back to generic validation (just checking if there are any provider records for the CID)
if (!hasProviderExpectations && expectedProviders.length > 0 && skippedProviderCount > 0) {
options?.logger?.info(
{ skippedProviderExpectationCount: skippedProviderCount, expectedProviders: expectedProviders.length },
'No provider multiaddrs derived from expected providers; falling back to generic IPNI validation'
)
}
return new Promise<boolean>((resolve, reject) => {
let retryCount = 0
// Tracks the most recent validation failure reason for error reporting
let lastFailureReason: string | undefined
// Tracks the actual multiaddrs found in the last IPNI response for error reporting
let lastActualMultiaddrs: Set<string> = new Set()
const check = async (): Promise<void> => {
if (options?.signal?.aborted) {
throw new Error('Check IPNI announce aborted', { cause: options?.signal })
}
options?.logger?.info(
{
event: 'check-ipni-announce',
ipfsRootCid: ipfsRootCid.toString(),
},
'Checking IPNI for announcement of IPFS Root CID "%s"',
ipfsRootCid.toString()
)
// Emit progress event for this attempt
try {
options?.onProgress?.({ type: 'ipniProviderResults.retryUpdate', data: { retryCount } })
} catch (error) {
options?.logger?.warn({ error }, 'Error in consumer onProgress callback for retryUpdate event')
}
// Fetch IPNI provider records
const fetchOptions: RequestInit = {
headers: { Accept: 'application/json' },
}
if (options?.signal) {
fetchOptions.signal = options?.signal
}
let response: Response | undefined
try {
response = await fetch(`${ipniIndexerUrl}/cid/${ipfsRootCid}`, fetchOptions)
} catch (fetchError) {
lastActualMultiaddrs = new Set()
lastFailureReason = `Failed to query IPNI indexer: ${getErrorMessage(fetchError)}`
options?.logger?.warn({ error: fetchError }, `${lastFailureReason}. Retrying...`)
}
// Parse and validate response
if (response?.ok) {
let providerResults: ProviderResult[] = []
try {
const body = (await response.json()) as IpniIndexerResponse
// Extract provider results
providerResults = (body.MultihashResults ?? []).flatMap((r) => r.ProviderResults ?? [])
// Extract all multiaddrs from provider results
lastActualMultiaddrs = new Set(providerResults.flatMap((pr) => pr.Provider?.Addrs ?? []))
lastFailureReason = undefined
} catch (parseError) {
// Clear actual multiaddrs on parse error
lastActualMultiaddrs = new Set()
lastFailureReason = `Failed to parse IPNI response body: ${getErrorMessage(parseError)}`
options?.logger?.warn({ error: parseError }, `${lastFailureReason}. Retrying...`)
}
// Check if we have provider results to validate
if (providerResults.length > 0) {
let isValid = false
if (hasProviderExpectations) {
// Find matching multiaddrs
const matchedMultiaddrs = lastActualMultiaddrs.intersection(expectedMultiaddrsSet)
isValid = matchedMultiaddrs.size === expectedMultiaddrs.size
if (!isValid) {
// Log validation gap
const missingMultiaddrs = expectedMultiaddrsSet.difference(matchedMultiaddrs)
lastFailureReason = `Missing provider records with expected multiaddr(s): ${Array.from(missingMultiaddrs).join(', ')}`
options?.logger?.info(
{
receivedMultiaddrs: lastActualMultiaddrs,
matchedMultiaddrs,
missingMultiaddrs,
},
`${lastFailureReason}. Retrying...`
)
}
} else {
// Generic validation: just need any provider with addresses
isValid = lastActualMultiaddrs.size > 0
if (!isValid) {
lastFailureReason = 'Expected at least one provider record'
options?.logger?.info(`${lastFailureReason}. Retrying...`)
}
}
if (isValid) {
// Validation succeeded!
try {
options?.onProgress?.({ type: 'ipniProviderResults.complete', data: { result: true, retryCount } })
} catch (error) {
options?.logger?.warn({ error }, 'Error in consumer onProgress callback for complete event')
}
resolve(true)
return
}
} else if (lastFailureReason == null) {
// Only set generic message if we don't already have a more specific reason (e.g., parse error)
lastFailureReason = 'IPNI response did not include any provider results'
// Track that we got an empty response
lastActualMultiaddrs = new Set()
options?.logger?.info(
{ providerResultsCount: providerResults?.length ?? 0 },
`${lastFailureReason}. Retrying...`
)
}
} else if (response != null) {
lastActualMultiaddrs = new Set()
lastFailureReason = `IPNI indexer request failed with status ${response.status}`
options?.logger?.info(
{ status: response.status, statusText: response.statusText },
`${lastFailureReason}. Retrying...`
)
}
// Retry or fail
if (++retryCount < maxAttempts) {
options?.logger?.info(
{ retryCount, maxAttempts },
'IPFS Root CID "%s" not announced to IPNI yet (%d/%d). Retrying in %dms...',
ipfsRootCid.toString(),
retryCount,
maxAttempts,
delayMs
)
await new Promise((resolve) => setTimeout(resolve, delayMs))
await check()
} else {
// Max attempts reached - validation failed
const msgBase = `IPFS root CID "${ipfsRootCid.toString()}" does not have expected IPNI ProviderResults after ${maxAttempts} attempt${maxAttempts === 1 ? '' : 's'}`
let msg = msgBase
if (lastFailureReason != null) {
msg = `${msgBase}. Last observation: ${lastFailureReason}`
}
// Include expected and actual multiaddrs for debugging
if (hasProviderExpectations) {
msg = `${msg}. Expected multiaddrs: [${Array.from(expectedMultiaddrs).join(', ')}]. Actual multiaddrs in response: [${Array.from(lastActualMultiaddrs).join(', ')}]`
}
const error = new Error(msg)
options?.logger?.warn({ error }, msg)
throw error
}
}
check().catch((error) => {
try {
options?.onProgress?.({ type: 'ipniProviderResults.failed', data: { error } })
} catch (callbackError) {
options?.logger?.warn({ error: callbackError }, 'Error in consumer onProgress callback for failed event')
}
reject(error)
})
})
}
/**
* Convert a PDP service URL to an IPNI multiaddr format.
*
* Storage providers expose their PDP (Proof of Data Possession) service via HTTP/HTTPS
* endpoints (e.g., "https://provider.example.com:8443"). When they advertise content
* to IPNI, they include multiaddrs in libp2p format (e.g., "/dns/provider.example.com/tcp/8443/https").
*
* This function converts between these representations to enable validation that a
* provider's IPNI provider records matches their registered service endpoint.
*
* @param serviceURL - HTTP/HTTPS URL of the provider's PDP service
* @param logger - Optional logger for warnings
* @returns Multiaddr string in libp2p format, or undefined if conversion fails
*
* @example
* serviceURLToMultiaddr('https://provider.example.com')
* // Returns: '/dns/provider.example.com/tcp/443/https'
*
* @example
* serviceURLToMultiaddr('http://provider.example.com:8080')
* // Returns: '/dns/provider.example.com/tcp/8080/http'
*/
export function serviceURLToMultiaddr(serviceURL: string, logger?: Logger): string | undefined {
try {
const url = new URL(serviceURL)
const port = url.port || (url.protocol === 'https:' ? '443' : '80')
const protocolComponent = url.protocol.replace(':', '')
return `/dns/${url.hostname}/tcp/${port}/${protocolComponent}`
} catch (error) {
const reason = getErrorMessage(error)
logger?.warn({ serviceURL, error }, `Unable to derive IPNI multiaddr from serviceURL: ${reason}`)
return undefined
}
}
/**
* Derive expected IPNI multiaddrs from provider information.
*
* For each provider, attempts to extract their PDP serviceURL and convert it to
* the multiaddr format used in IPNI advertisements. This allows validation that
* specific providers have advertised the content.
*
* Note: ProviderInfo should contain the serviceURL at `products.PDP.data.serviceURL`.
*
* @param providers - Array of provider info objects from synapse SDK
* @param logger - Optional logger for diagnostics
* @returns Expected multiaddrs and count of providers that couldn't be processed
*/
function deriveExpectedMultiaddrs(
providers: ProviderInfo[],
logger: Logger | undefined
): {
expectedMultiaddrs: Set<string>
skippedProviderCount: number
} {
const derivedMultiaddrs: Set<string> = new Set()
let skippedProviderCount = 0
for (const provider of providers) {
const serviceURL = provider.products?.PDP?.data?.serviceURL
if (!serviceURL) {
skippedProviderCount++
logger?.warn({ provider }, 'Expected provider is missing a PDP serviceURL; skipping IPNI multiaddr expectation')
continue
}
const derivedMultiaddr = serviceURLToMultiaddr(serviceURL, logger)
if (!derivedMultiaddr) {
skippedProviderCount++
logger?.warn({ provider, serviceURL }, 'Unable to derive IPNI multiaddr from serviceURL; skipping expectation')
continue
}
derivedMultiaddrs.add(derivedMultiaddr)
}
return {
expectedMultiaddrs: derivedMultiaddrs,
skippedProviderCount,
}
}