@artblocks/abx-token-api 0.1.0-alpha.2 → 0.1.0-alpha.20

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 (50) hide show
  1. package/dist/code.d.ts +33 -12
  2. package/dist/code.d.ts.map +1 -1
  3. package/dist/code.js +45 -55
  4. package/dist/code.js.map +1 -1
  5. package/dist/{art.d.ts → content.d.ts} +5 -5
  6. package/dist/content.d.ts.map +1 -0
  7. package/dist/{art.js → content.js} +6 -6
  8. package/dist/content.js.map +1 -0
  9. package/dist/control-plane.d.ts +85 -0
  10. package/dist/control-plane.d.ts.map +1 -0
  11. package/dist/control-plane.js +721 -0
  12. package/dist/control-plane.js.map +1 -0
  13. package/dist/dashboard.d.ts +1 -1
  14. package/dist/dashboard.d.ts.map +1 -1
  15. package/dist/dashboard.js +39 -16
  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 +7 -7
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +12 -10
  24. package/dist/index.js.map +1 -1
  25. package/dist/metadata.d.ts +29 -2
  26. package/dist/metadata.d.ts.map +1 -1
  27. package/dist/metadata.js +298 -104
  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 -27
  34. package/dist/server.d.ts.map +1 -1
  35. package/dist/server.js +251 -418
  36. package/dist/server.js.map +1 -1
  37. package/dist/watcher.d.ts.map +1 -1
  38. package/dist/watcher.js +137 -8
  39. package/dist/watcher.js.map +1 -1
  40. package/package.json +4 -4
  41. package/dist/abxjs.d.ts +0 -13
  42. package/dist/abxjs.d.ts.map +0 -1
  43. package/dist/abxjs.js +0 -49
  44. package/dist/abxjs.js.map +0 -1
  45. package/dist/art.d.ts.map +0 -1
  46. package/dist/art.js.map +0 -1
  47. package/dist/inline.d.ts +0 -19
  48. package/dist/inline.d.ts.map +0 -1
  49. package/dist/inline.js +0 -23
  50. 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,134 @@ 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
+ // (Found by abx-services reading this function, not by a test: nothing observable differed.)
165
+ return { value, prov: onChainProv(field, R.renderer, fromCollection) };
166
+ }
167
+ }
168
+ catch (err) {
169
+ // `IAbxFieldRenderer`'s first invariant is NEVER REVERT, but a resolver cannot rely on a
170
+ // third-party contract honouring it — a reverting renderer must degrade this one field, not
171
+ // 500 the whole token's metadata. We still fall through to the operator's off-chain value
172
+ // (better than nothing), but the provenance says the on-chain attempt failed rather than
173
+ // quietly reporting `off-chain` as if nothing were committed on-chain.
174
+ rendererFailure = err.message;
175
+ console.warn(`[metadata] field renderer for '${field}' on ${tokenAddress} reverted: ${rendererFailure}`);
176
+ }
177
+ }
178
+ const noteRendererFailure = (out) => rendererFailure === null
179
+ ? out
180
+ : { ...out, prov: { ...out.prov, note: `${out.prov.note} — on-chain field renderer reverted, so it could not be used` } };
181
+ // Content-addressed locator (`ipfs`/`arweave`): the CID/txid is identity, the collection's
182
+ // preferred gateway is what makes it dereferenceable. Mirrors {AbxMetadataRenderer._appendText}'s
183
+ // v11 arm. A value that locates nothing (`ipfs://` with no CID) falls through and omits, rather
184
+ // than emitting a bare gateway prefix that 404s.
185
+ if (entry && (entry.representation === R.ipfs || entry.representation === R.arweave)) {
186
+ const wrapped = gatewayFieldUrl(entry, opts.gateways, opts.tokenId === COLLECTION_TOKEN_ID ? undefined : opts.tokenId);
187
+ if (wrapped !== null) {
188
+ return {
189
+ value: wrapped,
190
+ prov: {
191
+ field,
192
+ source: entry.representation,
193
+ status: 'on-chain',
194
+ note: scopeNote(GATEWAY_SOURCE_NOTE, fromCollection),
195
+ },
196
+ };
197
+ }
81
198
  }
82
199
  // on-chain locator carried AS the text value (url, or url-template with {id} substituted).
83
200
  if (entry && (entry.representation === R.url || entry.representation === R.urlTemplate)) {
@@ -88,19 +205,48 @@ async function resolveText(client, fields, field, offChain, fallback, fallbackPr
88
205
  prov: {
89
206
  field,
90
207
  source: entry.representation,
91
- onChain: true,
92
208
  status: 'on-chain',
93
- note: scopeNote(`on-chain ${entry.representation} -> off-chain content`, fromCollection),
209
+ note: scopeNote('stored on chain; the value is a locator', fromCollection),
94
210
  },
95
211
  };
96
212
  }
97
213
  if (offChain != null) {
98
- return { value: offChain, prov: offChainProv(field, entry, offChain) };
214
+ return noteRendererFailure({ value: offChain, prov: offChainProv(field, entry, offChain) });
99
215
  }
100
216
  if (fallback != null) {
101
- return { value: fallback, prov: fallbackProv ?? placeholderProv(field, 'default (no on-chain or off-chain value)') };
217
+ return noteRendererFailure({
218
+ value: fallback,
219
+ prov: fallbackProv ?? placeholderProv(field, 'default (no on-chain or off-chain value)'),
220
+ });
102
221
  }
103
- return { value: null, prov: placeholderProv(field) };
222
+ return noteRendererFailure({ value: null, prov: placeholderProv(field) });
223
+ }
224
+ /**
225
+ * The project's preferred gateway prefix per scheme, resolved ONCE per document.
226
+ *
227
+ * The off-chain twin of `AbxMetadataRenderer._gatewayPrefix` (spec v11): the collection's reserved
228
+ * `abx_gateway_ipfs` / `abx_gateway_arweave` field, else this host's env default, else the public
229
+ * floor. Two conforming resolvers given the same chain must emit the same `image`, so the env read
230
+ * is a FLOOR and never an override — a project that stated a preference on chain gets that
231
+ * preference from every resolver, and a host's own gateway only ever fills a silence (which is the
232
+ * only way a managed provider serving a token it does not own can offer a better default).
233
+ */
234
+ export function projectGateways(state) {
235
+ const env = gatewayConfigFromEnv();
236
+ return {
237
+ ipfs: projectGatewayPrefix(state, 'ipfs', env),
238
+ arweave: projectGatewayPrefix(state, 'arweave', env),
239
+ };
240
+ }
241
+ /** The provenance note for a content-addressed locator. Byte-identical to the deployed renderer's
242
+ * `_sourceNote` for `ipfs`/`arweave` — deliberately "served through", never "verified": the
243
+ * locator is on chain, the bytes are not, and nothing here re-hashes what a gateway returns. */
244
+ const GATEWAY_SOURCE_NOTE = "stored on chain; a content-addressed locator, served through the collection's preferred gateway";
245
+ /** Project a content-addressed field value the way the deployed renderer does, or `null` when the
246
+ * value locates nothing. `tokenId` omitted ⇒ no `{id}` substitution (the collection surface). */
247
+ function gatewayFieldUrl(entry, gateways, tokenId) {
248
+ const network = entry.representation === R.ipfs ? 'ipfs' : 'arweave';
249
+ return projectGatewayUrl(network, inlineText(entry).trim(), gateways[network], tokenId);
104
250
  }
105
251
  /**
106
252
  * Map a locator to the provenance `source` describing where the bytes actually live. Locators are
@@ -125,7 +271,10 @@ function sourceForLocator(locator) {
125
271
  * one (bridged or from the active backend) so the heavy asset resolves peer-to-peer; else
126
272
  * this node's `/…/image` route serves it from custody. Either way it's `anchored` by the
127
273
  * on-chain hash (verify via `/verify`).
128
- * - locator on-chain (`ipfs`/`arweave`/`url`) → the on-chain pointer IS the URL; `on-chain`.
274
+ * - content-addressed on-chain (`ipfs`/`arweave`) → the CID/txid wrapped in the collection's
275
+ * preferred gateway prefix, so what lands in `image` is an `https://` a wallet can render;
276
+ * `on-chain`.
277
+ * - locator on-chain (`url`) → the on-chain pointer IS the URL; `on-chain`.
129
278
  * - unset → this node's `/…/image` route serves the deterministic generative placeholder.
130
279
  */
131
280
  async function resolveImage(state, token, baseUrl, chainId, display, storage) {
@@ -151,7 +300,6 @@ async function resolveImage(state, token, baseUrl, chainId, display, storage) {
151
300
  prov: {
152
301
  field: F.image,
153
302
  source: 'url-template',
154
- onChain: true,
155
303
  status: 'on-chain',
156
304
  note: scopeNote('on-chain url template -> off-chain content', fromCollection),
157
305
  },
@@ -168,7 +316,6 @@ async function resolveImage(state, token, baseUrl, chainId, display, storage) {
168
316
  prov: {
169
317
  field: F.image,
170
318
  source: sourceForLocator(locator),
171
- onChain: false,
172
319
  status: 'anchored',
173
320
  anchor,
174
321
  note: `off-chain bytes (${sourceForLocator(locator)}); on-chain ${anchor} integrity anchor`,
@@ -181,7 +328,6 @@ async function resolveImage(state, token, baseUrl, chainId, display, storage) {
181
328
  prov: {
182
329
  field: F.image,
183
330
  source: anchor,
184
- onChain: false,
185
331
  status: 'anchored',
186
332
  anchor,
187
333
  note: `off-chain bytes (served by this node); on-chain ${anchor} integrity anchor`,
@@ -189,27 +335,41 @@ async function resolveImage(state, token, baseUrl, chainId, display, storage) {
189
335
  };
190
336
  }
191
337
  // 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.
338
+ // at this node's route, which eth_calls the renderer and serves the computed bytes (302-ing to the
339
+ // locator if the computed content turns out to BE one). The provenance is the shared one: the route
340
+ // the bytes took is identical to any other on-chain content, and only the served `image` VALUE is
341
+ // this field's documented exception. A hand-built note here drifted from the deployed renderer's
342
+ // `_sourceNote` wording for the same representation.
193
343
  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
- };
344
+ return { url: nodeUrl, prov: onChainProv(F.image, R.renderer, fromCollection) };
204
345
  }
205
- // Locator committed ON-CHAIN (ipfs/arweave/url): the on-chain value is itself the address.
346
+ // Content-addressed ON-CHAIN (ipfs/arweave): the value is identity, not a URL. Wrap it in the
347
+ // collection's preferred gateway — the twin of {AbxMetadataRenderer._resolveImage}'s v11 arm.
348
+ // Before v11 both planes emitted the raw `ipfs://…` here, which no marketplace dereferences, and
349
+ // `--onchain-uri --backend ipfs` worked around it by baking a gateway host into a `url` field.
350
+ if (entry.representation === R.ipfs || entry.representation === R.arweave) {
351
+ const wrapped = gatewayFieldUrl(entry, projectGateways(state), token.tokenId);
352
+ if (wrapped !== null) {
353
+ return {
354
+ url: wrapped,
355
+ prov: {
356
+ field: F.image,
357
+ source: entry.representation,
358
+ status: 'on-chain',
359
+ note: scopeNote(GATEWAY_SOURCE_NOTE, fromCollection),
360
+ },
361
+ };
362
+ }
363
+ // locates nothing → fall through to the node's placeholder route rather than a bare prefix
364
+ return { url: nodeUrl, prov: placeholderProv(F.image, 'placeholder — the committed locator names no content') };
365
+ }
366
+ // Locator committed ON-CHAIN (url): the on-chain value is itself the address.
206
367
  const onChainLocator = inlineText(entry).trim();
207
368
  return {
208
369
  url: onChainLocator || nodeUrl,
209
370
  prov: {
210
371
  field: F.image,
211
372
  source: entry.representation,
212
- onChain: true,
213
373
  status: 'on-chain',
214
374
  note: scopeNote(`on-chain pointer (${entry.representation}) — bytes off-chain, located on-chain`, fromCollection),
215
375
  },
@@ -250,7 +410,11 @@ export async function fieldMimeType(client, state, entry, field, tokenId, displa
250
410
  return (await rendererContentType(client, state.address, entry, field, tokenId)) ?? 'application/octet-stream';
251
411
  }
252
412
  if (rep === R.inline || rep === R.inlineGzip || rep === R.reader || rep === R.readerGzip) {
253
- return field === F.image ? 'image/svg+xml' : 'application/octet-stream';
413
+ // registry conventions for on-chain content bytes: an `image` is SVG, an `animation_url` is
414
+ // the HTML document the renderer wraps as `data:text/html` (spec v4). Anything else: the floor.
415
+ if (field === F.image)
416
+ return 'image/svg+xml';
417
+ return field === F.animationUrl ? 'text/html' : 'application/octet-stream';
254
418
  }
255
419
  if (rep === R.keccak256 || rep === R.sha256) {
256
420
  const stored = storage ? await storage.get(entry.value).catch(() => null) : null;
@@ -260,15 +424,21 @@ export async function fieldMimeType(client, state, entry, field, tokenId, displa
260
424
  return bridged ? contentTypeFromPath(bridged) : 'application/octet-stream';
261
425
  }
262
426
  const raw = inlineText(entry).trim();
427
+ // For ipfs/arweave the extension (if any) rides the stored value — no need to wrap first, and
428
+ // wrapping would only add a gateway host that never carries the extension.
263
429
  return contentTypeFromPath(rep === R.urlTemplate ? applyTemplate(raw, tokenId) : raw);
264
430
  }
265
431
  /** A field artifact's `uri`: locator forms verbatim (content-addressed preferred, `{id}`
266
432
  * substituted); custody-hash forms prefer the bridged durable locator; everything this node
267
433
  * serves itself (inline/reader/renderer) rides the given `/data/{field}` route. Total — every
268
434
  * representation resolves to some uri (the complete-listing rule). */
269
- function fieldArtifactUri(entry, dataRoute, tokenId, display) {
435
+ function fieldArtifactUri(entry, dataRoute, tokenId, display, gateways) {
270
436
  const rep = entry.representation;
271
- if (rep === R.url || rep === R.ipfs || rep === R.arweave)
437
+ // Content-addressed forms carry the same gateway wrap the projected fields get, so a manifest
438
+ // entry and the `image` it duplicates never point at two different URLs for the same bytes.
439
+ if (rep === R.ipfs || rep === R.arweave)
440
+ return gatewayFieldUrl(entry, gateways, tokenId) ?? dataRoute;
441
+ if (rep === R.url)
272
442
  return inlineText(entry).trim() || dataRoute;
273
443
  if (rep === R.urlTemplate)
274
444
  return applyTemplate(inlineText(entry), tokenId);
@@ -283,21 +453,17 @@ function artifactFieldProv(field, entry, fromCollection) {
283
453
  if (rep === R.inline || rep === R.inlineGzip || rep === R.reader || rep === R.readerGzip) {
284
454
  return onChainProv(field, rep, fromCollection);
285
455
  }
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
- }
456
+ // Same call as the branch above — `renderer` is on-chain content like `inline`/`reader`, and the
457
+ // note belongs to `sourceNote` (which mirrors the deployed renderer's `_sourceNote` verbatim). This
458
+ // branch used to hand-build the object with a longer note of its own, so one function delegated in
459
+ // one arm and duplicated in the next.
460
+ if (rep === R.renderer)
461
+ return onChainProv(field, rep, fromCollection);
295
462
  if (rep === R.keccak256 || rep === R.sha256) {
296
463
  const anchor = rep;
297
464
  return {
298
465
  field,
299
466
  source: anchor,
300
- onChain: false,
301
467
  status: 'anchored',
302
468
  anchor,
303
469
  note: `off-chain bytes; on-chain ${anchor} integrity anchor`,
@@ -306,7 +472,6 @@ function artifactFieldProv(field, entry, fromCollection) {
306
472
  return {
307
473
  field,
308
474
  source: rep,
309
- onChain: true,
310
475
  status: 'on-chain',
311
476
  note: scopeNote(`on-chain pointer (${rep}) — bytes off-chain, located on-chain`, fromCollection),
312
477
  };
@@ -352,7 +517,7 @@ async function buildTokenArtifacts(client, state, token, baseUrl, chainId, displ
352
517
  entries.push({
353
518
  key: name,
354
519
  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),
520
+ uri: fieldArtifactUri(entry, `${baseUrl}/t/${chainId}/${state.address}/${token.tokenId}/data/${name}`, token.tokenId, display, projectGateways(state)),
356
521
  });
357
522
  prov.push(artifactFieldProv(name, entry, fromCollection));
358
523
  }
@@ -375,7 +540,6 @@ async function buildTokenArtifacts(client, state, token, baseUrl, chainId, displ
375
540
  prov.push({
376
541
  field: key,
377
542
  source: `effect:${row.effectKey}`,
378
- onChain: false,
379
543
  status: 'off-chain',
380
544
  note: 'derived effect output at the current inputsHash (re-creatable bytes)',
381
545
  });
@@ -408,7 +572,6 @@ function resolveAttributes(fields, offChain, collectionFields, fromRender = fals
408
572
  prov = {
409
573
  field: F.attributes,
410
574
  source: 'inline',
411
- onChain: true,
412
575
  status: 'on-chain',
413
576
  note: fromRender
414
577
  ? 'on-chain inline JSON + render-effect traits (+ operator), stitched (on-chain wins per trait_type)'
@@ -425,7 +588,6 @@ function resolveAttributes(fields, offChain, collectionFields, fromRender = fals
425
588
  prov = {
426
589
  field: F.attributes,
427
590
  source: fromRender ? 'effect:render' : 'off-chain',
428
- onChain: false,
429
591
  status: 'off-chain',
430
592
  note: fromRender
431
593
  ? 'script-reported traits captured at render (+ operator traits; render wins per trait_type)'
@@ -434,7 +596,8 @@ function resolveAttributes(fields, offChain, collectionFields, fromRender = fals
434
596
  }
435
597
  return { attributes: merged, prov };
436
598
  }
437
- /** ERC-721 metadata JSON for a token (the `tokenURI` target) + `abx_provenance` + `artifacts`. */
599
+ /** Token metadata JSON — the `tokenURI` target (ERC-721) / the `uri(id)` target (ERC-1155
600
+ * editions), assembled identically either way + `abx_provenance` + `artifacts`. */
438
601
  export async function buildTokenMetadata(client, state, token, baseUrl, chainId, display = {}, storage,
439
602
  // The effect-artifact registry read surface: rows a producer PUBLISHED to this resolver (or
440
603
  // recorded co-located). Feeds the `artifacts` manifest and the image seam's locator check —
@@ -463,7 +626,6 @@ plane) {
463
626
  image.prov = {
464
627
  field: F.image,
465
628
  source: 'effect:render',
466
- onChain: false,
467
629
  status: 'off-chain',
468
630
  note: viaLocator
469
631
  ? 'render effect output at the current inputsHash, published as a durable locator (resolver 302-redirects)'
@@ -484,12 +646,12 @@ plane) {
484
646
  // on-chain renderer's `_field`), and url-template fields substitute this token's id.
485
647
  const cf = state.collectionFields;
486
648
  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);
649
+ const opts = { collectionFields: cf, tokenId: tid, gateways: projectGateways(state) };
650
+ const name = await resolveText(client, state.address, f, F.name, undefined, `${state.name ?? state.address} #${token.tokenId}`, nameFallbackProv(state.name != null, ' + #id'), opts);
651
+ const description = await resolveText(client, state.address, f, F.description, display.description, null, undefined, opts); // optional → omit if unset
652
+ const externalUrl = await resolveText(client, state.address, f, F.externalUrl, display.externalUrl, null, undefined, opts); // optional → omit if unset
653
+ const backgroundColor = await resolveText(client, state.address, f, F.backgroundColor, undefined, null, undefined, opts);
654
+ const youtubeUrl = await resolveText(client, state.address, f, F.youtubeUrl, undefined, null, undefined, opts);
493
655
  provenance.push(name.prov); // name is required — always present
494
656
  // image: required — always served via our URL; image_data carries inline SVG if on-chain SVG.
495
657
  const img = fieldOf(f, F.image);
@@ -501,8 +663,13 @@ plane) {
501
663
  if (externalUrl.value !== null)
502
664
  provenance.push(externalUrl.prov);
503
665
  // 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);
666
+ // live-view route (suppressible via the `display.animation = none` contract param). On-chain
667
+ // content is served at this node's data route rather than inlined (see `uriRoute`); locator forms
668
+ // pass through untouched. The on-chain renderer still inlines it — it has no route to offer.
669
+ const animation = await resolveText(client, state.address, f, F.animationUrl, undefined, null, undefined, {
670
+ ...opts,
671
+ uriRoute: `${baseUrl}/t/${chainId}/${state.address}/${token.tokenId}/data/${F.animationUrl}`,
672
+ });
506
673
  let animationValue = animation.value;
507
674
  let animationProv = animation.prov;
508
675
  if (animationValue === null && liveViewEnabled(state)) {
@@ -510,7 +677,6 @@ plane) {
510
677
  animationProv = {
511
678
  field: F.animationUrl,
512
679
  source: 'live-view',
513
- onChain: false,
514
680
  status: 'off-chain',
515
681
  note: 'live view derived from the on-chain code (canonical tokenData injected at load)',
516
682
  };
@@ -522,22 +688,27 @@ plane) {
522
688
  // traits (captured at the current inputsHash), which win over the operator's off-chain
523
689
  // traits. ABX facts (version, canonical, royalty) are NOT traits; they live in this
524
690
  // provenance block / ERC-2981, never in the marketplace trait array.
691
+ // `traits` is a BOUND output (`effects.md → Bound vs referenced`): its content is held by this
692
+ // node, so there are exactly two places it can be, and BOTH are hash-gated by the key itself —
693
+ // a param change re-addresses the key, so stale traits go unstitched rather than being attributed
694
+ // to a state they don't depict.
695
+ // - the registry row (a producer REGISTERED it over /v1/effect-artifacts: bytes ride the row),
696
+ // - this node's storage (a CO-LOCATED runner wrote the artifact to the shared backend).
525
697
  let renderTraits = null;
526
- if (isCodeProject(state) && storage) {
698
+ if (isCodeProject(state) && (plane || storage)) {
527
699
  try {
528
- const { key, found } = await currentRenderArtifact(client, state, token, storage, 'traits', {
700
+ const { key, found } = await currentRenderArtifact(client, state, token, storage ?? undefined, 'traits', {
529
701
  hash: settledHash ?? undefined,
530
702
  });
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
- }
703
+ const boundBytes = plane?.get(key)?.bytes ?? null;
704
+ const raw = boundBytes ?? (found ? (await storage?.get(key))?.bytes ?? null : null);
705
+ const parsed = raw ? JSON.parse(new TextDecoder().decode(raw)) : null;
706
+ // scripts report either the OpenSea array or the natural object form
707
+ // (`abx.traits({Palette: 'Dusk'})`) — normalize the latter.
708
+ if (Array.isArray(parsed))
709
+ renderTraits = parsed;
710
+ else if (parsed && typeof parsed === 'object') {
711
+ renderTraits = Object.entries(parsed).map(([trait_type, value]) => ({ trait_type, value: value }));
541
712
  }
542
713
  }
543
714
  catch {
@@ -555,6 +726,13 @@ plane) {
555
726
  provenance.push(backgroundColor.prov);
556
727
  if (youtubeUrl.value)
557
728
  provenance.push(youtubeUrl.prov);
729
+ // NO `abx_params` block. It was emitted here and by the on-chain renderer and read back by
730
+ // nobody — the only consumers in the repo were its own tests. Params already enumerate directly
731
+ // from the contract (`tokenParamKeys` / `tokenParam` / `paramSchemaKeys`), which is canonical and
732
+ // needs no indexer, and a code project's script gets them through `tokenData`. So the projection
733
+ // was a third serialization of data available two better ways: uncapped in size, quadratic to
734
+ // build, and duplicated across two implementations — which is precisely the sibling drift that
735
+ // produced the computed-image-locator bug. Traits that belong to marketplaces go in `attributes`.
558
736
  // the `artifacts` manifest — the plane's complete listing (data-plane.md). Omitted when empty.
559
737
  const artifacts = await buildTokenArtifacts(client, state, token, baseUrl, chainId, display, storage, plane, {
560
738
  imageUrl: image.url,
@@ -592,20 +770,24 @@ export async function buildContractMetadata(client, state, baseUrl, chainId, dis
592
770
  const f = state.collectionFields;
593
771
  const provenance = [];
594
772
  // 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.
773
+ // first token — so "first with an image" surfaces a real work rather than a placeholder.
596
774
  const rep = state.tokens.find((t) => fieldOf(t.fields, F.image)) ?? state.tokens[0];
597
775
  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
776
+ // The collection surface has no token, so a `renderer`-represented field is called with the
777
+ // sentinel id — the same one {AbxMetadataRenderer} passes for its collection reads, so a field
778
+ // renderer that serves both scopes sees an identical call from chain and from here.
779
+ const collOpts = { tokenId: COLLECTION_TOKEN_ID, gateways: projectGateways(state) };
780
+ // name is required (fallback = the on-chain collection name); description + external_link
599
781
  // 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);
782
+ const name = await resolveText(client, state.address, f, F.name, undefined, state.name ?? state.address, nameFallbackProv(state.name != null, ''), collOpts);
783
+ const description = await resolveText(client, state.address, f, F.description, display.description, null, undefined, collOpts);
784
+ const externalLink = await resolveText(client, state.address, f, F.externalLink, display.externalUrl, null, undefined, collOpts);
603
785
  // authorship + rights (collection scope) — reserved fields, on-chain-only (no operator source):
604
786
  // 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);
787
+ const creator = await resolveText(client, state.address, f, F.creator, undefined, null, undefined, collOpts);
788
+ const displayNotes = await resolveText(client, state.address, f, F.displayNotes, undefined, null, undefined, collOpts);
789
+ const creatorLinks = await resolveText(client, state.address, f, F.creatorLinks, undefined, null, undefined, collOpts);
790
+ const license = await resolveText(client, state.address, f, F.license, undefined, null, undefined, collOpts);
609
791
  provenance.push(name.prov);
610
792
  if (image)
611
793
  provenance.push(image.prov);
@@ -613,18 +795,28 @@ export async function buildContractMetadata(client, state, baseUrl, chainId, dis
613
795
  provenance.push(description.prov);
614
796
  if (externalLink.value !== null)
615
797
  provenance.push(externalLink.prov);
616
- if (artist.value !== null)
617
- provenance.push(artist.prov);
798
+ if (creator.value !== null)
799
+ provenance.push(creator.prov);
618
800
  if (displayNotes.value !== null)
619
801
  provenance.push(displayNotes.prov);
620
- if (artistLinks.value !== null)
621
- provenance.push(artistLinks.prov);
802
+ if (creatorLinks.value !== null)
803
+ provenance.push(creatorLinks.prov);
622
804
  if (license.value !== null)
623
805
  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));
806
+ // The two reserved collection IMAGE keys. Both are top-level here, and both are top-level on the
807
+ // on-chain renderer as of spec v10 — the point of the three-list table in
808
+ // specs/protocol/onchain-metadata.md is that a key BOTH planes can emit must not be emitted by only
809
+ // one of them. Two things were wrong before v10: `featured_image` appeared in `artifacts` alone
810
+ // (reserved, so filtered out of the top level, yet still listed — "reserved but only in artifacts",
811
+ // which is neither), and `banner_image` was top-level only for `representation === url`, so an
812
+ // `inline` or `reader` banner silently dropped even though both resolve fine. Every chain-reachable
813
+ // representation now counts, matching `_appendContractText` on the renderer.
814
+ const bannerImage = await resolveText(client, state.address, f, F.bannerImage, undefined, null, undefined, collOpts);
815
+ const featuredImage = await resolveText(client, state.address, f, F.featuredImage, undefined, null, undefined, collOpts);
816
+ if (bannerImage.value !== null)
817
+ provenance.push(bannerImage.prov);
818
+ if (featuredImage.value !== null)
819
+ provenance.push(featuredImage.prov);
628
820
  // the collection-scope `artifacts` manifest — content-bearing collection fields (banner,
629
821
  // featured image, plus any non-reserved field). The representative-token image is a projection
630
822
  // courtesy, not a collection artifact; a collection-scope `image` doubles as the per-token
@@ -641,7 +833,7 @@ export async function buildContractMetadata(client, state, baseUrl, chainId, dis
641
833
  key: n,
642
834
  // collection surface has no token — a field renderer gets the sentinel id (mirrors on-chain)
643
835
  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),
836
+ uri: fieldArtifactUri(entry, `${baseUrl}/c/${chainId}/${state.address}/data/${n}`, '0', display, collOpts.gateways),
645
837
  });
646
838
  if (!RESERVED_FIELDS.has(n))
647
839
  provenance.push(artifactFieldProv(n, entry, false));
@@ -656,16 +848,18 @@ export async function buildContractMetadata(client, state, baseUrl, chainId, dis
656
848
  json.description = description.value;
657
849
  if (externalLink.value !== null)
658
850
  json.external_link = externalLink.value;
659
- if (artist.value !== null)
660
- json.artist = artist.value;
851
+ if (creator.value !== null)
852
+ json.creator = creator.value;
661
853
  if (displayNotes.value !== null)
662
854
  json.display_notes = displayNotes.value;
663
- if (artistLinks.value !== null)
664
- json.artist_links = artistLinks.value;
855
+ if (creatorLinks.value !== null)
856
+ json.creator_links = creatorLinks.value;
665
857
  if (license.value !== null)
666
858
  json.license = license.value;
667
- if (bannerImage)
668
- json.banner_image = bannerImage;
859
+ if (bannerImage.value !== null)
860
+ json.banner_image = bannerImage.value;
861
+ if (featuredImage.value !== null)
862
+ json.featured_image = featuredImage.value;
669
863
  if (artifacts.length)
670
864
  json.artifacts = artifacts;
671
865
  return json;