@artblocks/abx-cli 0.1.0-alpha.23 → 0.1.0-alpha.25

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.
@@ -15,7 +15,7 @@
15
15
  import { createHash } from 'node:crypto';
16
16
  import { readFileSync, readdirSync, statSync } from 'node:fs';
17
17
  import { basename, extname, join as joinPath, resolve as resolvePath } from 'node:path';
18
- import { DEFAULT_CHAIN_KEY, DEP_RESOLUTION, METADATA_FIELD as F, METADATA_REPRESENTATION as R, analyzeScript, assertChainId, checkRegistryDeps, dependencySetupCalls, deployOneOfOne, deploySeries, deployOneOfOneEdition, deployEditionImage, discoverDeployBlock, encodeFieldRenderer, encodeTag, expectedChainComplete, makeHotSender, makePublicClient, makeWalletClient, normalizeAttributes, onchainUriSetupCalls, oneOfOneImageAbi, oneOfOneEditionAbi, parseTraitPairs, planResume, predictClone, probeSeedSource, prepareCodeSetup, prepareDeployOneOfOne, prepareDeploySeries, prepareDeploySeriesCode, prepareDeployOneOfOneEdition, prepareDeployEditionImage, prepareDeployEditionCode, redactRpcUrl, resolveChain, resolveDepRegistryPointer, resolveGenerator, probeTransferValidator, resolveRecommendedTransferValidator, resolveRpcUrl, resolveSeedSource, resolveSeriesCodeFactory, saltFor, seriesCodeAbi, seriesCodeFactoryAbi, tryReadContract, } from '@artblocks/abx-sdk';
18
+ import { DEFAULT_CHAIN_KEY, DEP_RESOLUTION, METADATA_FIELD as F, contentIdFromLocator, METADATA_REPRESENTATION as R, analyzeScript, assertChainId, checkRegistryDeps, dependencySetupCalls, deployOneOfOne, deploySeries, deployOneOfOneEdition, deployEditionImage, discoverDeployBlock, encodeFieldRenderer, encodeTag, expectedChainComplete, makeHotSender, makePublicClient, makeWalletClient, normalizeAttributes, onchainUriSetupCalls, oneOfOneImageAbi, oneOfOneEditionAbi, parseTraitPairs, planResume, predictClone, probeSeedSource, prepareCodeSetup, prepareDeployOneOfOne, prepareDeploySeries, prepareDeploySeriesCode, prepareDeployOneOfOneEdition, prepareDeployEditionImage, prepareDeployEditionCode, redactRpcUrl, resolveChain, resolveDepRegistryPointer, resolveGenerator, probeTransferValidator, resolveRecommendedTransferValidator, resolveRpcUrl, resolveSeedSource, resolveSeriesCodeFactory, saltFor, seriesCodeAbi, seriesCodeFactoryAbi, tryReadContract, AbxServiceClient, } from '@artblocks/abx-sdk';
19
19
  import { DIRECT_URL_BACKENDS, contentTypeFromPath, decideImageContentLane, hashContent, isTurboArweave, resolveBackend, validateRenderStorageCombo, } from '@artblocks/abx-storage';
20
20
  import { DEFAULT_PORT, contentHash, generateContent, resolveBaseUrl, startTokenApiServer } from '@artblocks/abx-token-api';
21
21
  import { encodeFunctionData, toHex, zeroAddress } from 'viem';
@@ -24,10 +24,11 @@ import { parseDepFlag } from '../deps.js';
24
24
  import { allowUnreadableOnchain, isDryRun, parseSaltFlag, refuseStrayFlags, warnSignWithoutFor } from '../flags.js';
25
25
  import { isEditionContract } from '../kind.js';
26
26
  import { jsonSafe, withJson } from '../jsonout.js';
27
+ import { planOnChainScript } from '../script-chunks.js';
27
28
  import { bold, c, dim, ensureFactory, ensureRenderer, ensureSeedSource, ensureSeriesCodeFactory, ensureSeriesFactory, ensureOneOfOneEditionFactory, ensureEditionFactory, ensureEditionCodeFactory, g, info, keepAlive, ok, p, portInUse, printServing, registerAndIndexLocally, reindexAfterDeploy, step, warn, } from '../output.js';
28
- import { AUTHORSHIP_DEPLOY_FIELDS, ONCHAIN_PROJECT_SOFT_LIMIT, ONCHAIN_READ_WARN_BYTES, tokenUriGasEstimate, authorshipContractFields, computeContentPlan, envStagingSender, parseCompress, parseSeedSourceValue, canonicalSeedSource, refuseUnusableSeedSource, parseTransferValidatorValue, guardOnChainSize, previewImageStaging, sessionStagingSender, stageImageField, stageImageFieldsBatch, } from '../ownerops.js';
29
+ import { AUTHORSHIP_DEPLOY_FIELDS, ONCHAIN_PROJECT_SOFT_LIMIT, ONCHAIN_READ_WARN_BYTES, tokenUriGasEstimate, authorshipContractFields, gatewayContractFields, computeContentPlan, envStagingSender, parseCompress, parseSeedSourceValue, canonicalSeedSource, refuseUnusableSeedSource, parseTransferValidatorValue, guardOnChainSize, previewImageStaging, refusePrewrappedImage, sessionStagingSender, stageImageField, stageImageFieldsBatch, } from '../ownerops.js';
29
30
  import { canonicalLabel } from '../remote.js';
30
- import { confirmSend, gatedSend, laneFromFlags } from '../riskgate.js';
31
+ import { assertLaneCanSign, confirmSend, gatedSend, laneFromFlags } from '../riskgate.js';
31
32
  import { describeSchema, editionSchemaAdvisory, parseSchemaSpecs } from '../schema.js';
32
33
  import { parseSeriesTraits } from '../series-traits.js';
33
34
  import { decodeOnChainJson } from '../served.js';
@@ -192,6 +193,8 @@ export function deployCommandLine(flags, salt) {
192
193
  str('backend', flags.backend);
193
194
  str('gateway', flags.gateway);
194
195
  str('bucket', flags.bucket);
196
+ str('ipfs-gateway', flags['ipfs-gateway']);
197
+ str('arweave-gateway', flags['arweave-gateway']);
195
198
  str('public-base-url', flags['public-base-url']);
196
199
  if (flags.sign !== undefined)
197
200
  parts.push('--sign');
@@ -223,6 +226,8 @@ export function deploySeriesCommandLine(flags, salt) {
223
226
  str('backend', flags.backend);
224
227
  str('gateway', flags.gateway);
225
228
  str('bucket', flags.bucket);
229
+ str('ipfs-gateway', flags['ipfs-gateway']);
230
+ str('arweave-gateway', flags['arweave-gateway']);
226
231
  str('public-base', flags['public-base']);
227
232
  str('public-base-url', flags['public-base-url']);
228
233
  bool('mint-all');
@@ -278,6 +283,8 @@ export function deployCodeCommandLine(flags, salt) {
278
283
  str('backend', flags.backend);
279
284
  str('gateway', flags.gateway);
280
285
  str('public-base-url', flags['public-base-url']);
286
+ str('ipfs-gateway', flags['ipfs-gateway']);
287
+ str('arweave-gateway', flags['arweave-gateway']);
281
288
  if (flags.sign !== undefined)
282
289
  parts.push('--sign');
283
290
  if (flags.unsigned !== undefined)
@@ -296,11 +303,14 @@ export const imageKeccakField = (value) => ({
296
303
  representation: encodeTag(R.keccak256),
297
304
  value,
298
305
  });
299
- export const imageInlineField = (svg) => ({
300
- field: encodeTag(F.image),
301
- representation: encodeTag(R.inline),
302
- value: toHex(svg),
303
- });
306
+ export const imageInlineField = (svg) => {
307
+ refusePrewrappedImage(svg, 'inline image');
308
+ return {
309
+ field: encodeTag(F.image),
310
+ representation: encodeTag(R.inline),
311
+ value: toHex(svg),
312
+ };
313
+ };
304
314
  /** An `image` field pointing at an off-chain URL (the renderer emits it verbatim). */
305
315
  export const imageUrlField = (url) => ({
306
316
  field: encodeTag(F.image),
@@ -314,6 +324,59 @@ export const imageUrlTemplateField = (template) => ({
314
324
  representation: encodeTag(R.urlTemplate),
315
325
  value: toHex(template),
316
326
  });
327
+ /**
328
+ * An `image` field carrying a CONTENT-ADDRESSED identity — the bare CID / txid, with the serving
329
+ * gateway supplied at read time from the collection's `abx_gateway_*` preference (renderer spec v11).
330
+ *
331
+ * This replaces what `--onchain-uri --backend ipfs|arweave` used to write. That path stored the
332
+ * backend's gateway HTTPS URL as a `url` field, which looked identical in a marketplace and was
333
+ * wrong in two ways that only showed up later: the creator's chosen gateway host was welded into a
334
+ * value they might have locked, so a dead gateway became a dead token rather than a repoint; and
335
+ * `abx_provenance` reported `source: url` for bytes that live on IPFS, so the chain stopped saying
336
+ * where the work actually was. The CID is identity and the gateway is a preference — one field each.
337
+ *
338
+ * `{id}` in the value still substitutes at read (the O(1) directory pattern), so a collection-scope
339
+ * `ipfs` field addresses a whole pinned folder exactly as `url-template` does for a plain CDN.
340
+ */
341
+ export const imageContentAddressedField = (network, contentId) => ({
342
+ field: encodeTag(F.image),
343
+ representation: encodeTag(network === 'ipfs' ? R.ipfs : R.arweave),
344
+ value: toHex(contentId),
345
+ });
346
+ /**
347
+ * The `image` field for a locator a durable backend just produced — ONE decision point, so every
348
+ * lane (1/1, series, both edition twins) writes the same shape for the same backend.
349
+ *
350
+ * `ipfs` / `arweave` store IDENTITY: recover the bare CID/txid from the backend's gateway URL and
351
+ * commit that, leaving the serving prefix to the collection's `abx_gateway_*` preference. Any path
352
+ * suffix rides along, so a directory upload's `<cid>/{id}.png` is still one collection-scope field
353
+ * covering every token — the renderer substitutes `{id}` for `ipfs`/`arweave` exactly as it does for
354
+ * `url-template`.
355
+ *
356
+ * `cloud` and friends keep the plain `url` / `url-template` they always had: an https CDN locator IS
357
+ * the address, with no identity underneath to separate out.
358
+ */
359
+ /** How a lane should describe what it just committed, so the narration matches the field written. */
360
+ function describeLocatorField(backendId) {
361
+ return backendId === 'ipfs' || backendId === 'arweave'
362
+ ? `ON-CHAIN image as a bare ${backendId} id — the gateway is the collection's preference, repointable with ${bold('abx set-gateway')}`
363
+ : 'ON-CHAIN image url (the locator IS the address)';
364
+ }
365
+ function imageLocatorField(backendId, locator, isTemplate = false) {
366
+ const network = backendId === 'ipfs' || backendId === 'arweave' ? backendId : null;
367
+ if (network) {
368
+ const contentId = contentIdFromLocator(network, locator);
369
+ if (contentId)
370
+ return imageContentAddressedField(network, contentId);
371
+ // A subdomain-style gateway (`https://<cid>.ipfs.dweb.link`), which ABX's own backends never
372
+ // produce. Degrade to the old shape rather than guess at the CID — and say so, because the
373
+ // consequence (a welded host) is exactly what the caller thought they were avoiding.
374
+ warn(`could not recover the ${network} content id from ${locator} — writing the gateway URL as a plain ` +
375
+ `'url' field instead. That URL's host is then part of the on-chain value: a gateway change ` +
376
+ `needs ${bold('abx set-field')}, not ${bold('abx set-gateway')}.`);
377
+ }
378
+ return isTemplate ? imageUrlTemplateField(locator) : imageUrlField(locator);
379
+ }
317
380
  export const looksLikeSvg = (s) => /^\s*<(\?xml|svg)/i.test(s);
318
381
  /**
319
382
  * Does the IMAGE itself end up on-chain for this deploy? Two ways it can:
@@ -413,8 +476,8 @@ walletAddr) {
413
476
  const backendId = backendResolution(overrides).backend;
414
477
  if (onchainUrlLane) {
415
478
  return {
416
- tokenFields: [imageUrlField(`<${backendId}-url>`)],
417
- contentNote: `content: ${basename(path)} (${bytes.length} bytes, ${contentType}) → would upload to ${backendId}; its public URL becomes the ON-CHAIN image field (no server)`,
479
+ tokenFields: [imageLocatorField(backendId, `<${backendId}-url>`)],
480
+ contentNote: `content: ${basename(path)} (${bytes.length} bytes, ${contentType}) → would upload to ${backendId}; ${describeLocatorField(backendId)}`,
418
481
  };
419
482
  }
420
483
  return {
@@ -442,8 +505,8 @@ walletAddr) {
442
505
  await backend.put(hash, { bytes, contentType });
443
506
  ok(`image → ${url} ${dim(`(${backend.id}; baked on-chain, no server)`)}`);
444
507
  return {
445
- tokenFields: [imageUrlField(url)],
446
- contentNote: `content: ${basename(path)} (${bytes.length} bytes, ${contentType}) on ${backend.id} · ON-CHAIN image url ${url}`,
508
+ tokenFields: [imageLocatorField(backend.id, url)],
509
+ contentNote: `content: ${basename(path)} (${bytes.length} bytes, ${contentType}) on ${backend.id} · ON-CHAIN image ${describeLocatorField(backend.id)}`,
447
510
  };
448
511
  }
449
512
  warn(`'${backend.id}' cannot upload a directory, so no public image URL could be baked — falling back to custody (the on-chain image will be a placeholder).`);
@@ -815,6 +878,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
815
878
  // explicit --salt is given. With the wallet lane the signer is unknown until they
816
879
  // connect, so this runs per-signer.
817
880
  const buildForDeployer = async (deployer) => {
881
+ assertLaneCanSign(flags); // no upload before we know this run can be signed — see `assertLaneCanSign`
818
882
  const salt = explicitSalt ?? saltFor(deployer);
819
883
  const clone = await predictClone(publicClient, { factory, salt });
820
884
  // A baked on-chain image (--onchain-image) replaces prepareContent's image field —
@@ -853,7 +917,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
853
917
  transferValidator,
854
918
  tokenFields,
855
919
  // authorship + rights (creator / license / …) are collection-scope on-chain fields.
856
- contractFields: authorshipContractFields(flags),
920
+ contractFields: [...authorshipContractFields(flags), ...gatewayContractFields(flags)],
857
921
  };
858
922
  return { clone, params, salt, contentNote };
859
923
  };
@@ -1319,6 +1383,7 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1319
1383
  info('off-chain fallback pointer left empty (the renderer is authoritative); set --public-base-url to bake one anyway.');
1320
1384
  }
1321
1385
  const buildForDeployer = async (deployer) => {
1386
+ assertLaneCanSign(flags); // no upload before we know this run can be signed — see `assertLaneCanSign`
1322
1387
  const salt = explicitSalt ?? saltFor(deployer);
1323
1388
  const clone = await predictClone(publicClient, { factory, salt });
1324
1389
  const { tokenFields, contentNote } = bakedImage
@@ -1349,7 +1414,7 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1349
1414
  minter,
1350
1415
  paused,
1351
1416
  tokenFields,
1352
- contractFields: authorshipContractFields(flags),
1417
+ contractFields: [...authorshipContractFields(flags), ...gatewayContractFields(flags)],
1353
1418
  };
1354
1419
  return { clone, params, salt, contentNote };
1355
1420
  };
@@ -1659,7 +1724,7 @@ export async function cmdDeploySeriesBody(flags, emit) {
1659
1724
  // Image custody × URI resolution (see reference/hosting.md):
1660
1725
  // - --onchain-image → chunk store (on-chain bytes), JSON on-chain
1661
1726
  // - --onchain-uri + a durable backend → image OFF-CHAIN (ipfs/arweave), JSON on-chain via the
1662
- // (ipfs/arweave) renderer: ONE collection `url-template` when files share
1727
+ // (ipfs/arweave) renderer: ONE collection-scope image field when files share
1663
1728
  // an extension (O(1) directory), else per-token `url` (O(N))
1664
1729
  // - --onchain-uri alone → inline SVG on-chain
1665
1730
  // - neither → off-chain custody (keccak), served by a hosted resolver
@@ -1694,6 +1759,7 @@ export async function cmdDeploySeriesBody(flags, emit) {
1694
1759
  // early managed-key creation + funds guard and the up-front buildFields; the session branch runs them.
1695
1760
  const remoteEthUpload = lane === 'sign' && !onchainImage && !!backend && storageSignerChoice(overrides) === 'eth' && isTurboArweave(opts);
1696
1761
  if (backend && !remoteEthUpload) {
1762
+ assertLaneCanSign(flags); // no upload before we know this run can be signed — see `assertLaneCanSign`
1697
1763
  ensureArweaveIdentityForUpload(opts); // Turbo identity, created on first upload
1698
1764
  // The deployer wallet (--for, else the .env key's address) — checked for existing Turbo credits
1699
1765
  // if the managed key is short, so we recommend `--storage-signer eth` before any top-up.
@@ -1733,23 +1799,24 @@ export async function cmdDeploySeriesBody(flags, emit) {
1733
1799
  const uniform = exts[0] !== '' && exts.every((e) => e === exts[0]);
1734
1800
  if (dryRun || !backend) {
1735
1801
  if (uniform) {
1736
- contractFields = [imageUrlTemplateField(`<${backendId}-dir>/{id}${exts[0]}`)];
1737
- info(`would upload ${tokenPaths.length} file(s) as ONE ${backendId} directory → collection image url-template <${backendId}-dir>/{id}${exts[0]} (O(1))`);
1802
+ contractFields = [imageLocatorField(backendId, `<${backendId}-dir>/{id}${exts[0]}`, true)];
1803
+ info(`would upload ${tokenPaths.length} file(s) as ONE ${backendId} directory → collection image <${backendId}-dir>/{id}${exts[0]} (O(1); ${describeLocatorField(backendId)})`);
1738
1804
  }
1739
1805
  else {
1740
- tokenFields = tokenPaths.map(({ tokenId }) => tokenFieldOf(tokenId, imageUrlField(`<${backendId}-url-${tokenId}>`)));
1806
+ tokenFields = tokenPaths.map(({ tokenId }) => tokenFieldOf(tokenId, imageLocatorField(backendId, `<${backendId}-url-${tokenId}>`)));
1741
1807
  info(`would upload ${tokenPaths.length} file(s) to ${backendId} → per-token url image fields (mixed extensions, O(N))`);
1742
1808
  }
1743
1809
  return;
1744
1810
  }
1745
1811
  if (uniform && backend.putDirectory) {
1746
- // O(1) directory-base: upload the folder renamed to {id}{ext}; ONE collection url-template.
1812
+ // O(1) directory-base: upload the folder renamed to {id}{ext}; ONE collection-scope image
1813
+ // field. `{id}` substitutes at read on every locator representation, `ipfs`/`arweave` included.
1747
1814
  const entries = tokenPaths.map(({ tokenId, path }) => ({ name: `${tokenId}${exts[0]}`, bytes: new Uint8Array(readFileSync(path)), contentType: contentTypeFromPath(path) }));
1748
1815
  const { base } = await backend.putDirectory(entries);
1749
1816
  const template = `${base}/{id}${exts[0]}`;
1750
- contractFields = [imageUrlTemplateField(template)];
1817
+ contractFields = [imageLocatorField(backendId, template, true)];
1751
1818
  info(`uploaded ${entries.length} file(s) as one ${backendId} directory`);
1752
- info(`collection image (url-template): ${template} ${dim('— one field covers every token (O(1))')}`);
1819
+ info(`collection image: ${template} ${dim(`— one field covers every token (O(1); ${describeLocatorField(backendId)})`)}`);
1753
1820
  }
1754
1821
  else {
1755
1822
  // mixed extensions (or no directory support): per-token url fields (O(N)).
@@ -1760,10 +1827,10 @@ export async function cmdDeploySeriesBody(flags, emit) {
1760
1827
  const locator = (await backend.locator?.(hash)) ?? '';
1761
1828
  if (!locator)
1762
1829
  throw new Error(`backend ${backendId} returned no public locator for token ${tokenId} — a durable backend (ipfs/arweave) is required for --onchain-uri image hosting`);
1763
- tokenFields.push(tokenFieldOf(tokenId, imageUrlField(locator)));
1830
+ tokenFields.push(tokenFieldOf(tokenId, imageLocatorField(backendId, locator)));
1764
1831
  info(`token ${tokenId} ← ${fname} → ${locator}`);
1765
1832
  }
1766
- info(`mixed file extensions → per-token url fields (O(N)); a uniform extension enables the O(1) directory template`);
1833
+ info(`mixed file extensions → per-token image fields (O(N)); a uniform extension enables the O(1) directory template`);
1767
1834
  }
1768
1835
  return;
1769
1836
  }
@@ -1819,6 +1886,7 @@ export async function cmdDeploySeriesBody(flags, emit) {
1819
1886
  // Assemble the init params for a given deployer (owner/royalty/mintTo = deployer). The clone
1820
1887
  // address is deterministic in (factory, salt) — so URIs are baked before the tx is signed.
1821
1888
  const buildForDeployer = async (deployer) => {
1889
+ assertLaneCanSign(flags); // no upload before we know this run can be signed — see `assertLaneCanSign`
1822
1890
  const salt = explicitSalt ?? saltFor(deployer);
1823
1891
  const clone = await predictClone(publicClient, { factory, salt });
1824
1892
  const params = {
@@ -1839,8 +1907,8 @@ export async function cmdDeploySeriesBody(flags, emit) {
1839
1907
  mintTo: mintCount > 0 ? deployer : zeroAddress,
1840
1908
  mintCount,
1841
1909
  tokenFields,
1842
- // image contract fields (the O(1) url-template) + authorship/rights (creator / license / …).
1843
- contractFields: [...contractFields, ...authorshipContractFields(flags)],
1910
+ // image contract fields (the O(1) collection-scope image) + authorship/rights (creator / license / …).
1911
+ contractFields: [...contractFields, ...authorshipContractFields(flags), ...gatewayContractFields(flags)],
1844
1912
  };
1845
1913
  return { clone, params, salt };
1846
1914
  };
@@ -1877,7 +1945,9 @@ export async function cmdDeploySeriesBody(flags, emit) {
1877
1945
  info(onchainImage
1878
1946
  ? 'resolution: ON-CHAIN via the renderer (chunked reader per token — image bytes on-chain)'
1879
1947
  : offchainImageOnchainJson
1880
- ? `resolution: ON-CHAIN via the renderer; image OFF-CHAIN on ${backendId} (${contractFields.length ? 'one collection url-template, O(1)' : 'per-token url, O(N)'})`
1948
+ // Say which SHAPE landed, not a fixed representation name: since v11 a content-addressed
1949
+ // backend writes an `ipfs`/`arweave` field, not `url`/`url-template`.
1950
+ ? `resolution: ON-CHAIN via the renderer; image OFF-CHAIN on ${backendId} (${contractFields.length ? 'one collection-scope image field, O(1)' : 'per-token image fields, O(N)'})`
1881
1951
  : onChainUri
1882
1952
  ? 'resolution: ON-CHAIN via the renderer (inline SVG per token)'
1883
1953
  : `resolution: off-chain, base ${params.tokenURIBase}`);
@@ -2240,7 +2310,7 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2240
2310
  }, 0) + 1 // + the deploy tx
2241
2311
  : 1;
2242
2312
  const tokenFields = [];
2243
- // Collection-scope fields the image lane may add (the O(1) `url-template`). Kept separate from the
2313
+ // Collection-scope fields the image lane may add (the O(1) directory image). Kept separate from the
2244
2314
  // authorship fields and CONCATENATED at the params site — never assigned over them, or an
2245
2315
  // `--creator`/`--license` value would silently vanish whenever pattern 2 is in play.
2246
2316
  let imageContractFields = [];
@@ -2263,13 +2333,13 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2263
2333
  const uniform = exts[0] !== '' && exts.every((e) => e === exts[0]);
2264
2334
  if (dryRun || !backend) {
2265
2335
  if (uniform) {
2266
- imageContractFields = [imageUrlTemplateField(`<${backendId}-dir>/{id}${exts[0]}`)];
2267
- info(`would upload ${tokenPaths.length} file(s) as ONE ${backendId} directory → collection image url-template <${backendId}-dir>/{id}${exts[0]} (O(1))`);
2336
+ imageContractFields = [imageLocatorField(backendId, `<${backendId}-dir>/{id}${exts[0]}`, true)];
2337
+ info(`would upload ${tokenPaths.length} file(s) as ONE ${backendId} directory → collection image <${backendId}-dir>/{id}${exts[0]} (O(1); ${describeLocatorField(backendId)})`);
2268
2338
  }
2269
2339
  else {
2270
2340
  for (const { tokenId } of tokenPaths)
2271
- tokenFields.push(tokenFieldOf(tokenId, imageUrlField(`<${backendId}-url-${tokenId}>`)));
2272
- info(`would upload ${tokenPaths.length} file(s) to ${backendId} → per-id url image fields (mixed extensions, O(N))`);
2341
+ tokenFields.push(tokenFieldOf(tokenId, imageLocatorField(backendId, `<${backendId}-url-${tokenId}>`)));
2342
+ info(`would upload ${tokenPaths.length} file(s) to ${backendId} → per-id image fields (mixed extensions, O(N); ${describeLocatorField(backendId)})`);
2273
2343
  }
2274
2344
  }
2275
2345
  else if (uniform && backend.putDirectory) {
@@ -2277,9 +2347,9 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2277
2347
  const entries = tokenPaths.map(({ tokenId, path }) => ({ name: `${tokenId}${exts[0]}`, bytes: new Uint8Array(readFileSync(path)), contentType: contentTypeFromPath(path) }));
2278
2348
  const { base } = await backend.putDirectory(entries);
2279
2349
  const template = `${base}/{id}${exts[0]}`;
2280
- imageContractFields = [imageUrlTemplateField(template)];
2350
+ imageContractFields = [imageLocatorField(backendId, template, true)];
2281
2351
  info(`uploaded ${entries.length} file(s) as one ${backendId} directory`);
2282
- info(`collection image (url-template): ${template} ${dim('— one field covers every id (O(1))')}`);
2352
+ info(`collection image: ${template} ${dim(`— one field covers every id (O(1); ${describeLocatorField(backendId)})`)}`);
2283
2353
  }
2284
2354
  else {
2285
2355
  // mixed extensions (or no directory support): per-id url fields (O(N)).
@@ -2290,10 +2360,10 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2290
2360
  const locator = (await backend.locator?.(hash)) ?? '';
2291
2361
  if (!locator)
2292
2362
  throw new Error(`backend ${backendId} returned no public locator for id ${tokenId} — a durable backend (ipfs/arweave/cloud) is required for --onchain-uri image hosting`);
2293
- tokenFields.push(tokenFieldOf(tokenId, imageUrlField(locator)));
2363
+ tokenFields.push(tokenFieldOf(tokenId, imageLocatorField(backendId, locator)));
2294
2364
  info(`id ${tokenId} ← ${fname} → ${locator}`);
2295
2365
  }
2296
- info(`mixed file extensions → per-id url fields (O(N)); a uniform extension enables the O(1) directory template`);
2366
+ info(`mixed file extensions → per-id image fields (O(N)); a uniform extension enables the O(1) directory template`);
2297
2367
  }
2298
2368
  if (seriesTraitsOnchain)
2299
2369
  for (const [tokenId, attrs] of seriesTraits)
@@ -2349,6 +2419,7 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2349
2419
  if (!onchainImage)
2350
2420
  await buildFields();
2351
2421
  const buildForDeployer = async (deployer) => {
2422
+ assertLaneCanSign(flags); // no upload before we know this run can be signed — see `assertLaneCanSign`
2352
2423
  const salt = explicitSalt ?? saltFor(deployer);
2353
2424
  const clone = await predictClone(publicClient, { factory, salt });
2354
2425
  const params = {
@@ -2373,7 +2444,7 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2373
2444
  tokenFields,
2374
2445
  // Concatenate — the image lane's collection-scope url-template rides ALONGSIDE the authorship
2375
2446
  // fields. Assigning either over the other would silently drop the creator's --creator/--license.
2376
- contractFields: [...authorshipContractFields(flags), ...imageContractFields],
2447
+ contractFields: [...authorshipContractFields(flags), ...gatewayContractFields(flags), ...imageContractFields],
2377
2448
  };
2378
2449
  return { clone, params, salt };
2379
2450
  };
@@ -2738,6 +2809,11 @@ export const DEPLOY_CODE_FLAGS = new Set([
2738
2809
  // Storage + signing + preview flags shared by every deploy path (mirrors storageOverrides() + the lanes).
2739
2810
  export const SHARED_DEPLOY_FLAGS = [
2740
2811
  'backend', 'endpoint', 'bucket', 'region', 'prefix', 'public-base', 'gateway', 'mode', 'api-url', 'upload-url', 'provider', 'storage-signer',
2812
+ // The collection's PREFERRED SERVING gateways, written on chain (`abx_gateway_ipfs` /
2813
+ // `abx_gateway_arweave`) and repointable forever with `abx set-gateway`. Distinct from `--gateway`,
2814
+ // which is where the CLI uploads and probes; `--gateway` seeds these for the backend in use, and
2815
+ // these override it when the two should differ. Every deploy path takes them.
2816
+ 'ipfs-gateway', 'arweave-gateway',
2741
2817
  'sign', 'unsigned', 'for', 'sign-url-file', 'dry-run', 'confirm', 'yes', 'salt', 'factory', 'bootstrap-factory', 'port', 'renderer', 'public-base-url',
2742
2818
  '721c', // opt-in ERC-721C enrollment (recommended | 0x…) — every deploy path takes it
2743
2819
  'json', // B19: the deployed ADDRESS as data, narration to stderr — every deploy path takes it
@@ -3006,11 +3082,9 @@ export async function cmdDeployCodeBody(flags, emit) {
3006
3082
  }
3007
3083
  else if (scriptPath) {
3008
3084
  const source = readFileSync(resolvePath(scriptPath), 'utf8');
3009
- const bytes = new TextEncoder().encode(source);
3010
- const CHUNK = 22_000; // SSTORE2 headroom
3011
- for (let off = 0; off < bytes.length; off += CHUNK)
3012
- scriptChunks.push(toHex(bytes.slice(off, off + CHUNK)));
3013
- contentSummary = `script ${bytes.length} bytes → ${scriptChunks.length} on-chain chunk(s)`;
3085
+ scriptChunks = planOnChainScript(source);
3086
+ const bytes = new TextEncoder().encode(source).length;
3087
+ contentSummary = `script ${bytes} bytes ${scriptChunks.length} on-chain chunk(s)`;
3014
3088
  ok(contentSummary);
3015
3089
  scriptAnalysis = analyzeScript(source, deps.map((d) => d.display)); // traits + PostParam reads + doc size → the Surfaces disposition below
3016
3090
  }
@@ -3042,7 +3116,7 @@ export async function cmdDeployCodeBody(flags, emit) {
3042
3116
  identityFields.push('external_url');
3043
3117
  }
3044
3118
  // authorship + rights — same on-chain inline collection-field slot (creator / license / …).
3045
- contractFields.push(...authorshipContractFields(flags));
3119
+ contractFields.push(...authorshipContractFields(flags), ...gatewayContractFields(flags));
3046
3120
  for (const [flag, field] of AUTHORSHIP_DEPLOY_FIELDS)
3047
3121
  if (flags[flag])
3048
3122
  identityFields.push(field);
@@ -3243,7 +3317,7 @@ export async function cmdDeployCodeBody(flags, emit) {
3243
3317
  const undeclaredParams = (scriptAnalysis?.paramHints ?? []).filter((k) => !declaredSchemaKeys.has(k));
3244
3318
  const paramsDisposition = scriptAnalysis
3245
3319
  ? undeclaredParams.length
3246
- ? `⚠ the script READS ${undeclaredParams.join(', ')} but ${undeclaredParams.length === 1 ? "it isn't" : "they aren't"} in --schema → dropped at render (default used). Add --schema ${undeclaredParams.map((k) => `${k}:<Type>:<Auth>`).join(',')} (a palette collectors set = HexColor:TokenOwner)`
3320
+ ? `⚠ the script READS ${undeclaredParams.join(', ')} but ${undeclaredParams.length === 1 ? "it isn't" : "they aren't"} in --schema → dropped at render (default used) UNLESS an augment hook supplies ${undeclaredParams.length === 1 ? 'it' : 'them'}. Add --schema ${undeclaredParams.map((k) => `${k}:<Type>:<Auth>`).join(',')} only for collector/creator inputs (a palette collectors set = HexColor:TokenOwner); ignore this for keys your augment hook derives`
3247
3321
  : schemas.length
3248
3322
  ? `${schemas.length} declared: ${schemaSummary}`
3249
3323
  : 'none (the script reads no collector params)'
@@ -3380,7 +3454,7 @@ export async function cmdDeployCodeBody(flags, emit) {
3380
3454
  info(` ${g('fully on-chain')} — the whole tokenURI (name + image SVG + traits) is computed on-chain; zero dependency outside the EVM ${dim('(abx verify confirms from chain)')}`);
3381
3455
  }
3382
3456
  else {
3383
- info(` directory branch: no-server, not chain-complete — liveness rides the gateway (generator default; the ${bold('display.gateway')} contract param repoints), and the params ride the URL (8KB budget — abx verify reports urlOverBudget).`);
3457
+ info(` directory branch: no-server, not chain-complete — liveness rides the gateway (public default; ${bold('abx set-gateway')} repoints it), and the params ride the URL (8KB budget — abx verify reports urlOverBudget).`);
3384
3458
  }
3385
3459
  // A resolver is a "kinder serving path" ONLY when there's a live view / off-chain still to
3386
3460
  // serve — i.e. a program. The in-chain renderer-only lane has neither, so don't imply you
@@ -3446,12 +3520,27 @@ export async function cmdDeployCodeBody(flags, emit) {
3446
3520
  if (!imageOrphaned && !hasImageRenderer) {
3447
3521
  if (!hasPublicUrl)
3448
3522
  info(` ${dim('render mode (a still is never on-chain):')} ${bold('service')} (auto-render every mint/param change — a live/for-sale drop) · ${bold('once')} (fixed supply) · ${bold('none')} (placeholder). ${dim('freshness: no resolver ⇒ no chain-watcher ⇒ MANUAL/backfill (`abx render` after mints + each param change; the on-chain animation updates live, the bucket still does NOT). Continuous/live stills ⇒ run a resolver.')}`);
3449
- // The fs render-home footgun bites the RESOLVER lane hardest (a hosted resolver can't read your
3450
- // laptop disk → placeholder on OpenSea), so warn on BOTH lanes and name the concrete fix.
3451
- if (renderHome === 'fs')
3523
+ // A managed provider whose descriptor says `render.attached` owns rendering local
3524
+ // ABX_STORAGE_BACKEND is irrelevant, and warning "placeholder forever" is a lie.
3525
+ let renderAttached = false;
3526
+ if (hasPublicUrl && !loopbackBaseUrl(baseUrl)) {
3527
+ try {
3528
+ const d = await new AbxServiceClient({ baseUrl, timeoutMs: 2_500 }).descriptor();
3529
+ renderAttached = !!d.render?.attached;
3530
+ }
3531
+ catch { /* advisory — a down/non-ABX URL must not fail the dry run */ }
3532
+ }
3533
+ if (renderAttached) {
3534
+ info(` ${dim('render storage home:')} ${bold('managed by the resolver')} ${dim(`(${baseUrl}) — local ABX_STORAGE_BACKEND is irrelevant; skip abx deploy-effects`)}`);
3535
+ }
3536
+ else if (renderHome === 'fs') {
3537
+ // The fs render-home footgun bites the RESOLVER lane hardest (a hosted resolver can't read your
3538
+ // laptop disk → placeholder on OpenSea), so warn on BOTH lanes and name the concrete fix.
3452
3539
  warn(` render storage home is ${bold('fs')} — LOCAL to this machine, so ${hasPublicUrl ? 'a hosted resolver' : 'a marketplace'} can't read the rendered stills → placeholder forever. FIX before you render: ${bold('set ABX_STORAGE_BACKEND=arweave|s3|ipfs')} in .env (a PUBLIC home the runner uploads to).`);
3453
- else
3540
+ }
3541
+ else {
3454
3542
  info(` ${dim('render storage home:')} ${bold(renderHome)} ${dim('— a public home the runner uploads to ✓')}`);
3543
+ }
3455
3544
  }
3456
3545
  (traitsBroken ? warn : info)(`traits: ${traitsDisposition}`);
3457
3546
  const paramsNudge = !scriptAnalysis && !schemas.length && (hasImageRenderer || hasAttributesRenderer);
@@ -3467,7 +3556,7 @@ export async function cmdDeployCodeBody(flags, emit) {
3467
3556
  // A dropped param is its own, milder problem: the piece renders, that input just takes its
3468
3557
  // default. Kept separate from the broken-surface alarm above (see the note there).
3469
3558
  if (undeclaredParams.length)
3470
- info(` ${dim('dropped params render with their defaults — declare them to make them real:')} ${bold('--schema <key>:<Type>:<Auth>')}`);
3559
+ info(` ${dim('dropped params render with their defaults — declare collector/creator inputs with')} ${bold('--schema <key>:<Type>:<Auth>')} ${dim('(skip this for keys an augment hook supplies)')}`);
3471
3560
  // The runner/verify line is for lanes that need an off-chain STILL. An on-chain image renderer has
3472
3561
  // no still to render, host, or refresh — whether or not there's also a program driving
3473
3562
  // animation_url — so pointing at a runner and a bucket backend there is simply wrong.
@@ -3927,7 +4016,11 @@ export const DEPLOY_CODE_EDITION_FLAGS = new Set([
3927
4016
  // give them otherwise — a real but less helpful refusal (it doesn't say WHY, or the way out).
3928
4017
  // (`resume` is NOT here: --resume + --copies is refused earlier, at cmdDeployCode's dispatch —
3929
4018
  // this function never runs with flags.resume set, so listing it here would just be dead.)
3930
- 'code-dir', 'dep', 'dep-registry', 'image-renderer', 'attributes-renderer', 'image-base', 'no-delegation',
4019
+ 'code-dir', 'image-renderer', 'attributes-renderer', 'image-base', 'no-delegation',
4020
+ // NOT scope cuts: --dep/--dep-registry are fully wired on EditionCode (it inherits the same
4021
+ // Dependencies extension, and the legs ride the same setup multicall — see the Dependencies step
4022
+ // below). They're plain supported flags; `abx help deploy-code` documents them as working.
4023
+ 'dep', 'dep-registry',
3931
4024
  ]);
3932
4025
  export async function cmdDeployEditionCodeBody(flags, emit) {
3933
4026
  const usage = 'abx deploy-code --copies <n|open> --script <file> --name "Title" --symbol SYM (--public-base-url https://your.resolver.domain | --onchain-uri) ' +
@@ -4086,18 +4179,14 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4086
4179
  step('Content');
4087
4180
  const scriptPath = flags.script;
4088
4181
  const source = readFileSync(resolvePath(scriptPath), 'utf8');
4089
- const bytes = new TextEncoder().encode(source);
4090
- const CHUNK = 22_000; // SSTORE2 headroom
4091
- const scriptChunks = [];
4092
- for (let off = 0; off < bytes.length; off += CHUNK)
4093
- scriptChunks.push(toHex(bytes.slice(off, off + CHUNK)));
4094
- ok(`script ${bytes.length} bytes → ${scriptChunks.length} on-chain chunk(s)`);
4182
+ const scriptChunks = planOnChainScript(source);
4183
+ ok(`script ${new TextEncoder().encode(source).length} bytes ${scriptChunks.length} on-chain chunk(s)`);
4095
4184
  const contractFields = [];
4096
4185
  if (flags.description)
4097
4186
  contractFields.push({ field: encodeTag(F.description), representation: encodeTag(R.inline), value: toHex(String(flags.description)) });
4098
4187
  if (flags['external-url'])
4099
4188
  contractFields.push({ field: encodeTag(F.externalUrl), representation: encodeTag(R.inline), value: toHex(String(flags['external-url'])) });
4100
- contractFields.push(...authorshipContractFields(flags));
4189
+ contractFields.push(...authorshipContractFields(flags), ...gatewayContractFields(flags));
4101
4190
  const onchainUriLegs = onChainUri ? onchainUriSetupCalls({ generator, metadataRenderer }) : [];
4102
4191
  step('Deploy');
4103
4192
  const lane = laneFromFlags(flags);
@@ -4179,7 +4268,11 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4179
4268
  const depLegCount = dependencySetupCalls(deps, depRegistry).length;
4180
4269
  const approvals = 1 + (scriptChunks.length + schemas.length + depLegCount + onchainUriLegs.length + mintCount > 0 ? 1 : 0);
4181
4270
  if (!dryRun) {
4182
- await confirmSend(`About to deploy an edition code project "${name}" (${symbol}) — script ${bytes.length} bytes → ${scriptChunks.length} chunk(s); ` +
4271
+ await confirmSend(
4272
+ // Second site of the same missing binding as the plan line above — and the worse one: this is
4273
+ // the REAL deploy path, so `deploy-code --copies` threw at the confirmation prompt, after the
4274
+ // wallet and factory work was already done.
4275
+ `About to deploy an edition code project "${name}" (${symbol}) — script ${Buffer.byteLength(source, 'utf8')} bytes → ${scriptChunks.length} chunk(s); ` +
4183
4276
  `up to ${max} id(s) × ${editionSize === 0n ? 'open' : editionSize.toString()} cop${editionSize === 1n ? 'y' : 'ies'} each; ` +
4184
4277
  `${onChainUri ? `tokenURI ON-CHAIN via the renderer (generator ${generator})` : `resolver base ${baseUrl}`}; ` +
4185
4278
  `mint: ${mintCount > 0 ? `${mintCount} id(s) × ${mintAmount} at deploy` : 'deferred'}; approvals: ${approvals} wallet approval(s); owner+royalty: your wallet @ ${royaltyBps / 100}%.` +
@@ -4208,7 +4301,10 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4208
4301
  if (shownAddr)
4209
4302
  info(`deterministic address: ${shownAddr}`);
4210
4303
  info(`name "${name}" · symbol ${symbol} · max ${max} id(s) · royalty ${royaltyBps / 100}%`);
4211
- info(`content: script ${bytes.length} bytes ${scriptChunks.length} on-chain chunk(s)`);
4304
+ // `source` is what this lane read; there has never been a `bytes` binding here, so this line
4305
+ // threw a ReferenceError for every `deploy-code --copies … --dry-run` — the dry run crashed at the
4306
+ // last step of the plan it was printing. Byte length, not UTF-16 code units, matching the 721 twin.
4307
+ info(`content: script ${Buffer.byteLength(source, 'utf8')} bytes → ${scriptChunks.length} on-chain chunk(s)`);
4212
4308
  printSeedSourcePlan(seedSource, chainId);
4213
4309
  info(`PostParam schema(s): ${schemas.length ? schemas.map((s) => describeSchema(s)).join(', ') : 'none'}`);
4214
4310
  info(`mint: ${mintCount > 0 ? `${mintCount} id(s) × ${mintAmount} cop${mintAmount === 1n ? 'y' : 'ies'} → ${deployer ?? 'your wallet'} at deploy` : 'deferred (mint later / external minter)'}`);