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

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 (44) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/dist/commands/deploy.d.ts.map +1 -1
  3. package/dist/commands/deploy.js +300 -31
  4. package/dist/commands/deploy.js.map +1 -1
  5. package/dist/commands/project.d.ts.map +1 -1
  6. package/dist/commands/project.js +21 -5
  7. package/dist/commands/project.js.map +1 -1
  8. package/dist/commands/reads.d.ts.map +1 -1
  9. package/dist/commands/reads.js +33 -3
  10. package/dist/commands/reads.js.map +1 -1
  11. package/dist/commands/scaffold.d.ts.map +1 -1
  12. package/dist/commands/scaffold.js +17 -9
  13. package/dist/commands/scaffold.js.map +1 -1
  14. package/dist/commands/storage.d.ts.map +1 -1
  15. package/dist/commands/storage.js +11 -3
  16. package/dist/commands/storage.js.map +1 -1
  17. package/dist/flag-allowlists.d.ts +53 -0
  18. package/dist/flag-allowlists.d.ts.map +1 -0
  19. package/dist/flag-allowlists.js +148 -0
  20. package/dist/flag-allowlists.js.map +1 -0
  21. package/dist/flags.d.ts +2 -2
  22. package/dist/flags.d.ts.map +1 -1
  23. package/dist/flags.js.map +1 -1
  24. package/dist/kind.d.ts +8 -3
  25. package/dist/kind.d.ts.map +1 -1
  26. package/dist/kind.js +25 -0
  27. package/dist/kind.js.map +1 -1
  28. package/dist/main.js +41 -16
  29. package/dist/main.js.map +1 -1
  30. package/dist/ownerops.d.ts.map +1 -1
  31. package/dist/ownerops.js +87 -5
  32. package/dist/ownerops.js.map +1 -1
  33. package/dist/remote.d.ts.map +1 -1
  34. package/dist/remote.js +8 -2
  35. package/dist/remote.js.map +1 -1
  36. package/dist/update-check.d.ts +25 -0
  37. package/dist/update-check.d.ts.map +1 -1
  38. package/dist/update-check.js +40 -6
  39. package/dist/update-check.js.map +1 -1
  40. package/package.json +6 -6
  41. package/skill/SKILL.md +7 -5
  42. package/skill/reference/code-projects.md +22 -0
  43. package/skill/reference/decisions.md +26 -0
  44. package/skill/reference/operating.md +3 -0
@@ -1190,13 +1190,15 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1190
1190
  const dryRun = isDryRun(flags);
1191
1191
  const onchainImage = !!flags['onchain-image'];
1192
1192
  // Pure flag-combo validation — checked BEFORE any chain read (predict/factory/renderer all touch
1193
- // the network), same reasoning every other "this combo can never work" refusal in this file uses
1194
- // (e.g. the localhost-URL checks). Sequential staging (chunk tx → deploy) can't be split across
1195
- // the wallet-session/cold lanes in this pass (see the module-level scope note) refuse rather
1196
- // than half-build it.
1197
- if (onchainImage && lane !== 'send') {
1198
- throw new Error('--onchain-image on an edition deploy is wired for the hot lane only right now (a funded ABX_DEPLOYER_PK) — ' +
1199
- 'drop --onchain-image (off-chain custody or --onchain-uri still work on every lane), or switch to the hot lane. Tracked as a follow-up.');
1193
+ // the network), same reasoning every other "this combo can never work" refusal in this file uses.
1194
+ // Staging is a SEQUENCE (chunk write(s)the deploy that references the manifest) where each tx's
1195
+ // receipt feeds the next, so it cannot be signed offline in one run the cold lane is refused on
1196
+ // every lineage, 721 and edition alike. The wallet lane CAN do it (one session signs the chunk
1197
+ // writes and the deploy); this edition path was hot-lane-only purely because it lacked that
1198
+ // session branch, which it now has.
1199
+ if (onchainImage && lane === 'unsigned') {
1200
+ throw new Error('Staging an on-chain image (--onchain-image) needs interactive signing — each chunk tx feeds ' +
1201
+ "the next, so it can't run on the cold lane (--unsigned). Use the hot lane (a funded key) or --sign (browser wallet).");
1200
1202
  }
1201
1203
  if (dryRun)
1202
1204
  assertPreviewDeployer(flags);
@@ -1273,18 +1275,23 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1273
1275
  }
1274
1276
  step(`Deploy an edition of "${name}" to ${CHAIN}`);
1275
1277
  let bakedImage;
1278
+ // On the WALLET lane staging cannot happen here — it has to run inside the sign session, after the
1279
+ // connect, so the connecting wallet pays for and owns every chunk write. `stageNow` is the hot-lane
1280
+ // (and dry-run) path; the session branch below calls the same helper with a session-backed sender.
1281
+ const stageOnChainImage = async (sender) => {
1282
+ const { field, note } = await stageImageField(flags.image, parseCompress(flags.compress), sender);
1283
+ bakedImage = field;
1284
+ info(note);
1285
+ };
1276
1286
  if (onchainImage) {
1277
1287
  if (!flags.image)
1278
1288
  throw new Error('--onchain-image needs --image <path> (the bytes to put on-chain)');
1279
1289
  step('Stage on-chain image');
1280
- if (dryRun) {
1290
+ if (dryRun)
1281
1291
  info(previewImageStaging(flags.image, parseCompress(flags.compress)));
1282
- }
1283
- else {
1284
- const { field, note } = await stageImageField(flags.image, parseCompress(flags.compress), envStagingSender());
1285
- bakedImage = field;
1286
- info(note);
1287
- }
1292
+ else if (lane === 'send')
1293
+ await stageOnChainImage(envStagingSender());
1294
+ // lane === 'sign' staged inside the wallet session further down.
1288
1295
  }
1289
1296
  const explicitSalt = parseSaltFlag(flags.salt);
1290
1297
  const noMint = flags['no-mint'] !== undefined;
@@ -1295,6 +1302,21 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1295
1302
  const minter = flags.minter ?? zeroAddress;
1296
1303
  const primaryPayee = flags['primary-payee'] ?? zeroAddress;
1297
1304
  const paused = flags.unpaused === undefined;
1305
+ // Same wallet-signature math as the 721 twin (see `cmdDeployBody`): staging tx(s) + the deploy tx.
1306
+ // The edition paths shipped without this and without the readout line below, so `--copies` previews
1307
+ // silently dropped the approval count the skill promises "every preview" prints — leaving an agent
1308
+ // with nothing to tell the creator about how many wallet prompts to expect.
1309
+ const approvals = onchainImage
1310
+ ? (() => {
1311
+ const plan = computeContentPlan(readFileSync(resolvePath(flags.image)), parseCompress(flags.compress)).plan;
1312
+ return (plan.mode === 'single' ? 1 : plan.txCount) + 1;
1313
+ })()
1314
+ : 1;
1315
+ if (onChainUri) {
1316
+ info(`uri()/contractURI() resolve ON-CHAIN via the renderer — no resolver, no server, no localhost.`);
1317
+ if (!hasPublicUrl)
1318
+ info('off-chain fallback pointer left empty (the renderer is authoritative); set --public-base-url to bake one anyway.');
1319
+ }
1298
1320
  const buildForDeployer = async (deployer) => {
1299
1321
  const salt = explicitSalt ?? saltFor(deployer);
1300
1322
  const clone = await predictClone(publicClient, { factory, salt });
@@ -1367,6 +1389,7 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1367
1389
  if (primaryPayee !== zeroAddress)
1368
1390
  info(`primary payee ${primaryPayee}`);
1369
1391
  info(`paused ${paused}${flags.unpaused ? '' : dim(' (default — pass --unpaused to open at deploy)')}`);
1392
+ info(`approvals ${approvals} wallet approval(s)`); // parity with the 721 twin — TX signatures only
1370
1393
  if (!explicitSalt) {
1371
1394
  console.log(`\n ${bold('salt')} ${g(salt)}`);
1372
1395
  info(`address: pinned by salt — re-run with ${bold(`--salt ${salt}`)} (same address), or ${bold(`abx predict --salt ${salt} --for ${deployer} --copies ${flags.copies}`)}.`);
@@ -1376,7 +1399,7 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1376
1399
  emit(jsonSafe({ command: 'deploy', kind: '1of1-edition', copies: editionSize.toString(), dryRun: true, sent: false, address: explicitSalt ? predicted : null, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt, saltPinned: !!explicitSalt, name, symbol }));
1377
1400
  return;
1378
1401
  }
1379
- await confirmSend(`About to deploy an edition of "${name}" (${symbol}) — ${editionSize === 0n ? 'open' : editionSize.toString()} copies${flags.image ? ' with your image' : ' (generative demo art)'}; mint: ${mintAmount > 0n ? `${mintAmount} at deploy` : 'deferred'}; owner+royalty: your wallet @ ${royaltyBps / 100}%.` +
1402
+ await confirmSend(`About to deploy an edition of "${name}" (${symbol}) — ${editionSize === 0n ? 'open' : editionSize.toString()} copies${flags.image ? ' with your image' : ' (generative demo art)'}; mint: ${mintAmount > 0n ? `${mintAmount} at deploy` : 'deferred'}; approvals: ${approvals} wallet approval(s); owner+royalty: your wallet @ ${royaltyBps / 100}%.` +
1380
1403
  (transferValidator !== zeroAddress ? ` ERC-1155C: enrolled at deploy, permanently (validator ${transferValidator}).` : ''), flags);
1381
1404
  if (lane === 'send') {
1382
1405
  const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
@@ -1394,8 +1417,39 @@ export async function cmdDeployOneOfOneEditionBody(flags, emit) {
1394
1417
  ok(`deployed ${clone}`);
1395
1418
  info(`tx ${explorerBase()}/tx/${r.txHash} (block ${blockNumber})`);
1396
1419
  }
1420
+ else if (lane === 'sign' && onchainImage) {
1421
+ // Wallet lane + on-chain staging: ONE sign session signs every chunk write AND the deploy, so the
1422
+ // connecting wallet pays for and owns all of it. Staging can't precede the connect, so it runs
1423
+ // inside the session and the deploy then bakes in the resulting manifest field.
1424
+ // `--onchain-image` implies on-chain resolution, so nothing points at baseUrl — mirrors the 721 1/1.
1425
+ info('a wallet will become the owner; the edition resolves from chain — no URI base is baked in.');
1426
+ const session = await openWalletSession({
1427
+ chainKey: CHAIN,
1428
+ expectedSigner: flags.for,
1429
+ total: approvals, // the SAME staging-tx math the preview's `approvals` line reports
1430
+ port: flags.port ? Number(flags.port) : undefined,
1431
+ signUrlFile: flags['sign-url-file'],
1432
+ });
1433
+ let r;
1434
+ try {
1435
+ const signer = await session.connect();
1436
+ await stageOnChainImage(sessionStagingSender(session)); // sets bakedImage, read by buildForDeployer
1437
+ const { clone: predicted, params, salt, contentNote } = await buildForDeployer(signer);
1438
+ info(contentNote);
1439
+ info(mintAmount > 0n ? `mint: ${mintAmount} cop${mintAmount === 1n ? 'y' : 'ies'} of #0 → ${signer} at deploy` : 'mint: deferred — mint later with `abx mint`');
1440
+ const sent = await session.send(prepareDeployOneOfOneEdition({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted }));
1441
+ r = { txHash: sent.txHash, blockNumber: sent.receipt.blockNumber };
1442
+ clone = predicted;
1443
+ }
1444
+ finally {
1445
+ session.close();
1446
+ }
1447
+ blockNumber = r.blockNumber;
1448
+ ok(`deployed ${clone}`);
1449
+ info(`tx ${explorerBase()}/tx/${r.txHash} (block ${blockNumber})`);
1450
+ }
1397
1451
  else {
1398
- // wallet lane (no on-chain-image staging — refused above) or the cold lane: a single deploy tx.
1452
+ // wallet lane without staging, or the cold lane: a single deploy tx.
1399
1453
  info(onChainUri ? 'a wallet will become the owner; the token resolves from chain — no URI base is baked in.' : `a wallet will become the owner; URIs point at ${baseUrl}`);
1400
1454
  const result = await signTx(async (signer) => {
1401
1455
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
@@ -1570,6 +1624,10 @@ export async function cmdDeploySeriesBody(flags, emit) {
1570
1624
  if (onChainUri) {
1571
1625
  step('On-chain renderer');
1572
1626
  renderer = dryRun ? (rendererAddress(flags.renderer) ?? zeroAddress) : await ensureRenderer(flags.renderer);
1627
+ // Say WHICH renderer, like the 1/1 lane does. Both series lanes printed the step header with
1628
+ // nothing under it, while the skill's confirm-readout template implies an address is quotable
1629
+ // there — so an agent relaying the readout had a blank line to explain.
1630
+ info(renderer === zeroAddress ? 'would deploy the canonical renderer first' : `${dryRun ? 'would use' : 'using'} renderer ${renderer}`);
1573
1631
  }
1574
1632
  // Off-chain custody bakes the resolver URL on-chain — a localhost URL resolves for no one.
1575
1633
  // (Not a concern with --onchain-uri: the renderer is authoritative and resolves from chain.)
@@ -1607,6 +1665,16 @@ export async function cmdDeploySeriesBody(flags, emit) {
1607
1665
  const offchainImageOnchainJson = onChainUri && !onchainImage && directUrlBackend;
1608
1666
  const backend = (offchainImageOnchainJson || !onChainUri) && !dryRun ? resolveBackend(opts) : undefined;
1609
1667
  const tokenPaths = slots.map((s, i) => ({ tokenId: i, name: s, path: joinPath(dirPath, s) }));
1668
+ // Say WHICH backend holds the bytes, exactly as the 1/1 lane does. Both Series lanes resolved a
1669
+ // backend and never named it, so a creator asking for "images on IPFS" had no way to confirm from
1670
+ // the preview that their collection would actually pin there — the flag was accepted in silence.
1671
+ if (!onchainImage && (offchainImageOnchainJson || !onChainUri)) {
1672
+ const sr = backendResolution(overrides);
1673
+ info(`storage: ${sr.backend} ${dim(`(${sr.source === 'flag' ? '--backend' : sr.source === 'env' ? 'env' : 'default'})`)} — byte custody for the image(s)`);
1674
+ const gwWarn = localGatewayWarning(flags);
1675
+ if (gwWarn)
1676
+ warn(gwWarn);
1677
+ }
1610
1678
  // Wallet-signature count for THIS deploy — the same per-token chunk math that sizes the
1611
1679
  // wallet-lane session `total` below (B2), computed ONCE so preview/confirm text and the real
1612
1680
  // session can never disagree. TX signatures only — see the 1/1's `approvals` for the same note
@@ -2019,9 +2087,6 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2019
2087
  : `EditionImage (ERC-1155) — N ids from the folder × ${editionSize} cop${editionSize === 1n ? 'y' : 'ies'} each`)}`));
2020
2088
  if (editionSize === 1n)
2021
2089
  info(copiesOneNote('deploy-series'));
2022
- if (flags['onchain-image']) {
2023
- throw new Error('--onchain-image is not yet supported for edition deploys (--copies) — use --onchain-uri (inline SVG) or off-chain custody (--backend ipfs|arweave|cloud) instead. Tracked as a follow-up.');
2024
- }
2025
2090
  if (!flags.dir) {
2026
2091
  throw new Error('abx deploy-series --copies <n|open> --dir <media-dir> [--count N] [--mint-all | --mint-count N | --no-mint] [--mint-amount N] ' +
2027
2092
  '[--onchain-uri (inline SVG) | --backend ipfs|arweave|cloud (off-chain, hosted resolver) | --public-base-url https://…] ' +
@@ -2044,6 +2109,15 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2044
2109
  assertPreviewDeployer(flags);
2045
2110
  assertRealIdentity(flags, { name, symbol, dryRun });
2046
2111
  const lane = laneFromFlags(flags);
2112
+ const onchainImage = !!flags['onchain-image'];
2113
+ const compress = parseCompress(flags.compress);
2114
+ // Staging is a SEQUENCE (chunk writes → the deploy that references each manifest) where every tx's
2115
+ // receipt feeds the next, so it can't be signed offline in one run. Refused on the cold lane for the
2116
+ // same reason on every lineage, 721 and edition alike; hot + wallet both work.
2117
+ if (onchainImage && lane === 'unsigned') {
2118
+ throw new Error('Staging on-chain images (--onchain-image) needs interactive signing — each chunk tx feeds ' +
2119
+ "the next, so it can't run on the cold lane (--unsigned). Use the hot lane (a funded key) or --sign (browser wallet).");
2120
+ }
2047
2121
  const publicClient = makePublicClient({ chainKey: CHAIN });
2048
2122
  await assertChainId(CHAIN, { allowUnreachable: dryRun });
2049
2123
  const transferValidator = await resolveTransferValidatorFlag(flags, publicClient, dryRun, '1155C');
@@ -2060,7 +2134,12 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2060
2134
  if (effectiveMintCount > 0 && mintAmount === 0n) {
2061
2135
  throw new Error('--mint-amount 0 with ids being pre-minted at deploy makes no sense — pass a positive --mint-amount, or drop --mint-all/--mint-count to defer minting entirely.');
2062
2136
  }
2063
- const onChainUri = !!flags['onchain-uri'];
2137
+ // `--onchain-image` IMPLIES on-chain resolution — bytes on-chain behind a renderer that has no
2138
+ // reason to defer to a server. Every sibling lane already reads it that way (the 1/1, the
2139
+ // 1/1-edition, the 721 Series); this one didn't, which was harmless only while `--onchain-image`
2140
+ // was refused here. Now that it's wired, omitting it would demand a `--public-base-url` for a drop
2141
+ // whose bytes are already on-chain — incoherent, and it would bake a resolver URL nobody needs.
2142
+ const onChainUri = !!flags['onchain-uri'] || onchainImage;
2064
2143
  const minter = flags.minter ?? zeroAddress;
2065
2144
  const paused = flags.unpaused === undefined;
2066
2145
  const primaryPayee = flags['primary-payee'] ?? zeroAddress;
@@ -2087,6 +2166,10 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2087
2166
  if (onChainUri) {
2088
2167
  step('On-chain renderer');
2089
2168
  renderer = dryRun ? (rendererAddress(flags.renderer) ?? zeroAddress) : await ensureRenderer(flags.renderer);
2169
+ // Say WHICH renderer, like the 1/1 lane does. Both series lanes printed the step header with
2170
+ // nothing under it, while the skill's confirm-readout template implies an address is quotable
2171
+ // there — so an agent relaying the readout had a blank line to explain.
2172
+ info(renderer === zeroAddress ? 'would deploy the canonical renderer first' : `${dryRun ? 'would use' : 'using'} renderer ${renderer}`);
2090
2173
  }
2091
2174
  if (!onChainUri) {
2092
2175
  const loopback = loopbackBaseUrl(baseUrl);
@@ -2109,22 +2192,118 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2109
2192
  const overrides = storageOverrides(flags);
2110
2193
  const opts = storageOptions(overrides);
2111
2194
  const backendId = overrides.backend ?? process.env.ABX_STORAGE_BACKEND ?? 'fs';
2112
- const backend = !onChainUri && !dryRun ? resolveBackend(opts) : undefined;
2195
+ // Pattern 2 — image off-chain on a durable backend, JSON rendered on-chain, NOTHING to run. Ported
2196
+ // from the 721 Series twin, which has had it all along; `EditionImage` shipped without it, so a
2197
+ // multi-artwork RASTER edition had no no-server option at all and `--onchain-uri` just refused the
2198
+ // art. That pushed a cold agent into deploying one contract PER artwork to get permanence (B28).
2199
+ // Nothing on-chain needed changing: `EditionImageInitParams` already takes `contractFields`, and the
2200
+ // renderer's `url-template` substitutes `{id}` from whatever id it is called with — `uri(id)` on a
2201
+ // 1155 reaches it exactly as `tokenURI(id)` does on a 721.
2202
+ const directUrlBackend = backendId === 'ipfs' || backendId === 'arweave' || backendId === 'cloud';
2203
+ const offchainImageOnchainJson = onChainUri && !onchainImage && directUrlBackend;
2204
+ const backend = (offchainImageOnchainJson || (!onChainUri && !onchainImage)) && !dryRun ? resolveBackend(opts) : undefined;
2113
2205
  const tokenPaths = slots.map((s, i) => ({ tokenId: i, name: s, path: joinPath(dirPath, s) }));
2206
+ // Say WHICH backend holds the bytes, exactly as the 1/1 lane does. Both Series lanes resolved a
2207
+ // backend and never named it, so a creator asking for "images on IPFS" had no way to confirm from
2208
+ // the preview that their collection would actually pin there — the flag was accepted in silence.
2209
+ if (!onchainImage && (offchainImageOnchainJson || !onChainUri)) {
2210
+ const sr = backendResolution(overrides);
2211
+ info(`storage: ${sr.backend} ${dim(`(${sr.source === 'flag' ? '--backend' : sr.source === 'env' ? 'env' : 'default'})`)} — byte custody for the image(s)`);
2212
+ const gwWarn = localGatewayWarning(flags);
2213
+ if (gwWarn)
2214
+ warn(gwWarn);
2215
+ }
2114
2216
  const seriesTraits = parseSeriesTraits(flags.attributes ? readFileSync(resolvePath(process.cwd(), flags.attributes), 'utf8') : undefined, slots);
2115
2217
  const seriesTraitsOnchain = seriesTraits.size > 0 && (!!flags['traits-onchain'] || onChainUri);
2116
2218
  const offChainTokenTraits = seriesTraits.size && !seriesTraitsOnchain
2117
2219
  ? JSON.stringify(Object.fromEntries([...seriesTraits].map(([id, a]) => [String(id), a])))
2118
2220
  : undefined;
2221
+ // Wallet-signature count for THIS deploy — parity with the 721 Series twin, which prints it and
2222
+ // whose count the skill promises "every preview" shows. Same per-id chunk math as that twin, so the
2223
+ // preview, the confirm text, and the wallet session's `total` can never disagree.
2224
+ const approvals = onchainImage
2225
+ ? tokenPaths.reduce((n, { path }) => {
2226
+ const plan = computeContentPlan(readFileSync(path), compress).plan;
2227
+ return n + (plan.mode === 'single' ? 1 : plan.txCount);
2228
+ }, 0) + 1 // + the deploy tx
2229
+ : 1;
2119
2230
  const tokenFields = [];
2120
- const buildFields = async () => {
2231
+ // Collection-scope fields the image lane may add (the O(1) `url-template`). Kept separate from the
2232
+ // authorship fields and CONCATENATED at the params site — never assigned over them, or an
2233
+ // `--artist`/`--license` value would silently vanish whenever pattern 2 is in play.
2234
+ let imageContractFields = [];
2235
+ const buildFields = async (stage) => {
2236
+ // Pattern 1: the bytes themselves on-chain, staged into the shared chunk store per id, each id's
2237
+ // field pointing at its manifest. Ported from the 721 Series twin — the edition lane refused this
2238
+ // outright before (B28), though nothing on-chain prevented it.
2239
+ if (onchainImage) {
2240
+ const { fields } = await stageImageFieldsBatch(tokenPaths.map((s) => s.path), compress, stage, flags['chunk-store']);
2241
+ tokenPaths.forEach(({ tokenId }, i) => tokenFields.push(tokenFieldOf(tokenId, fields[i])));
2242
+ if (seriesTraitsOnchain)
2243
+ for (const [tokenId, attrs] of seriesTraits)
2244
+ tokenFields.push(tokenFieldOf(tokenId, attributesInlineField(attrs)));
2245
+ info(`uri()/contractURI() resolve ON-CHAIN via the renderer — the image BYTES are on-chain too, nothing off-chain at all.`);
2246
+ return;
2247
+ }
2248
+ // Pattern 2: one durable upload per id, then a locator on-chain instead of the bytes.
2249
+ if (offchainImageOnchainJson) {
2250
+ const exts = tokenPaths.map(({ path }) => extname(path).toLowerCase());
2251
+ const uniform = exts[0] !== '' && exts.every((e) => e === exts[0]);
2252
+ if (dryRun || !backend) {
2253
+ if (uniform) {
2254
+ imageContractFields = [imageUrlTemplateField(`<${backendId}-dir>/{id}${exts[0]}`)];
2255
+ info(`would upload ${tokenPaths.length} file(s) as ONE ${backendId} directory → collection image url-template <${backendId}-dir>/{id}${exts[0]} (O(1))`);
2256
+ }
2257
+ else {
2258
+ for (const { tokenId } of tokenPaths)
2259
+ tokenFields.push(tokenFieldOf(tokenId, imageUrlField(`<${backendId}-url-${tokenId}>`)));
2260
+ info(`would upload ${tokenPaths.length} file(s) to ${backendId} → per-id url image fields (mixed extensions, O(N))`);
2261
+ }
2262
+ }
2263
+ else if (uniform && backend.putDirectory) {
2264
+ // O(1) directory-base: upload the folder renamed to {id}{ext}; ONE collection url-template.
2265
+ const entries = tokenPaths.map(({ tokenId, path }) => ({ name: `${tokenId}${exts[0]}`, bytes: new Uint8Array(readFileSync(path)), contentType: contentTypeFromPath(path) }));
2266
+ const { base } = await backend.putDirectory(entries);
2267
+ const template = `${base}/{id}${exts[0]}`;
2268
+ imageContractFields = [imageUrlTemplateField(template)];
2269
+ info(`uploaded ${entries.length} file(s) as one ${backendId} directory`);
2270
+ info(`collection image (url-template): ${template} ${dim('— one field covers every id (O(1))')}`);
2271
+ }
2272
+ else {
2273
+ // mixed extensions (or no directory support): per-id url fields (O(N)).
2274
+ for (const { tokenId, name: fname, path } of tokenPaths) {
2275
+ const bytes = new Uint8Array(readFileSync(path));
2276
+ const hash = hashContent(bytes);
2277
+ await backend.put(hash, { bytes, contentType: contentTypeFromPath(path) });
2278
+ const locator = (await backend.locator?.(hash)) ?? '';
2279
+ if (!locator)
2280
+ throw new Error(`backend ${backendId} returned no public locator for id ${tokenId} — a durable backend (ipfs/arweave/cloud) is required for --onchain-uri image hosting`);
2281
+ tokenFields.push(tokenFieldOf(tokenId, imageUrlField(locator)));
2282
+ info(`id ${tokenId} ← ${fname} → ${locator}`);
2283
+ }
2284
+ info(`mixed file extensions → per-id url fields (O(N)); a uniform extension enables the O(1) directory template`);
2285
+ }
2286
+ if (seriesTraitsOnchain)
2287
+ for (const [tokenId, attrs] of seriesTraits)
2288
+ tokenFields.push(tokenFieldOf(tokenId, attributesInlineField(attrs)));
2289
+ info(`uri()/contractURI() resolve ON-CHAIN via the renderer — the image bytes live on ${backendId}, so there is still no server to run.`);
2290
+ return;
2291
+ }
2121
2292
  for (const { tokenId, name: fname, path } of tokenPaths) {
2122
2293
  const bytes = new Uint8Array(readFileSync(path));
2123
2294
  const contentType = contentTypeFromPath(path);
2124
2295
  if (onChainUri) {
2125
2296
  const text = Buffer.from(bytes).toString('utf8');
2126
2297
  if (!looksLikeSvg(text)) {
2127
- throw new Error(`--onchain-uri inlines SVG only; id ${tokenId} "${fname}" is ${contentType}. Drop --onchain-uri for off-chain custody instead.`);
2298
+ // Name the real alternatives, the way the 721 twin's version of this refusal does a bare
2299
+ // "drop --onchain-uri" once sent a cold agent fanning a 3-artwork edition out into THREE
2300
+ // separate contracts to find permanence. The `--backend` route is now the first suggestion
2301
+ // because it IS available on this lane (see `offchainImageOnchainJson` above).
2302
+ throw new Error(`--onchain-uri inlines SVG only; id ${tokenId} "${fname}" is ${contentType}. For raster art in ONE edition contract:\n` +
2303
+ ` • No server, images off-chain: add --backend arweave (or ipfs/cloud) — the image URL is baked into the on-chain JSON\n` +
2304
+ ` • Host the metadata yourself: drop --onchain-uri and pass --public-base-url https://your.domain\n` +
2305
+ ` • Keep it SVG: supply SVG art and --onchain-uri inlines every id on-chain\n` +
2306
+ ` • Bytes fully on-chain: --onchain-image --compress fastlz (hot or --sign lane; --unsigned is refused)`);
2128
2307
  }
2129
2308
  tokenFields.push(tokenFieldOf(tokenId, imageInlineField(text)));
2130
2309
  }
@@ -2139,14 +2318,24 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2139
2318
  if (seriesTraitsOnchain)
2140
2319
  for (const [tokenId, attrs] of seriesTraits)
2141
2320
  tokenFields.push(tokenFieldOf(tokenId, attributesInlineField(attrs)));
2321
+ // AFTER the per-id content actually validated — stating "resolves ON-CHAIN, no server" and then
2322
+ // refusing the very art that was passed reads as the tool contradicting itself.
2323
+ if (onChainUri) {
2324
+ info(`uri()/contractURI() resolve ON-CHAIN via the renderer — no resolver, no server, no localhost.`);
2325
+ if (!hasPublicUrl)
2326
+ info('off-chain fallback pointer left empty (the renderer is authoritative); set --public-base-url to bake one anyway.');
2327
+ }
2142
2328
  };
2143
2329
  if (!dryRun) {
2144
2330
  const custody = onChainUri ? 'inline SVG on-chain' : `off-chain custody (${backendId})`;
2145
2331
  await confirmSend(`About to deploy an edition series "${name}" (${symbol}) — ${count} id(s) × ${editionSize === 0n ? 'open' : editionSize.toString()} cop${editionSize === 1n ? 'y' : 'ies'} each; ${custody}; ` +
2146
- `mint: ${effectiveMintCount > 0 ? `${effectiveMintCount} id(s) × ${mintAmount} at deploy` : 'deferred'}; owner+royalty: your wallet @ ${royaltyBps / 100}%.` +
2332
+ `mint: ${effectiveMintCount > 0 ? `${effectiveMintCount} id(s) × ${mintAmount} at deploy` : 'deferred'}; approvals: ${approvals} wallet approval(s); owner+royalty: your wallet @ ${royaltyBps / 100}%.` +
2147
2333
  (transferValidator !== zeroAddress ? ` ERC-1155C: enrolled at deploy, permanently (validator ${transferValidator}).` : ''), flags);
2148
2334
  }
2149
- await buildFields();
2335
+ // `--onchain-image` defers field-building to a lane branch below: the hot lane stages with the env
2336
+ // key, the wallet lane inside its sign session. Every other custody mode can build up front.
2337
+ if (!onchainImage)
2338
+ await buildFields();
2150
2339
  const buildForDeployer = async (deployer) => {
2151
2340
  const salt = explicitSalt ?? saltFor(deployer);
2152
2341
  const clone = await predictClone(publicClient, { factory, salt });
@@ -2170,7 +2359,9 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2170
2359
  mintCount: effectiveMintCount,
2171
2360
  mintAmount,
2172
2361
  tokenFields,
2173
- contractFields: authorshipContractFields(flags),
2362
+ // Concatenate — the image lane's collection-scope url-template rides ALONGSIDE the authorship
2363
+ // fields. Assigning either over the other would silently drop the creator's --artist/--license.
2364
+ contractFields: [...authorshipContractFields(flags), ...imageContractFields],
2174
2365
  };
2175
2366
  return { clone, params, salt };
2176
2367
  };
@@ -2204,6 +2395,10 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2204
2395
  info(`minter ${minter}`);
2205
2396
  if (primaryPayee !== zeroAddress)
2206
2397
  info(`primary payee ${primaryPayee}`);
2398
+ // `paused` gates whether the public can mint at all — the 1/1-edition twin prints it and an
2399
+ // edition sale is the point of the lane, so it belongs in the readout, not just in the params.
2400
+ info(`paused ${paused}${flags.unpaused ? '' : dim(' (default — pass --unpaused to open at deploy)')}`);
2401
+ info(`approvals ${approvals} wallet approval(s)`); // parity with the 721 Series twin
2207
2402
  if (!explicitSalt) {
2208
2403
  console.log(`\n ${bold('salt')} ${g(salt)}`);
2209
2404
  info(`address: pinned by salt — re-run with ${bold(`--salt ${salt}`)} (same address).`);
@@ -2214,6 +2409,9 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2214
2409
  }
2215
2410
  if (lane === 'send') {
2216
2411
  const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
2412
+ // hot lane: the env key stages every id up front (deployer-independent), then deploys.
2413
+ if (onchainImage)
2414
+ await buildFields(envStagingSender());
2217
2415
  const { clone: predicted, params, salt } = await buildForDeployer(account.address);
2218
2416
  info(`deterministic address: ${predicted}`);
2219
2417
  info(`mint: ${effectiveMintCount > 0 ? `${effectiveMintCount} id(s) × ${mintAmount} cop${mintAmount === 1n ? 'y' : 'ies'} → ${account.address} at deploy` : 'deferred'}`);
@@ -2224,6 +2422,35 @@ export async function cmdDeployEditionImageBody(flags, emit) {
2224
2422
  ok(`deployed ${clone}`);
2225
2423
  info(`tx ${explorerBase()}/tx/${r.txHash} (block ${blockNumber})`);
2226
2424
  }
2425
+ else if (lane === 'sign' && onchainImage) {
2426
+ // Wallet lane + on-chain staging: ONE session signs every chunk write across all ids AND the
2427
+ // deploy. The connecting wallet pays for (and owns) it; staging can't precede the connect, so it
2428
+ // runs inside the session and the deploy then bakes in every id's manifest.
2429
+ info(`a wallet will become the owner; it will approve ${approvals - 1} staging tx(s) + the deploy in one session.`);
2430
+ const session = await openWalletSession({
2431
+ chainKey: CHAIN,
2432
+ expectedSigner: flags.for,
2433
+ total: approvals, // the SAME staging-tx math the preview's `approvals` line reports
2434
+ port: flags.port ? Number(flags.port) : undefined,
2435
+ signUrlFile: flags['sign-url-file'],
2436
+ });
2437
+ let r;
2438
+ try {
2439
+ const signer = await session.connect();
2440
+ await buildFields(sessionStagingSender(session));
2441
+ const { clone: predicted, params, salt } = await buildForDeployer(signer);
2442
+ info(`mint: ${effectiveMintCount > 0 ? `${effectiveMintCount} id(s) × ${mintAmount} cop${mintAmount === 1n ? 'y' : 'ies'} → ${signer} at deploy` : 'deferred'}`);
2443
+ const sent = await session.send(prepareDeployEditionImage({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted }));
2444
+ r = { txHash: sent.txHash, blockNumber: sent.receipt.blockNumber };
2445
+ clone = predicted;
2446
+ }
2447
+ finally {
2448
+ session.close();
2449
+ }
2450
+ blockNumber = r.blockNumber;
2451
+ ok(`deployed ${clone}`);
2452
+ info(`tx ${explorerBase()}/tx/${r.txHash} (block ${blockNumber})`);
2453
+ }
2227
2454
  else {
2228
2455
  info(onChainUri ? 'a wallet will become the owner; the tokens resolve from chain — no URI base is baked in.' : `a wallet will become the owner; URIs point at ${baseUrl}`);
2229
2456
  const result = await signTx(async (signer) => {
@@ -3657,10 +3884,6 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
3657
3884
  }
3658
3885
  if (!flags.script)
3659
3886
  throw new Error(usage);
3660
- if (flags.dep || flags['dep-registry']) {
3661
- throw new Error('--dep/--dep-registry are not yet supported for edition code deploys (--copies) — ship a self-contained script ' +
3662
- '(no external dependency declarations) for now, or drop --copies for the 721 lane. Tracked as a follow-up.');
3663
- }
3664
3887
  if (flags['image-renderer'] || flags['attributes-renderer'] || flags['image-base']) {
3665
3888
  throw new Error('--image-renderer/--attributes-renderer/--image-base are not yet supported for edition code deploys (--copies). Tracked as a follow-up.');
3666
3889
  }
@@ -3763,6 +3986,43 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
3763
3986
  info('off-chain fallback pointer left empty (the renderer is authoritative); set --public-base-url to bake one anyway.');
3764
3987
  }
3765
3988
  const schemas = parseSchemaSpecs(flags.schema);
3989
+ // Dependencies — same shape as the 721 SeriesCode twin (see its block for the full reasoning).
3990
+ // `EditionCode` already inherits the `Dependencies` extension and calls `_initDependencies()`, and
3991
+ // the legs are the same `setDependency`/`setDependencyRegistry` calls on the same ABI, so this was
3992
+ // never a contract gap: only the CLI refused it (B28). It matters because "an edition of my p5
3993
+ // sketch, with p5 coming from the chain" is a thing creators ask for directly, and the refusal sent
3994
+ // them to the 721 lane (unique tokens) or to shipping a sketch whose library never loads.
3995
+ const deps = parseDepFlag(flags.dep);
3996
+ const hasRegistryDeps = deps.some((d) => d.resolution === DEP_RESOLUTION.registry);
3997
+ const depPointer = hasRegistryDeps
3998
+ ? resolveDepRegistryPointer(flags['dep-registry'], chainId)
3999
+ : { registry: null, source: 'none' };
4000
+ const depRegistry = depPointer.registry;
4001
+ if (deps.length) {
4002
+ step('Dependencies');
4003
+ deps.forEach((d, i) => info(`[${i}] ${bold(d.display)} ${dim(d.resolution === DEP_RESOLUTION.registry ? '(registry name@version)' : '(on-chain data contract — read directly)')}${i === 0 ? dim(' · index 0 = the runtime') : ''}`));
4004
+ if (hasRegistryDeps) {
4005
+ if (depRegistry) {
4006
+ info(`registry pointer → ${depRegistry} ${dim(depPointer.source === 'flag' ? '(--dep-registry)' : "(the chain's AB Dependency Registry — soft, non-validating)")}`);
4007
+ const { checks, rpcOk } = await checkRegistryDeps(publicClient, depRegistry, deps);
4008
+ for (const chk of checks) {
4009
+ if (chk.status === 'found') {
4010
+ ok(chk.details.availableOnChain
4011
+ ? `${chk.dep} — on registry; ON-CHAIN bytes available (${chk.details.scriptCount} chunk(s)) — chain-complete capable`
4012
+ : `${chk.dep} — on registry; served from CDN ${chk.details.preferredCDN || '(none listed)'} ${dim('— the normal production path, not a degradation')}`);
4013
+ }
4014
+ else if (chk.status === 'not-found') {
4015
+ warn(`${bold(chk.dep)} NOT FOUND on registry ${depRegistry} — the resolver won't resolve it from there. Deploy proceeds (you may intend a custom registry / a pending addition); double-check the exact name@version spelling.`);
4016
+ }
4017
+ }
4018
+ if (!rpcOk)
4019
+ info('registry check skipped (RPC unreachable) — the deploy does not depend on it.');
4020
+ }
4021
+ else {
4022
+ warn('no dependency registry known for this chain — skipping the setDependencyRegistry leg (the pointer is SOFT; the resolver falls back to its built-in CDN map). Pass --dep-registry 0x… or run `abx set-dependency-registry` later.');
4023
+ }
4024
+ }
4025
+ }
3766
4026
  step('Content');
3767
4027
  const scriptPath = flags.script;
3768
4028
  const source = readFileSync(resolvePath(scriptPath), 'utf8');
@@ -3823,6 +4083,10 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
3823
4083
  args: [encodeTag(s.key), s.paramType, s.auth, s.authAddress, s.lockAfter, s.min, s.max, s.selectOptions],
3824
4084
  }));
3825
4085
  }
4086
+ // Dependency legs, ordered (index 0 = the runtime) + the SOFT registry pointer when one resolved.
4087
+ // Built by the SDK's shared `dependencySetupCalls` — the same function the 721 lane uses — so the
4088
+ // two lanes can never encode `setDependency` differently.
4089
+ calls.push(...dependencySetupCalls(deps, depRegistry));
3826
4090
  calls.push(...onchainUriLegs);
3827
4091
  // Reserve mints: one `mint(to, id, amount)` per premint id — the edition twin of the 721 lane's
3828
4092
  // N identical `mint(owner)` calls, one id finer (a specific amount of copies, not just "one").
@@ -3849,7 +4113,11 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
3849
4113
  }
3850
4114
  return { clone, txs };
3851
4115
  };
3852
- const approvals = 1 + (scriptChunks.length + schemas.length + onchainUriLegs.length + mintCount > 0 ? 1 : 0);
4116
+ // deps ride the same single setup multicall as chunks/schemas/mints, so they don't add an approval
4117
+ // but they must be COUNTED in the "is there a setup multicall at all" test, or a deps-only project
4118
+ // (a script with no schemas and no premints) would report 1 approval and send 2 transactions.
4119
+ const depLegCount = dependencySetupCalls(deps, depRegistry).length;
4120
+ const approvals = 1 + (scriptChunks.length + schemas.length + depLegCount + onchainUriLegs.length + mintCount > 0 ? 1 : 0);
3853
4121
  if (!dryRun) {
3854
4122
  await confirmSend(`About to deploy an edition code project "${name}" (${symbol}) — script ${bytes.length} bytes → ${scriptChunks.length} chunk(s); ` +
3855
4123
  `up to ${max} id(s) × ${editionSize === 0n ? 'open' : editionSize.toString()} cop${editionSize === 1n ? 'y' : 'ies'} each; ` +
@@ -3885,6 +4153,7 @@ export async function cmdDeployEditionCodeBody(flags, emit) {
3885
4153
  info(`PostParam schema(s): ${schemas.length ? schemas.map((s) => describeSchema(s)).join(', ') : 'none'}`);
3886
4154
  info(`mint: ${mintCount > 0 ? `${mintCount} id(s) × ${mintAmount} cop${mintAmount === 1n ? 'y' : 'ies'} → ${deployer ?? 'your wallet'} at deploy` : 'deferred (mint later / external minter)'}`);
3887
4155
  info(`transactions: 2 — deploy + setup multicall (script chunks${schemas.length ? ' + schemas' : ''}${onChainUri ? ' + on-chain URI wiring' : ''}${mintCount > 0 ? ' + reserve mints' : ''})`);
4156
+ info(`approvals ${approvals} wallet approval(s)`); // parity with the 721 code twin — it had this only in the --confirm sentence
3888
4157
  if (!explicitSalt && deployer) {
3889
4158
  console.log(`\n ${bold('salt')} ${g(salt)}`);
3890
4159
  info(`address: pinned by salt — re-run with ${bold(`--salt ${salt}`)} (same address).`);