Skip to content

Commit 7d4303d

Browse files
committed
fix: check ipni advertisement during upload
1 parent 849b333 commit 7d4303d

9 files changed

Lines changed: 399 additions & 4 deletions

File tree

src/common/upload-flow.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ export async function performUpload(
248248
const uploadResult = await executeUpload(synapseService, carData, rootCid, {
249249
logger,
250250
contextId: `${contextType}-${Date.now()}`,
251+
ipniValidation: { enabled: false },
251252
callbacks: {
252253
onUploadComplete: () => {
253254
spinner?.message('Upload complete, adding to data set...')

src/core/upload/index.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import {
1111
validatePaymentRequirements,
1212
} from '../payments/index.js'
1313
import { isSessionKeyMode, type SynapseService } from '../synapse/index.js'
14+
import {
15+
type ValidateIPNIAdvertisementOptions,
16+
validateIPNIAdvertisement,
17+
} from '../utils/validate-ipni-advertisement.js'
1418
import { type SynapseUploadResult, uploadToSynapse } from './synapse.js'
1519

1620
export type { SynapseUploadOptions, SynapseUploadResult } from './synapse.js'
@@ -179,13 +183,30 @@ export interface UploadExecutionOptions {
179183
callbacks?: UploadCallbacks
180184
/** Optional metadata to associate with the upload. */
181185
metadata?: Record<string, string>
186+
/**
187+
* Optional IPNI validation behaviour. When enabled (default), the upload flow will wait for the IPFS Root CID to be announced to IPNI.
188+
*/
189+
ipniValidation?: {
190+
/**
191+
* Enable the IPNI validation wait.
192+
*
193+
* @default: true
194+
*/
195+
enabled?: boolean
196+
} & ValidateIPNIAdvertisementOptions
182197
}
183198

184199
export interface UploadExecutionResult extends SynapseUploadResult {
185200
/** Active network derived from the Synapse instance. */
186201
network: string
187202
/** Transaction hash from the piece-addition step (if available). */
188203
transactionHash?: string | undefined
204+
/**
205+
* True if the IPFS Root CID was observed on filecoinpin.contact (IPNI).
206+
*
207+
* You should block any displaying, or attempting to access, of IPFS download URLs unless the IPNI validation is successful.
208+
*/
209+
ipniValidated: boolean
189210
}
190211

191212
/**
@@ -200,10 +221,24 @@ export async function executeUpload(
200221
): Promise<UploadExecutionResult> {
201222
const { logger, contextId, callbacks } = options
202223
let transactionHash: string | undefined
224+
let ipniValidationPromise: Promise<boolean> | undefined
203225

204226
const mergedCallbacks: UploadCallbacks = {
205227
onUploadComplete: (pieceCid) => {
206228
callbacks?.onUploadComplete?.(pieceCid)
229+
// Begin IPNI validation as soon as the upload completes
230+
if (options.ipniValidation?.enabled !== false && ipniValidationPromise == null) {
231+
try {
232+
const { enabled: _enabled, ...rest } = options.ipniValidation ?? {}
233+
ipniValidationPromise = validateIPNIAdvertisement(rootCid, {
234+
...rest,
235+
logger,
236+
})
237+
} catch (error) {
238+
logger.error({ error }, 'Could not begin IPNI advertisement validation')
239+
ipniValidationPromise = Promise.resolve(false)
240+
}
241+
}
207242
},
208243
onPieceAdded: (transaction) => {
209244
if (transaction?.hash) {
@@ -228,10 +263,24 @@ export async function executeUpload(
228263

229264
const uploadResult = await uploadToSynapse(synapseService, carData, rootCid, logger, uploadOptions)
230265

266+
// Optionally validate IPNI advertisement of the root CID before returning
267+
let ipniValidated = false
268+
if (options.ipniValidation?.enabled !== false) {
269+
try {
270+
if (ipniValidationPromise) {
271+
ipniValidated = await ipniValidationPromise
272+
}
273+
} catch (error) {
274+
logger.error({ error }, 'Could not validate IPNI advertisement')
275+
ipniValidated = false
276+
}
277+
}
278+
231279
const result: UploadExecutionResult = {
232280
...uploadResult,
233281
network: synapseService.synapse.getNetwork(),
234282
transactionHash,
283+
ipniValidated,
235284
}
236285

237286
return result

src/core/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './format.js'
2+
export * from './validate-ipni-advertisement.js'
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import type { CID } from 'multiformats/cid'
2+
import type { Logger } from 'pino'
3+
4+
export interface ValidateIPNIAdvertisementOptions {
5+
/**
6+
* maximum number of attempts
7+
*
8+
* @default: 10
9+
*/
10+
maxAttempts?: number | undefined
11+
12+
/**
13+
* delay between attempts in milliseconds
14+
*
15+
* @default: 5000
16+
*/
17+
delayMs?: number | undefined
18+
19+
/**
20+
* Abort signal
21+
*
22+
* @default: undefined
23+
*/
24+
signal?: AbortSignal | undefined
25+
26+
/**
27+
* Logger instance
28+
*
29+
* @default: undefined
30+
*/
31+
logger?: Logger | undefined
32+
33+
/**
34+
* Callback for progress updates
35+
*
36+
* @default: undefined
37+
*/
38+
onProgress?: (
39+
event:
40+
| { type: 'retryUpdate'; data: { retryCount: number } }
41+
| { type: 'complete'; data: { result: boolean; retryCount: number } }
42+
) => undefined | undefined
43+
}
44+
45+
/**
46+
* Check if the SP has announced the IPFS root CID to IPNI.
47+
*
48+
* 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.
49+
*
50+
* @param ipfsRootCid - The IPFS root CID to check
51+
* @param options - Options for the check
52+
* @returns True if the IPNI announce succeeded, false otherwise
53+
*/
54+
export async function validateIPNIAdvertisement(
55+
ipfsRootCid: CID,
56+
options?: ValidateIPNIAdvertisementOptions
57+
): Promise<boolean> {
58+
const delayMs = options?.delayMs ?? 5000
59+
const maxAttempts = options?.maxAttempts ?? 10
60+
61+
return new Promise<boolean>((resolve, reject) => {
62+
let retryCount = 0
63+
const check = async (): Promise<void> => {
64+
try {
65+
if (options?.signal?.aborted) {
66+
reject(new Error('Check IPNI announce aborted'))
67+
return
68+
}
69+
options?.logger?.info(
70+
{
71+
event: 'check-ipni-announce',
72+
ipfsRootCid: ipfsRootCid.toString(),
73+
},
74+
'Checking IPNI for announcement of IPFS Root CID "%s"',
75+
ipfsRootCid.toString()
76+
)
77+
const fetchOptions: RequestInit = {}
78+
if (options?.signal) {
79+
fetchOptions.signal = options?.signal
80+
}
81+
try {
82+
options?.onProgress?.({ type: 'retryUpdate', data: { retryCount } })
83+
} catch (error) {
84+
options?.logger?.error({ error }, 'Error in consumer onProgress callback for retryUpdate event')
85+
}
86+
87+
const response = await fetch(`https://filecoinpin.contact/cid/${ipfsRootCid}`, fetchOptions)
88+
if (response.ok) {
89+
try {
90+
options?.onProgress?.({ type: 'complete', data: { result: true, retryCount } })
91+
} catch (error) {
92+
options?.logger?.error({ error }, 'Error in consumer onProgress callback for complete event')
93+
}
94+
resolve(true)
95+
return
96+
}
97+
if (++retryCount < maxAttempts) {
98+
options?.logger?.info(
99+
{ retryCount, maxAttempts },
100+
'IPFS Root CID "%s" not announced to IPNI yet (%d/%d). Retrying in %dms...',
101+
ipfsRootCid.toString(),
102+
retryCount,
103+
maxAttempts,
104+
delayMs
105+
)
106+
await new Promise((resolve) => setTimeout(resolve, delayMs))
107+
await check()
108+
} else {
109+
const msg = `IPFS root CID "${ipfsRootCid.toString()}" not announced to IPNI after ${maxAttempts} attempts`
110+
const error = new Error(msg)
111+
options?.logger?.error({ error }, msg)
112+
try {
113+
options?.onProgress?.({ type: 'complete', data: { result: false, retryCount } })
114+
} catch (error) {
115+
options?.logger?.error({ error }, 'Error in consumer onProgress callback for complete event')
116+
}
117+
reject(error)
118+
}
119+
} catch (error) {
120+
reject(error)
121+
}
122+
}
123+
124+
check().catch(reject)
125+
})
126+
}

0 commit comments

Comments
 (0)