From 1f41cd9ad5942f407b6352e8905113dd457df5c7 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:44:17 -0500 Subject: [PATCH 01/35] fix: validateIpniAdvertisement checks provider previously, validateIpniAdvertisement did not validate that the ipni advertisement existed for the given provider. Now, we validate that the response includes the expected provider, and allow consumers to pass extra providers (to support future multiple provider uploads) --- src/core/upload/index.ts | 34 +- src/core/utils/validate-ipni-advertisement.ts | 412 +++++++++++++++++- .../unit/validate-ipni-advertisement.test.ts | 201 ++++++++- 3 files changed, 613 insertions(+), 34 deletions(-) diff --git a/src/core/upload/index.ts b/src/core/upload/index.ts index 54178031..4e821abb 100644 --- a/src/core/upload/index.ts +++ b/src/core/upload/index.ts @@ -230,16 +230,40 @@ export async function executeUpload( case 'onPieceAdded': { // Begin IPNI validation as soon as the piece is added and parked in the data set if (options.ipniValidation?.enabled !== false && ipniValidationPromise == null) { - const { enabled: _enabled, ...rest } = options.ipniValidation ?? {} - ipniValidationPromise = validateIPNIAdvertisement(rootCid, { - ...rest, + const { enabled: _enabled, expectedProviders, ...restOptions } = options.ipniValidation ?? {} + + // Build validation options + const validationOptions: ValidateIPNIAdvertisementOptions = { + ...restOptions, logger, - ...(options?.onProgress != null ? { onProgress: options.onProgress } : {}), - }).catch((error) => { + } + + // Forward progress events to caller if they provided a handler + if (options?.onProgress != null) { + validationOptions.onProgress = options.onProgress + } + + // Determine which providers to expect in IPNI + // Priority: user-provided expectedProviders > current provider > none (generic validation) + const providersToExpect = + expectedProviders && expectedProviders.length > 0 + ? expectedProviders + : synapseService.providerInfo != null + ? [synapseService.providerInfo] + : [] + + if (providersToExpect.length > 0) { + validationOptions.expectedProviders = providersToExpect + } + + // Start validation (runs in parallel with other operations) + ipniValidationPromise = validateIPNIAdvertisement(rootCid, validationOptions).catch((error) => { logger.warn({ error }, 'IPNI advertisement validation promise rejected') return false }) } + + // Capture transaction hash if available if (event.data.txHash != null) { transactionHash = event.data.txHash } diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index c004c5bb..7b32fc16 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -1,7 +1,38 @@ +import type { ProviderInfo } from '@filoz/synapse-sdk' import type { CID } from 'multiformats/cid' import type { Logger } from 'pino' import type { ProgressEvent, ProgressEventHandler } from './types.js' +/** + * Response structure from the filecoinpin.contact IPNI gateway. + * + * The gateway returns information about which storage providers have advertised + * a given CID through the InterPlanetary Network Indexer (IPNI). Each provider + * includes their peer ID and multiaddrs for retrieval. + */ +interface FilecoinPinContactResponse { + MultihashResults?: Array<{ + Multihash?: string + ProviderResults?: ProviderResult[] + }> +} + +/** + * A single provider's advertisement 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). + */ +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<'ipniAdvertisement.retryUpdate', { retryCount: number }> | ProgressEvent<'ipniAdvertisement.complete', { result: true; retryCount: number }> @@ -11,7 +42,7 @@ export interface ValidateIPNIAdvertisementOptions { /** * maximum number of attempts * - * @default: 10 + * @default: 20 */ maxAttempts?: number | undefined @@ -36,6 +67,26 @@ export interface ValidateIPNIAdvertisementOptions { */ logger?: Logger | undefined + /** + * Providers that are expected to appear in the IPNI advertisement. All + * providers supplied here must be present in the response for the validation + * to succeed. When omitted or empty, the validation succeeds once the IPNI + * response includes any provider entry that advertises at least one address + * for the root CID (no retrieval attempt is made here). + * + * @default: [] + */ + expectedProviders?: ProviderInfo[] | undefined + + /** + * Additional provider multiaddrs that must be present in the IPNI + * advertisement. These are merged with the derived multiaddrs from + * {@link expectedProviders}. + * + * @default: undefined + */ + expectedProviderMultiaddrs?: string[] | undefined + /** * Callback for progress updates * @@ -58,14 +109,36 @@ export async function validateIPNIAdvertisement( options?: ValidateIPNIAdvertisementOptions ): Promise { const delayMs = options?.delayMs ?? 5000 - const maxAttempts = options?.maxAttempts ?? 10 + const maxAttempts = options?.maxAttempts ?? 20 + const expectedProviders = options?.expectedProviders?.filter((provider) => provider != null) ?? [] + const { expectedMultiaddrs, skippedProviderCount } = deriveExpectedMultiaddrs( + expectedProviders, + options?.expectedProviderMultiaddrs, + options?.logger + ) + const expectedMultiaddrsSet = new Set(expectedMultiaddrs) + + const hasProviderExpectations = expectedMultiaddrs.length > 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 any provider advertises) + 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((resolve, reject) => { let retryCount = 0 + // Tracks the most recent validation failure reason for error reporting + let lastFailureReason: string | undefined + const check = async (): Promise => { if (options?.signal?.aborted) { throw new Error('Check IPNI announce aborted', { cause: options?.signal }) } + options?.logger?.info( { event: 'check-ipni-announce', @@ -74,27 +147,79 @@ export async function validateIPNIAdvertisement( 'Checking IPNI for announcement of IPFS Root CID "%s"', ipfsRootCid.toString() ) - const fetchOptions: RequestInit = {} - if (options?.signal) { - fetchOptions.signal = options?.signal - } + + // Emit progress event for this attempt try { options?.onProgress?.({ type: 'ipniAdvertisement.retryUpdate', data: { retryCount } }) } catch (error) { options?.logger?.warn({ error }, 'Error in consumer onProgress callback for retryUpdate event') } + // Fetch IPNI advertisement + const fetchOptions: RequestInit = { + headers: { Accept: 'application/json' }, + } + if (options?.signal) { + fetchOptions.signal = options?.signal + } + const response = await fetch(`https://filecoinpin.contact/cid/${ipfsRootCid}`, fetchOptions) + + // Parse and validate response if (response.ok) { + let providerResults: ProviderResult[] | undefined try { - options?.onProgress?.({ type: 'ipniAdvertisement.complete', data: { result: true, retryCount } }) - } catch (error) { - options?.logger?.warn({ error }, 'Error in consumer onProgress callback for complete event') + const body = (await response.json()) as FilecoinPinContactResponse + providerResults = extractProviderResults(body) + } catch (parseError) { + lastFailureReason = 'Failed to parse IPNI response body' + options?.logger?.warn({ error: parseError }, `${lastFailureReason}. Retrying...`) + } + + // Check if we have provider results to validate + if (providerResults != null && providerResults.length > 0) { + // Perform appropriate validation based on whether we have expectations + const hasGenericProvider = hasAnyProviderWithAddresses(providerResults) + const matchedMultiaddrs = hasProviderExpectations + ? findMatchingMultiaddrs(providerResults, expectedMultiaddrsSet) + : new Set() + + const isValid = isValidationSuccessful( + hasGenericProvider, + matchedMultiaddrs, + expectedMultiaddrsSet, + hasProviderExpectations + ) + + if (isValid) { + // Validation succeeded! + try { + options?.onProgress?.({ type: 'ipniAdvertisement.complete', data: { result: true, retryCount } }) + } catch (error) { + options?.logger?.warn({ error }, 'Error in consumer onProgress callback for complete event') + } + resolve(true) + return + } + + // Validation not yet successful - log why and retry + lastFailureReason = formatAndLogValidationGap( + matchedMultiaddrs, + expectedMultiaddrs, + hasProviderExpectations, + expectedProviders.length, + options?.logger + ) + } else { + lastFailureReason = 'IPNI response did not include any provider results' + options?.logger?.info( + { providerResultsCount: providerResults?.length ?? 0 }, + `${lastFailureReason}. Retrying...` + ) } - resolve(true) - return } + // Retry or fail if (++retryCount < maxAttempts) { options?.logger?.info( { retryCount, maxAttempts }, @@ -107,9 +232,9 @@ export async function validateIPNIAdvertisement( await new Promise((resolve) => setTimeout(resolve, delayMs)) await check() } else { - // Max attempts reached - don't emit 'complete' event, just throw - // The outer catch handler will emit 'failed' event - const msg = `IPFS root CID "${ipfsRootCid.toString()}" not announced to IPNI after ${maxAttempts} attempt${maxAttempts === 1 ? '' : 's'}` + // Max attempts reached - validation failed + const msgBase = `IPFS root CID "${ipfsRootCid.toString()}" not announced to IPNI after ${maxAttempts} attempt${maxAttempts === 1 ? '' : 's'}` + const msg = lastFailureReason != null ? `${msgBase}. Last observation: ${lastFailureReason}` : msgBase const error = new Error(msg) options?.logger?.warn({ error }, msg) throw error @@ -126,3 +251,262 @@ export async function validateIPNIAdvertisement( }) }) } + +/** + * Convert a PDP service URL to an IPNI multiaddr format. + * + * Storage providers expose their PDP (Piece Data Provider) 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 advertisement 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 !== '' + ? Number.parseInt(url.port, 10) + : url.protocol === 'https:' + ? 443 + : url.protocol === 'http:' + ? 80 + : undefined + + if (Number.isNaN(port) || port == null) { + return undefined + } + + const protocolComponent = + url.protocol === 'https:' ? 'https' : url.protocol === 'http:' ? 'http' : url.protocol.replace(':', '') + + return `/dns/${url.hostname}/tcp/${port}/${protocolComponent}` + } catch (error) { + logger?.warn({ serviceURL, error }, 'Unable to derive IPNI multiaddr from serviceURL') + return undefined + } +} + +/** + * Extract all provider results from the IPNI gateway response. + * + * The response can contain multiple multihash results, each with multiple provider + * results. This flattens them into a single array for easier processing. + * + * @param response - Raw response from filecoinpin.contact + * @returns Flat array of all provider results, or empty array if none found + */ +function extractProviderResults(response: FilecoinPinContactResponse): ProviderResult[] { + const results = response.MultihashResults + if (!Array.isArray(results)) { + return [] + } + + return results.flatMap(({ ProviderResults }) => { + if (!Array.isArray(ProviderResults)) { + return [] + } + return ProviderResults + }) +} + +/** + * 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`. + * In some SDK versions, it may be at the top level. This function checks both locations + * to maintain compatibility. + * + * @param providers - Array of provider info objects from synapse SDK + * @param extraMultiaddrs - Additional multiaddrs to include in expectations + * @param logger - Optional logger for diagnostics + * @returns Expected multiaddrs and count of providers that couldn't be processed + */ +function deriveExpectedMultiaddrs( + providers: ProviderInfo[], + extraMultiaddrs: string[] | undefined, + logger: Logger | undefined +): { + expectedMultiaddrs: string[] + skippedProviderCount: number +} { + const derivedMultiaddrs: string[] = [] + let skippedProviderCount = 0 + + for (const provider of providers) { + // Primary path: products.PDP.data.serviceURL (current SDK structure) + // Fallback path: top-level serviceURL (for compatibility with older SDK versions) + const serviceURL = + provider.products?.PDP?.data?.serviceURL ?? + (provider as unknown as { serviceURL?: string }).serviceURL ?? + undefined + + 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.push(derivedMultiaddr) + } + + const additionalMultiaddrs = extraMultiaddrs?.filter((addr) => addr != null && addr !== '') ?? [] + const expectedMultiaddrs = Array.from(new Set([...additionalMultiaddrs, ...derivedMultiaddrs])) + + return { + expectedMultiaddrs, + skippedProviderCount, + } +} + +/** + * Check if any provider in the IPNI response has at least one address. + * + * This is used for generic IPNI validation when no specific provider is expected. + * Passes if IPNI shows ANY provider advertising the content with addresses. + * + * @param providerResults - Provider results from IPNI response + * @returns True if at least one provider has non-empty addresses + */ +function hasAnyProviderWithAddresses(providerResults: ProviderResult[]): boolean { + for (const providerResult of providerResults) { + const provider = providerResult.Provider + if (!provider) continue + + const providerAddrs = provider.Addrs ?? [] + if (providerAddrs.length > 0) { + return true + } + } + + return false +} + +/** + * Find which expected multiaddrs are present in the IPNI response. + * + * This is used for specific provider validation. Returns the set of expected + * multiaddrs that were found, allowing the caller to check if ALL expected + * providers are advertising. + * + * @param providerResults - Provider results from IPNI response + * @param expectedMultiaddrs - Set of multiaddrs we expect to find + * @returns Set of expected multiaddrs that were found in the response + */ +function findMatchingMultiaddrs(providerResults: ProviderResult[], expectedMultiaddrs: Set): Set { + const matched = new Set() + + for (const providerResult of providerResults) { + const provider = providerResult.Provider + if (!provider) continue + + const providerAddrs = provider.Addrs ?? [] + for (const addr of providerAddrs) { + if (expectedMultiaddrs.has(addr)) { + matched.add(addr) + } + } + } + + return matched +} + +/** + * Check if the IPNI response satisfies the validation requirements. + * + * For generic validation (no expected providers): Passes if any provider has addresses. + * For specific validation (with expected providers): Passes only if ALL expected + * multiaddrs are present in the response. + * + * @param hasGenericProvider - True if any provider advertises with addresses + * @param matchedMultiaddrs - Set of expected multiaddrs found in response + * @param expectedMultiaddrs - Set of all expected multiaddrs + * @param hasProviderExpectations - Whether we're doing specific provider validation + * @returns True if validation requirements are satisfied + */ +function isValidationSuccessful( + hasGenericProvider: boolean, + matchedMultiaddrs: Set, + expectedMultiaddrs: Set, + hasProviderExpectations: boolean +): boolean { + if (!hasProviderExpectations) { + // Generic validation: just need any provider with addresses + return hasGenericProvider + } + + // Specific validation: need ALL expected multiaddrs to be present + return matchedMultiaddrs.size === expectedMultiaddrs.size +} + +/** + * Format and log diagnostics about why validation hasn't passed yet. + * + * This provides actionable feedback about what's missing from the IPNI response, + * helping users understand what the validation is waiting for. + * + * @param matchedMultiaddrs - Multiaddrs from expected set that were found + * @param expectedMultiaddrs - All expected multiaddrs (as array for iteration) + * @param hasProviderExpectations - Whether we're doing specific provider validation + * @param expectedProvidersCount - Number of providers we're expecting + * @param logger - Optional logger for output + * @returns Human-readable message describing what's missing + */ +function formatAndLogValidationGap( + matchedMultiaddrs: Set, + expectedMultiaddrs: string[], + hasProviderExpectations: boolean, + expectedProvidersCount: number, + logger: Logger | undefined +): string { + let message: string + + if (hasProviderExpectations) { + const missing = expectedMultiaddrs.filter((addr) => !matchedMultiaddrs.has(addr)) + + if (missing.length === 0) { + // All multiaddrs are present, but maybe not all with addresses yet + message = 'Expected providers not yet advertising reachable addresses' + } else { + message = `Missing advertisement for expected multiaddr(s): ${missing.join(', ')}` + } + + logger?.info( + { + expectation: `multiaddr(s): ${expectedMultiaddrs.join(', ')}`, + providerCount: expectedProvidersCount, + matchedMultiaddrs: Array.from(matchedMultiaddrs), + }, + `${message}. Retrying...` + ) + } else { + message = 'Expected provider advertisement to include at least one reachable address' + logger?.info(`${message}. Retrying...`) + } + + return message +} diff --git a/src/test/unit/validate-ipni-advertisement.test.ts b/src/test/unit/validate-ipni-advertisement.test.ts index 645f2d08..a614e72a 100644 --- a/src/test/unit/validate-ipni-advertisement.test.ts +++ b/src/test/unit/validate-ipni-advertisement.test.ts @@ -1,3 +1,4 @@ +import type { ProviderInfo } from '@filoz/synapse-sdk' import { CID } from 'multiformats/cid' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { validateIPNIAdvertisement } from '../../core/utils/validate-ipni-advertisement.js' @@ -6,6 +7,43 @@ describe('validateIPNIAdvertisement', () => { const testCid = CID.parse('bafkreia5fn4rmshmb7cl7fufkpcw733b5anhuhydtqstnglpkzosqln5kq') const mockFetch = vi.fn() + const createProviderInfo = (serviceURL: string): ProviderInfo => + ({ + id: 1234, + serviceProvider: 'f01234', + name: 'Test Provider', + products: { + PDP: { + data: { + serviceURL, + }, + }, + }, + }) as ProviderInfo + + const successResponse = (multiaddrs: string[] = ['/dns/example.com/tcp/443/https']) => ({ + ok: true, + json: vi.fn(async () => ({ + MultihashResults: [ + { + ProviderResults: multiaddrs.map((addr, index) => ({ + Provider: { + ID: `12D3KooWProvider${index}`, + Addrs: [addr], + }, + })), + }, + ], + })), + }) + + const emptyProviderResponse = () => ({ + ok: true, + json: vi.fn(async () => ({ + MultihashResults: [], + })), + }) + beforeEach(() => { vi.stubGlobal('fetch', mockFetch) vi.useFakeTimers() @@ -19,7 +57,7 @@ describe('validateIPNIAdvertisement', () => { describe('successful announcement', () => { it('should resolve true and emit a final complete event on first attempt', async () => { - mockFetch.mockResolvedValueOnce({ ok: true }) + mockFetch.mockResolvedValueOnce(successResponse()) const onProgress = vi.fn() const promise = validateIPNIAdvertisement(testCid, { onProgress }) @@ -28,7 +66,9 @@ describe('validateIPNIAdvertisement', () => { expect(result).toBe(true) expect(mockFetch).toHaveBeenCalledTimes(1) - expect(mockFetch).toHaveBeenCalledWith(`https://filecoinpin.contact/cid/${testCid}`, {}) + expect(mockFetch).toHaveBeenCalledWith(`https://filecoinpin.contact/cid/${testCid}`, { + headers: { Accept: 'application/json' }, + }) // Should emit retryUpdate for attempt 0 and a final complete(true) expect(onProgress).toHaveBeenCalledWith({ type: 'ipniAdvertisement.retryUpdate', data: { retryCount: 0 } }) @@ -43,7 +83,7 @@ describe('validateIPNIAdvertisement', () => { .mockResolvedValueOnce({ ok: false }) .mockResolvedValueOnce({ ok: false }) .mockResolvedValueOnce({ ok: false }) - .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce(successResponse()) const onProgress = vi.fn() const promise = validateIPNIAdvertisement(testCid, { maxAttempts: 5, onProgress }) @@ -63,6 +103,35 @@ describe('validateIPNIAdvertisement', () => { data: { result: true, retryCount: 3 }, }) }) + + it('should succeed when the expected provider advertises the derived multiaddr', async () => { + const provider = createProviderInfo('https://example.com') + const expectedMultiaddr = '/dns/example.com/tcp/443/https' + mockFetch.mockResolvedValueOnce(successResponse([expectedMultiaddr])) + + const promise = validateIPNIAdvertisement(testCid, { expectedProviders: [provider] }) + await vi.runAllTimersAsync() + const result = await promise + + expect(result).toBe(true) + expect(mockFetch).toHaveBeenCalledWith(`https://filecoinpin.contact/cid/${testCid}`, { + headers: { Accept: 'application/json' }, + }) + }) + + it('should succeed when all expected providers are advertised', async () => { + const providerA = createProviderInfo('https://a.example.com') + const providerB = createProviderInfo('https://b.example.com:8443') + const expectedMultiaddrs = ['/dns/a.example.com/tcp/443/https', '/dns/b.example.com/tcp/8443/https'] + + mockFetch.mockResolvedValueOnce(successResponse(expectedMultiaddrs)) + + const promise = validateIPNIAdvertisement(testCid, { expectedProviders: [providerA, providerB] }) + await vi.runAllTimersAsync() + const result = await promise + + expect(result).toBe(true) + }) }) describe('failed announcement', () => { @@ -108,6 +177,78 @@ describe('validateIPNIAdvertisement', () => { await expectPromise expect(mockFetch).toHaveBeenCalledTimes(1) }) + it('should reject when an expected provider is missing from the advertisement', async () => { + const provider = createProviderInfo('https://expected.example.com') + mockFetch.mockResolvedValueOnce(successResponse(['/dns/other.example.com/tcp/443/https'])) + + const promise = validateIPNIAdvertisement(testCid, { + maxAttempts: 1, + expectedProviders: [provider], + }) + + const expectPromise = expect(promise).rejects.toThrow( + `IPFS root CID "${testCid.toString()}" not announced to IPNI after 1 attempt. Last observation: Missing advertisement for expected multiaddr(s): /dns/expected.example.com/tcp/443/https` + ) + await vi.runAllTimersAsync() + await expectPromise + }) + + it('should reject when not all expected providers are advertised', async () => { + const providerA = createProviderInfo('https://a.example.com') + const providerB = createProviderInfo('https://b.example.com') + mockFetch.mockResolvedValueOnce(successResponse(['/dns/a.example.com/tcp/443/https'])) + + const promise = validateIPNIAdvertisement(testCid, { + maxAttempts: 1, + expectedProviders: [providerA, providerB], + }) + + const expectPromise = expect(promise).rejects.toThrow( + `IPFS root CID "${testCid.toString()}" not announced to IPNI after 1 attempt. Last observation: Missing advertisement for expected multiaddr(s): /dns/b.example.com/tcp/443/https` + ) + await vi.runAllTimersAsync() + await expectPromise + }) + + it('should retry until the expected provider appears in subsequent attempts', async () => { + const provider = createProviderInfo('https://expected.example.com') + const expectedMultiaddr = '/dns/expected.example.com/tcp/443/https' + mockFetch + .mockResolvedValueOnce(successResponse(['/dns/other.example.com/tcp/443/https'])) + .mockResolvedValueOnce(successResponse([expectedMultiaddr])) + + const promise = validateIPNIAdvertisement(testCid, { + maxAttempts: 3, + expectedProviders: [provider], + delayMs: 1, + }) + + await vi.runAllTimersAsync() + const result = await promise + + expect(result).toBe(true) + expect(mockFetch).toHaveBeenCalledTimes(2) + }) + + it('should retry when the IPNI response contains no provider results', async () => { + const provider = createProviderInfo('https://expected.example.com') + const expectedMultiaddr = '/dns/expected.example.com/tcp/443/https' + mockFetch + .mockResolvedValueOnce(emptyProviderResponse()) + .mockResolvedValueOnce(successResponse([expectedMultiaddr])) + + const promise = validateIPNIAdvertisement(testCid, { + maxAttempts: 3, + expectedProviders: [provider], + delayMs: 1, + }) + + await vi.runAllTimersAsync() + const result = await promise + + expect(result).toBe(true) + expect(mockFetch).toHaveBeenCalledTimes(2) + }) }) describe('abort signal', () => { @@ -148,13 +289,14 @@ describe('validateIPNIAdvertisement', () => { it('should pass abort signal to fetch when provided', async () => { const abortController = new AbortController() - mockFetch.mockResolvedValueOnce({ ok: true }) + mockFetch.mockResolvedValueOnce(successResponse()) const promise = validateIPNIAdvertisement(testCid, { signal: abortController.signal }) await vi.runAllTimersAsync() await promise expect(mockFetch).toHaveBeenCalledWith(`https://filecoinpin.contact/cid/${testCid}`, { + headers: { Accept: 'application/json' }, signal: abortController.signal, }) }) @@ -165,7 +307,6 @@ describe('validateIPNIAdvertisement', () => { mockFetch.mockRejectedValueOnce(new Error('Network error')) const promise = validateIPNIAdvertisement(testCid, {}) - // Attach rejection handler immediately const expectPromise = expect(promise).rejects.toThrow('Network error') await vi.runAllTimersAsync() @@ -174,28 +315,58 @@ describe('validateIPNIAdvertisement', () => { it('should handle different CID formats', async () => { const v0Cid = CID.parse('QmNT6isqrhH6LZWg8NeXQYTD9wPjJo2BHHzyezpf9BdHbD') - mockFetch.mockResolvedValueOnce({ ok: true }) + mockFetch.mockResolvedValueOnce(successResponse()) const promise = validateIPNIAdvertisement(v0Cid, {}) await vi.runAllTimersAsync() const result = await promise expect(result).toBe(true) - expect(mockFetch).toHaveBeenCalledWith(`https://filecoinpin.contact/cid/${v0Cid}`, {}) + expect(mockFetch).toHaveBeenCalledWith(`https://filecoinpin.contact/cid/${v0Cid}`, { + headers: { Accept: 'application/json' }, + }) }) - it('should handle maxAttempts of 1', async () => { - mockFetch.mockResolvedValue({ ok: false }) + it('should handle empty or missing provider data gracefully', async () => { + // Test that validation handles various malformed provider responses + mockFetch.mockResolvedValueOnce({ + ok: true, + json: vi.fn(async () => ({ + MultihashResults: [ + { + ProviderResults: [ + { Provider: null }, // null provider + { Provider: { ID: '12D3Koo1', Addrs: [] } }, // empty addrs + { Provider: { ID: '12D3Koo2', Addrs: ['/dns/valid.com/tcp/443/https'] } }, // valid + ], + }, + ], + })), + }) const promise = validateIPNIAdvertisement(testCid, { maxAttempts: 1 }) - // Attach rejection handler immediately - const expectPromise = expect(promise).rejects.toThrow( - `IPFS root CID "${testCid.toString()}" not announced to IPNI after 1 attempt` - ) + await vi.runAllTimersAsync() + const result = await promise + // Should succeed because at least one valid provider exists + expect(result).toBe(true) + }) + + it('should handle provider without serviceURL by falling back to generic validation', async () => { + const providerWithoutURL = { + id: 1234, + serviceProvider: 'f01234', + name: 'Test Provider', + products: { PDP: { data: {} } }, + } as ProviderInfo + + mockFetch.mockResolvedValueOnce(successResponse()) + + const promise = validateIPNIAdvertisement(testCid, { expectedProviders: [providerWithoutURL] }) await vi.runAllTimersAsync() - await expectPromise - expect(mockFetch).toHaveBeenCalledTimes(1) + const result = await promise + + expect(result).toBe(true) }) }) }) From 1e6e823a9a60b01bb704e692a21ef842014b4a70 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 10:48:43 -0500 Subject: [PATCH 02/35] Update src/core/utils/validate-ipni-advertisement.ts Co-authored-by: Steve Loeppky --- src/core/utils/validate-ipni-advertisement.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 7b32fc16..0a7c1d78 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -6,9 +6,9 @@ import type { ProgressEvent, ProgressEventHandler } from './types.js' /** * Response structure from the filecoinpin.contact IPNI gateway. * - * The gateway returns information about which storage providers have advertised - * a given CID through the InterPlanetary Network Indexer (IPNI). Each provider - * includes their peer ID and multiaddrs for retrieval. + * The Indexer returns provider records corresponding with each SP that advertised + * a given CID to the IPNI indexer. + * Each provider includes their peer ID and multiaddrs. */ interface FilecoinPinContactResponse { MultihashResults?: Array<{ From afb18ae9d014d39256da121cf4b6e2c06c844613 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 10:50:00 -0500 Subject: [PATCH 03/35] Update src/core/utils/validate-ipni-advertisement.ts Co-authored-by: Steve Loeppky --- src/core/utils/validate-ipni-advertisement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 0a7c1d78..378f0497 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -4,7 +4,7 @@ import type { Logger } from 'pino' import type { ProgressEvent, ProgressEventHandler } from './types.js' /** - * Response structure from the filecoinpin.contact IPNI gateway. + * Response structure from the filecoinpin.contact IPNI indexer. * * The Indexer returns provider records corresponding with each SP that advertised * a given CID to the IPNI indexer. From 7df9efd4b4d5c6d13fe0e59f3d655fcc4b51560d Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 11:09:47 -0500 Subject: [PATCH 04/35] fix: fine-tune logic, remove dead branches --- src/core/utils/validate-ipni-advertisement.ts | 12 ++++-------- .../unit/validate-ipni-advertisement.test.ts | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 378f0497..627247e9 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -210,7 +210,8 @@ export async function validateIPNIAdvertisement( expectedProviders.length, options?.logger ) - } else { + } 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' options?.logger?.info( { providerResultsCount: providerResults?.length ?? 0 }, @@ -486,14 +487,9 @@ function formatAndLogValidationGap( let message: string if (hasProviderExpectations) { + // If we're here, validation failed, so there must be missing multiaddrs const missing = expectedMultiaddrs.filter((addr) => !matchedMultiaddrs.has(addr)) - - if (missing.length === 0) { - // All multiaddrs are present, but maybe not all with addresses yet - message = 'Expected providers not yet advertising reachable addresses' - } else { - message = `Missing advertisement for expected multiaddr(s): ${missing.join(', ')}` - } + message = `Missing advertisement for expected multiaddr(s): ${missing.join(', ')}` logger?.info( { diff --git a/src/test/unit/validate-ipni-advertisement.test.ts b/src/test/unit/validate-ipni-advertisement.test.ts index a614e72a..2f7bb2ac 100644 --- a/src/test/unit/validate-ipni-advertisement.test.ts +++ b/src/test/unit/validate-ipni-advertisement.test.ts @@ -368,5 +368,21 @@ describe('validateIPNIAdvertisement', () => { expect(result).toBe(true) }) + + it('should preserve parse error message instead of overwriting with generic message', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: vi.fn(async () => { + throw new Error('Invalid JSON') + }), + }) + + const promise = validateIPNIAdvertisement(testCid, { maxAttempts: 1 }) + // Should preserve the specific "Failed to parse" message, not overwrite with generic message + const expectPromise = expect(promise).rejects.toThrow('Failed to parse IPNI response body') + + await vi.runAllTimersAsync() + await expectPromise + }) }) }) From 20364d6d44a16e6392375efa2830a1a48a2161b4 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 11:25:34 -0500 Subject: [PATCH 05/35] fix: allow ipniIndexer override, default to filecoinpin.contact --- src/core/utils/validate-ipni-advertisement.ts | 28 ++++++++++++------- .../unit/validate-ipni-advertisement.test.ts | 23 ++++++++++++--- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 627247e9..02ff2446 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -4,13 +4,13 @@ import type { Logger } from 'pino' import type { ProgressEvent, ProgressEventHandler } from './types.js' /** - * Response structure from the filecoinpin.contact IPNI indexer. + * Response structure from an IPNI indexer. * - * The Indexer returns provider records corresponding with each SP that advertised - * a given CID to the 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 FilecoinPinContactResponse { +interface IpniIndexerResponse { MultihashResults?: Array<{ Multihash?: string ProviderResults?: ProviderResult[] @@ -18,7 +18,7 @@ interface FilecoinPinContactResponse { } /** - * A single provider's advertisement from IPNI. + * 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 @@ -93,6 +93,13 @@ export interface ValidateIPNIAdvertisementOptions { * @default: undefined */ onProgress?: ProgressEventHandler + + /** + * IPNI indexer URL to query for content advertisements. + * + * @default 'https://filecoinpin.contact' + */ + ipniIndexerUrl?: string | undefined } /** @@ -110,6 +117,7 @@ export async function validateIPNIAdvertisement( ): Promise { 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, @@ -163,13 +171,13 @@ export async function validateIPNIAdvertisement( fetchOptions.signal = options?.signal } - const response = await fetch(`https://filecoinpin.contact/cid/${ipfsRootCid}`, fetchOptions) + const response = await fetch(`${ipniIndexerUrl}/cid/${ipfsRootCid}`, fetchOptions) // Parse and validate response if (response.ok) { let providerResults: ProviderResult[] | undefined try { - const body = (await response.json()) as FilecoinPinContactResponse + const body = (await response.json()) as IpniIndexerResponse providerResults = extractProviderResults(body) } catch (parseError) { lastFailureReason = 'Failed to parse IPNI response body' @@ -302,15 +310,15 @@ export function serviceURLToMultiaddr(serviceURL: string, logger?: Logger): stri } /** - * Extract all provider results from the IPNI gateway response. + * Extract all provider results from the IPNI indexer response. * * The response can contain multiple multihash results, each with multiple provider * results. This flattens them into a single array for easier processing. * - * @param response - Raw response from filecoinpin.contact + * @param response - Raw response from the IPNI indexer * @returns Flat array of all provider results, or empty array if none found */ -function extractProviderResults(response: FilecoinPinContactResponse): ProviderResult[] { +function extractProviderResults(response: IpniIndexerResponse): ProviderResult[] { const results = response.MultihashResults if (!Array.isArray(results)) { return [] diff --git a/src/test/unit/validate-ipni-advertisement.test.ts b/src/test/unit/validate-ipni-advertisement.test.ts index 2f7bb2ac..b74a9871 100644 --- a/src/test/unit/validate-ipni-advertisement.test.ts +++ b/src/test/unit/validate-ipni-advertisement.test.ts @@ -5,6 +5,7 @@ import { validateIPNIAdvertisement } from '../../core/utils/validate-ipni-advert describe('validateIPNIAdvertisement', () => { const testCid = CID.parse('bafkreia5fn4rmshmb7cl7fufkpcw733b5anhuhydtqstnglpkzosqln5kq') + const defaultIndexerUrl = 'https://filecoinpin.contact' const mockFetch = vi.fn() const createProviderInfo = (serviceURL: string): ProviderInfo => @@ -66,7 +67,7 @@ describe('validateIPNIAdvertisement', () => { expect(result).toBe(true) expect(mockFetch).toHaveBeenCalledTimes(1) - expect(mockFetch).toHaveBeenCalledWith(`https://filecoinpin.contact/cid/${testCid}`, { + expect(mockFetch).toHaveBeenCalledWith(`${defaultIndexerUrl}/cid/${testCid}`, { headers: { Accept: 'application/json' }, }) @@ -114,7 +115,7 @@ describe('validateIPNIAdvertisement', () => { const result = await promise expect(result).toBe(true) - expect(mockFetch).toHaveBeenCalledWith(`https://filecoinpin.contact/cid/${testCid}`, { + expect(mockFetch).toHaveBeenCalledWith(`${defaultIndexerUrl}/cid/${testCid}`, { headers: { Accept: 'application/json' }, }) }) @@ -295,7 +296,7 @@ describe('validateIPNIAdvertisement', () => { await vi.runAllTimersAsync() await promise - expect(mockFetch).toHaveBeenCalledWith(`https://filecoinpin.contact/cid/${testCid}`, { + expect(mockFetch).toHaveBeenCalledWith(`${defaultIndexerUrl}/cid/${testCid}`, { headers: { Accept: 'application/json' }, signal: abortController.signal, }) @@ -322,7 +323,7 @@ describe('validateIPNIAdvertisement', () => { const result = await promise expect(result).toBe(true) - expect(mockFetch).toHaveBeenCalledWith(`https://filecoinpin.contact/cid/${v0Cid}`, { + expect(mockFetch).toHaveBeenCalledWith(`${defaultIndexerUrl}/cid/${v0Cid}`, { headers: { Accept: 'application/json' }, }) }) @@ -384,5 +385,19 @@ describe('validateIPNIAdvertisement', () => { await vi.runAllTimersAsync() await expectPromise }) + + it('should use custom IPNI indexer URL when provided', async () => { + const customIndexerUrl = 'https://custom-indexer.example.com' + mockFetch.mockResolvedValueOnce(successResponse()) + + const promise = validateIPNIAdvertisement(testCid, { ipniIndexerUrl: customIndexerUrl }) + await vi.runAllTimersAsync() + const result = await promise + + expect(result).toBe(true) + expect(mockFetch).toHaveBeenCalledWith(`${customIndexerUrl}/cid/${testCid}`, { + headers: { Accept: 'application/json' }, + }) + }) }) }) From b62441fe8c124f1aaa644520d4e323d7152ce4c0 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 11:27:29 -0500 Subject: [PATCH 06/35] fix: use only current provider serviceURL --- src/core/utils/validate-ipni-advertisement.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 02ff2446..4b0c97de 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -340,8 +340,6 @@ function extractProviderResults(response: IpniIndexerResponse): ProviderResult[] * specific providers have advertised the content. * * Note: ProviderInfo should contain the serviceURL at `products.PDP.data.serviceURL`. - * In some SDK versions, it may be at the top level. This function checks both locations - * to maintain compatibility. * * @param providers - Array of provider info objects from synapse SDK * @param extraMultiaddrs - Additional multiaddrs to include in expectations @@ -360,12 +358,7 @@ function deriveExpectedMultiaddrs( let skippedProviderCount = 0 for (const provider of providers) { - // Primary path: products.PDP.data.serviceURL (current SDK structure) - // Fallback path: top-level serviceURL (for compatibility with older SDK versions) - const serviceURL = - provider.products?.PDP?.data?.serviceURL ?? - (provider as unknown as { serviceURL?: string }).serviceURL ?? - undefined + const serviceURL = provider.products?.PDP?.data?.serviceURL if (!serviceURL) { skippedProviderCount++ From 1f95e4d494c4244bd552fad75fb9ed4b05cdac92 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 11:28:36 -0500 Subject: [PATCH 07/35] Update src/core/utils/validate-ipni-advertisement.ts Co-authored-by: Steve Loeppky --- src/core/utils/validate-ipni-advertisement.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 4b0c97de..a824e9fa 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -23,6 +23,8 @@ interface IpniIndexerResponse { * 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?: { From 4c0c1c2e90fab0f23e58daf75f52f2dd61de6618 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 11:28:46 -0500 Subject: [PATCH 08/35] Update src/core/utils/validate-ipni-advertisement.ts Co-authored-by: Steve Loeppky --- src/core/utils/validate-ipni-advertisement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index a824e9fa..ebf04493 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -391,7 +391,7 @@ function deriveExpectedMultiaddrs( * Check if any provider in the IPNI response has at least one address. * * This is used for generic IPNI validation when no specific provider is expected. - * Passes if IPNI shows ANY provider advertising the content with addresses. + * Passes if IPNI has ANY provider records for the CID. * * @param providerResults - Provider results from IPNI response * @returns True if at least one provider has non-empty addresses From fa10f75213b151aa64674634ec24f5ba05625653 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 11:54:29 -0500 Subject: [PATCH 09/35] Update src/core/utils/validate-ipni-advertisement.ts Co-authored-by: Steve Loeppky --- src/core/utils/validate-ipni-advertisement.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index ebf04493..38bce9a3 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -314,8 +314,8 @@ export function serviceURLToMultiaddr(serviceURL: string, logger?: Logger): stri /** * Extract all provider results from the IPNI indexer response. * - * The response can contain multiple multihash results, each with multiple provider - * results. This flattens them into a single array for easier processing. + * The response can contain multiple providers results, each with multiple multiaddr esults. + * This flattens them into a single array for easier processing. * * @param response - Raw response from the IPNI indexer * @returns Flat array of all provider results, or empty array if none found From ad90c71b3e902c405a6694e1c064f88fb7c116c7 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 11:54:47 -0500 Subject: [PATCH 10/35] Update src/core/utils/validate-ipni-advertisement.ts Co-authored-by: Steve Loeppky --- src/core/utils/validate-ipni-advertisement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 38bce9a3..9e2a90a3 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -131,7 +131,7 @@ export async function validateIPNIAdvertisement( const hasProviderExpectations = expectedMultiaddrs.length > 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 any provider advertises) + // 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 }, From 31c609735d16f090c1eb15b85cc9dd5df1ad15e4 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 11:53:53 -0500 Subject: [PATCH 11/35] chore: more logic cleanup --- src/core/utils/validate-ipni-advertisement.ts | 52 ++++--------------- 1 file changed, 11 insertions(+), 41 deletions(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 9e2a90a3..dc2956c9 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -188,18 +188,16 @@ export async function validateIPNIAdvertisement( // Check if we have provider results to validate if (providerResults != null && providerResults.length > 0) { - // Perform appropriate validation based on whether we have expectations - const hasGenericProvider = hasAnyProviderWithAddresses(providerResults) - const matchedMultiaddrs = hasProviderExpectations - ? findMatchingMultiaddrs(providerResults, expectedMultiaddrsSet) - : new Set() - - const isValid = isValidationSuccessful( - hasGenericProvider, - matchedMultiaddrs, - expectedMultiaddrsSet, - hasProviderExpectations - ) + let matchedMultiaddrs = new Set() + let isValid = false + + if (hasProviderExpectations) { + matchedMultiaddrs = findMatchingMultiaddrs(providerResults, expectedMultiaddrsSet) + isValid = matchedMultiaddrs.size === expectedMultiaddrs.length + } else { + // Generic validation: just need any provider with addresses + isValid = hasAnyProviderWithMultiaddrs(providerResults) + } if (isValid) { // Validation succeeded! @@ -396,7 +394,7 @@ function deriveExpectedMultiaddrs( * @param providerResults - Provider results from IPNI response * @returns True if at least one provider has non-empty addresses */ -function hasAnyProviderWithAddresses(providerResults: ProviderResult[]): boolean { +function hasAnyProviderWithMultiaddrs(providerResults: ProviderResult[]): boolean { for (const providerResult of providerResults) { const provider = providerResult.Provider if (!provider) continue @@ -439,34 +437,6 @@ function findMatchingMultiaddrs(providerResults: ProviderResult[], expectedMulti return matched } -/** - * Check if the IPNI response satisfies the validation requirements. - * - * For generic validation (no expected providers): Passes if any provider has addresses. - * For specific validation (with expected providers): Passes only if ALL expected - * multiaddrs are present in the response. - * - * @param hasGenericProvider - True if any provider advertises with addresses - * @param matchedMultiaddrs - Set of expected multiaddrs found in response - * @param expectedMultiaddrs - Set of all expected multiaddrs - * @param hasProviderExpectations - Whether we're doing specific provider validation - * @returns True if validation requirements are satisfied - */ -function isValidationSuccessful( - hasGenericProvider: boolean, - matchedMultiaddrs: Set, - expectedMultiaddrs: Set, - hasProviderExpectations: boolean -): boolean { - if (!hasProviderExpectations) { - // Generic validation: just need any provider with addresses - return hasGenericProvider - } - - // Specific validation: need ALL expected multiaddrs to be present - return matchedMultiaddrs.size === expectedMultiaddrs.size -} - /** * Format and log diagnostics about why validation hasn't passed yet. * From 915e7e96cc5afd93ed4f212c074e27bc6c9fa0f8 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 12:10:22 -0500 Subject: [PATCH 12/35] fix: display received + expected multiaddrs --- src/core/utils/validate-ipni-advertisement.ts | 65 ++++++++++--------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index dc2956c9..5f52e71f 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -23,8 +23,8 @@ interface IpniIndexerResponse { * 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) + * + * Note: this format matches what IPNI indexers return (see https://cid.contact/cid/bafybeigvgzoolc3drupxhlevdp2ugqcrbcsqfmcek2zxiw5wctk3xjpjwy for an example) */ interface ProviderResult { Provider?: { @@ -143,6 +143,8 @@ export async function validateIPNIAdvertisement( 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: string[] | undefined const check = async (): Promise => { if (options?.signal?.aborted) { @@ -188,15 +190,18 @@ export async function validateIPNIAdvertisement( // Check if we have provider results to validate if (providerResults != null && providerResults.length > 0) { + // Track actual multiaddrs found in response for error reporting + lastActualMultiaddrs = extractAllMultiaddrs(providerResults) + let matchedMultiaddrs = new Set() let isValid = false if (hasProviderExpectations) { - matchedMultiaddrs = findMatchingMultiaddrs(providerResults, expectedMultiaddrsSet) + matchedMultiaddrs = findMatchingMultiaddrs(lastActualMultiaddrs, expectedMultiaddrsSet) isValid = matchedMultiaddrs.size === expectedMultiaddrs.length } else { // Generic validation: just need any provider with addresses - isValid = hasAnyProviderWithMultiaddrs(providerResults) + isValid = lastActualMultiaddrs.length > 0 } if (isValid) { @@ -221,6 +226,8 @@ export async function validateIPNIAdvertisement( } 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 = [] options?.logger?.info( { providerResultsCount: providerResults?.length ?? 0 }, `${lastFailureReason}. Retrying...` @@ -243,7 +250,19 @@ export async function validateIPNIAdvertisement( } else { // Max attempts reached - validation failed const msgBase = `IPFS root CID "${ipfsRootCid.toString()}" not announced to IPNI after ${maxAttempts} attempt${maxAttempts === 1 ? '' : 's'}` - const msg = lastFailureReason != null ? `${msgBase}. Last observation: ${lastFailureReason}` : msgBase + let msg = msgBase + if (lastFailureReason != null) { + msg = `${msgBase}. Last observation: ${lastFailureReason}` + } + // Include expected and actual multiaddrs for debugging + if (hasProviderExpectations && expectedMultiaddrs.length > 0) { + msg = `${msg}. Expected multiaddrs: [${expectedMultiaddrs.join(', ')}]` + } + if (lastActualMultiaddrs != null && lastActualMultiaddrs.length > 0) { + msg = `${msg}. Actual multiaddrs in response: [${lastActualMultiaddrs.join(', ')}]` + } else if (lastActualMultiaddrs != null) { + msg = `${msg}. Actual multiaddrs in response: [] (no multiaddrs found)` + } const error = new Error(msg) options?.logger?.warn({ error }, msg) throw error @@ -312,7 +331,7 @@ export function serviceURLToMultiaddr(serviceURL: string, logger?: Logger): stri /** * Extract all provider results from the IPNI indexer response. * - * The response can contain multiple providers results, each with multiple multiaddr esults. + * The response can contain multiple providers results, each with multiple multiaddr esults. * This flattens them into a single array for easier processing. * * @param response - Raw response from the IPNI indexer @@ -386,26 +405,20 @@ function deriveExpectedMultiaddrs( } /** - * Check if any provider in the IPNI response has at least one address. - * - * This is used for generic IPNI validation when no specific provider is expected. - * Passes if IPNI has ANY provider records for the CID. + * Extract all multiaddrs from provider results. * * @param providerResults - Provider results from IPNI response - * @returns True if at least one provider has non-empty addresses + * @returns Array of all multiaddrs found in the response */ -function hasAnyProviderWithMultiaddrs(providerResults: ProviderResult[]): boolean { +function extractAllMultiaddrs(providerResults: ProviderResult[]): string[] { + const allMultiaddrs: string[] = [] for (const providerResult of providerResults) { const provider = providerResult.Provider if (!provider) continue - const providerAddrs = provider.Addrs ?? [] - if (providerAddrs.length > 0) { - return true - } + allMultiaddrs.push(...providerAddrs) } - - return false + return allMultiaddrs } /** @@ -415,22 +428,16 @@ function hasAnyProviderWithMultiaddrs(providerResults: ProviderResult[]): boolea * multiaddrs that were found, allowing the caller to check if ALL expected * providers are advertising. * - * @param providerResults - Provider results from IPNI response + * @param allMultiaddrs - All multiaddrs found in the IPNI response * @param expectedMultiaddrs - Set of multiaddrs we expect to find * @returns Set of expected multiaddrs that were found in the response */ -function findMatchingMultiaddrs(providerResults: ProviderResult[], expectedMultiaddrs: Set): Set { +function findMatchingMultiaddrs(allMultiaddrs: string[], expectedMultiaddrs: Set): Set { const matched = new Set() - for (const providerResult of providerResults) { - const provider = providerResult.Provider - if (!provider) continue - - const providerAddrs = provider.Addrs ?? [] - for (const addr of providerAddrs) { - if (expectedMultiaddrs.has(addr)) { - matched.add(addr) - } + for (const addr of allMultiaddrs) { + if (expectedMultiaddrs.has(addr)) { + matched.add(addr) } } From a2df72fcb051667d54f7ea95e2021ebc2abaccc2 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 12:22:32 -0500 Subject: [PATCH 13/35] refactor: inline simple maps and filters --- src/core/utils/validate-ipni-advertisement.ts | 144 +++--------------- 1 file changed, 24 insertions(+), 120 deletions(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 5f52e71f..0a3b27e3 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -182,7 +182,8 @@ export async function validateIPNIAdvertisement( let providerResults: ProviderResult[] | undefined try { const body = (await response.json()) as IpniIndexerResponse - providerResults = extractProviderResults(body) + // Extract provider results + providerResults = (body.MultihashResults ?? []).flatMap((r) => r.ProviderResults ?? []) } catch (parseError) { lastFailureReason = 'Failed to parse IPNI response body' options?.logger?.warn({ error: parseError }, `${lastFailureReason}. Retrying...`) @@ -190,18 +191,36 @@ export async function validateIPNIAdvertisement( // Check if we have provider results to validate if (providerResults != null && providerResults.length > 0) { - // Track actual multiaddrs found in response for error reporting - lastActualMultiaddrs = extractAllMultiaddrs(providerResults) + // Extract all multiaddrs from provider results + lastActualMultiaddrs = providerResults.flatMap((pr) => pr.Provider?.Addrs ?? []) - let matchedMultiaddrs = new Set() let isValid = false if (hasProviderExpectations) { - matchedMultiaddrs = findMatchingMultiaddrs(lastActualMultiaddrs, expectedMultiaddrsSet) + // Find matching multiaddrs - inline filter + Set + const matchedMultiaddrs = new Set(lastActualMultiaddrs.filter((addr) => expectedMultiaddrsSet.has(addr))) isValid = matchedMultiaddrs.size === expectedMultiaddrs.length + + if (!isValid) { + // Log validation gap + const missing = expectedMultiaddrs.filter((addr) => !matchedMultiaddrs.has(addr)) + lastFailureReason = `Missing advertisement for expected multiaddr(s): ${missing.join(', ')}` + options?.logger?.info( + { + expectation: `multiaddr(s): ${expectedMultiaddrs.join(', ')}`, + providerCount: expectedProviders.length, + matchedMultiaddrs: Array.from(matchedMultiaddrs), + }, + `${lastFailureReason}. Retrying...` + ) + } } else { // Generic validation: just need any provider with addresses isValid = lastActualMultiaddrs.length > 0 + if (!isValid) { + lastFailureReason = 'Expected provider advertisement to include at least one reachable address' + options?.logger?.info(`${lastFailureReason}. Retrying...`) + } } if (isValid) { @@ -214,15 +233,6 @@ export async function validateIPNIAdvertisement( resolve(true) return } - - // Validation not yet successful - log why and retry - lastFailureReason = formatAndLogValidationGap( - matchedMultiaddrs, - expectedMultiaddrs, - hasProviderExpectations, - expectedProviders.length, - options?.logger - ) } 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' @@ -328,29 +338,6 @@ export function serviceURLToMultiaddr(serviceURL: string, logger?: Logger): stri } } -/** - * Extract all provider results from the IPNI indexer response. - * - * The response can contain multiple providers results, each with multiple multiaddr esults. - * This flattens them into a single array for easier processing. - * - * @param response - Raw response from the IPNI indexer - * @returns Flat array of all provider results, or empty array if none found - */ -function extractProviderResults(response: IpniIndexerResponse): ProviderResult[] { - const results = response.MultihashResults - if (!Array.isArray(results)) { - return [] - } - - return results.flatMap(({ ProviderResults }) => { - if (!Array.isArray(ProviderResults)) { - return [] - } - return ProviderResults - }) -} - /** * Derive expected IPNI multiaddrs from provider information. * @@ -403,86 +390,3 @@ function deriveExpectedMultiaddrs( skippedProviderCount, } } - -/** - * Extract all multiaddrs from provider results. - * - * @param providerResults - Provider results from IPNI response - * @returns Array of all multiaddrs found in the response - */ -function extractAllMultiaddrs(providerResults: ProviderResult[]): string[] { - const allMultiaddrs: string[] = [] - for (const providerResult of providerResults) { - const provider = providerResult.Provider - if (!provider) continue - const providerAddrs = provider.Addrs ?? [] - allMultiaddrs.push(...providerAddrs) - } - return allMultiaddrs -} - -/** - * Find which expected multiaddrs are present in the IPNI response. - * - * This is used for specific provider validation. Returns the set of expected - * multiaddrs that were found, allowing the caller to check if ALL expected - * providers are advertising. - * - * @param allMultiaddrs - All multiaddrs found in the IPNI response - * @param expectedMultiaddrs - Set of multiaddrs we expect to find - * @returns Set of expected multiaddrs that were found in the response - */ -function findMatchingMultiaddrs(allMultiaddrs: string[], expectedMultiaddrs: Set): Set { - const matched = new Set() - - for (const addr of allMultiaddrs) { - if (expectedMultiaddrs.has(addr)) { - matched.add(addr) - } - } - - return matched -} - -/** - * Format and log diagnostics about why validation hasn't passed yet. - * - * This provides actionable feedback about what's missing from the IPNI response, - * helping users understand what the validation is waiting for. - * - * @param matchedMultiaddrs - Multiaddrs from expected set that were found - * @param expectedMultiaddrs - All expected multiaddrs (as array for iteration) - * @param hasProviderExpectations - Whether we're doing specific provider validation - * @param expectedProvidersCount - Number of providers we're expecting - * @param logger - Optional logger for output - * @returns Human-readable message describing what's missing - */ -function formatAndLogValidationGap( - matchedMultiaddrs: Set, - expectedMultiaddrs: string[], - hasProviderExpectations: boolean, - expectedProvidersCount: number, - logger: Logger | undefined -): string { - let message: string - - if (hasProviderExpectations) { - // If we're here, validation failed, so there must be missing multiaddrs - const missing = expectedMultiaddrs.filter((addr) => !matchedMultiaddrs.has(addr)) - message = `Missing advertisement for expected multiaddr(s): ${missing.join(', ')}` - - logger?.info( - { - expectation: `multiaddr(s): ${expectedMultiaddrs.join(', ')}`, - providerCount: expectedProvidersCount, - matchedMultiaddrs: Array.from(matchedMultiaddrs), - }, - `${message}. Retrying...` - ) - } else { - message = 'Expected provider advertisement to include at least one reachable address' - logger?.info(`${message}. Retrying...`) - } - - return message -} From 0484671f86667c4d0bdcf53229e7ce63d0d6c4f4 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 12:41:55 -0500 Subject: [PATCH 14/35] chore: more code cleanup --- src/core/utils/validate-ipni-advertisement.ts | 32 ++++--------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 0a3b27e3..1f463ae7 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -144,7 +144,7 @@ export async function validateIPNIAdvertisement( // 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: string[] | undefined + let lastActualMultiaddrs: string[] = [] const check = async (): Promise => { if (options?.signal?.aborted) { @@ -179,7 +179,7 @@ export async function validateIPNIAdvertisement( // Parse and validate response if (response.ok) { - let providerResults: ProviderResult[] | undefined + let providerResults: ProviderResult[] = [] try { const body = (await response.json()) as IpniIndexerResponse // Extract provider results @@ -190,7 +190,7 @@ export async function validateIPNIAdvertisement( } // Check if we have provider results to validate - if (providerResults != null && providerResults.length > 0) { + if (providerResults.length > 0) { // Extract all multiaddrs from provider results lastActualMultiaddrs = providerResults.flatMap((pr) => pr.Provider?.Addrs ?? []) @@ -265,13 +265,8 @@ export async function validateIPNIAdvertisement( msg = `${msgBase}. Last observation: ${lastFailureReason}` } // Include expected and actual multiaddrs for debugging - if (hasProviderExpectations && expectedMultiaddrs.length > 0) { - msg = `${msg}. Expected multiaddrs: [${expectedMultiaddrs.join(', ')}]` - } - if (lastActualMultiaddrs != null && lastActualMultiaddrs.length > 0) { - msg = `${msg}. Actual multiaddrs in response: [${lastActualMultiaddrs.join(', ')}]` - } else if (lastActualMultiaddrs != null) { - msg = `${msg}. Actual multiaddrs in response: [] (no multiaddrs found)` + if (hasProviderExpectations) { + msg = `${msg}. Expected multiaddrs: [${expectedMultiaddrs.join(', ')}]. Actual multiaddrs in response: [${lastActualMultiaddrs.join(', ')}]` } const error = new Error(msg) options?.logger?.warn({ error }, msg) @@ -315,21 +310,8 @@ export async function validateIPNIAdvertisement( export function serviceURLToMultiaddr(serviceURL: string, logger?: Logger): string | undefined { try { const url = new URL(serviceURL) - const port = - url.port !== '' - ? Number.parseInt(url.port, 10) - : url.protocol === 'https:' - ? 443 - : url.protocol === 'http:' - ? 80 - : undefined - - if (Number.isNaN(port) || port == null) { - return undefined - } - - const protocolComponent = - url.protocol === 'https:' ? 'https' : url.protocol === 'http:' ? 'http' : url.protocol.replace(':', '') + const port = url.port || (url.protocol === 'https:' ? '443' : '80') + const protocolComponent = url.protocol.replace(':', '') return `/dns/${url.hostname}/tcp/${port}/${protocolComponent}` } catch (error) { From 35e989b1db2eaed2051139ef540b8ab1e30e2deb Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 12:44:09 -0500 Subject: [PATCH 15/35] fix: PDP definition --- src/core/utils/validate-ipni-advertisement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 1f463ae7..8d537de4 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -288,7 +288,7 @@ export async function validateIPNIAdvertisement( /** * Convert a PDP service URL to an IPNI multiaddr format. * - * Storage providers expose their PDP (Piece Data Provider) service via HTTP/HTTPS + * 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"). * From e5ea86de91452bf65357ee67cfaa05380d8b291b Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 12:50:58 -0500 Subject: [PATCH 16/35] chore: cleanup validateIpni options set in executeUpload --- src/core/upload/index.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/core/upload/index.ts b/src/core/upload/index.ts index 4e821abb..69a13238 100644 --- a/src/core/upload/index.ts +++ b/src/core/upload/index.ts @@ -245,15 +245,11 @@ export async function executeUpload( // Determine which providers to expect in IPNI // Priority: user-provided expectedProviders > current provider > none (generic validation) - const providersToExpect = - expectedProviders && expectedProviders.length > 0 - ? expectedProviders - : synapseService.providerInfo != null - ? [synapseService.providerInfo] - : [] - - if (providersToExpect.length > 0) { - validationOptions.expectedProviders = providersToExpect + // Note: If expectedProviders is explicitly [], we respect that (no provider expectations) + if (expectedProviders != null) { + validationOptions.expectedProviders = expectedProviders + } else if (synapseService.providerInfo != null) { + validationOptions.expectedProviders = [synapseService.providerInfo] } // Start validation (runs in parallel with other operations) @@ -262,8 +258,6 @@ export async function executeUpload( return false }) } - - // Capture transaction hash if available if (event.data.txHash != null) { transactionHash = event.data.txHash } From 540da6e2fd392d5d3109f4e6b3537889dc30124f Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:37:15 -0500 Subject: [PATCH 17/35] fix: last error message --- src/core/utils/validate-ipni-advertisement.ts | 8 ++-- .../unit/validate-ipni-advertisement.test.ts | 47 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 8d537de4..7e18ed29 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -184,16 +184,18 @@ export async function validateIPNIAdvertisement( const body = (await response.json()) as IpniIndexerResponse // Extract provider results providerResults = (body.MultihashResults ?? []).flatMap((r) => r.ProviderResults ?? []) + // Extract all multiaddrs from provider results + lastActualMultiaddrs = providerResults.flatMap((pr) => pr.Provider?.Addrs ?? []) + lastFailureReason = undefined } catch (parseError) { + // Clear actual multiaddrs on parse error + lastActualMultiaddrs = [] lastFailureReason = 'Failed to parse IPNI response body' options?.logger?.warn({ error: parseError }, `${lastFailureReason}. Retrying...`) } // Check if we have provider results to validate if (providerResults.length > 0) { - // Extract all multiaddrs from provider results - lastActualMultiaddrs = providerResults.flatMap((pr) => pr.Provider?.Addrs ?? []) - let isValid = false if (hasProviderExpectations) { diff --git a/src/test/unit/validate-ipni-advertisement.test.ts b/src/test/unit/validate-ipni-advertisement.test.ts index b74a9871..dd8c9e09 100644 --- a/src/test/unit/validate-ipni-advertisement.test.ts +++ b/src/test/unit/validate-ipni-advertisement.test.ts @@ -386,6 +386,53 @@ describe('validateIPNIAdvertisement', () => { await expectPromise }) + it('should clear stale multiaddrs when parse error occurs after successful response', async () => { + // Attempt 1: successful response with multiaddrs but doesn't match expectations + // Attempt 2: parse error - should clear the multiaddrs from attempt 1 + const provider = createProviderInfo('https://expected.example.com') + mockFetch.mockResolvedValueOnce(successResponse(['/dns/other.example.com/tcp/443/https'])).mockResolvedValueOnce({ + ok: true, + json: vi.fn(async () => { + throw new Error('Invalid JSON') + }), + }) + + const promise = validateIPNIAdvertisement(testCid, { + maxAttempts: 2, + expectedProviders: [provider], + }) + + const expectPromise = expect(promise).rejects.toThrow( + 'Failed to parse IPNI response body. Expected multiaddrs: [/dns/expected.example.com/tcp/443/https]. Actual multiaddrs in response: []' + ) + + await vi.runAllTimersAsync() + await expectPromise + }) + + it('should update failure reason on each attempt instead of preserving first error', async () => { + // Attempt 1: parse error + // Attempt 2: successful parse but empty results + // Final error should report empty results as last observation, not parse error + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: vi.fn(async () => { + throw new Error('Invalid JSON') + }), + }) + .mockResolvedValueOnce(emptyProviderResponse()) + + const promise = validateIPNIAdvertisement(testCid, { maxAttempts: 2 }) + + const expectPromise = expect(promise).rejects.toThrow( + 'Last observation: IPNI response did not include any provider results' + ) + + await vi.runAllTimersAsync() + await expectPromise + }) + it('should use custom IPNI indexer URL when provided', async () => { const customIndexerUrl = 'https://custom-indexer.example.com' mockFetch.mockResolvedValueOnce(successResponse()) From a89a4454e696307899a925aec6b381218f51aa63 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:56:54 -0500 Subject: [PATCH 18/35] Update src/core/utils/validate-ipni-advertisement.ts Co-authored-by: Steve Loeppky --- src/core/utils/validate-ipni-advertisement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 7e18ed29..4b6e33db 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -167,7 +167,7 @@ export async function validateIPNIAdvertisement( options?.logger?.warn({ error }, 'Error in consumer onProgress callback for retryUpdate event') } - // Fetch IPNI advertisement + // Fetch IPNI provider records const fetchOptions: RequestInit = { headers: { Accept: 'application/json' }, } From 1ff37465b7aca6765a160598de2682d6b26ab1a3 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:57:05 -0500 Subject: [PATCH 19/35] Update src/core/utils/validate-ipni-advertisement.ts Co-authored-by: Steve Loeppky --- src/core/utils/validate-ipni-advertisement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 4b6e33db..8eb731f5 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -97,7 +97,7 @@ export interface ValidateIPNIAdvertisementOptions { onProgress?: ProgressEventHandler /** - * IPNI indexer URL to query for content advertisements. + * IPNI indexer URL to query for provider records to confirm that advertisements were processed. * * @default 'https://filecoinpin.contact' */ From c0302d3d3494c100de8f16621e819a7486996cd5 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:57:30 -0500 Subject: [PATCH 20/35] Update src/test/unit/validate-ipni-advertisement.test.ts Co-authored-by: Steve Loeppky --- src/test/unit/validate-ipni-advertisement.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/unit/validate-ipni-advertisement.test.ts b/src/test/unit/validate-ipni-advertisement.test.ts index dd8c9e09..c0a08372 100644 --- a/src/test/unit/validate-ipni-advertisement.test.ts +++ b/src/test/unit/validate-ipni-advertisement.test.ts @@ -120,7 +120,7 @@ describe('validateIPNIAdvertisement', () => { }) }) - it('should succeed when all expected providers are advertised', async () => { + it('should succeed when all expected providers are in the IPNI ProviderResults', async () => { const providerA = createProviderInfo('https://a.example.com') const providerB = createProviderInfo('https://b.example.com:8443') const expectedMultiaddrs = ['/dns/a.example.com/tcp/443/https', '/dns/b.example.com/tcp/8443/https'] From d0923078e0c356243c1f9bcdbf622884b8b39cf1 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:57:40 -0500 Subject: [PATCH 21/35] Update src/test/unit/validate-ipni-advertisement.test.ts Co-authored-by: Steve Loeppky --- src/test/unit/validate-ipni-advertisement.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/unit/validate-ipni-advertisement.test.ts b/src/test/unit/validate-ipni-advertisement.test.ts index c0a08372..2a895dba 100644 --- a/src/test/unit/validate-ipni-advertisement.test.ts +++ b/src/test/unit/validate-ipni-advertisement.test.ts @@ -178,7 +178,7 @@ describe('validateIPNIAdvertisement', () => { await expectPromise expect(mockFetch).toHaveBeenCalledTimes(1) }) - it('should reject when an expected provider is missing from the advertisement', async () => { + it('should reject when an expected provider is missing from the IPNI ProviderResults', async () => { const provider = createProviderInfo('https://expected.example.com') mockFetch.mockResolvedValueOnce(successResponse(['/dns/other.example.com/tcp/443/https'])) From ea21fc718d76be0f64cda1e6fdda050c5bdf5028 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:57:55 -0500 Subject: [PATCH 22/35] Update src/test/unit/validate-ipni-advertisement.test.ts Co-authored-by: Steve Loeppky --- src/test/unit/validate-ipni-advertisement.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/unit/validate-ipni-advertisement.test.ts b/src/test/unit/validate-ipni-advertisement.test.ts index 2a895dba..b6dc859b 100644 --- a/src/test/unit/validate-ipni-advertisement.test.ts +++ b/src/test/unit/validate-ipni-advertisement.test.ts @@ -231,7 +231,7 @@ describe('validateIPNIAdvertisement', () => { expect(mockFetch).toHaveBeenCalledTimes(2) }) - it('should retry when the IPNI response contains no provider results', async () => { + it('should retry when the IPNI response is empty', async () => { const provider = createProviderInfo('https://expected.example.com') const expectedMultiaddr = '/dns/expected.example.com/tcp/443/https' mockFetch From 8d5d6546d43e46941bca1646676f3f6b4a807f4f Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:58:08 -0500 Subject: [PATCH 23/35] Update src/test/unit/validate-ipni-advertisement.test.ts Co-authored-by: Steve Loeppky --- src/test/unit/validate-ipni-advertisement.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/unit/validate-ipni-advertisement.test.ts b/src/test/unit/validate-ipni-advertisement.test.ts index b6dc859b..b855661c 100644 --- a/src/test/unit/validate-ipni-advertisement.test.ts +++ b/src/test/unit/validate-ipni-advertisement.test.ts @@ -194,7 +194,7 @@ describe('validateIPNIAdvertisement', () => { await expectPromise }) - it('should reject when not all expected providers are advertised', async () => { + it('should reject when not all expected providers are in the IPNI ProviderResults', async () => { const providerA = createProviderInfo('https://a.example.com') const providerB = createProviderInfo('https://b.example.com') mockFetch.mockResolvedValueOnce(successResponse(['/dns/a.example.com/tcp/443/https'])) From 6a55fef2a08165aab13120b2aec4b3b8279c28d1 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:58:30 -0500 Subject: [PATCH 24/35] Update src/core/utils/validate-ipni-advertisement.ts Co-authored-by: Steve Loeppky --- src/core/utils/validate-ipni-advertisement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 8eb731f5..88ce8dd6 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -206,7 +206,7 @@ export async function validateIPNIAdvertisement( if (!isValid) { // Log validation gap const missing = expectedMultiaddrs.filter((addr) => !matchedMultiaddrs.has(addr)) - lastFailureReason = `Missing advertisement for expected multiaddr(s): ${missing.join(', ')}` + lastFailureReason = `Missing provider records with expected multiaddr(s): ${missing.join(', ')}` options?.logger?.info( { expectation: `multiaddr(s): ${expectedMultiaddrs.join(', ')}`, From 623bf878f3639ecb72d2014c8e786243d86a97f9 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:58:54 -0500 Subject: [PATCH 25/35] Update src/core/utils/validate-ipni-advertisement.ts Co-authored-by: Steve Loeppky --- src/core/utils/validate-ipni-advertisement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 88ce8dd6..e7c5cdff 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -220,7 +220,7 @@ export async function validateIPNIAdvertisement( // Generic validation: just need any provider with addresses isValid = lastActualMultiaddrs.length > 0 if (!isValid) { - lastFailureReason = 'Expected provider advertisement to include at least one reachable address' + lastFailureReason = 'Expected at least one provider record' options?.logger?.info(`${lastFailureReason}. Retrying...`) } } From 23cab569755fec0ea5b3d5467281dc81620982ff Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:26:37 -0500 Subject: [PATCH 26/35] fix: remove expectedProviderMultiaddrs --- src/core/utils/validate-ipni-advertisement.ts | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index e7c5cdff..e5ab2300 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -80,15 +80,6 @@ export interface ValidateIPNIAdvertisementOptions { */ expectedProviders?: ProviderInfo[] | undefined - /** - * Additional provider multiaddrs that must be present in the IPNI - * advertisement. These are merged with the derived multiaddrs from - * {@link expectedProviders}. - * - * @default: undefined - */ - expectedProviderMultiaddrs?: string[] | undefined - /** * Callback for progress updates * @@ -121,11 +112,7 @@ export async function validateIPNIAdvertisement( 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?.expectedProviderMultiaddrs, - options?.logger - ) + const { expectedMultiaddrs, skippedProviderCount } = deriveExpectedMultiaddrs(expectedProviders, options?.logger) const expectedMultiaddrsSet = new Set(expectedMultiaddrs) const hasProviderExpectations = expectedMultiaddrs.length > 0 @@ -332,13 +319,11 @@ export function serviceURLToMultiaddr(serviceURL: string, logger?: Logger): stri * Note: ProviderInfo should contain the serviceURL at `products.PDP.data.serviceURL`. * * @param providers - Array of provider info objects from synapse SDK - * @param extraMultiaddrs - Additional multiaddrs to include in expectations * @param logger - Optional logger for diagnostics * @returns Expected multiaddrs and count of providers that couldn't be processed */ function deriveExpectedMultiaddrs( providers: ProviderInfo[], - extraMultiaddrs: string[] | undefined, logger: Logger | undefined ): { expectedMultiaddrs: string[] @@ -366,11 +351,8 @@ function deriveExpectedMultiaddrs( derivedMultiaddrs.push(derivedMultiaddr) } - const additionalMultiaddrs = extraMultiaddrs?.filter((addr) => addr != null && addr !== '') ?? [] - const expectedMultiaddrs = Array.from(new Set([...additionalMultiaddrs, ...derivedMultiaddrs])) - return { - expectedMultiaddrs, + expectedMultiaddrs: derivedMultiaddrs, skippedProviderCount, } } From 7bd263295fea222624befc7cbcf03239a5997c50 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:30:34 -0500 Subject: [PATCH 27/35] refactor: validateIPNIadvertisement -> waitForIpniProviderResults --- src/core/upload/index.ts | 4 +- src/core/utils/validate-ipni-advertisement.ts | 8 +++- .../unit/validate-ipni-advertisement.test.ts | 44 +++++++++---------- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/core/upload/index.ts b/src/core/upload/index.ts index 69a13238..a1aabed1 100644 --- a/src/core/upload/index.ts +++ b/src/core/upload/index.ts @@ -15,7 +15,7 @@ import type { ProgressEvent, ProgressEventHandler } from '../utils/types.js' import { type ValidateIPNIAdvertisementOptions, type ValidateIPNIProgressEvents, - validateIPNIAdvertisement, + waitForIpniProviderResults, } from '../utils/validate-ipni-advertisement.js' import { type SynapseUploadResult, type UploadProgressEvents, uploadToSynapse } from './synapse.js' @@ -253,7 +253,7 @@ export async function executeUpload( } // Start validation (runs in parallel with other operations) - ipniValidationPromise = validateIPNIAdvertisement(rootCid, validationOptions).catch((error) => { + ipniValidationPromise = waitForIpniProviderResults(rootCid, validationOptions).catch((error) => { logger.warn({ error }, 'IPNI advertisement validation promise rejected') return false }) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index e5ab2300..8b3aeda2 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -96,7 +96,11 @@ export interface ValidateIPNIAdvertisementOptions { } /** - * Check if the SP has announced the IPFS root CID to IPNI. + * Check if the IPNI Indexer has the provided ProviderResults for the provided ipfsRootCid. + * This effectively verifies the entire SP flow that: + * - The advertisement was announce to the IPNI indexer(s) + * - The IPNI indexer(s) pulled the advertisement + * - The IPNI indexer(s) updated their index * * 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. * @@ -104,7 +108,7 @@ export interface ValidateIPNIAdvertisementOptions { * @param options - Options for the check * @returns True if the IPNI announce succeeded, false otherwise */ -export async function validateIPNIAdvertisement( +export async function waitForIpniProviderResults( ipfsRootCid: CID, options?: ValidateIPNIAdvertisementOptions ): Promise { diff --git a/src/test/unit/validate-ipni-advertisement.test.ts b/src/test/unit/validate-ipni-advertisement.test.ts index b855661c..d9c01337 100644 --- a/src/test/unit/validate-ipni-advertisement.test.ts +++ b/src/test/unit/validate-ipni-advertisement.test.ts @@ -1,7 +1,7 @@ import type { ProviderInfo } from '@filoz/synapse-sdk' import { CID } from 'multiformats/cid' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { validateIPNIAdvertisement } from '../../core/utils/validate-ipni-advertisement.js' +import { waitForIpniProviderResults } from '../../core/utils/validate-ipni-advertisement.js' describe('validateIPNIAdvertisement', () => { const testCid = CID.parse('bafkreia5fn4rmshmb7cl7fufkpcw733b5anhuhydtqstnglpkzosqln5kq') @@ -61,7 +61,7 @@ describe('validateIPNIAdvertisement', () => { mockFetch.mockResolvedValueOnce(successResponse()) const onProgress = vi.fn() - const promise = validateIPNIAdvertisement(testCid, { onProgress }) + const promise = waitForIpniProviderResults(testCid, { onProgress }) await vi.runAllTimersAsync() const result = await promise @@ -87,7 +87,7 @@ describe('validateIPNIAdvertisement', () => { .mockResolvedValueOnce(successResponse()) const onProgress = vi.fn() - const promise = validateIPNIAdvertisement(testCid, { maxAttempts: 5, onProgress }) + const promise = waitForIpniProviderResults(testCid, { maxAttempts: 5, onProgress }) await vi.runAllTimersAsync() const result = await promise @@ -110,7 +110,7 @@ describe('validateIPNIAdvertisement', () => { const expectedMultiaddr = '/dns/example.com/tcp/443/https' mockFetch.mockResolvedValueOnce(successResponse([expectedMultiaddr])) - const promise = validateIPNIAdvertisement(testCid, { expectedProviders: [provider] }) + const promise = waitForIpniProviderResults(testCid, { expectedProviders: [provider] }) await vi.runAllTimersAsync() const result = await promise @@ -127,7 +127,7 @@ describe('validateIPNIAdvertisement', () => { mockFetch.mockResolvedValueOnce(successResponse(expectedMultiaddrs)) - const promise = validateIPNIAdvertisement(testCid, { expectedProviders: [providerA, providerB] }) + const promise = waitForIpniProviderResults(testCid, { expectedProviders: [providerA, providerB] }) await vi.runAllTimersAsync() const result = await promise @@ -139,7 +139,7 @@ describe('validateIPNIAdvertisement', () => { it('should reject after custom maxAttempts and emit a failed event', async () => { mockFetch.mockResolvedValue({ ok: false }) const onProgress = vi.fn() - const promise = validateIPNIAdvertisement(testCid, { maxAttempts: 3, onProgress }) + const promise = waitForIpniProviderResults(testCid, { maxAttempts: 3, onProgress }) // Attach rejection handler immediately const expectPromise = expect(promise).rejects.toThrow( `IPFS root CID "${testCid.toString()}" not announced to IPNI after 3 attempts` @@ -168,7 +168,7 @@ describe('validateIPNIAdvertisement', () => { it('should reject immediately when maxAttempts is 1', async () => { mockFetch.mockResolvedValue({ ok: false }) - const promise = validateIPNIAdvertisement(testCid, { maxAttempts: 1 }) + const promise = waitForIpniProviderResults(testCid, { maxAttempts: 1 }) // Attach rejection handler immediately const expectPromise = expect(promise).rejects.toThrow( `IPFS root CID "${testCid.toString()}" not announced to IPNI after 1 attempt` @@ -182,7 +182,7 @@ describe('validateIPNIAdvertisement', () => { const provider = createProviderInfo('https://expected.example.com') mockFetch.mockResolvedValueOnce(successResponse(['/dns/other.example.com/tcp/443/https'])) - const promise = validateIPNIAdvertisement(testCid, { + const promise = waitForIpniProviderResults(testCid, { maxAttempts: 1, expectedProviders: [provider], }) @@ -199,7 +199,7 @@ describe('validateIPNIAdvertisement', () => { const providerB = createProviderInfo('https://b.example.com') mockFetch.mockResolvedValueOnce(successResponse(['/dns/a.example.com/tcp/443/https'])) - const promise = validateIPNIAdvertisement(testCid, { + const promise = waitForIpniProviderResults(testCid, { maxAttempts: 1, expectedProviders: [providerA, providerB], }) @@ -218,7 +218,7 @@ describe('validateIPNIAdvertisement', () => { .mockResolvedValueOnce(successResponse(['/dns/other.example.com/tcp/443/https'])) .mockResolvedValueOnce(successResponse([expectedMultiaddr])) - const promise = validateIPNIAdvertisement(testCid, { + const promise = waitForIpniProviderResults(testCid, { maxAttempts: 3, expectedProviders: [provider], delayMs: 1, @@ -238,7 +238,7 @@ describe('validateIPNIAdvertisement', () => { .mockResolvedValueOnce(emptyProviderResponse()) .mockResolvedValueOnce(successResponse([expectedMultiaddr])) - const promise = validateIPNIAdvertisement(testCid, { + const promise = waitForIpniProviderResults(testCid, { maxAttempts: 3, expectedProviders: [provider], delayMs: 1, @@ -257,7 +257,7 @@ describe('validateIPNIAdvertisement', () => { const abortController = new AbortController() abortController.abort() - const promise = validateIPNIAdvertisement(testCid, { signal: abortController.signal }) + const promise = waitForIpniProviderResults(testCid, { signal: abortController.signal }) // Attach rejection handler immediately const expectPromise = expect(promise).rejects.toThrow('Check IPNI announce aborted') @@ -270,7 +270,7 @@ describe('validateIPNIAdvertisement', () => { const abortController = new AbortController() mockFetch.mockResolvedValue({ ok: false }) - const promise = validateIPNIAdvertisement(testCid, { signal: abortController.signal, maxAttempts: 5 }) + const promise = waitForIpniProviderResults(testCid, { signal: abortController.signal, maxAttempts: 5 }) // Let first check complete await vi.advanceTimersByTimeAsync(0) @@ -292,7 +292,7 @@ describe('validateIPNIAdvertisement', () => { const abortController = new AbortController() mockFetch.mockResolvedValueOnce(successResponse()) - const promise = validateIPNIAdvertisement(testCid, { signal: abortController.signal }) + const promise = waitForIpniProviderResults(testCid, { signal: abortController.signal }) await vi.runAllTimersAsync() await promise @@ -307,7 +307,7 @@ describe('validateIPNIAdvertisement', () => { it('should handle fetch throwing an error', async () => { mockFetch.mockRejectedValueOnce(new Error('Network error')) - const promise = validateIPNIAdvertisement(testCid, {}) + const promise = waitForIpniProviderResults(testCid, {}) const expectPromise = expect(promise).rejects.toThrow('Network error') await vi.runAllTimersAsync() @@ -318,7 +318,7 @@ describe('validateIPNIAdvertisement', () => { const v0Cid = CID.parse('QmNT6isqrhH6LZWg8NeXQYTD9wPjJo2BHHzyezpf9BdHbD') mockFetch.mockResolvedValueOnce(successResponse()) - const promise = validateIPNIAdvertisement(v0Cid, {}) + const promise = waitForIpniProviderResults(v0Cid, {}) await vi.runAllTimersAsync() const result = await promise @@ -345,7 +345,7 @@ describe('validateIPNIAdvertisement', () => { })), }) - const promise = validateIPNIAdvertisement(testCid, { maxAttempts: 1 }) + const promise = waitForIpniProviderResults(testCid, { maxAttempts: 1 }) await vi.runAllTimersAsync() const result = await promise @@ -363,7 +363,7 @@ describe('validateIPNIAdvertisement', () => { mockFetch.mockResolvedValueOnce(successResponse()) - const promise = validateIPNIAdvertisement(testCid, { expectedProviders: [providerWithoutURL] }) + const promise = waitForIpniProviderResults(testCid, { expectedProviders: [providerWithoutURL] }) await vi.runAllTimersAsync() const result = await promise @@ -378,7 +378,7 @@ describe('validateIPNIAdvertisement', () => { }), }) - const promise = validateIPNIAdvertisement(testCid, { maxAttempts: 1 }) + const promise = waitForIpniProviderResults(testCid, { maxAttempts: 1 }) // Should preserve the specific "Failed to parse" message, not overwrite with generic message const expectPromise = expect(promise).rejects.toThrow('Failed to parse IPNI response body') @@ -397,7 +397,7 @@ describe('validateIPNIAdvertisement', () => { }), }) - const promise = validateIPNIAdvertisement(testCid, { + const promise = waitForIpniProviderResults(testCid, { maxAttempts: 2, expectedProviders: [provider], }) @@ -423,7 +423,7 @@ describe('validateIPNIAdvertisement', () => { }) .mockResolvedValueOnce(emptyProviderResponse()) - const promise = validateIPNIAdvertisement(testCid, { maxAttempts: 2 }) + const promise = waitForIpniProviderResults(testCid, { maxAttempts: 2 }) const expectPromise = expect(promise).rejects.toThrow( 'Last observation: IPNI response did not include any provider results' @@ -437,7 +437,7 @@ describe('validateIPNIAdvertisement', () => { const customIndexerUrl = 'https://custom-indexer.example.com' mockFetch.mockResolvedValueOnce(successResponse()) - const promise = validateIPNIAdvertisement(testCid, { ipniIndexerUrl: customIndexerUrl }) + const promise = waitForIpniProviderResults(testCid, { ipniIndexerUrl: customIndexerUrl }) await vi.runAllTimersAsync() const result = await promise From 6ea2930f8edffc547aad818cab36eb51adc499b2 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:46:15 -0500 Subject: [PATCH 28/35] fix: use set operations, finish move to waitForIpniProviderResults --- src/core/upload/index.ts | 6 +-- src/core/utils/validate-ipni-advertisement.ts | 41 ++++++++++--------- src/test/unit/import.test.ts | 2 +- .../unit/validate-ipni-advertisement.test.ts | 6 +-- 4 files changed, 28 insertions(+), 27 deletions(-) diff --git a/src/core/upload/index.ts b/src/core/upload/index.ts index a1aabed1..4c238991 100644 --- a/src/core/upload/index.ts +++ b/src/core/upload/index.ts @@ -13,8 +13,8 @@ import { import { isSessionKeyMode, type SynapseService } from '../synapse/index.js' import type { ProgressEvent, ProgressEventHandler } from '../utils/types.js' import { - type ValidateIPNIAdvertisementOptions, type ValidateIPNIProgressEvents, + type WaitForIpniProviderResultsOptions, waitForIpniProviderResults, } from '../utils/validate-ipni-advertisement.js' import { type SynapseUploadResult, type UploadProgressEvents, uploadToSynapse } from './synapse.js' @@ -195,7 +195,7 @@ export interface UploadExecutionOptions { * @default: true */ enabled?: boolean - } & Omit + } & Omit } export interface UploadExecutionResult extends SynapseUploadResult { @@ -233,7 +233,7 @@ export async function executeUpload( const { enabled: _enabled, expectedProviders, ...restOptions } = options.ipniValidation ?? {} // Build validation options - const validationOptions: ValidateIPNIAdvertisementOptions = { + const validationOptions: WaitForIpniProviderResultsOptions = { ...restOptions, logger, } diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 8b3aeda2..dd0eee0f 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -40,7 +40,7 @@ export type ValidateIPNIProgressEvents = | ProgressEvent<'ipniAdvertisement.complete', { result: true; retryCount: number }> | ProgressEvent<'ipniAdvertisement.failed', { error: Error }> -export interface ValidateIPNIAdvertisementOptions { +export interface WaitForIpniProviderResultsOptions { /** * maximum number of attempts * @@ -110,7 +110,7 @@ export interface ValidateIPNIAdvertisementOptions { */ export async function waitForIpniProviderResults( ipfsRootCid: CID, - options?: ValidateIPNIAdvertisementOptions + options?: WaitForIpniProviderResultsOptions ): Promise { const delayMs = options?.delayMs ?? 5000 const maxAttempts = options?.maxAttempts ?? 20 @@ -119,7 +119,7 @@ export async function waitForIpniProviderResults( const { expectedMultiaddrs, skippedProviderCount } = deriveExpectedMultiaddrs(expectedProviders, options?.logger) const expectedMultiaddrsSet = new Set(expectedMultiaddrs) - const hasProviderExpectations = expectedMultiaddrs.length > 0 + 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) @@ -135,7 +135,7 @@ export async function waitForIpniProviderResults( // 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: string[] = [] + let lastActualMultiaddrs: Set = new Set() const check = async (): Promise => { if (options?.signal?.aborted) { @@ -176,11 +176,11 @@ export async function waitForIpniProviderResults( // Extract provider results providerResults = (body.MultihashResults ?? []).flatMap((r) => r.ProviderResults ?? []) // Extract all multiaddrs from provider results - lastActualMultiaddrs = providerResults.flatMap((pr) => pr.Provider?.Addrs ?? []) + lastActualMultiaddrs = new Set(providerResults.flatMap((pr) => pr.Provider?.Addrs ?? [])) lastFailureReason = undefined } catch (parseError) { // Clear actual multiaddrs on parse error - lastActualMultiaddrs = [] + lastActualMultiaddrs = new Set() lastFailureReason = 'Failed to parse IPNI response body' options?.logger?.warn({ error: parseError }, `${lastFailureReason}. Retrying...`) } @@ -190,26 +190,27 @@ export async function waitForIpniProviderResults( let isValid = false if (hasProviderExpectations) { - // Find matching multiaddrs - inline filter + Set - const matchedMultiaddrs = new Set(lastActualMultiaddrs.filter((addr) => expectedMultiaddrsSet.has(addr))) - isValid = matchedMultiaddrs.size === expectedMultiaddrs.length + // Find matching multiaddrs + + const matchedMultiaddrs = lastActualMultiaddrs.intersection(expectedMultiaddrsSet) + isValid = matchedMultiaddrs.size === expectedMultiaddrs.size if (!isValid) { // Log validation gap - const missing = expectedMultiaddrs.filter((addr) => !matchedMultiaddrs.has(addr)) - lastFailureReason = `Missing provider records with expected multiaddr(s): ${missing.join(', ')}` + const missingMultiaddrs = expectedMultiaddrsSet.difference(matchedMultiaddrs) + lastFailureReason = `Missing provider records with expected multiaddr(s): ${Array.from(missingMultiaddrs).join(', ')}` options?.logger?.info( { - expectation: `multiaddr(s): ${expectedMultiaddrs.join(', ')}`, - providerCount: expectedProviders.length, - matchedMultiaddrs: Array.from(matchedMultiaddrs), + receivedMultiaddrs: lastActualMultiaddrs, + matchedMultiaddrs, + missingMultiaddrs, }, `${lastFailureReason}. Retrying...` ) } } else { // Generic validation: just need any provider with addresses - isValid = lastActualMultiaddrs.length > 0 + isValid = lastActualMultiaddrs.size > 0 if (!isValid) { lastFailureReason = 'Expected at least one provider record' options?.logger?.info(`${lastFailureReason}. Retrying...`) @@ -230,7 +231,7 @@ export async function waitForIpniProviderResults( // 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 = [] + lastActualMultiaddrs = new Set() options?.logger?.info( { providerResultsCount: providerResults?.length ?? 0 }, `${lastFailureReason}. Retrying...` @@ -259,7 +260,7 @@ export async function waitForIpniProviderResults( } // Include expected and actual multiaddrs for debugging if (hasProviderExpectations) { - msg = `${msg}. Expected multiaddrs: [${expectedMultiaddrs.join(', ')}]. Actual multiaddrs in response: [${lastActualMultiaddrs.join(', ')}]` + 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) @@ -330,10 +331,10 @@ function deriveExpectedMultiaddrs( providers: ProviderInfo[], logger: Logger | undefined ): { - expectedMultiaddrs: string[] + expectedMultiaddrs: Set skippedProviderCount: number } { - const derivedMultiaddrs: string[] = [] + const derivedMultiaddrs: Set = new Set() let skippedProviderCount = 0 for (const provider of providers) { @@ -352,7 +353,7 @@ function deriveExpectedMultiaddrs( continue } - derivedMultiaddrs.push(derivedMultiaddr) + derivedMultiaddrs.add(derivedMultiaddr) } return { diff --git a/src/test/unit/import.test.ts b/src/test/unit/import.test.ts index 4fc49ffd..eec48129 100644 --- a/src/test/unit/import.test.ts +++ b/src/test/unit/import.test.ts @@ -77,7 +77,7 @@ vi.mock('../../core/payments/index.js', async () => { } }) vi.mock('../../core/utils/validate-ipni-advertisement.js', () => ({ - validateIPNIAdvertisement: vi.fn().mockResolvedValue(true), + waitForIpniProviderResults: vi.fn().mockResolvedValue(true), })) vi.mock('../../payments/setup.js', () => ({ diff --git a/src/test/unit/validate-ipni-advertisement.test.ts b/src/test/unit/validate-ipni-advertisement.test.ts index d9c01337..7fb0488e 100644 --- a/src/test/unit/validate-ipni-advertisement.test.ts +++ b/src/test/unit/validate-ipni-advertisement.test.ts @@ -3,7 +3,7 @@ import { CID } from 'multiformats/cid' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { waitForIpniProviderResults } from '../../core/utils/validate-ipni-advertisement.js' -describe('validateIPNIAdvertisement', () => { +describe('waitForIpniProviderResults', () => { const testCid = CID.parse('bafkreia5fn4rmshmb7cl7fufkpcw733b5anhuhydtqstnglpkzosqln5kq') const defaultIndexerUrl = 'https://filecoinpin.contact' const mockFetch = vi.fn() @@ -188,7 +188,7 @@ describe('validateIPNIAdvertisement', () => { }) const expectPromise = expect(promise).rejects.toThrow( - `IPFS root CID "${testCid.toString()}" not announced to IPNI after 1 attempt. Last observation: Missing advertisement for expected multiaddr(s): /dns/expected.example.com/tcp/443/https` + `IPFS root CID "${testCid.toString()}" not announced to IPNI after 1 attempt. Last observation: Missing provider records with expected multiaddr(s): /dns/expected.example.com/tcp/443/https` ) await vi.runAllTimersAsync() await expectPromise @@ -205,7 +205,7 @@ describe('validateIPNIAdvertisement', () => { }) const expectPromise = expect(promise).rejects.toThrow( - `IPFS root CID "${testCid.toString()}" not announced to IPNI after 1 attempt. Last observation: Missing advertisement for expected multiaddr(s): /dns/b.example.com/tcp/443/https` + `IPFS root CID "${testCid.toString()}" not announced to IPNI after 1 attempt. Last observation: Missing provider records with expected multiaddr(s): /dns/b.example.com/tcp/443/https` ) await vi.runAllTimersAsync() await expectPromise From 33f1ae992a7c265cf933080b13edfdd8791d20b5 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:55:27 -0500 Subject: [PATCH 29/35] fix: update terminology, ipniAdvertisement -> ipni provider results --- src/common/upload-flow.ts | 14 +++++------ src/core/upload/index.ts | 4 ++-- src/core/utils/validate-ipni-advertisement.ts | 16 ++++++------- .../unit/validate-ipni-advertisement.test.ts | 24 +++++++++---------- upload-action/src/filecoin.js | 14 +++++------ 5 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/common/upload-flow.ts b/src/common/upload-flow.ts index d056a015..a59b943f 100644 --- a/src/common/upload-flow.ts +++ b/src/common/upload-flow.ts @@ -267,7 +267,7 @@ export async function performUpload( let pieceCid: PieceCID | undefined function getIpniAdvertisementMsg(attemptCount: number): string { - return `Checking for IPNI advertisement (check #${attemptCount})` + return `Checking for IPNI provider records (check #${attemptCount})` } const uploadResult = await executeUpload(synapseService, carData, rootCid, { @@ -321,14 +321,14 @@ export async function performUpload( break } - case 'ipniAdvertisement.retryUpdate': { + case 'ipniProviderResults.retryUpdate': { const attemptCount = event.data.retryCount === 0 ? 1 : event.data.retryCount + 1 flow.addOperation('ipni', getIpniAdvertisementMsg(attemptCount)) break } - case 'ipniAdvertisement.complete': { + case 'ipniProviderResults.complete': { // complete event is only emitted when result === true (success) - flow.completeOperation('ipni', 'IPNI advertisement successful. IPFS retrieval possible.', { + flow.completeOperation('ipni', 'IPNI provider records found. IPFS retrieval possible.', { type: 'success', details: { title: 'IPFS Retrieval URLs', @@ -341,12 +341,12 @@ export async function performUpload( }) break } - case 'ipniAdvertisement.failed': { - flow.completeOperation('ipni', 'IPNI advertisement failed.', { + case 'ipniProviderResults.failed': { + flow.completeOperation('ipni', 'IPNI provider records not found.', { type: 'warning', details: { title: 'IPFS retrieval is not possible yet.', - content: [pc.gray(`IPNI advertisement does not exist at http://filecoinpin.contact/cid/${rootCid}`)], + content: [pc.gray(`IPNI provider records for this SP does not exist for the provided root CID`)], }, }) break diff --git a/src/core/upload/index.ts b/src/core/upload/index.ts index 4c238991..45092b89 100644 --- a/src/core/upload/index.ts +++ b/src/core/upload/index.ts @@ -254,7 +254,7 @@ export async function executeUpload( // Start validation (runs in parallel with other operations) ipniValidationPromise = waitForIpniProviderResults(rootCid, validationOptions).catch((error) => { - logger.warn({ error }, 'IPNI advertisement validation promise rejected') + logger.warn({ error }, 'IPNI provider results check was rejected') return false }) } @@ -288,7 +288,7 @@ export async function executeUpload( try { ipniValidated = await ipniValidationPromise } catch (error) { - logger.error({ error }, 'Could not validate IPNI advertisement') + logger.error({ error }, 'Could not validate IPNI provider records') ipniValidated = false } } diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index dd0eee0f..d4a7a00e 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -36,9 +36,9 @@ interface ProviderResult { } export type ValidateIPNIProgressEvents = - | ProgressEvent<'ipniAdvertisement.retryUpdate', { retryCount: number }> - | ProgressEvent<'ipniAdvertisement.complete', { result: true; retryCount: number }> - | ProgressEvent<'ipniAdvertisement.failed', { error: Error }> + | ProgressEvent<'ipniProviderResults.retryUpdate', { retryCount: number }> + | ProgressEvent<'ipniProviderResults.complete', { result: true; retryCount: number }> + | ProgressEvent<'ipniProviderResults.failed', { error: Error }> export interface WaitForIpniProviderResultsOptions { /** @@ -70,7 +70,7 @@ export interface WaitForIpniProviderResultsOptions { logger?: Logger | undefined /** - * Providers that are expected to appear in the IPNI advertisement. All + * 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 succeeds once the IPNI * response includes any provider entry that advertises at least one address @@ -153,7 +153,7 @@ export async function waitForIpniProviderResults( // Emit progress event for this attempt try { - options?.onProgress?.({ type: 'ipniAdvertisement.retryUpdate', data: { retryCount } }) + options?.onProgress?.({ type: 'ipniProviderResults.retryUpdate', data: { retryCount } }) } catch (error) { options?.logger?.warn({ error }, 'Error in consumer onProgress callback for retryUpdate event') } @@ -220,7 +220,7 @@ export async function waitForIpniProviderResults( if (isValid) { // Validation succeeded! try { - options?.onProgress?.({ type: 'ipniAdvertisement.complete', data: { result: true, retryCount } }) + options?.onProgress?.({ type: 'ipniProviderResults.complete', data: { result: true, retryCount } }) } catch (error) { options?.logger?.warn({ error }, 'Error in consumer onProgress callback for complete event') } @@ -270,7 +270,7 @@ export async function waitForIpniProviderResults( check().catch((error) => { try { - options?.onProgress?.({ type: 'ipniAdvertisement.failed', data: { error } }) + options?.onProgress?.({ type: 'ipniProviderResults.failed', data: { error } }) } catch (callbackError) { options?.logger?.warn({ error: callbackError }, 'Error in consumer onProgress callback for failed event') } @@ -287,7 +287,7 @@ export async function waitForIpniProviderResults( * 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 advertisement matches their registered service endpoint. + * 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 diff --git a/src/test/unit/validate-ipni-advertisement.test.ts b/src/test/unit/validate-ipni-advertisement.test.ts index 7fb0488e..8ece605c 100644 --- a/src/test/unit/validate-ipni-advertisement.test.ts +++ b/src/test/unit/validate-ipni-advertisement.test.ts @@ -72,9 +72,9 @@ describe('waitForIpniProviderResults', () => { }) // Should emit retryUpdate for attempt 0 and a final complete(true) - expect(onProgress).toHaveBeenCalledWith({ type: 'ipniAdvertisement.retryUpdate', data: { retryCount: 0 } }) + expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.retryUpdate', data: { retryCount: 0 } }) expect(onProgress).toHaveBeenCalledWith({ - type: 'ipniAdvertisement.complete', + type: 'ipniProviderResults.complete', data: { result: true, retryCount: 0 }, }) }) @@ -95,12 +95,12 @@ describe('waitForIpniProviderResults', () => { expect(mockFetch).toHaveBeenCalledTimes(4) // Expect retryUpdate with counts 0,1,2,3 and final complete with retryCount 3 - expect(onProgress).toHaveBeenCalledWith({ type: 'ipniAdvertisement.retryUpdate', data: { retryCount: 0 } }) - expect(onProgress).toHaveBeenCalledWith({ type: 'ipniAdvertisement.retryUpdate', data: { retryCount: 1 } }) - expect(onProgress).toHaveBeenCalledWith({ type: 'ipniAdvertisement.retryUpdate', data: { retryCount: 2 } }) - expect(onProgress).toHaveBeenCalledWith({ type: 'ipniAdvertisement.retryUpdate', data: { retryCount: 3 } }) + expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.retryUpdate', data: { retryCount: 0 } }) + expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.retryUpdate', data: { retryCount: 1 } }) + expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.retryUpdate', data: { retryCount: 2 } }) + expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.retryUpdate', data: { retryCount: 3 } }) expect(onProgress).toHaveBeenCalledWith({ - type: 'ipniAdvertisement.complete', + type: 'ipniProviderResults.complete', data: { result: true, retryCount: 3 }, }) }) @@ -150,17 +150,17 @@ describe('waitForIpniProviderResults', () => { expect(mockFetch).toHaveBeenCalledTimes(3) // Expect retryUpdate with counts 0,1,2 and final failed event (no complete event on failure) - expect(onProgress).toHaveBeenCalledWith({ type: 'ipniAdvertisement.retryUpdate', data: { retryCount: 0 } }) - expect(onProgress).toHaveBeenCalledWith({ type: 'ipniAdvertisement.retryUpdate', data: { retryCount: 1 } }) - expect(onProgress).toHaveBeenCalledWith({ type: 'ipniAdvertisement.retryUpdate', data: { retryCount: 2 } }) + expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.retryUpdate', data: { retryCount: 0 } }) + expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.retryUpdate', data: { retryCount: 1 } }) + expect(onProgress).toHaveBeenCalledWith({ type: 'ipniProviderResults.retryUpdate', data: { retryCount: 2 } }) // Should emit failed event, not complete(false) expect(onProgress).toHaveBeenCalledWith({ - type: 'ipniAdvertisement.failed', + type: 'ipniProviderResults.failed', data: { error: expect.any(Error) }, }) // Should NOT emit complete event expect(onProgress).not.toHaveBeenCalledWith({ - type: 'ipniAdvertisement.complete', + type: 'ipniProviderResults.complete', data: { result: false, retryCount: expect.any(Number) }, }) }) diff --git a/upload-action/src/filecoin.js b/upload-action/src/filecoin.js index 43b9bd44..760efac1 100644 --- a/upload-action/src/filecoin.js +++ b/upload-action/src/filecoin.js @@ -178,17 +178,17 @@ export async function uploadCarToFilecoin(synapse, carPath, ipfsRootCid, options console.log(`Piece ID(s): ${event.data.pieceIds.join(', ')}`) break } - // IPNI advertisement progress events - case 'ipniAdvertisement.retryUpdate': { - console.log(`IPNI advertisement validation attempt #${event.data.retryCount + 1}...`) + // IPNI provider results progress events + case 'ipniProviderResults.retryUpdate': { + console.log(`IPNI provider results check attempt #${event.data.retryCount + 1}...`) break } - case 'ipniAdvertisement.complete': { - console.log(event.data.result ? '✓ IPNI advertisement successful' : '✗ IPNI advertisement failed') + case 'ipniProviderResults.complete': { + console.log(event.data.result ? '✓ IPNI provider results found' : '✗ IPNI provider results not found') break } - case 'ipniAdvertisement.failed': { - console.log('✗ IPNI advertisement failed') + case 'ipniProviderResults.failed': { + console.log('✗ IPNI provider results not found') console.log(`Error: ${event.data.error.message}`) break } From c1edc1654ac468339267596ea1c7a3e27fcfd55e Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 21:07:39 -0500 Subject: [PATCH 30/35] fix: include Set operations in typescript --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index a886510b..3cab49c2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "lib": ["ES2022", "DOM"], + "lib": ["ES2022", "ESNext.Collection", "DOM"], "outDir": "./dist", "rootDir": "./src", "strict": true, From 3ae6b18f0f7bfa931152e9653912be3525a17933 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 21:12:58 -0500 Subject: [PATCH 31/35] Update src/core/utils/validate-ipni-advertisement.ts Co-authored-by: Steve Loeppky --- src/core/utils/validate-ipni-advertisement.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index d4a7a00e..cc44c660 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -72,9 +72,8 @@ export interface WaitForIpniProviderResultsOptions { /** * 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 succeeds once the IPNI - * response includes any provider entry that advertises at least one address - * for the root CID (no retrieval attempt is made here). + * to succeed. When omitted or empty, the validation when the IPNI + * response is non-empty. * * @default: [] */ From 83177bcab9a11554c8651577e3380314ee48a8c4 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 21:13:29 -0500 Subject: [PATCH 32/35] Update src/core/utils/validate-ipni-advertisement.ts Co-authored-by: Steve Loeppky --- src/core/utils/validate-ipni-advertisement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index cc44c660..08effeae 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -252,7 +252,7 @@ export async function waitForIpniProviderResults( await check() } else { // Max attempts reached - validation failed - const msgBase = `IPFS root CID "${ipfsRootCid.toString()}" not announced to IPNI after ${maxAttempts} attempt${maxAttempts === 1 ? '' : 's'}` + 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}` From edb047aaef2152ce191f9113fbdaabd005e78201 Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 21:13:40 -0500 Subject: [PATCH 33/35] Update src/core/utils/validate-ipni-advertisement.ts Co-authored-by: Steve Loeppky --- src/core/utils/validate-ipni-advertisement.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 08effeae..27a0f199 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -96,10 +96,13 @@ export interface WaitForIpniProviderResultsOptions { /** * Check if the IPNI Indexer has the provided ProviderResults for the provided ipfsRootCid. - * This effectively verifies the entire SP flow that: - * - The advertisement was announce to the IPNI indexer(s) - * - The IPNI indexer(s) pulled the advertisement + * 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. * From bcc6b8ae82c587bdcacdec1f04c445c8888c4b0b Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 21:13:58 -0500 Subject: [PATCH 34/35] chore: lint fix --- src/core/utils/validate-ipni-advertisement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/utils/validate-ipni-advertisement.ts b/src/core/utils/validate-ipni-advertisement.ts index 27a0f199..ae377940 100644 --- a/src/core/utils/validate-ipni-advertisement.ts +++ b/src/core/utils/validate-ipni-advertisement.ts @@ -100,7 +100,7 @@ export interface WaitForIpniProviderResultsOptions { * - 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. + * 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. * From 6f595e1e529a4d3b6b9ed23f41c4ad263341632f Mon Sep 17 00:00:00 2001 From: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Date: Wed, 12 Nov 2025 21:33:35 -0500 Subject: [PATCH 35/35] test: fix tests after error msg change --- src/test/unit/validate-ipni-advertisement.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test/unit/validate-ipni-advertisement.test.ts b/src/test/unit/validate-ipni-advertisement.test.ts index 8ece605c..d4ab1640 100644 --- a/src/test/unit/validate-ipni-advertisement.test.ts +++ b/src/test/unit/validate-ipni-advertisement.test.ts @@ -142,7 +142,7 @@ describe('waitForIpniProviderResults', () => { const promise = waitForIpniProviderResults(testCid, { maxAttempts: 3, onProgress }) // Attach rejection handler immediately const expectPromise = expect(promise).rejects.toThrow( - `IPFS root CID "${testCid.toString()}" not announced to IPNI after 3 attempts` + `IPFS root CID "${testCid.toString()}" does not have expected IPNI ProviderResults after 3 attempts` ) await vi.runAllTimersAsync() @@ -171,7 +171,7 @@ describe('waitForIpniProviderResults', () => { const promise = waitForIpniProviderResults(testCid, { maxAttempts: 1 }) // Attach rejection handler immediately const expectPromise = expect(promise).rejects.toThrow( - `IPFS root CID "${testCid.toString()}" not announced to IPNI after 1 attempt` + `IPFS root CID "${testCid.toString()}" does not have expected IPNI ProviderResults after 1 attempt` ) await vi.runAllTimersAsync() @@ -188,7 +188,7 @@ describe('waitForIpniProviderResults', () => { }) const expectPromise = expect(promise).rejects.toThrow( - `IPFS root CID "${testCid.toString()}" not announced to IPNI after 1 attempt. Last observation: Missing provider records with expected multiaddr(s): /dns/expected.example.com/tcp/443/https` + `IPFS root CID "${testCid.toString()}" does not have expected IPNI ProviderResults after 1 attempt. Last observation: Missing provider records with expected multiaddr(s): /dns/expected.example.com/tcp/443/https` ) await vi.runAllTimersAsync() await expectPromise @@ -205,7 +205,7 @@ describe('waitForIpniProviderResults', () => { }) const expectPromise = expect(promise).rejects.toThrow( - `IPFS root CID "${testCid.toString()}" not announced to IPNI after 1 attempt. Last observation: Missing provider records with expected multiaddr(s): /dns/b.example.com/tcp/443/https` + `IPFS root CID "${testCid.toString()}" does not have expected IPNI ProviderResults after 1 attempt. Last observation: Missing provider records with expected multiaddr(s): /dns/b.example.com/tcp/443/https` ) await vi.runAllTimersAsync() await expectPromise