@feralfile/cli 2.10.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,428 @@
1
+ "use strict";
2
+ /**
3
+ * Token item construction shared by every token source.
4
+ *
5
+ * This module turns a StandardToken, the vendor-neutral token shape, into a
6
+ * DP-1 playlist item. It used to live inside nft-indexer.js, which meant the
7
+ * item id, provenance, inline manifest and timing rules would have been
8
+ * deleted along with the ff-indexer-v2 client if Raster replaces it
9
+ * (feral-file#3576). Keeping them here, with no knowledge of any vendor, is
10
+ * what lets two providers emit byte-identical items for the same token: the
11
+ * indexer provider and the Raster provider both map their rows to
12
+ * a StandardToken and hand it to convertToDP1Item.
13
+ *
14
+ * Invariants that must not change without a migration of existing playlists:
15
+ * - the item id is sha256(`${contractAddress}-${tokenId}`) formatted as a
16
+ * UUID, so the same token always yields the same id across vendors;
17
+ * - provenance.contract.chain comes from the row's CAIP-2 chain when the
18
+ * row has one, and only falls back to the caller's chain name otherwise;
19
+ * - the inline manifest id is derived from the token coordinate by
20
+ * ref-manifest.ts, independent of which vendor supplied the metadata.
21
+ */
22
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
23
+ if (k2 === undefined) k2 = k;
24
+ var desc = Object.getOwnPropertyDescriptor(m, k);
25
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
26
+ desc = { enumerable: true, get: function() { return m[k]; } };
27
+ }
28
+ Object.defineProperty(o, k2, desc);
29
+ }) : (function(o, m, k, k2) {
30
+ if (k2 === undefined) k2 = k;
31
+ o[k2] = m[k];
32
+ }));
33
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
34
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
35
+ }) : function(o, v) {
36
+ o["default"] = v;
37
+ });
38
+ var __importStar = (this && this.__importStar) || (function () {
39
+ var ownKeys = function(o) {
40
+ ownKeys = Object.getOwnPropertyNames || function (o) {
41
+ var ar = [];
42
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
43
+ return ar;
44
+ };
45
+ return ownKeys(o);
46
+ };
47
+ return function (mod) {
48
+ if (mod && mod.__esModule) return mod;
49
+ var result = {};
50
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
51
+ __setModuleDefault(result, mod);
52
+ return result;
53
+ };
54
+ })();
55
+ Object.defineProperty(exports, "__esModule", { value: true });
56
+ exports.CID_STANDARDS = void 0;
57
+ exports.detectTokenStandard = detectTokenStandard;
58
+ exports.normalizeCidStandard = normalizeCidStandard;
59
+ exports.resolveTokenStandard = resolveTokenStandard;
60
+ exports.dp1ChainFromCaip2 = dp1ChainFromCaip2;
61
+ exports.resolveDp1Chain = resolveDp1Chain;
62
+ exports.extractArtistName = extractArtistName;
63
+ exports.isUsableSourceUrl = isUsableSourceUrl;
64
+ exports.getBestMediaUrl = getBestMediaUrl;
65
+ exports.itemIdFor = itemIdFor;
66
+ exports.convertToDP1Item = convertToDP1Item;
67
+ exports.stillOf = stillOf;
68
+ const node_crypto_1 = require("node:crypto");
69
+ const dp1_js_1 = require("dp1-js");
70
+ const logger = __importStar(require("../logger"));
71
+ const ref_manifest_1 = require("./ref-manifest");
72
+ // playlist-builder.js is still CommonJS; require keeps interop simple, the
73
+ // same way find.ts and playlist-source.ts load it.
74
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
75
+ const { applyTimingToItemBuilder, buildDefaultDisplay } = require('./playlist-builder');
76
+ /**
77
+ * detectTokenStandard guesses a token standard from a chain name and an address.
78
+ *
79
+ * This is the last-resort fallback, not the answer. It sees no token, only a
80
+ * chain name and an address format, so it cannot tell ERC-721 from ERC-1155
81
+ * and answers `erc721` for every EVM contract. It is still needed in two
82
+ * places: a lookup by coordinate has to build some key before a provider has
83
+ * been asked anything, and a token row that arrived without a `standard`
84
+ * field has nothing better to offer.
85
+ *
86
+ * Prefer, in order: the provider row's own standard, a caller's assertion, and
87
+ * only then this. On-chain detection is deliberately not attempted here.
88
+ *
89
+ * @param chain - Blockchain network
90
+ * @param contractAddress - Contract address
91
+ * @returns Token standard (erc721, erc1155, fa2, or other)
92
+ */
93
+ function detectTokenStandard(chain, contractAddress) {
94
+ const lowerChain = String(chain || '').toLowerCase();
95
+ // Tezos contracts use FA2 standard
96
+ if (lowerChain === 'tezos' || String(contractAddress || '').startsWith('KT')) {
97
+ return 'fa2';
98
+ }
99
+ // Ethereum: erc721 is a guess, not a reading. Only the provider row or a
100
+ // caller's assertion can distinguish an ERC-1155 token from an ERC-721 one.
101
+ if (lowerChain === 'ethereum') {
102
+ return 'erc721';
103
+ }
104
+ return 'other';
105
+ }
106
+ /**
107
+ * CID_STANDARDS are the token standards the indexer keys CIDs on and DP-1
108
+ * `provenance.contract.standard` accepts from this CLI. A standard outside
109
+ * this set, asserted by a caller or arriving on a row, says nothing the key
110
+ * can carry, so detection decides instead.
111
+ */
112
+ exports.CID_STANDARDS = new Set(['erc721', 'erc1155', 'fa2']);
113
+ /**
114
+ * normalizeCidStandard returns a standard the CID format can carry, or ''.
115
+ *
116
+ * Both a provider row and a caller's assertion are untrusted strings from
117
+ * outside this module ('ERC1155', ' fa2 ', 'ERC-1155', undefined). Anything not
118
+ * in CID_STANDARDS is treated as absent rather than passed through, so a
119
+ * misspelled standard never reaches a CID or a DP-1 `provenance.contract`.
120
+ *
121
+ * @param value - Candidate standard
122
+ * @returns Normalized standard, or '' when it is not one we can use
123
+ */
124
+ function normalizeCidStandard(value) {
125
+ const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
126
+ return exports.CID_STANDARDS.has(normalized) ? normalized : '';
127
+ }
128
+ /**
129
+ * resolveTokenStandard picks the standard a lookup key is built with.
130
+ *
131
+ * detectTokenStandard cannot tell ERC-721 from ERC-1155, so when the caller
132
+ * knows the standard (a DP-1 playlist carries it in
133
+ * `provenance.contract.standard`) that assertion wins.
134
+ *
135
+ * @param chain - Blockchain network
136
+ * @param contractAddress - Contract address
137
+ * @param asserted - Standard the caller vouches for, if any
138
+ * @returns Token standard for the lookup key
139
+ */
140
+ function resolveTokenStandard(chain, contractAddress, asserted) {
141
+ return normalizeCidStandard(asserted) || detectTokenStandard(chain, contractAddress);
142
+ }
143
+ /**
144
+ * CAIP2_NAMESPACE_TO_DP1_CHAIN maps CAIP-2 namespaces to the chain values DP-1
145
+ * `provenance.contract` allows. Namespaces outside this map have no DP-1
146
+ * equivalent.
147
+ */
148
+ const CAIP2_NAMESPACE_TO_DP1_CHAIN = {
149
+ eip155: 'evm',
150
+ tezos: 'tezos',
151
+ bitmark: 'bitmark',
152
+ };
153
+ /**
154
+ * dp1ChainFromCaip2 derives a DP-1 chain from a CAIP-2 chain id.
155
+ *
156
+ * @param caip2Chain - CAIP-2 chain id from a provider row, e.g. `eip155:1`
157
+ * @returns DP-1 chain (`evm`, `tezos`, `bitmark`), or '' when unknown or absent
158
+ */
159
+ function dp1ChainFromCaip2(caip2Chain) {
160
+ if (typeof caip2Chain !== 'string') {
161
+ return '';
162
+ }
163
+ const namespace = caip2Chain.trim().toLowerCase().split(':')[0];
164
+ return CAIP2_NAMESPACE_TO_DP1_CHAIN[namespace] || '';
165
+ }
166
+ /**
167
+ * CHAIN_NAME_TO_DP1_CHAIN maps client chain names to the chain values DP-1
168
+ * `provenance.contract` allows. It answers only for tokens that reached us
169
+ * without a provider row, that is a caller-supplied coordinate.
170
+ */
171
+ const CHAIN_NAME_TO_DP1_CHAIN = {
172
+ ethereum: 'evm',
173
+ polygon: 'evm',
174
+ arbitrum: 'evm',
175
+ optimism: 'evm',
176
+ base: 'evm',
177
+ zora: 'evm',
178
+ tezos: 'tezos',
179
+ bitmark: 'bitmark',
180
+ };
181
+ /**
182
+ * resolveDp1Chain picks the DP-1 chain for a token's `provenance.contract`.
183
+ *
184
+ * The row's CAIP-2 chain and the caller's chain name are not two guesses to try
185
+ * in turn: they are different claims, and the row's is the network the
186
+ * provider recorded. So a token carrying a row chain is answered from that
187
+ * chain alone; a namespace with no DP-1 equivalent becomes `other`, never the
188
+ * caller's name. Falling through would be actively wrong, because owner and
189
+ * contract lookups label every non-KT address `ethereum` before mapping, so a
190
+ * Solana row would inherit that label and be published as an EVM contract.
191
+ *
192
+ * @param token - Token in the internal standard format
193
+ * @returns DP-1 chain (`evm`, `tezos`, `bitmark`, or `other`)
194
+ */
195
+ function resolveDp1Chain(token) {
196
+ const rowChain = typeof token.caip2Chain === 'string' ? token.caip2Chain.trim() : '';
197
+ if (rowChain) {
198
+ return dp1ChainFromCaip2(rowChain) || 'other';
199
+ }
200
+ return CHAIN_NAME_TO_DP1_CHAIN[String(token.chain || '').toLowerCase()] || 'other';
201
+ }
202
+ /**
203
+ * extractArtistName returns the first artist's name from an artists array, or ''.
204
+ *
205
+ * @param artists - Array of artist objects with a name
206
+ * @returns Artist name or empty string
207
+ */
208
+ function extractArtistName(artists) {
209
+ if (!Array.isArray(artists) || artists.length === 0) {
210
+ return '';
211
+ }
212
+ const first = artists[0];
213
+ return typeof first?.name === 'string' ? first.name : '';
214
+ }
215
+ /**
216
+ * isUsableSourceUrl reports whether a media URL can be a DP-1 item source.
217
+ *
218
+ * Data URIs are refused because the FF1 display path cannot load them and
219
+ * inlining base64 would dwarf the playlist; URLs over 1024 characters exceed
220
+ * the DP-1 limit on `source`.
221
+ *
222
+ * @param url - Candidate media URL
223
+ * @returns True when URL is usable in a DP-1 source
224
+ */
225
+ function isUsableSourceUrl(url) {
226
+ if (!url || typeof url !== 'string') {
227
+ return false;
228
+ }
229
+ if (url.startsWith('data:')) {
230
+ return false;
231
+ }
232
+ if (url.length > 1024) {
233
+ return false;
234
+ }
235
+ return true;
236
+ }
237
+ /**
238
+ * getBestMediaUrl picks a DP-1 usable URL from a `display` block and asset rows.
239
+ *
240
+ * Order: display.animation_url, transcoded asset URLs (source + variants),
241
+ * display.image_url. The thumbnail is always `display.image_url`, the field
242
+ * reserved for a still.
243
+ *
244
+ * @param display - Merged presentation block
245
+ * @param mediaAssets - Transcoded asset rows
246
+ * @returns Object with url and thumbnail properties
247
+ */
248
+ function getBestMediaUrl(display = {}, mediaAssets = []) {
249
+ const urlsFromAssets = [];
250
+ for (const asset of Array.isArray(mediaAssets) ? mediaAssets : []) {
251
+ if (asset?.source_url) {
252
+ urlsFromAssets.push(asset.source_url);
253
+ }
254
+ const v = asset?.variants;
255
+ if (v && typeof v === 'object') {
256
+ for (const val of Object.values(v)) {
257
+ if (typeof val === 'string') {
258
+ urlsFromAssets.push(val);
259
+ }
260
+ }
261
+ }
262
+ }
263
+ const candidates = [display?.animation_url, ...urlsFromAssets, display?.image_url];
264
+ for (const candidate of candidates) {
265
+ if (isUsableSourceUrl(candidate)) {
266
+ return {
267
+ url: candidate,
268
+ thumbnail: display?.image_url || '',
269
+ };
270
+ }
271
+ }
272
+ const imageUrl = display?.image_url || '';
273
+ return {
274
+ url: imageUrl,
275
+ thumbnail: imageUrl,
276
+ };
277
+ }
278
+ /**
279
+ * itemIdFor derives the deterministic DP-1 item id for a token coordinate.
280
+ *
281
+ * sha256 of `${contractAddress}-${tokenId}` formatted UUID-style. This is a
282
+ * cross-vendor contract: a playlist built from the indexer and one built from
283
+ * Raster must carry the same id for the same token.
284
+ *
285
+ * @param token - Token coordinate
286
+ * @returns UUID-formatted id
287
+ */
288
+ function itemIdFor(token) {
289
+ const idSource = `${token.contractAddress}-${token.tokenId}`;
290
+ const hash = (0, node_crypto_1.createHash)('sha256').update(idSource).digest('hex');
291
+ return `${hash.substr(0, 8)}-${hash.substr(8, 4)}-${hash.substr(12, 4)}-${hash.substr(16, 4)}-${hash.substr(20, 12)}`;
292
+ }
293
+ /**
294
+ * pickSourceUrl chooses the item source from a token's media candidates.
295
+ *
296
+ * Priority: animation_url, then image.url. The first usable candidate wins;
297
+ * when none is usable the first non-empty one is returned so the caller can
298
+ * report precisely why it was rejected.
299
+ *
300
+ * @param token - Token in the internal standard format
301
+ * @returns Source URL, or '' when the token names no media at all
302
+ */
303
+ function pickSourceUrl(token) {
304
+ const candidateSourceUrls = [
305
+ token.animation_url || token.animationUrl,
306
+ token.image && typeof token.image === 'object' ? token.image.url : '',
307
+ ]
308
+ .map((value) => String(value || ''))
309
+ .filter(Boolean);
310
+ return candidateSourceUrls.find((url) => isUsableSourceUrl(url)) || candidateSourceUrls[0] || '';
311
+ }
312
+ /**
313
+ * rejectSourceUrl explains why a source URL cannot be used, or returns ''.
314
+ *
315
+ * @param token - Token the URL belongs to, for the log line
316
+ * @param sourceUrl - Candidate source
317
+ * @returns Error message, or '' when the URL is usable
318
+ */
319
+ function rejectSourceUrl(token, sourceUrl) {
320
+ const coordinate = { contractAddress: token.contractAddress, tokenId: token.tokenId };
321
+ if (!sourceUrl) {
322
+ logger.warn('[Token Item] No source URL found for token:', coordinate);
323
+ return 'No source URL available';
324
+ }
325
+ if (sourceUrl.startsWith('data:')) {
326
+ logger.debug('[Token Item] Skipping token with data URI:', coordinate);
327
+ return 'Source is a data URI (not supported)';
328
+ }
329
+ if (sourceUrl.length > 1024) {
330
+ logger.debug('[Token Item] Skipping token with source URL too long:', {
331
+ ...coordinate,
332
+ urlLength: sourceUrl.length,
333
+ });
334
+ return `Source URL too long (${sourceUrl.length} chars, max 1024)`;
335
+ }
336
+ return '';
337
+ }
338
+ /**
339
+ * convertToDP1Item converts a token in the standard format to a DP-1 item.
340
+ *
341
+ * When the token carries description, artist, or a still image distinct from
342
+ * the source, the item also gets an inline Ref Manifest (see ref-manifest.ts).
343
+ *
344
+ * @param tokenData - Token data in standard format
345
+ * @param duration - Explicit display seconds; omit for auto timing
346
+ * (video/audio play natural length per DP-1 §4.1)
347
+ * @returns DP-1 item with success/error status, plus the token's still
348
+ */
349
+ function convertToDP1Item(tokenData, duration) {
350
+ const { token } = tokenData;
351
+ if (!token) {
352
+ return {
353
+ success: false,
354
+ error: tokenData.error || 'Invalid token data',
355
+ };
356
+ }
357
+ const sourceUrl = pickSourceUrl(token);
358
+ const rejection = rejectSourceUrl(token, sourceUrl);
359
+ if (rejection) {
360
+ return { success: false, error: rejection };
361
+ }
362
+ // Build via dp1-js PlaylistItemBuilder so leaf blocks match the DP-1 AJV schema.
363
+ const itemBuilder = new dp1_js_1.PlaylistItemBuilder()
364
+ .id(itemIdFor(token))
365
+ .source(sourceUrl)
366
+ .license('open')
367
+ .provenance(new dp1_js_1.ProvenanceBuilder().type('onChain').contract(new dp1_js_1.ContractBuilder()
368
+ // resolveDp1Chain and the standard helpers only ever return members
369
+ // of the dp1-js unions, so the casts narrow rather than lie.
370
+ .chain(resolveDp1Chain(token))
371
+ .standard((token.standard ||
372
+ detectTokenStandard(token.chain, token.contractAddress)))
373
+ .address(token.contractAddress)
374
+ .tokenId(String(token.tokenId))));
375
+ if (token.name) {
376
+ itemBuilder.title(token.name);
377
+ }
378
+ // Carry description, artist, and still image inline (DP-1 Playlist Extension
379
+ // §3.6). The helper returns undefined when the token has nothing beyond its
380
+ // title, so thin tokens stay thin.
381
+ const inlineManifest = (0, ref_manifest_1.buildInlineManifestForToken)(token, { sourceUrl });
382
+ if (inlineManifest) {
383
+ itemBuilder.inlineManifest(inlineManifest);
384
+ }
385
+ const mimeType = typeof token.image === 'object' && token.image ? token.image.mimeType : undefined;
386
+ applyTimingToItemBuilder(itemBuilder, { mimeType, sourceUrl }, duration, buildDefaultDisplay());
387
+ let dp1Item;
388
+ try {
389
+ dp1Item = itemBuilder.build();
390
+ }
391
+ catch (error) {
392
+ return {
393
+ success: false,
394
+ error: error instanceof Error ? error.message : String(error),
395
+ };
396
+ }
397
+ logger.debug('[Token Item] ✓ Converted to DP-1:', {
398
+ title: token.name,
399
+ source: sourceUrl ? sourceUrl.substring(0, 60) + '...' : '(no source URL)',
400
+ });
401
+ return {
402
+ success: true,
403
+ item: dp1Item,
404
+ still: stillOf(token),
405
+ };
406
+ }
407
+ /**
408
+ * stillOf names the token's still image as the provider identifies it, or ''.
409
+ *
410
+ * Only `image.thumbnail` qualifies. `image.url` is the media the provider
411
+ * selected as the source, which is the live rendition whenever one exists,
412
+ * exactly what must never land in a thumbnail slot. It is returned beside the
413
+ * DP-1 item rather than inside it because the item only carries the still when
414
+ * it differs from the item's own source, and a consumer building a manifest
415
+ * against a different source (see enrich-playlist.ts) needs the still
416
+ * regardless. Nothing about the URL's shape is inspected: the provider's word
417
+ * that it is an image is the evidence, and a suffix check would reject the
418
+ * extensionless CDN URLs providers commonly return.
419
+ *
420
+ * @param token - Token in the internal standard format
421
+ * @returns http(s) still URL, or '' when the provider named none
422
+ */
423
+ function stillOf(token) {
424
+ const image = token.image;
425
+ const candidate = typeof image === 'string' ? image : typeof image === 'object' && image ? image.thumbnail : '';
426
+ const trimmed = String(candidate || '').trim();
427
+ return trimmed.startsWith('http://') || trimmed.startsWith('https://') ? trimmed : '';
428
+ }
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ /**
3
+ * Token source contract: the vendor-neutral seam between commands and the
4
+ * services that answer "which token is this, and what does it display".
5
+ *
6
+ * Commands (`find`, `build`, `enrich`) depend on this contract only. Each
7
+ * vendor is one provider module that implements it: token-source-indexer.ts
8
+ * (the default) and token-source-raster.ts (opt-in, feral-file#3576).
9
+ * A future art-catalog-service adapter would be a third provider, and nothing
10
+ * outside token-source.ts would change.
11
+ *
12
+ * Kept free of provider imports so providers can import it without a cycle;
13
+ * the factory that chooses a provider lives in token-source.ts.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.RASTER_CANNOT_LIST_CONTRACT = exports.TokenSourceCapabilityError = void 0;
17
+ exports.isResolved = isResolved;
18
+ exports.isFailed = isFailed;
19
+ exports.itemsOf = itemsOf;
20
+ exports.failureMessages = failureMessages;
21
+ /**
22
+ * isResolved narrows a result. This repo compiles without strictNullChecks,
23
+ * under which TypeScript does not narrow a union on a boolean discriminant,
24
+ * so callers use these guards instead of `if (result.success)`.
25
+ */
26
+ function isResolved(result) {
27
+ return result.success === true;
28
+ }
29
+ /** isFailed is the complement of isResolved. */
30
+ function isFailed(result) {
31
+ return result.success === false;
32
+ }
33
+ /**
34
+ * TokenSourceCapabilityError means the active provider cannot answer this
35
+ * kind of request at all. It is deliberately not retried against another
36
+ * provider: one vendor answers a whole run (owner decision, feral-file#3576).
37
+ */
38
+ class TokenSourceCapabilityError extends Error {
39
+ constructor(message) {
40
+ super(message);
41
+ this.name = 'TokenSourceCapabilityError';
42
+ }
43
+ }
44
+ exports.TokenSourceCapabilityError = TokenSourceCapabilityError;
45
+ /** RASTER_CANNOT_LIST_CONTRACT is the user-facing reason contract listing fails on Raster. */
46
+ exports.RASTER_CANNOT_LIST_CONTRACT = 'Raster cannot list a contract. Give tokenIds, an owner address, or run find on the series URL.';
47
+ /** itemsOf keeps the items of the successful results, in order. */
48
+ function itemsOf(results) {
49
+ return results.filter(isResolved).map((result) => result.item);
50
+ }
51
+ /** failureMessages returns each distinct failure reason once, for a summary line. */
52
+ function failureMessages(results) {
53
+ const messages = results.filter(isFailed).map((result) => result.error);
54
+ return [...new Set(messages)];
55
+ }
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ /**
3
+ * Indexer token source: adapter over the ff-indexer-v2 client.
4
+ *
5
+ * The indexer is the default token source. Raster is the opt-in alternative
6
+ * under evaluation (feral-file#3576); keep both behind the TokenSource
7
+ * contract so the vendor stays a configuration switch. This adapter keeps the
8
+ * pre-Raster behaviour on purpose, including the indexing trigger and the
9
+ * polling that makes unseen tokens slow (ff-cli#132).
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.createIndexerTokenSource = createIndexerTokenSource;
13
+ // nft-indexer.js is CommonJS and is required lazily so a Raster run never
14
+ // loads the indexer client at all.
15
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
16
+ const loadIndexer = () => require('./nft-indexer');
17
+ /** Page size the owner walk has always used against the indexer. */
18
+ const OWNER_PAGE_SIZE = 50;
19
+ /** Per-request cap the indexer path has always applied to a bounded listing. */
20
+ const MAX_BOUNDED_LIMIT = 100;
21
+ /** chainOfContract infers a chain from an address; listings carry no chain name. */
22
+ function chainOfContract(contractAddress) {
23
+ return contractAddress.startsWith('KT') ? 'tezos' : 'ethereum';
24
+ }
25
+ /** rowsToResults converts raw indexer rows to results, one per row. */
26
+ function rowsToResults(rows, duration) {
27
+ const indexer = loadIndexer();
28
+ return rows.map((row) => {
29
+ const contractAddress = row.contract_address || '';
30
+ const coordinate = {
31
+ chain: chainOfContract(contractAddress),
32
+ contractAddress,
33
+ tokenId: String(row.token_number ?? ''),
34
+ };
35
+ const mapped = indexer.mapIndexerDataToStandardFormat(row, coordinate.chain);
36
+ const converted = mapped.success ? indexer.convertToDP1Item(mapped, duration) : mapped;
37
+ if (!converted.success || !converted.item) {
38
+ return { success: false, coordinate, error: converted.error || 'Item build failed' };
39
+ }
40
+ return { success: true, coordinate, item: converted.item, still: converted.still || '' };
41
+ });
42
+ }
43
+ /**
44
+ * walkOwner pages through the indexer for an owner, as queryTokensByAddress did.
45
+ * A failed first page throws; a failed later page keeps what was collected,
46
+ * which is the indexer path's long-standing behaviour.
47
+ */
48
+ async function walkOwner(address, limit) {
49
+ const indexer = loadIndexer();
50
+ if (limit !== 'all') {
51
+ const result = await indexer.queryTokensByOwner(address, Math.min(limit, MAX_BOUNDED_LIMIT));
52
+ if (!result.success) {
53
+ throw new Error(result.error || 'Indexer owner query failed');
54
+ }
55
+ return result.tokens;
56
+ }
57
+ const rows = [];
58
+ for (let offset = 0;; offset += OWNER_PAGE_SIZE) {
59
+ const result = await indexer.queryTokensByOwner(address, OWNER_PAGE_SIZE, offset);
60
+ if (!result.success && offset === 0) {
61
+ throw new Error(result.error || 'Indexer owner query failed');
62
+ }
63
+ if (!result.success || result.tokens.length === 0) {
64
+ return rows;
65
+ }
66
+ rows.push(...result.tokens);
67
+ if (result.tokens.length < OWNER_PAGE_SIZE) {
68
+ return rows;
69
+ }
70
+ }
71
+ }
72
+ /** createIndexerTokenSource builds the indexer provider. */
73
+ function createIndexerTokenSource() {
74
+ return {
75
+ name: 'indexer',
76
+ async resolveTokens(coords, opts = {}) {
77
+ const raw = await loadIndexer().resolveTokenBatch(coords, opts.duration, opts.onProgress);
78
+ return coords.map((coordinate, i) => {
79
+ const result = raw[i];
80
+ if (result?.success && result.item) {
81
+ return { success: true, coordinate, item: result.item, still: result.still || '' };
82
+ }
83
+ return { success: false, coordinate, error: result?.error || 'Token not resolved' };
84
+ });
85
+ },
86
+ async listTokensByOwner(address, opts) {
87
+ const rows = await walkOwner(address, opts.limit);
88
+ return { results: rowsToResults(rows, opts.duration), skippedUnsupported: 0 };
89
+ },
90
+ async listTokensByContract(address, opts) {
91
+ const limit = opts.limit === 'all' ? MAX_BOUNDED_LIMIT : Math.min(opts.limit, MAX_BOUNDED_LIMIT);
92
+ const result = await loadIndexer().queryTokensByContract(address, limit);
93
+ if (!result.success) {
94
+ throw new Error(result.error || 'Indexer contract query failed');
95
+ }
96
+ return { results: rowsToResults(result.tokens, opts.duration), skippedUnsupported: 0 };
97
+ },
98
+ };
99
+ }