@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.
- package/LICENSE +21 -0
- package/README.md +96 -0
- package/config.json.example +96 -0
- package/dist/index.js +54 -0
- package/dist/src/ai-orchestrator/index.js +1019 -0
- package/dist/src/ai-orchestrator/registry.js +96 -0
- package/dist/src/commands/build.js +69 -0
- package/dist/src/commands/chat.js +189 -0
- package/dist/src/commands/config.js +68 -0
- package/dist/src/commands/device.js +278 -0
- package/dist/src/commands/helpers/config-files.js +62 -0
- package/dist/src/commands/helpers/device-discovery.js +111 -0
- package/dist/src/commands/helpers/playlist-display.js +161 -0
- package/dist/src/commands/helpers/prompt.js +65 -0
- package/dist/src/commands/helpers/ssh-helpers.js +44 -0
- package/dist/src/commands/play.js +110 -0
- package/dist/src/commands/publish.js +115 -0
- package/dist/src/commands/setup.js +225 -0
- package/dist/src/commands/sign.js +41 -0
- package/dist/src/commands/ssh.js +108 -0
- package/dist/src/commands/status.js +126 -0
- package/dist/src/commands/validate.js +18 -0
- package/dist/src/config.js +441 -0
- package/dist/src/intent-parser/index.js +1382 -0
- package/dist/src/intent-parser/utils.js +108 -0
- package/dist/src/logger.js +82 -0
- package/dist/src/main.js +459 -0
- package/dist/src/types.js +5 -0
- package/dist/src/utilities/address-validator.js +242 -0
- package/dist/src/utilities/device-default.js +36 -0
- package/dist/src/utilities/device-lookup.js +107 -0
- package/dist/src/utilities/device-normalize.js +62 -0
- package/dist/src/utilities/device-upsert.js +91 -0
- package/dist/src/utilities/domain-resolver.js +291 -0
- package/dist/src/utilities/ed25519-key-derive.js +155 -0
- package/dist/src/utilities/feed-fetcher.js +471 -0
- package/dist/src/utilities/ff1-compatibility.js +269 -0
- package/dist/src/utilities/ff1-device.js +250 -0
- package/dist/src/utilities/ff1-discovery.js +330 -0
- package/dist/src/utilities/functions.js +308 -0
- package/dist/src/utilities/index.js +469 -0
- package/dist/src/utilities/nft-indexer.js +1024 -0
- package/dist/src/utilities/playlist-builder.js +523 -0
- package/dist/src/utilities/playlist-publisher.js +131 -0
- package/dist/src/utilities/playlist-send.js +260 -0
- package/dist/src/utilities/playlist-signer.js +204 -0
- package/dist/src/utilities/playlist-signing-role.js +41 -0
- package/dist/src/utilities/playlist-source.js +128 -0
- package/dist/src/utilities/playlist-verifier.js +274 -0
- package/dist/src/utilities/ssh-access.js +145 -0
- package/dist/src/utils.js +48 -0
- package/docs/CONFIGURATION.md +206 -0
- package/docs/EXAMPLES.md +390 -0
- package/docs/FUNCTION_CALLING.md +96 -0
- package/docs/PROJECT_SPEC.md +228 -0
- package/docs/README.md +348 -0
- package/docs/RELEASING.md +73 -0
- 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
|
+
};
|