@artblocks/abx-token-api 0.1.0-alpha.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/dist/abxjs.d.ts +13 -0
  3. package/dist/abxjs.d.ts.map +1 -0
  4. package/dist/abxjs.js +49 -0
  5. package/dist/abxjs.js.map +1 -0
  6. package/dist/art.d.ts +26 -0
  7. package/dist/art.d.ts.map +1 -0
  8. package/dist/art.js +85 -0
  9. package/dist/art.js.map +1 -0
  10. package/dist/code.d.ts +73 -0
  11. package/dist/code.d.ts.map +1 -0
  12. package/dist/code.js +216 -0
  13. package/dist/code.js.map +1 -0
  14. package/dist/dashboard.d.ts +12 -0
  15. package/dist/dashboard.d.ts.map +1 -0
  16. package/dist/dashboard.js +231 -0
  17. package/dist/dashboard.js.map +1 -0
  18. package/dist/deps.d.ts +146 -0
  19. package/dist/deps.d.ts.map +1 -0
  20. package/dist/deps.js +297 -0
  21. package/dist/deps.js.map +1 -0
  22. package/dist/index.d.ts +15 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +15 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/inline.d.ts +19 -0
  27. package/dist/inline.d.ts.map +1 -0
  28. package/dist/inline.js +23 -0
  29. package/dist/inline.js.map +1 -0
  30. package/dist/metadata.d.ts +89 -0
  31. package/dist/metadata.d.ts.map +1 -0
  32. package/dist/metadata.js +673 -0
  33. package/dist/metadata.js.map +1 -0
  34. package/dist/resolve.d.ts +74 -0
  35. package/dist/resolve.d.ts.map +1 -0
  36. package/dist/resolve.js +107 -0
  37. package/dist/resolve.js.map +1 -0
  38. package/dist/server.d.ts +91 -0
  39. package/dist/server.d.ts.map +1 -0
  40. package/dist/server.js +940 -0
  41. package/dist/server.js.map +1 -0
  42. package/dist/watcher.d.ts +56 -0
  43. package/dist/watcher.d.ts.map +1 -0
  44. package/dist/watcher.js +179 -0
  45. package/dist/watcher.js.map +1 -0
  46. package/package.json +50 -0
@@ -0,0 +1,673 @@
1
+ import { fieldOf, inlineText, renderArtifactKey, stitchAttributes, verifyAgainstHash, METADATA_FIELD as F, METADATA_REPRESENTATION as R, } from '@artblocks/abx-sdk';
2
+ import { contentTypeFromPath } from '@artblocks/abx-storage';
3
+ import { resolveFieldRendered, resolveFieldText, COLLECTION_TOKEN_ID } from './resolve.js';
4
+ import { currentRenderArtifact, currentSettledInputsHash, isCodeProject, liveViewEnabled, liveViewUrl } from './code.js';
5
+ /**
6
+ * Standard, marketplace-facing metadata, built from the reconstructed projection by the
7
+ * protocol rule: each field has ONE active on-chain representation — if set, it wins;
8
+ * otherwise the off-chain operator value fills in; otherwise a default. Every served field
9
+ * gets an `abx_provenance` entry saying where it came from and whether the chain vouches for it.
10
+ */
11
+ export function tokenImageUrl(baseUrl, chainId, address, tokenId) {
12
+ return `${baseUrl}/t/${chainId}/${address}/${tokenId}/image`;
13
+ }
14
+ const onChainProv = (field, rep, fromCollection = false) => ({
15
+ field,
16
+ source: rep,
17
+ onChain: true, // the bytes live on-chain (gzip variants are just inflated off-chain on the way out)
18
+ status: 'on-chain',
19
+ note: scopeNote(rep === R.inlineGzip || rep === R.readerGzip ? `on-chain (${rep} — inflated off-chain)` : `on-chain (${rep})`, fromCollection),
20
+ });
21
+ /** Tag a provenance note with `[collection]` when the value came from the collection scope
22
+ * (a token field fell back to the contract-wide field). Mirrors the on-chain renderer. */
23
+ const scopeNote = (note, fromCollection) => (fromCollection ? `${note} [collection]` : note);
24
+ /** Substitute the decimal tokenId for every `{id}` in a URL template (mirrors the renderer). */
25
+ const applyTemplate = (template, tokenId) => template.split('{id}').join(tokenId);
26
+ /**
27
+ * Read a token field with COLLECTION-scope fallback: the token-scope field wins; else the
28
+ * contract-wide field. Mirrors {AbxMetadataRenderer._field}, so one collection-scope field
29
+ * (e.g. an `image` `url-template`) covers every token in O(1) storage.
30
+ */
31
+ function fieldWithFallback(tokenFields, collectionFields, field) {
32
+ const t = fieldOf(tokenFields, field);
33
+ if (t)
34
+ return { entry: t, fromCollection: false };
35
+ const c = collectionFields ? fieldOf(collectionFields, field) : null;
36
+ return { entry: c, fromCollection: !!c };
37
+ }
38
+ /** Provenance for an off-chain-served value, verified against the field's hash anchor if it carries one. */
39
+ function offChainProv(field, entry, served) {
40
+ const verified = entry ? verifyAgainstHash(served, entry) : null;
41
+ const anchor = entry && (entry.representation === R.keccak256 || entry.representation === R.sha256)
42
+ ? entry.representation
43
+ : undefined;
44
+ const status = verified === true ? 'verified' : verified === false ? 'mismatch' : 'off-chain';
45
+ // Notes are a short, DECLARATIVE gloss for whoever reads the served JSON (marketplaces, clients)
46
+ // — never an operator instruction ("run abx …"); the structured fields carry the signal, and
47
+ // how-to-act guidance lives in the CLI/skill, not the public metadata.
48
+ const note = verified === true
49
+ ? `off-chain bytes; on-chain ${anchor} anchor verified`
50
+ : verified === false
51
+ ? `off-chain bytes; on-chain ${anchor} anchor MISMATCH`
52
+ : 'off-chain operator metadata; no on-chain anchor';
53
+ return { field, source: 'off-chain', onChain: false, status, anchor, note };
54
+ }
55
+ const placeholderProv = (field, note = 'placeholder — unset') => ({
56
+ field,
57
+ source: 'placeholder',
58
+ onChain: false,
59
+ status: 'n/a',
60
+ note,
61
+ });
62
+ /** The `name` fallback: the ERC-721 `name()` (on-chain contract storage) + tokenId. `source`
63
+ * is "fallback" (not from the metadata field store) but the value is reconstructable from
64
+ * chain alone, so it's `on-chain` — mirrors the on-chain renderer's _resolveName. */
65
+ const fallbackNameProv = (note) => ({
66
+ field: F.name,
67
+ source: 'fallback',
68
+ onChain: true,
69
+ status: 'on-chain',
70
+ note,
71
+ });
72
+ /** Resolve a text field: on-chain content (inline/reader, gzip'd or not) → off-chain operator
73
+ * value → fallback default. On-chain content is decoded via the shared resolver (eth_call for
74
+ * `reader`), so a large reader-backed description resolves off-chain exactly as on-chain. */
75
+ async function resolveText(client, fields, field, offChain, fallback, fallbackProv, opts = {}) {
76
+ const { entry, fromCollection } = fieldWithFallback(fields, opts.collectionFields, field);
77
+ // on-chain content (inline/reader, ±gzip) → decoded value.
78
+ const onChain = await resolveFieldText(client, entry);
79
+ if (onChain !== null && entry) {
80
+ return { value: onChain, prov: onChainProv(field, entry.representation, fromCollection) };
81
+ }
82
+ // on-chain locator carried AS the text value (url, or url-template with {id} substituted).
83
+ if (entry && (entry.representation === R.url || entry.representation === R.urlTemplate)) {
84
+ const raw = inlineText(entry);
85
+ const value = entry.representation === R.urlTemplate && opts.tokenId != null ? applyTemplate(raw, opts.tokenId) : raw;
86
+ return {
87
+ value,
88
+ prov: {
89
+ field,
90
+ source: entry.representation,
91
+ onChain: true,
92
+ status: 'on-chain',
93
+ note: scopeNote(`on-chain ${entry.representation} -> off-chain content`, fromCollection),
94
+ },
95
+ };
96
+ }
97
+ if (offChain != null) {
98
+ return { value: offChain, prov: offChainProv(field, entry, offChain) };
99
+ }
100
+ if (fallback != null) {
101
+ return { value: fallback, prov: fallbackProv ?? placeholderProv(field, 'default (no on-chain or off-chain value)') };
102
+ }
103
+ return { value: null, prov: placeholderProv(field) };
104
+ }
105
+ /**
106
+ * Map a locator to the provenance `source` describing where the bytes actually live. Locators are
107
+ * typically **gateway HTTPS URLs** (`https://<gw>/ipfs/<cid>`, `https://<gw>/<txid>`) — the form
108
+ * that renders everywhere — so we detect the backend from the URL shape, not just a `ipfs://`/`ar://`
109
+ * scheme. An IPFS gateway URL still reports `source: ipfs` (not the generic `url`).
110
+ */
111
+ function sourceForLocator(locator) {
112
+ const l = locator.toLowerCase();
113
+ if (l.startsWith('ipfs://') || l.includes('/ipfs/') || /\.ipfs\./.test(l))
114
+ return 'ipfs';
115
+ if (l.startsWith('ar://') || l.includes('arweave.net'))
116
+ return 'arweave';
117
+ return 'url';
118
+ }
119
+ /**
120
+ * The `image` URL the metadata points at, with matching provenance. The image is *required*,
121
+ * so this always returns a usable URL. Cases, by the field's single active representation:
122
+ * - on-chain content (`inline`/`reader`, ±gzip) → this node's `/…/image` route serves the
123
+ * decoded bytes; `on-chain`.
124
+ * - off-chain by hash (`keccak256`/`sha256`) → a durable `ipfs://`/`ar://` locator when we have
125
+ * one (bridged or from the active backend) so the heavy asset resolves peer-to-peer; else
126
+ * this node's `/…/image` route serves it from custody. Either way it's `anchored` by the
127
+ * on-chain hash (verify via `/verify`).
128
+ * - locator on-chain (`ipfs`/`arweave`/`url`) → the on-chain pointer IS the URL; `on-chain`.
129
+ * - unset → this node's `/…/image` route serves the deterministic generative placeholder.
130
+ */
131
+ async function resolveImage(state, token, baseUrl, chainId, display, storage) {
132
+ const nodeUrl = tokenImageUrl(baseUrl, chainId, state.address, token.tokenId);
133
+ // token-scope image wins; else the collection-scope image (the O(1) directory pattern).
134
+ const { entry, fromCollection } = fieldWithFallback(token.fields, state.collectionFields, F.image);
135
+ if (!entry) {
136
+ return { url: nodeUrl, prov: placeholderProv(F.image, 'placeholder — generative-from-address (no committed image)') };
137
+ }
138
+ // On-chain content: the node serves the decoded bytes; nothing off-chain to locate.
139
+ if (entry.representation === R.inline ||
140
+ entry.representation === R.inlineGzip ||
141
+ entry.representation === R.reader ||
142
+ entry.representation === R.readerGzip) {
143
+ return { url: nodeUrl, prov: onChainProv(F.image, entry.representation, fromCollection) };
144
+ }
145
+ // Locator with a {id} placeholder → substitute the tokenId (one collection field → whole
146
+ // directory). The on-chain pointer IS the URL, keyed to a pinned IPFS dir / Arweave manifest.
147
+ if (entry.representation === R.urlTemplate) {
148
+ const url = applyTemplate(inlineText(entry), token.tokenId);
149
+ return {
150
+ url,
151
+ prov: {
152
+ field: F.image,
153
+ source: 'url-template',
154
+ onChain: true,
155
+ status: 'on-chain',
156
+ note: scopeNote('on-chain url template -> off-chain content', fromCollection),
157
+ },
158
+ };
159
+ }
160
+ // Off-chain bytes anchored by an on-chain hash. Point at a durable locator if we have one.
161
+ if (entry.representation === R.keccak256 || entry.representation === R.sha256) {
162
+ const anchor = entry.representation;
163
+ const bridged = display.contentLocators?.[entry.value.toLowerCase()];
164
+ const locator = bridged ?? (storage?.locator ? await storage.locator(entry.value) : null);
165
+ if (locator) {
166
+ return {
167
+ url: locator,
168
+ prov: {
169
+ field: F.image,
170
+ source: sourceForLocator(locator),
171
+ onChain: false,
172
+ status: 'anchored',
173
+ anchor,
174
+ note: `off-chain bytes (${sourceForLocator(locator)}); on-chain ${anchor} integrity anchor`,
175
+ },
176
+ };
177
+ }
178
+ // No durable locator known here: serve from this node's custody, still anchored on-chain.
179
+ return {
180
+ url: nodeUrl,
181
+ prov: {
182
+ field: F.image,
183
+ source: anchor,
184
+ onChain: false,
185
+ status: 'anchored',
186
+ anchor,
187
+ note: `off-chain bytes (served by this node); on-chain ${anchor} integrity anchor`,
188
+ },
189
+ };
190
+ }
191
+ // Computed on-chain (`renderer`): the value is a field-renderer address, not a locator — point
192
+ // at this node's route, which eth_calls the renderer and serves the computed bytes.
193
+ if (entry.representation === R.renderer) {
194
+ return {
195
+ url: nodeUrl,
196
+ prov: {
197
+ field: F.image,
198
+ source: 'renderer',
199
+ onChain: true,
200
+ status: 'on-chain',
201
+ note: scopeNote('on-chain field renderer (computed from chain state at read)', fromCollection),
202
+ },
203
+ };
204
+ }
205
+ // Locator committed ON-CHAIN (ipfs/arweave/url): the on-chain value is itself the address.
206
+ const onChainLocator = inlineText(entry).trim();
207
+ return {
208
+ url: onChainLocator || nodeUrl,
209
+ prov: {
210
+ field: F.image,
211
+ source: entry.representation,
212
+ onChain: true,
213
+ status: 'on-chain',
214
+ note: scopeNote(`on-chain pointer (${entry.representation}) — bytes off-chain, located on-chain`, fromCollection),
215
+ },
216
+ };
217
+ }
218
+ // ── the `artifacts` manifest (specs/protocol/data-plane.md) ───────────────────────
219
+ /** The registry's reserved field vocabulary — projection/display keys. Every other field tag a
220
+ * creator sets is a first-class plane artifact. */
221
+ const RESERVED_FIELDS = new Set(Object.values(F));
222
+ /** contentType cache per (renderer address, field) — stable per renderer in practice. */
223
+ const rendererTypeCache = new Map();
224
+ /** Best-effort declared contentType of a `renderer` field (null on any failure). */
225
+ async function rendererContentType(client, tokenAddress, entry, field, tokenId) {
226
+ try {
227
+ const cacheKey = `${entry.value}:${field}`;
228
+ const hit = rendererTypeCache.get(cacheKey);
229
+ if (hit)
230
+ return hit;
231
+ const rendered = await resolveFieldRendered(client, tokenAddress, tokenId, field, entry);
232
+ if (rendered?.contentType)
233
+ rendererTypeCache.set(cacheKey, rendered.contentType);
234
+ return rendered?.contentType || null;
235
+ }
236
+ catch {
237
+ return null;
238
+ }
239
+ }
240
+ /**
241
+ * The declared-type ladder for a FIELD artifact (`data-plane.md → Declared type, never sniffed`):
242
+ * the representation is the declaration channel — `renderer` returns its contentType from chain;
243
+ * on-chain image bytes are SVG by the registry convention; custody bytes carry the type declared
244
+ * at upload; locator forms get a *labeled* extension-map fallback (never byte-sniffing). Unknown
245
+ * → `application/octet-stream`, never omitted (the complete-listing rule).
246
+ */
247
+ export async function fieldMimeType(client, state, entry, field, tokenId, display, storage) {
248
+ const rep = entry.representation;
249
+ if (rep === R.renderer) {
250
+ return (await rendererContentType(client, state.address, entry, field, tokenId)) ?? 'application/octet-stream';
251
+ }
252
+ if (rep === R.inline || rep === R.inlineGzip || rep === R.reader || rep === R.readerGzip) {
253
+ return field === F.image ? 'image/svg+xml' : 'application/octet-stream';
254
+ }
255
+ if (rep === R.keccak256 || rep === R.sha256) {
256
+ const stored = storage ? await storage.get(entry.value).catch(() => null) : null;
257
+ if (stored?.contentType)
258
+ return stored.contentType;
259
+ const bridged = display.contentLocators?.[entry.value.toLowerCase()];
260
+ return bridged ? contentTypeFromPath(bridged) : 'application/octet-stream';
261
+ }
262
+ const raw = inlineText(entry).trim();
263
+ return contentTypeFromPath(rep === R.urlTemplate ? applyTemplate(raw, tokenId) : raw);
264
+ }
265
+ /** A field artifact's `uri`: locator forms verbatim (content-addressed preferred, `{id}`
266
+ * substituted); custody-hash forms prefer the bridged durable locator; everything this node
267
+ * serves itself (inline/reader/renderer) rides the given `/data/{field}` route. Total — every
268
+ * representation resolves to some uri (the complete-listing rule). */
269
+ function fieldArtifactUri(entry, dataRoute, tokenId, display) {
270
+ const rep = entry.representation;
271
+ if (rep === R.url || rep === R.ipfs || rep === R.arweave)
272
+ return inlineText(entry).trim() || dataRoute;
273
+ if (rep === R.urlTemplate)
274
+ return applyTemplate(inlineText(entry), tokenId);
275
+ if (rep === R.keccak256 || rep === R.sha256) {
276
+ return display.contentLocators?.[entry.value.toLowerCase()] ?? dataRoute;
277
+ }
278
+ return dataRoute; // inline / reader / renderer — this node serves the bytes
279
+ }
280
+ /** Provenance for a non-reserved field artifact (reserved fields carry their projection rows). */
281
+ function artifactFieldProv(field, entry, fromCollection) {
282
+ const rep = entry.representation;
283
+ if (rep === R.inline || rep === R.inlineGzip || rep === R.reader || rep === R.readerGzip) {
284
+ return onChainProv(field, rep, fromCollection);
285
+ }
286
+ if (rep === R.renderer) {
287
+ return {
288
+ field,
289
+ source: 'renderer',
290
+ onChain: true,
291
+ status: 'on-chain',
292
+ note: scopeNote('on-chain field renderer (computed from chain state at read)', fromCollection),
293
+ };
294
+ }
295
+ if (rep === R.keccak256 || rep === R.sha256) {
296
+ const anchor = rep;
297
+ return {
298
+ field,
299
+ source: anchor,
300
+ onChain: false,
301
+ status: 'anchored',
302
+ anchor,
303
+ note: `off-chain bytes; on-chain ${anchor} integrity anchor`,
304
+ };
305
+ }
306
+ return {
307
+ field,
308
+ source: rep,
309
+ onChain: true,
310
+ status: 'on-chain',
311
+ note: scopeNote(`on-chain pointer (${rep}) — bytes off-chain, located on-chain`, fromCollection),
312
+ };
313
+ }
314
+ /**
315
+ * Assemble the token's `artifacts` manifest — the COMPLETE listing of its data plane
316
+ * (`data-plane.md → The manifest`): creator-set content fields (including ones already projected
317
+ * into reserved keys — deliberate duplication; the manifest alone reconstructs the file set) plus
318
+ * every producer-registered effect output whose row matches the CURRENT settled inputsHash (a
319
+ * param change re-addresses → stale rows go silently unlisted). Deterministic order: image,
320
+ * animation_url, non-reserved fields (alpha), then effect entries by effectKey/outputKey.
321
+ */
322
+ async function buildTokenArtifacts(client, state, token, baseUrl, chainId, display, storage, plane, resolved) {
323
+ const entries = [];
324
+ const prov = [];
325
+ const cf = state.collectionFields;
326
+ // image — a creator-set field only (an unset image projected from the render effect is listed
327
+ // as its render/image entry below; the deterministic placeholder is not an artifact).
328
+ if (resolved.imageEntry) {
329
+ entries.push({
330
+ key: F.image,
331
+ mimeType: await fieldMimeType(client, state, resolved.imageEntry, F.image, token.tokenId, display, storage),
332
+ uri: resolved.imageUrl,
333
+ });
334
+ }
335
+ // animation_url — the explicit field only (the derived live view is a projection of the code;
336
+ // `code` never appears in served JSON per the field registry).
337
+ if (resolved.animationEntry && resolved.animationValue) {
338
+ entries.push({
339
+ key: F.animationUrl,
340
+ mimeType: await fieldMimeType(client, state, resolved.animationEntry, F.animationUrl, token.tokenId, display, storage),
341
+ uri: resolved.animationValue,
342
+ });
343
+ }
344
+ // every non-reserved field the creator set (token scope wins collection) — first-class artifacts.
345
+ const names = [...new Set([...token.fields, ...(cf ?? [])].map((x) => x.field))]
346
+ .filter((name) => !RESERVED_FIELDS.has(name) && name !== 'code')
347
+ .sort();
348
+ for (const name of names) {
349
+ const { entry, fromCollection } = fieldWithFallback(token.fields, cf, name);
350
+ if (!entry)
351
+ continue;
352
+ entries.push({
353
+ key: name,
354
+ mimeType: await fieldMimeType(client, state, entry, name, token.tokenId, display, storage),
355
+ uri: fieldArtifactUri(entry, `${baseUrl}/t/${chainId}/${state.address}/${token.tokenId}/data/${name}`, token.tokenId, display),
356
+ });
357
+ prov.push(artifactFieldProv(name, entry, fromCollection));
358
+ }
359
+ // effect outputs at the CURRENT settled inputsHash — the registry rows are producer-published;
360
+ // the currency filter recomputes each row's expected address and drops what doesn't match.
361
+ if (plane && resolved.settledHash) {
362
+ const hash = resolved.settledHash;
363
+ const rows = plane
364
+ .list(state.address, token.tokenId)
365
+ .filter((row) => row.key.toLowerCase() ===
366
+ renderArtifactKey(state.chainId, state.address, token.tokenId, hash, row.outputKey, row.effectKey).toLowerCase())
367
+ .sort((a, b) => `${a.effectKey}/${a.outputKey}`.localeCompare(`${b.effectKey}/${b.outputKey}`));
368
+ for (const row of rows) {
369
+ const key = `${row.effectKey}/${row.outputKey}`;
370
+ entries.push({
371
+ key,
372
+ mimeType: row.contentType ?? 'application/octet-stream',
373
+ uri: row.locator ?? `${baseUrl}/t/${chainId}/${state.address}/${token.tokenId}/data/${key}`,
374
+ });
375
+ prov.push({
376
+ field: key,
377
+ source: `effect:${row.effectKey}`,
378
+ onChain: false,
379
+ status: 'off-chain',
380
+ note: 'derived effect output at the current inputsHash (re-creatable bytes)',
381
+ });
382
+ }
383
+ }
384
+ return { entries, prov };
385
+ }
386
+ const looksLikeSvg = (s) => /^\s*<(\?xml|svg)/i.test(s);
387
+ /** On-chain `attributes` inline-JSON (if any) + the operator's off-chain attributes, stitched
388
+ * (on-chain wins per trait_type). Returns the merged array + a provenance row, or null when
389
+ * neither source has traits (so `attributes` is simply omitted — no boilerplate, no ABX facts). */
390
+ function resolveAttributes(fields, offChain, collectionFields, fromRender = false) {
391
+ const { entry } = fieldWithFallback(fields, collectionFields, F.attributes);
392
+ let onChain = null;
393
+ let onChainBad = false;
394
+ if (entry && entry.representation === R.inline) {
395
+ try {
396
+ const parsed = JSON.parse(inlineText(entry));
397
+ onChain = Array.isArray(parsed) ? parsed : null;
398
+ }
399
+ catch {
400
+ onChainBad = true;
401
+ }
402
+ }
403
+ const merged = stitchAttributes(onChain, offChain);
404
+ if (merged.length === 0 && !onChainBad)
405
+ return null;
406
+ let prov;
407
+ if (onChain && offChain && offChain.length) {
408
+ prov = {
409
+ field: F.attributes,
410
+ source: 'inline',
411
+ onChain: true,
412
+ status: 'on-chain',
413
+ note: fromRender
414
+ ? 'on-chain inline JSON + render-effect traits (+ operator), stitched (on-chain wins per trait_type)'
415
+ : 'on-chain inline JSON + off-chain operator traits, stitched (on-chain wins per trait_type)',
416
+ };
417
+ }
418
+ else if (onChain) {
419
+ prov = onChainProv(F.attributes, R.inline);
420
+ }
421
+ else if (onChainBad) {
422
+ prov = placeholderProv(F.attributes, 'on-chain attributes unparseable — omitted');
423
+ }
424
+ else {
425
+ prov = {
426
+ field: F.attributes,
427
+ source: fromRender ? 'effect:render' : 'off-chain',
428
+ onChain: false,
429
+ status: 'off-chain',
430
+ note: fromRender
431
+ ? 'script-reported traits captured at render (+ operator traits; render wins per trait_type)'
432
+ : 'off-chain operator traits',
433
+ };
434
+ }
435
+ return { attributes: merged, prov };
436
+ }
437
+ /** ERC-721 metadata JSON for a token (the `tokenURI` target) + `abx_provenance` + `artifacts`. */
438
+ export async function buildTokenMetadata(client, state, token, baseUrl, chainId, display = {}, storage,
439
+ // The effect-artifact registry read surface: rows a producer PUBLISHED to this resolver (or
440
+ // recorded co-located). Feeds the `artifacts` manifest and the image seam's locator check —
441
+ // without it a locator-backed image reads "placeholder" while it serves fine.
442
+ plane) {
443
+ const f = token.fields;
444
+ const provenance = [];
445
+ const image = await resolveImage(state, token, baseUrl, chainId, display, storage);
446
+ // ONE settled-inputsHash computation per build — shared by the image seam, the traits stitch,
447
+ // and the manifest's currency filter (they must all agree on "current").
448
+ const settledHash = isCodeProject(state)
449
+ ? await currentSettledInputsHash(client, state, token).catch(() => null)
450
+ : null;
451
+ // the render-effect seam: an unset image on a code project serves the artifact at the
452
+ // CURRENT inputsHash address when a producer has stored one (a param change re-addresses
453
+ // output, so this is self-invalidating — stale renders are never claimed). A render can live
454
+ // in this node's `storage` (co-located) OR as a published locator (the bridge) — check both, so
455
+ // the provenance matches what `/…/image` actually serves (the false-placeholder bug).
456
+ if (image.prov.source === 'placeholder' && isCodeProject(state) && (storage || plane)) {
457
+ try {
458
+ const { key, found } = await currentRenderArtifact(client, state, token, storage ?? undefined, 'image', {
459
+ hash: settledHash ?? undefined,
460
+ });
461
+ const viaLocator = !found && !!plane?.get(key);
462
+ if (found || viaLocator) {
463
+ image.prov = {
464
+ field: F.image,
465
+ source: 'effect:render',
466
+ onChain: false,
467
+ status: 'off-chain',
468
+ note: viaLocator
469
+ ? 'render effect output at the current inputsHash, published as a durable locator (resolver 302-redirects)'
470
+ : 'render effect output for the current inputsHash (re-creatable derived bytes)',
471
+ };
472
+ }
473
+ }
474
+ catch {
475
+ // seam is best-effort — the placeholder stays honest
476
+ }
477
+ }
478
+ // Required fields (always present, with on-chain-computable fallbacks — mirrors the
479
+ // on-chain renderer). Everything else is optional: included only when it actually
480
+ // resolves (on-chain wins → operator-supplied off-chain → otherwise omitted, never a
481
+ // boilerplate filler). The off-chain caveat to renderer parity: this resolver may stitch
482
+ // operator/custody data + derive enrichment the on-chain renderer can't reach.
483
+ // token fields resolve token-scope first, then fall back to the collection scope (mirrors the
484
+ // on-chain renderer's `_field`), and url-template fields substitute this token's id.
485
+ const cf = state.collectionFields;
486
+ const tid = token.tokenId;
487
+ const opts = { collectionFields: cf, tokenId: tid };
488
+ const name = await resolveText(client, f, F.name, undefined, `${state.name ?? state.address} #${token.tokenId}`, fallbackNameProv('on-chain (ERC-721 name() + #id)'), opts);
489
+ const description = await resolveText(client, f, F.description, display.description, null, undefined, opts); // optional → omit if unset
490
+ const externalUrl = await resolveText(client, f, F.externalUrl, display.externalUrl, null, undefined, opts); // optional → omit if unset
491
+ const backgroundColor = await resolveText(client, f, F.backgroundColor, undefined, null, undefined, opts);
492
+ const youtubeUrl = await resolveText(client, f, F.youtubeUrl, undefined, null, undefined, opts);
493
+ provenance.push(name.prov); // name is required — always present
494
+ // image: required — always served via our URL; image_data carries inline SVG if on-chain SVG.
495
+ const img = fieldOf(f, F.image);
496
+ const imageData = img && img.representation === R.inline && looksLikeSvg(inlineText(img)) ? inlineText(img) : null;
497
+ provenance.push(image.prov);
498
+ // optional text fields → record provenance only when actually present.
499
+ if (description.value !== null)
500
+ provenance.push(description.prov);
501
+ if (externalUrl.value !== null)
502
+ provenance.push(externalUrl.prov);
503
+ // animation_url: an explicit `animation` field wins; else a code project derives the
504
+ // live-view route (suppressible via the `display.animation = none` contract param).
505
+ const animation = await resolveText(client, f, F.animationUrl, undefined, null, undefined, opts);
506
+ let animationValue = animation.value;
507
+ let animationProv = animation.prov;
508
+ if (animationValue === null && liveViewEnabled(state)) {
509
+ animationValue = liveViewUrl(baseUrl, chainId, state.address, token.tokenId);
510
+ animationProv = {
511
+ field: F.animationUrl,
512
+ source: 'live-view',
513
+ onChain: false,
514
+ status: 'off-chain',
515
+ note: 'live view derived from the on-chain code (canonical tokenData injected at load)',
516
+ };
517
+ }
518
+ if (animationValue !== null)
519
+ provenance.push(animationProv);
520
+ // attributes: real OpenSea traits only — three creator-controlled sources, stitched:
521
+ // on-chain inline JSON wins per trait_type over the render effect's script-reported
522
+ // traits (captured at the current inputsHash), which win over the operator's off-chain
523
+ // traits. ABX facts (version, canonical, royalty) are NOT traits; they live in this
524
+ // provenance block / ERC-2981, never in the marketplace trait array.
525
+ let renderTraits = null;
526
+ if (isCodeProject(state) && storage) {
527
+ try {
528
+ const { key, found } = await currentRenderArtifact(client, state, token, storage, 'traits', {
529
+ hash: settledHash ?? undefined,
530
+ });
531
+ if (found) {
532
+ const artifact = await storage.get(key);
533
+ const parsed = artifact ? JSON.parse(new TextDecoder().decode(artifact.bytes)) : null;
534
+ // scripts report either the OpenSea array or the natural object form
535
+ // (`abx.traits({Palette: 'Dusk'})`) — normalize the latter.
536
+ if (Array.isArray(parsed))
537
+ renderTraits = parsed;
538
+ else if (parsed && typeof parsed === 'object') {
539
+ renderTraits = Object.entries(parsed).map(([trait_type, value]) => ({ trait_type, value: value }));
540
+ }
541
+ }
542
+ }
543
+ catch {
544
+ // seam is best-effort — traits simply don't stitch until a render lands
545
+ }
546
+ }
547
+ // Per-token off-chain traits (a Series' editable attributes) win over the collection-scope
548
+ // `display.attributes` (which a 1/1 uses); on-chain attributes still win over both (resolveAttributes).
549
+ const offChainBase = display.tokenAttributes?.[String(tid)] ?? display.attributes;
550
+ const offChainTraits = renderTraits ? stitchAttributes(renderTraits, offChainBase) : offChainBase;
551
+ const attr = resolveAttributes(f, offChainTraits, cf, renderTraits !== null);
552
+ if (attr)
553
+ provenance.push(attr.prov);
554
+ if (backgroundColor.value)
555
+ provenance.push(backgroundColor.prov);
556
+ if (youtubeUrl.value)
557
+ provenance.push(youtubeUrl.prov);
558
+ // the `artifacts` manifest — the plane's complete listing (data-plane.md). Omitted when empty.
559
+ const artifacts = await buildTokenArtifacts(client, state, token, baseUrl, chainId, display, storage, plane, {
560
+ imageUrl: image.url,
561
+ imageEntry: fieldWithFallback(f, cf, F.image).entry,
562
+ animationValue: animation.value,
563
+ animationEntry: animation.value !== null ? fieldWithFallback(f, cf, F.animationUrl).entry : null,
564
+ settledHash,
565
+ });
566
+ provenance.push(...artifacts.prov);
567
+ const json = {
568
+ name: name.value,
569
+ image: image.url,
570
+ abx_provenance: provenance,
571
+ };
572
+ if (attr)
573
+ json.attributes = attr.attributes;
574
+ if (description.value !== null)
575
+ json.description = description.value;
576
+ if (externalUrl.value !== null)
577
+ json.external_url = externalUrl.value;
578
+ if (imageData)
579
+ json.image_data = imageData;
580
+ if (animationValue !== null)
581
+ json.animation_url = animationValue;
582
+ if (backgroundColor.value)
583
+ json.background_color = backgroundColor.value;
584
+ if (youtubeUrl.value)
585
+ json.youtube_url = youtubeUrl.value;
586
+ if (artifacts.entries.length)
587
+ json.artifacts = artifacts.entries;
588
+ return json;
589
+ }
590
+ /** ERC-7572 collection metadata JSON (the `contractURI` target) + `abx_provenance`. */
591
+ export async function buildContractMetadata(client, state, baseUrl, chainId, display = {}, storage) {
592
+ const f = state.collectionFields;
593
+ const provenance = [];
594
+ // Representative image: the first token that actually carries an `image` field, else the
595
+ // first token — so "first with an image" surfaces a real artwork rather than a placeholder.
596
+ const rep = state.tokens.find((t) => fieldOf(t.fields, F.image)) ?? state.tokens[0];
597
+ const image = rep ? await resolveImage(state, rep, baseUrl, chainId, display, storage) : undefined;
598
+ // name is required (fallback = the ERC-721 collection name); description + external_link
599
+ // are optional → omit when neither on-chain nor operator-supplied (no boilerplate).
600
+ const name = await resolveText(client, f, F.name, undefined, state.name ?? state.address, fallbackNameProv('on-chain (ERC-721 name())'));
601
+ const description = await resolveText(client, f, F.description, display.description, null);
602
+ const externalLink = await resolveText(client, f, F.externalLink, display.externalUrl, null);
603
+ // authorship + rights (collection scope) — reserved fields, on-chain-only (no operator source):
604
+ // included only when the creator set them on-chain, omitted otherwise (no boilerplate).
605
+ const artist = await resolveText(client, f, F.artist, undefined, null);
606
+ const displayNotes = await resolveText(client, f, F.displayNotes, undefined, null);
607
+ const artistLinks = await resolveText(client, f, F.artistLinks, undefined, null);
608
+ const license = await resolveText(client, f, F.license, undefined, null);
609
+ provenance.push(name.prov);
610
+ if (image)
611
+ provenance.push(image.prov);
612
+ if (description.value !== null)
613
+ provenance.push(description.prov);
614
+ if (externalLink.value !== null)
615
+ provenance.push(externalLink.prov);
616
+ if (artist.value !== null)
617
+ provenance.push(artist.prov);
618
+ if (displayNotes.value !== null)
619
+ provenance.push(displayNotes.prov);
620
+ if (artistLinks.value !== null)
621
+ provenance.push(artistLinks.prov);
622
+ if (license.value !== null)
623
+ provenance.push(license.prov);
624
+ const banner = fieldOf(f, F.bannerImage);
625
+ const bannerImage = banner && banner.representation === R.url ? inlineText(banner) : null;
626
+ if (bannerImage)
627
+ provenance.push(onChainProv(F.bannerImage, R.url));
628
+ // the collection-scope `artifacts` manifest — content-bearing collection fields (banner,
629
+ // featured image, plus any non-reserved field). The representative-token image is a projection
630
+ // courtesy, not a collection artifact; a collection-scope `image` doubles as the per-token
631
+ // default (the url-template pattern), so it lists on tokens, not here. Omitted when empty.
632
+ const artifacts = [];
633
+ const artifactNames = [...new Set(f.map((x) => x.field))]
634
+ .filter((n) => (n === F.bannerImage || n === F.featuredImage || !RESERVED_FIELDS.has(n)) && n !== 'code')
635
+ .sort();
636
+ for (const n of artifactNames) {
637
+ const entry = fieldOf(f, n);
638
+ if (!entry || entry.representation === R.urlTemplate)
639
+ continue; // a per-token template lists on tokens
640
+ artifacts.push({
641
+ key: n,
642
+ // collection surface has no token — a field renderer gets the sentinel id (mirrors on-chain)
643
+ mimeType: await fieldMimeType(client, state, entry, n, COLLECTION_TOKEN_ID, display, storage),
644
+ uri: fieldArtifactUri(entry, `${baseUrl}/c/${chainId}/${state.address}/data/${n}`, '0', display),
645
+ });
646
+ if (!RESERVED_FIELDS.has(n))
647
+ provenance.push(artifactFieldProv(n, entry, false));
648
+ }
649
+ const json = {
650
+ name: name.value,
651
+ abx_provenance: provenance,
652
+ };
653
+ if (image)
654
+ json.image = image.url;
655
+ if (description.value !== null)
656
+ json.description = description.value;
657
+ if (externalLink.value !== null)
658
+ json.external_link = externalLink.value;
659
+ if (artist.value !== null)
660
+ json.artist = artist.value;
661
+ if (displayNotes.value !== null)
662
+ json.display_notes = displayNotes.value;
663
+ if (artistLinks.value !== null)
664
+ json.artist_links = artistLinks.value;
665
+ if (license.value !== null)
666
+ json.license = license.value;
667
+ if (bannerImage)
668
+ json.banner_image = bannerImage;
669
+ if (artifacts.length)
670
+ json.artifacts = artifacts;
671
+ return json;
672
+ }
673
+ //# sourceMappingURL=metadata.js.map