@feralfile/cli 1.1.1

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