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

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 (77) hide show
  1. package/CHANGELOG.md +222 -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 +51 -1
  10. package/dist/commands/deploy.d.ts.map +1 -1
  11. package/dist/commands/deploy.js +1008 -172
  12. package/dist/commands/deploy.js.map +1 -1
  13. package/dist/commands/maintenance.d.ts +4 -0
  14. package/dist/commands/maintenance.d.ts.map +1 -0
  15. package/dist/commands/maintenance.js +114 -0
  16. package/dist/commands/maintenance.js.map +1 -0
  17. package/dist/commands/project.d.ts +175 -3
  18. package/dist/commands/project.d.ts.map +1 -1
  19. package/dist/commands/project.js +359 -29
  20. package/dist/commands/project.js.map +1 -1
  21. package/dist/commands/reads.d.ts +21 -0
  22. package/dist/commands/reads.d.ts.map +1 -1
  23. package/dist/commands/reads.js +171 -4
  24. package/dist/commands/reads.js.map +1 -1
  25. package/dist/commands/scaffold.d.ts.map +1 -1
  26. package/dist/commands/scaffold.js +5 -0
  27. package/dist/commands/scaffold.js.map +1 -1
  28. package/dist/commands/service.d.ts.map +1 -1
  29. package/dist/commands/service.js +7 -0
  30. package/dist/commands/service.js.map +1 -1
  31. package/dist/commands/storage.d.ts.map +1 -1
  32. package/dist/commands/storage.js +32 -3
  33. package/dist/commands/storage.js.map +1 -1
  34. package/dist/commands/submit-app.d.ts +43 -0
  35. package/dist/commands/submit-app.d.ts.map +1 -1
  36. package/dist/commands/submit-app.js +115 -6
  37. package/dist/commands/submit-app.js.map +1 -1
  38. package/dist/config.d.ts +16 -1
  39. package/dist/config.d.ts.map +1 -1
  40. package/dist/config.js +73 -7
  41. package/dist/config.js.map +1 -1
  42. package/dist/deploy-plan.d.ts +221 -0
  43. package/dist/deploy-plan.d.ts.map +1 -0
  44. package/dist/deploy-plan.js +20 -0
  45. package/dist/deploy-plan.js.map +1 -0
  46. package/dist/flag-allowlists.d.ts.map +1 -1
  47. package/dist/flag-allowlists.js +5 -1
  48. package/dist/flag-allowlists.js.map +1 -1
  49. package/dist/flags.d.ts +6 -0
  50. package/dist/flags.d.ts.map +1 -1
  51. package/dist/flags.js +14 -0
  52. package/dist/flags.js.map +1 -1
  53. package/dist/jsonout.d.ts.map +1 -1
  54. package/dist/jsonout.js +11 -0
  55. package/dist/jsonout.js.map +1 -1
  56. package/dist/main.js +62 -13
  57. package/dist/main.js.map +1 -1
  58. package/dist/ownerops.d.ts +139 -1
  59. package/dist/ownerops.d.ts.map +1 -1
  60. package/dist/ownerops.js +382 -19
  61. package/dist/ownerops.js.map +1 -1
  62. package/dist/riskgate.d.ts +36 -0
  63. package/dist/riskgate.d.ts.map +1 -1
  64. package/dist/riskgate.js +91 -17
  65. package/dist/riskgate.js.map +1 -1
  66. package/dist/script-chunks.d.ts +1 -1
  67. package/dist/script-chunks.d.ts.map +1 -1
  68. package/dist/script-chunks.js +2 -2
  69. package/dist/script-chunks.js.map +1 -1
  70. package/package.json +6 -6
  71. package/skill/SKILL.md +6 -4
  72. package/skill/reference/code.md +31 -8
  73. package/skill/reference/deploy.md +9 -2
  74. package/skill/reference/diagnose.md +15 -2
  75. package/skill/reference/hosting.md +26 -0
  76. package/skill/reference/operate.md +55 -4
  77. 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, DEFAULT_TX_GAS_BUDGET, MEASURED_ESTIMATE_GAS_ALLOWANCE, SETUP_LEG_GAS, estimateChunkGasForBytes, packCallsByGas, 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');
@@ -906,10 +926,22 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
906
926
  // never disagree. Counts TX signatures only: a storage upload signed by a connected wallet
907
927
  // (Arweave via --storage-signer eth) is a message signature, not a transaction, and is already
908
928
  // named separately (see the `remoteEthUpload` narration) — it does not add to this count.
929
+ // Also captures the plan object's `custody.image` file metadata (issue #123 follow-up): the SAME
930
+ // bytes read here for the tx-count math, hashed/typed locally (no chain read) — never re-derived
931
+ // at the emit site below.
932
+ let planImageFile = null;
909
933
  const approvals = onchainImage
910
934
  ? (() => {
911
- const plan = computeContentPlan(readFileSync(resolvePath(flags.image)), parseCompress(flags.compress)).plan;
912
- return (plan.mode === 'single' ? 1 : plan.txCount) + 1; // staging tx(s) + the deploy tx
935
+ const bytes = readFileSync(resolvePath(flags.image));
936
+ const compress = parseCompress(flags.compress);
937
+ const contentPlan = computeContentPlan(bytes, compress);
938
+ planImageFile = {
939
+ bytes: bytes.length,
940
+ stagedBytes: contentPlan.stagedBytes,
941
+ mimeType: contentTypeFromPath(resolvePath(flags.image)),
942
+ contentHash: hashContent(bytes),
943
+ };
944
+ return (contentPlan.plan.mode === 'single' ? 1 : contentPlan.plan.txCount) + 1; // staging tx(s) + the deploy tx
913
945
  })()
914
946
  : 1;
915
947
  // Mint-on-deploy is the default; --no-mint defers it so you can stand up + warm
@@ -978,6 +1010,10 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
978
1010
  };
979
1011
  let clone;
980
1012
  let blockNumber;
1013
+ // The eventual owner/signer — set in whichever lane branch below learns it, so the SENT plan
1014
+ // object (further down) can report `roles` without re-deriving anything. `undefined` only while
1015
+ // no branch has run yet; every path that reaches the sent `emit()` has set it by then.
1016
+ let deployerAddr;
981
1017
  if (dryRun) {
982
1018
  let deployer;
983
1019
  if (flags.for)
@@ -1035,7 +1071,41 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1035
1071
  // A dry run DOES know the address when --salt pinned it (a pure function of factory+salt); without
1036
1072
  // --salt it's null — reporting the freshly-reserved one would be a real-looking value a script
1037
1073
  // 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 }));
1074
+ emit(jsonSafe({
1075
+ command: 'deploy', dryRun: true, sent: false, address: explicitSalt ? predicted : null, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt, saltPinned: !!explicitSalt, name, symbol,
1076
+ // The structured plan (issue #123) — built from the SAME locals the readout above just
1077
+ // printed; nothing here is re-derived or newly computed. `deployer` is a real wallet address
1078
+ // regardless of salt pinning (only the PREDICTED CONTRACT address carries that caveat, and
1079
+ // this plan doesn't repeat it — see `address`, above), so roles are safe to report unconditionally.
1080
+ plan: {
1081
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
1082
+ family: '1of1',
1083
+ lane,
1084
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging', 'deploy'] : null },
1085
+ // The 1/1 lane's InitParams has no minter/primaryPayee field at all — `null` here means
1086
+ // "no concept on this lane", not "unset".
1087
+ roles: { signer: deployer, owner: deployer, royaltyReceiver: deployer, primaryPayee: null, minter: null },
1088
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
1089
+ custody: {
1090
+ onChainUri,
1091
+ imageOnChain: onchainImage,
1092
+ backend: !onChainUri && !onchainImage ? backendResolution(storageOverrides(flags)).backend : null,
1093
+ // `''` (on-chain lane, no explicit --public-base-url) is reported as `null` here — the
1094
+ // human line already spells out WHY (renderer-authoritative); a JSON consumer just needs
1095
+ // "nothing baked", not an empty string to interpret.
1096
+ tokenUriBase: params.tokenURIBase || null,
1097
+ contractUriBase: params.contractURIBase || null,
1098
+ renderer: onChainUri ? renderer : null,
1099
+ image: planImageFile,
1100
+ },
1101
+ mint: { deferred: noMint, count: noMint ? 0 : 1, amountPerId: null, recipient: noMint ? null : deployer },
1102
+ estimate: { ethApprox: null, gasApprox: null }, // the 1/1 lane computes no cost estimate
1103
+ warnings: planWarnings.slice(),
1104
+ surfaces: null, // the code lanes' per-surface disposition has no equivalent here
1105
+ dependencies: null, // the 1/1 lane has no dependency-registry concept
1106
+ resume: null,
1107
+ },
1108
+ }));
1039
1109
  return;
1040
1110
  }
1041
1111
  // opt-in --confirm: a final y/N before the real send (no-op without --confirm; never blocks scripts)
@@ -1044,6 +1114,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1044
1114
  if (lane === 'send') {
1045
1115
  // hot lane: the env key is the deployer, known up front.
1046
1116
  const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
1117
+ deployerAddr = account.address;
1047
1118
  const { clone: predicted, params, salt, contentNote } = await buildForDeployer(account.address);
1048
1119
  info(`deterministic address: ${predicted}`);
1049
1120
  info(describeDeployField('description', flags.description, onChainUri || !!flags['description-onchain']));
@@ -1081,6 +1152,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1081
1152
  let r;
1082
1153
  try {
1083
1154
  const signer = await session.connect();
1155
+ deployerAddr = signer;
1084
1156
  // Stage on-chain through the session (sets bakedImage, which buildForDeployer reads below).
1085
1157
  const { field, note } = await stageImageField(flags.image, parseCompress(flags.compress), sessionStagingSender(session));
1086
1158
  bakedImage = field;
@@ -1113,6 +1185,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1113
1185
  let r;
1114
1186
  try {
1115
1187
  const signer = await session.connect();
1188
+ deployerAddr = signer;
1116
1189
  // Route Turbo uploads' signatures to this session before building content (which uploads).
1117
1190
  uploadRemoteEth = { address: signer, signMessage: (m) => session.signMessage(m, 'Sign Arweave upload (paid from your Turbo credits)') };
1118
1191
  const { clone: predicted, params, salt, contentNote } = await buildForDeployer(signer);
@@ -1137,6 +1210,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1137
1210
  ? 'a wallet will become the owner; the token resolves from chain — no URI base is baked in.'
1138
1211
  : `a wallet will become the owner; URIs point at ${baseUrl}`);
1139
1212
  const result = await signTx(async (signer) => {
1213
+ deployerAddr = signer;
1140
1214
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
1141
1215
  return prepareDeployOneOfOne({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
1142
1216
  }, { lane, chainKey: CHAIN, yes: !!flags.yes, expectedSigner: flags.for, port: flags.port ? Number(flags.port) : undefined, signUrlFile: flags['sign-url-file'] });
@@ -1150,7 +1224,34 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1150
1224
  }
1151
1225
  // Emitted the moment the address is known, and again below once minting is settled — so a crash in
1152
1226
  // 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 }));
1227
+ emit(jsonSafe({
1228
+ command: 'deploy', address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol,
1229
+ plan: {
1230
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
1231
+ family: '1of1',
1232
+ lane,
1233
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging', 'deploy'] : null },
1234
+ roles: { signer: deployerAddr ?? null, owner: deployerAddr ?? null, royaltyReceiver: deployerAddr ?? null, primaryPayee: null, minter: null },
1235
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
1236
+ custody: {
1237
+ onChainUri,
1238
+ imageOnChain: onchainImage,
1239
+ backend: !onChainUri && !onchainImage ? backendResolution(storageOverrides(flags)).backend : null,
1240
+ // Recomputed from the same top-level locals `buildForDeployer`'s `params` used (no branch
1241
+ // carries `params` out to this scope) — never a new decision, the identical ternary.
1242
+ tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
1243
+ contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
1244
+ renderer: onChainUri ? renderer : null,
1245
+ image: planImageFile,
1246
+ },
1247
+ mint: { deferred: noMint, count: noMint ? 0 : 1, amountPerId: null, recipient: noMint ? null : deployerAddr ?? null },
1248
+ estimate: { ethApprox: null, gasApprox: null },
1249
+ warnings: planWarnings.slice(),
1250
+ surfaces: null,
1251
+ dependencies: null,
1252
+ resume: null,
1253
+ },
1254
+ }));
1154
1255
  step(serveAfter ? 'What the chain knows now' : 'Index it — replay the event spine from chain');
1155
1256
  const indexer = localIndexer();
1156
1257
  // Off-chain traits ride in the registration (on-chain ones are already in the contract fields).
@@ -1284,6 +1385,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1284
1385
  // spreads `DEPLOY_FLAGS`, so it must come AFTER that declaration at module scope, same reason
1285
1386
  // `DEPLOY_SERIES_EDITION_FLAGS` sits next to it rather than beside `cmdDeployEditionImageBody`.)
1286
1387
  export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1388
+ beginPlanWarnings();
1287
1389
  refuseStrayFlags(flags, DEPLOY_EDITION_FLAGS, 'deploy');
1288
1390
  warnSignWithoutFor(flags);
1289
1391
  // `--type` is the 721 lane's OWN dimension seam (cmdDeployBody redirects `--type series` to
@@ -1436,10 +1538,21 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1436
1538
  // The edition paths shipped without this and without the readout line below, so `--copies` previews
1437
1539
  // silently dropped the approval count the skill promises "every preview" prints — leaving an agent
1438
1540
  // with nothing to tell the creator about how many wallet prompts to expect.
1541
+ // Also captures `custody.image` file metadata (issue #123 follow-up) — see the 1/1 twin's identical
1542
+ // local for why it's computed here rather than re-derived at the emit site.
1543
+ let planImageFile = null;
1439
1544
  const approvals = onchainImage
1440
1545
  ? (() => {
1441
- const plan = computeContentPlan(readFileSync(resolvePath(flags.image)), parseCompress(flags.compress)).plan;
1442
- return (plan.mode === 'single' ? 1 : plan.txCount) + 1;
1546
+ const bytes = readFileSync(resolvePath(flags.image));
1547
+ const compress = parseCompress(flags.compress);
1548
+ const contentPlan = computeContentPlan(bytes, compress);
1549
+ planImageFile = {
1550
+ bytes: bytes.length,
1551
+ stagedBytes: contentPlan.stagedBytes,
1552
+ mimeType: contentTypeFromPath(resolvePath(flags.image)),
1553
+ contentHash: hashContent(bytes),
1554
+ };
1555
+ return (contentPlan.plan.mode === 'single' ? 1 : contentPlan.plan.txCount) + 1;
1443
1556
  })()
1444
1557
  : 1;
1445
1558
  if (onChainUri) {
@@ -1487,6 +1600,8 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1487
1600
  };
1488
1601
  let clone;
1489
1602
  let blockNumber;
1603
+ // See the 1/1 lane's identical local for why this is hoisted (feeds the sent plan's `roles`).
1604
+ let deployerAddr;
1490
1605
  if (dryRun) {
1491
1606
  let deployer;
1492
1607
  if (flags.for)
@@ -1531,13 +1646,43 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1531
1646
  }
1532
1647
  info(dim('the values above are exactly what a real deploy writes — nothing else is added.'));
1533
1648
  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 }));
1649
+ emit(jsonSafe({
1650
+ 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,
1651
+ plan: {
1652
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
1653
+ family: '1of1-edition',
1654
+ lane,
1655
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging', 'deploy'] : null },
1656
+ roles: {
1657
+ signer: deployer, owner: deployer, royaltyReceiver: deployer,
1658
+ primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
1659
+ minter: minter !== zeroAddress ? minter : null,
1660
+ },
1661
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
1662
+ custody: {
1663
+ onChainUri,
1664
+ imageOnChain: onchainImage,
1665
+ backend: !onChainUri && !onchainImage ? backendResolution(storageOverrides(flags)).backend : null,
1666
+ tokenUriBase: params.tokenURIBase || null,
1667
+ contractUriBase: params.contractURIBase || null,
1668
+ renderer: onChainUri ? renderer : null,
1669
+ image: planImageFile,
1670
+ },
1671
+ mint: { deferred: mintAmount === 0n, count: mintAmount > 0n ? 1 : 0, amountPerId: mintAmount.toString(), recipient: mintAmount > 0n ? deployer : null },
1672
+ estimate: { ethApprox: null, gasApprox: null },
1673
+ warnings: planWarnings.slice(),
1674
+ surfaces: null,
1675
+ dependencies: null,
1676
+ resume: null,
1677
+ },
1678
+ }));
1535
1679
  return;
1536
1680
  }
1537
1681
  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
1682
  (transferValidator !== zeroAddress ? ` ERC-1155C: enrolled at deploy, permanently (validator ${transferValidator}).` : ''), flags);
1539
1683
  if (lane === 'send') {
1540
1684
  const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
1685
+ deployerAddr = account.address;
1541
1686
  const { clone: predicted, params, salt, contentNote } = await buildForDeployer(account.address);
1542
1687
  info(`deterministic address: ${predicted}`);
1543
1688
  info(describeDeployField('description', flags.description, onChainUri || !!flags['description-onchain']));
@@ -1568,6 +1713,7 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1568
1713
  let r;
1569
1714
  try {
1570
1715
  const signer = await session.connect();
1716
+ deployerAddr = signer;
1571
1717
  await stageOnChainImage(sessionStagingSender(session)); // sets bakedImage, read by buildForDeployer
1572
1718
  const { clone: predicted, params, salt, contentNote } = await buildForDeployer(signer);
1573
1719
  info(contentNote);
@@ -1587,6 +1733,7 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1587
1733
  // wallet lane without staging, or the cold lane: a single deploy tx.
1588
1734
  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
1735
  const result = await signTx(async (signer) => {
1736
+ deployerAddr = signer;
1590
1737
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
1591
1738
  return prepareDeployOneOfOneEdition({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
1592
1739
  }, { lane, chainKey: CHAIN, yes: !!flags.yes, expectedSigner: flags.for, port: flags.port ? Number(flags.port) : undefined, signUrlFile: flags['sign-url-file'] });
@@ -1598,7 +1745,36 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1598
1745
  blockNumber = result.blockNumber;
1599
1746
  ok(`deployed ${clone}`);
1600
1747
  }
1601
- emit(jsonSafe({ command: 'deploy', kind: '1of1-edition', copies: editionSize.toString(), address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol }));
1748
+ emit(jsonSafe({
1749
+ command: 'deploy', kind: '1of1-edition', copies: editionSize.toString(), address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol,
1750
+ plan: {
1751
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
1752
+ family: '1of1-edition',
1753
+ lane,
1754
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging', 'deploy'] : null },
1755
+ roles: {
1756
+ signer: deployerAddr ?? null, owner: deployerAddr ?? null, royaltyReceiver: deployerAddr ?? null,
1757
+ primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
1758
+ minter: minter !== zeroAddress ? minter : null,
1759
+ },
1760
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
1761
+ custody: {
1762
+ onChainUri,
1763
+ imageOnChain: onchainImage,
1764
+ backend: !onChainUri && !onchainImage ? backendResolution(storageOverrides(flags)).backend : null,
1765
+ tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
1766
+ contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
1767
+ renderer: onChainUri ? renderer : null,
1768
+ image: planImageFile,
1769
+ },
1770
+ mint: { deferred: mintAmount === 0n, count: mintAmount > 0n ? 1 : 0, amountPerId: mintAmount.toString(), recipient: mintAmount > 0n ? deployerAddr ?? null : null },
1771
+ estimate: { ethApprox: null, gasApprox: null },
1772
+ warnings: planWarnings.slice(),
1773
+ surfaces: null,
1774
+ dependencies: null,
1775
+ resume: null,
1776
+ },
1777
+ }));
1602
1778
  step('Index it — replay the event spine from chain');
1603
1779
  const indexer = localIndexer();
1604
1780
  const offChainTraits = !traitsOnchain && traits.length ? JSON.stringify(traits) : undefined;
@@ -1668,6 +1844,7 @@ export async function cmdDeploySeries(flags) {
1668
1844
  return withJson(flags, async (emit) => cmdDeploySeriesBody(flags, emit));
1669
1845
  }
1670
1846
  export async function cmdDeploySeriesBody(flags, emit) {
1847
+ beginPlanWarnings();
1671
1848
  refuseStrayFlags(flags, DEPLOY_SERIES_FLAGS, 'deploy-series');
1672
1849
  warnSignWithoutFor(flags);
1673
1850
  const name = flags.name ?? 'ABX Series';
@@ -2070,14 +2247,50 @@ export async function cmdDeploySeriesBody(flags, emit) {
2070
2247
  console.log(`\n ${g('dry run')} ${dim('— nothing sent, no bytes stored.')}\n`);
2071
2248
  // Without --salt, `address` would be a freshly-reserved value a real deploy will NOT land at —
2072
2249
  // 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 }));
2250
+ emit(jsonSafe({
2251
+ command: 'deploy-series', dryRun: true, sent: false, address: explicitSalt ? predicted : null, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt, saltPinned: !!explicitSalt, name, symbol,
2252
+ plan: {
2253
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
2254
+ family: 'series',
2255
+ lane,
2256
+ // A staged on-chain collection is many chunk-write txs, one per file — that per-token detail
2257
+ // is printed above (`previewImageStaging`), not re-derived here; `legs` just names the shape.
2258
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging (per token)', 'deploy'] : null },
2259
+ roles: {
2260
+ signer: deployer, owner: deployer, royaltyReceiver: deployer,
2261
+ primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
2262
+ minter: minter !== zeroAddress ? minter : null,
2263
+ },
2264
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
2265
+ custody: {
2266
+ onChainUri,
2267
+ imageOnChain: onchainImage,
2268
+ backend: offchainImageOnchainJson || !onChainUri ? backendId : null,
2269
+ tokenUriBase: params.tokenURIBase || null,
2270
+ contractUriBase: params.contractURIBase || null,
2271
+ renderer: onChainUri ? renderer : null,
2272
+ // Series stages ONE FILE PER TOKEN under --onchain-image — no single-file shape fits (see
2273
+ // this field's own doc comment) — so this stays null even when `imageOnChain` is true.
2274
+ image: null,
2275
+ },
2276
+ mint: { deferred: mintCount === 0, count: mintCount, amountPerId: null, recipient: mintCount > 0 ? deployer : null },
2277
+ estimate: { ethApprox: null, gasApprox: null },
2278
+ warnings: planWarnings.slice(),
2279
+ surfaces: null,
2280
+ dependencies: null,
2281
+ resume: null,
2282
+ },
2283
+ }));
2074
2284
  return;
2075
2285
  }
2076
2286
  let clone;
2077
2287
  let blockNumber;
2288
+ // See the 1/1 lane's identical local for why this is hoisted (feeds the sent plan's `roles`).
2289
+ let deployerAddr;
2078
2290
  if (lane === 'send') {
2079
2291
  // hot lane: the env key stages every token up front (deployer-independent), then deploys.
2080
2292
  const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
2293
+ deployerAddr = account.address;
2081
2294
  if (onchainImage)
2082
2295
  await buildFields(envStagingSender());
2083
2296
  const { clone: predicted, params, salt } = await buildForDeployer(account.address);
@@ -2104,6 +2317,7 @@ export async function cmdDeploySeriesBody(flags, emit) {
2104
2317
  });
2105
2318
  try {
2106
2319
  const signer = await session.connect();
2320
+ deployerAddr = signer;
2107
2321
  if (onchainImage)
2108
2322
  await buildFields(sessionStagingSender(session));
2109
2323
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
@@ -2129,6 +2343,7 @@ export async function cmdDeploySeriesBody(flags, emit) {
2129
2343
  });
2130
2344
  try {
2131
2345
  const signer = await session.connect();
2346
+ deployerAddr = signer;
2132
2347
  // Route Turbo uploads through the wallet (mutate the SAME opts.arweave the backend captured).
2133
2348
  if (opts.arweave)
2134
2349
  opts.arweave.remoteEth = { address: signer, signMessage: (m) => session.signMessage(m, 'Sign Arweave upload (paid from your Turbo credits)') };
@@ -2151,6 +2366,7 @@ export async function cmdDeploySeriesBody(flags, emit) {
2151
2366
  ? 'a wallet will become the owner; tokens resolve from chain — no URI base is baked in.'
2152
2367
  : `a wallet will become the owner; URIs point at ${baseUrl}`);
2153
2368
  const result = await signTx(async (signer) => {
2369
+ deployerAddr = signer;
2154
2370
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
2155
2371
  return prepareDeploySeries({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
2156
2372
  }, { lane, chainKey: CHAIN, yes: !!flags.yes, expectedSigner: flags.for, port: flags.port ? Number(flags.port) : undefined, signUrlFile: flags['sign-url-file'] });
@@ -2168,7 +2384,36 @@ export async function cmdDeploySeriesBody(flags, emit) {
2168
2384
  info(`permanent: size ${count} · royalty ${royaltyBps / 100}%${flags['royalty-bps'] === undefined ? ' ⚠ default' : ''} → the deploying wallet` +
2169
2385
  ` · cap ${maxRoyaltyBps / 100}%${burnable ? ' · burnable' : ''}` +
2170
2386
  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 }));
2387
+ emit(jsonSafe({
2388
+ command: 'deploy-series', address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol,
2389
+ plan: {
2390
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
2391
+ family: 'series',
2392
+ lane,
2393
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging (per token)', 'deploy'] : null },
2394
+ roles: {
2395
+ signer: deployerAddr ?? null, owner: deployerAddr ?? null, royaltyReceiver: deployerAddr ?? null,
2396
+ primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
2397
+ minter: minter !== zeroAddress ? minter : null,
2398
+ },
2399
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
2400
+ custody: {
2401
+ onChainUri,
2402
+ imageOnChain: onchainImage,
2403
+ backend: offchainImageOnchainJson || !onChainUri ? backendId : null,
2404
+ tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
2405
+ contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
2406
+ renderer: onChainUri ? renderer : null,
2407
+ image: null, // per-token staging — see the dry-run emit's identical comment
2408
+ },
2409
+ mint: { deferred: mintCount === 0, count: mintCount, amountPerId: null, recipient: mintCount > 0 ? deployerAddr ?? null : null },
2410
+ estimate: { ethApprox: null, gasApprox: null },
2411
+ warnings: planWarnings.slice(),
2412
+ surfaces: null,
2413
+ dependencies: null,
2414
+ resume: null,
2415
+ },
2416
+ }));
2172
2417
  step('Index it — replay the event spine from chain');
2173
2418
  const indexer = localIndexer();
2174
2419
  const baseReg = {
@@ -2244,6 +2489,7 @@ export async function cmdDeploySeriesBody(flags, emit) {
2244
2489
  // (`DEPLOY_SERIES_EDITION_FLAGS` is declared further down, beside `DEPLOY_EDITION_FLAGS` — see that
2245
2490
  // const's own note on why.)
2246
2491
  export async function cmdDeployEditionImageBody(flags, emit) {
2492
+ beginPlanWarnings();
2247
2493
  refuseStrayFlags(flags, DEPLOY_SERIES_EDITION_FLAGS, 'deploy-series');
2248
2494
  warnSignWithoutFor(flags);
2249
2495
  const editionSize = parseCopies(flags.copies);
@@ -2563,6 +2809,8 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2563
2809
  };
2564
2810
  let clone;
2565
2811
  let blockNumber;
2812
+ // See the 1/1 lane's identical local for why this is hoisted (feeds the sent plan's `roles`).
2813
+ let deployerAddr;
2566
2814
  if (dryRun) {
2567
2815
  let deployer;
2568
2816
  if (flags.for)
@@ -2602,11 +2850,48 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2602
2850
  info(`address: pinned by salt — re-run with ${bold(`--salt ${salt}`)} (same address).`);
2603
2851
  }
2604
2852
  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 }));
2853
+ emit(jsonSafe({
2854
+ 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,
2855
+ plan: {
2856
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
2857
+ family: 'series-edition',
2858
+ lane,
2859
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging (per id)', 'deploy'] : null },
2860
+ roles: {
2861
+ signer: deployer, owner: deployer, royaltyReceiver: deployer,
2862
+ primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
2863
+ minter: minter !== zeroAddress ? minter : null,
2864
+ },
2865
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
2866
+ custody: {
2867
+ onChainUri,
2868
+ imageOnChain: onchainImage,
2869
+ backend: !onchainImage && (offchainImageOnchainJson || !onChainUri) ? backendId : null,
2870
+ tokenUriBase: params.tokenURIBase || null,
2871
+ contractUriBase: params.contractURIBase || null,
2872
+ renderer: onChainUri ? renderer : null,
2873
+ // Series-edition stages ONE FILE PER id under --onchain-image — no single-file shape fits
2874
+ // (see this field's own doc comment) — so this stays null even when `imageOnChain` is true.
2875
+ image: null,
2876
+ },
2877
+ mint: {
2878
+ deferred: effectiveMintCount === 0,
2879
+ count: effectiveMintCount,
2880
+ amountPerId: mintAmount.toString(),
2881
+ recipient: effectiveMintCount > 0 ? deployer : null,
2882
+ },
2883
+ estimate: { ethApprox: null, gasApprox: null },
2884
+ warnings: planWarnings.slice(),
2885
+ surfaces: null,
2886
+ dependencies: null,
2887
+ resume: null,
2888
+ },
2889
+ }));
2606
2890
  return;
2607
2891
  }
2608
2892
  if (lane === 'send') {
2609
2893
  const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
2894
+ deployerAddr = account.address;
2610
2895
  // hot lane: the env key stages every id up front (deployer-independent), then deploys.
2611
2896
  if (onchainImage)
2612
2897
  await buildFields(envStagingSender());
@@ -2635,6 +2920,7 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2635
2920
  let r;
2636
2921
  try {
2637
2922
  const signer = await session.connect();
2923
+ deployerAddr = signer;
2638
2924
  await buildFields(sessionStagingSender(session));
2639
2925
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
2640
2926
  info(`mint: ${effectiveMintCount > 0 ? `${effectiveMintCount} id(s) × ${mintAmount} cop${mintAmount === 1n ? 'y' : 'ies'} → ${signer} at deploy` : 'deferred'}`);
@@ -2652,6 +2938,7 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2652
2938
  else {
2653
2939
  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
2940
  const result = await signTx(async (signer) => {
2941
+ deployerAddr = signer;
2655
2942
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
2656
2943
  return prepareDeployEditionImage({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
2657
2944
  }, { lane, chainKey: CHAIN, yes: !!flags.yes, expectedSigner: flags.for, port: flags.port ? Number(flags.port) : undefined, signUrlFile: flags['sign-url-file'] });
@@ -2663,7 +2950,41 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2663
2950
  blockNumber = result.blockNumber;
2664
2951
  ok(`deployed ${clone}`);
2665
2952
  }
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 }));
2953
+ emit(jsonSafe({
2954
+ command: 'deploy-series', kind: 'edition', copies: editionSize.toString(), ids: count, address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol,
2955
+ plan: {
2956
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
2957
+ family: 'series-edition',
2958
+ lane,
2959
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging (per id)', 'deploy'] : null },
2960
+ roles: {
2961
+ signer: deployerAddr ?? null, owner: deployerAddr ?? null, royaltyReceiver: deployerAddr ?? null,
2962
+ primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
2963
+ minter: minter !== zeroAddress ? minter : null,
2964
+ },
2965
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
2966
+ custody: {
2967
+ onChainUri,
2968
+ imageOnChain: onchainImage,
2969
+ backend: !onchainImage && (offchainImageOnchainJson || !onChainUri) ? backendId : null,
2970
+ tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
2971
+ contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
2972
+ renderer: onChainUri ? renderer : null,
2973
+ image: null, // per-id staging — see the dry-run emit's identical comment
2974
+ },
2975
+ mint: {
2976
+ deferred: effectiveMintCount === 0,
2977
+ count: effectiveMintCount,
2978
+ amountPerId: mintAmount.toString(),
2979
+ recipient: effectiveMintCount > 0 ? deployerAddr ?? null : null,
2980
+ },
2981
+ estimate: { ethApprox: null, gasApprox: null },
2982
+ warnings: planWarnings.slice(),
2983
+ surfaces: null,
2984
+ dependencies: null,
2985
+ resume: null,
2986
+ },
2987
+ }));
2667
2988
  step('Index it — replay the event spine from chain');
2668
2989
  const indexer = localIndexer();
2669
2990
  const offChainTraits = !seriesTraitsOnchain && offChainTokenTraits ? offChainTokenTraits : undefined;
@@ -2919,6 +3240,10 @@ export const DEPLOY_CODE_FLAGS = new Set([
2919
3240
  'dry-run', 'confirm', 'yes',
2920
3241
  'json', // the deployed address as data, narration to stderr
2921
3242
  'resume', // finish an existing contract whose setup transaction never landed (no deploy)
3243
+ // Meaningful ONLY on `--resume` against an EditionCode target (per-id copies for the premint
3244
+ // shortfall) — kept in the allowlist (not stray) so a misuse elsewhere gets this function's own
3245
+ // pointed refusal instead of the generic "unrecognized flag". See the --resume block.
3246
+ 'mint-amount',
2922
3247
  ]);
2923
3248
  // Storage + signing + preview flags shared by every deploy path (mirrors storageOverrides() + the lanes).
2924
3249
  export const SHARED_DEPLOY_FLAGS = [
@@ -2963,6 +3288,76 @@ export const DEPLOY_SERIES_FLAGS = new Set([
2963
3288
  // module has finished evaluating.
2964
3289
  export const DEPLOY_EDITION_FLAGS = new Set([...DEPLOY_FLAGS, 'copies', 'mint-amount', 'minter', 'primary-payee', 'unpaused']);
2965
3290
  export const DEPLOY_SERIES_EDITION_FLAGS = new Set([...DEPLOY_SERIES_FLAGS, 'copies', 'mint-amount']);
3291
+ export const chunkSetupLeg = (index, hex, data) => {
3292
+ const bytes = (hex.length - 2) / 2;
3293
+ return { calls: [data], gas: estimateChunkGasForBytes(bytes), label: `script chunk [${index}] (${bytes} bytes)`, kind: 'chunk', chunkBytes: [bytes] };
3294
+ };
3295
+ export const schemaSetupLeg = (key, data) => ({
3296
+ calls: [data], gas: SETUP_LEG_GAS.schema, label: `param schema '${key}'`, kind: 'schema', schemaKey: key,
3297
+ });
3298
+ export const depsSetupLeg = (calls) => ({
3299
+ calls, gas: calls.length * SETUP_LEG_GAS.dependency, label: `${calls.length} dependency declaration(s)`, kind: 'deps',
3300
+ });
3301
+ export const uriSetupLeg = (calls) => ({
3302
+ calls, gas: calls.length * SETUP_LEG_GAS.onchainUri, label: 'on-chain URI wiring', kind: 'uri',
3303
+ });
3304
+ export const mintSetupLeg = (data) => ({ calls: [data], gas: SETUP_LEG_GAS.mint, label: 'reserve mint', kind: 'mint' });
3305
+ /** `SetupLegsCore` (resume.ts's id-agnostic four leg groups, shared by both 721 and EditionCode) →
3306
+ * the gas-batching shape above — schemas individually (resume diffs them per-key too), deps and
3307
+ * on-chain-URI as one atomic leg apiece (resume diffs them as one group too — see resume.ts's
3308
+ * `planCoreLegs`). Mints are NOT included here: their shape differs by lane (721 whole-total vs
3309
+ * EditionCode per-id vs a `--resume` shortfall) — every caller builds its own mint legs. */
3310
+ export function coreSetupLegs(legs) {
3311
+ return {
3312
+ chunks: legs.chunks.map((c) => chunkSetupLeg(c.index, c.hex, c.data)),
3313
+ config: [
3314
+ ...legs.schemas.map((s) => schemaSetupLeg(s.key, s.data)),
3315
+ ...(legs.deps.calls.length ? [depsSetupLeg(legs.deps.calls)] : []),
3316
+ ...(legs.uri.calls.length ? [uriSetupLeg(legs.uri.calls)] : []),
3317
+ ],
3318
+ };
3319
+ }
3320
+ /**
3321
+ * Split three ordered leg groups into gas-bounded batches — chunks, then config, then mints, NEVER
3322
+ * mixed (see the module note above). Refuses BEFORE building anything if a single leg's own gas
3323
+ * estimate alone exceeds the binding `eth_estimateGas` ceiling: batching only helps when the problem
3324
+ * is too MANY legs, not one leg too big, and that case must never reach a raw "gas limit too high".
3325
+ * Deterministic in the legs' `.gas` values alone (never their `.calls`), so calling this once early
3326
+ * (to size `approvals`, before an owner is even known) and again later with the real calldata
3327
+ * produces the IDENTICAL batch shape both times — no drift between what's previewed and what's sent.
3328
+ */
3329
+ export function planCodeSetupBatches(groups) {
3330
+ const all = [...groups.chunks, ...groups.config, ...groups.mints];
3331
+ if (all.length === 0)
3332
+ return [];
3333
+ const oversized = all.find((leg) => leg.gas > MEASURED_ESTIMATE_GAS_ALLOWANCE);
3334
+ if (oversized) {
3335
+ throw new Error(`setup leg — ${oversized.label} — needs ~${oversized.gas.toLocaleString()} gas on its own, which exceeds the ` +
3336
+ `~${MEASURED_ESTIMATE_GAS_ALLOWANCE.toLocaleString()} eth_estimateGas ceiling every transaction is bound by ` +
3337
+ `(that RPC allowance is the binding constraint — not the higher block gas limit). No amount of batching can ` +
3338
+ `make ONE leg fit a transaction by itself — shrink it before deploying (a smaller chunk size, fewer items in ` +
3339
+ `this leg), then re-run.`);
3340
+ }
3341
+ const total = all.reduce((g, l) => g + l.gas, 0);
3342
+ if (total <= DEFAULT_TX_GAS_BUDGET)
3343
+ return [all]; // fits one tx — unchanged common-case behavior
3344
+ return [...packCallsByGas(groups.chunks), ...packCallsByGas(groups.config), ...packCallsByGas(groups.mints)].filter((b) => b.length > 0);
3345
+ }
3346
+ /** Turn ordered setup-leg batches into the actual `prepareCodeSetup` transactions — rebuilding each
3347
+ * batch's chunk/schema/dependency/on-chain-URI labeling from its OWN legs, since a batch born from
3348
+ * the split may carry only part of a group (see {@link planCodeSetupBatches}). */
3349
+ export function codeSetupTxsFromBatches(batches, args) {
3350
+ return batches.map((batch) => prepareCodeSetup({
3351
+ contract: args.contract,
3352
+ chainId: args.chainId,
3353
+ calls: batch.flatMap((l) => l.calls),
3354
+ chunkCount: batch.filter((l) => l.kind === 'chunk').length,
3355
+ chunkBytes: batch.filter((l) => l.kind === 'chunk').flatMap((l) => l.chunkBytes ?? []),
3356
+ schemaKeys: batch.filter((l) => l.kind === 'schema').map((l) => l.schemaKey),
3357
+ deps: batch.some((l) => l.kind === 'deps') ? args.deps : [],
3358
+ onchainUri: batch.some((l) => l.kind === 'uri'),
3359
+ }));
3360
+ }
2966
3361
  export async function cmdDeployCode(flags) {
2967
3362
  // `--resume` targets an EXISTING contract — its standard (721 or edition) was fixed at THAT
2968
3363
  // contract's own deploy, so `--copies` (a creation-time choice) is nonsensical alongside it.
@@ -2981,6 +3376,7 @@ export async function cmdDeployCode(flags) {
2981
3376
  return withJson(flags, async (emit) => cmdDeployCodeBody(flags, emit));
2982
3377
  }
2983
3378
  export async function cmdDeployCodeBody(flags, emit) {
3379
+ beginPlanWarnings();
2984
3380
  const usage = 'abx deploy-code (--script <file> | --code-dir <dir>) --name "Title" --symbol SYM ' +
2985
3381
  '(--public-base-url https://your.resolver.domain | --onchain-uri) ' +
2986
3382
  '[--description "<s>"] [--external-url <url>] [--image-base <url> | --image-renderer 0x..] [--attributes-renderer 0x..] ' +
@@ -3004,6 +3400,16 @@ export async function cmdDeployCodeBody(flags, emit) {
3004
3400
  // Shared path so the "did you mean --max / price is post-deploy" hints fire here too.
3005
3401
  refuseStrayFlags(flags, DEPLOY_CODE_FLAGS, 'deploy-code');
3006
3402
  warnSignWithoutFor(flags);
3403
+ // --mint-amount only means anything on a --resume against an EXISTING EditionCode target (a per-id
3404
+ // premint shortfall needs an amount; a fresh 721 deploy has no such field, and a 721 resume's mint
3405
+ // leg is a single whole-contract shortfall with no amount either). Checked HERE, before the dry-run
3406
+ // preview branch below returns early for a fresh deploy — a --resume against an actual EditionCode
3407
+ // (where the flag DOES apply) gets the more specific 721-vs-edition guard further down instead.
3408
+ if (flags['mint-amount'] !== undefined && flags.resume === undefined) {
3409
+ throw new Error('--mint-amount only applies to `abx deploy-code --resume <address>` against an EXISTING EditionCode contract — ' +
3410
+ 'a fresh 721 deploy (this command, without --copies) has no per-id mint amount. For a NEW edition deploy, use ' +
3411
+ '`abx deploy-code --copies <n|open> --mint-amount <n>`.');
3412
+ }
3007
3413
  const dryRun = isDryRun(flags);
3008
3414
  const name = flags.name ?? 'ABX Code';
3009
3415
  const symbol = flags.symbol ?? 'ABXC';
@@ -3393,7 +3799,7 @@ export async function cmdDeployCodeBody(flags, emit) {
3393
3799
  if (flags['image-renderer'] || flags['attributes-renderer']) {
3394
3800
  warn(`${bold('renderer check — yours to verify')} (the CLI confirms code at the address, NOT that render() behaves): an IAbxFieldRenderer must ` +
3395
3801
  `${bold('NEVER revert')} for any token/param state incl. the collection surface (tokenId = type(uint256).max) — a revert bricks the WHOLE tokenURI (no try/catch) — ` +
3396
- `and must return the right content-type (image → ${bold('image/svg+xml')} · attributes → a JSON array). Review the render function + forge-test it before shipping. Invariants: reference/code-projects.md.`);
3802
+ `and must return the right content-type (image → ${bold('image/svg+xml')} · attributes → a JSON array). Review the render function + forge-test it before shipping. Invariants: reference/code.md.`);
3397
3803
  }
3398
3804
  // ── Surfaces disposition — resolve EVERY product dimension explicitly, up front ──────────────
3399
3805
  // A code project has four surfaces that each land somewhere (or nowhere). A real session shipped
@@ -3458,6 +3864,10 @@ export async function cmdDeployCodeBody(flags, emit) {
3458
3864
  // PostParams it reads (unlike the JS lane's static paramHints), so nudge — the palette-miss.
3459
3865
  ? `⚠ 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
3866
  : 'none declared';
3867
+ // Hoisted here (rather than beside its one printed use, in the Surfaces block below) so BOTH the
3868
+ // dry-run's Surfaces block and the plan object's `surfaces.postParams.ok` (populated at every one
3869
+ // of this lane's emit sites, dry-run/sent/resume alike) read the exact same verdict.
3870
+ const paramsNudge = !scriptAnalysis && !schemas.length && (hasImageRenderer || hasAttributesRenderer);
3461
3871
  // TOKENURI PUBLIC-READ GAS — a large --onchain-uri document assembles on-chain per call; an
3462
3872
  // unauthenticated public read (Etherscan "Read Contract" with no wallet connected) can hit a client
3463
3873
  // gas cap and appear to REVERT. Expected, not a bug — a real session mis-diagnosed it as indexing lag.
@@ -3493,7 +3903,60 @@ export async function cmdDeployCodeBody(flags, emit) {
3493
3903
  // one whenever a dependency leg rode the multicall alongside a renderer-only (`foldIntoInit`) fold.
3494
3904
  const depLegs = deps.length + (depRegistry ? 1 : 0);
3495
3905
  const setupLen = scriptChunks.length + schemas.length + depLegs + (onchainUriLegs?.length ?? 0) + setupMintCount;
3496
- const approvals = 1 + (setupLen > 0 ? 1 : 0); // deploy tx + (a setup multicall only if something rides it)
3906
+ // The setup legs, GROUPED. The normal deploy flattens them; `--resume` diffs them against chain
3907
+ // state and sends only what is missing (see resume.ts). One builder for both, so a resume can never
3908
+ // drift from what a fresh deploy would have written — a second implementation of this sequence is
3909
+ // the failure mode a repair verb most easily introduces. Moved up here (it used to sit right before
3910
+ // `preparedFor`, far below) so `approvals`/`planSetupLegs`/the dry-run's tx-count line can all size
3911
+ // themselves off the SAME gas-bounded batch plan `preparedFor` actually sends — see
3912
+ // `planCodeSetupBatches`'s own doc for why calling it here with a placeholder owner and again below
3913
+ // with the real one can never disagree (it's a pure function of each leg's `.gas`, never its
3914
+ // `.calls`, and owner only ever appears in a mint call's calldata, never its gas).
3915
+ const setupLegGroups = (owner) => ({
3916
+ chunks: scriptChunks.map((chunk, i) => ({
3917
+ index: i,
3918
+ hex: chunk,
3919
+ data: encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setScriptChunk', args: [BigInt(i), chunk] }),
3920
+ })),
3921
+ schemas: schemas.map(({ key, paramType, auth, authAddress, lockAfter, min, max, selectOptions }) => ({
3922
+ key,
3923
+ data: encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setParamSchema', args: [encodeTag(key), paramType, auth, authAddress, lockAfter, min, max, selectOptions] }),
3924
+ })),
3925
+ deps: { count: deps.length, registry: depRegistry ?? null, calls: dependencySetupCalls(deps, depRegistry) },
3926
+ // program lane only: animation_url field · the URI renderers (before the mints)
3927
+ uri: { calls: onchainUriLegs ?? [], animationField: onchainUriLegs?.length ? F.animationUrl : null },
3928
+ // The INTENDED TOTAL, not a count to add — a resume mints the shortfall. `mintCount` covers both
3929
+ // lanes: folded-into-init (renderer-only) and setup-carried, since either way it is what the
3930
+ // creator asked for.
3931
+ mints: { intendedTotal: mintCount, data: encodeFunctionData({ abi: seriesCodeAbi, functionName: 'mint', args: [owner] }) },
3932
+ });
3933
+ // Gas-bounded setup-transaction batches for a FRESH deploy — chunks, then config, then mints, never
3934
+ // mixed (see `planCodeSetupBatches`). `owner` is irrelevant to the batch SHAPE (only a mint call's
3935
+ // calldata embeds it, never its gas), so `zeroAddress` here is a placeholder that's never sent —
3936
+ // it exists purely so `approvals`/`planSetupLegs` (and the dry-run prose) can report the REAL
3937
+ // transaction count before a deployer is even known, with zero risk of drifting from what
3938
+ // `preparedFor` below actually builds and sends.
3939
+ const freshDeployBatches = planCodeSetupBatches({
3940
+ ...coreSetupLegs(setupLegGroups(zeroAddress)),
3941
+ mints: Array.from({ length: setupMintCount }, () => mintSetupLeg('0x')),
3942
+ });
3943
+ const approvals = 1 + freshDeployBatches.length; // deploy tx + however many gas-bounded setup batches
3944
+ // Ordered legs riding the setup (if any), for the plan object's `transactions.legs` — GROUP names,
3945
+ // not a literal per-transaction count (a group can span more than one batch — see deploy-plan.ts's
3946
+ // own doc comment on `legs`). The SAME booleans `setupLen`/`setupBits` (the dry-run's own
3947
+ // `transactions:` line, below) already use, so this can't drift from the tx(es) the wallet actually
3948
+ // signs. Always ends with `'deploy'` when non-null — the setup batch(es) (if any) ride AHEAD of the
3949
+ // deploy tx, never instead of it.
3950
+ const planSetupLegs = setupLen > 0
3951
+ ? [
3952
+ scriptChunks.length ? 'chunks' : null,
3953
+ schemas.length ? 'param-schemas' : null,
3954
+ depLegs ? 'dependencies' : null,
3955
+ onchainUriLegs ? 'onchain-uri' : null,
3956
+ setupMintCount ? 'mints' : null,
3957
+ 'deploy',
3958
+ ].filter((leg) => leg !== null)
3959
+ : null;
3497
3960
  // opt-in --confirm: one y/N before ANY upload or send (no-op without --confirm; never blocks scripts).
3498
3961
  if (!dryRun) {
3499
3962
  const renderHome = process.env.ABX_STORAGE_BACKEND || 'fs'; // empty string counts as unset (=fs)
@@ -3609,13 +4072,20 @@ export async function cmdDeployCodeBody(flags, emit) {
3609
4072
  onchainUriLegs ? 'on-chain-uri' : '',
3610
4073
  setupMintCount ? 'mints' : '',
3611
4074
  ].filter(Boolean).join('/');
3612
- info(`transactions: ${1 + (setupLen > 0 ? 1 : 0)} (deploy${foldIntoInit ? ' — on-chain-uri wiring + mint fold into it' : ''}${setupLen > 0 ? ` + one setup multicall: ${setupBits}` : ''})` +
3613
- (foldIntoInit && setupLen === 0 ? ` ${dim('— single transaction, fully self-contained')}` : ''));
4075
+ info(`transactions: ${approvals} (deploy${foldIntoInit ? ' — on-chain-uri wiring + mint fold into it' : ''}` +
4076
+ (freshDeployBatches.length > 0
4077
+ ? ` + ${freshDeployBatches.length} setup ${freshDeployBatches.length === 1 ? 'transaction' : 'transactions (gas-bounded)'}: ${setupBits}`
4078
+ : '') +
4079
+ ')' +
4080
+ (foldIntoInit && freshDeployBatches.length === 0 ? ` ${dim('— single transaction, fully self-contained')}` : ''));
3614
4081
  info(`approvals ${approvals} wallet approval(s)`); // TX signatures only; the same number the tx-count line above implies
3615
4082
  // Best-effort cost guidance (owner: a bonus in dry-run, never a blocker — silently skip if the
3616
4083
  // RPC can't price gas; see cli-ux-decisions). On-chain byte storage dominates a template drop,
3617
4084
  // so apply the skill's own ~200 gas/byte heuristic to the stored bytes + coarse per-op costs,
3618
4085
  // priced at the live gas price. Clearly hedged — it's an order-of-magnitude figure, not a quote.
4086
+ // `planEstimate` carries the SAME figures into the plan object below — `null` (not computed)
4087
+ // when the RPC can't price gas, exactly mirroring the human line's own silent skip.
4088
+ let planEstimate = null;
3619
4089
  try {
3620
4090
  const chunkBytes = scriptChunks.reduce((n, c) => n + (c.length - 2) / 2, 0);
3621
4091
  const fieldBytes = contractFields.reduce((n, f) => n + (f.value.length - 2) / 2, 0);
@@ -3632,6 +4102,7 @@ export async function cmdDeployCodeBody(flags, emit) {
3632
4102
  const ethStr = eth >= 0.00001 ? eth.toFixed(5) : '<0.00001';
3633
4103
  info(`est. on-chain cost: ${dim('~')}${ethStr} ETH ` +
3634
4104
  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' : ''})`));
4105
+ planEstimate = { ethApprox: ethStr, gasApprox: roughGas.toString() };
3635
4106
  }
3636
4107
  catch { /* gas pricing unavailable — cost guidance is a bonus, skip silently */ }
3637
4108
  // ── Surfaces — every marketplace-facing dimension, resolved NOW (none is backfillable) ──────
@@ -3675,8 +4146,7 @@ export async function cmdDeployCodeBody(flags, emit) {
3675
4146
  info(` ${dim('render storage home:')} ${bold(renderHome)} ${dim('— a public home the runner uploads to ✓')}`);
3676
4147
  }
3677
4148
  }
3678
- (traitsBroken ? warn : info)(`traits: ${traitsDisposition}`);
3679
- const paramsNudge = !scriptAnalysis && !schemas.length && (hasImageRenderer || hasAttributesRenderer);
4149
+ (traitsBroken ? warn : info)(`traits: ${traitsDisposition}`); // `paramsNudge` is hoisted above (with `paramsDisposition`) — shared with the plan object
3680
4150
  ((undeclaredParams.length || paramsNudge ? warn : info))(`postparams: ${paramsDisposition}`);
3681
4151
  if (anySurfaceBroken) {
3682
4152
  const remedies = [
@@ -3769,7 +4239,46 @@ export async function cmdDeployCodeBody(flags, emit) {
3769
4239
  // `address` is null without --salt pinning it or in the wallet lane with no --for (the
3770
4240
  // signer decides the salt, so no address exists yet) — reporting null is the honest answer;
3771
4241
  // 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 }));
4242
+ emit(jsonSafe({
4243
+ command: 'deploy-code', dryRun: true, sent: false, address: shownAddr, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt: salt ?? null, saltPinned: !!explicitSalt, name, symbol, onChainUri,
4244
+ plan: {
4245
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
4246
+ family: 'code',
4247
+ // `lane` (the hoisted const) isn't declared until AFTER this early-return block — reading it
4248
+ // here would hit its temporal dead zone. `laneFromFlags` is pure (flags only), so call it
4249
+ // directly; the sent-side emit, below `const lane = …`, uses the hoisted binding instead.
4250
+ lane: laneFromFlags(flags),
4251
+ transactions: { approvals, legs: planSetupLegs },
4252
+ roles: {
4253
+ signer: deployer, owner: deployer, royaltyReceiver: deployer,
4254
+ primaryPayee: flags['primary-payee'] ?? null,
4255
+ minter: flags.minter ?? null,
4256
+ },
4257
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
4258
+ custody: {
4259
+ onChainUri,
4260
+ // The code lane's image is one of several independently-disposed surfaces, not a single
4261
+ // boolean — see `surfaces.thumbnail` below.
4262
+ imageOnChain: null,
4263
+ backend: dirUpload ? dirUpload.backend.id : null,
4264
+ tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
4265
+ contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
4266
+ renderer: onChainUri ? metadataRenderer : null,
4267
+ // This lane has no single `--image` file — see `surfaces.thumbnail` below instead.
4268
+ image: null,
4269
+ },
4270
+ mint: { deferred: mintCount === 0, count: mintCount, amountPerId: null, recipient: mintCount > 0 ? deployer : null },
4271
+ estimate: planEstimate ?? { ethApprox: null, gasApprox: null },
4272
+ warnings: planWarnings.slice(),
4273
+ surfaces: {
4274
+ thumbnail: { ok: !imageOrphaned, detail: stripAnsiForPlan(imageDisposition) },
4275
+ traits: { ok: !traitsBroken, detail: stripAnsiForPlan(traitsDisposition) },
4276
+ postParams: { ok: !(undeclaredParams.length > 0 || paramsNudge), detail: stripAnsiForPlan(paramsDisposition) },
4277
+ },
4278
+ dependencies: { count: deps.length, registry: depRegistry ?? null },
4279
+ resume: null,
4280
+ },
4281
+ }));
3773
4282
  return;
3774
4283
  }
3775
4284
  // Real deploy: perform the deferred directory upload now (past the confirm gate).
@@ -3820,47 +4329,21 @@ export async function cmdDeployCodeBody(flags, emit) {
3820
4329
  tokenFields: [],
3821
4330
  contractFields,
3822
4331
  });
3823
- // The setup legs, GROUPED. The normal deploy flattens them; `--resume` diffs them against chain
3824
- // state and sends only what is missing (see resume.ts). One builder for both, so a resume can never
3825
- // drift from what a fresh deploy would have written a second implementation of this sequence is
3826
- // the failure mode a repair verb most easily introduces.
3827
- const setupLegGroups = (owner) => ({
3828
- chunks: scriptChunks.map((chunk, i) => ({
3829
- index: i,
3830
- hex: chunk,
3831
- data: encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setScriptChunk', args: [BigInt(i), chunk] }),
3832
- })),
3833
- schemas: schemas.map(({ key, paramType, auth, authAddress, lockAfter, min, max, selectOptions }) => ({
3834
- key,
3835
- data: encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setParamSchema', args: [encodeTag(key), paramType, auth, authAddress, lockAfter, min, max, selectOptions] }),
3836
- })),
3837
- deps: { count: deps.length, registry: depRegistry ?? null, calls: dependencySetupCalls(deps, depRegistry) },
3838
- // program lane only: animation_url field · the URI renderers (before the mints)
3839
- uri: { calls: onchainUriLegs ?? [], animationField: onchainUriLegs?.length ? F.animationUrl : null },
3840
- // The INTENDED TOTAL, not a count to add — a resume mints the shortfall. `mintCount` covers both
3841
- // lanes: folded-into-init (renderer-only) and setup-carried, since either way it is what the
3842
- // creator asked for.
3843
- mints: { intendedTotal: mintCount, data: encodeFunctionData({ abi: seriesCodeAbi, functionName: 'mint', args: [owner] }) },
3844
- });
3845
- const setupCalls = (owner) => {
3846
- const g = setupLegGroups(owner);
3847
- return [
3848
- ...g.chunks.map((c) => c.data),
3849
- ...g.schemas.map((x) => x.data),
3850
- ...g.deps.calls,
3851
- ...g.uri.calls,
3852
- // folded mints ride init, not here — so this uses setupMintCount, not the intended total.
3853
- ...Array.from({ length: setupMintCount }, () => g.mints.data),
3854
- ];
3855
- };
4332
+ // `setupLegGroups` (the shared, resume-compatible builder) and the fresh-deploy batch shape
4333
+ // (`freshDeployBatches`) are hoisted above see the comment there for why. Build the REAL
4334
+ // transactions for the actual signer from the SAME batch shape (identical `.gas` sequence
4335
+ // identical split see `planCodeSetupBatches`'s doc), this time with real mint calldata.
3856
4336
  const cid = resolveChain(CHAIN).id;
3857
4337
  const preparedFor = async (owner) => {
3858
4338
  const salt = parseSaltFlag(flags.salt) ?? saltFor(owner);
3859
4339
  const clone = (await publicClient.readContract({ address: factory, abi: seriesCodeFactoryAbi, functionName: 'predictDeterministicAddress', args: [salt] }));
3860
- const calls = setupCalls(owner);
3861
- const txs = [prepareDeploySeriesCode({ factory, params: initParamsFor(owner), salt, chainId: cid, clone })];
3862
- if (calls.length)
3863
- txs.push(prepareCodeSetup({ contract: clone, calls, chainId: cid, chunkCount: scriptChunks.length, chunkBytes: scriptChunks.map((h) => (h.length - 2) / 2), schemaKeys: schemas.map((x) => x.key), deps: deps.map((d) => d.display), onchainUri: onChainUri && hasProgram }));
4340
+ const g = setupLegGroups(owner);
4341
+ const batches = planCodeSetupBatches({
4342
+ ...coreSetupLegs(g),
4343
+ mints: Array.from({ length: setupMintCount }, () => mintSetupLeg(g.mints.data)),
4344
+ });
4345
+ const setupTxs = codeSetupTxsFromBatches(batches, { contract: clone, chainId: cid, deps: deps.map((d) => d.display) });
4346
+ const txs = [prepareDeploySeriesCode({ factory, params: initParamsFor(owner), salt, chainId: cid, clone }), ...setupTxs];
3864
4347
  return { clone, txs };
3865
4348
  };
3866
4349
  // ── --resume: finish an EXISTING contract, deploy nothing ──────────────────────────────────
@@ -3894,36 +4377,39 @@ export async function cmdDeployCodeBody(flags, emit) {
3894
4377
  if (flags[flag] !== undefined)
3895
4378
  throw new Error(`--resume cannot be combined with --${flag}: ${why}.`);
3896
4379
  }
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
- }
4380
+ // 721 SeriesCode vs EditionCode: same setup shape (script chunks, schemas, deps, on-chain URI
4381
+ // legs), different mint shape — a 721 mints ONE sequential token against a whole-contract
4382
+ // `totalSupply()`; an edition mints AMOUNT copies of a specific id against that id's own
4383
+ // `totalSupply(id)` (there is no whole-contract total on an edition — see tokens.ts's
4384
+ // `TokenListing.totalSupply` doc). `resume.ts` models both (`planResume`/`planEditionResume`,
4385
+ // sharing the four non-mint legs via `planCoreLegs`); this is the one place that decides which.
4386
+ const isEdition = await isEditionContract(publicClient, target);
4387
+ if (!isEdition && flags['mint-amount'] !== undefined) {
4388
+ throw new Error(`--resume ${target}: this is a 721 SeriesCode contract, so --mint-amount has nothing to address its mint leg ` +
4389
+ `is a single whole-contract shortfall with no per-id amount. Drop --mint-amount (use --mint-count).`);
4390
+ }
4391
+ const abi = isEdition ? editionCodeAbi : seriesCodeAbi;
3909
4392
  const owner = await publicClient
3910
- .readContract({ address: target, abi: seriesCodeAbi, functionName: 'owner' })
4393
+ .readContract({ address: target, abi, functionName: 'owner' })
3911
4394
  .catch(() => undefined);
3912
4395
  if (!owner)
3913
- throw new Error(`--resume ${target}: could not read owner() — is this an ABX SeriesCode contract?`);
4396
+ throw new Error(`--resume ${target}: could not read owner() — is this an ABX SeriesCode/EditionCode contract?`);
3914
4397
  step('Resume — read what is missing, send only that');
3915
- info(`target ${bold(target)} ${dim(`· owner ${owner}`)}`);
4398
+ info(`target ${bold(target)} ${dim(`· owner ${owner}${isEdition ? ' · EditionCode (ERC-1155)' : ''}`)}`);
3916
4399
  // 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.
4400
+ // uses, so what gets sent is by construction what the deploy would have written. Only the four
4401
+ // non-mint groups are used on the edition branch below — `legs.mints` is the 721 shape and is
4402
+ // rebuilt per-id there instead (see resume.ts's class doc for why the split).
3918
4403
  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.
4404
+ // The contract is `T | null` (the planner distinguishes "read failed" from a legitimately-falsy
4405
+ // result); tryReadContract's is `T | undefined` — adapt at the boundary.
3921
4406
  const readAt = async (functionName, args = []) => {
3922
- const result = await tryReadContract(publicClient, { address: target, abi: seriesCodeAbi, functionName, args });
4407
+ const result = await tryReadContract(publicClient, { address: target, abi, functionName, args });
3923
4408
  return result === undefined ? null : result;
3924
4409
  };
3925
4410
  const nonZero = (a) => (a && a !== zeroAddress ? a : null);
3926
- const reader = {
4411
+ // The four id-agnostic reads, shared verbatim by both standards (see resume.ts's `ResumeReaderCore`).
4412
+ const coreReads = {
3927
4413
  scriptChunkCount: async () => Number((await readAt('scriptChunkCount')) ?? 0n),
3928
4414
  scriptChunk: (index) => readAt('scriptChunk', [BigInt(index)]),
3929
4415
  schemaExists: async (key) => {
@@ -3938,53 +4424,180 @@ export async function cmdDeployCodeBody(flags, emit) {
3938
4424
  const r = await readAt('contractField', [encodeTag(field)]);
3939
4425
  return !!r && r[0] !== `0x${'0'.repeat(64)}`;
3940
4426
  },
3941
- totalSupply: async () => Number((await readAt('totalSupply')) ?? 0n),
3942
4427
  };
3943
- const plan = await planResume(reader, legs);
4428
+ const plan = isEdition
4429
+ ? await (async () => {
4430
+ // `--mint-count`/`--mint-amount` are the SAME flags a fresh `--copies` deploy reads (see
4431
+ // cmdDeployEditionCodeBody) — so re-running the original command with `--copies` swapped
4432
+ // for `--resume <address>` reproduces the identical intended mint plan: ids 0..mintCount-1,
4433
+ // each an intended TOTAL of mintAmount copies.
4434
+ const mintAmount = flags['mint-amount'] !== undefined ? parseNonNegativeIntFlag(String(flags['mint-amount']), 'mint-amount') : 1n;
4435
+ if (mintCount > 0 && mintAmount === 0n) {
4436
+ 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.');
4437
+ }
4438
+ const editionLegs = {
4439
+ chunks: legs.chunks,
4440
+ schemas: legs.schemas,
4441
+ deps: legs.deps,
4442
+ uri: legs.uri,
4443
+ mints: Array.from({ length: mintCount }, (_, id) => ({
4444
+ id: BigInt(id),
4445
+ intendedAmount: mintAmount,
4446
+ dataForAmount: (amount) => encodeFunctionData({ abi: oneOfOneEditionAbi, functionName: 'mint', args: [owner, BigInt(id), amount] }),
4447
+ })),
4448
+ };
4449
+ const reader = {
4450
+ ...coreReads,
4451
+ totalSupplyForId: async (id) => Number((await readAt('totalSupply', [id])) ?? 0n),
4452
+ };
4453
+ return planEditionResume(reader, editionLegs);
4454
+ })()
4455
+ : await planResume({ ...coreReads, totalSupply: async () => Number((await readAt('totalSupply')) ?? 0n) }, legs);
3944
4456
  for (const line of plan.done)
3945
4457
  ok(line);
3946
4458
  for (const line of plan.todo)
3947
4459
  info(`will send — ${line}`);
4460
+ // A resume plan object (below) reports its OWN family/roles/custody, deliberately lighter than a
4461
+ // fresh deploy's: royalty, mint, and byte custody are InitParams fields fixed at the ORIGINAL
4462
+ // deploy — a resume only ever finishes MISSING setup legs, so those fields are explicit `null`
4463
+ // (see deploy-plan.ts's own doc comments) rather than restating what this invocation's flags say,
4464
+ // which a resume does not actually write.
4465
+ // ANNOTATED, not inferred: this object is spread into four `satisfies DeployPlan` emits below, and
4466
+ // an un-annotated literal WIDENS its own properties (`schemaVersion` 1 -> number, `family`
4467
+ // 'code'|'code-edition' -> string), so every one of those spreads would fail to satisfy the plan.
4468
+ // The Omit names exactly the three keys each call site supplies for itself.
4469
+ const resumePlanBase = {
4470
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
4471
+ family: isEdition ? 'code-edition' : 'code',
4472
+ lane,
4473
+ roles: { signer: owner, owner, royaltyReceiver: null, primaryPayee: null, minter: null },
4474
+ royalty: null,
4475
+ // renderer/image: null — both are InitParams facts fixed at the ORIGINAL deploy (like
4476
+ // royalty/mint above); a resume doesn't re-read or re-decide either here.
4477
+ custody: { onChainUri, imageOnChain: null, backend: null, tokenUriBase: null, contractUriBase: null, renderer: null, image: null },
4478
+ mint: null,
4479
+ warnings: planWarnings.slice(),
4480
+ // Still reflects what THIS invocation's flags say about every marketplace-facing surface —
4481
+ // computed once, unconditionally, at the top of the function (see `imageDisposition` et al.),
4482
+ // so it's available whether this run turns out to be a fresh deploy or a `--resume`.
4483
+ surfaces: {
4484
+ thumbnail: { ok: !imageOrphaned, detail: stripAnsiForPlan(imageDisposition) },
4485
+ traits: { ok: !traitsBroken, detail: stripAnsiForPlan(traitsDisposition) },
4486
+ postParams: { ok: !(undeclaredParams.length > 0 || paramsNudge), detail: stripAnsiForPlan(paramsDisposition) },
4487
+ },
4488
+ dependencies: { count: deps.length, registry: depRegistry ?? null },
4489
+ };
3948
4490
  if (!plan.calls.length) {
3949
4491
  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 }));
4492
+ emit(jsonSafe({
4493
+ command: 'deploy-code', kind: isEdition ? 'edition-code' : 'code', resumed: target, chain: CHAIN, chainId: resolveChain(CHAIN).id, sent: false, complete: true, sentLegs: 0,
4494
+ plan: {
4495
+ ...resumePlanBase,
4496
+ transactions: { approvals: 0, legs: null },
4497
+ estimate: { ethApprox: null, gasApprox: null },
4498
+ resume: { target, sentLegs: 0, complete: true },
4499
+ },
4500
+ }));
3951
4501
  console.log('');
3952
4502
  return;
3953
4503
  }
3954
- const tx = prepareCodeSetup({
4504
+ // Reconstruct `plan.calls` (one flat, ORDERED array) back into the four leg groups it was built
4505
+ // from — chunks, schemas, deps, uri, in that exact order (resume.ts's `planCoreLegs` guarantees
4506
+ // it; see its module doc) — using the counts `plan.sending` already reports, so this can't drift
4507
+ // from what the diff actually decided to send. Mints (whatever is left) are appended by
4508
+ // `planResume`/`planEditionResume` AFTER these four, also guaranteed by resume.ts.
4509
+ let resumeCallPtr = 0;
4510
+ const takeResumeCalls = (n) => {
4511
+ const slice = plan.calls.slice(resumeCallPtr, resumeCallPtr + n);
4512
+ resumeCallPtr += n;
4513
+ return slice;
4514
+ };
4515
+ const missingChunkCalls = takeResumeCalls(plan.sending.chunkIndices.length);
4516
+ const missingSchemaCalls = takeResumeCalls(plan.sending.schemaKeys.length);
4517
+ const missingDepsCalls = takeResumeCalls(plan.sending.deps ? legs.deps.calls.length : 0);
4518
+ const missingUriCalls = takeResumeCalls(plan.sending.uri ? legs.uri.calls.length : 0);
4519
+ const missingMintCalls = takeResumeCalls(plan.sending.mints);
4520
+ // Same gas-bounded batching a fresh deploy uses (`planCodeSetupBatches`) — a `--resume` whose
4521
+ // setup never landed AT ALL is missing exactly as much as a fresh deploy would have sent, so it
4522
+ // needs the identical split to clear the same `eth_estimateGas` ceiling.
4523
+ const resumeBatches = planCodeSetupBatches({
4524
+ chunks: missingChunkCalls.map((data, i) => {
4525
+ const bytes = plan.sending.chunkBytes[i];
4526
+ return { calls: [data], gas: estimateChunkGasForBytes(bytes), label: `script chunk [${plan.sending.chunkIndices[i]}] (${bytes} bytes)`, kind: 'chunk', chunkBytes: [bytes] };
4527
+ }),
4528
+ config: [
4529
+ ...missingSchemaCalls.map((data, i) => schemaSetupLeg(plan.sending.schemaKeys[i], data)),
4530
+ ...(missingDepsCalls.length ? [depsSetupLeg(missingDepsCalls)] : []),
4531
+ ...(missingUriCalls.length ? [uriSetupLeg(missingUriCalls)] : []),
4532
+ ],
4533
+ mints: missingMintCalls.map(mintSetupLeg),
4534
+ });
4535
+ const setupTxs = codeSetupTxsFromBatches(resumeBatches, {
3955
4536
  contract: target,
3956
- calls: plan.calls,
3957
4537
  chainId: resolveChain(CHAIN).id,
3958
- chunkCount: plan.sending.chunkIndices.length,
3959
- chunkBytes: plan.sending.chunkBytes,
3960
- schemaKeys: plan.sending.schemaKeys,
3961
4538
  deps: plan.sending.deps ? deps.map((d) => d.display) : [],
3962
- onchainUri: plan.sending.uri,
3963
4539
  });
4540
+ // `transactions.legs` names WHICH groups ride the setup, from the same `plan.sending` flags that
4541
+ // build the batches above (never re-derived) — GROUP names, not a literal per-transaction count
4542
+ // (see deploy-plan.ts's own doc comment on `legs`; a `--resume` may now send more than one setup
4543
+ // transaction, same as a fresh deploy).
4544
+ const resumeLegs = [
4545
+ plan.sending.chunkIndices.length ? 'chunks' : null,
4546
+ plan.sending.schemaKeys.length ? 'param-schemas' : null,
4547
+ plan.sending.deps ? 'dependencies' : null,
4548
+ plan.sending.uri ? 'onchain-uri' : null,
4549
+ ].filter((leg) => leg !== null);
3964
4550
  // Routed through the shared risk gate — the same choke point ownerops.ts's `runWrite` uses for
3965
4551
  // every owner-op: --dry-run preview / --confirm prompt / lane selection / signing, all one way.
3966
4552
  // This used to be its own hand-rolled copy of that exact shape (a dry-run print then confirmSend
3967
4553
  // then signTx, duplicated from ownerops.ts's `runWrite`); a deploy-family write is a write like
3968
4554
  // any other, so it takes the identical gate (the preview also gains the `to`/`owner` lines
3969
- // `runWrite`'s preview always printed).
4555
+ // `runWrite`'s preview always printed) — called once per gas-bounded batch, in order.
3970
4556
  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 }));
4557
+ emit(jsonSafe({
4558
+ 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,
4559
+ plan: {
4560
+ ...resumePlanBase,
4561
+ transactions: { approvals: setupTxs.length, legs: resumeLegs },
4562
+ estimate: { ethApprox: null, gasApprox: null },
4563
+ resume: { target, sentLegs: plan.calls.length, complete: false },
4564
+ },
4565
+ }));
3972
4566
  }
3973
- const result = await gatedSend(() => tx, flags, { chainKey: CHAIN, expectedSigner: owner });
3974
- if (!result) {
3975
- if (isDryRun(flags))
3976
- return;
3977
- 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 }));
4567
+ let lastResult = null;
4568
+ for (const tx of setupTxs) {
4569
+ const result = await gatedSend(() => tx, flags, { chainKey: CHAIN, expectedSigner: owner });
4570
+ if (result) {
4571
+ ok(`${tx.summary}`);
4572
+ lastResult = result;
4573
+ }
4574
+ }
4575
+ if (isDryRun(flags))
4576
+ return;
4577
+ if (!lastResult) {
4578
+ // The cold ('unsigned') lane: every batch printed its own unsigned tx above (in order — they
4579
+ // target the SAME already-existing contract, so unlike a fresh deploy's deploy→setup dependency
4580
+ // there's no reason to withhold the later ones). Nothing was broadcast, so there is no block to
4581
+ // scan from and no indexing to do yet.
4582
+ console.log(`\n${dim(` unsigned — broadcast ${setupTxs.length > 1 ? 'these, in order,' : 'it'} then re-run \`abx verify ${target}\` to confirm the setup completed.`)}\n`);
4583
+ emit(jsonSafe({
4584
+ command: 'deploy-code', kind: isEdition ? 'edition-code' : 'code', resumed: target, chain: CHAIN, chainId: resolveChain(CHAIN).id, sent: false, complete: false, sentLegs: plan.calls.length,
4585
+ plan: {
4586
+ ...resumePlanBase,
4587
+ transactions: { approvals: setupTxs.length, legs: resumeLegs },
4588
+ estimate: { ethApprox: null, gasApprox: null },
4589
+ resume: { target, sentLegs: plan.calls.length, complete: false },
4590
+ },
4591
+ }));
3979
4592
  return;
3980
4593
  }
3981
- ok(`${tx.summary}`);
3982
4594
  // Locals, not the outer clone/deployBlock: this branch never falls through to the deploy path, and
3983
4595
  // the authoritative scan floor is still the clone's CREATION block (getCode search), not this
3984
4596
  // repair tx's block — flooring the resolver above the deploy would hide the `code` field again.
3985
- const resumedFloor = (await discoverDeployBlock(publicClient, target)) ?? result.blockNumber;
4597
+ const resumedFloor = (await discoverDeployBlock(publicClient, target)) ?? lastResult.blockNumber;
3986
4598
  emit(jsonSafe({
3987
4599
  command: 'deploy-code',
4600
+ kind: isEdition ? 'edition-code' : 'code',
3988
4601
  resumed: target,
3989
4602
  address: target,
3990
4603
  chain: CHAIN,
@@ -3993,16 +4606,34 @@ export async function cmdDeployCodeBody(flags, emit) {
3993
4606
  sent: true,
3994
4607
  complete: true,
3995
4608
  sentLegs: plan.calls.length,
3996
- txHash: result.txHash,
4609
+ txHash: lastResult.txHash,
4610
+ plan: {
4611
+ ...resumePlanBase,
4612
+ transactions: { approvals: setupTxs.length, legs: resumeLegs },
4613
+ estimate: { ethApprox: null, gasApprox: null },
4614
+ resume: { target, sentLegs: plan.calls.length, complete: true },
4615
+ },
3997
4616
  }));
3998
4617
  step('Index');
3999
- await registerAndIndexLocally(target, { 'from-block': resumedFloor.toString(), factory, label: name });
4618
+ // The factory HINT registerAndIndexLocally passes through to detectCanonicalFactory is taken as
4619
+ // an UNVERIFIED override when present (see anchors.ts) — so it must name the RIGHT factory family
4620
+ // for this target. `factory` (outer scope) is the 721 SeriesCode factory this function resolved at
4621
+ // its own "Trust anchor" step; an EditionCode target needs its own factory's address instead (a
4622
+ // read-only manifest lookup — never bootstraps one, unlike a real edition deploy).
4623
+ await registerAndIndexLocally(target, {
4624
+ 'from-block': resumedFloor.toString(),
4625
+ factory: isEdition ? editionCodeFactoryAddress(flags.factory) : factory,
4626
+ label: name,
4627
+ });
4000
4628
  console.log(`\n ${g('\u2713 setup finished')} \u2014 ${dim('confirm it resolves:')} ${bold(`abx verify ${target}`)}` +
4001
4629
  `${onChainUri ? dim(' (chain-complete + the on-chain tokenURI)') : ''}\n`);
4002
4630
  return;
4003
4631
  }
4004
4632
  let clone;
4005
4633
  let deployBlock;
4634
+ // See the 1/1 lane's identical local for why this is hoisted (feeds the sent plan's `roles`). The
4635
+ // 'unsigned' branch below returns before the sent `emit()`, so it never needs to set this.
4636
+ let deployerAddr;
4006
4637
  if (lane === 'unsigned') {
4007
4638
  // cold lane: deterministic deploy makes the whole sequence pre-computable — the clone
4008
4639
  // address is a pure function of (factory, salt), so setup + mints target it up front.
@@ -4027,6 +4658,7 @@ export async function cmdDeployCodeBody(flags, emit) {
4027
4658
  const session = await openWalletSession({ chainKey: CHAIN, expectedSigner: flags.for, total: approvals, port: flags.port ? Number(flags.port) : undefined, signUrlFile: flags['sign-url-file'] });
4028
4659
  try {
4029
4660
  const signer = await session.connect();
4661
+ deployerAddr = signer;
4030
4662
  const { clone: predicted, txs } = await preparedFor(signer);
4031
4663
  // The scan floor is the clone-CREATION block (txs[0]) — NOT the last tx. A wallet session can
4032
4664
  // span blocks (deploy at N, setup+mint at N+2), and the `code` field is written in the deploy
@@ -4052,6 +4684,7 @@ export async function cmdDeployCodeBody(flags, emit) {
4052
4684
  // `signHotSequence` (signer.ts) / `makeHotSender` (sdk execute.ts) for the read-after-write-lag
4053
4685
  // reasoning this used to be a hand-rolled loop for.
4054
4686
  const { account } = makeWalletClient({ chainKey: CHAIN });
4687
+ deployerAddr = account.address;
4055
4688
  const { clone: predicted, txs } = await preparedFor(account.address);
4056
4689
  const results = await signHotSequence(txs, { chainKey: CHAIN, yes: !!flags.yes });
4057
4690
  clone = predicted;
@@ -4087,6 +4720,39 @@ export async function cmdDeployCodeBody(flags, emit) {
4087
4720
  name,
4088
4721
  symbol,
4089
4722
  onChainUri,
4723
+ plan: {
4724
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
4725
+ family: 'code',
4726
+ lane,
4727
+ transactions: { approvals, legs: planSetupLegs },
4728
+ roles: {
4729
+ signer: deployerAddr ?? null, owner: deployerAddr ?? null, royaltyReceiver: deployerAddr ?? null,
4730
+ primaryPayee: flags['primary-payee'] ?? null,
4731
+ minter: flags.minter ?? null,
4732
+ },
4733
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
4734
+ custody: {
4735
+ onChainUri,
4736
+ imageOnChain: null, // see `surfaces.thumbnail`
4737
+ backend: dirUpload ? dirUpload.backend.id : null,
4738
+ tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
4739
+ contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
4740
+ renderer: onChainUri ? metadataRenderer : null,
4741
+ image: null, // see `surfaces.thumbnail`
4742
+ },
4743
+ mint: { deferred: mintCount === 0, count: mintCount, amountPerId: null, recipient: mintCount > 0 ? deployerAddr ?? null : null },
4744
+ // Unlike the dry run, the sent path computes no cost estimate (the gas-guidance try/catch is
4745
+ // dry-run-only — a real send just pays whatever `estimateGas` returns at signing time).
4746
+ estimate: { ethApprox: null, gasApprox: null },
4747
+ warnings: planWarnings.slice(),
4748
+ surfaces: {
4749
+ thumbnail: { ok: !imageOrphaned, detail: stripAnsiForPlan(imageDisposition) },
4750
+ traits: { ok: !traitsBroken, detail: stripAnsiForPlan(traitsDisposition) },
4751
+ postParams: { ok: !(undeclaredParams.length > 0 || paramsNudge), detail: stripAnsiForPlan(paramsDisposition) },
4752
+ },
4753
+ dependencies: { count: deps.length, registry: depRegistry ?? null },
4754
+ resume: null,
4755
+ },
4090
4756
  }));
4091
4757
  step('Index');
4092
4758
  await registerAndIndexLocally(clone, { 'from-block': deployBlock.toString(), factory, label: name });
@@ -4175,43 +4841,51 @@ export const DEPLOY_CODE_EDITION_FLAGS = new Set([
4175
4841
  'public-base-url', 'onchain-uri', 'generator', 'renderer', 'port',
4176
4842
  'sign', 'unsigned', 'for', 'salt', 'factory', 'bootstrap-factory', 'sign-url-file',
4177
4843
  '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).
4844
+ // storage (directory mode + --image-base's off-chain still) mirrors storageOverrides(), same
4845
+ // allowlist entries as the 721 twin's DEPLOY_CODE_FLAGS.
4846
+ 'code-dir', 'backend', 'endpoint', 'bucket', 'region', 'prefix', 'public-base', 'gateway', 'mode', 'api-url', 'upload-url', 'provider', 'storage-signer',
4847
+ 'ipfs-gateway', 'arweave-gateway',
4848
+ // NOT scope cuts: --image-renderer/--attributes-renderer (on-chain field renderers) and
4849
+ // --image-base (an off-chain deterministic-per-id still) are all fully wired on EditionCode —
4850
+ // `abx help deploy-code` documents them as working. --dep/--dep-registry likewise: EditionCode
4851
+ // inherits the same Dependencies extension, and the legs ride the same setup multicall.
4852
+ 'image-renderer', 'attributes-renderer', 'image-base', 'dep', 'dep-registry',
4853
+ // v1-scope-cut flag: kept in the ALLOWLIST (not stray) so it reaches this function's own pointed
4854
+ // refusal below, rather than the generic "unrecognized flag" `refuseStrayFlags` would give it
4855
+ // otherwise — a real but less helpful refusal (it doesn't say WHY, or the way out).
4181
4856
  // (`resume` is NOT here: --resume + --copies is refused earlier, at cmdDeployCode's dispatch —
4182
4857
  // 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',
4858
+ 'no-delegation',
4188
4859
  ]);
4189
4860
  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) ' +
4861
+ beginPlanWarnings();
4862
+ 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
4863
  '[--description "<s>"] [--external-url <url>] [--max N] [--mint-count N | --mint-all] [--mint-amount N] ' +
4192
4864
  '[--schema key:Type:Auth,…] [--no-seed | --seed-source 0x..] [--unpaused] [--minter 0x..] [--primary-payee 0x..] [--royalty-bps N] [--721c recommended|0x..]';
4193
4865
  refuseStrayFlags(flags, DEPLOY_CODE_EDITION_FLAGS, 'deploy-code');
4194
4866
  warnSignWithoutFor(flags);
4195
4867
  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
- }
4868
+ const scriptPath = flags.script;
4869
+ const codeDir = flags['code-dir'];
4870
+ if (scriptPath && codeDir)
4871
+ throw new Error(usage);
4208
4872
  // The in-chain Solidity renderer lane, same as the 721 twin: EditionCode composes the same
4209
4873
  // OnChainMetadata extension and the same URI-renderer slots, so `image`/`attributes` at a
4210
4874
  // `renderer` representation resolve through the canonical metadata renderer with no server in the
4211
4875
  // graph. Proven end-to-end in contracts/test/EditionOnChainRender.t.sol.
4212
4876
  const imageRendererFlag = flags['image-renderer'] ? String(flags['image-renderer']) : undefined;
4213
4877
  const attributesRendererFlag = flags['attributes-renderer'] ? String(flags['attributes-renderer']) : undefined;
4214
- const hasProgram = !!flags.script;
4878
+ // --image-renderer and --image-base both set the `image` field — pick ONE, same rule as the 721
4879
+ // twin. Checked before either is resolved so the refusal is immediate rather than after a renderer
4880
+ // code-presence probe.
4881
+ if (imageRendererFlag && flags['image-base']) {
4882
+ 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).');
4883
+ }
4884
+ // A JS program either way (template chunks or a hosted build) — same "hasProgram" test as the 721
4885
+ // twin (see its own `const hasProgram = !!(scriptPath || codeDir);`), so every downstream
4886
+ // conditional gated on it (generator resolution, animation_url, the on-chain-URI legs) already
4887
+ // does the right thing for a directory build without a SEPARATE flag to thread through.
4888
+ const hasProgram = !!(scriptPath || codeDir);
4215
4889
  const hasFieldRenderers = !!(imageRendererFlag || attributesRendererFlag);
4216
4890
  if (!hasProgram && !hasFieldRenderers) {
4217
4891
  throw new Error(`${usage}\n (or, for a FULLY on-chain Solidity render with no program: --copies <n|open> --image-renderer 0x.. ` +
@@ -4373,11 +5047,30 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4373
5047
  }
4374
5048
  }
4375
5049
  }
5050
+ // Content: directory mode uploads the build (its root locator → the `code` collection field);
5051
+ // template mode chunks the script for on-chain storage. The directory UPLOAD is deferred until
5052
+ // after the --confirm gate (and skipped entirely on --dry-run) so a preview never pins bytes —
5053
+ // same shape as the 721 twin's identical block (see its own comment for the full reasoning).
4376
5054
  step('Content');
4377
5055
  let scriptChunks = [];
5056
+ let dirUpload = null;
4378
5057
  let contentSummary;
4379
- if (hasProgram) {
4380
- const source = readFileSync(resolvePath(flags.script), 'utf8');
5058
+ if (codeDir) {
5059
+ const dirPath = resolvePath(codeDir);
5060
+ const files = readdirSync(dirPath).filter((f) => !f.startsWith('.') && statSync(joinPath(dirPath, f)).isFile());
5061
+ if (!files.includes('index.html'))
5062
+ throw new Error(`${dirPath} has no index.html — the directory entry, by convention`);
5063
+ const backend = resolveBackend(storageOptions(storageOverrides(flags)));
5064
+ if (!backend.putDirectory)
5065
+ throw new Error(`storage backend '${backend.id}' has no directory upload — use --backend ipfs (pinata) or arweave`);
5066
+ const entries = files.map((f) => ({ name: f, bytes: new Uint8Array(readFileSync(joinPath(dirPath, f))), contentType: contentTypeFromPath(joinPath(dirPath, f)) }));
5067
+ dirUpload = { backend, entries, sizes: entries.map((e) => e.bytes.length) };
5068
+ contentSummary = `${entries.length} file(s) → ${backend.id} directory (code field)`;
5069
+ info(`${entries.length} file(s) from ${basename(dirPath)}/ → ${backend.id} directory (code field)`);
5070
+ 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).'));
5071
+ }
5072
+ else if (scriptPath) {
5073
+ const source = readFileSync(resolvePath(scriptPath), 'utf8');
4381
5074
  scriptChunks = planOnChainScript(source);
4382
5075
  contentSummary = `script ${Buffer.byteLength(source, 'utf8')} bytes → ${scriptChunks.length} on-chain chunk(s)`;
4383
5076
  ok(contentSummary);
@@ -4431,16 +5124,67 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4431
5124
  if (hasFieldRenderers && !onChainUri) {
4432
5125
  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
5126
  }
5127
+ // --image-base: bake the on-chain `image` as a url-template (`{base}/{id}.png`) — same field write
5128
+ // as the 721 twin's identical block (see its own comment for the full reasoning), one id space
5129
+ // finer: `{id}` substitutes the EditionCode id, not a sequential 721 token number, and the effect
5130
+ // runner's render sweep already excludes an id with no live copies (harness.ts) — an id with no
5131
+ // mint-time seed drawn yet has nothing to render FROM, the same reason a burned 721 token is
5132
+ // excluded. There is no per-copy addressing: every copy of an id shares one image, same as every
5133
+ // other field on an id.
5134
+ if (flags['image-base']) {
5135
+ const base = String(flags['image-base']);
5136
+ // --image-base needs a mutable, path-addressed URL (the effect runner overwrites the SAME key in
5137
+ // place) — a content-addressed gateway URL (ipfs/arweave) can't back a FIXED per-id address, no
5138
+ // matter what THIS deploy's own --backend happens to be. One shared validator decides this
5139
+ // (packages/storage/content-plan.ts), so this can never disagree with the 721 lane's own refusal.
5140
+ const imageBaseCombo = validateRenderStorageCombo({ imageBaseUrl: base });
5141
+ if (!imageBaseCombo.ok) {
5142
+ if (dryRun)
5143
+ warn(`would REFUSE — ${imageBaseCombo.reason}`);
5144
+ else
5145
+ throw new Error(imageBaseCombo.reason);
5146
+ }
5147
+ // NEVER bake a localhost/loopback image host on-chain — the token's `image` would resolve for no
5148
+ // one. This is the marketplace still's PERMANENT address.
5149
+ if (loopbackBaseUrl(base)) {
5150
+ 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.`;
5151
+ if (dryRun)
5152
+ warn(`would REFUSE — ${m}`);
5153
+ else
5154
+ throw new Error(m);
5155
+ }
5156
+ // The exact trap a real agent hit on the 721 lane: it baked the S3 API endpoint (auth-only) as
5157
+ // --image-base. Refuse the R2 API form (never public); warn on the S3 API form.
5158
+ if (/\.r2\.cloudflarestorage\.com/i.test(base)) {
5159
+ 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.`;
5160
+ if (dryRun)
5161
+ warn(`would REFUSE — ${m}`);
5162
+ else
5163
+ throw new Error(m);
5164
+ }
5165
+ else if (/(^|\/\/)s3[.-][^/]*amazonaws\.com/i.test(base) || /\.s3[.-][^/]*amazonaws\.com/i.test(base)) {
5166
+ 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.`);
5167
+ }
5168
+ const imageTemplate = base.includes('{id}') ? base : `${base.replace(/\/+$/, '')}/{id}.png`;
5169
+ contractFields.push({ field: encodeTag(F.image), representation: encodeTag(R.urlTemplate), value: toHex(imageTemplate) });
5170
+ 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)')}`);
5171
+ 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)')}`);
5172
+ const s3Base = process.env.ABX_S3_PUBLIC_BASE?.replace(/\/+$/, '');
5173
+ if (s3Base && !imageTemplate.startsWith(`${s3Base}/`)) {
5174
+ 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.`);
5175
+ }
5176
+ }
4434
5177
  const onchainUriLegs = onChainUri ? onchainUriSetupCalls({ generator, metadataRenderer }) : [];
4435
5178
  step('Deploy');
4436
5179
  const lane = laneFromFlags(flags);
4437
5180
  const minter = flags.minter ?? zeroAddress;
4438
5181
  const primaryPayee = flags['primary-payee'] ?? zeroAddress;
4439
5182
  const explicitSalt = parseSaltFlag(flags.salt);
4440
- // Always TWO transactions (deploy + one setup multicall) — this leaner lane skips the 721 code
4441
- // lane's "fold reserve mints + renderers into init" micro-optimization (only reachable there for
4442
- // a renderer-only, no-program drop, which this function refuses anyway — every edition-code
4443
- // deploy has a program).
5183
+ // Normally TWO transactions (deploy + one gas-bounded setup batch) — this leaner lane skips the
5184
+ // 721 code lane's "fold reserve mints + renderers into init" micro-optimization (only reachable
5185
+ // there for a renderer-only, no-program drop, which this function refuses anyway — every
5186
+ // edition-code deploy has a program). A setup large enough to miss the `eth_estimateGas` ceiling
5187
+ // (a big script) splits into MORE than one setup transaction — see `planCodeSetupBatches`.
4444
5188
  const initParamsFor = (owner) => ({
4445
5189
  owner,
4446
5190
  name,
@@ -4466,53 +5210,52 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4466
5210
  tokenFields: [],
4467
5211
  contractFields,
4468
5212
  });
4469
- const setupCallsFor = (owner) => {
4470
- const calls = [];
4471
- for (let i = 0; i < scriptChunks.length; i++) {
4472
- calls.push(encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setScriptChunk', args: [BigInt(i), scriptChunks[i]] }));
4473
- }
4474
- for (const s of schemas) {
4475
- calls.push(encodeFunctionData({
4476
- abi: seriesCodeAbi,
4477
- functionName: 'setParamSchema',
4478
- args: [encodeTag(s.key), s.paramType, s.auth, s.authAddress, s.lockAfter, s.min, s.max, s.selectOptions],
4479
- }));
4480
- }
4481
- // Dependency legs, ordered (index 0 = the runtime) + the SOFT registry pointer when one resolved.
4482
- // Built by the SDK's shared `dependencySetupCalls` — the same function the 721 lane uses — so the
4483
- // two lanes can never encode `setDependency` differently.
4484
- calls.push(...dependencySetupCalls(deps, depRegistry));
4485
- calls.push(...onchainUriLegs);
4486
- // Reserve mints: one `mint(to, id, amount)` per premint id the edition twin of the 721 lane's
4487
- // N identical `mint(owner)` calls, one id finer (a specific amount of copies, not just "one").
4488
- for (let id = 0; id < mintCount; id++) {
4489
- calls.push(encodeFunctionData({ abi: oneOfOneEditionAbi, functionName: 'mint', args: [owner, BigInt(id), mintAmount] }));
4490
- }
4491
- return calls;
4492
- };
5213
+ // Gas-bounded setup-transaction legs script chunks, schemas, deps, on-chain-URI wiring are all
5214
+ // OWNER-INDEPENDENT (only a mint call's calldata embeds the owner+id+amount; its gas is a flat
5215
+ // constant regardless), so they're built once, up front, and reused both to size
5216
+ // `approvals`/`planSetupLegs` now and to build the real transactions in `preparedFor` below —
5217
+ // identical `.gas` sequence ⇒ identical split (see `planCodeSetupBatches`'s own doc for why that
5218
+ // can never drift).
5219
+ const chunkLegs = scriptChunks.map((chunk, i) => chunkSetupLeg(i, chunk, encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setScriptChunk', args: [BigInt(i), chunk] })));
5220
+ const depCallsForEdition = dependencySetupCalls(deps, depRegistry);
5221
+ const configLegs = [
5222
+ ...schemas.map((s) => schemaSetupLeg(s.key, encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setParamSchema', args: [encodeTag(s.key), s.paramType, s.auth, s.authAddress, s.lockAfter, s.min, s.max, s.selectOptions] }))),
5223
+ ...(depCallsForEdition.length ? [depsSetupLeg(depCallsForEdition)] : []),
5224
+ ...(onchainUriLegs.length ? [uriSetupLeg(onchainUriLegs)] : []),
5225
+ ];
5226
+ // Reserve mints: one `mint(to, id, amount)` per premint id — the edition twin of the 721 lane's N
5227
+ // identical `mint(owner)` calls, one id finer (a specific amount of copies, not just "one").
5228
+ const mintLegsFor = (owner) => Array.from({ length: mintCount }, (_, id) => mintSetupLeg(encodeFunctionData({ abi: oneOfOneEditionAbi, functionName: 'mint', args: [owner, BigInt(id), mintAmount] })));
5229
+ // `owner` is irrelevant to the batch SHAPE (see the comment above), so `zeroAddress` here is a
5230
+ // placeholder used purely to size `approvals` before a deployer is known never sent.
5231
+ const editionSetupBatches = planCodeSetupBatches({ chunks: chunkLegs, config: configLegs, mints: mintLegsFor(zeroAddress) });
4493
5232
  const preparedFor = async (owner) => {
4494
5233
  const salt = explicitSalt ?? saltFor(owner);
4495
5234
  const clone = await predictClone(publicClient, { factory, salt });
4496
- const calls = setupCallsFor(owner);
4497
- const txs = [prepareDeployEditionCode({ factory, params: initParamsFor(owner), salt, chainId, clone })];
4498
- if (calls.length) {
4499
- txs.push(prepareCodeSetup({
4500
- contract: clone,
4501
- calls,
4502
- chainId,
4503
- chunkCount: scriptChunks.length,
4504
- chunkBytes: scriptChunks.map((h) => (h.length - 2) / 2),
4505
- schemaKeys: schemas.map((s) => s.key),
4506
- onchainUri: onChainUri,
4507
- }));
4508
- }
5235
+ const setupTxs = codeSetupTxsFromBatches(planCodeSetupBatches({ chunks: chunkLegs, config: configLegs, mints: mintLegsFor(owner) }), { contract: clone, chainId, deps: deps.map((d) => d.display) });
5236
+ const txs = [prepareDeployEditionCode({ factory, params: initParamsFor(owner), salt, chainId, clone }), ...setupTxs];
4509
5237
  return { clone, txs };
4510
5238
  };
4511
- // deps ride the same single setup multicall as chunks/schemas/mints, so they don't add an approval —
4512
- // but they must be COUNTED in the "is there a setup multicall at all" test, or a deps-only project
4513
- // (a script with no schemas and no premints) would report 1 approval and send 2 transactions.
4514
- const depLegCount = dependencySetupCalls(deps, depRegistry).length;
4515
- const approvals = 1 + (scriptChunks.length + schemas.length + depLegCount + onchainUriLegs.length + mintCount > 0 ? 1 : 0);
5239
+ // deps ride the same setup batch as chunks/schemas/mints when everything fits one transaction, so
5240
+ // they don't add an approval on their own — but they must be COUNTED in the "is there a setup
5241
+ // transaction at all" test, or a deps-only project (a script with no schemas and no premints) would
5242
+ // report 1 approval and send 2 transactions.
5243
+ const depLegCount = depCallsForEdition.length;
5244
+ const approvals = 1 + editionSetupBatches.length; // deploy tx + however many gas-bounded setup batches
5245
+ // Ordered legs riding the setup (if any) — GROUP names, not a literal per-transaction count (see
5246
+ // deploy-plan.ts's own doc comment on `legs`); same booleans `approvals` above is built from, so the
5247
+ // plan object's `transactions.legs` can never drift from the tx(es) the wallet actually signs.
5248
+ // Always ends with `'deploy'` when non-null.
5249
+ const planSetupLegs = approvals > 1
5250
+ ? [
5251
+ scriptChunks.length ? 'chunks' : null,
5252
+ schemas.length ? 'param-schemas' : null,
5253
+ depLegCount ? 'dependencies' : null,
5254
+ onchainUriLegs.length ? 'onchain-uri' : null,
5255
+ mintCount ? 'mints' : null,
5256
+ 'deploy',
5257
+ ].filter((leg) => leg !== null)
5258
+ : null;
4516
5259
  if (!dryRun) {
4517
5260
  await confirmSend(
4518
5261
  // Second site of the same missing binding as the plan line above — and the worse one: this is
@@ -4551,18 +5294,70 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4551
5294
  printSeedSourcePlan(seedSource, chainId);
4552
5295
  info(`PostParam schema(s): ${schemas.length ? schemas.map((s) => describeSchema(s)).join(', ') : 'none'}`);
4553
5296
  info(`mint: ${mintCount > 0 ? `${mintCount} id(s) × ${mintAmount} cop${mintAmount === 1n ? 'y' : 'ies'} → ${deployer ?? 'your wallet'} at deploy` : 'deferred (mint later / external minter)'}`);
4554
- info(`transactions: 2 — deploy + setup multicall (${[scriptChunks.length ? 'script chunks' : null, schemas.length ? 'schemas' : null, onChainUri ? 'on-chain URI wiring' : null, mintCount > 0 ? 'reserve mints' : null].filter(Boolean).join(' + ') || 'nothing else'})`);
5297
+ info(`transactions: ${approvals} — deploy + ${editionSetupBatches.length} setup ${editionSetupBatches.length === 1 ? 'transaction' : 'transactions (gas-bounded)'} ` +
5298
+ `(${[scriptChunks.length ? 'script chunks' : null, schemas.length ? 'schemas' : null, onChainUri ? 'on-chain URI wiring' : null, mintCount > 0 ? 'reserve mints' : null].filter(Boolean).join(' + ') || 'nothing else'})`);
4555
5299
  info(`approvals ${approvals} wallet approval(s)`); // parity with the 721 code twin — it had this only in the --confirm sentence
4556
5300
  if (!explicitSalt && deployer) {
4557
5301
  console.log(`\n ${bold('salt')} ${g(salt)}`);
4558
5302
  info(`address: pinned by salt — re-run with ${bold(`--salt ${salt}`)} (same address).`);
4559
5303
  }
4560
5304
  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 }));
5305
+ emit(jsonSafe({
5306
+ command: 'deploy-code', kind: 'edition-code', copies: editionSize.toString(), dryRun: true, sent: false, address: shownAddr, chain: CHAIN, chainId, factory, name, symbol,
5307
+ plan: {
5308
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
5309
+ family: 'code-edition',
5310
+ lane,
5311
+ transactions: { approvals, legs: planSetupLegs },
5312
+ roles: {
5313
+ signer: deployer, owner: deployer, royaltyReceiver: deployer,
5314
+ primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
5315
+ minter: minter !== zeroAddress ? minter : null,
5316
+ },
5317
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
5318
+ custody: {
5319
+ onChainUri,
5320
+ 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
5321
+ backend: dirUpload ? dirUpload.backend.id : null,
5322
+ tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
5323
+ contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
5324
+ renderer: onChainUri ? metadataRenderer : null,
5325
+ image: null, // no single --image file on this lane (see custody.image's own doc comment)
5326
+ },
5327
+ mint: {
5328
+ deferred: mintCount === 0,
5329
+ count: mintCount,
5330
+ amountPerId: mintAmount.toString(),
5331
+ recipient: mintCount > 0 ? deployer : null,
5332
+ },
5333
+ estimate: { ethApprox: null, gasApprox: null }, // this lane computes no cost estimate
5334
+ warnings: planWarnings.slice(),
5335
+ surfaces: null, // only the fresh 721 code lane computes the per-surface disposition today
5336
+ dependencies: { count: deps.length, registry: depRegistry ?? null },
5337
+ resume: null,
5338
+ },
5339
+ }));
4562
5340
  return;
4563
5341
  }
5342
+ // Real deploy: perform the deferred directory upload now (past the confirm gate). `contractFields`
5343
+ // is captured by reference in `initParamsFor` above — pushing to it here still lands in the CREATE
5344
+ // tx's init params, since that closure isn't actually CALLED until the signing lane below runs.
5345
+ if (dirUpload) {
5346
+ const { base } = await dirUpload.backend.putDirectory(dirUpload.entries);
5347
+ // Store the BARE locator (CID / arweave txid), never a full gateway URL — the resolver applies
5348
+ // the gateway at serve time per `representation`.
5349
+ const ipfsCid = base.match(/\/ipfs\/([^/]+)/)?.[1];
5350
+ const arTxid = base.match(/arweave\.net\/([^/?#]+)/i)?.[1];
5351
+ const rep = ipfsCid ? 'ipfs' : dirUpload.backend.id === 'arweave' ? 'arweave' : 'url';
5352
+ const value = ipfsCid ?? (rep === 'arweave' ? (arTxid ?? base) : base);
5353
+ contractFields.push({ field: encodeTag('code'), representation: encodeTag(rep), value: toHex(value) });
5354
+ ok(`uploaded ${dirUpload.entries.length} file(s) → code field (${rep}): ${value}`);
5355
+ }
4564
5356
  let clone;
4565
5357
  let deployBlock;
5358
+ // See the 1/1 lane's identical local for why this is hoisted (feeds the sent plan's `roles`). The
5359
+ // 'unsigned' branch below returns before the sent `emit()`, so it never needs to set this.
5360
+ let deployerAddr;
4566
5361
  if (lane === 'unsigned') {
4567
5362
  const signer = flags.for;
4568
5363
  if (!signer)
@@ -4577,6 +5372,7 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4577
5372
  const session = await openWalletSession({ chainKey: CHAIN, expectedSigner: flags.for, total: approvals, port: flags.port ? Number(flags.port) : undefined, signUrlFile: flags['sign-url-file'] });
4578
5373
  try {
4579
5374
  const signer = await session.connect();
5375
+ deployerAddr = signer;
4580
5376
  const { clone: predicted, txs } = await preparedFor(signer);
4581
5377
  let first;
4582
5378
  for (const tx of txs) {
@@ -4593,6 +5389,7 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4593
5389
  }
4594
5390
  else {
4595
5391
  const { account } = makeWalletClient({ chainKey: CHAIN });
5392
+ deployerAddr = account.address;
4596
5393
  const { clone: predicted, txs } = await preparedFor(account.address);
4597
5394
  const results = await signHotSequence(txs, { chainKey: CHAIN, yes: !!flags.yes });
4598
5395
  clone = predicted;
@@ -4606,7 +5403,41 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4606
5403
  info(`permanent: max ${max} id(s) · royalty ${royaltyBps / 100}%${flags['royalty-bps'] === undefined ? ' ⚠ default' : ''} → the deploying wallet` +
4607
5404
  ` · cap ${maxRoyaltyBps / 100}%${burnable ? ' · burnable' : ''}` +
4608
5405
  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 }));
5406
+ emit(jsonSafe({
5407
+ command: 'deploy-code', kind: 'edition-code', copies: editionSize.toString(), address: clone, chain: CHAIN, chainId, factory, deployBlock, name, symbol, onChainUri,
5408
+ plan: {
5409
+ schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
5410
+ family: 'code-edition',
5411
+ lane,
5412
+ transactions: { approvals, legs: planSetupLegs },
5413
+ roles: {
5414
+ signer: deployerAddr ?? null, owner: deployerAddr ?? null, royaltyReceiver: deployerAddr ?? null,
5415
+ primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
5416
+ minter: minter !== zeroAddress ? minter : null,
5417
+ },
5418
+ royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
5419
+ custody: {
5420
+ onChainUri,
5421
+ imageOnChain: null,
5422
+ backend: dirUpload ? dirUpload.backend.id : null,
5423
+ tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
5424
+ contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
5425
+ renderer: onChainUri ? metadataRenderer : null,
5426
+ image: null,
5427
+ },
5428
+ mint: {
5429
+ deferred: mintCount === 0,
5430
+ count: mintCount,
5431
+ amountPerId: mintAmount.toString(),
5432
+ recipient: mintCount > 0 ? deployerAddr ?? null : null,
5433
+ },
5434
+ estimate: { ethApprox: null, gasApprox: null },
5435
+ warnings: planWarnings.slice(),
5436
+ surfaces: null,
5437
+ dependencies: { count: deps.length, registry: depRegistry ?? null },
5438
+ resume: null,
5439
+ },
5440
+ }));
4610
5441
  step('Index');
4611
5442
  await registerAndIndexLocally(clone, { 'from-block': deployBlock.toString(), factory, label: name });
4612
5443
  if (onChainUri) {
@@ -4634,6 +5465,11 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
4634
5465
  console.log(` ${bold(`abx mint ${clone} --token-id <id> --amount <n>`)} ${dim('# mint more copies of an id')}`);
4635
5466
  console.log(` ${bold(`abx minter configure ${clone} --token-id <id> --price <eth> --allocation <n>`)} ${dim('# set up a priced sale for one id')}`);
4636
5467
  console.log(` ${bold(`abx refresh ${clone}`)} ${dim('# nudge marketplaces once metadata is live')}\n`);
5468
+ // Same Arweave-propagation caveat as the 721 twin (see its own comment): the gateway 404s until
5469
+ // Turbo settles the bundle, so a render run right after deploy correctly finds nothing servable yet.
5470
+ if (dirUpload?.backend.id === 'arweave') {
5471
+ 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).')}`);
5472
+ }
4637
5473
  }
4638
5474
  /**
4639
5475
  * Resolve `--721c` into the InitParams `transferValidator`. Absent → `zeroAddress`: a plain