@artblocks/abx-cli 0.1.0-alpha.40 → 0.1.0-alpha.41

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 (72) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/assets/renderer-scaffold/README.md +12 -0
  3. package/assets/renderer-scaffold/foundry.toml +4 -0
  4. package/assets/renderer-scaffold/script/Preview.s.sol +99 -0
  5. package/dist/capabilities.d.ts +9 -4
  6. package/dist/capabilities.d.ts.map +1 -1
  7. package/dist/capabilities.js +13 -4
  8. package/dist/capabilities.js.map +1 -1
  9. package/dist/commands/deploy.d.ts.map +1 -1
  10. package/dist/commands/deploy.js +731 -65
  11. package/dist/commands/deploy.js.map +1 -1
  12. package/dist/commands/maintenance.d.ts +4 -0
  13. package/dist/commands/maintenance.d.ts.map +1 -0
  14. package/dist/commands/maintenance.js +114 -0
  15. package/dist/commands/maintenance.js.map +1 -0
  16. package/dist/commands/project.d.ts +175 -3
  17. package/dist/commands/project.d.ts.map +1 -1
  18. package/dist/commands/project.js +333 -27
  19. package/dist/commands/project.js.map +1 -1
  20. package/dist/commands/reads.d.ts +21 -0
  21. package/dist/commands/reads.d.ts.map +1 -1
  22. package/dist/commands/reads.js +171 -4
  23. package/dist/commands/reads.js.map +1 -1
  24. package/dist/commands/scaffold.d.ts.map +1 -1
  25. package/dist/commands/scaffold.js +5 -0
  26. package/dist/commands/scaffold.js.map +1 -1
  27. package/dist/commands/service.d.ts.map +1 -1
  28. package/dist/commands/service.js +7 -0
  29. package/dist/commands/service.js.map +1 -1
  30. package/dist/commands/storage.d.ts.map +1 -1
  31. package/dist/commands/storage.js +32 -3
  32. package/dist/commands/storage.js.map +1 -1
  33. package/dist/commands/submit-app.d.ts +43 -0
  34. package/dist/commands/submit-app.d.ts.map +1 -1
  35. package/dist/commands/submit-app.js +115 -6
  36. package/dist/commands/submit-app.js.map +1 -1
  37. package/dist/config.d.ts +16 -1
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/config.js +73 -7
  40. package/dist/config.js.map +1 -1
  41. package/dist/deploy-plan.d.ts +150 -0
  42. package/dist/deploy-plan.d.ts.map +1 -0
  43. package/dist/deploy-plan.js +31 -0
  44. package/dist/deploy-plan.js.map +1 -0
  45. package/dist/flag-allowlists.d.ts.map +1 -1
  46. package/dist/flag-allowlists.js +5 -1
  47. package/dist/flag-allowlists.js.map +1 -1
  48. package/dist/flags.d.ts +6 -0
  49. package/dist/flags.d.ts.map +1 -1
  50. package/dist/flags.js +14 -0
  51. package/dist/flags.js.map +1 -1
  52. package/dist/jsonout.d.ts.map +1 -1
  53. package/dist/jsonout.js +11 -0
  54. package/dist/jsonout.js.map +1 -1
  55. package/dist/main.js +46 -7
  56. package/dist/main.js.map +1 -1
  57. package/dist/ownerops.d.ts +69 -0
  58. package/dist/ownerops.d.ts.map +1 -1
  59. package/dist/ownerops.js +221 -17
  60. package/dist/ownerops.js.map +1 -1
  61. package/dist/script-chunks.d.ts +1 -1
  62. package/dist/script-chunks.d.ts.map +1 -1
  63. package/dist/script-chunks.js +2 -2
  64. package/dist/script-chunks.js.map +1 -1
  65. package/package.json +6 -6
  66. package/skill/SKILL.md +6 -4
  67. package/skill/reference/code.md +17 -8
  68. package/skill/reference/deploy.md +9 -2
  69. package/skill/reference/diagnose.md +15 -2
  70. package/skill/reference/hosting.md +26 -0
  71. package/skill/reference/operate.md +43 -4
  72. package/skill/reference/setup.md +14 -0
@@ -15,7 +15,7 @@
15
15
  import { createHash } from 'node:crypto';
16
16
  import { readFileSync, readdirSync, statSync } from 'node:fs';
17
17
  import { basename, extname, join as joinPath, resolve as resolvePath } from 'node:path';
18
- import { DEFAULT_CHAIN_KEY, DEP_RESOLUTION, METADATA_FIELD as F, contentIdFromLocator, METADATA_REPRESENTATION as R, analyzeScript, assertChainId, checkRegistryDeps, dependencySetupCalls, deployOneOfOne, deploySeries, deployOneOfOneEdition, deployEditionImage, discoverDeployBlock, encodeFieldRenderer, encodeTag, expectedChainComplete, makeHotSender, makePublicClient, makeWalletClient, normalizeAttributes, onchainUriSetupCalls, oneOfOneImageAbi, oneOfOneEditionAbi, parseTraitPairs, planResume, predictClone, probeSeedSource, prepareCodeSetup, prepareDeployOneOfOne, prepareDeploySeries, prepareDeploySeriesCode, prepareDeployOneOfOneEdition, prepareDeployEditionImage, prepareDeployEditionCode, redactRpcUrl, resolveChain, resolveDepRegistryPointer, resolveGenerator, probeTransferValidator, resolveRecommendedTransferValidator, resolveRpcUrl, resolveSeedSource, resolveSeriesCodeFactory, saltFor, seriesCodeAbi, seriesCodeFactoryAbi, tryReadContract, AbxServiceClient, } from '@artblocks/abx-sdk';
18
+ import { DEFAULT_CHAIN_KEY, DEP_RESOLUTION, METADATA_FIELD as F, contentIdFromLocator, METADATA_REPRESENTATION as R, analyzeScript, assertChainId, checkRegistryDeps, dependencySetupCalls, deployOneOfOne, deploySeries, deployOneOfOneEdition, deployEditionImage, discoverDeployBlock, encodeFieldRenderer, encodeTag, expectedChainComplete, makeHotSender, makePublicClient, makeWalletClient, normalizeAttributes, onchainUriSetupCalls, oneOfOneImageAbi, oneOfOneEditionAbi, editionCodeAbi, parseTraitPairs, planResume, planEditionResume, predictClone, probeSeedSource, prepareCodeSetup, prepareDeployOneOfOne, prepareDeploySeries, prepareDeploySeriesCode, prepareDeployOneOfOneEdition, prepareDeployEditionImage, prepareDeployEditionCode, redactRpcUrl, resolveChain, resolveDepRegistryPointer, resolveGenerator, probeTransferValidator, resolveRecommendedTransferValidator, resolveRpcUrl, resolveSeedSource, resolveSeriesCodeFactory, saltFor, seriesCodeAbi, seriesCodeFactoryAbi, tryReadContract, AbxServiceClient, } from '@artblocks/abx-sdk';
19
19
  import { DIRECT_URL_BACKENDS, contentTypeFromPath, decideImageContentLane, hashContent, isTurboArweave, resolveBackend, validateRenderStorageCombo, } from '@artblocks/abx-storage';
20
20
  import { DEFAULT_PORT, contentHash, generateContent, resolveBaseUrl, startTokenApiServer } from '@artblocks/abx-token-api';
21
21
  import { encodeFunctionData, getAddress, toHex, zeroAddress } from 'viem';
@@ -25,7 +25,8 @@ import { isDryRun, parseSaltFlag, refuseStrayFlags, warnSignWithoutFor } from '.
25
25
  import { isEditionContract } from '../kind.js';
26
26
  import { jsonSafe, withJson } from '../jsonout.js';
27
27
  import { planOnChainScript } from '../script-chunks.js';
28
- import { bold, c, dim, ensureFactory, ensureRenderer, ensureSeedSource, ensureSeriesCodeFactory, ensureSeriesFactory, ensureOneOfOneEditionFactory, ensureEditionFactory, ensureEditionCodeFactory, g, info, keepAlive, ok, p, portInUse, printServing, registerAndIndexLocally, reindexAfterDeploy, step, warn, } from '../output.js';
28
+ import { bold, c, dim, ensureFactory, ensureRenderer, ensureSeedSource, ensureSeriesCodeFactory, ensureSeriesFactory, ensureOneOfOneEditionFactory, ensureEditionFactory, ensureEditionCodeFactory, g, info, keepAlive, ok, p, portInUse, printServing, registerAndIndexLocally, reindexAfterDeploy, step, warn as printWarn, } from '../output.js';
29
+ import { DEPLOY_PLAN_SCHEMA_VERSION, stripAnsiForPlan, } from '../deploy-plan.js';
29
30
  import { parseRoyaltyBps, AUTHORSHIP_DEPLOY_FIELDS, ONCHAIN_PROJECT_SOFT_LIMIT, ONCHAIN_READ_WARN_BYTES, tokenUriGasEstimate, authorshipContractFields, gatewayContractFields, servingGateway, computeContentPlan, envStagingSender, parseCompress, parseSeedSourceValue, canonicalSeedSource, refuseUnusableSeedSource, parseTransferValidatorValue, guardOnChainSize, previewImageStaging, refusePrewrappedImage, sessionStagingSender, stageImageField, stageImageFieldsBatch, } from '../ownerops.js';
30
31
  import { canonicalLabel } from '../remote.js';
31
32
  import { assertLaneCanSign, confirmSend, gatedSend, laneFromFlags } from '../riskgate.js';
@@ -33,6 +34,24 @@ import { describeSchema, editionSchemaAdvisory, parseSchemaSpecs } from '../sche
33
34
  import { parseSeriesTraits } from '../series-traits.js';
34
35
  import { decodeOnChainJson } from '../served.js';
35
36
  import { openWalletSession, signHotSequence, signTx } from '../signer.js';
37
+ // ── plan-object warning capture (issue #123) ─────────────────────────────────────────────────────
38
+ // The structured `--json` plan object (deploy-plan.ts) reports "the warnings the lane raised"
39
+ // alongside every other computed field — but ~400 `warn()` call sites in this file exist purely as
40
+ // human prose, and rewriting each to ALSO push onto a collector would be exactly the kind of
41
+ // touch-every-call-site change the plan-object work was scoped to avoid (a wording regression is one
42
+ // typo away, across 17 test files that assert on this prose byte-for-byte). Re-binding the name
43
+ // instead means every existing `warn(...)` call — unchanged, still reading `warn` from this file's own
44
+ // scope — transparently also feeds the plan. `planWarnings` is reset at the top of each
45
+ // `cmdDeploy*Body` (`beginPlanWarnings`) and read once, right before that invocation's `emit()` call(s).
46
+ // Not reentrant/thread-safe by construction — fine, since one CLI process runs exactly one command.
47
+ let planWarnings = [];
48
+ function beginPlanWarnings() {
49
+ planWarnings = [];
50
+ }
51
+ function warn(s) {
52
+ planWarnings.push(stripAnsiForPlan(s));
53
+ printWarn(s);
54
+ }
36
55
  // Product dimensions — what you can launch. Two concrete contract FAMILIES exist today: the 721
37
56
  // ladder (`--type`, below — 1/1 image or Series) and, orthogonally, ERC-1155 editions (`--copies`,
38
57
  // on ANY of the three deploy commands — see `parseCopies`). `--type` picks among unique-token
@@ -629,6 +648,7 @@ export async function cmdDeploy(flags, serveAfter) {
629
648
  return withJson(flags, async (emit) => cmdDeployBody(flags, serveAfter, emit));
630
649
  }
631
650
  export async function cmdDeployBody(flags, serveAfter, emit) {
651
+ beginPlanWarnings(); // see the collector's own module note, above the DIMENSIONS block
632
652
  refuseStrayFlags(flags, DEPLOY_FLAGS, 'deploy');
633
653
  warnSignWithoutFor(flags);
634
654
  const dimension = resolveDimension(flags.type ?? '1of1');
@@ -978,6 +998,10 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
978
998
  };
979
999
  let clone;
980
1000
  let blockNumber;
1001
+ // The eventual owner/signer — set in whichever lane branch below learns it, so the SENT plan
1002
+ // object (further down) can report `roles` without re-deriving anything. `undefined` only while
1003
+ // no branch has run yet; every path that reaches the sent `emit()` has set it by then.
1004
+ let deployerAddr;
981
1005
  if (dryRun) {
982
1006
  let deployer;
983
1007
  if (flags.for)
@@ -1035,7 +1059,39 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1035
1059
  // A dry run DOES know the address when --salt pinned it (a pure function of factory+salt); without
1036
1060
  // --salt it's null — reporting the freshly-reserved one would be a real-looking value a script
1037
1061
  // could act on that the actual deploy will not land at. `saltPinned` still says why.
1038
- emit(jsonSafe({ command: 'deploy', dryRun: true, sent: false, address: explicitSalt ? predicted : null, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt, saltPinned: !!explicitSalt, name, symbol }));
1062
+ emit(jsonSafe({
1063
+ command: 'deploy', dryRun: true, sent: false, address: explicitSalt ? predicted : null, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt, saltPinned: !!explicitSalt, name, symbol,
1064
+ // The structured plan (issue #123) — built from the SAME locals the readout above just
1065
+ // printed; nothing here is re-derived or newly computed. `deployer` is a real wallet address
1066
+ // regardless of salt pinning (only the PREDICTED CONTRACT address carries that caveat, and
1067
+ // this plan doesn't repeat it — see `address`, above), so roles are safe to report unconditionally.
1068
+ plan: {
1069
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
1070
+ family: '1of1',
1071
+ lane,
1072
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging'] : null },
1073
+ // The 1/1 lane's InitParams has no minter/primaryPayee field at all — `null` here means
1074
+ // "no concept on this lane", not "unset".
1075
+ roles: { signer: deployer, owner: deployer, royaltyReceiver: deployer, primaryPayee: null, minter: null },
1076
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
1077
+ custody: {
1078
+ onChainUri,
1079
+ imageOnChain: onchainImage,
1080
+ backend: !onChainUri && !onchainImage ? backendResolution(storageOverrides(flags)).backend : null,
1081
+ // `''` (on-chain lane, no explicit --public-base-url) is reported as `null` here — the
1082
+ // human line already spells out WHY (renderer-authoritative); a JSON consumer just needs
1083
+ // "nothing baked", not an empty string to interpret.
1084
+ tokenUriBase: params.tokenURIBase || null,
1085
+ contractUriBase: params.contractURIBase || null,
1086
+ },
1087
+ mint: { deferred: noMint, count: noMint ? 0 : 1, amountPerId: null, recipient: noMint ? null : deployer },
1088
+ estimate: { ethApprox: null, gasApprox: null }, // the 1/1 lane computes no cost estimate
1089
+ warnings: planWarnings.slice(),
1090
+ surfaces: null, // the code lanes' per-surface disposition has no equivalent here
1091
+ dependencies: null, // the 1/1 lane has no dependency-registry concept
1092
+ resume: null,
1093
+ },
1094
+ }));
1039
1095
  return;
1040
1096
  }
1041
1097
  // opt-in --confirm: a final y/N before the real send (no-op without --confirm; never blocks scripts)
@@ -1044,6 +1100,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1044
1100
  if (lane === 'send') {
1045
1101
  // hot lane: the env key is the deployer, known up front.
1046
1102
  const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
1103
+ deployerAddr = account.address;
1047
1104
  const { clone: predicted, params, salt, contentNote } = await buildForDeployer(account.address);
1048
1105
  info(`deterministic address: ${predicted}`);
1049
1106
  info(describeDeployField('description', flags.description, onChainUri || !!flags['description-onchain']));
@@ -1081,6 +1138,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1081
1138
  let r;
1082
1139
  try {
1083
1140
  const signer = await session.connect();
1141
+ deployerAddr = signer;
1084
1142
  // Stage on-chain through the session (sets bakedImage, which buildForDeployer reads below).
1085
1143
  const { field, note } = await stageImageField(flags.image, parseCompress(flags.compress), sessionStagingSender(session));
1086
1144
  bakedImage = field;
@@ -1113,6 +1171,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1113
1171
  let r;
1114
1172
  try {
1115
1173
  const signer = await session.connect();
1174
+ deployerAddr = signer;
1116
1175
  // Route Turbo uploads' signatures to this session before building content (which uploads).
1117
1176
  uploadRemoteEth = { address: signer, signMessage: (m) => session.signMessage(m, 'Sign Arweave upload (paid from your Turbo credits)') };
1118
1177
  const { clone: predicted, params, salt, contentNote } = await buildForDeployer(signer);
@@ -1137,6 +1196,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1137
1196
  ? 'a wallet will become the owner; the token resolves from chain — no URI base is baked in.'
1138
1197
  : `a wallet will become the owner; URIs point at ${baseUrl}`);
1139
1198
  const result = await signTx(async (signer) => {
1199
+ deployerAddr = signer;
1140
1200
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
1141
1201
  return prepareDeployOneOfOne({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
1142
1202
  }, { lane, chainKey: CHAIN, yes: !!flags.yes, expectedSigner: flags.for, port: flags.port ? Number(flags.port) : undefined, signUrlFile: flags['sign-url-file'] });
@@ -1150,7 +1210,32 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1150
1210
  }
1151
1211
  // Emitted the moment the address is known, and again below once minting is settled — so a crash in
1152
1212
  // the indexing steps that follow still yields the address of a contract that really does exist.
1153
- emit(jsonSafe({ command: 'deploy', address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol }));
1213
+ emit(jsonSafe({
1214
+ command: 'deploy', address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol,
1215
+ plan: {
1216
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
1217
+ family: '1of1',
1218
+ lane,
1219
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging'] : null },
1220
+ roles: { signer: deployerAddr ?? null, owner: deployerAddr ?? null, royaltyReceiver: deployerAddr ?? null, primaryPayee: null, minter: null },
1221
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
1222
+ custody: {
1223
+ onChainUri,
1224
+ imageOnChain: onchainImage,
1225
+ backend: !onChainUri && !onchainImage ? backendResolution(storageOverrides(flags)).backend : null,
1226
+ // Recomputed from the same top-level locals `buildForDeployer`'s `params` used (no branch
1227
+ // carries `params` out to this scope) — never a new decision, the identical ternary.
1228
+ tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
1229
+ contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
1230
+ },
1231
+ mint: { deferred: noMint, count: noMint ? 0 : 1, amountPerId: null, recipient: noMint ? null : deployerAddr ?? null },
1232
+ estimate: { ethApprox: null, gasApprox: null },
1233
+ warnings: planWarnings.slice(),
1234
+ surfaces: null,
1235
+ dependencies: null,
1236
+ resume: null,
1237
+ },
1238
+ }));
1154
1239
  step(serveAfter ? 'What the chain knows now' : 'Index it — replay the event spine from chain');
1155
1240
  const indexer = localIndexer();
1156
1241
  // Off-chain traits ride in the registration (on-chain ones are already in the contract fields).
@@ -1284,6 +1369,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1284
1369
  // spreads `DEPLOY_FLAGS`, so it must come AFTER that declaration at module scope, same reason
1285
1370
  // `DEPLOY_SERIES_EDITION_FLAGS` sits next to it rather than beside `cmdDeployEditionImageBody`.)
1286
1371
  export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1372
+ beginPlanWarnings();
1287
1373
  refuseStrayFlags(flags, DEPLOY_EDITION_FLAGS, 'deploy');
1288
1374
  warnSignWithoutFor(flags);
1289
1375
  // `--type` is the 721 lane's OWN dimension seam (cmdDeployBody redirects `--type series` to
@@ -1487,6 +1573,8 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1487
1573
  };
1488
1574
  let clone;
1489
1575
  let blockNumber;
1576
+ // See the 1/1 lane's identical local for why this is hoisted (feeds the sent plan's `roles`).
1577
+ let deployerAddr;
1490
1578
  if (dryRun) {
1491
1579
  let deployer;
1492
1580
  if (flags.for)
@@ -1531,13 +1619,41 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1531
1619
  }
1532
1620
  info(dim('the values above are exactly what a real deploy writes — nothing else is added.'));
1533
1621
  console.log(`\n ${g('dry run')} ${dim('— nothing sent, no bytes stored. Re-run without --dry-run to deploy.')}\n`);
1534
- 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 }));
1622
+ emit(jsonSafe({
1623
+ 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,
1624
+ plan: {
1625
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
1626
+ family: '1of1-edition',
1627
+ lane,
1628
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging'] : null },
1629
+ roles: {
1630
+ signer: deployer, owner: deployer, royaltyReceiver: deployer,
1631
+ primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
1632
+ minter: minter !== zeroAddress ? minter : null,
1633
+ },
1634
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
1635
+ custody: {
1636
+ onChainUri,
1637
+ imageOnChain: onchainImage,
1638
+ backend: !onChainUri && !onchainImage ? backendResolution(storageOverrides(flags)).backend : null,
1639
+ tokenUriBase: params.tokenURIBase || null,
1640
+ contractUriBase: params.contractURIBase || null,
1641
+ },
1642
+ mint: { deferred: mintAmount === 0n, count: mintAmount > 0n ? 1 : 0, amountPerId: mintAmount.toString(), recipient: mintAmount > 0n ? deployer : null },
1643
+ estimate: { ethApprox: null, gasApprox: null },
1644
+ warnings: planWarnings.slice(),
1645
+ surfaces: null,
1646
+ dependencies: null,
1647
+ resume: null,
1648
+ },
1649
+ }));
1535
1650
  return;
1536
1651
  }
1537
1652
  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}%.` +
1538
1653
  (transferValidator !== zeroAddress ? ` ERC-1155C: enrolled at deploy, permanently (validator ${transferValidator}).` : ''), flags);
1539
1654
  if (lane === 'send') {
1540
1655
  const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
1656
+ deployerAddr = account.address;
1541
1657
  const { clone: predicted, params, salt, contentNote } = await buildForDeployer(account.address);
1542
1658
  info(`deterministic address: ${predicted}`);
1543
1659
  info(describeDeployField('description', flags.description, onChainUri || !!flags['description-onchain']));
@@ -1568,6 +1684,7 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1568
1684
  let r;
1569
1685
  try {
1570
1686
  const signer = await session.connect();
1687
+ deployerAddr = signer;
1571
1688
  await stageOnChainImage(sessionStagingSender(session)); // sets bakedImage, read by buildForDeployer
1572
1689
  const { clone: predicted, params, salt, contentNote } = await buildForDeployer(signer);
1573
1690
  info(contentNote);
@@ -1587,6 +1704,7 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1587
1704
  // wallet lane without staging, or the cold lane: a single deploy tx.
1588
1705
  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}`);
1589
1706
  const result = await signTx(async (signer) => {
1707
+ deployerAddr = signer;
1590
1708
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
1591
1709
  return prepareDeployOneOfOneEdition({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
1592
1710
  }, { lane, chainKey: CHAIN, yes: !!flags.yes, expectedSigner: flags.for, port: flags.port ? Number(flags.port) : undefined, signUrlFile: flags['sign-url-file'] });
@@ -1598,7 +1716,34 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1598
1716
  blockNumber = result.blockNumber;
1599
1717
  ok(`deployed ${clone}`);
1600
1718
  }
1601
- emit(jsonSafe({ command: 'deploy', kind: '1of1-edition', copies: editionSize.toString(), address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol }));
1719
+ emit(jsonSafe({
1720
+ command: 'deploy', kind: '1of1-edition', copies: editionSize.toString(), address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol,
1721
+ plan: {
1722
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
1723
+ family: '1of1-edition',
1724
+ lane,
1725
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging'] : null },
1726
+ roles: {
1727
+ signer: deployerAddr ?? null, owner: deployerAddr ?? null, royaltyReceiver: deployerAddr ?? null,
1728
+ primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
1729
+ minter: minter !== zeroAddress ? minter : null,
1730
+ },
1731
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
1732
+ custody: {
1733
+ onChainUri,
1734
+ imageOnChain: onchainImage,
1735
+ backend: !onChainUri && !onchainImage ? backendResolution(storageOverrides(flags)).backend : null,
1736
+ tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
1737
+ contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
1738
+ },
1739
+ mint: { deferred: mintAmount === 0n, count: mintAmount > 0n ? 1 : 0, amountPerId: mintAmount.toString(), recipient: mintAmount > 0n ? deployerAddr ?? null : null },
1740
+ estimate: { ethApprox: null, gasApprox: null },
1741
+ warnings: planWarnings.slice(),
1742
+ surfaces: null,
1743
+ dependencies: null,
1744
+ resume: null,
1745
+ },
1746
+ }));
1602
1747
  step('Index it — replay the event spine from chain');
1603
1748
  const indexer = localIndexer();
1604
1749
  const offChainTraits = !traitsOnchain && traits.length ? JSON.stringify(traits) : undefined;
@@ -1668,6 +1813,7 @@ export async function cmdDeploySeries(flags) {
1668
1813
  return withJson(flags, async (emit) => cmdDeploySeriesBody(flags, emit));
1669
1814
  }
1670
1815
  export async function cmdDeploySeriesBody(flags, emit) {
1816
+ beginPlanWarnings();
1671
1817
  refuseStrayFlags(flags, DEPLOY_SERIES_FLAGS, 'deploy-series');
1672
1818
  warnSignWithoutFor(flags);
1673
1819
  const name = flags.name ?? 'ABX Series';
@@ -2070,14 +2216,46 @@ export async function cmdDeploySeriesBody(flags, emit) {
2070
2216
  console.log(`\n ${g('dry run')} ${dim('— nothing sent, no bytes stored.')}\n`);
2071
2217
  // Without --salt, `address` would be a freshly-reserved value a real deploy will NOT land at —
2072
2218
  // report null rather than a real-looking value a script could wrongly act on.
2073
- emit(jsonSafe({ command: 'deploy-series', dryRun: true, sent: false, address: explicitSalt ? predicted : null, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt, saltPinned: !!explicitSalt, name, symbol }));
2219
+ emit(jsonSafe({
2220
+ command: 'deploy-series', dryRun: true, sent: false, address: explicitSalt ? predicted : null, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt, saltPinned: !!explicitSalt, name, symbol,
2221
+ plan: {
2222
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
2223
+ family: 'series',
2224
+ lane,
2225
+ // A staged on-chain collection is many chunk-write txs, one per file — that per-token detail
2226
+ // is printed above (`previewImageStaging`), not re-derived here; `legs` just names the shape.
2227
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging (per token)'] : null },
2228
+ roles: {
2229
+ signer: deployer, owner: deployer, royaltyReceiver: deployer,
2230
+ primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
2231
+ minter: minter !== zeroAddress ? minter : null,
2232
+ },
2233
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
2234
+ custody: {
2235
+ onChainUri,
2236
+ imageOnChain: onchainImage,
2237
+ backend: offchainImageOnchainJson || !onChainUri ? backendId : null,
2238
+ tokenUriBase: params.tokenURIBase || null,
2239
+ contractUriBase: params.contractURIBase || null,
2240
+ },
2241
+ mint: { deferred: mintCount === 0, count: mintCount, amountPerId: null, recipient: mintCount > 0 ? deployer : null },
2242
+ estimate: { ethApprox: null, gasApprox: null },
2243
+ warnings: planWarnings.slice(),
2244
+ surfaces: null,
2245
+ dependencies: null,
2246
+ resume: null,
2247
+ },
2248
+ }));
2074
2249
  return;
2075
2250
  }
2076
2251
  let clone;
2077
2252
  let blockNumber;
2253
+ // See the 1/1 lane's identical local for why this is hoisted (feeds the sent plan's `roles`).
2254
+ let deployerAddr;
2078
2255
  if (lane === 'send') {
2079
2256
  // hot lane: the env key stages every token up front (deployer-independent), then deploys.
2080
2257
  const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
2258
+ deployerAddr = account.address;
2081
2259
  if (onchainImage)
2082
2260
  await buildFields(envStagingSender());
2083
2261
  const { clone: predicted, params, salt } = await buildForDeployer(account.address);
@@ -2104,6 +2282,7 @@ export async function cmdDeploySeriesBody(flags, emit) {
2104
2282
  });
2105
2283
  try {
2106
2284
  const signer = await session.connect();
2285
+ deployerAddr = signer;
2107
2286
  if (onchainImage)
2108
2287
  await buildFields(sessionStagingSender(session));
2109
2288
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
@@ -2129,6 +2308,7 @@ export async function cmdDeploySeriesBody(flags, emit) {
2129
2308
  });
2130
2309
  try {
2131
2310
  const signer = await session.connect();
2311
+ deployerAddr = signer;
2132
2312
  // Route Turbo uploads through the wallet (mutate the SAME opts.arweave the backend captured).
2133
2313
  if (opts.arweave)
2134
2314
  opts.arweave.remoteEth = { address: signer, signMessage: (m) => session.signMessage(m, 'Sign Arweave upload (paid from your Turbo credits)') };
@@ -2151,6 +2331,7 @@ export async function cmdDeploySeriesBody(flags, emit) {
2151
2331
  ? 'a wallet will become the owner; tokens resolve from chain — no URI base is baked in.'
2152
2332
  : `a wallet will become the owner; URIs point at ${baseUrl}`);
2153
2333
  const result = await signTx(async (signer) => {
2334
+ deployerAddr = signer;
2154
2335
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
2155
2336
  return prepareDeploySeries({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
2156
2337
  }, { lane, chainKey: CHAIN, yes: !!flags.yes, expectedSigner: flags.for, port: flags.port ? Number(flags.port) : undefined, signUrlFile: flags['sign-url-file'] });
@@ -2168,7 +2349,34 @@ export async function cmdDeploySeriesBody(flags, emit) {
2168
2349
  info(`permanent: size ${count} · royalty ${royaltyBps / 100}%${flags['royalty-bps'] === undefined ? ' ⚠ default' : ''} → the deploying wallet` +
2169
2350
  ` · cap ${maxRoyaltyBps / 100}%${burnable ? ' · burnable' : ''}` +
2170
2351
  dim(' (rate is changeable with `abx set-royalty`; the CAP only ever goes down)'));
2171
- emit(jsonSafe({ command: 'deploy-series', address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol }));
2352
+ emit(jsonSafe({
2353
+ command: 'deploy-series', address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol,
2354
+ plan: {
2355
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
2356
+ family: 'series',
2357
+ lane,
2358
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging (per token)'] : null },
2359
+ roles: {
2360
+ signer: deployerAddr ?? null, owner: deployerAddr ?? null, royaltyReceiver: deployerAddr ?? null,
2361
+ primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
2362
+ minter: minter !== zeroAddress ? minter : null,
2363
+ },
2364
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
2365
+ custody: {
2366
+ onChainUri,
2367
+ imageOnChain: onchainImage,
2368
+ backend: offchainImageOnchainJson || !onChainUri ? backendId : null,
2369
+ tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
2370
+ contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
2371
+ },
2372
+ mint: { deferred: mintCount === 0, count: mintCount, amountPerId: null, recipient: mintCount > 0 ? deployerAddr ?? null : null },
2373
+ estimate: { ethApprox: null, gasApprox: null },
2374
+ warnings: planWarnings.slice(),
2375
+ surfaces: null,
2376
+ dependencies: null,
2377
+ resume: null,
2378
+ },
2379
+ }));
2172
2380
  step('Index it — replay the event spine from chain');
2173
2381
  const indexer = localIndexer();
2174
2382
  const baseReg = {
@@ -2244,6 +2452,7 @@ export async function cmdDeploySeriesBody(flags, emit) {
2244
2452
  // (`DEPLOY_SERIES_EDITION_FLAGS` is declared further down, beside `DEPLOY_EDITION_FLAGS` — see that
2245
2453
  // const's own note on why.)
2246
2454
  export async function cmdDeployEditionImageBody(flags, emit) {
2455
+ beginPlanWarnings();
2247
2456
  refuseStrayFlags(flags, DEPLOY_SERIES_EDITION_FLAGS, 'deploy-series');
2248
2457
  warnSignWithoutFor(flags);
2249
2458
  const editionSize = parseCopies(flags.copies);
@@ -2563,6 +2772,8 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2563
2772
  };
2564
2773
  let clone;
2565
2774
  let blockNumber;
2775
+ // See the 1/1 lane's identical local for why this is hoisted (feeds the sent plan's `roles`).
2776
+ let deployerAddr;
2566
2777
  if (dryRun) {
2567
2778
  let deployer;
2568
2779
  if (flags.for)
@@ -2602,11 +2813,44 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2602
2813
  info(`address: pinned by salt — re-run with ${bold(`--salt ${salt}`)} (same address).`);
2603
2814
  }
2604
2815
  console.log(`\n ${g('dry run')} ${dim('— nothing sent, no bytes stored. Re-run without --dry-run to deploy.')}\n`);
2605
- emit(jsonSafe({ command: 'deploy-series', kind: 'edition', copies: editionSize.toString(), ids: count, dryRun: true, sent: false, address: explicitSalt ? predicted : null, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt, saltPinned: !!explicitSalt, name, symbol }));
2816
+ emit(jsonSafe({
2817
+ command: 'deploy-series', kind: 'edition', copies: editionSize.toString(), ids: count, dryRun: true, sent: false, address: explicitSalt ? predicted : null, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt, saltPinned: !!explicitSalt, name, symbol,
2818
+ plan: {
2819
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
2820
+ family: 'series-edition',
2821
+ lane,
2822
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging (per id)'] : null },
2823
+ roles: {
2824
+ signer: deployer, owner: deployer, royaltyReceiver: deployer,
2825
+ primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
2826
+ minter: minter !== zeroAddress ? minter : null,
2827
+ },
2828
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
2829
+ custody: {
2830
+ onChainUri,
2831
+ imageOnChain: onchainImage,
2832
+ backend: !onchainImage && (offchainImageOnchainJson || !onChainUri) ? backendId : null,
2833
+ tokenUriBase: params.tokenURIBase || null,
2834
+ contractUriBase: params.contractURIBase || null,
2835
+ },
2836
+ mint: {
2837
+ deferred: effectiveMintCount === 0,
2838
+ count: effectiveMintCount,
2839
+ amountPerId: mintAmount.toString(),
2840
+ recipient: effectiveMintCount > 0 ? deployer : null,
2841
+ },
2842
+ estimate: { ethApprox: null, gasApprox: null },
2843
+ warnings: planWarnings.slice(),
2844
+ surfaces: null,
2845
+ dependencies: null,
2846
+ resume: null,
2847
+ },
2848
+ }));
2606
2849
  return;
2607
2850
  }
2608
2851
  if (lane === 'send') {
2609
2852
  const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
2853
+ deployerAddr = account.address;
2610
2854
  // hot lane: the env key stages every id up front (deployer-independent), then deploys.
2611
2855
  if (onchainImage)
2612
2856
  await buildFields(envStagingSender());
@@ -2635,6 +2879,7 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2635
2879
  let r;
2636
2880
  try {
2637
2881
  const signer = await session.connect();
2882
+ deployerAddr = signer;
2638
2883
  await buildFields(sessionStagingSender(session));
2639
2884
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
2640
2885
  info(`mint: ${effectiveMintCount > 0 ? `${effectiveMintCount} id(s) × ${mintAmount} cop${mintAmount === 1n ? 'y' : 'ies'} → ${signer} at deploy` : 'deferred'}`);
@@ -2652,6 +2897,7 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2652
2897
  else {
2653
2898
  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}`);
2654
2899
  const result = await signTx(async (signer) => {
2900
+ deployerAddr = signer;
2655
2901
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
2656
2902
  return prepareDeployEditionImage({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
2657
2903
  }, { lane, chainKey: CHAIN, yes: !!flags.yes, expectedSigner: flags.for, port: flags.port ? Number(flags.port) : undefined, signUrlFile: flags['sign-url-file'] });
@@ -2663,7 +2909,39 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2663
2909
  blockNumber = result.blockNumber;
2664
2910
  ok(`deployed ${clone}`);
2665
2911
  }
2666
- emit(jsonSafe({ command: 'deploy-series', kind: 'edition', copies: editionSize.toString(), ids: count, address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol }));
2912
+ emit(jsonSafe({
2913
+ command: 'deploy-series', kind: 'edition', copies: editionSize.toString(), ids: count, address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol,
2914
+ plan: {
2915
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
2916
+ family: 'series-edition',
2917
+ lane,
2918
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging (per id)'] : null },
2919
+ roles: {
2920
+ signer: deployerAddr ?? null, owner: deployerAddr ?? null, royaltyReceiver: deployerAddr ?? null,
2921
+ primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
2922
+ minter: minter !== zeroAddress ? minter : null,
2923
+ },
2924
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
2925
+ custody: {
2926
+ onChainUri,
2927
+ imageOnChain: onchainImage,
2928
+ backend: !onchainImage && (offchainImageOnchainJson || !onChainUri) ? backendId : null,
2929
+ tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
2930
+ contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
2931
+ },
2932
+ mint: {
2933
+ deferred: effectiveMintCount === 0,
2934
+ count: effectiveMintCount,
2935
+ amountPerId: mintAmount.toString(),
2936
+ recipient: effectiveMintCount > 0 ? deployerAddr ?? null : null,
2937
+ },
2938
+ estimate: { ethApprox: null, gasApprox: null },
2939
+ warnings: planWarnings.slice(),
2940
+ surfaces: null,
2941
+ dependencies: null,
2942
+ resume: null,
2943
+ },
2944
+ }));
2667
2945
  step('Index it — replay the event spine from chain');
2668
2946
  const indexer = localIndexer();
2669
2947
  const offChainTraits = !seriesTraitsOnchain && offChainTokenTraits ? offChainTokenTraits : undefined;
@@ -2919,6 +3197,10 @@ export const DEPLOY_CODE_FLAGS = new Set([
2919
3197
  'dry-run', 'confirm', 'yes',
2920
3198
  'json', // the deployed address as data, narration to stderr
2921
3199
  'resume', // finish an existing contract whose setup transaction never landed (no deploy)
3200
+ // Meaningful ONLY on `--resume` against an EditionCode target (per-id copies for the premint
3201
+ // shortfall) — kept in the allowlist (not stray) so a misuse elsewhere gets this function's own
3202
+ // pointed refusal instead of the generic "unrecognized flag". See the --resume block.
3203
+ 'mint-amount',
2922
3204
  ]);
2923
3205
  // Storage + signing + preview flags shared by every deploy path (mirrors storageOverrides() + the lanes).
2924
3206
  export const SHARED_DEPLOY_FLAGS = [
@@ -2981,6 +3263,7 @@ export async function cmdDeployCode(flags) {
2981
3263
  return withJson(flags, async (emit) => cmdDeployCodeBody(flags, emit));
2982
3264
  }
2983
3265
  export async function cmdDeployCodeBody(flags, emit) {
3266
+ beginPlanWarnings();
2984
3267
  const usage = 'abx deploy-code (--script <file> | --code-dir <dir>) --name "Title" --symbol SYM ' +
2985
3268
  '(--public-base-url https://your.resolver.domain | --onchain-uri) ' +
2986
3269
  '[--description "<s>"] [--external-url <url>] [--image-base <url> | --image-renderer 0x..] [--attributes-renderer 0x..] ' +
@@ -3004,6 +3287,16 @@ export async function cmdDeployCodeBody(flags, emit) {
3004
3287
  // Shared path so the "did you mean --max / price is post-deploy" hints fire here too.
3005
3288
  refuseStrayFlags(flags, DEPLOY_CODE_FLAGS, 'deploy-code');
3006
3289
  warnSignWithoutFor(flags);
3290
+ // --mint-amount only means anything on a --resume against an EXISTING EditionCode target (a per-id
3291
+ // premint shortfall needs an amount; a fresh 721 deploy has no such field, and a 721 resume's mint
3292
+ // leg is a single whole-contract shortfall with no amount either). Checked HERE, before the dry-run
3293
+ // preview branch below returns early for a fresh deploy — a --resume against an actual EditionCode
3294
+ // (where the flag DOES apply) gets the more specific 721-vs-edition guard further down instead.
3295
+ if (flags['mint-amount'] !== undefined && flags.resume === undefined) {
3296
+ throw new Error('--mint-amount only applies to `abx deploy-code --resume <address>` against an EXISTING EditionCode contract — ' +
3297
+ 'a fresh 721 deploy (this command, without --copies) has no per-id mint amount. For a NEW edition deploy, use ' +
3298
+ '`abx deploy-code --copies <n|open> --mint-amount <n>`.');
3299
+ }
3007
3300
  const dryRun = isDryRun(flags);
3008
3301
  const name = flags.name ?? 'ABX Code';
3009
3302
  const symbol = flags.symbol ?? 'ABXC';
@@ -3458,6 +3751,10 @@ export async function cmdDeployCodeBody(flags, emit) {
3458
3751
  // PostParams it reads (unlike the JS lane's static paramHints), so nudge — the palette-miss.
3459
3752
  ? `⚠ none declared — if your Solidity renderer reads ANY collector PostParam, declare EACH with ${bold('--schema <key>:Type:Auth')} (matching the key your renderer reads — e.g. ${bold('palette:HexColor:TokenOwner')}) or that input is FIXED at the renderer's default forever and collectors can't set it (the CLI can't detect the key — a Solidity renderer is opaque, so this is on you to declare)`
3460
3753
  : 'none declared';
3754
+ // Hoisted here (rather than beside its one printed use, in the Surfaces block below) so BOTH the
3755
+ // dry-run's Surfaces block and the plan object's `surfaces.postParams.ok` (populated at every one
3756
+ // of this lane's emit sites, dry-run/sent/resume alike) read the exact same verdict.
3757
+ const paramsNudge = !scriptAnalysis && !schemas.length && (hasImageRenderer || hasAttributesRenderer);
3461
3758
  // TOKENURI PUBLIC-READ GAS — a large --onchain-uri document assembles on-chain per call; an
3462
3759
  // unauthenticated public read (Etherscan "Read Contract" with no wallet connected) can hit a client
3463
3760
  // gas cap and appear to REVERT. Expected, not a bug — a real session mis-diagnosed it as indexing lag.
@@ -3494,6 +3791,18 @@ export async function cmdDeployCodeBody(flags, emit) {
3494
3791
  const depLegs = deps.length + (depRegistry ? 1 : 0);
3495
3792
  const setupLen = scriptChunks.length + schemas.length + depLegs + (onchainUriLegs?.length ?? 0) + setupMintCount;
3496
3793
  const approvals = 1 + (setupLen > 0 ? 1 : 0); // deploy tx + (a setup multicall only if something rides it)
3794
+ // Ordered legs riding that setup multicall (if any), for the plan object's `transactions.legs` —
3795
+ // the SAME booleans `setupLen`/`setupBits` (the dry-run's own `transactions:` line, below) already
3796
+ // use, so this can't drift from the tx the wallet actually signs.
3797
+ const planSetupLegs = setupLen > 0
3798
+ ? [
3799
+ scriptChunks.length ? 'chunks' : null,
3800
+ schemas.length ? 'param-schemas' : null,
3801
+ depLegs ? 'dependencies' : null,
3802
+ onchainUriLegs ? 'onchain-uri' : null,
3803
+ setupMintCount ? 'mints' : null,
3804
+ ].filter((leg) => leg !== null)
3805
+ : null;
3497
3806
  // opt-in --confirm: one y/N before ANY upload or send (no-op without --confirm; never blocks scripts).
3498
3807
  if (!dryRun) {
3499
3808
  const renderHome = process.env.ABX_STORAGE_BACKEND || 'fs'; // empty string counts as unset (=fs)
@@ -3616,6 +3925,9 @@ export async function cmdDeployCodeBody(flags, emit) {
3616
3925
  // RPC can't price gas; see cli-ux-decisions). On-chain byte storage dominates a template drop,
3617
3926
  // so apply the skill's own ~200 gas/byte heuristic to the stored bytes + coarse per-op costs,
3618
3927
  // priced at the live gas price. Clearly hedged — it's an order-of-magnitude figure, not a quote.
3928
+ // `planEstimate` carries the SAME figures into the plan object below — `null` (not computed)
3929
+ // when the RPC can't price gas, exactly mirroring the human line's own silent skip.
3930
+ let planEstimate = null;
3619
3931
  try {
3620
3932
  const chunkBytes = scriptChunks.reduce((n, c) => n + (c.length - 2) / 2, 0);
3621
3933
  const fieldBytes = contractFields.reduce((n, f) => n + (f.value.length - 2) / 2, 0);
@@ -3632,6 +3944,7 @@ export async function cmdDeployCodeBody(flags, emit) {
3632
3944
  const ethStr = eth >= 0.00001 ? eth.toFixed(5) : '<0.00001';
3633
3945
  info(`est. on-chain cost: ${dim('~')}${ethStr} ETH ` +
3634
3946
  dim(`(very rough — ~${Number(roughGas).toLocaleString()} gas @ ${(Number(gasPrice) / 1e9).toFixed(2)} gwei; excludes off-chain storage${codeDir ? ', the dominant cost for a directory drop' : ''})`));
3947
+ planEstimate = { ethApprox: ethStr, gasApprox: roughGas.toString() };
3635
3948
  }
3636
3949
  catch { /* gas pricing unavailable — cost guidance is a bonus, skip silently */ }
3637
3950
  // ── Surfaces — every marketplace-facing dimension, resolved NOW (none is backfillable) ──────
@@ -3675,8 +3988,7 @@ export async function cmdDeployCodeBody(flags, emit) {
3675
3988
  info(` ${dim('render storage home:')} ${bold(renderHome)} ${dim('— a public home the runner uploads to ✓')}`);
3676
3989
  }
3677
3990
  }
3678
- (traitsBroken ? warn : info)(`traits: ${traitsDisposition}`);
3679
- const paramsNudge = !scriptAnalysis && !schemas.length && (hasImageRenderer || hasAttributesRenderer);
3991
+ (traitsBroken ? warn : info)(`traits: ${traitsDisposition}`); // `paramsNudge` is hoisted above (with `paramsDisposition`) — shared with the plan object
3680
3992
  ((undeclaredParams.length || paramsNudge ? warn : info))(`postparams: ${paramsDisposition}`);
3681
3993
  if (anySurfaceBroken) {
3682
3994
  const remedies = [
@@ -3769,7 +4081,43 @@ export async function cmdDeployCodeBody(flags, emit) {
3769
4081
  // `address` is null without --salt pinning it or in the wallet lane with no --for (the
3770
4082
  // signer decides the salt, so no address exists yet) — reporting null is the honest answer;
3771
4083
  // inventing one would be a wrong reservation.
3772
- emit(jsonSafe({ command: 'deploy-code', dryRun: true, sent: false, address: shownAddr, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt: salt ?? null, saltPinned: !!explicitSalt, name, symbol, onChainUri }));
4084
+ emit(jsonSafe({
4085
+ command: 'deploy-code', dryRun: true, sent: false, address: shownAddr, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt: salt ?? null, saltPinned: !!explicitSalt, name, symbol, onChainUri,
4086
+ plan: {
4087
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
4088
+ family: 'code',
4089
+ // `lane` (the hoisted const) isn't declared until AFTER this early-return block — reading it
4090
+ // here would hit its temporal dead zone. `laneFromFlags` is pure (flags only), so call it
4091
+ // directly; the sent-side emit, below `const lane = …`, uses the hoisted binding instead.
4092
+ lane: laneFromFlags(flags),
4093
+ transactions: { approvals, legs: planSetupLegs },
4094
+ roles: {
4095
+ signer: deployer, owner: deployer, royaltyReceiver: deployer,
4096
+ primaryPayee: flags['primary-payee'] ?? null,
4097
+ minter: flags.minter ?? null,
4098
+ },
4099
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
4100
+ custody: {
4101
+ onChainUri,
4102
+ // The code lane's image is one of several independently-disposed surfaces, not a single
4103
+ // boolean — see `surfaces.thumbnail` below.
4104
+ imageOnChain: null,
4105
+ backend: dirUpload ? dirUpload.backend.id : null,
4106
+ tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
4107
+ contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
4108
+ },
4109
+ mint: { deferred: mintCount === 0, count: mintCount, amountPerId: null, recipient: mintCount > 0 ? deployer : null },
4110
+ estimate: planEstimate ?? { ethApprox: null, gasApprox: null },
4111
+ warnings: planWarnings.slice(),
4112
+ surfaces: {
4113
+ thumbnail: { ok: !imageOrphaned, detail: stripAnsiForPlan(imageDisposition) },
4114
+ traits: { ok: !traitsBroken, detail: stripAnsiForPlan(traitsDisposition) },
4115
+ postParams: { ok: !(undeclaredParams.length > 0 || paramsNudge), detail: stripAnsiForPlan(paramsDisposition) },
4116
+ },
4117
+ dependencies: { count: deps.length, registry: depRegistry ?? null },
4118
+ resume: null,
4119
+ },
4120
+ }));
3773
4121
  return;
3774
4122
  }
3775
4123
  // Real deploy: perform the deferred directory upload now (past the confirm gate).
@@ -3894,36 +4242,39 @@ export async function cmdDeployCodeBody(flags, emit) {
3894
4242
  if (flags[flag] !== undefined)
3895
4243
  throw new Error(`--resume cannot be combined with --${flag}: ${why}.`);
3896
4244
  }
3897
- // Edition resume is NOT supported: the SDK's `planResume`/`ResumeReader` (resume.ts) assume the
3898
- // 721 shape throughout — a single sequential `mint(address)` shortfall against a whole-contract
3899
- // `totalSupply()`, neither of which exists on EditionCode (`mint(address,uint256,uint256)` per id,
3900
- // no whole-contract total — see tokens.ts's `TokenListing.totalSupply` doc). Refuse cleanly rather
3901
- // than diff against the wrong shape and silently under- or over-report what's missing.
3902
- if (await isEditionContract(publicClient, target)) {
3903
- throw new Error(`--resume ${target}: this is an EditionCode contract, and resume isn't wired for editions yet — the diff logic ` +
3904
- `(packages/sdk/src/resume.ts) assumes the 721 shape (one sequential mint, one whole-contract totalSupply), ` +
3905
- `neither of which an edition has. If the setup multicall is what failed, the safest path right now is a hand-` +
3906
- `assembled resend (the SAME idea resume.ts automates for 721 see its class-level doc comment) or filing this ` +
3907
- `as a gap: edition resume needs its own ResumeReader/SetupLegs shape (per-id totalSupply, a (to,id,amount) mint leg).`);
3908
- }
4245
+ // 721 SeriesCode vs EditionCode: same setup shape (script chunks, schemas, deps, on-chain URI
4246
+ // legs), different mint shape — a 721 mints ONE sequential token against a whole-contract
4247
+ // `totalSupply()`; an edition mints AMOUNT copies of a specific id against that id's own
4248
+ // `totalSupply(id)` (there is no whole-contract total on an edition — see tokens.ts's
4249
+ // `TokenListing.totalSupply` doc). `resume.ts` models both (`planResume`/`planEditionResume`,
4250
+ // sharing the four non-mint legs via `planCoreLegs`); this is the one place that decides which.
4251
+ const isEdition = await isEditionContract(publicClient, target);
4252
+ if (!isEdition && flags['mint-amount'] !== undefined) {
4253
+ throw new Error(`--resume ${target}: this is a 721 SeriesCode contract, so --mint-amount has nothing to address its mint leg ` +
4254
+ `is a single whole-contract shortfall with no per-id amount. Drop --mint-amount (use --mint-count).`);
4255
+ }
4256
+ const abi = isEdition ? editionCodeAbi : seriesCodeAbi;
3909
4257
  const owner = await publicClient
3910
- .readContract({ address: target, abi: seriesCodeAbi, functionName: 'owner' })
4258
+ .readContract({ address: target, abi, functionName: 'owner' })
3911
4259
  .catch(() => undefined);
3912
4260
  if (!owner)
3913
- throw new Error(`--resume ${target}: could not read owner() — is this an ABX SeriesCode contract?`);
4261
+ throw new Error(`--resume ${target}: could not read owner() — is this an ABX SeriesCode/EditionCode contract?`);
3914
4262
  step('Resume — read what is missing, send only that');
3915
- info(`target ${bold(target)} ${dim(`· owner ${owner}`)}`);
4263
+ info(`target ${bold(target)} ${dim(`· owner ${owner}${isEdition ? ' · EditionCode (ERC-1155)' : ''}`)}`);
3916
4264
  // Every read is a view on the target; the intended legs come from the SAME builder a fresh deploy
3917
- // uses, so what gets sent is by construction what the deploy would have written.
4265
+ // uses, so what gets sent is by construction what the deploy would have written. Only the four
4266
+ // non-mint groups are used on the edition branch below — `legs.mints` is the 721 shape and is
4267
+ // rebuilt per-id there instead (see resume.ts's class doc for why the split).
3918
4268
  const legs = setupLegGroups(owner);
3919
- // ResumeReader's contract is `T | null` (its planner distinguishes "read failed" from a
3920
- // legitimately-falsy result); tryReadContract's is `T | undefined` — adapt at the boundary.
4269
+ // The contract is `T | null` (the planner distinguishes "read failed" from a legitimately-falsy
4270
+ // result); tryReadContract's is `T | undefined` — adapt at the boundary.
3921
4271
  const readAt = async (functionName, args = []) => {
3922
- const result = await tryReadContract(publicClient, { address: target, abi: seriesCodeAbi, functionName, args });
4272
+ const result = await tryReadContract(publicClient, { address: target, abi, functionName, args });
3923
4273
  return result === undefined ? null : result;
3924
4274
  };
3925
4275
  const nonZero = (a) => (a && a !== zeroAddress ? a : null);
3926
- const reader = {
4276
+ // The four id-agnostic reads, shared verbatim by both standards (see resume.ts's `ResumeReaderCore`).
4277
+ const coreReads = {
3927
4278
  scriptChunkCount: async () => Number((await readAt('scriptChunkCount')) ?? 0n),
3928
4279
  scriptChunk: (index) => readAt('scriptChunk', [BigInt(index)]),
3929
4280
  schemaExists: async (key) => {
@@ -3938,16 +4289,78 @@ export async function cmdDeployCodeBody(flags, emit) {
3938
4289
  const r = await readAt('contractField', [encodeTag(field)]);
3939
4290
  return !!r && r[0] !== `0x${'0'.repeat(64)}`;
3940
4291
  },
3941
- totalSupply: async () => Number((await readAt('totalSupply')) ?? 0n),
3942
4292
  };
3943
- const plan = await planResume(reader, legs);
4293
+ const plan = isEdition
4294
+ ? await (async () => {
4295
+ // `--mint-count`/`--mint-amount` are the SAME flags a fresh `--copies` deploy reads (see
4296
+ // cmdDeployEditionCodeBody) — so re-running the original command with `--copies` swapped
4297
+ // for `--resume <address>` reproduces the identical intended mint plan: ids 0..mintCount-1,
4298
+ // each an intended TOTAL of mintAmount copies.
4299
+ const mintAmount = flags['mint-amount'] !== undefined ? parseNonNegativeIntFlag(String(flags['mint-amount']), 'mint-amount') : 1n;
4300
+ if (mintCount > 0 && mintAmount === 0n) {
4301
+ throw new Error('--mint-amount 0 with ids being pre-minted makes no sense — pass a positive --mint-amount, or drop --mint-count/--no-mint it entirely.');
4302
+ }
4303
+ const editionLegs = {
4304
+ chunks: legs.chunks,
4305
+ schemas: legs.schemas,
4306
+ deps: legs.deps,
4307
+ uri: legs.uri,
4308
+ mints: Array.from({ length: mintCount }, (_, id) => ({
4309
+ id: BigInt(id),
4310
+ intendedAmount: mintAmount,
4311
+ dataForAmount: (amount) => encodeFunctionData({ abi: oneOfOneEditionAbi, functionName: 'mint', args: [owner, BigInt(id), amount] }),
4312
+ })),
4313
+ };
4314
+ const reader = {
4315
+ ...coreReads,
4316
+ totalSupplyForId: async (id) => Number((await readAt('totalSupply', [id])) ?? 0n),
4317
+ };
4318
+ return planEditionResume(reader, editionLegs);
4319
+ })()
4320
+ : await planResume({ ...coreReads, totalSupply: async () => Number((await readAt('totalSupply')) ?? 0n) }, legs);
3944
4321
  for (const line of plan.done)
3945
4322
  ok(line);
3946
4323
  for (const line of plan.todo)
3947
4324
  info(`will send — ${line}`);
4325
+ // A resume plan object (below) reports its OWN family/roles/custody, deliberately lighter than a
4326
+ // fresh deploy's: royalty, mint, and byte custody are InitParams fields fixed at the ORIGINAL
4327
+ // deploy — a resume only ever finishes MISSING setup legs, so those fields are explicit `null`
4328
+ // (see deploy-plan.ts's own doc comments) rather than restating what this invocation's flags say,
4329
+ // which a resume does not actually write.
4330
+ // ANNOTATED, not inferred: this object is spread into four `satisfies DeployPlan` emits below, and
4331
+ // an un-annotated literal WIDENS its own properties (`schemaVersion` 1 -> number, `family`
4332
+ // 'code'|'code-edition' -> string), so every one of those spreads would fail to satisfy the plan.
4333
+ // The Omit names exactly the three keys each call site supplies for itself.
4334
+ const resumePlanBase = {
4335
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
4336
+ family: isEdition ? 'code-edition' : 'code',
4337
+ lane,
4338
+ roles: { signer: owner, owner, royaltyReceiver: null, primaryPayee: null, minter: null },
4339
+ royalty: null,
4340
+ custody: { onChainUri, imageOnChain: null, backend: null, tokenUriBase: null, contractUriBase: null },
4341
+ mint: null,
4342
+ warnings: planWarnings.slice(),
4343
+ // Still reflects what THIS invocation's flags say about every marketplace-facing surface —
4344
+ // computed once, unconditionally, at the top of the function (see `imageDisposition` et al.),
4345
+ // so it's available whether this run turns out to be a fresh deploy or a `--resume`.
4346
+ surfaces: {
4347
+ thumbnail: { ok: !imageOrphaned, detail: stripAnsiForPlan(imageDisposition) },
4348
+ traits: { ok: !traitsBroken, detail: stripAnsiForPlan(traitsDisposition) },
4349
+ postParams: { ok: !(undeclaredParams.length > 0 || paramsNudge), detail: stripAnsiForPlan(paramsDisposition) },
4350
+ },
4351
+ dependencies: { count: deps.length, registry: depRegistry ?? null },
4352
+ };
3948
4353
  if (!plan.calls.length) {
3949
4354
  ok(`nothing missing — this contract's setup is already complete. ${dim('Confirm with `abx verify ' + target + '`.')}`);
3950
- emit(jsonSafe({ command: 'deploy-code', resumed: target, chain: CHAIN, chainId: resolveChain(CHAIN).id, sent: false, complete: true, sentLegs: 0 }));
4355
+ emit(jsonSafe({
4356
+ command: 'deploy-code', kind: isEdition ? 'edition-code' : 'code', resumed: target, chain: CHAIN, chainId: resolveChain(CHAIN).id, sent: false, complete: true, sentLegs: 0,
4357
+ plan: {
4358
+ ...resumePlanBase,
4359
+ transactions: { approvals: 0, legs: null },
4360
+ estimate: { ethApprox: null, gasApprox: null },
4361
+ resume: { target, sentLegs: 0, complete: true },
4362
+ },
4363
+ }));
3951
4364
  console.log('');
3952
4365
  return;
3953
4366
  }
@@ -3961,6 +4374,15 @@ export async function cmdDeployCodeBody(flags, emit) {
3961
4374
  deps: plan.sending.deps ? deps.map((d) => d.display) : [],
3962
4375
  onchainUri: plan.sending.uri,
3963
4376
  });
4377
+ // A resume always sends its missing legs as ONE setup-multicall tx — one wallet approval — so
4378
+ // `transactions.legs` names WHICH groups ride it, from the same `plan.sending` flags that build
4379
+ // the tx above (never re-derived).
4380
+ const resumeLegs = [
4381
+ plan.sending.chunkIndices.length ? 'chunks' : null,
4382
+ plan.sending.schemaKeys.length ? 'param-schemas' : null,
4383
+ plan.sending.deps ? 'dependencies' : null,
4384
+ plan.sending.uri ? 'onchain-uri' : null,
4385
+ ].filter((leg) => leg !== null);
3964
4386
  // Routed through the shared risk gate — the same choke point ownerops.ts's `runWrite` uses for
3965
4387
  // every owner-op: --dry-run preview / --confirm prompt / lane selection / signing, all one way.
3966
4388
  // This used to be its own hand-rolled copy of that exact shape (a dry-run print then confirmSend
@@ -3968,14 +4390,30 @@ export async function cmdDeployCodeBody(flags, emit) {
3968
4390
  // any other, so it takes the identical gate (the preview also gains the `to`/`owner` lines
3969
4391
  // `runWrite`'s preview always printed).
3970
4392
  if (isDryRun(flags)) {
3971
- emit(jsonSafe({ command: 'deploy-code', resumed: target, chain: CHAIN, chainId: resolveChain(CHAIN).id, dryRun: true, sent: false, complete: false, sentLegs: plan.calls.length }));
4393
+ emit(jsonSafe({
4394
+ command: 'deploy-code', kind: isEdition ? 'edition-code' : 'code', resumed: target, chain: CHAIN, chainId: resolveChain(CHAIN).id, dryRun: true, sent: false, complete: false, sentLegs: plan.calls.length,
4395
+ plan: {
4396
+ ...resumePlanBase,
4397
+ transactions: { approvals: 1, legs: resumeLegs },
4398
+ estimate: { ethApprox: null, gasApprox: null },
4399
+ resume: { target, sentLegs: plan.calls.length, complete: false },
4400
+ },
4401
+ }));
3972
4402
  }
3973
4403
  const result = await gatedSend(() => tx, flags, { chainKey: CHAIN, expectedSigner: owner });
3974
4404
  if (!result) {
3975
4405
  if (isDryRun(flags))
3976
4406
  return;
3977
4407
  console.log(`\n${dim(' unsigned — broadcast it, then re-run `abx verify ' + target + '` to confirm the setup completed.')}\n`);
3978
- emit(jsonSafe({ command: 'deploy-code', resumed: target, chain: CHAIN, chainId: resolveChain(CHAIN).id, sent: false, complete: false, sentLegs: plan.calls.length }));
4408
+ emit(jsonSafe({
4409
+ command: 'deploy-code', kind: isEdition ? 'edition-code' : 'code', resumed: target, chain: CHAIN, chainId: resolveChain(CHAIN).id, sent: false, complete: false, sentLegs: plan.calls.length,
4410
+ plan: {
4411
+ ...resumePlanBase,
4412
+ transactions: { approvals: 1, legs: resumeLegs },
4413
+ estimate: { ethApprox: null, gasApprox: null },
4414
+ resume: { target, sentLegs: plan.calls.length, complete: false },
4415
+ },
4416
+ }));
3979
4417
  return;
3980
4418
  }
3981
4419
  ok(`${tx.summary}`);
@@ -3985,6 +4423,7 @@ export async function cmdDeployCodeBody(flags, emit) {
3985
4423
  const resumedFloor = (await discoverDeployBlock(publicClient, target)) ?? result.blockNumber;
3986
4424
  emit(jsonSafe({
3987
4425
  command: 'deploy-code',
4426
+ kind: isEdition ? 'edition-code' : 'code',
3988
4427
  resumed: target,
3989
4428
  address: target,
3990
4429
  chain: CHAIN,
@@ -3994,15 +4433,33 @@ export async function cmdDeployCodeBody(flags, emit) {
3994
4433
  complete: true,
3995
4434
  sentLegs: plan.calls.length,
3996
4435
  txHash: result.txHash,
4436
+ plan: {
4437
+ ...resumePlanBase,
4438
+ transactions: { approvals: 1, legs: resumeLegs },
4439
+ estimate: { ethApprox: null, gasApprox: null },
4440
+ resume: { target, sentLegs: plan.calls.length, complete: true },
4441
+ },
3997
4442
  }));
3998
4443
  step('Index');
3999
- await registerAndIndexLocally(target, { 'from-block': resumedFloor.toString(), factory, label: name });
4444
+ // The factory HINT registerAndIndexLocally passes through to detectCanonicalFactory is taken as
4445
+ // an UNVERIFIED override when present (see anchors.ts) — so it must name the RIGHT factory family
4446
+ // for this target. `factory` (outer scope) is the 721 SeriesCode factory this function resolved at
4447
+ // its own "Trust anchor" step; an EditionCode target needs its own factory's address instead (a
4448
+ // read-only manifest lookup — never bootstraps one, unlike a real edition deploy).
4449
+ await registerAndIndexLocally(target, {
4450
+ 'from-block': resumedFloor.toString(),
4451
+ factory: isEdition ? editionCodeFactoryAddress(flags.factory) : factory,
4452
+ label: name,
4453
+ });
4000
4454
  console.log(`\n ${g('\u2713 setup finished')} \u2014 ${dim('confirm it resolves:')} ${bold(`abx verify ${target}`)}` +
4001
4455
  `${onChainUri ? dim(' (chain-complete + the on-chain tokenURI)') : ''}\n`);
4002
4456
  return;
4003
4457
  }
4004
4458
  let clone;
4005
4459
  let deployBlock;
4460
+ // See the 1/1 lane's identical local for why this is hoisted (feeds the sent plan's `roles`). The
4461
+ // 'unsigned' branch below returns before the sent `emit()`, so it never needs to set this.
4462
+ let deployerAddr;
4006
4463
  if (lane === 'unsigned') {
4007
4464
  // cold lane: deterministic deploy makes the whole sequence pre-computable — the clone
4008
4465
  // address is a pure function of (factory, salt), so setup + mints target it up front.
@@ -4027,6 +4484,7 @@ export async function cmdDeployCodeBody(flags, emit) {
4027
4484
  const session = await openWalletSession({ chainKey: CHAIN, expectedSigner: flags.for, total: approvals, port: flags.port ? Number(flags.port) : undefined, signUrlFile: flags['sign-url-file'] });
4028
4485
  try {
4029
4486
  const signer = await session.connect();
4487
+ deployerAddr = signer;
4030
4488
  const { clone: predicted, txs } = await preparedFor(signer);
4031
4489
  // The scan floor is the clone-CREATION block (txs[0]) — NOT the last tx. A wallet session can
4032
4490
  // span blocks (deploy at N, setup+mint at N+2), and the `code` field is written in the deploy
@@ -4052,6 +4510,7 @@ export async function cmdDeployCodeBody(flags, emit) {
4052
4510
  // `signHotSequence` (signer.ts) / `makeHotSender` (sdk execute.ts) for the read-after-write-lag
4053
4511
  // reasoning this used to be a hand-rolled loop for.
4054
4512
  const { account } = makeWalletClient({ chainKey: CHAIN });
4513
+ deployerAddr = account.address;
4055
4514
  const { clone: predicted, txs } = await preparedFor(account.address);
4056
4515
  const results = await signHotSequence(txs, { chainKey: CHAIN, yes: !!flags.yes });
4057
4516
  clone = predicted;
@@ -4087,6 +4546,37 @@ export async function cmdDeployCodeBody(flags, emit) {
4087
4546
  name,
4088
4547
  symbol,
4089
4548
  onChainUri,
4549
+ plan: {
4550
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
4551
+ family: 'code',
4552
+ lane,
4553
+ transactions: { approvals, legs: planSetupLegs },
4554
+ roles: {
4555
+ signer: deployerAddr ?? null, owner: deployerAddr ?? null, royaltyReceiver: deployerAddr ?? null,
4556
+ primaryPayee: flags['primary-payee'] ?? null,
4557
+ minter: flags.minter ?? null,
4558
+ },
4559
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
4560
+ custody: {
4561
+ onChainUri,
4562
+ imageOnChain: null, // see `surfaces.thumbnail`
4563
+ backend: dirUpload ? dirUpload.backend.id : null,
4564
+ tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
4565
+ contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
4566
+ },
4567
+ mint: { deferred: mintCount === 0, count: mintCount, amountPerId: null, recipient: mintCount > 0 ? deployerAddr ?? null : null },
4568
+ // Unlike the dry run, the sent path computes no cost estimate (the gas-guidance try/catch is
4569
+ // dry-run-only — a real send just pays whatever `estimateGas` returns at signing time).
4570
+ estimate: { ethApprox: null, gasApprox: null },
4571
+ warnings: planWarnings.slice(),
4572
+ surfaces: {
4573
+ thumbnail: { ok: !imageOrphaned, detail: stripAnsiForPlan(imageDisposition) },
4574
+ traits: { ok: !traitsBroken, detail: stripAnsiForPlan(traitsDisposition) },
4575
+ postParams: { ok: !(undeclaredParams.length > 0 || paramsNudge), detail: stripAnsiForPlan(paramsDisposition) },
4576
+ },
4577
+ dependencies: { count: deps.length, registry: depRegistry ?? null },
4578
+ resume: null,
4579
+ },
4090
4580
  }));
4091
4581
  step('Index');
4092
4582
  await registerAndIndexLocally(clone, { 'from-block': deployBlock.toString(), factory, label: name });
@@ -4175,43 +4665,51 @@ export const DEPLOY_CODE_EDITION_FLAGS = new Set([
4175
4665
  'public-base-url', 'onchain-uri', 'generator', 'renderer', 'port',
4176
4666
  'sign', 'unsigned', 'for', 'salt', 'factory', 'bootstrap-factory', 'sign-url-file',
4177
4667
  'dry-run', 'confirm', 'yes', 'json', 'copies',
4178
- // v1-scope-cut flags: kept in the ALLOWLIST (not stray) so they reach this function's own
4179
- // pointed refusal below, rather than the generic "unrecognized flag" `refuseStrayFlags` would
4180
- // give them otherwise a real but less helpful refusal (it doesn't say WHY, or the way out).
4668
+ // storage (directory mode + --image-base's off-chain still) mirrors storageOverrides(), same
4669
+ // allowlist entries as the 721 twin's DEPLOY_CODE_FLAGS.
4670
+ 'code-dir', 'backend', 'endpoint', 'bucket', 'region', 'prefix', 'public-base', 'gateway', 'mode', 'api-url', 'upload-url', 'provider', 'storage-signer',
4671
+ 'ipfs-gateway', 'arweave-gateway',
4672
+ // NOT scope cuts: --image-renderer/--attributes-renderer (on-chain field renderers) and
4673
+ // --image-base (an off-chain deterministic-per-id still) are all fully wired on EditionCode —
4674
+ // `abx help deploy-code` documents them as working. --dep/--dep-registry likewise: EditionCode
4675
+ // inherits the same Dependencies extension, and the legs ride the same setup multicall.
4676
+ 'image-renderer', 'attributes-renderer', 'image-base', 'dep', 'dep-registry',
4677
+ // v1-scope-cut flag: kept in the ALLOWLIST (not stray) so it reaches this function's own pointed
4678
+ // refusal below, rather than the generic "unrecognized flag" `refuseStrayFlags` would give it
4679
+ // otherwise — a real but less helpful refusal (it doesn't say WHY, or the way out).
4181
4680
  // (`resume` is NOT here: --resume + --copies is refused earlier, at cmdDeployCode's dispatch —
4182
4681
  // this function never runs with flags.resume set, so listing it here would just be dead.)
4183
- 'code-dir', 'image-renderer', 'attributes-renderer', 'image-base', 'no-delegation',
4184
- // NOT scope cuts: --dep/--dep-registry are fully wired on EditionCode (it inherits the same
4185
- // Dependencies extension, and the legs ride the same setup multicall — see the Dependencies step
4186
- // below). They're plain supported flags; `abx help deploy-code` documents them as working.
4187
- 'dep', 'dep-registry',
4682
+ 'no-delegation',
4188
4683
  ]);
4189
4684
  export async function cmdDeployEditionCodeBody(flags, emit) {
4190
- const usage = 'abx deploy-code --copies <n|open> --script <file> --name "Title" --symbol SYM (--public-base-url https://your.resolver.domain | --onchain-uri) ' +
4685
+ beginPlanWarnings();
4686
+ const usage = 'abx deploy-code --copies <n|open> (--script <file> | --code-dir <dir>) --name "Title" --symbol SYM (--public-base-url https://your.resolver.domain | --onchain-uri) ' +
4191
4687
  '[--description "<s>"] [--external-url <url>] [--max N] [--mint-count N | --mint-all] [--mint-amount N] ' +
4192
4688
  '[--schema key:Type:Auth,…] [--no-seed | --seed-source 0x..] [--unpaused] [--minter 0x..] [--primary-payee 0x..] [--royalty-bps N] [--721c recommended|0x..]';
4193
4689
  refuseStrayFlags(flags, DEPLOY_CODE_EDITION_FLAGS, 'deploy-code');
4194
4690
  warnSignWithoutFor(flags);
4195
4691
  const editionSize = parseCopies(flags.copies);
4196
- if (flags['code-dir']) {
4197
- throw new Error('--code-dir is not yet supported for edition code deploys (--copies) — use --script (on-chain chunks) instead. Tracked as a follow-up.');
4198
- }
4199
- // --image-base stays refused: unlike the renderer flags it is not a field write, it needs the
4200
- // effect runner's per-token still + the url-template's `{id}` substitution over an id SPACE rather
4201
- // than a token sequence. That port is real work; these two were not.
4202
- if (flags['image-base']) {
4203
- throw new Error('--image-base is not wired for edition code deploys (--copies) yet — it needs the effect runner to write a\n' +
4204
- ' still per id, which the edition lane does not drive. The two flags beside it DO work here:\n' +
4205
- ' `--image-renderer 0x.. --attributes-renderer 0x.. --onchain-uri` computes image + traits in Solidity, on\n' +
4206
- ' chain, with nothing to render or host. Otherwise use --public-base-url and a resolver.');
4207
- }
4692
+ const scriptPath = flags.script;
4693
+ const codeDir = flags['code-dir'];
4694
+ if (scriptPath && codeDir)
4695
+ throw new Error(usage);
4208
4696
  // The in-chain Solidity renderer lane, same as the 721 twin: EditionCode composes the same
4209
4697
  // OnChainMetadata extension and the same URI-renderer slots, so `image`/`attributes` at a
4210
4698
  // `renderer` representation resolve through the canonical metadata renderer with no server in the
4211
4699
  // graph. Proven end-to-end in contracts/test/EditionOnChainRender.t.sol.
4212
4700
  const imageRendererFlag = flags['image-renderer'] ? String(flags['image-renderer']) : undefined;
4213
4701
  const attributesRendererFlag = flags['attributes-renderer'] ? String(flags['attributes-renderer']) : undefined;
4214
- const hasProgram = !!flags.script;
4702
+ // --image-renderer and --image-base both set the `image` field — pick ONE, same rule as the 721
4703
+ // twin. Checked before either is resolved so the refusal is immediate rather than after a renderer
4704
+ // code-presence probe.
4705
+ if (imageRendererFlag && flags['image-base']) {
4706
+ throw new Error('--image-renderer and --image-base both set the `image` field — pick ONE: an ON-CHAIN Solidity render (--image-renderer, no infra) OR an off-chain bucket URL (--image-base).');
4707
+ }
4708
+ // A JS program either way (template chunks or a hosted build) — same "hasProgram" test as the 721
4709
+ // twin (see its own `const hasProgram = !!(scriptPath || codeDir);`), so every downstream
4710
+ // conditional gated on it (generator resolution, animation_url, the on-chain-URI legs) already
4711
+ // does the right thing for a directory build without a SEPARATE flag to thread through.
4712
+ const hasProgram = !!(scriptPath || codeDir);
4215
4713
  const hasFieldRenderers = !!(imageRendererFlag || attributesRendererFlag);
4216
4714
  if (!hasProgram && !hasFieldRenderers) {
4217
4715
  throw new Error(`${usage}\n (or, for a FULLY on-chain Solidity render with no program: --copies <n|open> --image-renderer 0x.. ` +
@@ -4373,11 +4871,30 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4373
4871
  }
4374
4872
  }
4375
4873
  }
4874
+ // Content: directory mode uploads the build (its root locator → the `code` collection field);
4875
+ // template mode chunks the script for on-chain storage. The directory UPLOAD is deferred until
4876
+ // after the --confirm gate (and skipped entirely on --dry-run) so a preview never pins bytes —
4877
+ // same shape as the 721 twin's identical block (see its own comment for the full reasoning).
4376
4878
  step('Content');
4377
4879
  let scriptChunks = [];
4880
+ let dirUpload = null;
4378
4881
  let contentSummary;
4379
- if (hasProgram) {
4380
- const source = readFileSync(resolvePath(flags.script), 'utf8');
4882
+ if (codeDir) {
4883
+ const dirPath = resolvePath(codeDir);
4884
+ const files = readdirSync(dirPath).filter((f) => !f.startsWith('.') && statSync(joinPath(dirPath, f)).isFile());
4885
+ if (!files.includes('index.html'))
4886
+ throw new Error(`${dirPath} has no index.html — the directory entry, by convention`);
4887
+ const backend = resolveBackend(storageOptions(storageOverrides(flags)));
4888
+ if (!backend.putDirectory)
4889
+ throw new Error(`storage backend '${backend.id}' has no directory upload — use --backend ipfs (pinata) or arweave`);
4890
+ const entries = files.map((f) => ({ name: f, bytes: new Uint8Array(readFileSync(joinPath(dirPath, f))), contentType: contentTypeFromPath(joinPath(dirPath, f)) }));
4891
+ dirUpload = { backend, entries, sizes: entries.map((e) => e.bytes.length) };
4892
+ contentSummary = `${entries.length} file(s) → ${backend.id} directory (code field)`;
4893
+ info(`${entries.length} file(s) from ${basename(dirPath)}/ → ${backend.id} directory (code field)`);
4894
+ info(dim('the live view 302s through the gateway — it must serve HTML (the shared Pinata public gateway does not; use a dedicated gateway or arweave).'));
4895
+ }
4896
+ else if (scriptPath) {
4897
+ const source = readFileSync(resolvePath(scriptPath), 'utf8');
4381
4898
  scriptChunks = planOnChainScript(source);
4382
4899
  contentSummary = `script ${Buffer.byteLength(source, 'utf8')} bytes → ${scriptChunks.length} on-chain chunk(s)`;
4383
4900
  ok(contentSummary);
@@ -4431,6 +4948,56 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4431
4948
  if (hasFieldRenderers && !onChainUri) {
4432
4949
  warn('--image-renderer/--attributes-renderer compute fields ON-CHAIN, but without --onchain-uri the URI renderers are never wired, so uri() still resolves through your resolver and nothing reads them. Add --onchain-uri.');
4433
4950
  }
4951
+ // --image-base: bake the on-chain `image` as a url-template (`{base}/{id}.png`) — same field write
4952
+ // as the 721 twin's identical block (see its own comment for the full reasoning), one id space
4953
+ // finer: `{id}` substitutes the EditionCode id, not a sequential 721 token number, and the effect
4954
+ // runner's render sweep already excludes an id with no live copies (harness.ts) — an id with no
4955
+ // mint-time seed drawn yet has nothing to render FROM, the same reason a burned 721 token is
4956
+ // excluded. There is no per-copy addressing: every copy of an id shares one image, same as every
4957
+ // other field on an id.
4958
+ if (flags['image-base']) {
4959
+ const base = String(flags['image-base']);
4960
+ // --image-base needs a mutable, path-addressed URL (the effect runner overwrites the SAME key in
4961
+ // place) — a content-addressed gateway URL (ipfs/arweave) can't back a FIXED per-id address, no
4962
+ // matter what THIS deploy's own --backend happens to be. One shared validator decides this
4963
+ // (packages/storage/content-plan.ts), so this can never disagree with the 721 lane's own refusal.
4964
+ const imageBaseCombo = validateRenderStorageCombo({ imageBaseUrl: base });
4965
+ if (!imageBaseCombo.ok) {
4966
+ if (dryRun)
4967
+ warn(`would REFUSE — ${imageBaseCombo.reason}`);
4968
+ else
4969
+ throw new Error(imageBaseCombo.reason);
4970
+ }
4971
+ // NEVER bake a localhost/loopback image host on-chain — the token's `image` would resolve for no
4972
+ // one. This is the marketplace still's PERMANENT address.
4973
+ if (loopbackBaseUrl(base)) {
4974
+ const m = `--image-base ${base} is localhost/loopback — the on-chain image URL would resolve for no marketplace. Use a PUBLIC bucket (S3/R2/CDN) you control.`;
4975
+ if (dryRun)
4976
+ warn(`would REFUSE — ${m}`);
4977
+ else
4978
+ throw new Error(m);
4979
+ }
4980
+ // The exact trap a real agent hit on the 721 lane: it baked the S3 API endpoint (auth-only) as
4981
+ // --image-base. Refuse the R2 API form (never public); warn on the S3 API form.
4982
+ if (/\.r2\.cloudflarestorage\.com/i.test(base)) {
4983
+ const m = `--image-base ${base} is R2's S3 API endpoint (auth-only) — marketplaces get 403, it is NEVER public. Put THIS URL in ${bold('ABX_S3_ENDPOINT')} (uploads), and pass --image-base your bucket's PUBLIC read URL — enable an ${bold('r2.dev')} public URL (\`https://pub-<hash>.r2.dev\`) or a custom domain in the R2 dashboard.`;
4984
+ if (dryRun)
4985
+ warn(`would REFUSE — ${m}`);
4986
+ else
4987
+ throw new Error(m);
4988
+ }
4989
+ else if (/(^|\/\/)s3[.-][^/]*amazonaws\.com/i.test(base) || /\.s3[.-][^/]*amazonaws\.com/i.test(base)) {
4990
+ warn(`--image-base ${base} looks like the S3 API host — marketplaces can read it ONLY if the bucket has public-read (or you front it with CloudFront/a domain). If it's not public, use that URL in ${bold('ABX_S3_ENDPOINT')} and pass a public URL here instead.`);
4991
+ }
4992
+ const imageTemplate = base.includes('{id}') ? base : `${base.replace(/\/+$/, '')}/{id}.png`;
4993
+ contractFields.push({ field: encodeTag(F.image), representation: encodeTag(R.urlTemplate), value: toHex(imageTemplate) });
4994
+ info(`collection image → on-chain url-template ${bold(imageTemplate)} ${dim('(off-chain thumbnail at a deterministic per-id URL; the effect runner overwrites each key once a copy is minted)')}`);
4995
+ info(` ${dim('the runner uploads stills here via')} ${bold('ABX_S3_ENDPOINT · ABX_S3_BUCKET · ABX_S3_ACCESS_KEY_ID · ABX_S3_SECRET_ACCESS_KEY')} ${dim('(set ABX_S3_PUBLIC_BASE to this same --image-base; `abx storage show` checks them; details → reference/hosting.md)')}`);
4996
+ const s3Base = process.env.ABX_S3_PUBLIC_BASE?.replace(/\/+$/, '');
4997
+ if (s3Base && !imageTemplate.startsWith(`${s3Base}/`)) {
4998
+ warn(`--image-base isn't under ABX_S3_PUBLIC_BASE (${s3Base}) — the runner keys its deterministic publish off that base, so it won't auto-upload here unless they share an origin.`);
4999
+ }
5000
+ }
4434
5001
  const onchainUriLegs = onChainUri ? onchainUriSetupCalls({ generator, metadataRenderer }) : [];
4435
5002
  step('Deploy');
4436
5003
  const lane = laneFromFlags(flags);
@@ -4513,6 +5080,17 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4513
5080
  // (a script with no schemas and no premints) would report 1 approval and send 2 transactions.
4514
5081
  const depLegCount = dependencySetupCalls(deps, depRegistry).length;
4515
5082
  const approvals = 1 + (scriptChunks.length + schemas.length + depLegCount + onchainUriLegs.length + mintCount > 0 ? 1 : 0);
5083
+ // Ordered legs riding the setup multicall (if any) — same booleans `approvals` above uses, so the
5084
+ // plan object's `transactions.legs` can never drift from the tx the wallet actually signs.
5085
+ const planSetupLegs = approvals > 1
5086
+ ? [
5087
+ scriptChunks.length ? 'chunks' : null,
5088
+ schemas.length ? 'param-schemas' : null,
5089
+ depLegCount ? 'dependencies' : null,
5090
+ onchainUriLegs.length ? 'onchain-uri' : null,
5091
+ mintCount ? 'mints' : null,
5092
+ ].filter((leg) => leg !== null)
5093
+ : null;
4516
5094
  if (!dryRun) {
4517
5095
  await confirmSend(
4518
5096
  // Second site of the same missing binding as the plan line above — and the worse one: this is
@@ -4558,11 +5136,60 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4558
5136
  info(`address: pinned by salt — re-run with ${bold(`--salt ${salt}`)} (same address).`);
4559
5137
  }
4560
5138
  console.log(`\n ${g('dry run')} ${dim('— nothing sent.')}\n`);
4561
- emit(jsonSafe({ command: 'deploy-code', kind: 'edition-code', copies: editionSize.toString(), dryRun: true, sent: false, address: shownAddr, chain: CHAIN, chainId, factory, name, symbol }));
5139
+ emit(jsonSafe({
5140
+ command: 'deploy-code', kind: 'edition-code', copies: editionSize.toString(), dryRun: true, sent: false, address: shownAddr, chain: CHAIN, chainId, factory, name, symbol,
5141
+ plan: {
5142
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
5143
+ family: 'code-edition',
5144
+ lane,
5145
+ transactions: { approvals, legs: planSetupLegs },
5146
+ roles: {
5147
+ signer: deployer, owner: deployer, royaltyReceiver: deployer,
5148
+ primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
5149
+ minter: minter !== zeroAddress ? minter : null,
5150
+ },
5151
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
5152
+ custody: {
5153
+ onChainUri,
5154
+ imageOnChain: null, // this lane has no surfaces block (see deploy-plan.ts); a field-renderer image is still `hasFieldRenderers` in the prose, not tracked here
5155
+ backend: dirUpload ? dirUpload.backend.id : null,
5156
+ tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
5157
+ contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
5158
+ },
5159
+ mint: {
5160
+ deferred: mintCount === 0,
5161
+ count: mintCount,
5162
+ amountPerId: mintAmount.toString(),
5163
+ recipient: mintCount > 0 ? deployer : null,
5164
+ },
5165
+ estimate: { ethApprox: null, gasApprox: null }, // this lane computes no cost estimate
5166
+ warnings: planWarnings.slice(),
5167
+ surfaces: null, // only the fresh 721 code lane computes the per-surface disposition today
5168
+ dependencies: { count: deps.length, registry: depRegistry ?? null },
5169
+ resume: null,
5170
+ },
5171
+ }));
4562
5172
  return;
4563
5173
  }
5174
+ // Real deploy: perform the deferred directory upload now (past the confirm gate). `contractFields`
5175
+ // is captured by reference in `initParamsFor` above — pushing to it here still lands in the CREATE
5176
+ // tx's init params, since that closure isn't actually CALLED until the signing lane below runs.
5177
+ if (dirUpload) {
5178
+ const { base } = await dirUpload.backend.putDirectory(dirUpload.entries);
5179
+ // Store the BARE locator (CID / arweave txid), never a full gateway URL — the resolver applies
5180
+ // the gateway at serve time per `representation`.
5181
+ const ipfsCid = base.match(/\/ipfs\/([^/]+)/)?.[1];
5182
+ const arTxid = base.match(/arweave\.net\/([^/?#]+)/i)?.[1];
5183
+ const rep = ipfsCid ? 'ipfs' : dirUpload.backend.id === 'arweave' ? 'arweave' : 'url';
5184
+ const value = ipfsCid ?? (rep === 'arweave' ? (arTxid ?? base) : base);
5185
+ contractFields.push({ field: encodeTag('code'), representation: encodeTag(rep), value: toHex(value) });
5186
+ ok(`uploaded ${dirUpload.entries.length} file(s) → code field (${rep}): ${value}`);
5187
+ }
4564
5188
  let clone;
4565
5189
  let deployBlock;
5190
+ // See the 1/1 lane's identical local for why this is hoisted (feeds the sent plan's `roles`). The
5191
+ // 'unsigned' branch below returns before the sent `emit()`, so it never needs to set this.
5192
+ let deployerAddr;
4566
5193
  if (lane === 'unsigned') {
4567
5194
  const signer = flags.for;
4568
5195
  if (!signer)
@@ -4577,6 +5204,7 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4577
5204
  const session = await openWalletSession({ chainKey: CHAIN, expectedSigner: flags.for, total: approvals, port: flags.port ? Number(flags.port) : undefined, signUrlFile: flags['sign-url-file'] });
4578
5205
  try {
4579
5206
  const signer = await session.connect();
5207
+ deployerAddr = signer;
4580
5208
  const { clone: predicted, txs } = await preparedFor(signer);
4581
5209
  let first;
4582
5210
  for (const tx of txs) {
@@ -4593,6 +5221,7 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4593
5221
  }
4594
5222
  else {
4595
5223
  const { account } = makeWalletClient({ chainKey: CHAIN });
5224
+ deployerAddr = account.address;
4596
5225
  const { clone: predicted, txs } = await preparedFor(account.address);
4597
5226
  const results = await signHotSequence(txs, { chainKey: CHAIN, yes: !!flags.yes });
4598
5227
  clone = predicted;
@@ -4606,7 +5235,39 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4606
5235
  info(`permanent: max ${max} id(s) · royalty ${royaltyBps / 100}%${flags['royalty-bps'] === undefined ? ' ⚠ default' : ''} → the deploying wallet` +
4607
5236
  ` · cap ${maxRoyaltyBps / 100}%${burnable ? ' · burnable' : ''}` +
4608
5237
  dim(' (rate is changeable with `abx set-royalty`; the CAP only ever goes down)'));
4609
- emit(jsonSafe({ command: 'deploy-code', kind: 'edition-code', copies: editionSize.toString(), address: clone, chain: CHAIN, chainId, factory, deployBlock, name, symbol, onChainUri }));
5238
+ emit(jsonSafe({
5239
+ command: 'deploy-code', kind: 'edition-code', copies: editionSize.toString(), address: clone, chain: CHAIN, chainId, factory, deployBlock, name, symbol, onChainUri,
5240
+ plan: {
5241
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
5242
+ family: 'code-edition',
5243
+ lane,
5244
+ transactions: { approvals, legs: planSetupLegs },
5245
+ roles: {
5246
+ signer: deployerAddr ?? null, owner: deployerAddr ?? null, royaltyReceiver: deployerAddr ?? null,
5247
+ primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
5248
+ minter: minter !== zeroAddress ? minter : null,
5249
+ },
5250
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
5251
+ custody: {
5252
+ onChainUri,
5253
+ imageOnChain: null,
5254
+ backend: dirUpload ? dirUpload.backend.id : null,
5255
+ tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
5256
+ contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
5257
+ },
5258
+ mint: {
5259
+ deferred: mintCount === 0,
5260
+ count: mintCount,
5261
+ amountPerId: mintAmount.toString(),
5262
+ recipient: mintCount > 0 ? deployerAddr ?? null : null,
5263
+ },
5264
+ estimate: { ethApprox: null, gasApprox: null },
5265
+ warnings: planWarnings.slice(),
5266
+ surfaces: null,
5267
+ dependencies: { count: deps.length, registry: depRegistry ?? null },
5268
+ resume: null,
5269
+ },
5270
+ }));
4610
5271
  step('Index');
4611
5272
  await registerAndIndexLocally(clone, { 'from-block': deployBlock.toString(), factory, label: name });
4612
5273
  if (onChainUri) {
@@ -4634,6 +5295,11 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4634
5295
  console.log(` ${bold(`abx mint ${clone} --token-id <id> --amount <n>`)} ${dim('# mint more copies of an id')}`);
4635
5296
  console.log(` ${bold(`abx minter configure ${clone} --token-id <id> --price <eth> --allocation <n>`)} ${dim('# set up a priced sale for one id')}`);
4636
5297
  console.log(` ${bold(`abx refresh ${clone}`)} ${dim('# nudge marketplaces once metadata is live')}\n`);
5298
+ // Same Arweave-propagation caveat as the 721 twin (see its own comment): the gateway 404s until
5299
+ // Turbo settles the bundle, so a render run right after deploy correctly finds nothing servable yet.
5300
+ if (dirUpload?.backend.id === 'arweave') {
5301
+ console.log(`\n ${c.orange}⚠${c.reset} ${bold('Arweave upload propagates with a delay.')} ${dim('Turbo settles the bundle over minutes (sometimes longer); until then the gateway 404s, so the live view and a render will fail. A render run now correctly reports the content is not servable yet and stores NOTHING (not a garbage 404 thumbnail) — this is expected. Re-run the render once the content is live (the effects service will pick it up on its next sweep).')}`);
5302
+ }
4637
5303
  }
4638
5304
  /**
4639
5305
  * Resolve `--721c` into the InitParams `transferValidator`. Absent → `zeroAddress`: a plain