ff1-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1013 @@
1
+ /**
2
+ * NFT Indexer Client
3
+ * This module provides functions to interact with NFT indexing services
4
+ * to retrieve comprehensive token information.
5
+ */
6
+ const GRAPHQL_ENDPOINT = 'https://indexer-v2.feralfile.com/graphql';
7
+ const logger = require('../logger');
8
+ // Polling configuration (in milliseconds)
9
+ const POLLING_INTERVAL_MS = 2000; // Poll every 2 seconds
10
+ const POLLING_TIMEOUT_MS = 60000; // Max poll for 1 minute
11
+ /**
12
+ * Initialize indexer (no-op for compatibility)
13
+ *
14
+ * The indexer endpoint is now hardcoded to the Feral File production endpoint.
15
+ * This function is kept for backwards compatibility but does nothing.
16
+ *
17
+ * @deprecated This function is no longer needed as the endpoint is hardcoded
18
+ * @param {Object} _config - Unused config parameter
19
+ */
20
+ function initializeIndexer(_config) {
21
+ logger.debug('[NFT Indexer] Using endpoint:', GRAPHQL_ENDPOINT);
22
+ }
23
+ /**
24
+ * Detect token standard based on chain and contract address
25
+ *
26
+ * Determines the appropriate ERC/token standard for the given blockchain
27
+ * and contract format.
28
+ *
29
+ * @param {string} chain - Blockchain network
30
+ * @param {string} contractAddress - Contract address
31
+ * @returns {string} Token standard (erc721, erc1155, fa2, or other)
32
+ */
33
+ function detectTokenStandard(chain, contractAddress) {
34
+ const lowerChain = chain.toLowerCase();
35
+ // Tezos contracts use FA2 standard
36
+ if (lowerChain === 'tezos' || contractAddress.startsWith('KT')) {
37
+ return 'fa2';
38
+ }
39
+ // Ethereum uses ERC721
40
+ // TODO: Enhance with on-chain detection for ERC1155 support
41
+ if (lowerChain === 'ethereum') {
42
+ return 'erc721';
43
+ }
44
+ return 'other';
45
+ }
46
+ /**
47
+ * Build CAIP-2 token CID for indexer v2
48
+ *
49
+ * Constructs a token identifier in CAIP-2 format compatible with ff-indexer-v2.
50
+ * Format: `{caip2Chain}:{standard}:{contractAddress}:{tokenNumber}`
51
+ *
52
+ * @param {string} chain - Blockchain network (ethereum, polygon, tezos, etc)
53
+ * @param {string} contractAddress - Contract address
54
+ * @param {string} tokenId - Token ID
55
+ * @returns {string} Token CID in CAIP-2 format
56
+ * @example
57
+ * // Returns: eip155:1:erc721:0xabc123:456
58
+ * const cid = buildTokenCID('ethereum', '0xabc123', '456');
59
+ * @example
60
+ * // Returns: tezos:mainnet:fa2:KT1abc:789
61
+ * const cid = buildTokenCID('tezos', 'KT1abc', '789');
62
+ */
63
+ function buildTokenCID(chain, contractAddress, tokenId) {
64
+ // Map chain names to CAIP-2 format (supports only Ethereum and Tezos)
65
+ const caip2Map = {
66
+ ethereum: 'eip155:1',
67
+ tezos: 'tezos:mainnet',
68
+ fa2: 'tezos:mainnet', // FA2 is Tezos
69
+ };
70
+ const lowerChain = chain.toLowerCase();
71
+ const caip2Chain = caip2Map[lowerChain];
72
+ if (!caip2Chain) {
73
+ throw new Error(`Unsupported chain: ${chain}. Only ethereum and tezos are supported.`);
74
+ }
75
+ const standard = detectTokenStandard(chain, contractAddress);
76
+ return `${caip2Chain}:${standard}:${contractAddress}:${tokenId}`;
77
+ }
78
+ /**
79
+ * Unified GraphQL query for tokens from indexer v2
80
+ *
81
+ * Supports querying by token CIDs and/or owners. Returns tokens with full metadata.
82
+ *
83
+ * @param {Object} params - Query parameters
84
+ * @param {Array<string>} [params.token_cids] - Array of token CIDs to query
85
+ * @param {Array<string>} [params.owners] - Array of owner addresses to query
86
+ * @param {number} [params.limit] - Maximum number of tokens to return (default: 50)
87
+ * @param {number} [params.offset] - Offset for pagination (default: 0)
88
+ * @returns {Promise<Array<Object>>} Array of token data
89
+ * @throws {Error} When query fails
90
+ * @example
91
+ * // Query by token CID
92
+ * const tokens = await queryTokens({ token_cids: ['eip155:1:erc721:0xabc:123'] });
93
+ *
94
+ * // Query by owner address
95
+ * const tokens = await queryTokens({ owners: ['0x1234...'], limit: 100 });
96
+ *
97
+ * // Query specific tokens for a specific owner
98
+ * const tokens = await queryTokens({ token_cids: ['eip155:1:erc721:0xabc:123'], owners: ['0x1234...'] });
99
+ */
100
+ async function queryTokens(params = {}) {
101
+ const { token_cids = [], owners = [], limit = 50, offset = 0 } = params;
102
+ // Build GraphQL query without variables - inline parameters
103
+ // (API expects inline parameters, not variables)
104
+ const ownerFilter = owners.length > 0 ? `owners: ${JSON.stringify(owners)},` : '';
105
+ const tokenCidsFilter = token_cids.length > 0 ? `token_cids: ${JSON.stringify(token_cids)},` : '';
106
+ const query = `
107
+ query {
108
+ tokens(${ownerFilter} ${tokenCidsFilter} expands: ["enrichment_source", "metadata_media_asset", "enrichment_source_media_asset"], limit: ${limit}, offset: ${offset}) {
109
+ items {
110
+ token_cid
111
+ chain
112
+ standard
113
+ contract_address
114
+ token_number
115
+ current_owner
116
+ burned
117
+ metadata {
118
+ name
119
+ description
120
+ mime_type
121
+ image_url
122
+ animation_url
123
+ artists {
124
+ did
125
+ name
126
+ }
127
+ }
128
+ enrichment_source {
129
+ name
130
+ description
131
+ image_url
132
+ animation_url
133
+ artists {
134
+ did
135
+ name
136
+ }
137
+ }
138
+ metadata_media_assets {
139
+ source_url
140
+ mime_type
141
+ variant_urls
142
+ }
143
+ enrichment_source_media_assets {
144
+ source_url
145
+ mime_type
146
+ variant_urls
147
+ }
148
+ }
149
+ total
150
+ }
151
+ }
152
+ `;
153
+ try {
154
+ const headers = { 'Content-Type': 'application/json' };
155
+ logger.debug('[NFT Indexer] Querying tokens:', { token_cids, owners, limit, offset });
156
+ logger.debug('[NFT Indexer] GraphQL query:', query);
157
+ const response = await fetch(GRAPHQL_ENDPOINT, {
158
+ method: 'POST',
159
+ headers,
160
+ body: JSON.stringify({ query }),
161
+ });
162
+ if (!response.ok) {
163
+ const errorBody = await response.text();
164
+ logger.error('[NFT Indexer] HTTP error response:', {
165
+ status: response.status,
166
+ body: errorBody.substring(0, 500),
167
+ });
168
+ throw new Error(`HTTP error! status: ${response.status}`);
169
+ }
170
+ const result = await response.json();
171
+ if (result.errors) {
172
+ logger.error('[NFT Indexer] GraphQL errors:', result.errors);
173
+ throw new Error(`GraphQL errors: ${result.errors.map((e) => e.message).join(', ')}`);
174
+ }
175
+ // v2 API wraps tokens in { items: [...], total: N }
176
+ const tokenList = result.data?.tokens;
177
+ const tokens = tokenList?.items || [];
178
+ return tokens;
179
+ }
180
+ catch (error) {
181
+ logger.error('[NFT Indexer] Failed to query tokens:', error.message);
182
+ throw error;
183
+ }
184
+ }
185
+ /**
186
+ * Query single token data from indexer by token CID
187
+ *
188
+ * Convenience wrapper around queryTokens for single token queries.
189
+ *
190
+ * @param {string} tokenCID - Token CID in CAIP-2 format
191
+ * @returns {Promise<Object|null>} Token data or null if not found
192
+ */
193
+ async function queryTokenDataFromIndexer(tokenCID) {
194
+ try {
195
+ const tokens = await queryTokens({ token_cids: [tokenCID] });
196
+ return tokens[0] || null;
197
+ }
198
+ catch (error) {
199
+ logger.error('[NFT Indexer] Failed to query token data:', error.message);
200
+ return null;
201
+ }
202
+ }
203
+ /**
204
+ * Extract artist name from artists array
205
+ *
206
+ * Converts the new artists array format to a single artist name string.
207
+ *
208
+ * @param {Array} artists - Array of artist objects with did and name
209
+ * @returns {string} Artist name or empty string
210
+ */
211
+ function extractArtistName(artists) {
212
+ if (!Array.isArray(artists) || artists.length === 0) {
213
+ return '';
214
+ }
215
+ // Use first artist's name, or join multiple if needed
216
+ return artists[0]?.name || '';
217
+ }
218
+ /**
219
+ * Get best media URL from metadata and enrichment source
220
+ *
221
+ * Prioritizes: enrichment_source.animation_url > metadata.animation_url > media_assets.source_url > image_url
222
+ *
223
+ * @param {Object} metadata - Token metadata object
224
+ * @param {Object} enrichmentSource - Token enrichment_source object
225
+ * @param {Array} metadataMediaAssets - Token metadata media assets array
226
+ * @param {Array} enrichmentMediaAssets - Token enrichment source media assets array
227
+ * @returns {Object} Object with url and thumbnail properties
228
+ */
229
+ function getBestMediaUrl(metadata = {}, enrichmentSource = {}, metadataMediaAssets = [], enrichmentMediaAssets = []) {
230
+ // Priority: enrichment_source.animation_url > metadata.animation_url > media_assets > image_url
231
+ // Prefer enrichment_source animation URL first (if enrichment_source is available)
232
+ if (enrichmentSource && enrichmentSource.animation_url) {
233
+ return {
234
+ url: enrichmentSource.animation_url,
235
+ thumbnail: enrichmentSource.image_url || metadata.image_url || '',
236
+ };
237
+ }
238
+ // Fallback to metadata animation URL
239
+ if (metadata && metadata.animation_url) {
240
+ return {
241
+ url: metadata.animation_url,
242
+ thumbnail: metadata.image_url || (enrichmentSource && enrichmentSource.image_url) || '',
243
+ };
244
+ }
245
+ // Check enrichment source media assets (if enrichment_source is available)
246
+ if (enrichmentSource &&
247
+ Array.isArray(enrichmentMediaAssets) &&
248
+ enrichmentMediaAssets.length > 0) {
249
+ const asset = enrichmentMediaAssets[0];
250
+ if (asset && asset.source_url) {
251
+ return {
252
+ url: asset.source_url,
253
+ thumbnail: enrichmentSource.image_url || metadata.image_url || '',
254
+ };
255
+ }
256
+ }
257
+ // Check metadata media assets (fallback if enrichment_source not available)
258
+ if (Array.isArray(metadataMediaAssets) && metadataMediaAssets.length > 0) {
259
+ const asset = metadataMediaAssets[0];
260
+ if (asset && asset.source_url) {
261
+ return {
262
+ url: asset.source_url,
263
+ thumbnail: metadata.image_url || (enrichmentSource && enrichmentSource.image_url) || '',
264
+ };
265
+ }
266
+ }
267
+ // Fallback to static image URLs (prefer enrichment_source if available)
268
+ const imageUrl = (enrichmentSource && enrichmentSource.image_url) || metadata.image_url || '';
269
+ return {
270
+ url: imageUrl,
271
+ thumbnail: imageUrl,
272
+ };
273
+ }
274
+ /**
275
+ * Map indexer v2 token data to standard format
276
+ *
277
+ * Converts the new GraphQL v2 schema format to internal standard format
278
+ * for compatibility with existing code.
279
+ *
280
+ * @param {Object} indexerData - Data from indexer GraphQL v2 query
281
+ * @param {string} chain - Blockchain network
282
+ * @returns {Object} Standardized token data
283
+ */
284
+ function mapIndexerDataToStandardFormat(indexerData, chain) {
285
+ if (!indexerData) {
286
+ return {
287
+ success: false,
288
+ error: 'Token not found in indexer',
289
+ };
290
+ }
291
+ // Use metadata first, fallback to enrichment_source for missing fields
292
+ const metadata = indexerData.metadata || {};
293
+ const enrichmentSource = indexerData.enrichment_source || {};
294
+ const metadataMediaAssets = indexerData.metadata_media_assets || [];
295
+ const enrichmentMediaAssets = indexerData.enrichment_source_media_assets || [];
296
+ // Get best media URLs (prioritizes enrichment_source.animation_url first)
297
+ const media = getBestMediaUrl(metadata, enrichmentSource, metadataMediaAssets, enrichmentMediaAssets);
298
+ // Extract artist name from array format
299
+ const artistName = extractArtistName(metadata.artists) || extractArtistName(enrichmentSource.artists) || '';
300
+ // Determine best name and description
301
+ const name = metadata.name || enrichmentSource.name || `Token #${indexerData.token_number}`;
302
+ const description = metadata.description || enrichmentSource.description || '';
303
+ return {
304
+ success: true,
305
+ token: {
306
+ chain,
307
+ contractAddress: indexerData.contract_address,
308
+ tokenId: indexerData.token_number,
309
+ name,
310
+ description,
311
+ image: {
312
+ url: media.url,
313
+ mimeType: metadata.mime_type || 'image/png',
314
+ thumbnail: media.thumbnail,
315
+ },
316
+ animation_url: metadata.animation_url || enrichmentSource.animation_url,
317
+ metadata: {
318
+ attributes: [],
319
+ artistName,
320
+ },
321
+ owner: indexerData.current_owner,
322
+ collection: {
323
+ name: name.split('#')[0].trim(),
324
+ description,
325
+ },
326
+ burned: indexerData.burned || false,
327
+ },
328
+ };
329
+ }
330
+ /**
331
+ * Convert token data to DP1 item format
332
+ *
333
+ * Extracts source URL from indexer data with proper priority:
334
+ * animation_url > image.url, ensuring the best quality media is used.
335
+ *
336
+ * @param {Object} tokenData - Token data in standard format
337
+ * @param {number} duration - Display duration in seconds
338
+ * @returns {Object} DP1 item with source URL from indexer
339
+ */
340
+ function convertToDP1Item(tokenData, duration = 10) {
341
+ const { token } = tokenData;
342
+ if (!token) {
343
+ return {
344
+ success: false,
345
+ error: tokenData.error || 'Invalid token data',
346
+ };
347
+ }
348
+ // Generate deterministic ID for this item based on contract + tokenId
349
+ // Use a simple hash to create a consistent ID
350
+ const crypto = require('crypto');
351
+ const idSource = `${token.contractAddress}-${token.tokenId}`;
352
+ const hash = crypto.createHash('sha256').update(idSource).digest('hex');
353
+ // Format as UUID-like string for consistency
354
+ const itemId = `${hash.substr(0, 8)}-${hash.substr(8, 4)}-${hash.substr(12, 4)}-${hash.substr(16, 4)}-${hash.substr(20, 12)}`;
355
+ // Get source URL from indexer data
356
+ // Priority: animation_url > image.url (from getBestMediaUrl)
357
+ let sourceUrl = token.animation_url || token.animationUrl;
358
+ if (!sourceUrl && token.image && typeof token.image === 'object') {
359
+ sourceUrl = token.image.url;
360
+ }
361
+ sourceUrl = String(sourceUrl || '');
362
+ // Validate source URL
363
+ if (!sourceUrl) {
364
+ logger.warn('[NFT Indexer] No source URL found for token:', {
365
+ contractAddress: token.contractAddress,
366
+ tokenId: token.tokenId,
367
+ });
368
+ return {
369
+ success: false,
370
+ error: 'No source URL available',
371
+ };
372
+ }
373
+ // Skip data URIs (base64-encoded content)
374
+ if (sourceUrl.startsWith('data:')) {
375
+ logger.debug('[NFT Indexer] Skipping token with data URI:', {
376
+ contractAddress: token.contractAddress,
377
+ tokenId: token.tokenId,
378
+ });
379
+ return {
380
+ success: false,
381
+ error: 'Source is a data URI (not supported)',
382
+ };
383
+ }
384
+ // Skip URLs that exceed DP1 spec limit (1024 characters)
385
+ if (sourceUrl.length > 1024) {
386
+ logger.debug('[NFT Indexer] Skipping token with source URL too long:', {
387
+ contractAddress: token.contractAddress,
388
+ tokenId: token.tokenId,
389
+ urlLength: sourceUrl.length,
390
+ });
391
+ return {
392
+ success: false,
393
+ error: `Source URL too long (${sourceUrl.length} chars, max 1024)`,
394
+ };
395
+ }
396
+ // Map chain name to DP1 format (according to DP1 spec)
397
+ // NOTE: This is for DP1 provenance output, NOT for indexer queries
398
+ // The indexer uses 'eth'/'tez'/'bmk', but DP1 spec uses 'evm'/'tezos'/'bitmark'
399
+ const chainMap = {
400
+ ethereum: 'evm',
401
+ polygon: 'evm',
402
+ arbitrum: 'evm',
403
+ optimism: 'evm',
404
+ base: 'evm',
405
+ zora: 'evm',
406
+ tezos: 'tezos', // DP1 spec uses 'tezos', not 'tez'
407
+ bitmark: 'bitmark', // DP1 spec uses 'bitmark', not 'bmk'
408
+ };
409
+ // Build DP1 item structure (strict DP1 v1.0.0 compliance)
410
+ const dp1Item = {
411
+ id: itemId,
412
+ source: sourceUrl,
413
+ duration: duration,
414
+ license: 'open',
415
+ created: new Date().toISOString(),
416
+ provenance: {
417
+ type: 'onChain',
418
+ contract: {
419
+ chain: chainMap[token.chain.toLowerCase()] || 'other',
420
+ standard: 'other',
421
+ address: token.contractAddress,
422
+ tokenId: String(token.tokenId),
423
+ },
424
+ },
425
+ };
426
+ // Add title if available (valid DP1 field)
427
+ if (token.name) {
428
+ dp1Item.title = token.name;
429
+ }
430
+ logger.debug('[NFT Indexer] ✓ Converted to DP1:', {
431
+ title: token.name,
432
+ source: sourceUrl ? sourceUrl.substring(0, 60) + '...' : '(no source URL)',
433
+ });
434
+ return {
435
+ success: true,
436
+ item: dp1Item,
437
+ };
438
+ }
439
+ /**
440
+ * Get NFT token information from indexer and return as DP1 item (supports single or batch)
441
+ * @param {Object|Array} params - Token parameters (single object or array)
442
+ * @param {number} duration - Display duration in seconds (default: 10)
443
+ * @returns {Promise<Object>} DP1 item(s)
444
+ */
445
+ async function getNFTTokenInfo(params) {
446
+ const duration = params.duration || 10;
447
+ // Handle array input for batch processing
448
+ if (Array.isArray(params.tokens)) {
449
+ return await getNFTTokenInfoBatch(params.tokens, duration);
450
+ }
451
+ // Handle single token
452
+ const { chain, contractAddress, tokenId } = params;
453
+ const result = await getNFTTokenInfoSingle({ chain, contractAddress, tokenId }, duration);
454
+ return result;
455
+ }
456
+ /**
457
+ * Get single NFT token information from indexer and return as DP1 item
458
+ *
459
+ * Queries the indexer for token data. If not found, triggers async indexing workflow,
460
+ * polls for completion, and retries token query. Also polls for metadata_media_assets
461
+ * to ensure indexed media is available.
462
+ *
463
+ * @param {Object} params - Token parameters
464
+ * @param {string} params.chain - Blockchain network
465
+ * @param {string} params.contractAddress - Contract address
466
+ * @param {string} params.tokenId - Token ID
467
+ * @param {number} duration - Display duration in seconds
468
+ * @returns {Promise<Object>} DP1 item with success/error status
469
+ */
470
+ async function getNFTTokenInfoSingle(params, duration = 10) {
471
+ let chain = params.chain;
472
+ const { contractAddress, tokenId } = params;
473
+ // DEFENSIVE: Auto-detect and correct chain based on contract address format
474
+ if (contractAddress.startsWith('KT') && chain !== 'tezos') {
475
+ logger.warn(`[NFT Indexer] Chain mismatch detected! Contract ${contractAddress} starts with KT but chain="${chain}". Auto-correcting to "tezos".`);
476
+ chain = 'tezos';
477
+ }
478
+ else if (contractAddress.startsWith('0x') && chain === 'tezos') {
479
+ logger.warn(`[NFT Indexer] Chain mismatch detected! Contract ${contractAddress} starts with 0x but chain="tezos". Auto-correcting to "ethereum".`);
480
+ chain = 'ethereum';
481
+ }
482
+ logger.info(`[NFT Indexer] Fetching token info for:`, {
483
+ chain,
484
+ contractAddress,
485
+ tokenId,
486
+ });
487
+ try {
488
+ // Build token CID in CAIP-2 format
489
+ const tokenCID = buildTokenCID(chain, contractAddress, tokenId);
490
+ logger.info(`[NFT Indexer] Built token CID: ${tokenCID}`);
491
+ // Query the indexer
492
+ logger.info(`[NFT Indexer] Querying indexer GraphQL for token...`);
493
+ let indexerData = await queryTokenDataFromIndexer(tokenCID);
494
+ // If token not found, trigger async indexing and poll
495
+ if (!indexerData) {
496
+ logger.info(`[NFT Indexer] Token not in database, triggering async indexing...`);
497
+ // Trigger background indexing workflow
498
+ const indexResult = await triggerIndexingAsync(chain, contractAddress, tokenId);
499
+ if (!indexResult.success) {
500
+ logger.error(`[NFT Indexer] Failed to trigger indexing:`, indexResult.error);
501
+ return {
502
+ success: false,
503
+ error: `Token not found and indexing failed: ${indexResult.error}`,
504
+ };
505
+ }
506
+ logger.info('[NFT Indexer] Indexing workflow triggered', {
507
+ workflow_id: indexResult.workflow_id,
508
+ run_id: indexResult.run_id,
509
+ });
510
+ // Poll for workflow completion
511
+ const pollResult = await pollForWorkflowCompletion(indexResult.workflow_id, indexResult.run_id);
512
+ if (!pollResult.success) {
513
+ logger.error('[NFT Indexer] Workflow polling failed:', pollResult.error);
514
+ return {
515
+ success: false,
516
+ error: `Indexing workflow failed: ${pollResult.error}`,
517
+ };
518
+ }
519
+ if (pollResult.timedOut) {
520
+ logger.warn('[NFT Indexer] Workflow polling timed out before completion');
521
+ return {
522
+ success: false,
523
+ error: `Token indexing timed out. Please try again in a moment.`,
524
+ };
525
+ }
526
+ // Workflow completed, query token again
527
+ logger.info(`[NFT Indexer] Workflow completed, querying token again...`);
528
+ indexerData = await queryTokenDataFromIndexer(tokenCID);
529
+ // If still not found after indexing, consider it invalid
530
+ if (!indexerData) {
531
+ logger.warn(`[NFT Indexer] Token still not found after indexing. Contract or token ID may be invalid.`);
532
+ return {
533
+ success: false,
534
+ error: `Token not found. Invalid contract address or token ID.`,
535
+ };
536
+ }
537
+ }
538
+ logger.info(`[NFT Indexer] ✓ Token found in database`);
539
+ // If token found but no metadata_media_assets, poll for them
540
+ if (!Array.isArray(indexerData.metadata_media_assets) ||
541
+ indexerData.metadata_media_assets.length === 0) {
542
+ logger.info('[NFT Indexer] Metadata assets not available, polling...');
543
+ indexerData = await pollForMetadataAssets(tokenCID);
544
+ if (!indexerData) {
545
+ logger.warn('[NFT Indexer] Failed to retrieve token data during metadata polling');
546
+ return {
547
+ success: false,
548
+ error: `Failed to retrieve complete token data`,
549
+ };
550
+ }
551
+ }
552
+ // Map to standard format and convert to DP1
553
+ const tokenData = mapIndexerDataToStandardFormat(indexerData, chain);
554
+ return convertToDP1Item(tokenData, duration);
555
+ }
556
+ catch (error) {
557
+ logger.error(`[NFT Indexer] Error fetching token:`, error.message);
558
+ return {
559
+ success: false,
560
+ error: error.message,
561
+ };
562
+ }
563
+ }
564
+ /**
565
+ * Get NFT token information in batch and return as DP1 items (parallel processing)
566
+ *
567
+ * For tokens not in database:
568
+ * 1. Triggers indexing for all missing tokens in parallel
569
+ * 2. Collects workflow IDs
570
+ * 3. Polls each workflow individually with its own ID
571
+ * 4. Continues with remaining tokens even if some fail
572
+ *
573
+ * @param {Array} tokens - Array of token parameters
574
+ * @param {number} duration - Display duration in seconds
575
+ * @returns {Promise<Array>} Array of DP1 items
576
+ */
577
+ async function getNFTTokenInfoBatch(tokens, duration = 10) {
578
+ logger.info(`[NFT Indexer] 📦 Starting batch processing for ${tokens.length} token(s)...`);
579
+ logger.debug('[NFT Indexer] Batch tokens:', tokens);
580
+ const results = [];
581
+ // Process in parallel with concurrency limit
582
+ const concurrency = 10;
583
+ for (let i = 0; i < tokens.length; i += concurrency) {
584
+ const batch = tokens.slice(i, i + concurrency);
585
+ logger.info(`[NFT Indexer] Processing batch ${Math.floor(i / concurrency) + 1}/${Math.ceil(tokens.length / concurrency)} (${batch.length} tokens)`);
586
+ const batchResults = await Promise.all(batch.map((token, idx) => getNFTTokenInfoSingle(token, duration).catch((error) => {
587
+ logger.error(`[NFT Indexer] Token ${idx + 1} in batch failed:`, error.message);
588
+ return {
589
+ success: false,
590
+ error: error.message,
591
+ token,
592
+ };
593
+ })));
594
+ // Log results
595
+ const successful = batchResults.filter((r) => r.success).length;
596
+ const failed = batchResults.filter((r) => !r.success).length;
597
+ logger.info(`[NFT Indexer] Batch complete: ${successful} success, ${failed} failed`);
598
+ results.push(...batchResults);
599
+ }
600
+ logger.info(`[NFT Indexer] ✓ Batch processing complete: ${results.length} total results`);
601
+ const successCount = results.filter((r) => r.success && r.item).length;
602
+ const failedCount = results.length - successCount;
603
+ logger.info(`[NFT Indexer] Final: ${successCount} items with data, ${failedCount} without`);
604
+ // Return only items (not error objects)
605
+ const items = results.filter((r) => r.success && r.item).map((r) => r.item);
606
+ logger.info(`[NFT Indexer] Returning ${items.length} items`);
607
+ return items;
608
+ }
609
+ /**
610
+ * Get collection information
611
+ * @param {Object} params - Collection parameters
612
+ * @param {string} params.chain - Blockchain network
613
+ * @param {string} params.contractAddress - Collection contract address
614
+ * @returns {Promise<Object>} Collection information
615
+ */
616
+ async function getCollectionInfo(params) {
617
+ const { chain, contractAddress } = params;
618
+ // TODO: Implement collection info fetching
619
+ logger.debug(`[NFT Indexer] Fetching collection info for:`, {
620
+ chain,
621
+ contractAddress,
622
+ });
623
+ return {
624
+ success: true,
625
+ collection: {
626
+ chain,
627
+ contractAddress,
628
+ name: 'Collection Name',
629
+ description: 'Collection Description',
630
+ image: 'https://example.com/collection.png',
631
+ totalSupply: 10000,
632
+ floorPrice: {
633
+ value: '0.1',
634
+ currency: 'ETH',
635
+ },
636
+ metadata: {},
637
+ },
638
+ };
639
+ }
640
+ /**
641
+ * Trigger async indexing workflow for a token (fire-and-forget)
642
+ *
643
+ * Starts an asynchronous background workflow to index and persist the token via GraphQL mutation.
644
+ * Does not wait for completion - returns immediately.
645
+ *
646
+ * @param {string} chain - Blockchain network
647
+ * @param {string} contractAddress - Contract address (required)
648
+ * @param {string} tokenId - Token ID (required)
649
+ * @returns {Promise<Object>} Result with workflow info
650
+ * @returns {boolean} returns.success - Whether workflow was triggered
651
+ * @returns {string} [returns.workflow_id] - Workflow ID if triggered
652
+ * @returns {string} [returns.run_id] - Run ID if triggered
653
+ * @returns {string} [returns.error] - Error message if failed
654
+ */
655
+ async function triggerIndexingAsync(chain, contractAddress, tokenId) {
656
+ try {
657
+ // Build token CID
658
+ const tokenCID = buildTokenCID(chain, contractAddress, tokenId);
659
+ logger.debug('[NFT Indexer] Triggering async indexing workflow via GraphQL mutation:', {
660
+ tokenCID,
661
+ });
662
+ const mutation = `
663
+ mutation TriggerTokenIndexing($token_cids: [String!]!) {
664
+ triggerTokenIndexing(token_cids: $token_cids) {
665
+ workflow_id
666
+ run_id
667
+ }
668
+ }
669
+ `;
670
+ const variables = {
671
+ token_cids: [tokenCID],
672
+ };
673
+ const headers = { 'Content-Type': 'application/json' };
674
+ const response = await fetch(GRAPHQL_ENDPOINT, {
675
+ method: 'POST',
676
+ headers,
677
+ body: JSON.stringify({ query: mutation, variables }),
678
+ });
679
+ if (!response.ok) {
680
+ throw new Error(`HTTP error! status: ${response.status}`);
681
+ }
682
+ const result = await response.json();
683
+ if (result.errors) {
684
+ throw new Error(`GraphQL errors: ${result.errors.map((e) => e.message).join(', ')}`);
685
+ }
686
+ const triggerResult = result.data?.triggerTokenIndexing;
687
+ if (triggerResult?.workflow_id) {
688
+ logger.debug('[NFT Indexer] ✓ Async indexing workflow started:', triggerResult);
689
+ return {
690
+ success: true,
691
+ workflow_id: triggerResult.workflow_id,
692
+ run_id: triggerResult.run_id,
693
+ };
694
+ }
695
+ else {
696
+ logger.warn('[NFT Indexer] Unexpected mutation response:', result);
697
+ return {
698
+ success: false,
699
+ error: 'No workflow ID returned',
700
+ };
701
+ }
702
+ }
703
+ catch (error) {
704
+ logger.error('[NFT Indexer] Async indexing error:', error.message);
705
+ return {
706
+ success: false,
707
+ error: error.message,
708
+ };
709
+ }
710
+ }
711
+ /**
712
+ * Query workflow status from indexer
713
+ *
714
+ * Checks the status of an async indexing workflow to determine if it has completed.
715
+ *
716
+ * @param {string} workflowId - Workflow ID returned from triggerIndexing
717
+ * @param {string} runId - Run ID returned from triggerIndexing
718
+ * @returns {Promise<Object>} Status result
719
+ * @returns {boolean} returns.success - Whether query succeeded
720
+ * @returns {string} [returns.status] - Workflow status (running, completed, failed)
721
+ * @returns {Object} [returns.workflowData] - Full workflow data
722
+ * @returns {string} [returns.error] - Error message if failed
723
+ */
724
+ async function queryWorkflowStatus(workflowId, runId) {
725
+ try {
726
+ const query = `
727
+ query WorkflowStatus($workflow_id: String!, $run_id: String!) {
728
+ workflowStatus(workflow_id: $workflow_id, run_id: $run_id) {
729
+ workflow_id
730
+ run_id
731
+ status
732
+ start_time
733
+ close_time
734
+ execution_time_ms
735
+ }
736
+ }
737
+ `;
738
+ const variables = {
739
+ workflow_id: workflowId,
740
+ run_id: runId,
741
+ };
742
+ const headers = { 'Content-Type': 'application/json' };
743
+ const response = await fetch(GRAPHQL_ENDPOINT, {
744
+ method: 'POST',
745
+ headers,
746
+ body: JSON.stringify({ query, variables }),
747
+ });
748
+ if (!response.ok) {
749
+ throw new Error(`HTTP error! status: ${response.status}`);
750
+ }
751
+ const result = await response.json();
752
+ if (result.errors) {
753
+ throw new Error(`GraphQL errors: ${result.errors.map((e) => e.message).join(', ')}`);
754
+ }
755
+ const workflowData = result.data?.workflowStatus;
756
+ if (workflowData) {
757
+ return {
758
+ success: true,
759
+ status: workflowData.status,
760
+ workflowData,
761
+ };
762
+ }
763
+ else {
764
+ return {
765
+ success: false,
766
+ error: 'No workflow data returned',
767
+ };
768
+ }
769
+ }
770
+ catch (error) {
771
+ logger.error('[NFT Indexer] Failed to query workflow status:', error.message);
772
+ return {
773
+ success: false,
774
+ error: error.message,
775
+ };
776
+ }
777
+ }
778
+ /**
779
+ * Poll for workflow completion with configurable interval and timeout
780
+ *
781
+ * Continuously checks workflow status until completion or timeout.
782
+ *
783
+ * @param {string} workflowId - Workflow ID
784
+ * @param {string} runId - Run ID
785
+ * @returns {Promise<Object>} Polling result
786
+ * @returns {boolean} returns.success - Whether polling succeeded
787
+ * @returns {boolean} returns.completed - Whether workflow completed
788
+ * @returns {boolean} returns.timedOut - Whether polling timed out
789
+ * @returns {string} [returns.status] - Final workflow status
790
+ * @returns {string} [returns.error] - Error message if failed
791
+ */
792
+ async function pollForWorkflowCompletion(workflowId, runId) {
793
+ const startTime = Date.now();
794
+ let pollCount = 0;
795
+ logger.debug('[NFT Indexer] Starting workflow polling...', {
796
+ workflowId,
797
+ runId,
798
+ timeoutMs: POLLING_TIMEOUT_MS,
799
+ intervalMs: POLLING_INTERVAL_MS,
800
+ });
801
+ try {
802
+ while (true) {
803
+ const statusResult = await queryWorkflowStatus(workflowId, runId);
804
+ if (!statusResult.success) {
805
+ return {
806
+ success: false,
807
+ error: statusResult.error,
808
+ };
809
+ }
810
+ const status = statusResult.status;
811
+ pollCount += 1;
812
+ logger.debug(`[NFT Indexer] Poll #${pollCount}: status = ${status}`);
813
+ // Check if workflow has completed (case-insensitive)
814
+ if (status.toLowerCase() === 'completed') {
815
+ const elapsedMs = Date.now() - startTime;
816
+ logger.info(`[NFT Indexer] ✓ Workflow completed after ${pollCount} polls (${elapsedMs}ms)`);
817
+ return {
818
+ success: true,
819
+ completed: true,
820
+ timedOut: false,
821
+ status,
822
+ };
823
+ }
824
+ // Check if workflow failed (case-insensitive)
825
+ if (status.toLowerCase() === 'failed') {
826
+ logger.warn('[NFT Indexer] Workflow failed');
827
+ return {
828
+ success: false,
829
+ completed: false,
830
+ timedOut: false,
831
+ status,
832
+ error: 'Workflow failed',
833
+ };
834
+ }
835
+ // Check timeout
836
+ const elapsedMs = Date.now() - startTime;
837
+ if (elapsedMs >= POLLING_TIMEOUT_MS) {
838
+ logger.warn(`[NFT Indexer] Polling timed out after ${pollCount} polls (${elapsedMs}ms)`);
839
+ return {
840
+ success: true,
841
+ completed: false,
842
+ timedOut: true,
843
+ status,
844
+ };
845
+ }
846
+ // Wait before next poll
847
+ await new Promise((resolve) => setTimeout(resolve, POLLING_INTERVAL_MS));
848
+ }
849
+ }
850
+ catch (error) {
851
+ logger.error('[NFT Indexer] Error during workflow polling:', error.message);
852
+ return {
853
+ success: false,
854
+ error: error.message,
855
+ };
856
+ }
857
+ }
858
+ /**
859
+ * Poll for metadata assets to appear on a token
860
+ *
861
+ * Continuously queries token until metadata_media_assets appears or timeout.
862
+ *
863
+ * @param {string} tokenCID - Token CID in CAIP-2 format
864
+ * @returns {Promise<Object|null>} Token data when assets appear, null if timeout
865
+ */
866
+ async function pollForMetadataAssets(tokenCID) {
867
+ const startTime = Date.now();
868
+ let pollCount = 0;
869
+ logger.debug('[NFT Indexer] Starting metadata assets polling...', {
870
+ tokenCID,
871
+ timeoutMs: POLLING_TIMEOUT_MS,
872
+ intervalMs: POLLING_INTERVAL_MS,
873
+ });
874
+ try {
875
+ while (true) {
876
+ const tokenData = await queryTokenDataFromIndexer(tokenCID);
877
+ pollCount += 1;
878
+ // Check if metadata_media_assets exists
879
+ if (tokenData &&
880
+ Array.isArray(tokenData.metadata_media_assets) &&
881
+ tokenData.metadata_media_assets.length > 0) {
882
+ const elapsedMs = Date.now() - startTime;
883
+ logger.info(`[NFT Indexer] ✓ Metadata assets found after ${pollCount} polls (${elapsedMs}ms)`);
884
+ return tokenData;
885
+ }
886
+ logger.debug(`[NFT Indexer] Poll #${pollCount}: metadata_media_assets not yet available`);
887
+ // Check timeout
888
+ const elapsedMs = Date.now() - startTime;
889
+ if (elapsedMs >= POLLING_TIMEOUT_MS) {
890
+ logger.warn(`[NFT Indexer] Metadata assets polling timed out after ${pollCount} polls (${elapsedMs}ms). Using fallback URLs.`);
891
+ return tokenData; // Return token data as-is, will use fallback URLs
892
+ }
893
+ // Wait before next poll
894
+ await new Promise((resolve) => setTimeout(resolve, POLLING_INTERVAL_MS));
895
+ }
896
+ }
897
+ catch (error) {
898
+ logger.error('[NFT Indexer] Error during metadata assets polling:', error.message);
899
+ return null;
900
+ }
901
+ }
902
+ /**
903
+ * Search for NFTs by query
904
+ * @param {Object} params - Search parameters
905
+ * @param {string} params.query - Search query
906
+ * @param {string} params.chain - Optional chain filter
907
+ * @param {number} params.limit - Results limit
908
+ * @returns {Promise<Object>} Search results
909
+ */
910
+ async function searchNFTs(params) {
911
+ const { query, chain, limit = 10 } = params;
912
+ // TODO: Implement NFT search if indexer supports it
913
+ logger.debug(`[NFT Indexer] Searching NFTs:`, { query, chain, limit });
914
+ return {
915
+ success: true,
916
+ results: [],
917
+ total: 0,
918
+ };
919
+ }
920
+ /**
921
+ * Query tokens owned by an address
922
+ *
923
+ * Fetches all tokens owned by a given address from the indexer.
924
+ * Returns token data in a format suitable for conversion to DP1 items.
925
+ *
926
+ * @param {string} ownerAddress - Owner wallet address
927
+ * @param {number} [limit=100] - Maximum number of tokens to fetch
928
+ * @returns {Promise<Object>} Result with tokens array
929
+ * @returns {boolean} returns.success - Whether query succeeded
930
+ * @returns {Array} [returns.tokens] - Array of token data
931
+ * @returns {string} [returns.error] - Error message if failed
932
+ * @example
933
+ * const result = await queryTokensByOwner('0x1234...', 50);
934
+ * if (result.success) {
935
+ * console.log(`Found ${result.tokens.length} tokens`);
936
+ * }
937
+ */
938
+ /**
939
+ * Query tokens owned by an address
940
+ *
941
+ * Convenience wrapper around queryTokens for owner-based queries.
942
+ * Fetches all tokens owned by a given address from the indexer.
943
+ * Supports pagination for fetching large collections.
944
+ *
945
+ * @param {string} ownerAddress - Owner wallet address
946
+ * @param {number} [limit=100] - Maximum number of tokens to fetch per page
947
+ * @param {number} [offset=0] - Offset for pagination
948
+ * @returns {Promise<Object>} Result with tokens array
949
+ * @returns {boolean} returns.success - Whether query succeeded
950
+ * @returns {Array} [returns.tokens] - Array of token data
951
+ * @returns {number} [returns.count] - Number of tokens found in this page
952
+ * @returns {string} [returns.error] - Error message if failed
953
+ * @example
954
+ * // Fetch first page
955
+ * const result = await queryTokensByOwner('0x1234...', 100, 0);
956
+ * // Fetch second page
957
+ * const result2 = await queryTokensByOwner('0x1234...', 100, 100);
958
+ */
959
+ async function queryTokensByOwner(ownerAddress, limit = 100, offset = 0) {
960
+ try {
961
+ logger.info(`[NFT Indexer] Querying tokens for owner: ${ownerAddress} (limit: ${limit}, offset: ${offset})`);
962
+ const tokens = await queryTokens({
963
+ owners: [ownerAddress],
964
+ limit,
965
+ offset,
966
+ });
967
+ logger.info(`[NFT Indexer] Found ${tokens.length} token(s) for owner ${ownerAddress}`);
968
+ return {
969
+ success: true,
970
+ tokens,
971
+ count: tokens.length,
972
+ };
973
+ }
974
+ catch (error) {
975
+ logger.error('[NFT Indexer] Failed to query tokens by owner:', error.message);
976
+ return {
977
+ success: false,
978
+ error: error.message,
979
+ tokens: [],
980
+ count: 0,
981
+ };
982
+ }
983
+ }
984
+ module.exports = {
985
+ // Initialization
986
+ initializeIndexer,
987
+ // Primary functions (return DP1 items)
988
+ getNFTTokenInfo,
989
+ // Batch processing
990
+ getNFTTokenInfoBatch,
991
+ // Single token processing
992
+ getNFTTokenInfoSingle,
993
+ // Additional functions
994
+ getCollectionInfo,
995
+ searchNFTs,
996
+ triggerIndexingAsync,
997
+ buildTokenCID,
998
+ convertToDP1Item,
999
+ // Address-based functions
1000
+ queryTokensByOwner,
1001
+ // Unified GraphQL query
1002
+ queryTokens,
1003
+ // Workflow and polling functions
1004
+ queryWorkflowStatus,
1005
+ pollForWorkflowCompletion,
1006
+ pollForMetadataAssets,
1007
+ // Export for testing
1008
+ queryTokenDataFromIndexer,
1009
+ mapIndexerDataToStandardFormat,
1010
+ detectTokenStandard,
1011
+ extractArtistName,
1012
+ getBestMediaUrl,
1013
+ };