@artblocks/abx-token-api 0.1.0-alpha.5 → 0.1.0-alpha.50

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 (51) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/dist/code.d.ts +33 -12
  3. package/dist/code.d.ts.map +1 -1
  4. package/dist/code.js +68 -56
  5. package/dist/code.js.map +1 -1
  6. package/dist/{art.d.ts → content.d.ts} +5 -5
  7. package/dist/content.d.ts.map +1 -0
  8. package/dist/{art.js → content.js} +6 -6
  9. package/dist/content.js.map +1 -0
  10. package/dist/control-plane.d.ts +24 -13
  11. package/dist/control-plane.d.ts.map +1 -1
  12. package/dist/control-plane.js +289 -43
  13. package/dist/control-plane.js.map +1 -1
  14. package/dist/dashboard.d.ts.map +1 -1
  15. package/dist/dashboard.js +55 -8
  16. package/dist/dashboard.js.map +1 -1
  17. package/dist/deps.d.ts +28 -110
  18. package/dist/deps.d.ts.map +1 -1
  19. package/dist/deps.js +34 -184
  20. package/dist/deps.js.map +1 -1
  21. package/dist/index.d.ts +8 -8
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +13 -11
  24. package/dist/index.js.map +1 -1
  25. package/dist/metadata.d.ts +87 -4
  26. package/dist/metadata.d.ts.map +1 -1
  27. package/dist/metadata.js +343 -105
  28. package/dist/metadata.js.map +1 -1
  29. package/dist/resolve.d.ts +0 -2
  30. package/dist/resolve.d.ts.map +1 -1
  31. package/dist/resolve.js +0 -5
  32. package/dist/resolve.js.map +1 -1
  33. package/dist/server.d.ts +4 -1
  34. package/dist/server.d.ts.map +1 -1
  35. package/dist/server.js +257 -70
  36. package/dist/server.js.map +1 -1
  37. package/dist/watcher.d.ts +1 -1
  38. package/dist/watcher.d.ts.map +1 -1
  39. package/dist/watcher.js +137 -8
  40. package/dist/watcher.js.map +1 -1
  41. package/package.json +9 -8
  42. package/dist/abxjs.d.ts +0 -13
  43. package/dist/abxjs.d.ts.map +0 -1
  44. package/dist/abxjs.js +0 -49
  45. package/dist/abxjs.js.map +0 -1
  46. package/dist/art.d.ts.map +0 -1
  47. package/dist/art.js.map +0 -1
  48. package/dist/inline.d.ts +0 -19
  49. package/dist/inline.d.ts.map +0 -1
  50. package/dist/inline.js +0 -23
  51. package/dist/inline.js.map +0 -1
package/dist/metadata.js CHANGED
@@ -1,6 +1,6 @@
1
- import { fieldOf, inlineText, renderArtifactKey, stitchAttributes, verifyAgainstHash, METADATA_FIELD as F, METADATA_REPRESENTATION as R, } from '@artblocks/abx-sdk';
1
+ import { fieldOf, inlineText, renderArtifactKey, stitchAttributes, verifyAgainstHash, METADATA_FIELD as F, METADATA_REPRESENTATION as R, gatewayConfigFromEnv, projectGatewayPrefix, projectGatewayUrl, } from '@artblocks/abx-sdk';
2
2
  import { contentTypeFromPath } from '@artblocks/abx-storage';
3
- import { resolveFieldRendered, resolveFieldText, COLLECTION_TOKEN_ID } from './resolve.js';
3
+ import { resolveFieldBytes, resolveFieldRendered, COLLECTION_TOKEN_ID } from './resolve.js';
4
4
  import { currentRenderArtifact, currentSettledInputsHash, isCodeProject, liveViewEnabled, liveViewUrl } from './code.js';
5
5
  /**
6
6
  * Standard, marketplace-facing metadata, built from the reconstructed projection by the
@@ -11,12 +11,29 @@ import { currentRenderArtifact, currentSettledInputsHash, isCodeProject, liveVie
11
11
  export function tokenImageUrl(baseUrl, chainId, address, tokenId) {
12
12
  return `${baseUrl}/t/${chainId}/${address}/${tokenId}/image`;
13
13
  }
14
+ /** How the bytes reached the document — the off-chain twin of `AbxMetadataRenderer._sourceNote`.
15
+ * `url`/`url-template` say "locator" because that is what the REPRESENTATION means by
16
+ * construction, not because anything parsed the string. */
17
+ const sourceNote = (rep) => {
18
+ if (rep === R.url)
19
+ return 'stored on chain; the value is a locator';
20
+ if (rep === R.urlTemplate)
21
+ return 'composed on chain from a stored template; a locator';
22
+ if (rep === R.renderer)
23
+ return 'computed on chain (field renderer)';
24
+ if (rep === R.inlineGzip || rep === R.readerGzip)
25
+ return `stored on chain (${rep} — inflated off-chain)`;
26
+ if (rep === R.reader)
27
+ return 'stored on chain (chunked)';
28
+ return 'stored on chain';
29
+ };
14
30
  const onChainProv = (field, rep, fromCollection = false) => ({
15
31
  field,
16
32
  source: rep,
17
- onChain: true, // the bytes live on-chain (gzip variants are just inflated off-chain on the way out)
18
33
  status: 'on-chain',
19
- note: scopeNote(rep === R.inlineGzip || rep === R.readerGzip ? `on-chain (${rep} — inflated off-chain)` : `on-chain (${rep})`, fromCollection),
34
+ // Mirrors AbxMetadataRenderer._sourceNote exactly — the two lanes describe the same routes in
35
+ // the same words, and neither claims anything about where a value RESOLVES.
36
+ note: scopeNote(sourceNote(rep), fromCollection),
20
37
  });
21
38
  /** Tag a provenance note with `[collection]` when the value came from the collection scope
22
39
  * (a token field fell back to the contract-wide field). Mirrors the on-chain renderer. */
@@ -50,34 +67,133 @@ function offChainProv(field, entry, served) {
50
67
  : verified === false
51
68
  ? `off-chain bytes; on-chain ${anchor} anchor MISMATCH`
52
69
  : 'off-chain operator metadata; no on-chain anchor';
53
- return { field, source: 'off-chain', onChain: false, status, anchor, note };
70
+ return { field, source: 'off-chain', status, anchor, note };
54
71
  }
55
72
  const placeholderProv = (field, note = 'placeholder — unset') => ({
56
73
  field,
57
74
  source: 'placeholder',
58
- onChain: false,
59
75
  status: 'n/a',
60
76
  note,
61
77
  });
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. */
78
+ /** The `name` fallback: the contract's `name()` (on-chain storage — `CollectionMetadataLib`,
79
+ * shared by the 721 and 1155 bases) + tokenId. `source` is "fallback" (not from the metadata
80
+ * field store) but the value is reconstructable from chain alone, so it's `on-chain` — mirrors
81
+ * the on-chain renderer's _resolveName. */
65
82
  const fallbackNameProv = (note) => ({
66
83
  field: F.name,
67
84
  source: 'fallback',
68
- onChain: true,
69
85
  status: 'on-chain',
70
86
  note,
71
87
  });
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 = {}) {
88
+ /**
89
+ * The `name` fallback's provenance, told truthfully about which read actually produced it.
90
+ *
91
+ * `state.name` is a value the spine only *pings*, so `reconstructProject` reads it at head — and a
92
+ * head read that fails is persisted as `null`, indistinguishable from a contract that genuinely has
93
+ * no name. The fallback then serves `state.address`, and the old note still claimed the value came
94
+ * from `ERC-721 name()`. A hosted node served `"name": "0xb844…c35e56 #0"` beside
95
+ * `note: "on-chain (ERC-721 name() + #id)"` while that contract's `name()` was `ABXdoku`; it
96
+ * self-corrected on the next re-index, which is the signature of the head-read theory.
97
+ *
98
+ * The wrong name is cosmetic. Provenance is the surface you'd point a creator at to audit their own
99
+ * metadata, so a false positive there costs more than the name does. We deliberately do NOT
100
+ * adjudicate *why* the read came back empty — the projection cannot tell an unnamed contract from a
101
+ * failed read, and guessing is how the original note came to lie.
102
+ */
103
+ const nameFallbackProv = (named, suffix) => fallbackNameProv(named
104
+ ? `on-chain (name()${suffix})`
105
+ : `on-chain (contract address${suffix}) — name() returned no value`);
106
+ /** Resolve a text field: on-chain content (inline/reader ±gzip, or `renderer` computed at read) →
107
+ * off-chain operator value → fallback default. On-chain content is decoded via the shared resolver
108
+ * (eth_call for `reader` and `renderer`), so a large reader-backed description resolves off-chain
109
+ * exactly as on-chain.
110
+ *
111
+ * `opts.uriRoute` marks a **URI-valued** field (`animation_url`): its stored bytes ARE the document,
112
+ * so they cannot land as the value — a marketplace iframe would get HTML where it expected a URL.
113
+ * The on-chain renderer has no URL space and must inline them as a `data:` URI; this node has one,
114
+ * so it serves the same bytes at `uriRoute` (the data plane's `/…/data/{field}` route, declared
115
+ * Content-Type, 302 to a durable locator when one exists) and carries the route as the value —
116
+ * exactly what `image` has always done for the same content, through the same kind of route.
117
+ *
118
+ * Why not the `data:` wrap here too: the value is repeated verbatim into the `artifacts` listing, so
119
+ * a multi-megabyte inline document shipped TWICE in a document marketplaces re-fetch on every view,
120
+ * while the sibling `image` — usually the smaller asset — shipped as a link. The wrap was also
121
+ * already the exception rather than the rule off chain: a code project's `animation_url` has always
122
+ * been the live-view route. A locator representation (`url`/`url-template`, or a computed
123
+ * `text/uri-list`) still lands verbatim — it is already a URI, and re-hosting it would hide it.
124
+ *
125
+ * `tokenAddress` is positional, not an opt, deliberately: it is only needed for the `renderer`
126
+ * eth_call, and an optional field that silently disables a whole representation is exactly how
127
+ * that branch went missing here in the first place. The compiler now makes every call site say it. */
128
+ async function resolveText(client, tokenAddress, fields, field, offChain, fallback,
129
+ // Explicit `| undefined` rather than optional: `opts` after it carries the REQUIRED gateway
130
+ // prefixes, and an optional parameter cannot precede a required one. Every call site already
131
+ // passes this positionally anyway.
132
+ fallbackProv, opts) {
76
133
  const { entry, fromCollection } = fieldWithFallback(fields, opts.collectionFields, field);
77
- // on-chain content (inline/reader, ±gzip) → decoded value.
78
- const onChain = await resolveFieldText(client, entry);
134
+ // on-chain content (inline/reader, ±gzip) → the document bytes, decoded.
135
+ const onChain = await resolveFieldBytes(client, entry);
79
136
  if (onChain !== null && entry) {
80
- return { value: onChain, prov: onChainProv(field, entry.representation, fromCollection) };
137
+ // The bytes reached the document either way; `uriRoute` only decides whether they ride IN it.
138
+ const value = opts.uriRoute ?? new TextDecoder().decode(onChain);
139
+ return { value, prov: onChainProv(field, entry.representation, fromCollection) };
140
+ }
141
+ // Computed on-chain at read (`renderer`) — the field's value is a field-renderer address, so the
142
+ // content only exists after an eth_call. This branch mirrors {AbxMetadataRenderer._appendText}'s
143
+ // R_RENDERER arm **exactly**, because the two must agree: with `tokenURIRenderer` set the chain
144
+ // assembles this JSON and this resolver merely re-serves it, so any difference here is a resolver
145
+ // that contradicts the token's own tokenURI. Hence the declared contentType is used verbatim
146
+ // (`renderer` is the one representation that types itself on-chain) rather than assuming HTML.
147
+ //
148
+ // The `text/uri-list` exception (RFC 2483 — "this payload is a URI") carries the whole reason a
149
+ // URI-valued field can't just be data-wrapped: the computed bytes ARE a locator, so they land
150
+ // verbatim and stay dereferenceable. That is the canonical generator's directory branch.
151
+ let rendererFailure = null;
152
+ if (entry && entry.representation === R.renderer) {
153
+ try {
154
+ const rendered = await resolveFieldRendered(client, tokenAddress, opts.tokenId ?? COLLECTION_TOKEN_ID, field, entry);
155
+ if (rendered) {
156
+ const uriList = rendered.contentType === 'text/uri-list';
157
+ const text = new TextDecoder().decode(rendered.bytes);
158
+ // A computed locator lands verbatim (it IS a URI); computed CONTENT on a URI-valued field
159
+ // goes to the route, same as stored content above; a plain text field carries the text.
160
+ const value = uriList ? text.trim() : (opts.uriRoute ?? text);
161
+ // ONE provenance for both arms. The `text/uri-list` arm used to hand-build an object that
162
+ // was byte-identical to this call — same keys, same order, the same note string typed a
163
+ // second time — so the only thing the branch could ever do was drift from `sourceNote`.
164
+ return { value, prov: onChainProv(field, R.renderer, fromCollection) };
165
+ }
166
+ }
167
+ catch (err) {
168
+ // `IAbxFieldRenderer`'s first invariant is NEVER REVERT, but a resolver cannot rely on a
169
+ // third-party contract honouring it — a reverting renderer must degrade this one field, not
170
+ // 500 the whole token's metadata. We still fall through to the operator's off-chain value
171
+ // (better than nothing), but the provenance says the on-chain attempt failed rather than
172
+ // quietly reporting `off-chain` as if nothing were committed on-chain.
173
+ rendererFailure = err.message;
174
+ console.warn(`[metadata] field renderer for '${field}' on ${tokenAddress} reverted: ${rendererFailure}`);
175
+ }
176
+ }
177
+ const noteRendererFailure = (out) => rendererFailure === null
178
+ ? out
179
+ : { ...out, prov: { ...out.prov, note: `${out.prov.note} — on-chain field renderer reverted, so it could not be used` } };
180
+ // Content-addressed locator (`ipfs`/`arweave`): the CID/txid is identity, the collection's
181
+ // preferred gateway is what makes it dereferenceable. Mirrors {AbxMetadataRenderer._appendText}'s
182
+ // v11 arm. A value that locates nothing (`ipfs://` with no CID) falls through and omits, rather
183
+ // than emitting a bare gateway prefix that 404s.
184
+ if (entry && (entry.representation === R.ipfs || entry.representation === R.arweave)) {
185
+ const wrapped = gatewayFieldUrl(entry, opts.gateways, opts.tokenId === COLLECTION_TOKEN_ID ? undefined : opts.tokenId);
186
+ if (wrapped !== null) {
187
+ return {
188
+ value: wrapped,
189
+ prov: {
190
+ field,
191
+ source: entry.representation,
192
+ status: 'on-chain',
193
+ note: scopeNote(GATEWAY_SOURCE_NOTE, fromCollection),
194
+ },
195
+ };
196
+ }
81
197
  }
82
198
  // on-chain locator carried AS the text value (url, or url-template with {id} substituted).
83
199
  if (entry && (entry.representation === R.url || entry.representation === R.urlTemplate)) {
@@ -88,19 +204,48 @@ async function resolveText(client, fields, field, offChain, fallback, fallbackPr
88
204
  prov: {
89
205
  field,
90
206
  source: entry.representation,
91
- onChain: true,
92
207
  status: 'on-chain',
93
- note: scopeNote(`on-chain ${entry.representation} -> off-chain content`, fromCollection),
208
+ note: scopeNote('stored on chain; the value is a locator', fromCollection),
94
209
  },
95
210
  };
96
211
  }
97
212
  if (offChain != null) {
98
- return { value: offChain, prov: offChainProv(field, entry, offChain) };
213
+ return noteRendererFailure({ value: offChain, prov: offChainProv(field, entry, offChain) });
99
214
  }
100
215
  if (fallback != null) {
101
- return { value: fallback, prov: fallbackProv ?? placeholderProv(field, 'default (no on-chain or off-chain value)') };
216
+ return noteRendererFailure({
217
+ value: fallback,
218
+ prov: fallbackProv ?? placeholderProv(field, 'default (no on-chain or off-chain value)'),
219
+ });
102
220
  }
103
- return { value: null, prov: placeholderProv(field) };
221
+ return noteRendererFailure({ value: null, prov: placeholderProv(field) });
222
+ }
223
+ /**
224
+ * The project's preferred gateway prefix per scheme, resolved ONCE per document.
225
+ *
226
+ * The off-chain twin of `AbxMetadataRenderer._gatewayPrefix` (spec v11): the collection's reserved
227
+ * `abx_gateway_ipfs` / `abx_gateway_arweave` field, else this host's env default, else the public
228
+ * floor. Two conforming resolvers given the same chain must emit the same `image`, so the env read
229
+ * is a FLOOR and never an override — a project that stated a preference on chain gets that
230
+ * preference from every resolver, and a host's own gateway only ever fills a silence (which is the
231
+ * only way a managed provider serving a token it does not own can offer a better default).
232
+ */
233
+ export function projectGateways(state) {
234
+ const env = gatewayConfigFromEnv();
235
+ return {
236
+ ipfs: projectGatewayPrefix(state, 'ipfs', env),
237
+ arweave: projectGatewayPrefix(state, 'arweave', env),
238
+ };
239
+ }
240
+ /** The provenance note for a content-addressed locator. Byte-identical to the deployed renderer's
241
+ * `_sourceNote` for `ipfs`/`arweave` — deliberately "served through", never "verified": the
242
+ * locator is on chain, the bytes are not, and nothing here re-hashes what a gateway returns. */
243
+ const GATEWAY_SOURCE_NOTE = "stored on chain; a content-addressed locator, served through the collection's preferred gateway";
244
+ /** Project a content-addressed field value the way the deployed renderer does, or `null` when the
245
+ * value locates nothing. `tokenId` omitted ⇒ no `{id}` substitution (the collection surface). */
246
+ function gatewayFieldUrl(entry, gateways, tokenId) {
247
+ const network = entry.representation === R.ipfs ? 'ipfs' : 'arweave';
248
+ return projectGatewayUrl(network, inlineText(entry).trim(), gateways[network], tokenId);
104
249
  }
105
250
  /**
106
251
  * Map a locator to the provenance `source` describing where the bytes actually live. Locators are
@@ -125,7 +270,10 @@ function sourceForLocator(locator) {
125
270
  * one (bridged or from the active backend) so the heavy asset resolves peer-to-peer; else
126
271
  * this node's `/…/image` route serves it from custody. Either way it's `anchored` by the
127
272
  * on-chain hash (verify via `/verify`).
128
- * - locator on-chain (`ipfs`/`arweave`/`url`) → the on-chain pointer IS the URL; `on-chain`.
273
+ * - content-addressed on-chain (`ipfs`/`arweave`) → the CID/txid wrapped in the collection's
274
+ * preferred gateway prefix, so what lands in `image` is an `https://` a wallet can render;
275
+ * `on-chain`.
276
+ * - locator on-chain (`url`) → the on-chain pointer IS the URL; `on-chain`.
129
277
  * - unset → this node's `/…/image` route serves the deterministic generative placeholder.
130
278
  */
131
279
  async function resolveImage(state, token, baseUrl, chainId, display, storage) {
@@ -151,7 +299,6 @@ async function resolveImage(state, token, baseUrl, chainId, display, storage) {
151
299
  prov: {
152
300
  field: F.image,
153
301
  source: 'url-template',
154
- onChain: true,
155
302
  status: 'on-chain',
156
303
  note: scopeNote('on-chain url template -> off-chain content', fromCollection),
157
304
  },
@@ -168,7 +315,6 @@ async function resolveImage(state, token, baseUrl, chainId, display, storage) {
168
315
  prov: {
169
316
  field: F.image,
170
317
  source: sourceForLocator(locator),
171
- onChain: false,
172
318
  status: 'anchored',
173
319
  anchor,
174
320
  note: `off-chain bytes (${sourceForLocator(locator)}); on-chain ${anchor} integrity anchor`,
@@ -181,7 +327,6 @@ async function resolveImage(state, token, baseUrl, chainId, display, storage) {
181
327
  prov: {
182
328
  field: F.image,
183
329
  source: anchor,
184
- onChain: false,
185
330
  status: 'anchored',
186
331
  anchor,
187
332
  note: `off-chain bytes (served by this node); on-chain ${anchor} integrity anchor`,
@@ -189,33 +334,47 @@ async function resolveImage(state, token, baseUrl, chainId, display, storage) {
189
334
  };
190
335
  }
191
336
  // 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.
337
+ // at this node's route, which eth_calls the renderer and serves the computed bytes (302-ing to the
338
+ // locator if the computed content turns out to BE one). The provenance is the shared one: the route
339
+ // the bytes took is identical to any other on-chain content, and only the served `image` VALUE is
340
+ // this field's documented exception. A hand-built note here drifted from the deployed renderer's
341
+ // `_sourceNote` wording for the same representation.
193
342
  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
- };
343
+ return { url: nodeUrl, prov: onChainProv(F.image, R.renderer, fromCollection) };
344
+ }
345
+ // Content-addressed ON-CHAIN (ipfs/arweave): the value is identity, not a URL. Wrap it in the
346
+ // collection's preferred gateway — the twin of {AbxMetadataRenderer._resolveImage}'s v11 arm.
347
+ // Before v11 both planes emitted the raw `ipfs://…` here, which no marketplace dereferences, and
348
+ // `--onchain-uri --backend ipfs` worked around it by baking a gateway host into a `url` field.
349
+ if (entry.representation === R.ipfs || entry.representation === R.arweave) {
350
+ const wrapped = gatewayFieldUrl(entry, projectGateways(state), token.tokenId);
351
+ if (wrapped !== null) {
352
+ return {
353
+ url: wrapped,
354
+ prov: {
355
+ field: F.image,
356
+ source: entry.representation,
357
+ status: 'on-chain',
358
+ note: scopeNote(GATEWAY_SOURCE_NOTE, fromCollection),
359
+ },
360
+ };
361
+ }
362
+ // locates nothing → fall through to the node's placeholder route rather than a bare prefix
363
+ return { url: nodeUrl, prov: placeholderProv(F.image, 'placeholder — the committed locator names no content') };
204
364
  }
205
- // Locator committed ON-CHAIN (ipfs/arweave/url): the on-chain value is itself the address.
365
+ // Locator committed ON-CHAIN (url): the on-chain value is itself the address.
206
366
  const onChainLocator = inlineText(entry).trim();
207
367
  return {
208
368
  url: onChainLocator || nodeUrl,
209
369
  prov: {
210
370
  field: F.image,
211
371
  source: entry.representation,
212
- onChain: true,
213
372
  status: 'on-chain',
214
373
  note: scopeNote(`on-chain pointer (${entry.representation}) — bytes off-chain, located on-chain`, fromCollection),
215
374
  },
216
375
  };
217
376
  }
218
- // ── the `artifacts` manifest (specs/protocol/data-plane.md) ───────────────────────
377
+ // ── the `artifacts` manifest (site/content/docs/protocol/data-plane.mdx) ───────────────────────
219
378
  /** The registry's reserved field vocabulary — projection/display keys. Every other field tag a
220
379
  * creator sets is a first-class plane artifact. */
221
380
  const RESERVED_FIELDS = new Set(Object.values(F));
@@ -250,7 +409,11 @@ export async function fieldMimeType(client, state, entry, field, tokenId, displa
250
409
  return (await rendererContentType(client, state.address, entry, field, tokenId)) ?? 'application/octet-stream';
251
410
  }
252
411
  if (rep === R.inline || rep === R.inlineGzip || rep === R.reader || rep === R.readerGzip) {
253
- return field === F.image ? 'image/svg+xml' : 'application/octet-stream';
412
+ // registry conventions for on-chain content bytes: an `image` is SVG, an `animation_url` is
413
+ // the HTML document the renderer wraps as `data:text/html` (spec v4). Anything else: the floor.
414
+ if (field === F.image)
415
+ return 'image/svg+xml';
416
+ return field === F.animationUrl ? 'text/html' : 'application/octet-stream';
254
417
  }
255
418
  if (rep === R.keccak256 || rep === R.sha256) {
256
419
  const stored = storage ? await storage.get(entry.value).catch(() => null) : null;
@@ -260,15 +423,21 @@ export async function fieldMimeType(client, state, entry, field, tokenId, displa
260
423
  return bridged ? contentTypeFromPath(bridged) : 'application/octet-stream';
261
424
  }
262
425
  const raw = inlineText(entry).trim();
426
+ // For ipfs/arweave the extension (if any) rides the stored value — no need to wrap first, and
427
+ // wrapping would only add a gateway host that never carries the extension.
263
428
  return contentTypeFromPath(rep === R.urlTemplate ? applyTemplate(raw, tokenId) : raw);
264
429
  }
265
430
  /** A field artifact's `uri`: locator forms verbatim (content-addressed preferred, `{id}`
266
431
  * substituted); custody-hash forms prefer the bridged durable locator; everything this node
267
432
  * serves itself (inline/reader/renderer) rides the given `/data/{field}` route. Total — every
268
433
  * representation resolves to some uri (the complete-listing rule). */
269
- function fieldArtifactUri(entry, dataRoute, tokenId, display) {
434
+ function fieldArtifactUri(entry, dataRoute, tokenId, display, gateways) {
270
435
  const rep = entry.representation;
271
- if (rep === R.url || rep === R.ipfs || rep === R.arweave)
436
+ // Content-addressed forms carry the same gateway wrap the projected fields get, so a manifest
437
+ // entry and the `image` it duplicates never point at two different URLs for the same bytes.
438
+ if (rep === R.ipfs || rep === R.arweave)
439
+ return gatewayFieldUrl(entry, gateways, tokenId) ?? dataRoute;
440
+ if (rep === R.url)
272
441
  return inlineText(entry).trim() || dataRoute;
273
442
  if (rep === R.urlTemplate)
274
443
  return applyTemplate(inlineText(entry), tokenId);
@@ -283,21 +452,17 @@ function artifactFieldProv(field, entry, fromCollection) {
283
452
  if (rep === R.inline || rep === R.inlineGzip || rep === R.reader || rep === R.readerGzip) {
284
453
  return onChainProv(field, rep, fromCollection);
285
454
  }
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
- }
455
+ // Same call as the branch above — `renderer` is on-chain content like `inline`/`reader`, and the
456
+ // note belongs to `sourceNote` (which mirrors the deployed renderer's `_sourceNote` verbatim). This
457
+ // branch used to hand-build the object with a longer note of its own, so one function delegated in
458
+ // one arm and duplicated in the next.
459
+ if (rep === R.renderer)
460
+ return onChainProv(field, rep, fromCollection);
295
461
  if (rep === R.keccak256 || rep === R.sha256) {
296
462
  const anchor = rep;
297
463
  return {
298
464
  field,
299
465
  source: anchor,
300
- onChain: false,
301
466
  status: 'anchored',
302
467
  anchor,
303
468
  note: `off-chain bytes; on-chain ${anchor} integrity anchor`,
@@ -306,7 +471,6 @@ function artifactFieldProv(field, entry, fromCollection) {
306
471
  return {
307
472
  field,
308
473
  source: rep,
309
- onChain: true,
310
474
  status: 'on-chain',
311
475
  note: scopeNote(`on-chain pointer (${rep}) — bytes off-chain, located on-chain`, fromCollection),
312
476
  };
@@ -352,7 +516,7 @@ async function buildTokenArtifacts(client, state, token, baseUrl, chainId, displ
352
516
  entries.push({
353
517
  key: name,
354
518
  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),
519
+ uri: fieldArtifactUri(entry, `${baseUrl}/t/${chainId}/${state.address}/${token.tokenId}/data/${name}`, token.tokenId, display, projectGateways(state)),
356
520
  });
357
521
  prov.push(artifactFieldProv(name, entry, fromCollection));
358
522
  }
@@ -375,7 +539,6 @@ async function buildTokenArtifacts(client, state, token, baseUrl, chainId, displ
375
539
  prov.push({
376
540
  field: key,
377
541
  source: `effect:${row.effectKey}`,
378
- onChain: false,
379
542
  status: 'off-chain',
380
543
  note: 'derived effect output at the current inputsHash (re-creatable bytes)',
381
544
  });
@@ -383,6 +546,51 @@ async function buildTokenArtifacts(client, state, token, baseUrl, chainId, displ
383
546
  }
384
547
  return { entries, prov };
385
548
  }
549
+ export async function tokenArtifacts(client, state, token, baseUrl, chainId, display = {}, storage, plane) {
550
+ const f = token.fields;
551
+ const cf = state.collectionFields;
552
+ const opts = { collectionFields: cf, tokenId: token.tokenId, gateways: projectGateways(state) };
553
+ // Mirrors buildTokenMetadata's own field resolution exactly (same helpers, same call shape),
554
+ // purely so `buildTokenArtifacts` receives the identical `resolved` shape it gets from the real
555
+ // document build — reusing the filter itself rather than re-deriving it.
556
+ const image = await resolveImage(state, token, baseUrl, chainId, display, storage);
557
+ const animation = await resolveText(client, state.address, f, F.animationUrl, undefined, null, undefined, {
558
+ ...opts,
559
+ uriRoute: `${baseUrl}/t/${chainId}/${state.address}/${token.tokenId}/data/${F.animationUrl}`,
560
+ });
561
+ const currentInputsHash = isCodeProject(state) ? await currentSettledInputsHash(client, state, token).catch(() => null) : null;
562
+ const { entries, prov } = await buildTokenArtifacts(client, state, token, baseUrl, chainId, display, storage, plane, {
563
+ imageUrl: image.url,
564
+ imageEntry: fieldWithFallback(f, cf, F.image).entry,
565
+ animationValue: animation.value,
566
+ animationEntry: animation.value !== null ? fieldWithFallback(f, cf, F.animationUrl).entry : null,
567
+ settledHash: currentInputsHash,
568
+ });
569
+ // The visibility this read ADDS over the manifest: every registered row, current or stale, each
570
+ // labeled. `plane.list` is unfiltered — the manifest's own currency filter runs separately, above
571
+ // via `buildTokenArtifacts` — so recompute each row's OWN current-key match here rather than
572
+ // trusting the manifest's inclusion, which already dropped anything stale.
573
+ const effects = [];
574
+ if (plane && currentInputsHash) {
575
+ const rows = [...plane.list(state.address, token.tokenId)].sort((a, b) => `${a.effectKey}/${a.outputKey}`.localeCompare(`${b.effectKey}/${b.outputKey}`));
576
+ for (const row of rows) {
577
+ const key = `${row.effectKey}/${row.outputKey}`;
578
+ // `state.chainId` (the project's OWN chain), not the `chainId` param (which only shapes served
579
+ // URLs) — matching `buildTokenArtifacts`'/`currentRenderArtifact`'s own key computation exactly.
580
+ const expected = renderArtifactKey(state.chainId, state.address, token.tokenId, currentInputsHash, row.outputKey, row.effectKey);
581
+ effects.push({
582
+ key,
583
+ effectKey: row.effectKey,
584
+ outputKey: row.outputKey,
585
+ status: row.key.toLowerCase() === expected.toLowerCase() ? 'current' : 'stale',
586
+ inputsHash: row.inputsHash ?? null,
587
+ contentType: row.contentType,
588
+ uri: row.locator ?? `${baseUrl}/t/${chainId}/${state.address}/${token.tokenId}/data/${key}`,
589
+ });
590
+ }
591
+ }
592
+ return { entries, prov, effects, planeConsulted: !!plane, currentInputsHash };
593
+ }
386
594
  const looksLikeSvg = (s) => /^\s*<(\?xml|svg)/i.test(s);
387
595
  /** On-chain `attributes` inline-JSON (if any) + the operator's off-chain attributes, stitched
388
596
  * (on-chain wins per trait_type). Returns the merged array + a provenance row, or null when
@@ -408,7 +616,6 @@ function resolveAttributes(fields, offChain, collectionFields, fromRender = fals
408
616
  prov = {
409
617
  field: F.attributes,
410
618
  source: 'inline',
411
- onChain: true,
412
619
  status: 'on-chain',
413
620
  note: fromRender
414
621
  ? 'on-chain inline JSON + render-effect traits (+ operator), stitched (on-chain wins per trait_type)'
@@ -425,7 +632,6 @@ function resolveAttributes(fields, offChain, collectionFields, fromRender = fals
425
632
  prov = {
426
633
  field: F.attributes,
427
634
  source: fromRender ? 'effect:render' : 'off-chain',
428
- onChain: false,
429
635
  status: 'off-chain',
430
636
  note: fromRender
431
637
  ? 'script-reported traits captured at render (+ operator traits; render wins per trait_type)'
@@ -434,7 +640,8 @@ function resolveAttributes(fields, offChain, collectionFields, fromRender = fals
434
640
  }
435
641
  return { attributes: merged, prov };
436
642
  }
437
- /** ERC-721 metadata JSON for a token (the `tokenURI` target) + `abx_provenance` + `artifacts`. */
643
+ /** Token metadata JSON — the `tokenURI` target (ERC-721) / the `uri(id)` target (ERC-1155
644
+ * editions), assembled identically either way + `abx_provenance` + `artifacts`. */
438
645
  export async function buildTokenMetadata(client, state, token, baseUrl, chainId, display = {}, storage,
439
646
  // The effect-artifact registry read surface: rows a producer PUBLISHED to this resolver (or
440
647
  // recorded co-located). Feeds the `artifacts` manifest and the image seam's locator check —
@@ -463,7 +670,6 @@ plane) {
463
670
  image.prov = {
464
671
  field: F.image,
465
672
  source: 'effect:render',
466
- onChain: false,
467
673
  status: 'off-chain',
468
674
  note: viaLocator
469
675
  ? 'render effect output at the current inputsHash, published as a durable locator (resolver 302-redirects)'
@@ -484,12 +690,12 @@ plane) {
484
690
  // on-chain renderer's `_field`), and url-template fields substitute this token's id.
485
691
  const cf = state.collectionFields;
486
692
  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);
693
+ const opts = { collectionFields: cf, tokenId: tid, gateways: projectGateways(state) };
694
+ const name = await resolveText(client, state.address, f, F.name, undefined, `${state.name ?? state.address} #${token.tokenId}`, nameFallbackProv(state.name != null, ' + #id'), opts);
695
+ const description = await resolveText(client, state.address, f, F.description, display.description, null, undefined, opts); // optional → omit if unset
696
+ const externalUrl = await resolveText(client, state.address, f, F.externalUrl, display.externalUrl, null, undefined, opts); // optional → omit if unset
697
+ const backgroundColor = await resolveText(client, state.address, f, F.backgroundColor, undefined, null, undefined, opts);
698
+ const youtubeUrl = await resolveText(client, state.address, f, F.youtubeUrl, undefined, null, undefined, opts);
493
699
  provenance.push(name.prov); // name is required — always present
494
700
  // image: required — always served via our URL; image_data carries inline SVG if on-chain SVG.
495
701
  const img = fieldOf(f, F.image);
@@ -501,8 +707,13 @@ plane) {
501
707
  if (externalUrl.value !== null)
502
708
  provenance.push(externalUrl.prov);
503
709
  // 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);
710
+ // live-view route (suppressible via the `display.animation = none` contract param). On-chain
711
+ // content is served at this node's data route rather than inlined (see `uriRoute`); locator forms
712
+ // pass through untouched. The on-chain renderer still inlines it — it has no route to offer.
713
+ const animation = await resolveText(client, state.address, f, F.animationUrl, undefined, null, undefined, {
714
+ ...opts,
715
+ uriRoute: `${baseUrl}/t/${chainId}/${state.address}/${token.tokenId}/data/${F.animationUrl}`,
716
+ });
506
717
  let animationValue = animation.value;
507
718
  let animationProv = animation.prov;
508
719
  if (animationValue === null && liveViewEnabled(state)) {
@@ -510,7 +721,6 @@ plane) {
510
721
  animationProv = {
511
722
  field: F.animationUrl,
512
723
  source: 'live-view',
513
- onChain: false,
514
724
  status: 'off-chain',
515
725
  note: 'live view derived from the on-chain code (canonical tokenData injected at load)',
516
726
  };
@@ -522,22 +732,27 @@ plane) {
522
732
  // traits (captured at the current inputsHash), which win over the operator's off-chain
523
733
  // traits. ABX facts (version, canonical, royalty) are NOT traits; they live in this
524
734
  // provenance block / ERC-2981, never in the marketplace trait array.
735
+ // `traits` is a BOUND output (`effects.md → Bound vs referenced`): its content is held by this
736
+ // node, so there are exactly two places it can be, and BOTH are hash-gated by the key itself —
737
+ // a param change re-addresses the key, so stale traits go unstitched rather than being attributed
738
+ // to a state they don't depict.
739
+ // - the registry row (a producer REGISTERED it over /v1/effect-artifacts: bytes ride the row),
740
+ // - this node's storage (a CO-LOCATED runner wrote the artifact to the shared backend).
525
741
  let renderTraits = null;
526
- if (isCodeProject(state) && storage) {
742
+ if (isCodeProject(state) && (plane || storage)) {
527
743
  try {
528
- const { key, found } = await currentRenderArtifact(client, state, token, storage, 'traits', {
744
+ const { key, found } = await currentRenderArtifact(client, state, token, storage ?? undefined, 'traits', {
529
745
  hash: settledHash ?? undefined,
530
746
  });
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
- }
747
+ const boundBytes = plane?.get(key)?.bytes ?? null;
748
+ const raw = boundBytes ?? (found ? (await storage?.get(key))?.bytes ?? null : null);
749
+ const parsed = raw ? JSON.parse(new TextDecoder().decode(raw)) : null;
750
+ // scripts report either the OpenSea array or the natural object form
751
+ // (`abx.traits({Palette: 'Dusk'})`) — normalize the latter.
752
+ if (Array.isArray(parsed))
753
+ renderTraits = parsed;
754
+ else if (parsed && typeof parsed === 'object') {
755
+ renderTraits = Object.entries(parsed).map(([trait_type, value]) => ({ trait_type, value: value }));
541
756
  }
542
757
  }
543
758
  catch {
@@ -555,6 +770,13 @@ plane) {
555
770
  provenance.push(backgroundColor.prov);
556
771
  if (youtubeUrl.value)
557
772
  provenance.push(youtubeUrl.prov);
773
+ // NO `abx_params` block. It was emitted here and by the on-chain renderer and read back by
774
+ // nobody — the only consumers in the repo were its own tests. Params already enumerate directly
775
+ // from the contract (`tokenParamKeys` / `tokenParam` / `paramSchemaKeys`), which is canonical and
776
+ // needs no indexer, and a code project's script gets them through `tokenData`. So the projection
777
+ // was a third serialization of data available two better ways: uncapped in size, quadratic to
778
+ // build, and duplicated across two implementations — which is precisely the sibling drift that
779
+ // produced the computed-image-locator bug. Traits that belong to marketplaces go in `attributes`.
558
780
  // the `artifacts` manifest — the plane's complete listing (data-plane.md). Omitted when empty.
559
781
  const artifacts = await buildTokenArtifacts(client, state, token, baseUrl, chainId, display, storage, plane, {
560
782
  imageUrl: image.url,
@@ -592,20 +814,24 @@ export async function buildContractMetadata(client, state, baseUrl, chainId, dis
592
814
  const f = state.collectionFields;
593
815
  const provenance = [];
594
816
  // 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.
817
+ // first token — so "first with an image" surfaces a real work rather than a placeholder.
596
818
  const rep = state.tokens.find((t) => fieldOf(t.fields, F.image)) ?? state.tokens[0];
597
819
  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
820
+ // The collection surface has no token, so a `renderer`-represented field is called with the
821
+ // sentinel id — the same one {AbxMetadataRenderer} passes for its collection reads, so a field
822
+ // renderer that serves both scopes sees an identical call from chain and from here.
823
+ const collOpts = { tokenId: COLLECTION_TOKEN_ID, gateways: projectGateways(state) };
824
+ // name is required (fallback = the on-chain collection name); description + external_link
599
825
  // 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);
826
+ const name = await resolveText(client, state.address, f, F.name, undefined, state.name ?? state.address, nameFallbackProv(state.name != null, ''), collOpts);
827
+ const description = await resolveText(client, state.address, f, F.description, display.description, null, undefined, collOpts);
828
+ const externalLink = await resolveText(client, state.address, f, F.externalLink, display.externalUrl, null, undefined, collOpts);
603
829
  // authorship + rights (collection scope) — reserved fields, on-chain-only (no operator source):
604
830
  // 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);
831
+ const creator = await resolveText(client, state.address, f, F.creator, undefined, null, undefined, collOpts);
832
+ const displayNotes = await resolveText(client, state.address, f, F.displayNotes, undefined, null, undefined, collOpts);
833
+ const creatorLinks = await resolveText(client, state.address, f, F.creatorLinks, undefined, null, undefined, collOpts);
834
+ const license = await resolveText(client, state.address, f, F.license, undefined, null, undefined, collOpts);
609
835
  provenance.push(name.prov);
610
836
  if (image)
611
837
  provenance.push(image.prov);
@@ -613,18 +839,28 @@ export async function buildContractMetadata(client, state, baseUrl, chainId, dis
613
839
  provenance.push(description.prov);
614
840
  if (externalLink.value !== null)
615
841
  provenance.push(externalLink.prov);
616
- if (artist.value !== null)
617
- provenance.push(artist.prov);
842
+ if (creator.value !== null)
843
+ provenance.push(creator.prov);
618
844
  if (displayNotes.value !== null)
619
845
  provenance.push(displayNotes.prov);
620
- if (artistLinks.value !== null)
621
- provenance.push(artistLinks.prov);
846
+ if (creatorLinks.value !== null)
847
+ provenance.push(creatorLinks.prov);
622
848
  if (license.value !== null)
623
849
  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));
850
+ // The two reserved collection IMAGE keys. Both are top-level here, and both are top-level on the
851
+ // on-chain renderer as of spec v10 — the point of the three-list table in
852
+ // site/content/docs/protocol/metadata.mdx is that a key BOTH planes can emit must not be emitted by only
853
+ // one of them. Two things were wrong before v10: `featured_image` appeared in `artifacts` alone
854
+ // (reserved, so filtered out of the top level, yet still listed — "reserved but only in artifacts",
855
+ // which is neither), and `banner_image` was top-level only for `representation === url`, so an
856
+ // `inline` or `reader` banner silently dropped even though both resolve fine. Every chain-reachable
857
+ // representation now counts, matching `_appendContractText` on the renderer.
858
+ const bannerImage = await resolveText(client, state.address, f, F.bannerImage, undefined, null, undefined, collOpts);
859
+ const featuredImage = await resolveText(client, state.address, f, F.featuredImage, undefined, null, undefined, collOpts);
860
+ if (bannerImage.value !== null)
861
+ provenance.push(bannerImage.prov);
862
+ if (featuredImage.value !== null)
863
+ provenance.push(featuredImage.prov);
628
864
  // the collection-scope `artifacts` manifest — content-bearing collection fields (banner,
629
865
  // featured image, plus any non-reserved field). The representative-token image is a projection
630
866
  // courtesy, not a collection artifact; a collection-scope `image` doubles as the per-token
@@ -641,7 +877,7 @@ export async function buildContractMetadata(client, state, baseUrl, chainId, dis
641
877
  key: n,
642
878
  // collection surface has no token — a field renderer gets the sentinel id (mirrors on-chain)
643
879
  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),
880
+ uri: fieldArtifactUri(entry, `${baseUrl}/c/${chainId}/${state.address}/data/${n}`, '0', display, collOpts.gateways),
645
881
  });
646
882
  if (!RESERVED_FIELDS.has(n))
647
883
  provenance.push(artifactFieldProv(n, entry, false));
@@ -656,16 +892,18 @@ export async function buildContractMetadata(client, state, baseUrl, chainId, dis
656
892
  json.description = description.value;
657
893
  if (externalLink.value !== null)
658
894
  json.external_link = externalLink.value;
659
- if (artist.value !== null)
660
- json.artist = artist.value;
895
+ if (creator.value !== null)
896
+ json.creator = creator.value;
661
897
  if (displayNotes.value !== null)
662
898
  json.display_notes = displayNotes.value;
663
- if (artistLinks.value !== null)
664
- json.artist_links = artistLinks.value;
899
+ if (creatorLinks.value !== null)
900
+ json.creator_links = creatorLinks.value;
665
901
  if (license.value !== null)
666
902
  json.license = license.value;
667
- if (bannerImage)
668
- json.banner_image = bannerImage;
903
+ if (bannerImage.value !== null)
904
+ json.banner_image = bannerImage.value;
905
+ if (featuredImage.value !== null)
906
+ json.featured_image = featuredImage.value;
669
907
  if (artifacts.length)
670
908
  json.artifacts = artifacts;
671
909
  return json;