Skip to content

Commit b020a42

Browse files
SgtPookiCopilotBigLepclaudeZenGround0
authored
fix: check ipni advertisement during upload (#183)
* fix: check ipni advertisement during upload * fix: executeUpload only awaits ipniValidationPromise if it exists * fix: better onProgressTypes * Update src/core/utils/validate-ipni-advertisement.ts Co-authored-by: Copilot <[email protected]> * Update src/test/unit/validate-ipni-advertisement.test.ts Co-authored-by: Copilot <[email protected]> * Update src/test/unit/validate-ipni-advertisement.test.ts Co-authored-by: Copilot <[email protected]> * Update upload-action/src/upload.js Co-authored-by: Copilot <[email protected]> * fix(ci): handle duplicate items in add-to-project workflow (#184) * fix(ci): handle duplicate items in add-to-project workflow Add continue-on-error to prevent workflow failure when an issue/PR already exists in the project board. The actions/add-to-project action does not currently handle this case gracefully. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * fix(ci): only run workflow when team/fs-wg label is added Add conditional to prevent workflow from running on every labeled event. Now it only runs when the specific 'team/fs-wg' label is added, avoiding duplicate runs when multiple labels are added at once. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]> * chore: move to the version of synapse-sdk on `next` branch (#146) * Explicitly declare direct dep rather than rely on synapse * cdn endEpoch removed from ds info * 10 day grace period => 30 day grace period * fix: plumb --warm-storage-address through `payments setup` * fix: check lockup period during allowance check * chore: update to synapse@next tag, fix test failures & update more lockup mismatches * post rebase wip * Update synapse to v0.35.0 * Make things compile with add piece and create all in one * chore: fix onPieceAdded txHash * fix test --------- Co-authored-by: zenground0 <[email protected]> Co-authored-by: Rod Vagg <[email protected]> Co-authored-by: Russell Dempsey <[email protected]> * chore: remove CombineProgressEvents type * chore: fix type errs --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: Steve Loeppky <[email protected]> Co-authored-by: Claude <[email protected]> Co-authored-by: ZenGround0 <[email protected]> Co-authored-by: zenground0 <[email protected]> Co-authored-by: Rod Vagg <[email protected]>
1 parent 528c6fa commit b020a42

File tree

10 files changed

+418
-5
lines changed

10 files changed

+418
-5
lines changed

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: 47 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: (txHash) => {
209244
if (txHash) {
@@ -228,10 +263,22 @@ 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 (ipniValidationPromise != null) {
269+
try {
270+
ipniValidated = await ipniValidationPromise
271+
} catch (error) {
272+
logger.error({ error }, 'Could not validate IPNI advertisement')
273+
ipniValidated = false
274+
}
275+
}
276+
231277
const result: UploadExecutionResult = {
232278
...uploadResult,
233279
network: synapseService.synapse.getNetwork(),
234280
transactionHash,
281+
ipniValidated,
235282
}
236283

237284
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'

src/core/utils/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
type AnyProgressEvent = { type: string; data?: unknown }
2+
3+
export type ProgressEvent<T extends string = string, D = undefined> = D extends undefined
4+
? { type: T }
5+
: { type: T; data: D }
6+
7+
export type ProgressEventHandler<E extends AnyProgressEvent = AnyProgressEvent> = (event: E) => void
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+
import type { ProgressEvent, ProgressEventHandler } from './types.js'
4+
5+
export type ValidateIPNIProgressEvents =
6+
| ProgressEvent<'ipniAdvertisement.retryUpdate', { retryCount: number }>
7+
| ProgressEvent<'ipniAdvertisement.complete', { result: boolean; retryCount: number }>
8+
| ProgressEvent<'ipniAdvertisement.failed', { error: Error }>
9+
10+
export interface ValidateIPNIAdvertisementOptions {
11+
/**
12+
* maximum number of attempts
13+
*
14+
* @default: 10
15+
*/
16+
maxAttempts?: number | undefined
17+
18+
/**
19+
* delay between attempts in milliseconds
20+
*
21+
* @default: 5000
22+
*/
23+
delayMs?: number | undefined
24+
25+
/**
26+
* Abort signal
27+
*
28+
* @default: undefined
29+
*/
30+
signal?: AbortSignal | undefined
31+
32+
/**
33+
* Logger instance
34+
*
35+
* @default: undefined
36+
*/
37+
logger?: Logger | undefined
38+
39+
/**
40+
* Callback for progress updates
41+
*
42+
* @default: undefined
43+
*/
44+
onProgress?: ProgressEventHandler<ValidateIPNIProgressEvents>
45+
}
46+
47+
/**
48+
* Check if the SP has announced the IPFS root CID to IPNI.
49+
*
50+
* 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.
51+
*
52+
* @param ipfsRootCid - The IPFS root CID to check
53+
* @param options - Options for the check
54+
* @returns True if the IPNI announce succeeded, false otherwise
55+
*/
56+
export async function validateIPNIAdvertisement(
57+
ipfsRootCid: CID,
58+
options?: ValidateIPNIAdvertisementOptions
59+
): Promise<boolean> {
60+
const delayMs = options?.delayMs ?? 5000
61+
const maxAttempts = options?.maxAttempts ?? 10
62+
63+
return new Promise<boolean>((resolve, reject) => {
64+
let retryCount = 0
65+
const check = async (): Promise<void> => {
66+
if (options?.signal?.aborted) {
67+
throw new Error('Check IPNI announce aborted', { cause: options?.signal })
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: 'ipniAdvertisement.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: 'ipniAdvertisement.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} attempt${maxAttempts === 1 ? '' : 's'}`
110+
const error = new Error(msg)
111+
options?.logger?.error({ error }, msg)
112+
try {
113+
options?.onProgress?.({ type: 'ipniAdvertisement.complete', data: { result: false, retryCount } })
114+
} catch (error) {
115+
options?.logger?.error({ error }, 'Error in consumer onProgress callback for complete event')
116+
}
117+
throw error
118+
}
119+
}
120+
121+
check().catch((error) => {
122+
options?.onProgress?.({ type: 'ipniAdvertisement.failed', data: { error } })
123+
reject(error)
124+
})
125+
})
126+
}

0 commit comments

Comments
 (0)