@artblocks/abx-cli 0.1.0-alpha.20 → 0.1.0-alpha.22

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 (82) hide show
  1. package/CHANGELOG.md +1081 -0
  2. package/assets/renderer-scaffold/README.md +2 -2
  3. package/assets/renderer-scaffold/src/MyRenderer.sol +2 -2
  4. package/assets/renderer-scaffold/src/interfaces/IAbxFieldRenderer.sol +1 -1
  5. package/assets/renderer-scaffold/src/interfaces/IAbxParams.sol +2 -2
  6. package/assets/renderer-scaffold/test/MyRenderer.t.sol +1 -1
  7. package/dist/commands/deploy.d.ts +30 -17
  8. package/dist/commands/deploy.d.ts.map +1 -1
  9. package/dist/commands/deploy.js +523 -117
  10. package/dist/commands/deploy.js.map +1 -1
  11. package/dist/commands/project.d.ts +16 -0
  12. package/dist/commands/project.d.ts.map +1 -1
  13. package/dist/commands/project.js +171 -15
  14. package/dist/commands/project.js.map +1 -1
  15. package/dist/commands/reads.d.ts.map +1 -1
  16. package/dist/commands/reads.js +35 -5
  17. package/dist/commands/reads.js.map +1 -1
  18. package/dist/commands/scaffold.d.ts +3 -1
  19. package/dist/commands/scaffold.d.ts.map +1 -1
  20. package/dist/commands/scaffold.js +81 -29
  21. package/dist/commands/scaffold.js.map +1 -1
  22. package/dist/commands/service.js +1 -1
  23. package/dist/commands/service.js.map +1 -1
  24. package/dist/commands/storage.d.ts.map +1 -1
  25. package/dist/commands/storage.js +11 -3
  26. package/dist/commands/storage.js.map +1 -1
  27. package/dist/commands/submit-app.d.ts +58 -0
  28. package/dist/commands/submit-app.d.ts.map +1 -0
  29. package/dist/commands/submit-app.js +512 -0
  30. package/dist/commands/submit-app.js.map +1 -0
  31. package/dist/config.d.ts +1 -15
  32. package/dist/config.d.ts.map +1 -1
  33. package/dist/config.js +18 -1
  34. package/dist/config.js.map +1 -1
  35. package/dist/flag-allowlists.d.ts +53 -0
  36. package/dist/flag-allowlists.d.ts.map +1 -0
  37. package/dist/flag-allowlists.js +152 -0
  38. package/dist/flag-allowlists.js.map +1 -0
  39. package/dist/flags.d.ts +6 -2
  40. package/dist/flags.d.ts.map +1 -1
  41. package/dist/flags.js +23 -0
  42. package/dist/flags.js.map +1 -1
  43. package/dist/kind.d.ts +8 -3
  44. package/dist/kind.d.ts.map +1 -1
  45. package/dist/kind.js +25 -0
  46. package/dist/kind.js.map +1 -1
  47. package/dist/main.js +166 -53
  48. package/dist/main.js.map +1 -1
  49. package/dist/mintpage.d.ts.map +1 -1
  50. package/dist/mintpage.js +29 -4
  51. package/dist/mintpage.js.map +1 -1
  52. package/dist/output.d.ts +32 -1
  53. package/dist/output.d.ts.map +1 -1
  54. package/dist/output.js +74 -9
  55. package/dist/output.js.map +1 -1
  56. package/dist/ownerops.d.ts +101 -13
  57. package/dist/ownerops.d.ts.map +1 -1
  58. package/dist/ownerops.js +421 -71
  59. package/dist/ownerops.js.map +1 -1
  60. package/dist/preview.d.ts +1 -1
  61. package/dist/preview.js +1 -1
  62. package/dist/remote.d.ts.map +1 -1
  63. package/dist/remote.js +8 -2
  64. package/dist/remote.js.map +1 -1
  65. package/dist/schema.d.ts +18 -0
  66. package/dist/schema.d.ts.map +1 -1
  67. package/dist/schema.js +37 -2
  68. package/dist/schema.js.map +1 -1
  69. package/dist/served.js +1 -1
  70. package/dist/update-check.d.ts +25 -0
  71. package/dist/update-check.d.ts.map +1 -1
  72. package/dist/update-check.js +49 -9
  73. package/dist/update-check.js.map +1 -1
  74. package/package.json +6 -6
  75. package/skill/SKILL.md +64 -32
  76. package/skill/reference/code-projects.md +98 -22
  77. package/skill/reference/creator-token.md +16 -3
  78. package/skill/reference/decisions.md +83 -11
  79. package/skill/reference/hosting.md +1 -1
  80. package/skill/reference/operating.md +35 -12
  81. package/skill/reference/setup.md +1 -1
  82. package/skill/reference/troubleshooting.md +16 -6
@@ -15,20 +15,20 @@
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, prepareCodeSetup, prepareDeployOneOfOne, prepareDeploySeries, prepareDeploySeriesCode, prepareDeployOneOfOneEdition, prepareDeployEditionImage, prepareDeployEditionCode, redactRpcUrl, resolveChain, resolveDepRegistryPointer, resolveGenerator, resolveRecommendedTransferValidator, resolveRpcUrl, resolveSeedSource, resolveSeriesCodeFactory, saltFor, seriesCodeAbi, seriesCodeFactoryAbi, tryReadContract, } from '@artblocks/abx-sdk';
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';
19
19
  import { DIRECT_URL_BACKENDS, contentTypeFromPath, decideImageContentLane, hashContent, isTurboArweave, resolveBackend, validateRenderStorageCombo, } from '@artblocks/abx-storage';
20
- import { DEFAULT_PORT, artContentHash, generateArt, resolveBaseUrl, startTokenApiServer } from '@artblocks/abx-token-api';
20
+ import { DEFAULT_PORT, contentHash, generateContent, resolveBaseUrl, startTokenApiServer } from '@artblocks/abx-token-api';
21
21
  import { encodeFunctionData, toHex, zeroAddress } from 'viem';
22
22
  import { CHAIN, explorerBase, assertTurboFundsForUpload, backendResolution, collectContentLocators, ensureArweaveIdentityForUpload, factoryAddress, faucetHint, localIndexer, loopbackBaseUrl, noteArweavePlan, noteStorageReadiness, rendererAddress, seriesFactoryAddress, oneOfOneEditionFactoryAddress, editionFactoryAddress, editionCodeFactoryAddress, storageOptions, storageOverrides, storageSignerChoice, } from '../config.js';
23
23
  import { parseDepFlag } from '../deps.js';
24
- import { isDryRun, parseSaltFlag, refuseStrayFlags, warnSignWithoutFor } from '../flags.js';
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
27
  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, authorshipContractFields, computeContentPlan, envStagingSender, parseCompress, parseTransferValidatorValue, previewImageStaging, sessionStagingSender, stageImageField, stageImageFieldsBatch, } from '../ownerops.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
29
  import { canonicalLabel } from '../remote.js';
30
30
  import { confirmSend, gatedSend, laneFromFlags } from '../riskgate.js';
31
- import { describeSchema, parseSchemaSpecs } from '../schema.js';
31
+ import { describeSchema, editionSchemaAdvisory, parseSchemaSpecs } from '../schema.js';
32
32
  import { parseSeriesTraits } from '../series-traits.js';
33
33
  import { decodeOnChainJson } from '../served.js';
34
34
  import { openWalletSession, signHotSequence, signTx } from '../signer.js';
@@ -261,6 +261,7 @@ export function deployCodeCommandLine(flags, salt) {
261
261
  str('max', flags.max);
262
262
  str('schema', flags.schema);
263
263
  bool('no-seed');
264
+ str('seed-source', flags['seed-source']);
264
265
  bool('onchain-uri');
265
266
  str('generator', flags.generator);
266
267
  str('renderer', flags.renderer);
@@ -289,7 +290,7 @@ export function deployCodeCommandLine(flags, salt) {
289
290
  // Build the token's `image` field as an on-chain keccak256 commitment over the bytes,
290
291
  // and (with --image) store those bytes in custody keyed by that hash so the resolver
291
292
  // can serve + verify them. Without --image, fall back to the demo's generative-from-
292
- // address art (recomputable, nothing to store). Returns on-chain field inputs.
293
+ // address content (recomputable, nothing to store). Returns on-chain field inputs.
293
294
  export const imageKeccakField = (value) => ({
294
295
  field: encodeTag(F.image),
295
296
  representation: encodeTag(R.keccak256),
@@ -364,12 +365,12 @@ walletAddr) {
364
365
  // off-chain mode commits its keccak and regenerates/serves it.
365
366
  if (onChain) {
366
367
  return {
367
- tokenFields: [imageInlineField(generateArt(clone))],
368
+ tokenFields: [imageInlineField(generateContent(clone))],
368
369
  contentNote: 'content: generative SVG, INLINE on-chain (self-resolving, no custody)',
369
370
  };
370
371
  }
371
372
  return {
372
- tokenFields: [imageKeccakField(artContentHash(clone))],
373
+ tokenFields: [imageKeccakField(contentHash(clone))],
373
374
  contentNote: 'content: generative SVG from the contract address (demo default)',
374
375
  };
375
376
  }
@@ -509,7 +510,7 @@ export function describeTraits(traits, onchain) {
509
510
  // ── deploy (+ optionally serve) ──────────────────────────────────────────────
510
511
  export async function cmdDeploy(flags, serveAfter) {
511
512
  // `--copies <N|open>` routes to the ERC-1155 edition twin (OneOfOneEdition) — the flagship
512
- // edition product: a priced open/limited edition of ONE artwork. Checked before anything else
513
+ // edition product: a priced open/limited edition of ONE work. Checked before anything else
513
514
  // touches `flags`, so the 721 lane (`cmdDeployBody`) below is reached ONLY when `--copies` is
514
515
  // absent, and is otherwise untouched by this feature — byte-identical to before editions shipped.
515
516
  if (flags.copies !== undefined) {
@@ -543,7 +544,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
543
544
  // lockable) — mirrors the description model. On-chain wins if both ever set the same trait.
544
545
  const traits = parseDeployTraits(flags);
545
546
  console.log(serveAfter
546
- ? bold(`\n ABX · your first token\n ${dim("we'll put art on a blockchain — then delete our copy and get all of it back")}`)
547
+ ? bold(`\n ABX · your first token\n ${dim("we'll put your work on a blockchain — then delete our copy and get all of it back")}`)
547
548
  : bold(`\n ABX Self-Host Toolkit — deploy\n ${dim('a project, served from chain alone')}`));
548
549
  // Signing lane. `demo` used to hard-force the hot lane, which SILENTLY dropped `--sign`: with no
549
550
  // env key it died confusingly, and WITH one it signed from that key while the operator had asked
@@ -639,17 +640,17 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
639
640
  // otherwise leave it EMPTY rather than baking a misleading localhost into the contract.
640
641
  const hasPublicUrl = !!(flags['public-base-url'] || process.env.ABX_PUBLIC_BASE_URL);
641
642
  const onchainImage = !!flags['onchain-image'];
642
- // `abx demo` defaults to FULLY ON-CHAIN. Its default art is a generative SVG, which the renderer
643
+ // `abx demo` defaults to FULLY ON-CHAIN. Its default content is a generative SVG, which the renderer
643
644
  // can inline — so the token self-resolves and, crucially, NO localhost gets baked into the
644
645
  // contract as the tokenURI base. The old default shipped a first-ever token that resolved for
645
646
  // nobody but its author (broken on every marketplace, dead the moment `abx serve` stops) and
646
- // taught that as the normal shape of an NFT. It also undercut the demo's own claim: with the art
647
+ // taught that as the normal shape of an NFT. It also undercut the demo's own claim: with the content
647
648
  // on-chain, "rebuilt from the chain alone" now covers the IMAGE, not just the metadata.
648
649
  //
649
650
  // Opting back into off-chain custody is anything that says "I have somewhere to host": a real base
650
651
  // URL, an explicit --backend, or a RASTER --image. An SVG --image still goes on-chain — inlining is
651
652
  // exactly what the renderer supports (v1 is SVG-only), so there's no reason to send someone's own
652
- // vector art down the localhost path. Raster stays off-chain because forcing it on-chain would
653
+ // vector content down the localhost path. Raster stays off-chain because forcing it on-chain would
653
654
  // silently inline a placeholder instead of their image.
654
655
  const demoImageInlineable = !flags.image || contentTypeFromPath(flags.image) === 'image/svg+xml';
655
656
  const demoDefaultsOnChain = serveAfter && demoImageInlineable && !flags.backend && !hasPublicUrl;
@@ -680,12 +681,12 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
680
681
  // and it's invisible unless someone says it out loud. Two honest versions, because the answer is
681
682
  // genuinely different per lane. Demo only — `deploy` prints the same facts per-field.
682
683
  if (serveAfter && onChainUri) {
683
- console.log(` Your art goes ${bold('INTO')} the contract. Not a link to it — the image itself.`);
684
- console.log(` ${g('✦')} the chain will hold ${dim('your art · your name on it · your royalty · you as owner')}`);
684
+ console.log(` Your work goes ${bold('INTO')} the contract. Not a link to it — the image itself.`);
685
+ console.log(` ${g('✦')} the chain will hold ${dim('your work · your name on it · your royalty · you as owner')}`);
685
686
  console.log(` ${g('✦')} you will need ${dim('nothing else. no server, no IPFS pin, no monthly bill to forget')}`);
686
687
  }
687
688
  else if (serveAfter) {
688
- console.log(` ${g('✦')} the chain will hold ${dim('your name · your royalty · you as owner · a fingerprint of the art')}`);
689
+ console.log(` ${g('✦')} the chain will hold ${dim('your name · your royalty · you as owner · a fingerprint of the work')}`);
689
690
  console.log(` ${g('✦')} this computer holds ${dim('the image bytes themselves')}`);
690
691
  info(dim('the chain proves the bytes are unaltered; it does not store them (that fingerprint is a keccak256 hash)'));
691
692
  }
@@ -702,7 +703,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
702
703
  const devAllow = process.env.ABX_DEV_ALLOW_LOCALHOST_URI === '1';
703
704
  const msg = `Off-chain metadata needs a PUBLIC resolver URL baked on-chain — ${hasPublicUrl ? baseUrl : 'localhost'} resolves for no one ` +
704
705
  `(not marketplaces, not wallets, not even your own browser unless \`abx serve\` is running). Pick a real path:\n` +
705
- ` • Fully on-chain, no host: --onchain-image --compress fastlz (best for small static art)\n` +
706
+ ` • Fully on-chain, no host: --onchain-image --compress fastlz (best for small static content)\n` +
706
707
  ` • Hosted resolver: --public-base-url https://your.domain (or set ABX_PUBLIC_BASE_URL)`;
707
708
  if (!devAllow) {
708
709
  if (dryRun)
@@ -769,12 +770,12 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
769
770
  }
770
771
  step('Stage on-chain image');
771
772
  if (dryRun) {
772
- info(previewImageStaging(flags.image, parseCompress(flags.compress)));
773
+ info(previewImageStaging(flags.image, parseCompress(flags.compress), allowUnreadableOnchain(flags)));
773
774
  }
774
775
  else if (lane === 'send') {
775
776
  // hot lane: the env key stages now (deployer-independent — the manifest doesn't depend
776
777
  // on the clone address), then the deploy below bakes in the reader field.
777
- const { field, note } = await stageImageField(flags.image, parseCompress(flags.compress), envStagingSender());
778
+ const { field, note } = await stageImageField(flags.image, parseCompress(flags.compress), envStagingSender(), allowUnreadableOnchain(flags));
778
779
  bakedImage = field;
779
780
  info(note);
780
781
  }
@@ -851,7 +852,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
851
852
  royaltyBps,
852
853
  transferValidator,
853
854
  tokenFields,
854
- // authorship + rights (artist / license / …) are collection-scope on-chain fields.
855
+ // authorship + rights (creator / license / …) are collection-scope on-chain fields.
855
856
  contractFields: authorshipContractFields(flags),
856
857
  };
857
858
  return { clone, params, salt, contentNote };
@@ -917,7 +918,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
917
918
  return;
918
919
  }
919
920
  // opt-in --confirm: a final y/N before the real send (no-op without --confirm; never blocks scripts)
920
- await confirmSend(`About to deploy 1/1 "${name}" (${symbol})${flags.image ? ' with your image' : ' (generative demo art)'}; mint: ${noMint ? 'deferred' : 'token #0 at deploy'}; approvals: ${approvals} wallet approval(s); owner+royalty: your wallet @ ${royaltyBps / 100}%.` +
921
+ await confirmSend(`About to deploy 1/1 "${name}" (${symbol})${flags.image ? ' with your image' : ' (generative demo content)'}; mint: ${noMint ? 'deferred' : 'token #0 at deploy'}; approvals: ${approvals} wallet approval(s); owner+royalty: your wallet @ ${royaltyBps / 100}%.` +
921
922
  (transferValidator !== zeroAddress ? ` ERC-721C: enrolled at deploy, permanently (validator ${transferValidator}).` : ''), flags);
922
923
  if (lane === 'send') {
923
924
  // hot lane: the env key is the deployer, known up front.
@@ -960,7 +961,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
960
961
  try {
961
962
  const signer = await session.connect();
962
963
  // Stage on-chain through the session (sets bakedImage, which buildForDeployer reads below).
963
- const { field, note } = await stageImageField(flags.image, parseCompress(flags.compress), sessionStagingSender(session));
964
+ const { field, note } = await stageImageField(flags.image, parseCompress(flags.compress), sessionStagingSender(session), allowUnreadableOnchain(flags));
964
965
  bakedImage = field;
965
966
  info(note);
966
967
  const { clone: predicted, params, salt, contentNote } = await buildForDeployer(signer);
@@ -1148,7 +1149,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1148
1149
  keepAlive();
1149
1150
  }
1150
1151
  // ── deploy --copies: OneOfOneEdition (the flagship ERC-1155 product) ─────────────────────────────
1151
- // Copies of ONE artwork — a priced open/limited edition. The edition twin of `cmdDeployBody`,
1152
+ // Copies of ONE work — a priced open/limited edition. The edition twin of `cmdDeployBody`,
1152
1153
  // reusing every standard-agnostic helper it does (content custody, traits, authorship fields, the
1153
1154
  // `--721c`/`--1155c`-shared validator resolution, salt prediction, signing lanes) but written as its
1154
1155
  // own function rather than threaded into `cmdDeployBody` with `if (copies)` branches throughout —
@@ -1168,10 +1169,10 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1168
1169
  // `--type` is the 721 lane's OWN dimension seam (cmdDeployBody redirects `--type series` to
1169
1170
  // `deploy-series`) — it never even reaches this function for that redirect, since --copies routes
1170
1171
  // here before --type is ever inspected. Refuse the confusing combination explicitly rather than
1171
- // silently ignore it (a creator combining them almost certainly means the multi-artwork edition —
1172
+ // silently ignore it (a creator combining them almost certainly means the multi-work edition —
1172
1173
  // `deploy-series --copies`, not this 1/1-edition lane).
1173
1174
  if (flags.type !== undefined && resolveDimension(flags.type) === 'series') {
1174
- throw new Error("--type series + --copies: this is the 1/1-edition lane (one artwork, copies of it) — for N artworks each with copies, " +
1175
+ throw new Error("--type series + --copies: this is the 1/1-edition lane (one work, copies of it) — for N works each with copies, " +
1175
1176
  'use `abx deploy-series --copies <n|open> --dir <folder>` instead. Drop --type here.');
1176
1177
  }
1177
1178
  const editionSize = parseCopies(flags.copies);
@@ -1182,21 +1183,23 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1182
1183
  const baseUrl = flags['public-base-url'] ?? resolveBaseUrl(port);
1183
1184
  const traits = parseDeployTraits(flags);
1184
1185
  console.log(bold(`\n ABX Self-Host Toolkit — deploy edition\n ${dim(editionSize === 0n
1185
- ? 'OneOfOneEdition (ERC-1155) — an OPEN edition of one artwork, served from chain alone'
1186
- : `OneOfOneEdition (ERC-1155) — ${editionSize} cop${editionSize === 1n ? 'y' : 'ies'} of one artwork, served from chain alone`)}`));
1186
+ ? 'OneOfOneEdition (ERC-1155) — an OPEN edition of one work, served from chain alone'
1187
+ : `OneOfOneEdition (ERC-1155) — ${editionSize} cop${editionSize === 1n ? 'y' : 'ies'} of one work, served from chain alone`)}`));
1187
1188
  if (editionSize === 1n)
1188
1189
  info(copiesOneNote('deploy'));
1189
1190
  const lane = laneFromFlags(flags);
1190
1191
  const dryRun = isDryRun(flags);
1191
1192
  const onchainImage = !!flags['onchain-image'];
1192
1193
  // Pure flag-combo validation — checked BEFORE any chain read (predict/factory/renderer all touch
1193
- // the network), same reasoning every other "this combo can never work" refusal in this file uses
1194
- // (e.g. the localhost-URL checks). Sequential staging (chunk tx → deploy) can't be split across
1195
- // the wallet-session/cold lanes in this pass (see the module-level scope note) refuse rather
1196
- // than half-build it.
1197
- if (onchainImage && lane !== 'send') {
1198
- throw new Error('--onchain-image on an edition deploy is wired for the hot lane only right now (a funded ABX_DEPLOYER_PK) — ' +
1199
- 'drop --onchain-image (off-chain custody or --onchain-uri still work on every lane), or switch to the hot lane. Tracked as a follow-up.');
1194
+ // the network), same reasoning every other "this combo can never work" refusal in this file uses.
1195
+ // Staging is a SEQUENCE (chunk write(s)the deploy that references the manifest) where each tx's
1196
+ // receipt feeds the next, so it cannot be signed offline in one run the cold lane is refused on
1197
+ // every lineage, 721 and edition alike. The wallet lane CAN do it (one session signs the chunk
1198
+ // writes and the deploy); this edition path was hot-lane-only purely because it lacked that
1199
+ // session branch, which it now has.
1200
+ if (onchainImage && lane === 'unsigned') {
1201
+ throw new Error('Staging an on-chain image (--onchain-image) needs interactive signing — each chunk tx feeds ' +
1202
+ "the next, so it can't run on the cold lane (--unsigned). Use the hot lane (a funded key) or --sign (browser wallet).");
1200
1203
  }
1201
1204
  if (dryRun)
1202
1205
  assertPreviewDeployer(flags);
@@ -1255,7 +1258,7 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1255
1258
  if (!hasPublicUrl || loopback) {
1256
1259
  const devAllow = process.env.ABX_DEV_ALLOW_LOCALHOST_URI === '1';
1257
1260
  const msg = `Off-chain metadata needs a PUBLIC resolver URL baked on-chain — ${hasPublicUrl ? baseUrl : 'localhost'} resolves for no one. ` +
1258
- `Pick a real path:\n • Fully on-chain (SVG art): --onchain-uri\n • Fully on-chain (any media): --onchain-image --compress fastlz\n • Hosted resolver: --public-base-url https://your.domain`;
1261
+ `Pick a real path:\n • Fully on-chain (SVG content): --onchain-uri\n • Fully on-chain (any media): --onchain-image --compress fastlz\n • Hosted resolver: --public-base-url https://your.domain`;
1259
1262
  if (!devAllow) {
1260
1263
  if (dryRun)
1261
1264
  warn(`would REFUSE to deploy — ${msg}`);
@@ -1273,18 +1276,23 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1273
1276
  }
1274
1277
  step(`Deploy an edition of "${name}" to ${CHAIN}`);
1275
1278
  let bakedImage;
1279
+ // On the WALLET lane staging cannot happen here — it has to run inside the sign session, after the
1280
+ // connect, so the connecting wallet pays for and owns every chunk write. `stageNow` is the hot-lane
1281
+ // (and dry-run) path; the session branch below calls the same helper with a session-backed sender.
1282
+ const stageOnChainImage = async (sender) => {
1283
+ const { field, note } = await stageImageField(flags.image, parseCompress(flags.compress), sender, allowUnreadableOnchain(flags));
1284
+ bakedImage = field;
1285
+ info(note);
1286
+ };
1276
1287
  if (onchainImage) {
1277
1288
  if (!flags.image)
1278
1289
  throw new Error('--onchain-image needs --image <path> (the bytes to put on-chain)');
1279
1290
  step('Stage on-chain image');
1280
- if (dryRun) {
1281
- info(previewImageStaging(flags.image, parseCompress(flags.compress)));
1282
- }
1283
- else {
1284
- const { field, note } = await stageImageField(flags.image, parseCompress(flags.compress), envStagingSender());
1285
- bakedImage = field;
1286
- info(note);
1287
- }
1291
+ if (dryRun)
1292
+ info(previewImageStaging(flags.image, parseCompress(flags.compress), allowUnreadableOnchain(flags)));
1293
+ else if (lane === 'send')
1294
+ await stageOnChainImage(envStagingSender());
1295
+ // lane === 'sign' staged inside the wallet session further down.
1288
1296
  }
1289
1297
  const explicitSalt = parseSaltFlag(flags.salt);
1290
1298
  const noMint = flags['no-mint'] !== undefined;
@@ -1295,6 +1303,21 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1295
1303
  const minter = flags.minter ?? zeroAddress;
1296
1304
  const primaryPayee = flags['primary-payee'] ?? zeroAddress;
1297
1305
  const paused = flags.unpaused === undefined;
1306
+ // Same wallet-signature math as the 721 twin (see `cmdDeployBody`): staging tx(s) + the deploy tx.
1307
+ // The edition paths shipped without this and without the readout line below, so `--copies` previews
1308
+ // silently dropped the approval count the skill promises "every preview" prints — leaving an agent
1309
+ // with nothing to tell the creator about how many wallet prompts to expect.
1310
+ const approvals = onchainImage
1311
+ ? (() => {
1312
+ const plan = computeContentPlan(readFileSync(resolvePath(flags.image)), parseCompress(flags.compress)).plan;
1313
+ return (plan.mode === 'single' ? 1 : plan.txCount) + 1;
1314
+ })()
1315
+ : 1;
1316
+ if (onChainUri) {
1317
+ info(`uri()/contractURI() resolve ON-CHAIN via the renderer — no resolver, no server, no localhost.`);
1318
+ if (!hasPublicUrl)
1319
+ info('off-chain fallback pointer left empty (the renderer is authoritative); set --public-base-url to bake one anyway.');
1320
+ }
1298
1321
  const buildForDeployer = async (deployer) => {
1299
1322
  const salt = explicitSalt ?? saltFor(deployer);
1300
1323
  const clone = await predictClone(publicClient, { factory, salt });
@@ -1367,6 +1390,7 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1367
1390
  if (primaryPayee !== zeroAddress)
1368
1391
  info(`primary payee ${primaryPayee}`);
1369
1392
  info(`paused ${paused}${flags.unpaused ? '' : dim(' (default — pass --unpaused to open at deploy)')}`);
1393
+ info(`approvals ${approvals} wallet approval(s)`); // parity with the 721 twin — TX signatures only
1370
1394
  if (!explicitSalt) {
1371
1395
  console.log(`\n ${bold('salt')} ${g(salt)}`);
1372
1396
  info(`address: pinned by salt — re-run with ${bold(`--salt ${salt}`)} (same address), or ${bold(`abx predict --salt ${salt} --for ${deployer} --copies ${flags.copies}`)}.`);
@@ -1376,7 +1400,7 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1376
1400
  emit(jsonSafe({ command: 'deploy', kind: '1of1-edition', copies: editionSize.toString(), dryRun: true, sent: false, address: explicitSalt ? predicted : null, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt, saltPinned: !!explicitSalt, name, symbol }));
1377
1401
  return;
1378
1402
  }
1379
- await confirmSend(`About to deploy an edition of "${name}" (${symbol}) — ${editionSize === 0n ? 'open' : editionSize.toString()} copies${flags.image ? ' with your image' : ' (generative demo art)'}; mint: ${mintAmount > 0n ? `${mintAmount} at deploy` : 'deferred'}; owner+royalty: your wallet @ ${royaltyBps / 100}%.` +
1403
+ await confirmSend(`About to deploy an edition of "${name}" (${symbol}) — ${editionSize === 0n ? 'open' : editionSize.toString()} copies${flags.image ? ' with your image' : ' (generative demo content)'}; mint: ${mintAmount > 0n ? `${mintAmount} at deploy` : 'deferred'}; approvals: ${approvals} wallet approval(s); owner+royalty: your wallet @ ${royaltyBps / 100}%.` +
1380
1404
  (transferValidator !== zeroAddress ? ` ERC-1155C: enrolled at deploy, permanently (validator ${transferValidator}).` : ''), flags);
1381
1405
  if (lane === 'send') {
1382
1406
  const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
@@ -1394,8 +1418,39 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1394
1418
  ok(`deployed ${clone}`);
1395
1419
  info(`tx ${explorerBase()}/tx/${r.txHash} (block ${blockNumber})`);
1396
1420
  }
1421
+ else if (lane === 'sign' && onchainImage) {
1422
+ // Wallet lane + on-chain staging: ONE sign session signs every chunk write AND the deploy, so the
1423
+ // connecting wallet pays for and owns all of it. Staging can't precede the connect, so it runs
1424
+ // inside the session and the deploy then bakes in the resulting manifest field.
1425
+ // `--onchain-image` implies on-chain resolution, so nothing points at baseUrl — mirrors the 721 1/1.
1426
+ info('a wallet will become the owner; the edition resolves from chain — no URI base is baked in.');
1427
+ const session = await openWalletSession({
1428
+ chainKey: CHAIN,
1429
+ expectedSigner: flags.for,
1430
+ total: approvals, // the SAME staging-tx math the preview's `approvals` line reports
1431
+ port: flags.port ? Number(flags.port) : undefined,
1432
+ signUrlFile: flags['sign-url-file'],
1433
+ });
1434
+ let r;
1435
+ try {
1436
+ const signer = await session.connect();
1437
+ await stageOnChainImage(sessionStagingSender(session)); // sets bakedImage, read by buildForDeployer
1438
+ const { clone: predicted, params, salt, contentNote } = await buildForDeployer(signer);
1439
+ info(contentNote);
1440
+ info(mintAmount > 0n ? `mint: ${mintAmount} cop${mintAmount === 1n ? 'y' : 'ies'} of #0 → ${signer} at deploy` : 'mint: deferred — mint later with `abx mint`');
1441
+ const sent = await session.send(prepareDeployOneOfOneEdition({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted }));
1442
+ r = { txHash: sent.txHash, blockNumber: sent.receipt.blockNumber };
1443
+ clone = predicted;
1444
+ }
1445
+ finally {
1446
+ session.close();
1447
+ }
1448
+ blockNumber = r.blockNumber;
1449
+ ok(`deployed ${clone}`);
1450
+ info(`tx ${explorerBase()}/tx/${r.txHash} (block ${blockNumber})`);
1451
+ }
1397
1452
  else {
1398
- // wallet lane (no on-chain-image staging — refused above) or the cold lane: a single deploy tx.
1453
+ // wallet lane without staging, or the cold lane: a single deploy tx.
1399
1454
  info(onChainUri ? 'a wallet will become the owner; the token resolves from chain — no URI base is baked in.' : `a wallet will become the owner; URIs point at ${baseUrl}`);
1400
1455
  const result = await signTx(async (signer) => {
1401
1456
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
@@ -1455,7 +1510,7 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1455
1510
  // token (`0..N-1`, natural-sorted) — a token's metadata is its token id, and tokens mint
1456
1511
  // strictly in order. Content is hosted exactly like a 1/1, per token — three placements:
1457
1512
  // • off-chain custody (default): keccak-committed, bytes served by the resolver.
1458
- // • `--onchain-uri`: inline each token's SVG on-chain (tiny art; SVG only).
1513
+ // • `--onchain-uri`: inline each token's SVG on-chain (tiny content; SVG only).
1459
1514
  // • `--onchain-image [--compress fastlz]`: stage each token's bytes (raster or SVG) into the
1460
1515
  // shared chunk store and bake a per-token `reader` field — multi-chunk on-chain content,
1461
1516
  // self-resolving via the renderer. Implies on-chain URI resolution.
@@ -1533,13 +1588,16 @@ export async function cmdDeploySeriesBody(flags, emit) {
1533
1588
  throw new Error('Staging on-chain images (--onchain-image) needs interactive signing — each chunk tx feeds ' +
1534
1589
  "the next, so it can't run on the cold lane (--unsigned). Use the hot lane (a funded key) or --sign (browser wallet).");
1535
1590
  }
1536
- // Whole-collection cost guard: per-file warnings fire during staging/preview, but a set of many
1537
- // small files can still sum to an expensive on-chain deploy flag the total once, up front.
1591
+ // Whole-collection WRITE-cost guard: a set of many small files can still sum to an expensive
1592
+ // on-chain deploy, so flag the total once, up front. This is a cost signal only — the READ gate is
1593
+ // per token (`guardOnChainSize`, fired per file during staging/preview), because each token's
1594
+ // `tokenURI` assembles only its own content, so a 300-file collection of 5KB pieces reads fine.
1538
1595
  if (onchainImage) {
1539
1596
  const totalBytes = slots.reduce((n, s) => n + statSync(joinPath(dirPath, s)).size, 0);
1540
1597
  if (totalBytes > ONCHAIN_PROJECT_SOFT_LIMIT) {
1541
- warn(`on-chain collection total is ${Math.round(totalBytes / 1024)}KB across ${slots.length} file(s) — on-chain costs ~200 gas/byte, so this is expensive, not cheaper. ` +
1542
- `Past ~${Math.round(ONCHAIN_PROJECT_SOFT_LIMIT / 1024)}KB total, prefer off-chain: --backend arweave (pay-once, permanent) or a hosted resolver + IPFS.`);
1598
+ warn(`on-chain collection total is ${Math.round(totalBytes / 1024)}KB across ${slots.length} file(s) — on-chain costs ~200 gas/byte to WRITE, so this is expensive, not cheaper. ` +
1599
+ `Past ~${Math.round(ONCHAIN_PROJECT_SOFT_LIMIT / 1024)}KB total, prefer off-chain: --backend arweave (pay-once, permanent) or a hosted resolver + IPFS. ` +
1600
+ `(Per-token readability is judged per file, not on this total.)`);
1543
1601
  }
1544
1602
  }
1545
1603
  const minter = flags.minter ?? zeroAddress;
@@ -1570,6 +1628,10 @@ export async function cmdDeploySeriesBody(flags, emit) {
1570
1628
  if (onChainUri) {
1571
1629
  step('On-chain renderer');
1572
1630
  renderer = dryRun ? (rendererAddress(flags.renderer) ?? zeroAddress) : await ensureRenderer(flags.renderer);
1631
+ // Say WHICH renderer, like the 1/1 lane does. Both series lanes printed the step header with
1632
+ // nothing under it, while the skill's confirm-readout template implies an address is quotable
1633
+ // there — so an agent relaying the readout had a blank line to explain.
1634
+ info(renderer === zeroAddress ? 'would deploy the canonical renderer first' : `${dryRun ? 'would use' : 'using'} renderer ${renderer}`);
1573
1635
  }
1574
1636
  // Off-chain custody bakes the resolver URL on-chain — a localhost URL resolves for no one.
1575
1637
  // (Not a concern with --onchain-uri: the renderer is authoritative and resolves from chain.)
@@ -1578,7 +1640,7 @@ export async function cmdDeploySeriesBody(flags, emit) {
1578
1640
  if (!hasPublicUrl || loopback) {
1579
1641
  const devAllow = process.env.ABX_DEV_ALLOW_LOCALHOST_URI === '1';
1580
1642
  const msg = `Off-chain metadata needs a PUBLIC resolver URL baked on-chain — ${hasPublicUrl ? baseUrl : 'localhost'} resolves for no one. ` +
1581
- `Pick a real path:\n • Fully on-chain (SVG art): --onchain-uri\n • Fully on-chain (any media): --onchain-image --compress fastlz\n • Hosted resolver: --public-base-url https://your.domain`;
1643
+ `Pick a real path:\n • Fully on-chain (SVG content): --onchain-uri\n • Fully on-chain (any media): --onchain-image --compress fastlz\n • Hosted resolver: --public-base-url https://your.domain`;
1582
1644
  if (!devAllow) {
1583
1645
  if (dryRun)
1584
1646
  warn(`would REFUSE to deploy — ${msg}`);
@@ -1607,6 +1669,16 @@ export async function cmdDeploySeriesBody(flags, emit) {
1607
1669
  const offchainImageOnchainJson = onChainUri && !onchainImage && directUrlBackend;
1608
1670
  const backend = (offchainImageOnchainJson || !onChainUri) && !dryRun ? resolveBackend(opts) : undefined;
1609
1671
  const tokenPaths = slots.map((s, i) => ({ tokenId: i, name: s, path: joinPath(dirPath, s) }));
1672
+ // Say WHICH backend holds the bytes, exactly as the 1/1 lane does. Both Series lanes resolved a
1673
+ // backend and never named it, so a creator asking for "images on IPFS" had no way to confirm from
1674
+ // the preview that their collection would actually pin there — the flag was accepted in silence.
1675
+ if (!onchainImage && (offchainImageOnchainJson || !onChainUri)) {
1676
+ const sr = backendResolution(overrides);
1677
+ info(`storage: ${sr.backend} ${dim(`(${sr.source === 'flag' ? '--backend' : sr.source === 'env' ? 'env' : 'default'})`)} — byte custody for the image(s)`);
1678
+ const gwWarn = localGatewayWarning(flags);
1679
+ if (gwWarn)
1680
+ warn(gwWarn);
1681
+ }
1610
1682
  // Wallet-signature count for THIS deploy — the same per-token chunk math that sizes the
1611
1683
  // wallet-lane session `total` below (B2), computed ONCE so preview/confirm text and the real
1612
1684
  // session can never disagree. TX signatures only — see the 1/1's `approvals` for the same note
@@ -1651,7 +1723,7 @@ export async function cmdDeploySeriesBody(flags, emit) {
1651
1723
  const buildImageFields = async (stage) => {
1652
1724
  // --onchain-image: stage every token's bytes into ONE shared chunk store, each a `reader` field.
1653
1725
  if (onchainImage) {
1654
- const { fields } = await stageImageFieldsBatch(tokenPaths.map((s) => s.path), compress, stage, flags['chunk-store']);
1726
+ const { fields } = await stageImageFieldsBatch(tokenPaths.map((s) => s.path), compress, stage, flags['chunk-store'], allowUnreadableOnchain(flags));
1655
1727
  tokenFields = tokenPaths.map(({ tokenId }, i) => tokenFieldOf(tokenId, fields[i]));
1656
1728
  return;
1657
1729
  }
@@ -1767,7 +1839,7 @@ export async function cmdDeploySeriesBody(flags, emit) {
1767
1839
  mintTo: mintCount > 0 ? deployer : zeroAddress,
1768
1840
  mintCount,
1769
1841
  tokenFields,
1770
- // image contract fields (the O(1) url-template) + authorship/rights (artist / license / …).
1842
+ // image contract fields (the O(1) url-template) + authorship/rights (creator / license / …).
1771
1843
  contractFields: [...contractFields, ...authorshipContractFields(flags)],
1772
1844
  };
1773
1845
  return { clone, params, salt };
@@ -1819,7 +1891,7 @@ export async function cmdDeploySeriesBody(flags, emit) {
1819
1891
  }
1820
1892
  if (onchainImage) {
1821
1893
  for (const { tokenId, name: fname, path } of tokenPaths) {
1822
- info(`token ${tokenId} ← ${fname}: ${previewImageStaging(path, compress)}`);
1894
+ info(`token ${tokenId} ← ${fname}: ${previewImageStaging(path, compress, allowUnreadableOnchain(flags))}`);
1823
1895
  }
1824
1896
  }
1825
1897
  info(`approvals ${approvals} wallet approval(s)`); // B2 — TX signatures only
@@ -1994,7 +2066,7 @@ export async function cmdDeploySeriesBody(flags, emit) {
1994
2066
  console.log(` ${bold(`abx refresh ${clone}`)} ${dim('# nudge marketplaces once metadata is live')}\n`);
1995
2067
  }
1996
2068
  // ── deploy-series --copies: EditionImage ──────────────────────────────────────────────────────────
1997
- // N distinct artworks (one per file, like a Series) × copies each. The edition twin of
2069
+ // N distinct works (one per file, like a Series) × copies each. The edition twin of
1998
2070
  // `cmdDeploySeriesBody`, kept as its OWN function for the same reason `cmdDeployOneOfOneEditionBody`
1999
2071
  // is (byte-identical 721 lane — see that function's module note).
2000
2072
  //
@@ -2019,9 +2091,6 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2019
2091
  : `EditionImage (ERC-1155) — N ids from the folder × ${editionSize} cop${editionSize === 1n ? 'y' : 'ies'} each`)}`));
2020
2092
  if (editionSize === 1n)
2021
2093
  info(copiesOneNote('deploy-series'));
2022
- if (flags['onchain-image']) {
2023
- throw new Error('--onchain-image is not yet supported for edition deploys (--copies) — use --onchain-uri (inline SVG) or off-chain custody (--backend ipfs|arweave|cloud) instead. Tracked as a follow-up.');
2024
- }
2025
2094
  if (!flags.dir) {
2026
2095
  throw new Error('abx deploy-series --copies <n|open> --dir <media-dir> [--count N] [--mint-all | --mint-count N | --no-mint] [--mint-amount N] ' +
2027
2096
  '[--onchain-uri (inline SVG) | --backend ipfs|arweave|cloud (off-chain, hosted resolver) | --public-base-url https://…] ' +
@@ -2044,6 +2113,15 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2044
2113
  assertPreviewDeployer(flags);
2045
2114
  assertRealIdentity(flags, { name, symbol, dryRun });
2046
2115
  const lane = laneFromFlags(flags);
2116
+ const onchainImage = !!flags['onchain-image'];
2117
+ const compress = parseCompress(flags.compress);
2118
+ // Staging is a SEQUENCE (chunk writes → the deploy that references each manifest) where every tx's
2119
+ // receipt feeds the next, so it can't be signed offline in one run. Refused on the cold lane for the
2120
+ // same reason on every lineage, 721 and edition alike; hot + wallet both work.
2121
+ if (onchainImage && lane === 'unsigned') {
2122
+ throw new Error('Staging on-chain images (--onchain-image) needs interactive signing — each chunk tx feeds ' +
2123
+ "the next, so it can't run on the cold lane (--unsigned). Use the hot lane (a funded key) or --sign (browser wallet).");
2124
+ }
2047
2125
  const publicClient = makePublicClient({ chainKey: CHAIN });
2048
2126
  await assertChainId(CHAIN, { allowUnreachable: dryRun });
2049
2127
  const transferValidator = await resolveTransferValidatorFlag(flags, publicClient, dryRun, '1155C');
@@ -2060,7 +2138,12 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2060
2138
  if (effectiveMintCount > 0 && mintAmount === 0n) {
2061
2139
  throw new Error('--mint-amount 0 with ids being pre-minted at deploy makes no sense — pass a positive --mint-amount, or drop --mint-all/--mint-count to defer minting entirely.');
2062
2140
  }
2063
- const onChainUri = !!flags['onchain-uri'];
2141
+ // `--onchain-image` IMPLIES on-chain resolution — bytes on-chain behind a renderer that has no
2142
+ // reason to defer to a server. Every sibling lane already reads it that way (the 1/1, the
2143
+ // 1/1-edition, the 721 Series); this one didn't, which was harmless only while `--onchain-image`
2144
+ // was refused here. Now that it's wired, omitting it would demand a `--public-base-url` for a drop
2145
+ // whose bytes are already on-chain — incoherent, and it would bake a resolver URL nobody needs.
2146
+ const onChainUri = !!flags['onchain-uri'] || onchainImage;
2064
2147
  const minter = flags.minter ?? zeroAddress;
2065
2148
  const paused = flags.unpaused === undefined;
2066
2149
  const primaryPayee = flags['primary-payee'] ?? zeroAddress;
@@ -2087,13 +2170,17 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2087
2170
  if (onChainUri) {
2088
2171
  step('On-chain renderer');
2089
2172
  renderer = dryRun ? (rendererAddress(flags.renderer) ?? zeroAddress) : await ensureRenderer(flags.renderer);
2173
+ // Say WHICH renderer, like the 1/1 lane does. Both series lanes printed the step header with
2174
+ // nothing under it, while the skill's confirm-readout template implies an address is quotable
2175
+ // there — so an agent relaying the readout had a blank line to explain.
2176
+ info(renderer === zeroAddress ? 'would deploy the canonical renderer first' : `${dryRun ? 'would use' : 'using'} renderer ${renderer}`);
2090
2177
  }
2091
2178
  if (!onChainUri) {
2092
2179
  const loopback = loopbackBaseUrl(baseUrl);
2093
2180
  if (!hasPublicUrl || loopback) {
2094
2181
  const devAllow = process.env.ABX_DEV_ALLOW_LOCALHOST_URI === '1';
2095
2182
  const msg = `Off-chain metadata needs a PUBLIC resolver URL baked on-chain — ${hasPublicUrl ? baseUrl : 'localhost'} resolves for no one. ` +
2096
- `Pick a real path:\n • Fully on-chain (SVG art): --onchain-uri\n • Hosted resolver: --public-base-url https://your.domain`;
2183
+ `Pick a real path:\n • Fully on-chain (SVG content): --onchain-uri\n • Hosted resolver: --public-base-url https://your.domain`;
2097
2184
  if (!devAllow) {
2098
2185
  if (dryRun)
2099
2186
  warn(`would REFUSE to deploy — ${msg}`);
@@ -2109,22 +2196,126 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2109
2196
  const overrides = storageOverrides(flags);
2110
2197
  const opts = storageOptions(overrides);
2111
2198
  const backendId = overrides.backend ?? process.env.ABX_STORAGE_BACKEND ?? 'fs';
2112
- const backend = !onChainUri && !dryRun ? resolveBackend(opts) : undefined;
2199
+ // Pattern 2 — image off-chain on a durable backend, JSON rendered on-chain, NOTHING to run. Ported
2200
+ // from the 721 Series twin, which has had it all along; `EditionImage` shipped without it, so a
2201
+ // multi-work RASTER edition had no no-server option at all and `--onchain-uri` just refused the
2202
+ // content. That pushed a cold agent into deploying one contract PER work to get permanence (B28).
2203
+ // Nothing on-chain needed changing: `EditionImageInitParams` already takes `contractFields`, and the
2204
+ // renderer's `url-template` substitutes `{id}` from whatever id it is called with — `uri(id)` on a
2205
+ // 1155 reaches it exactly as `tokenURI(id)` does on a 721.
2206
+ const directUrlBackend = backendId === 'ipfs' || backendId === 'arweave' || backendId === 'cloud';
2207
+ const offchainImageOnchainJson = onChainUri && !onchainImage && directUrlBackend;
2208
+ const backend = (offchainImageOnchainJson || (!onChainUri && !onchainImage)) && !dryRun ? resolveBackend(opts) : undefined;
2113
2209
  const tokenPaths = slots.map((s, i) => ({ tokenId: i, name: s, path: joinPath(dirPath, s) }));
2210
+ // The render-gas gate on the dry run. Unlike the 721 Series twin this lane has no per-id
2211
+ // `previewImageStaging` line to hang it off, so without this an oversized edition would only be
2212
+ // refused once `buildFields` reached the first real staging call — i.e. after the chunk store was
2213
+ // already deployed. Same per-id predicate either way (`guardOnChainSize` runs again during staging).
2214
+ if (onchainImage && dryRun) {
2215
+ for (const { name, path } of tokenPaths)
2216
+ guardOnChainSize(statSync(path).size, name, allowUnreadableOnchain(flags));
2217
+ }
2218
+ // Say WHICH backend holds the bytes, exactly as the 1/1 lane does. Both Series lanes resolved a
2219
+ // backend and never named it, so a creator asking for "images on IPFS" had no way to confirm from
2220
+ // the preview that their collection would actually pin there — the flag was accepted in silence.
2221
+ if (!onchainImage && (offchainImageOnchainJson || !onChainUri)) {
2222
+ const sr = backendResolution(overrides);
2223
+ info(`storage: ${sr.backend} ${dim(`(${sr.source === 'flag' ? '--backend' : sr.source === 'env' ? 'env' : 'default'})`)} — byte custody for the image(s)`);
2224
+ const gwWarn = localGatewayWarning(flags);
2225
+ if (gwWarn)
2226
+ warn(gwWarn);
2227
+ }
2114
2228
  const seriesTraits = parseSeriesTraits(flags.attributes ? readFileSync(resolvePath(process.cwd(), flags.attributes), 'utf8') : undefined, slots);
2115
2229
  const seriesTraitsOnchain = seriesTraits.size > 0 && (!!flags['traits-onchain'] || onChainUri);
2116
2230
  const offChainTokenTraits = seriesTraits.size && !seriesTraitsOnchain
2117
2231
  ? JSON.stringify(Object.fromEntries([...seriesTraits].map(([id, a]) => [String(id), a])))
2118
2232
  : undefined;
2233
+ // Wallet-signature count for THIS deploy — parity with the 721 Series twin, which prints it and
2234
+ // whose count the skill promises "every preview" shows. Same per-id chunk math as that twin, so the
2235
+ // preview, the confirm text, and the wallet session's `total` can never disagree.
2236
+ const approvals = onchainImage
2237
+ ? tokenPaths.reduce((n, { path }) => {
2238
+ const plan = computeContentPlan(readFileSync(path), compress).plan;
2239
+ return n + (plan.mode === 'single' ? 1 : plan.txCount);
2240
+ }, 0) + 1 // + the deploy tx
2241
+ : 1;
2119
2242
  const tokenFields = [];
2120
- const buildFields = async () => {
2243
+ // Collection-scope fields the image lane may add (the O(1) `url-template`). Kept separate from the
2244
+ // authorship fields and CONCATENATED at the params site — never assigned over them, or an
2245
+ // `--creator`/`--license` value would silently vanish whenever pattern 2 is in play.
2246
+ let imageContractFields = [];
2247
+ const buildFields = async (stage) => {
2248
+ // Pattern 1: the bytes themselves on-chain, staged into the shared chunk store per id, each id's
2249
+ // field pointing at its manifest. Ported from the 721 Series twin — the edition lane refused this
2250
+ // outright before (B28), though nothing on-chain prevented it.
2251
+ if (onchainImage) {
2252
+ const { fields } = await stageImageFieldsBatch(tokenPaths.map((s) => s.path), compress, stage, flags['chunk-store'], allowUnreadableOnchain(flags));
2253
+ tokenPaths.forEach(({ tokenId }, i) => tokenFields.push(tokenFieldOf(tokenId, fields[i])));
2254
+ if (seriesTraitsOnchain)
2255
+ for (const [tokenId, attrs] of seriesTraits)
2256
+ tokenFields.push(tokenFieldOf(tokenId, attributesInlineField(attrs)));
2257
+ info(`uri()/contractURI() resolve ON-CHAIN via the renderer — the image BYTES are on-chain too, nothing off-chain at all.`);
2258
+ return;
2259
+ }
2260
+ // Pattern 2: one durable upload per id, then a locator on-chain instead of the bytes.
2261
+ if (offchainImageOnchainJson) {
2262
+ const exts = tokenPaths.map(({ path }) => extname(path).toLowerCase());
2263
+ const uniform = exts[0] !== '' && exts.every((e) => e === exts[0]);
2264
+ if (dryRun || !backend) {
2265
+ 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))`);
2268
+ }
2269
+ else {
2270
+ 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))`);
2273
+ }
2274
+ }
2275
+ else if (uniform && backend.putDirectory) {
2276
+ // O(1) directory-base: upload the folder renamed to {id}{ext}; ONE collection url-template.
2277
+ const entries = tokenPaths.map(({ tokenId, path }) => ({ name: `${tokenId}${exts[0]}`, bytes: new Uint8Array(readFileSync(path)), contentType: contentTypeFromPath(path) }));
2278
+ const { base } = await backend.putDirectory(entries);
2279
+ const template = `${base}/{id}${exts[0]}`;
2280
+ imageContractFields = [imageUrlTemplateField(template)];
2281
+ 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))')}`);
2283
+ }
2284
+ else {
2285
+ // mixed extensions (or no directory support): per-id url fields (O(N)).
2286
+ for (const { tokenId, name: fname, path } of tokenPaths) {
2287
+ const bytes = new Uint8Array(readFileSync(path));
2288
+ const hash = hashContent(bytes);
2289
+ await backend.put(hash, { bytes, contentType: contentTypeFromPath(path) });
2290
+ const locator = (await backend.locator?.(hash)) ?? '';
2291
+ if (!locator)
2292
+ 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)));
2294
+ info(`id ${tokenId} ← ${fname} → ${locator}`);
2295
+ }
2296
+ info(`mixed file extensions → per-id url fields (O(N)); a uniform extension enables the O(1) directory template`);
2297
+ }
2298
+ if (seriesTraitsOnchain)
2299
+ for (const [tokenId, attrs] of seriesTraits)
2300
+ tokenFields.push(tokenFieldOf(tokenId, attributesInlineField(attrs)));
2301
+ info(`uri()/contractURI() resolve ON-CHAIN via the renderer — the image bytes live on ${backendId}, so there is still no server to run.`);
2302
+ return;
2303
+ }
2121
2304
  for (const { tokenId, name: fname, path } of tokenPaths) {
2122
2305
  const bytes = new Uint8Array(readFileSync(path));
2123
2306
  const contentType = contentTypeFromPath(path);
2124
2307
  if (onChainUri) {
2125
2308
  const text = Buffer.from(bytes).toString('utf8');
2126
2309
  if (!looksLikeSvg(text)) {
2127
- throw new Error(`--onchain-uri inlines SVG only; id ${tokenId} "${fname}" is ${contentType}. Drop --onchain-uri for off-chain custody instead.`);
2310
+ // Name the real alternatives, the way the 721 twin's version of this refusal does a bare
2311
+ // "drop --onchain-uri" once sent a cold agent fanning a 3-work edition out into THREE
2312
+ // separate contracts to find permanence. The `--backend` route is now the first suggestion
2313
+ // because it IS available on this lane (see `offchainImageOnchainJson` above).
2314
+ throw new Error(`--onchain-uri inlines SVG only; id ${tokenId} "${fname}" is ${contentType}. For raster content in ONE edition contract:\n` +
2315
+ ` • No server, images off-chain: add --backend arweave (or ipfs/cloud) — the image URL is baked into the on-chain JSON\n` +
2316
+ ` • Host the metadata yourself: drop --onchain-uri and pass --public-base-url https://your.domain\n` +
2317
+ ` • Keep it SVG: supply SVG content and --onchain-uri inlines every id on-chain\n` +
2318
+ ` • Bytes fully on-chain: --onchain-image --compress fastlz (hot or --sign lane; --unsigned is refused)`);
2128
2319
  }
2129
2320
  tokenFields.push(tokenFieldOf(tokenId, imageInlineField(text)));
2130
2321
  }
@@ -2139,14 +2330,24 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2139
2330
  if (seriesTraitsOnchain)
2140
2331
  for (const [tokenId, attrs] of seriesTraits)
2141
2332
  tokenFields.push(tokenFieldOf(tokenId, attributesInlineField(attrs)));
2333
+ // AFTER the per-id content actually validated — stating "resolves ON-CHAIN, no server" and then
2334
+ // refusing the very content that was passed reads as the tool contradicting itself.
2335
+ if (onChainUri) {
2336
+ info(`uri()/contractURI() resolve ON-CHAIN via the renderer — no resolver, no server, no localhost.`);
2337
+ if (!hasPublicUrl)
2338
+ info('off-chain fallback pointer left empty (the renderer is authoritative); set --public-base-url to bake one anyway.');
2339
+ }
2142
2340
  };
2143
2341
  if (!dryRun) {
2144
2342
  const custody = onChainUri ? 'inline SVG on-chain' : `off-chain custody (${backendId})`;
2145
2343
  await confirmSend(`About to deploy an edition series "${name}" (${symbol}) — ${count} id(s) × ${editionSize === 0n ? 'open' : editionSize.toString()} cop${editionSize === 1n ? 'y' : 'ies'} each; ${custody}; ` +
2146
- `mint: ${effectiveMintCount > 0 ? `${effectiveMintCount} id(s) × ${mintAmount} at deploy` : 'deferred'}; owner+royalty: your wallet @ ${royaltyBps / 100}%.` +
2344
+ `mint: ${effectiveMintCount > 0 ? `${effectiveMintCount} id(s) × ${mintAmount} at deploy` : 'deferred'}; approvals: ${approvals} wallet approval(s); owner+royalty: your wallet @ ${royaltyBps / 100}%.` +
2147
2345
  (transferValidator !== zeroAddress ? ` ERC-1155C: enrolled at deploy, permanently (validator ${transferValidator}).` : ''), flags);
2148
2346
  }
2149
- await buildFields();
2347
+ // `--onchain-image` defers field-building to a lane branch below: the hot lane stages with the env
2348
+ // key, the wallet lane inside its sign session. Every other custody mode can build up front.
2349
+ if (!onchainImage)
2350
+ await buildFields();
2150
2351
  const buildForDeployer = async (deployer) => {
2151
2352
  const salt = explicitSalt ?? saltFor(deployer);
2152
2353
  const clone = await predictClone(publicClient, { factory, salt });
@@ -2170,7 +2371,9 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2170
2371
  mintCount: effectiveMintCount,
2171
2372
  mintAmount,
2172
2373
  tokenFields,
2173
- contractFields: authorshipContractFields(flags),
2374
+ // Concatenate — the image lane's collection-scope url-template rides ALONGSIDE the authorship
2375
+ // fields. Assigning either over the other would silently drop the creator's --creator/--license.
2376
+ contractFields: [...authorshipContractFields(flags), ...imageContractFields],
2174
2377
  };
2175
2378
  return { clone, params, salt };
2176
2379
  };
@@ -2204,6 +2407,10 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2204
2407
  info(`minter ${minter}`);
2205
2408
  if (primaryPayee !== zeroAddress)
2206
2409
  info(`primary payee ${primaryPayee}`);
2410
+ // `paused` gates whether the public can mint at all — the 1/1-edition twin prints it and an
2411
+ // edition sale is the point of the lane, so it belongs in the readout, not just in the params.
2412
+ info(`paused ${paused}${flags.unpaused ? '' : dim(' (default — pass --unpaused to open at deploy)')}`);
2413
+ info(`approvals ${approvals} wallet approval(s)`); // parity with the 721 Series twin
2207
2414
  if (!explicitSalt) {
2208
2415
  console.log(`\n ${bold('salt')} ${g(salt)}`);
2209
2416
  info(`address: pinned by salt — re-run with ${bold(`--salt ${salt}`)} (same address).`);
@@ -2214,6 +2421,9 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2214
2421
  }
2215
2422
  if (lane === 'send') {
2216
2423
  const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
2424
+ // hot lane: the env key stages every id up front (deployer-independent), then deploys.
2425
+ if (onchainImage)
2426
+ await buildFields(envStagingSender());
2217
2427
  const { clone: predicted, params, salt } = await buildForDeployer(account.address);
2218
2428
  info(`deterministic address: ${predicted}`);
2219
2429
  info(`mint: ${effectiveMintCount > 0 ? `${effectiveMintCount} id(s) × ${mintAmount} cop${mintAmount === 1n ? 'y' : 'ies'} → ${account.address} at deploy` : 'deferred'}`);
@@ -2224,6 +2434,35 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2224
2434
  ok(`deployed ${clone}`);
2225
2435
  info(`tx ${explorerBase()}/tx/${r.txHash} (block ${blockNumber})`);
2226
2436
  }
2437
+ else if (lane === 'sign' && onchainImage) {
2438
+ // Wallet lane + on-chain staging: ONE session signs every chunk write across all ids AND the
2439
+ // deploy. The connecting wallet pays for (and owns) it; staging can't precede the connect, so it
2440
+ // runs inside the session and the deploy then bakes in every id's manifest.
2441
+ info(`a wallet will become the owner; it will approve ${approvals - 1} staging tx(s) + the deploy in one session.`);
2442
+ const session = await openWalletSession({
2443
+ chainKey: CHAIN,
2444
+ expectedSigner: flags.for,
2445
+ total: approvals, // the SAME staging-tx math the preview's `approvals` line reports
2446
+ port: flags.port ? Number(flags.port) : undefined,
2447
+ signUrlFile: flags['sign-url-file'],
2448
+ });
2449
+ let r;
2450
+ try {
2451
+ const signer = await session.connect();
2452
+ await buildFields(sessionStagingSender(session));
2453
+ const { clone: predicted, params, salt } = await buildForDeployer(signer);
2454
+ info(`mint: ${effectiveMintCount > 0 ? `${effectiveMintCount} id(s) × ${mintAmount} cop${mintAmount === 1n ? 'y' : 'ies'} → ${signer} at deploy` : 'deferred'}`);
2455
+ const sent = await session.send(prepareDeployEditionImage({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted }));
2456
+ r = { txHash: sent.txHash, blockNumber: sent.receipt.blockNumber };
2457
+ clone = predicted;
2458
+ }
2459
+ finally {
2460
+ session.close();
2461
+ }
2462
+ blockNumber = r.blockNumber;
2463
+ ok(`deployed ${clone}`);
2464
+ info(`tx ${explorerBase()}/tx/${r.txHash} (block ${blockNumber})`);
2465
+ }
2227
2466
  else {
2228
2467
  info(onChainUri ? 'a wallet will become the owner; the tokens resolve from chain — no URI base is baked in.' : `a wallet will become the owner; URIs point at ${baseUrl}`);
2229
2468
  const result = await signTx(async (signer) => {
@@ -2292,8 +2531,40 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2292
2531
  * (`putDirectory`; ipfs/arweave), its root as the `code` field
2293
2532
  *
2294
2533
  * Schemas: --schema key:Type:Auth[,key:Type:Auth…] (e.g. palette:HexColor:TokenOwner).
2295
- * Seeds: canonical randomizer by default; --no-seed opts out.
2534
+ * Seeds: the canonical pseudorandom seed source by default; --no-seed opts out.
2535
+ */
2536
+ /**
2537
+ * The one honest sentence about the canonical seed, printed beside the `seed source` line in the
2538
+ * deploy plan — the moment a creator is choosing it, not an appendix. `AbxSeedSource` is
2539
+ * pseudorandom from on-chain values: replayable after the mint (what makes generative output
2540
+ * verifiable) and computable *during* it, so a buyer can decline an outcome for the price of gas.
2541
+ * That rules out anything lottery-like; the escape hatch is a per-project address, so we name it —
2542
+ * and now name it as the FLAG it is (`--seed-source 0x…`), not as an env var, because the flag is
2543
+ * probed and the whole point of naming an escape hatch is that a creator can actually reach it.
2544
+ */
2545
+ const seedHonestyNote = ' pseudorandom — replayable after the mint, computable DURING it (a buyer can decline an outcome).\n' +
2546
+ ' Right for diversifying output; NOT strong enough for a raffle or prize draw — for that, pass\n' +
2547
+ ' --seed-source 0x… pointing at your own IAbxSeedSource over commit-reveal or a VRF oracle\n' +
2548
+ ' (a per-project swap, not a fork). Change it later with `abx set-seed-source`.';
2549
+ /**
2550
+ * The `seed source` line of a deploy plan, for both code lanes. Three states, three different
2551
+ * sentences — the honesty note describes `AbxSeedSource`'s OWN properties, so printing it beside a
2552
+ * stranger's contract would be us vouching for randomness we have never seen.
2296
2553
  */
2554
+ function printSeedSourcePlan(seedSource, chainId) {
2555
+ if (seedSource === zeroAddress) {
2556
+ info('seed source: none (--no-seed / not configured)');
2557
+ return;
2558
+ }
2559
+ if (seedSource.toLowerCase() === canonicalSeedSource(chainId).toLowerCase()) {
2560
+ info(`seed source: ${seedSource} ${dim('(canonical AbxSeedSource)')}`);
2561
+ info(seedHonestyNote);
2562
+ return;
2563
+ }
2564
+ info(`seed source: ${seedSource} ${dim('— CUSTOM (yours, not the canonical AbxSeedSource)')}`);
2565
+ info(' probed: it answers seed(uint256,address) with 32 bytes. Its randomness properties are YOURS to\n' +
2566
+ ' state to buyers — ABX makes no claim about them, and the honesty note above does not apply.');
2567
+ }
2297
2568
  // ── the demo walkthrough: teaching sections, demo-only ────────────────────────
2298
2569
  //
2299
2570
  // `abx demo` is a TEACHING command, not a shortcut — the docs point a first-time reader here to
@@ -2381,7 +2652,7 @@ export async function walkthroughRebuild(indexer, address, before) {
2381
2652
  * Which is genuinely a different act per lane, so it reads from the real source in each case rather
2382
2653
  * than always going through the local server:
2383
2654
  * • fully on-chain → call `tokenURI(0)` on the contract. That IS what a marketplace does, and on
2384
- * this lane the whole answer (art included) comes back from the chain with nothing else running.
2655
+ * this lane the whole answer (content included) comes back from the chain with nothing else running.
2385
2656
  * • off-chain custody → fetch the resolver, because that's what the baked URI points at.
2386
2657
  * Reading the on-chain lane over HTTP would have quietly implied the local server was load-bearing
2387
2658
  * when it isn't — the opposite of the lesson.
@@ -2408,9 +2679,9 @@ export async function walkthroughReadBack(state, baseUrl, onChainUri) {
2408
2679
  const img = typeof parsed.image === 'string' ? parsed.image : '';
2409
2680
  console.log(` ${g('✓')} came back with: ${bold(String(parsed.name ?? '(no name)'))}`);
2410
2681
  console.log(` ${dim(`image: ${img.slice(0, 48)}${img.length > 48 ? '…' : ''}`)}`);
2411
- // The punchline of the whole lane: a data: URI means the art travelled IN the answer.
2682
+ // The punchline of the whole lane: a data: URI means the work travelled IN the answer.
2412
2683
  if (img.startsWith('data:'))
2413
- info(`${g('the art itself came back in that answer')} ${dim('— no link to follow, nothing to go missing')}`);
2684
+ info(`${g('the work itself came back in that answer')} ${dim('— no link to follow, nothing to go missing')}`);
2414
2685
  }
2415
2686
  else {
2416
2687
  console.log(` ${dim(uri.slice(0, 160))}${uri.length > 160 ? dim('…') : ''}`);
@@ -2447,12 +2718,12 @@ export async function walkthroughReadBack(state, baseUrl, onChainUri) {
2447
2718
  export const DEPLOY_CODE_FLAGS = new Set([
2448
2719
  // content + collection identity
2449
2720
  'script', 'code-dir', 'name', 'symbol', 'description', 'external-url', 'description-onchain',
2450
- ...AUTHORSHIP_DEPLOY_FIELDS.map(([flag]) => flag), // artist · display-notes · artist-links · license
2721
+ ...AUTHORSHIP_DEPLOY_FIELDS.map(([flag]) => flag), // creator · display-notes · creator-links · license
2451
2722
  'image-base', 'attributes-renderer', 'image-renderer',
2452
2723
  // supply + mint + economics
2453
2724
  'max', 'mint-count', 'mint-all', 'no-mint', 'unpaused', 'minter', 'primary-payee', 'royalty-bps', '721c',
2454
2725
  // params + dependencies
2455
- 'schema', 'no-seed', 'no-delegation', 'dep', 'dep-registry',
2726
+ 'schema', 'no-seed', 'seed-source', 'no-delegation', 'dep', 'dep-registry',
2456
2727
  // serving lane
2457
2728
  'public-base-url', 'onchain-uri', 'generator', 'renderer', 'port',
2458
2729
  // storage (directory mode) — mirrors storageOverrides()
@@ -2474,7 +2745,7 @@ export const SHARED_DEPLOY_FLAGS = [
2474
2745
  // A 1/1 `abx deploy` accepts these (see `abx help deploy`). Anything else warns (typo'd/unsupported).
2475
2746
  export const DEPLOY_FLAGS = new Set([
2476
2747
  'image', 'name', 'symbol', 'type', 'description', 'description-onchain', 'external-url', 'traits', 'traits-onchain', 'attributes',
2477
- 'onchain-uri', 'onchain-image', 'compress', 'royalty-bps', 'no-mint',
2748
+ 'onchain-uri', 'onchain-image', 'compress', 'allow-unreadable-onchain', 'royalty-bps', 'no-mint',
2478
2749
  ...AUTHORSHIP_DEPLOY_FIELDS.map(([flag]) => flag),
2479
2750
  ...SHARED_DEPLOY_FLAGS,
2480
2751
  ]);
@@ -2482,7 +2753,7 @@ export const DEPLOY_FLAGS = new Set([
2482
2753
  export const DEPLOY_SERIES_FLAGS = new Set([
2483
2754
  'dir', 'count', 'name', 'symbol', 'description', 'external-url', 'royalty-bps', 'attributes', 'traits-onchain',
2484
2755
  'no-mint', 'mint-all', 'mint-count', 'unpaused', 'minter', 'primary-payee',
2485
- 'onchain-uri', 'onchain-image', 'compress', 'chunk-store',
2756
+ 'onchain-uri', 'onchain-image', 'compress', 'allow-unreadable-onchain', 'chunk-store',
2486
2757
  ...AUTHORSHIP_DEPLOY_FIELDS.map(([flag]) => flag),
2487
2758
  ...SHARED_DEPLOY_FLAGS,
2488
2759
  ]);
@@ -2516,7 +2787,7 @@ export async function cmdDeployCodeBody(flags, emit) {
2516
2787
  const usage = 'abx deploy-code (--script <file> | --code-dir <dir>) --name "Title" --symbol SYM ' +
2517
2788
  '(--public-base-url https://your.resolver.domain | --onchain-uri) ' +
2518
2789
  '[--description "<s>"] [--external-url <url>] [--image-base <url> | --image-renderer 0x..] [--attributes-renderer 0x..] ' +
2519
- '[--max N] [--mint-count N | --mint-all] [--schema key:Type:Auth,…] [--no-seed] ' +
2790
+ '[--max N] [--mint-count N | --mint-all] [--schema key:Type:Auth,…] [--no-seed | --seed-source 0x..] ' +
2520
2791
  '[--dep <name@version|0x..>[,…]] [--dep-registry 0x..] ' +
2521
2792
  '[--unpaused] [--minter 0x..] [--primary-payee 0x..] [--royalty-bps N] [--721c recommended|0x..] [--backend ipfs|arweave] [--dry-run] [--confirm] [--bootstrap-factory]';
2522
2793
  const scriptPath = flags.script;
@@ -2608,11 +2879,7 @@ export async function cmdDeployCodeBody(flags, emit) {
2608
2879
  else {
2609
2880
  factory = await ensureSeriesCodeFactory(publicClient, flags.factory, !!flags['bootstrap-factory']);
2610
2881
  }
2611
- const seedSource = flags['no-seed'] !== undefined
2612
- ? zeroAddress
2613
- : dryRun
2614
- ? (resolveSeedSource(chainId) ?? zeroAddress)
2615
- : await ensureSeedSource(publicClient);
2882
+ const seedSource = await resolveSeedSourceFlag(flags, publicClient, dryRun, chainId);
2616
2883
  // --onchain-uri: resolve the two canonical singletons the lane points at. The generator is
2617
2884
  // per-chain infrastructure (constructor-wired to the dependency registry, the abx.js/gunzip
2618
2885
  // runtime pointers, and default gateways), so it is NEVER auto-deployed — a chain without one
@@ -2663,6 +2930,13 @@ export async function cmdDeployCodeBody(flags, emit) {
2663
2930
  // schemas: key:Type:Auth (comma-separated), Type carrying optional Select options / Range bounds
2664
2931
  // (Select[A|B|C], Uint256Range[0..100], …) → key, enum indices, bytes32 min/max, selectOptions.
2665
2932
  const schemas = parseSchemaSpecs(flags.schema);
2933
+ // Say it where the decision is made, not in a doc the creator may never open: on an edition, a
2934
+ // holder-writable param is SHARED by every holder of that id. See `editionSchemaAdvisory`.
2935
+ if (flags.copies !== undefined) {
2936
+ const advisory = editionSchemaAdvisory(schemas);
2937
+ if (advisory)
2938
+ warn(advisory);
2939
+ }
2666
2940
  // Dependencies: ordered `--dep` refs (repeatable / comma-separable) — index 0 = the runtime,
2667
2941
  // by convention. `name@version` ⇒ Resolution.Registry (resolved through the soft registry
2668
2942
  // pointer); `0x…` ⇒ Resolution.OnChain (a raw data contract, no registry involved). The legs
@@ -2742,7 +3016,7 @@ export async function cmdDeployCodeBody(flags, emit) {
2742
3016
  }
2743
3017
  else {
2744
3018
  // Renderer-only (in-chain SVG): no program to chunk — the image/attributes are Solidity field
2745
- // renderers. Nothing rides SSTORE2; the whole artwork is computed on-chain per view.
3019
+ // renderers. Nothing rides SSTORE2; the whole work is computed on-chain per view.
2746
3020
  contentSummary = 'no program — image + traits computed on-chain by Solidity field renderers';
2747
3021
  ok(contentSummary);
2748
3022
  }
@@ -2767,14 +3041,14 @@ export async function cmdDeployCodeBody(flags, emit) {
2767
3041
  contractFields.push({ field: encodeTag(F.externalUrl), representation: encodeTag(R.inline), value: toHex(String(flags['external-url'])) });
2768
3042
  identityFields.push('external_url');
2769
3043
  }
2770
- // authorship + rights — same on-chain inline collection-field slot (artist / license / …).
3044
+ // authorship + rights — same on-chain inline collection-field slot (creator / license / …).
2771
3045
  contractFields.push(...authorshipContractFields(flags));
2772
3046
  for (const [flag, field] of AUTHORSHIP_DEPLOY_FIELDS)
2773
3047
  if (flags[flag])
2774
3048
  identityFields.push(field);
2775
3049
  // --image-renderer: bake the on-chain `image` field to a `renderer` representation — the SVG is
2776
3050
  // COMPUTED on-chain by a Solidity IAbxFieldRenderer (e.g. a SeedSvgRenderer fork) from the token's
2777
- // seed + params. THE in-chain-art lane: the marketplace still lives in tokenURI itself — no bucket,
3051
+ // seed + params. THE in-chain-renderer lane: the marketplace still lives in tokenURI itself — no bucket,
2778
3052
  // no effect runner, no resolver. Mutually exclusive with --image-base (both set `image`). Verified
2779
3053
  // like --attributes-renderer: a real deploy REFUSES a codeless address; dry-run probes best-effort.
2780
3054
  if (flags['image-renderer']) {
@@ -2983,7 +3257,15 @@ export async function cmdDeployCodeBody(flags, emit) {
2983
3257
  // TOKENURI PUBLIC-READ GAS — a large --onchain-uri document assembles on-chain per call; an
2984
3258
  // unauthenticated public read (Etherscan "Read Contract" with no wallet connected) can hit a client
2985
3259
  // gas cap and appear to REVERT. Expected, not a bug — a real session mis-diagnosed it as indexing lag.
2986
- const tokenUriGasRisk = onChainUri && !codeDir && (scriptAnalysis?.doc.estBytes ?? 0) > 30_000;
3260
+ //
3261
+ // Same threshold as the staged-bytes gate (`ONCHAIN_READ_WARN_BYTES`), since it is the same measured
3262
+ // cost: `tokenURI` string-building runs ~360-405k gas per KB of assembled document (superlinear — the
3263
+ // rate climbs with size). This one only WARNS
3264
+ // where the staged-bytes path refuses, and deliberately so: `estBytes` is an ESTIMATE (a dependency
3265
+ // of unknown size is undercounted — see `unknownDepSizes`), and a registry-hosted library like p5 is
3266
+ // legitimately ~200KB on its own, so the honest move is to name the cost and point at the piecewise
3267
+ // getters rather than to block a shipped lane on a heuristic.
3268
+ const tokenUriGasRisk = onChainUri && !codeDir && (scriptAnalysis?.doc.estBytes ?? 0) >= ONCHAIN_READ_WARN_BYTES;
2987
3269
  // --onchain-uri legs (ride the setup multicall, before the mints): the animation_url field
2988
3270
  // pointing at the generator, and the two URI renderers. The param surface needs NO leg — the
2989
3271
  // generator enumerates it from the token on-chain (`contractParamKeys`/`tokenParamKeys`, kept in
@@ -3071,7 +3353,7 @@ export async function cmdDeployCodeBody(flags, emit) {
3071
3353
  if (!flags.description)
3072
3354
  warn(`no --description — the on-chain metadata ships with no description (the one creators most often forget). Add --description "…" or deploy bare.`);
3073
3355
  info(`content: ${contentSummary}`);
3074
- info(`seed source: ${seedSource === zeroAddress ? 'none (--no-seed / not configured)' : seedSource}`);
3356
+ printSeedSourcePlan(seedSource, chainId);
3075
3357
  info(`PostParam schema(s): ${schemaSummary}`);
3076
3358
  info(`dependencies: ${deps.length ? deps.map((d, i) => `[${i}] ${d.display}`).join(' · ') + ` — ${deps.length} setDependency leg(s)` : 'none'}` +
3077
3359
  (depRegistry ? ` + setDependencyRegistry → ${depRegistry}` : hasRegistryDeps ? ' (no registry pointer — see the warning above)' : ''));
@@ -3108,7 +3390,9 @@ export async function cmdDeployCodeBody(flags, emit) {
3108
3390
  else
3109
3391
  info(` ${dim('nothing to run — image + traits are on-chain; a resolver is neither needed nor a benefit here.')}`);
3110
3392
  if (tokenUriGasRisk) {
3111
- info(` ${dim('public-read note:')} this ${Math.round((scriptAnalysis?.doc.estBytes ?? 0) / 1000)}KB doc assembles on-chain per call, so an ${bold('unauthenticated read on Etherscan')} ("Read Contract", no wallet connected) may hit its gas cap and appear to ${bold('REVERT')} — ${dim('EXPECTED, not a broken token and NOT an indexing issue. Verify with `abx tokenuri`, or connect a wallet / use a high-gas RPC. Do not run `abx index --full` to "fix" it.')}`);
3393
+ const docKb = Math.round((scriptAnalysis?.doc.estBytes ?? 0) / 1024);
3394
+ info(` ${dim('public-read note:')} this ~${docKb}KB doc assembles on-chain per call — ${bold(`~${Math.round(tokenUriGasEstimate(scriptAnalysis?.doc.estBytes ?? 0) / 1_000_000)}M gas per tokenURI`)} at the measured ~360-405k gas/KB (climbing with size). An ${bold('unauthenticated read on Etherscan')} ("Read Contract", no wallet connected) may hit its gas cap and appear to ${bold('REVERT')} — ${dim('EXPECTED, not a broken token and NOT an indexing issue. Verify with `abx tokenuri`, or connect a wallet / use a high-gas RPC. Do not run `abx index --full` to "fix" it.')}`);
3395
+ info(` ${dim('these are OFF-CHAIN view reads by design:')} past a node\'s cap, a client assembles from the generator\'s piecewise getters instead of one call — ${dim('`document`, `tokenDataJson`, `dependencyTag`, `abxJs`, `gunzipScript`, `registryScriptChunk`. No contract can call tokenURI inside a transaction at this size; nothing on-chain depends on it.')}`);
3112
3396
  }
3113
3397
  }
3114
3398
  else {
@@ -3622,7 +3906,7 @@ export async function cmdDeployCodeBody(flags, emit) {
3622
3906
  // • `--code-dir` (directory-build code drops) — not ported; use `--script`.
3623
3907
  // • `--dep` / `--dep-registry` (declared code dependencies) — not ported; ship a self-contained
3624
3908
  // script (the skill already recommends zero-dependency vanilla JS as the cleanest option).
3625
- // • `--image-renderer` / `--attributes-renderer` / `--image-base` (the in-chain-Solidity-art and
3909
+ // • `--image-renderer` / `--attributes-renderer` / `--image-base` (the in-chain-Solidity-renderer and
3626
3910
  // deterministic-off-chain-thumbnail lanes) — not ported.
3627
3911
  // • `--no-delegation` — refused outright: EditionCode has no TokenOwner-leg delegation to opt out
3628
3912
  // of (its auth leg generalizes to "any holder", per the parity plan's ConfigurableParams note).
@@ -3634,7 +3918,7 @@ export const DEPLOY_CODE_EDITION_FLAGS = new Set([
3634
3918
  'script', 'name', 'symbol', 'description', 'external-url', 'description-onchain',
3635
3919
  ...AUTHORSHIP_DEPLOY_FIELDS.map(([flag]) => flag),
3636
3920
  'max', 'mint-count', 'mint-all', 'no-mint', 'mint-amount', 'unpaused', 'minter', 'primary-payee', 'royalty-bps', '721c',
3637
- 'schema', 'no-seed',
3921
+ 'schema', 'no-seed', 'seed-source',
3638
3922
  'public-base-url', 'onchain-uri', 'generator', 'renderer', 'port',
3639
3923
  'sign', 'unsigned', 'for', 'salt', 'factory', 'bootstrap-factory', 'sign-url-file',
3640
3924
  'dry-run', 'confirm', 'yes', 'json', 'copies',
@@ -3648,7 +3932,7 @@ export const DEPLOY_CODE_EDITION_FLAGS = new Set([
3648
3932
  export async function cmdDeployEditionCodeBody(flags, emit) {
3649
3933
  const usage = 'abx deploy-code --copies <n|open> --script <file> --name "Title" --symbol SYM (--public-base-url https://your.resolver.domain | --onchain-uri) ' +
3650
3934
  '[--description "<s>"] [--external-url <url>] [--max N] [--mint-count N | --mint-all] [--mint-amount N] ' +
3651
- '[--schema key:Type:Auth,…] [--no-seed] [--unpaused] [--minter 0x..] [--primary-payee 0x..] [--royalty-bps N] [--721c recommended|0x..]';
3935
+ '[--schema key:Type:Auth,…] [--no-seed | --seed-source 0x..] [--unpaused] [--minter 0x..] [--primary-payee 0x..] [--royalty-bps N] [--721c recommended|0x..]';
3652
3936
  refuseStrayFlags(flags, DEPLOY_CODE_EDITION_FLAGS, 'deploy-code');
3653
3937
  warnSignWithoutFor(flags);
3654
3938
  const editionSize = parseCopies(flags.copies);
@@ -3657,10 +3941,6 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
3657
3941
  }
3658
3942
  if (!flags.script)
3659
3943
  throw new Error(usage);
3660
- if (flags.dep || flags['dep-registry']) {
3661
- throw new Error('--dep/--dep-registry are not yet supported for edition code deploys (--copies) — ship a self-contained script ' +
3662
- '(no external dependency declarations) for now, or drop --copies for the 721 lane. Tracked as a follow-up.');
3663
- }
3664
3944
  if (flags['image-renderer'] || flags['attributes-renderer'] || flags['image-base']) {
3665
3945
  throw new Error('--image-renderer/--attributes-renderer/--image-base are not yet supported for edition code deploys (--copies). Tracked as a follow-up.');
3666
3946
  }
@@ -3734,11 +4014,7 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
3734
4014
  else {
3735
4015
  factory = await ensureEditionCodeFactory(publicClient, flags.factory, !!flags['bootstrap-factory']);
3736
4016
  }
3737
- const seedSource = flags['no-seed'] !== undefined
3738
- ? zeroAddress
3739
- : dryRun
3740
- ? (resolveSeedSource(chainId) ?? zeroAddress)
3741
- : await ensureSeedSource(publicClient);
4017
+ const seedSource = await resolveSeedSourceFlag(flags, publicClient, dryRun, chainId);
3742
4018
  let generator = zeroAddress;
3743
4019
  let metadataRenderer = zeroAddress;
3744
4020
  if (onChainUri) {
@@ -3763,6 +4039,50 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
3763
4039
  info('off-chain fallback pointer left empty (the renderer is authoritative); set --public-base-url to bake one anyway.');
3764
4040
  }
3765
4041
  const schemas = parseSchemaSpecs(flags.schema);
4042
+ // Say it where the decision is made, not in a doc the creator may never open: on an edition, a
4043
+ // holder-writable param is SHARED by every holder of that id. See `editionSchemaAdvisory`.
4044
+ if (flags.copies !== undefined) {
4045
+ const advisory = editionSchemaAdvisory(schemas);
4046
+ if (advisory)
4047
+ warn(advisory);
4048
+ }
4049
+ // Dependencies — same shape as the 721 SeriesCode twin (see its block for the full reasoning).
4050
+ // `EditionCode` already inherits the `Dependencies` extension and calls `_initDependencies()`, and
4051
+ // the legs are the same `setDependency`/`setDependencyRegistry` calls on the same ABI, so this was
4052
+ // never a contract gap: only the CLI refused it (B28). It matters because "an edition of my p5
4053
+ // sketch, with p5 coming from the chain" is a thing creators ask for directly, and the refusal sent
4054
+ // them to the 721 lane (unique tokens) or to shipping a sketch whose library never loads.
4055
+ const deps = parseDepFlag(flags.dep);
4056
+ const hasRegistryDeps = deps.some((d) => d.resolution === DEP_RESOLUTION.registry);
4057
+ const depPointer = hasRegistryDeps
4058
+ ? resolveDepRegistryPointer(flags['dep-registry'], chainId)
4059
+ : { registry: null, source: 'none' };
4060
+ const depRegistry = depPointer.registry;
4061
+ if (deps.length) {
4062
+ step('Dependencies');
4063
+ deps.forEach((d, i) => info(`[${i}] ${bold(d.display)} ${dim(d.resolution === DEP_RESOLUTION.registry ? '(registry name@version)' : '(on-chain data contract — read directly)')}${i === 0 ? dim(' · index 0 = the runtime') : ''}`));
4064
+ if (hasRegistryDeps) {
4065
+ if (depRegistry) {
4066
+ info(`registry pointer → ${depRegistry} ${dim(depPointer.source === 'flag' ? '(--dep-registry)' : "(the chain's AB Dependency Registry — soft, non-validating)")}`);
4067
+ const { checks, rpcOk } = await checkRegistryDeps(publicClient, depRegistry, deps);
4068
+ for (const chk of checks) {
4069
+ if (chk.status === 'found') {
4070
+ ok(chk.details.availableOnChain
4071
+ ? `${chk.dep} — on registry; ON-CHAIN bytes available (${chk.details.scriptCount} chunk(s)) — chain-complete capable`
4072
+ : `${chk.dep} — on registry; served from CDN ${chk.details.preferredCDN || '(none listed)'} ${dim('— the normal production path, not a degradation')}`);
4073
+ }
4074
+ else if (chk.status === 'not-found') {
4075
+ warn(`${bold(chk.dep)} NOT FOUND on registry ${depRegistry} — the resolver won't resolve it from there. Deploy proceeds (you may intend a custom registry / a pending addition); double-check the exact name@version spelling.`);
4076
+ }
4077
+ }
4078
+ if (!rpcOk)
4079
+ info('registry check skipped (RPC unreachable) — the deploy does not depend on it.');
4080
+ }
4081
+ else {
4082
+ warn('no dependency registry known for this chain — skipping the setDependencyRegistry leg (the pointer is SOFT; the resolver falls back to its built-in CDN map). Pass --dep-registry 0x… or run `abx set-dependency-registry` later.');
4083
+ }
4084
+ }
4085
+ }
3766
4086
  step('Content');
3767
4087
  const scriptPath = flags.script;
3768
4088
  const source = readFileSync(resolvePath(scriptPath), 'utf8');
@@ -3823,6 +4143,10 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
3823
4143
  args: [encodeTag(s.key), s.paramType, s.auth, s.authAddress, s.lockAfter, s.min, s.max, s.selectOptions],
3824
4144
  }));
3825
4145
  }
4146
+ // Dependency legs, ordered (index 0 = the runtime) + the SOFT registry pointer when one resolved.
4147
+ // Built by the SDK's shared `dependencySetupCalls` — the same function the 721 lane uses — so the
4148
+ // two lanes can never encode `setDependency` differently.
4149
+ calls.push(...dependencySetupCalls(deps, depRegistry));
3826
4150
  calls.push(...onchainUriLegs);
3827
4151
  // Reserve mints: one `mint(to, id, amount)` per premint id — the edition twin of the 721 lane's
3828
4152
  // N identical `mint(owner)` calls, one id finer (a specific amount of copies, not just "one").
@@ -3849,7 +4173,11 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
3849
4173
  }
3850
4174
  return { clone, txs };
3851
4175
  };
3852
- const approvals = 1 + (scriptChunks.length + schemas.length + onchainUriLegs.length + mintCount > 0 ? 1 : 0);
4176
+ // deps ride the same single setup multicall as chunks/schemas/mints, so they don't add an approval
4177
+ // but they must be COUNTED in the "is there a setup multicall at all" test, or a deps-only project
4178
+ // (a script with no schemas and no premints) would report 1 approval and send 2 transactions.
4179
+ const depLegCount = dependencySetupCalls(deps, depRegistry).length;
4180
+ const approvals = 1 + (scriptChunks.length + schemas.length + depLegCount + onchainUriLegs.length + mintCount > 0 ? 1 : 0);
3853
4181
  if (!dryRun) {
3854
4182
  await confirmSend(`About to deploy an edition code project "${name}" (${symbol}) — script ${bytes.length} bytes → ${scriptChunks.length} chunk(s); ` +
3855
4183
  `up to ${max} id(s) × ${editionSize === 0n ? 'open' : editionSize.toString()} cop${editionSize === 1n ? 'y' : 'ies'} each; ` +
@@ -3881,10 +4209,11 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
3881
4209
  info(`deterministic address: ${shownAddr}`);
3882
4210
  info(`name "${name}" · symbol ${symbol} · max ${max} id(s) · royalty ${royaltyBps / 100}%`);
3883
4211
  info(`content: script ${bytes.length} bytes → ${scriptChunks.length} on-chain chunk(s)`);
3884
- info(`seed source: ${seedSource === zeroAddress ? 'none (--no-seed / not configured)' : seedSource}`);
4212
+ printSeedSourcePlan(seedSource, chainId);
3885
4213
  info(`PostParam schema(s): ${schemas.length ? schemas.map((s) => describeSchema(s)).join(', ') : 'none'}`);
3886
4214
  info(`mint: ${mintCount > 0 ? `${mintCount} id(s) × ${mintAmount} cop${mintAmount === 1n ? 'y' : 'ies'} → ${deployer ?? 'your wallet'} at deploy` : 'deferred (mint later / external minter)'}`);
3887
4215
  info(`transactions: 2 — deploy + setup multicall (script chunks${schemas.length ? ' + schemas' : ''}${onChainUri ? ' + on-chain URI wiring' : ''}${mintCount > 0 ? ' + reserve mints' : ''})`);
4216
+ info(`approvals ${approvals} wallet approval(s)`); // parity with the 721 code twin — it had this only in the --confirm sentence
3888
4217
  if (!explicitSalt && deployer) {
3889
4218
  console.log(`\n ${bold('salt')} ${g(salt)}`);
3890
4219
  info(`address: pinned by salt — re-run with ${bold(`--salt ${salt}`)} (same address).`);
@@ -3974,26 +4303,28 @@ export async function resolveTransferValidatorFlag(flags, publicClient, dryRun,
3974
4303
  return zeroAddress;
3975
4304
  const chainId = resolveChain(CHAIN).id;
3976
4305
  const validator = parseTransferValidatorValue(raw, { chainId, chainLabel: CHAIN });
3977
- // Has-code precheck — for the recommended constant too (presence on THIS chain is the fact that
3978
- // matters; a sandbox/private chain won't have it). A dry run tolerates an unreachable RPC (it
3979
- // sends nothing); a real deploy refuses to enroll blind.
3980
- let code;
3981
- try {
3982
- code = await publicClient.getCode({ address: validator });
3983
- }
3984
- catch (err) {
4306
+ // Usability precheck — for the recommended constant too (presence on THIS chain is the fact that
4307
+ // matters; a sandbox/private chain won't have it). This asks the same question the factory asks:
4308
+ // has code AND is not a permissive fallback that would enforce nothing (a Safe, an uninitialised
4309
+ // proxy, a 7702-delegated EOA all pass a bare has-code check and then wave every transfer
4310
+ // through). A dry run tolerates an unreachable RPC (it sends nothing); a real deploy refuses to
4311
+ // enroll blind.
4312
+ const probe = await probeTransferValidator(publicClient, validator);
4313
+ if (probe.verdict === 'unreachable') {
3985
4314
  if (!dryRun)
3986
- throw new Error(`--721c: couldn't verify the validator has code at ${validator} (${err.message}) — refusing to enroll blind; retry when the RPC answers.`);
3987
- code = null; // offline previewnote it below
3988
- }
3989
- if (code === null) {
3990
- info(dim(`--721c: RPC unreachable, so the validator code check is deferred — a real deploy verifies ${validator} has code first.`));
4315
+ throw new Error(`--721c: couldn't verify the validator at ${validator} (${probe.error ?? 'RPC did not answer'}) — refusing to enroll blind; retry when the RPC answers.`);
4316
+ info(dim(`--721c: RPC unreachable, so the validator check is deferred a real deploy verifies ${validator} is a usable validator first.`));
3991
4317
  }
3992
- else if (!code || code === '0x') {
4318
+ else if (probe.verdict !== 'ok') {
3993
4319
  const rec = resolveRecommendedTransferValidator(chainId);
3994
- throw new Error(`--721c: no contract code at ${validator} on ${CHAIN} the deploy would revert InvalidTransferValidator(). ` +
3995
- `A transfer validator must be a DEPLOYED contract on this chain` +
3996
- (rec && rec !== validator ? ` (the known-good one: --721c recommended → ${rec})` : '') + `.`);
4320
+ const recHint = rec && rec !== validator ? ` (the known-good one: --721c recommended → ${rec})` : '';
4321
+ throw new Error(probe.verdict === 'no-code'
4322
+ ? `--721c: no contract code at ${validator} on ${CHAIN} the deploy would revert InvalidTransferValidator(). ` +
4323
+ `A transfer validator must be a DEPLOYED contract on this chain${recHint}.`
4324
+ : `--721c: ${validator} has code on ${CHAIN}, but it is not a transfer validator — it answers ANY function call ` +
4325
+ `successfully (a Safe, an uninitialised proxy, or a 7702-delegated EOA does this), so every transfer would ` +
4326
+ `silently pass validation while ERC-165 and getTransferValidator() reported enforcement as ON. The factory ` +
4327
+ `refuses it too (InvalidTransferValidator()). Pass a real validator contract${recHint}, or drop --721c.`);
3997
4328
  }
3998
4329
  // The one plain statement — printed once, only when enrolling. Never appears un-enrolled.
3999
4330
  info(validator === resolveRecommendedTransferValidator(chainId)
@@ -4001,4 +4332,79 @@ export async function resolveTransferValidatorFlag(flags, publicClient, dryRun,
4001
4332
  : `ERC-${standard}: every non-mint transfer is checked by validator ${validator} — its policy decides which operators may transfer. Manage with \`abx set-transfer-validator\`.`);
4002
4333
  return validator;
4003
4334
  }
4335
+ /**
4336
+ * Resolve the three-way seed decision into the InitParams `seedSource`, for both code lanes:
4337
+ *
4338
+ * - `--no-seed` → `zeroAddress`. No mint-time seed at all.
4339
+ * - nothing, or `canonical` → the canonical `AbxSeedSource` via `ensureSeedSource` (manifest →
4340
+ * self-heal to the deterministic address → CREATE2-deploy it). This
4341
+ * is today's behaviour, byte-for-byte, and stays the default.
4342
+ * - `--seed-source 0x…` → the creator's own `IAbxSeedSource`, **probed first**.
4343
+ *
4344
+ * The flag exists because we tell creators, in the docs and in this command's own help, that the fix
4345
+ * for "the canonical seed isn't strong enough for my raffle" is to point `seedSource` at a
4346
+ * commit-reveal / VRF-backed source of their own. Until now the only route was an `ABX_SEED_SOURCE`
4347
+ * env var checked for nothing but code length, which made a first-class promise out of a
4348
+ * side entrance.
4349
+ *
4350
+ * **Everything that isn't the canonical singleton gets probed**, including an address that arrived
4351
+ * via `ABX_SEED_SOURCE` — so the env lane keeps working and stops being the unchecked one. The
4352
+ * comparison is against the deterministic canonical address rather than "did a flag say so", because
4353
+ * that is the actual question (is this our audited bytecode, or someone's contract we know nothing
4354
+ * about?). A dry run tolerates an unreachable RPC and says the check is deferred; a real deploy
4355
+ * refuses to bake a seed source it couldn't verify.
4356
+ *
4357
+ * The probe here runs with **no `from`**, unlike the owner op's (which calls as the token). That is
4358
+ * not an oversight: the clone's address is a function of the CREATE2 salt, which isn't reserved yet
4359
+ * at this point, so a source that gates on `msg.sender` could not have been told about this token in
4360
+ * the first place. A gated/commit-reveal source is a deploy-then-`abx set-seed-source` flow by
4361
+ * construction, and the `reverted` refusal says exactly that.
4362
+ */
4363
+ export async function resolveSeedSourceFlag(flags, publicClient, dryRun, chainId) {
4364
+ const raw = flags['seed-source'];
4365
+ // `--resume` finishes an EXISTING contract, and the seed source is an InitParams field — fixed at
4366
+ // creation, never in the setup multicall this replays. So there is nothing here to resolve, probe,
4367
+ // or (worse) lazily DEPLOY. Not a refusal: `--resume`'s own contract is "pass the same content
4368
+ // flags", so the creator's original command legitimately still carries `--no-seed`/`--seed-source`.
4369
+ // Say plainly that it's read from chain, and name the verb that can actually change it.
4370
+ if (flags.resume !== undefined) {
4371
+ if (raw !== undefined || flags['no-seed'] !== undefined) {
4372
+ info(dim(`--resume: the seed source was fixed when ${flags.resume} was created, so ${raw !== undefined ? '--seed-source' : '--no-seed'} has nothing to write here ` +
4373
+ `(it isn't part of the setup transaction being replayed). Read it with \`abx state\`; change it with \`abx set-seed-source\`.`));
4374
+ }
4375
+ return zeroAddress; // unused by the resume legs
4376
+ }
4377
+ if (flags['no-seed'] !== undefined) {
4378
+ // Two flags, one field. Refuse rather than pick a winner — a creator who passed both has a
4379
+ // belief about the outcome, and either answer betrays half of them.
4380
+ if (raw !== undefined) {
4381
+ throw new Error('--no-seed and --seed-source contradict each other: one says "no mint-time seed at all", the other names where the seed comes from. Drop one.');
4382
+ }
4383
+ return zeroAddress;
4384
+ }
4385
+ const canonical = canonicalSeedSource(chainId);
4386
+ const requested = raw === undefined ? canonical : parseSeedSourceValue(raw, { chainId });
4387
+ // The canonical singleton keeps its existing bootstrap path (deploy it if this chain lacks one).
4388
+ // Anything else must already exist — we never deploy a stranger's contract.
4389
+ const same = (a, b) => a.toLowerCase() === b.toLowerCase();
4390
+ let seedSource;
4391
+ if (same(requested, canonical)) {
4392
+ seedSource = dryRun ? (resolveSeedSource(chainId) ?? zeroAddress) : await ensureSeedSource(publicClient);
4393
+ }
4394
+ else {
4395
+ seedSource = requested;
4396
+ }
4397
+ if (seedSource === zeroAddress || same(seedSource, canonical))
4398
+ return seedSource;
4399
+ // Custom source (flag or ABX_SEED_SOURCE) — ask it the question the mint will ask.
4400
+ const probe = await probeSeedSource(publicClient, seedSource);
4401
+ if (probe.verdict === 'unreachable' && dryRun) {
4402
+ info(dim(`--seed-source: RPC unreachable, so the seed-source probe is deferred — a real deploy verifies ${seedSource} answers seed(uint256,address) with 32 bytes first.`));
4403
+ return seedSource;
4404
+ }
4405
+ if (probe.verdict !== 'ok')
4406
+ refuseUnusableSeedSource(probe, { flag: raw === undefined ? 'ABX_SEED_SOURCE' : '--seed-source', chainLabel: CHAIN });
4407
+ info(`seed source: CUSTOM ${seedSource} ${dim('— answers seed(uint256,address) with 32 bytes (probed). Its randomness properties are yours to state to buyers; ABX makes no claim about them.')}`);
4408
+ return seedSource;
4409
+ }
4004
4410
  //# sourceMappingURL=deploy.js.map