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.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/config.json.example +78 -0
- package/dist/index.js +627 -0
- package/dist/src/ai-orchestrator/index.js +870 -0
- package/dist/src/ai-orchestrator/registry.js +96 -0
- package/dist/src/config.js +352 -0
- package/dist/src/intent-parser/index.js +1342 -0
- package/dist/src/intent-parser/utils.js +108 -0
- package/dist/src/logger.js +72 -0
- package/dist/src/main.js +393 -0
- package/dist/src/types.js +5 -0
- package/dist/src/utilities/address-validator.js +242 -0
- package/dist/src/utilities/domain-resolver.js +291 -0
- package/dist/src/utilities/feed-fetcher.js +387 -0
- package/dist/src/utilities/ff1-device.js +176 -0
- package/dist/src/utilities/functions.js +325 -0
- package/dist/src/utilities/index.js +372 -0
- package/dist/src/utilities/nft-indexer.js +1013 -0
- package/dist/src/utilities/playlist-builder.js +522 -0
- package/dist/src/utilities/playlist-publisher.js +131 -0
- package/dist/src/utilities/playlist-send.js +241 -0
- package/dist/src/utilities/playlist-signer.js +171 -0
- package/dist/src/utilities/playlist-verifier.js +156 -0
- package/dist/src/utils.js +48 -0
- package/docs/CONFIGURATION.md +178 -0
- package/docs/EXAMPLES.md +331 -0
- package/docs/FUNCTION_CALLING.md +92 -0
- package/docs/README.md +267 -0
- package/docs/RELEASING.md +22 -0
- package/package.json +75 -0
|
@@ -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
|
+
};
|