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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,7 @@
15
15
  import { createHash } from 'node:crypto';
16
16
  import { readFileSync, readdirSync, statSync } from 'node:fs';
17
17
  import { basename, extname, join as joinPath, resolve as resolvePath } from 'node:path';
18
- import { DEFAULT_CHAIN_KEY, DEP_RESOLUTION, METADATA_FIELD as F, contentIdFromLocator, METADATA_REPRESENTATION as R, analyzeScript, assertChainId, checkRegistryDeps, dependencySetupCalls, deployOneOfOne, deploySeries, deployOneOfOneEdition, deployEditionImage, discoverDeployBlock, encodeFieldRenderer, encodeTag, expectedChainComplete, makeHotSender, makePublicClient, makeWalletClient, normalizeAttributes, onchainUriSetupCalls, oneOfOneImageAbi, oneOfOneEditionAbi, editionCodeAbi, parseTraitPairs, planResume, planEditionResume, predictClone, probeSeedSource, prepareCodeSetup, prepareDeployOneOfOne, prepareDeploySeries, prepareDeploySeriesCode, prepareDeployOneOfOneEdition, prepareDeployEditionImage, prepareDeployEditionCode, redactRpcUrl, resolveChain, resolveDepRegistryPointer, resolveGenerator, probeTransferValidator, resolveRecommendedTransferValidator, resolveRpcUrl, resolveSeedSource, resolveSeriesCodeFactory, saltFor, seriesCodeAbi, seriesCodeFactoryAbi, tryReadContract, AbxServiceClient, } from '@artblocks/abx-sdk';
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';
@@ -34,7 +34,7 @@ import { describeSchema, editionSchemaAdvisory, parseSchemaSpecs } from '../sche
34
34
  import { parseSeriesTraits } from '../series-traits.js';
35
35
  import { decodeOnChainJson } from '../served.js';
36
36
  import { openWalletSession, signHotSequence, signTx } from '../signer.js';
37
- // ── plan-object warning capture (issue #123) ─────────────────────────────────────────────────────
37
+ // ── plan-object warning capture ──────────────────────────────────────────────────────────────────
38
38
  // The structured `--json` plan object (deploy-plan.ts) reports "the warnings the lane raised"
39
39
  // alongside every other computed field — but ~400 `warn()` call sites in this file exist purely as
40
40
  // human prose, and rewriting each to ALSO push onto a collector would be exactly the kind of
@@ -926,10 +926,22 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
926
926
  // never disagree. Counts TX signatures only: a storage upload signed by a connected wallet
927
927
  // (Arweave via --storage-signer eth) is a message signature, not a transaction, and is already
928
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: 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;
929
933
  const approvals = onchainImage
930
934
  ? (() => {
931
- const plan = computeContentPlan(readFileSync(resolvePath(flags.image)), parseCompress(flags.compress)).plan;
932
- 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
933
945
  })()
934
946
  : 1;
935
947
  // Mint-on-deploy is the default; --no-mint defers it so you can stand up + warm
@@ -1061,7 +1073,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1061
1073
  // could act on that the actual deploy will not land at. `saltPinned` still says why.
1062
1074
  emit(jsonSafe({
1063
1075
  command: 'deploy', dryRun: true, sent: false, address: explicitSalt ? predicted : null, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt, saltPinned: !!explicitSalt, name, symbol,
1064
- // The structured plan (issue #123) — built from the SAME locals the readout above just
1076
+ // The structured plan — built from the SAME locals the readout above just
1065
1077
  // printed; nothing here is re-derived or newly computed. `deployer` is a real wallet address
1066
1078
  // regardless of salt pinning (only the PREDICTED CONTRACT address carries that caveat, and
1067
1079
  // this plan doesn't repeat it — see `address`, above), so roles are safe to report unconditionally.
@@ -1069,7 +1081,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1069
1081
  schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
1070
1082
  family: '1of1',
1071
1083
  lane,
1072
- transactions: { approvals, legs: onchainImage ? ['onchain-image-staging'] : null },
1084
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging', 'deploy'] : null },
1073
1085
  // The 1/1 lane's InitParams has no minter/primaryPayee field at all — `null` here means
1074
1086
  // "no concept on this lane", not "unset".
1075
1087
  roles: { signer: deployer, owner: deployer, royaltyReceiver: deployer, primaryPayee: null, minter: null },
@@ -1083,6 +1095,8 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1083
1095
  // "nothing baked", not an empty string to interpret.
1084
1096
  tokenUriBase: params.tokenURIBase || null,
1085
1097
  contractUriBase: params.contractURIBase || null,
1098
+ renderer: onChainUri ? renderer : null,
1099
+ image: planImageFile,
1086
1100
  },
1087
1101
  mint: { deferred: noMint, count: noMint ? 0 : 1, amountPerId: null, recipient: noMint ? null : deployer },
1088
1102
  estimate: { ethApprox: null, gasApprox: null }, // the 1/1 lane computes no cost estimate
@@ -1216,7 +1230,7 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1216
1230
  schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
1217
1231
  family: '1of1',
1218
1232
  lane,
1219
- transactions: { approvals, legs: onchainImage ? ['onchain-image-staging'] : null },
1233
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging', 'deploy'] : null },
1220
1234
  roles: { signer: deployerAddr ?? null, owner: deployerAddr ?? null, royaltyReceiver: deployerAddr ?? null, primaryPayee: null, minter: null },
1221
1235
  royalty: { bps: royaltyBps, capBps: maxRoyaltyBps, burnable },
1222
1236
  custody: {
@@ -1227,6 +1241,8 @@ export async function cmdDeployBody(flags, serveAfter, emit) {
1227
1241
  // carries `params` out to this scope) — never a new decision, the identical ternary.
1228
1242
  tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
1229
1243
  contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
1244
+ renderer: onChainUri ? renderer : null,
1245
+ image: planImageFile,
1230
1246
  },
1231
1247
  mint: { deferred: noMint, count: noMint ? 0 : 1, amountPerId: null, recipient: noMint ? null : deployerAddr ?? null },
1232
1248
  estimate: { ethApprox: null, gasApprox: null },
@@ -1522,10 +1538,21 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1522
1538
  // The edition paths shipped without this and without the readout line below, so `--copies` previews
1523
1539
  // silently dropped the approval count the skill promises "every preview" prints — leaving an agent
1524
1540
  // with nothing to tell the creator about how many wallet prompts to expect.
1541
+ // Also captures `custody.image` file metadata — 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;
1525
1544
  const approvals = onchainImage
1526
1545
  ? (() => {
1527
- const plan = computeContentPlan(readFileSync(resolvePath(flags.image)), parseCompress(flags.compress)).plan;
1528
- 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;
1529
1556
  })()
1530
1557
  : 1;
1531
1558
  if (onChainUri) {
@@ -1625,7 +1652,7 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1625
1652
  schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
1626
1653
  family: '1of1-edition',
1627
1654
  lane,
1628
- transactions: { approvals, legs: onchainImage ? ['onchain-image-staging'] : null },
1655
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging', 'deploy'] : null },
1629
1656
  roles: {
1630
1657
  signer: deployer, owner: deployer, royaltyReceiver: deployer,
1631
1658
  primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
@@ -1638,6 +1665,8 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1638
1665
  backend: !onChainUri && !onchainImage ? backendResolution(storageOverrides(flags)).backend : null,
1639
1666
  tokenUriBase: params.tokenURIBase || null,
1640
1667
  contractUriBase: params.contractURIBase || null,
1668
+ renderer: onChainUri ? renderer : null,
1669
+ image: planImageFile,
1641
1670
  },
1642
1671
  mint: { deferred: mintAmount === 0n, count: mintAmount > 0n ? 1 : 0, amountPerId: mintAmount.toString(), recipient: mintAmount > 0n ? deployer : null },
1643
1672
  estimate: { ethApprox: null, gasApprox: null },
@@ -1722,7 +1751,7 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1722
1751
  schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
1723
1752
  family: '1of1-edition',
1724
1753
  lane,
1725
- transactions: { approvals, legs: onchainImage ? ['onchain-image-staging'] : null },
1754
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging', 'deploy'] : null },
1726
1755
  roles: {
1727
1756
  signer: deployerAddr ?? null, owner: deployerAddr ?? null, royaltyReceiver: deployerAddr ?? null,
1728
1757
  primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
@@ -1735,6 +1764,8 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1735
1764
  backend: !onChainUri && !onchainImage ? backendResolution(storageOverrides(flags)).backend : null,
1736
1765
  tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
1737
1766
  contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
1767
+ renderer: onChainUri ? renderer : null,
1768
+ image: planImageFile,
1738
1769
  },
1739
1770
  mint: { deferred: mintAmount === 0n, count: mintAmount > 0n ? 1 : 0, amountPerId: mintAmount.toString(), recipient: mintAmount > 0n ? deployerAddr ?? null : null },
1740
1771
  estimate: { ethApprox: null, gasApprox: null },
@@ -2224,7 +2255,7 @@ export async function cmdDeploySeriesBody(flags, emit) {
2224
2255
  lane,
2225
2256
  // A staged on-chain collection is many chunk-write txs, one per file — that per-token detail
2226
2257
  // is printed above (`previewImageStaging`), not re-derived here; `legs` just names the shape.
2227
- transactions: { approvals, legs: onchainImage ? ['onchain-image-staging (per token)'] : null },
2258
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging (per token)', 'deploy'] : null },
2228
2259
  roles: {
2229
2260
  signer: deployer, owner: deployer, royaltyReceiver: deployer,
2230
2261
  primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
@@ -2237,6 +2268,10 @@ export async function cmdDeploySeriesBody(flags, emit) {
2237
2268
  backend: offchainImageOnchainJson || !onChainUri ? backendId : null,
2238
2269
  tokenUriBase: params.tokenURIBase || null,
2239
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,
2240
2275
  },
2241
2276
  mint: { deferred: mintCount === 0, count: mintCount, amountPerId: null, recipient: mintCount > 0 ? deployer : null },
2242
2277
  estimate: { ethApprox: null, gasApprox: null },
@@ -2355,7 +2390,7 @@ export async function cmdDeploySeriesBody(flags, emit) {
2355
2390
  schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
2356
2391
  family: 'series',
2357
2392
  lane,
2358
- transactions: { approvals, legs: onchainImage ? ['onchain-image-staging (per token)'] : null },
2393
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging (per token)', 'deploy'] : null },
2359
2394
  roles: {
2360
2395
  signer: deployerAddr ?? null, owner: deployerAddr ?? null, royaltyReceiver: deployerAddr ?? null,
2361
2396
  primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
@@ -2368,6 +2403,8 @@ export async function cmdDeploySeriesBody(flags, emit) {
2368
2403
  backend: offchainImageOnchainJson || !onChainUri ? backendId : null,
2369
2404
  tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
2370
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
2371
2408
  },
2372
2409
  mint: { deferred: mintCount === 0, count: mintCount, amountPerId: null, recipient: mintCount > 0 ? deployerAddr ?? null : null },
2373
2410
  estimate: { ethApprox: null, gasApprox: null },
@@ -2819,7 +2856,7 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2819
2856
  schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
2820
2857
  family: 'series-edition',
2821
2858
  lane,
2822
- transactions: { approvals, legs: onchainImage ? ['onchain-image-staging (per id)'] : null },
2859
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging (per id)', 'deploy'] : null },
2823
2860
  roles: {
2824
2861
  signer: deployer, owner: deployer, royaltyReceiver: deployer,
2825
2862
  primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
@@ -2832,6 +2869,10 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2832
2869
  backend: !onchainImage && (offchainImageOnchainJson || !onChainUri) ? backendId : null,
2833
2870
  tokenUriBase: params.tokenURIBase || null,
2834
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,
2835
2876
  },
2836
2877
  mint: {
2837
2878
  deferred: effectiveMintCount === 0,
@@ -2915,7 +2956,7 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2915
2956
  schemaVersion: DEPLOY_PLAN_SCHEMA_VERSION,
2916
2957
  family: 'series-edition',
2917
2958
  lane,
2918
- transactions: { approvals, legs: onchainImage ? ['onchain-image-staging (per id)'] : null },
2959
+ transactions: { approvals, legs: onchainImage ? ['onchain-image-staging (per id)', 'deploy'] : null },
2919
2960
  roles: {
2920
2961
  signer: deployerAddr ?? null, owner: deployerAddr ?? null, royaltyReceiver: deployerAddr ?? null,
2921
2962
  primaryPayee: primaryPayee !== zeroAddress ? primaryPayee : null,
@@ -2928,6 +2969,8 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2928
2969
  backend: !onchainImage && (offchainImageOnchainJson || !onChainUri) ? backendId : null,
2929
2970
  tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
2930
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
2931
2974
  },
2932
2975
  mint: {
2933
2976
  deferred: effectiveMintCount === 0,
@@ -3245,6 +3288,76 @@ export const DEPLOY_SERIES_FLAGS = new Set([
3245
3288
  // module has finished evaluating.
3246
3289
  export const DEPLOY_EDITION_FLAGS = new Set([...DEPLOY_FLAGS, 'copies', 'mint-amount', 'minter', 'primary-payee', 'unpaused']);
3247
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
+ }
3248
3361
  export async function cmdDeployCode(flags) {
3249
3362
  // `--resume` targets an EXISTING contract — its standard (721 or edition) was fixed at THAT
3250
3363
  // contract's own deploy, so `--copies` (a creation-time choice) is nonsensical alongside it.
@@ -3686,7 +3799,7 @@ export async function cmdDeployCodeBody(flags, emit) {
3686
3799
  if (flags['image-renderer'] || flags['attributes-renderer']) {
3687
3800
  warn(`${bold('renderer check — yours to verify')} (the CLI confirms code at the address, NOT that render() behaves): an IAbxFieldRenderer must ` +
3688
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) — ` +
3689
- `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.`);
3690
3803
  }
3691
3804
  // ── Surfaces disposition — resolve EVERY product dimension explicitly, up front ──────────────
3692
3805
  // A code project has four surfaces that each land somewhere (or nowhere). A real session shipped
@@ -3790,10 +3903,50 @@ export async function cmdDeployCodeBody(flags, emit) {
3790
3903
  // one whenever a dependency leg rode the multicall alongside a renderer-only (`foldIntoInit`) fold.
3791
3904
  const depLegs = deps.length + (depRegistry ? 1 : 0);
3792
3905
  const setupLen = scriptChunks.length + schemas.length + depLegs + (onchainUriLegs?.length ?? 0) + setupMintCount;
3793
- const approvals = 1 + (setupLen > 0 ? 1 : 0); // deploy tx + (a setup multicall only if something rides it)
3794
- // Ordered legs riding that setup multicall (if any), for the plan object's `transactions.legs`
3795
- // the SAME booleans `setupLen`/`setupBits` (the dry-run's own `transactions:` line, below) already
3796
- // use, so this can't drift from the tx the wallet actually signs.
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.
3797
3950
  const planSetupLegs = setupLen > 0
3798
3951
  ? [
3799
3952
  scriptChunks.length ? 'chunks' : null,
@@ -3801,6 +3954,7 @@ export async function cmdDeployCodeBody(flags, emit) {
3801
3954
  depLegs ? 'dependencies' : null,
3802
3955
  onchainUriLegs ? 'onchain-uri' : null,
3803
3956
  setupMintCount ? 'mints' : null,
3957
+ 'deploy',
3804
3958
  ].filter((leg) => leg !== null)
3805
3959
  : null;
3806
3960
  // opt-in --confirm: one y/N before ANY upload or send (no-op without --confirm; never blocks scripts).
@@ -3918,8 +4072,12 @@ export async function cmdDeployCodeBody(flags, emit) {
3918
4072
  onchainUriLegs ? 'on-chain-uri' : '',
3919
4073
  setupMintCount ? 'mints' : '',
3920
4074
  ].filter(Boolean).join('/');
3921
- info(`transactions: ${1 + (setupLen > 0 ? 1 : 0)} (deploy${foldIntoInit ? ' — on-chain-uri wiring + mint fold into it' : ''}${setupLen > 0 ? ` + one setup multicall: ${setupBits}` : ''})` +
3922
- (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')}` : ''));
3923
4081
  info(`approvals ${approvals} wallet approval(s)`); // TX signatures only; the same number the tx-count line above implies
3924
4082
  // Best-effort cost guidance (owner: a bonus in dry-run, never a blocker — silently skip if the
3925
4083
  // RPC can't price gas; see cli-ux-decisions). On-chain byte storage dominates a template drop,
@@ -4105,6 +4263,9 @@ export async function cmdDeployCodeBody(flags, emit) {
4105
4263
  backend: dirUpload ? dirUpload.backend.id : null,
4106
4264
  tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
4107
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,
4108
4269
  },
4109
4270
  mint: { deferred: mintCount === 0, count: mintCount, amountPerId: null, recipient: mintCount > 0 ? deployer : null },
4110
4271
  estimate: planEstimate ?? { ethApprox: null, gasApprox: null },
@@ -4168,47 +4329,21 @@ export async function cmdDeployCodeBody(flags, emit) {
4168
4329
  tokenFields: [],
4169
4330
  contractFields,
4170
4331
  });
4171
- // The setup legs, GROUPED. The normal deploy flattens them; `--resume` diffs them against chain
4172
- // state and sends only what is missing (see resume.ts). One builder for both, so a resume can never
4173
- // drift from what a fresh deploy would have written a second implementation of this sequence is
4174
- // the failure mode a repair verb most easily introduces.
4175
- const setupLegGroups = (owner) => ({
4176
- chunks: scriptChunks.map((chunk, i) => ({
4177
- index: i,
4178
- hex: chunk,
4179
- data: encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setScriptChunk', args: [BigInt(i), chunk] }),
4180
- })),
4181
- schemas: schemas.map(({ key, paramType, auth, authAddress, lockAfter, min, max, selectOptions }) => ({
4182
- key,
4183
- data: encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setParamSchema', args: [encodeTag(key), paramType, auth, authAddress, lockAfter, min, max, selectOptions] }),
4184
- })),
4185
- deps: { count: deps.length, registry: depRegistry ?? null, calls: dependencySetupCalls(deps, depRegistry) },
4186
- // program lane only: animation_url field · the URI renderers (before the mints)
4187
- uri: { calls: onchainUriLegs ?? [], animationField: onchainUriLegs?.length ? F.animationUrl : null },
4188
- // The INTENDED TOTAL, not a count to add — a resume mints the shortfall. `mintCount` covers both
4189
- // lanes: folded-into-init (renderer-only) and setup-carried, since either way it is what the
4190
- // creator asked for.
4191
- mints: { intendedTotal: mintCount, data: encodeFunctionData({ abi: seriesCodeAbi, functionName: 'mint', args: [owner] }) },
4192
- });
4193
- const setupCalls = (owner) => {
4194
- const g = setupLegGroups(owner);
4195
- return [
4196
- ...g.chunks.map((c) => c.data),
4197
- ...g.schemas.map((x) => x.data),
4198
- ...g.deps.calls,
4199
- ...g.uri.calls,
4200
- // folded mints ride init, not here — so this uses setupMintCount, not the intended total.
4201
- ...Array.from({ length: setupMintCount }, () => g.mints.data),
4202
- ];
4203
- };
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.
4204
4336
  const cid = resolveChain(CHAIN).id;
4205
4337
  const preparedFor = async (owner) => {
4206
4338
  const salt = parseSaltFlag(flags.salt) ?? saltFor(owner);
4207
4339
  const clone = (await publicClient.readContract({ address: factory, abi: seriesCodeFactoryAbi, functionName: 'predictDeterministicAddress', args: [salt] }));
4208
- const calls = setupCalls(owner);
4209
- const txs = [prepareDeploySeriesCode({ factory, params: initParamsFor(owner), salt, chainId: cid, clone })];
4210
- if (calls.length)
4211
- 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];
4212
4347
  return { clone, txs };
4213
4348
  };
4214
4349
  // ── --resume: finish an EXISTING contract, deploy nothing ──────────────────────────────────
@@ -4337,7 +4472,9 @@ export async function cmdDeployCodeBody(flags, emit) {
4337
4472
  lane,
4338
4473
  roles: { signer: owner, owner, royaltyReceiver: null, primaryPayee: null, minter: null },
4339
4474
  royalty: null,
4340
- custody: { onChainUri, imageOnChain: null, backend: null, tokenUriBase: null, contractUriBase: 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 },
4341
4478
  mint: null,
4342
4479
  warnings: planWarnings.slice(),
4343
4480
  // Still reflects what THIS invocation's flags say about every marketplace-facing surface —
@@ -4364,19 +4501,46 @@ export async function cmdDeployCodeBody(flags, emit) {
4364
4501
  console.log('');
4365
4502
  return;
4366
4503
  }
4367
- 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, {
4368
4536
  contract: target,
4369
- calls: plan.calls,
4370
4537
  chainId: resolveChain(CHAIN).id,
4371
- chunkCount: plan.sending.chunkIndices.length,
4372
- chunkBytes: plan.sending.chunkBytes,
4373
- schemaKeys: plan.sending.schemaKeys,
4374
4538
  deps: plan.sending.deps ? deps.map((d) => d.display) : [],
4375
- onchainUri: plan.sending.uri,
4376
4539
  });
4377
- // A resume always sends its missing legs as ONE setup-multicall tx one wallet approval — so
4378
- // `transactions.legs` names WHICH groups ride it, from the same `plan.sending` flags that build
4379
- // the tx above (never re-derived).
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).
4380
4544
  const resumeLegs = [
4381
4545
  plan.sending.chunkIndices.length ? 'chunks' : null,
4382
4546
  plan.sending.schemaKeys.length ? 'param-schemas' : null,
@@ -4388,39 +4552,49 @@ export async function cmdDeployCodeBody(flags, emit) {
4388
4552
  // This used to be its own hand-rolled copy of that exact shape (a dry-run print then confirmSend
4389
4553
  // then signTx, duplicated from ownerops.ts's `runWrite`); a deploy-family write is a write like
4390
4554
  // any other, so it takes the identical gate (the preview also gains the `to`/`owner` lines
4391
- // `runWrite`'s preview always printed).
4555
+ // `runWrite`'s preview always printed) — called once per gas-bounded batch, in order.
4392
4556
  if (isDryRun(flags)) {
4393
4557
  emit(jsonSafe({
4394
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,
4395
4559
  plan: {
4396
4560
  ...resumePlanBase,
4397
- transactions: { approvals: 1, legs: resumeLegs },
4561
+ transactions: { approvals: setupTxs.length, legs: resumeLegs },
4398
4562
  estimate: { ethApprox: null, gasApprox: null },
4399
4563
  resume: { target, sentLegs: plan.calls.length, complete: false },
4400
4564
  },
4401
4565
  }));
4402
4566
  }
4403
- const result = await gatedSend(() => tx, flags, { chainKey: CHAIN, expectedSigner: owner });
4404
- if (!result) {
4405
- if (isDryRun(flags))
4406
- return;
4407
- console.log(`\n${dim(' unsigned — broadcast it, then re-run `abx verify ' + target + '` to confirm the setup completed.')}\n`);
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`);
4408
4583
  emit(jsonSafe({
4409
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,
4410
4585
  plan: {
4411
4586
  ...resumePlanBase,
4412
- transactions: { approvals: 1, legs: resumeLegs },
4587
+ transactions: { approvals: setupTxs.length, legs: resumeLegs },
4413
4588
  estimate: { ethApprox: null, gasApprox: null },
4414
4589
  resume: { target, sentLegs: plan.calls.length, complete: false },
4415
4590
  },
4416
4591
  }));
4417
4592
  return;
4418
4593
  }
4419
- ok(`${tx.summary}`);
4420
4594
  // Locals, not the outer clone/deployBlock: this branch never falls through to the deploy path, and
4421
4595
  // the authoritative scan floor is still the clone's CREATION block (getCode search), not this
4422
4596
  // repair tx's block — flooring the resolver above the deploy would hide the `code` field again.
4423
- const resumedFloor = (await discoverDeployBlock(publicClient, target)) ?? result.blockNumber;
4597
+ const resumedFloor = (await discoverDeployBlock(publicClient, target)) ?? lastResult.blockNumber;
4424
4598
  emit(jsonSafe({
4425
4599
  command: 'deploy-code',
4426
4600
  kind: isEdition ? 'edition-code' : 'code',
@@ -4432,10 +4606,10 @@ export async function cmdDeployCodeBody(flags, emit) {
4432
4606
  sent: true,
4433
4607
  complete: true,
4434
4608
  sentLegs: plan.calls.length,
4435
- txHash: result.txHash,
4609
+ txHash: lastResult.txHash,
4436
4610
  plan: {
4437
4611
  ...resumePlanBase,
4438
- transactions: { approvals: 1, legs: resumeLegs },
4612
+ transactions: { approvals: setupTxs.length, legs: resumeLegs },
4439
4613
  estimate: { ethApprox: null, gasApprox: null },
4440
4614
  resume: { target, sentLegs: plan.calls.length, complete: true },
4441
4615
  },
@@ -4563,6 +4737,8 @@ export async function cmdDeployCodeBody(flags, emit) {
4563
4737
  backend: dirUpload ? dirUpload.backend.id : null,
4564
4738
  tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
4565
4739
  contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
4740
+ renderer: onChainUri ? metadataRenderer : null,
4741
+ image: null, // see `surfaces.thumbnail`
4566
4742
  },
4567
4743
  mint: { deferred: mintCount === 0, count: mintCount, amountPerId: null, recipient: mintCount > 0 ? deployerAddr ?? null : null },
4568
4744
  // Unlike the dry run, the sent path computes no cost estimate (the gas-guidance try/catch is
@@ -5004,10 +5180,11 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
5004
5180
  const minter = flags.minter ?? zeroAddress;
5005
5181
  const primaryPayee = flags['primary-payee'] ?? zeroAddress;
5006
5182
  const explicitSalt = parseSaltFlag(flags.salt);
5007
- // Always TWO transactions (deploy + one setup multicall) — this leaner lane skips the 721 code
5008
- // lane's "fold reserve mints + renderers into init" micro-optimization (only reachable there for
5009
- // a renderer-only, no-program drop, which this function refuses anyway — every edition-code
5010
- // 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`.
5011
5188
  const initParamsFor = (owner) => ({
5012
5189
  owner,
5013
5190
  name,
@@ -5033,55 +5210,42 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
5033
5210
  tokenFields: [],
5034
5211
  contractFields,
5035
5212
  });
5036
- const setupCallsFor = (owner) => {
5037
- const calls = [];
5038
- for (let i = 0; i < scriptChunks.length; i++) {
5039
- calls.push(encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setScriptChunk', args: [BigInt(i), scriptChunks[i]] }));
5040
- }
5041
- for (const s of schemas) {
5042
- calls.push(encodeFunctionData({
5043
- abi: seriesCodeAbi,
5044
- functionName: 'setParamSchema',
5045
- args: [encodeTag(s.key), s.paramType, s.auth, s.authAddress, s.lockAfter, s.min, s.max, s.selectOptions],
5046
- }));
5047
- }
5048
- // Dependency legs, ordered (index 0 = the runtime) + the SOFT registry pointer when one resolved.
5049
- // Built by the SDK's shared `dependencySetupCalls` — the same function the 721 lane uses — so the
5050
- // two lanes can never encode `setDependency` differently.
5051
- calls.push(...dependencySetupCalls(deps, depRegistry));
5052
- calls.push(...onchainUriLegs);
5053
- // Reserve mints: one `mint(to, id, amount)` per premint id the edition twin of the 721 lane's
5054
- // N identical `mint(owner)` calls, one id finer (a specific amount of copies, not just "one").
5055
- for (let id = 0; id < mintCount; id++) {
5056
- calls.push(encodeFunctionData({ abi: oneOfOneEditionAbi, functionName: 'mint', args: [owner, BigInt(id), mintAmount] }));
5057
- }
5058
- return calls;
5059
- };
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) });
5060
5232
  const preparedFor = async (owner) => {
5061
5233
  const salt = explicitSalt ?? saltFor(owner);
5062
5234
  const clone = await predictClone(publicClient, { factory, salt });
5063
- const calls = setupCallsFor(owner);
5064
- const txs = [prepareDeployEditionCode({ factory, params: initParamsFor(owner), salt, chainId, clone })];
5065
- if (calls.length) {
5066
- txs.push(prepareCodeSetup({
5067
- contract: clone,
5068
- calls,
5069
- chainId,
5070
- chunkCount: scriptChunks.length,
5071
- chunkBytes: scriptChunks.map((h) => (h.length - 2) / 2),
5072
- schemaKeys: schemas.map((s) => s.key),
5073
- onchainUri: onChainUri,
5074
- }));
5075
- }
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];
5076
5237
  return { clone, txs };
5077
5238
  };
5078
- // deps ride the same single setup multicall as chunks/schemas/mints, so they don't add an approval —
5079
- // but they must be COUNTED in the "is there a setup multicall at all" test, or a deps-only project
5080
- // (a script with no schemas and no premints) would report 1 approval and send 2 transactions.
5081
- const depLegCount = dependencySetupCalls(deps, depRegistry).length;
5082
- const approvals = 1 + (scriptChunks.length + schemas.length + depLegCount + onchainUriLegs.length + mintCount > 0 ? 1 : 0);
5083
- // Ordered legs riding the setup multicall (if any) same booleans `approvals` above uses, so the
5084
- // plan object's `transactions.legs` can never drift from the tx the wallet actually signs.
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.
5085
5249
  const planSetupLegs = approvals > 1
5086
5250
  ? [
5087
5251
  scriptChunks.length ? 'chunks' : null,
@@ -5089,6 +5253,7 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
5089
5253
  depLegCount ? 'dependencies' : null,
5090
5254
  onchainUriLegs.length ? 'onchain-uri' : null,
5091
5255
  mintCount ? 'mints' : null,
5256
+ 'deploy',
5092
5257
  ].filter((leg) => leg !== null)
5093
5258
  : null;
5094
5259
  if (!dryRun) {
@@ -5129,7 +5294,8 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
5129
5294
  printSeedSourcePlan(seedSource, chainId);
5130
5295
  info(`PostParam schema(s): ${schemas.length ? schemas.map((s) => describeSchema(s)).join(', ') : 'none'}`);
5131
5296
  info(`mint: ${mintCount > 0 ? `${mintCount} id(s) × ${mintAmount} cop${mintAmount === 1n ? 'y' : 'ies'} → ${deployer ?? 'your wallet'} at deploy` : 'deferred (mint later / external minter)'}`);
5132
- 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'})`);
5133
5299
  info(`approvals ${approvals} wallet approval(s)`); // parity with the 721 code twin — it had this only in the --confirm sentence
5134
5300
  if (!explicitSalt && deployer) {
5135
5301
  console.log(`\n ${bold('salt')} ${g(salt)}`);
@@ -5155,6 +5321,8 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
5155
5321
  backend: dirUpload ? dirUpload.backend.id : null,
5156
5322
  tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
5157
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)
5158
5326
  },
5159
5327
  mint: {
5160
5328
  deferred: mintCount === 0,
@@ -5254,6 +5422,8 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
5254
5422
  backend: dirUpload ? dirUpload.backend.id : null,
5255
5423
  tokenUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/t`,
5256
5424
  contractUriBase: onChainUri && !hasPublicUrl ? null : `${baseUrl}/c`,
5425
+ renderer: onChainUri ? metadataRenderer : null,
5426
+ image: null,
5257
5427
  },
5258
5428
  mint: {
5259
5429
  deferred: mintCount === 0,