@artblocks/abx-cli 0.1.0-alpha.14 → 0.1.0-alpha.16

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.
package/dist/main.js CHANGED
@@ -58,11 +58,12 @@ import { encodeFunctionData } from 'viem';
58
58
  import { DEP_RESOLUTION, deploySeedSource, deploySeriesCodeFactory, prepareCodeSetup, prepareDeploySeriesCode, resolveGenerator, resolveSeedSource, resolveSeriesCodeFactory, seriesCodeAbi, readParamSchema, seriesCodeFactoryAbi, } from '@artblocks/abx-sdk';
59
59
  import { checkRegistryDeps, dependencySetupCalls, parseDepFlag, resolveDepRegistryPointer } from './deps.js';
60
60
  import { expectedChainComplete, hasOnChainUriLane, onchainUriSetupCalls, onChainUriReport, readLegacyParamsKeys, readParamSchemaKeys, readSetParamKeys, } from './onchain-uri.js';
61
- import { parseFlags, unknownFlags } from './flags.js';
61
+ import { parseFlags, positionalArgs, unknownFlags } from './flags.js';
62
62
  import { AGENT_SKILL_PARENTS, checkForCliUpdate, compareVersions, installedSkillVersions, readCliVersion, readSkillVersion, SKILL_DIR_NAME, } from './update-check.js';
63
63
  import { analyzeScript, recommendLane } from './inspect.js';
64
64
  import { previewConfigFromFlags, previewDepTags, parsePreviewParams, shootPreview, startPreviewServer, DEFAULT_PREVIEW_PORT, PREVIEW_FLAGS } from './preview.js';
65
65
  import { parseSchemaSpecs, describeSchema } from './schema.js';
66
+ import { copyRendererScaffold } from './scaffold.js';
66
67
  import { declinesSkillInstall } from './prompt.js';
67
68
  import { pinGas, waitForCodeAt } from './gas.js';
68
69
  import { parseSeriesTraits, looksPerTokenAttributes, parseSeriesTraitsById } from './series-traits.js';
@@ -74,11 +75,11 @@ const SCHEMA_CATALOG = 'Types: Bool·Select·Uint256Range·Int256Range·DecimalR
74
75
  'Select needs options — key:Select[A|B|C]:Auth; a Range takes bounds — key:Uint256Range[0..100]:Auth. ' +
75
76
  'A palette collectors set = palette:HexColor:TokenOwner';
76
77
  import { uploadAndLocate } from './upload.js';
77
- import { assertChainId, discoverDeployBlock, deployFactory, deploySeriesFactory, deploySeries, deployOneOfOne, deployRenderer, predictRenderer, predictSeedSource, encodeTag, encodeFieldRenderer, isCodeProject, loadDotEnv, makePublicClient, makeWalletClient, oneOfOneImageAbi, oneOfOneImageFactoryAbi, seriesImageAbi, seriesImageFactoryAbi, abxMetadataRendererAbi, predictClone, probeRpcEndpoints, prepareDeployOneOfOne, prepareDeploySeries, reconstructProject, saltFor, saltGuard, resolveChain, resolveRpcUrl, redactRpcUrl, explorerUrl, DEFAULT_CHAIN_KEY, KNOWN_CHAIN_KEYS, normalizeAttributes, parseTraitPairs, METADATA_FIELD as F, METADATA_REPRESENTATION as R, indexProgress, isAccepted, AbxIndexTimeoutError, AbxServiceError, } from '@artblocks/abx-sdk';
78
+ import { assertChainId, discoverDeployBlock, deployFactory, deploySeriesFactory, deploySeries, deployOneOfOne, deployRenderer, predictRenderer, predictSeedSource, encodeTag, encodeFieldRenderer, isCodeProject, loadDotEnv, makePublicClient, makeWalletClient, oneOfOneImageAbi, oneOfOneImageFactoryAbi, seriesImageAbi, seriesImageFactoryAbi, abxMetadataRendererAbi, predictClone, probeRpcEndpoints, prepareDeployOneOfOne, prepareDeploySeries, reconstructProject, saltFor, saltGuard, resolveChain, resolveRpcUrl, redactRpcUrl, explorerUrl, DEFAULT_CHAIN_KEY, KNOWN_CHAIN_KEYS, normalizeAttributes, parseTraitPairs, resolveRecommendedTransferValidator, CREATOR_TOKEN_INTERFACE_ID, METADATA_FIELD as F, METADATA_REPRESENTATION as R, indexProgress, isAccepted, AbxIndexTimeoutError, AbxServiceError, } from '@artblocks/abx-sdk';
78
79
  import { SelfHostIndexer, SqliteStore } from '@artblocks/abx-indexer';
79
80
  import { artContentHash, currentRenderArtifact, generateArt, resolveBaseUrl, startChainWatcher, startTokenApiServer, verifyProject, watchIntervalMs, DEFAULT_PORT, } from '@artblocks/abx-token-api';
80
81
  import { ARWEAVE_FREE_UPLOAD_LIMIT, arweaveAddress, arweaveFunding, contentTypeFromPath, hashContent, resolveBackend, turboBalanceForAddress, turboUploadCostUsd, turboUploadWinc } from '@artblocks/abx-storage';
81
- import { cmdTransfer, cmdMint, cmdSetMinter, cmdSetMaxInvocations, cmdConfigureParam, cmdSetSchema, cmdRetireParam, cmdSetParamHooks, cmdSetDependency, cmdRemoveLastDependency, cmdSetDependencyRegistry, cmdLockDependencies, cmdSetPrimaryPayee, cmdPause, cmdUnpause, cmdRefresh, cmdSetTokenUri, cmdSetContractUri, cmdSetRoyalty, cmdSetField, cmdAttach, cmdLockField, cmdSetRenderer, cmdLockUri, cmdSetAdmin, cmdMinterConfigure, cmdMinterShow, cmdMinterBuy, computeContentPlan, envStagingSender, laneFromFlags, ONCHAIN_PROJECT_SOFT_LIMIT, parseCompress, previewImageStaging, sessionStagingSender, stageImageField, stageImageFieldsBatch, authorshipContractFields, AUTHORSHIP_DEPLOY_FIELDS, } from './ownerops.js';
82
+ import { cmdTransfer, cmdMint, cmdSetMinter, cmdSetMaxInvocations, cmdConfigureParam, cmdSetSchema, cmdRetireParam, cmdSetParamHooks, cmdSetDependency, cmdRemoveLastDependency, cmdSetDependencyRegistry, cmdLockDependencies, cmdSetPrimaryPayee, cmdPause, cmdUnpause, cmdRefresh, cmdSetTokenUri, cmdSetContractUri, cmdSetRoyalty, cmdSetTransferValidator, cmdSetField, cmdAttach, parseTransferValidatorValue, cmdLockField, cmdSetRenderer, cmdLockUri, cmdSetAdmin, cmdMinterConfigure, cmdMinterShow, cmdMinterBuy, computeContentPlan, envStagingSender, laneFromFlags, ONCHAIN_PROJECT_SOFT_LIMIT, parseCompress, previewImageStaging, sessionStagingSender, stageImageField, stageImageFieldsBatch, authorshipContractFields, AUTHORSHIP_DEPLOY_FIELDS, } from './ownerops.js';
82
83
  import { openWalletSession, signTx } from './signer.js';
83
84
  import { describeRemoteError, listConfiguredRemotes, misnamedRemoteVars, requireRemoteToken, resolveRemote, serviceClient, tokenSourceLabel } from './remote.js';
84
85
  import { buildMigrationPlan, repinNodeCustody, verifyParity } from './migrate.js';
@@ -161,9 +162,16 @@ async function maybeNotifyUpdate(flags) {
161
162
  }
162
163
  const latest = await checkForCliUpdate(current);
163
164
  if (latest) {
165
+ // The notes pointer must RESOLVE. This printed github.com/ArtBlocks/abx/releases, which 404s
166
+ // for everyone outside the org (the repo is private) — so a tester reconstructed the diff by
167
+ // running the same dry run on both versions, which is how they discovered the canonical
168
+ // singletons had moved and then had no way to tell whether that needed them to act. The
169
+ // packaged CHANGELOG.md ships with every install (see package.json `files`) and carries the
170
+ // real per-release notes, so it's readable offline and always matches the version you have.
164
171
  console.error(`\n ${c.orange}⚠${c.reset} update available: ${bold('abx')} ${dim(current)} → ${g(latest)}\n` +
165
172
  ` upgrade: ${g('npm i -g @artblocks/abx-cli@latest')} ${dim('· or invoke:')} ${g('npx @artblocks/abx-cli@latest <command>')}\n` +
166
- ` release notes: https://github.com/ArtBlocks/abx/releases ${dim('· silence: ABX_NO_UPDATE_CHECK=1')}\n`);
173
+ ` release notes: ${g('abx changelog')} ${dim('(ships with the CLI) · all versions: https://www.npmjs.com/package/@artblocks/abx-cli?activeTab=versions')}\n` +
174
+ ` ${dim('silence: ABX_NO_UPDATE_CHECK=1')}\n`);
167
175
  }
168
176
  }
169
177
  catch {
@@ -212,13 +220,14 @@ async function main() {
212
220
  case 'inspect': return cmdInspect(rest[0], flags);
213
221
  case 'preview': return cmdPreview(flags);
214
222
  case 'scaffold-renderer': return cmdScaffoldRenderer(rest, flags);
223
+ case 'changelog': return cmdChangelog(flags);
215
224
  case 'predict': return cmdPredict(flags);
216
225
  case 'add': return cmdAdd(rest[0], flags);
217
226
  case 'index': return cmdIndex(rest[0], flags);
218
227
  case 'verify': return cmdVerify(rest[0], flags);
219
228
  case 'render': return cmdRender(rest[0], rest.slice(1), flags);
220
229
  case 'effects': return cmdEffects(flags);
221
- case 'tokenuri': return cmdTokenUri(rest[0], flags);
230
+ case 'tokenuri': return cmdTokenUri(rest[0], flags, rest.slice(1));
222
231
  case 'contracturi': return cmdContractUri(rest[0], flags);
223
232
  case 'serve': return cmdServe(flags);
224
233
  // owner operations — write + sign (hot/wallet/cold lane), then re-index
@@ -245,6 +254,7 @@ async function main() {
245
254
  case 'deploy-effects': return cmdDeployEffects(flags);
246
255
  case 'mint-page': return cmdMintPage(rest[0], flags);
247
256
  case 'set-royalty': return cmdSetRoyalty(rest[0], flags);
257
+ case 'set-transfer-validator': return cmdSetTransferValidator(rest[0], rest.slice(1), flags);
248
258
  case 'set-field': return cmdSetField(rest[0], flags);
249
259
  case 'attach': return cmdAttach(rest, flags);
250
260
  case 'lock-field': return cmdLockField(rest[0], flags);
@@ -445,6 +455,7 @@ function deployCommandLine(flags, salt) {
445
455
  str('compress', flags.compress);
446
456
  str('royalty-bps', flags['royalty-bps']);
447
457
  bool('no-mint');
458
+ str('721c', flags['721c'] === 'true' ? 'recommended' : flags['721c']); // bare --721c ≡ recommended
448
459
  str('backend', flags.backend);
449
460
  str('gateway', flags.gateway);
450
461
  str('bucket', flags.bucket);
@@ -488,6 +499,7 @@ function deploySeriesCommandLine(flags, salt) {
488
499
  str('minter', flags.minter);
489
500
  str('primary-payee', flags['primary-payee']);
490
501
  str('royalty-bps', flags['royalty-bps']);
502
+ str('721c', flags['721c'] === 'true' ? 'recommended' : flags['721c']); // bare --721c ≡ recommended
491
503
  if (flags.sign !== undefined)
492
504
  parts.push('--sign');
493
505
  if (flags.unsigned !== undefined)
@@ -528,6 +540,7 @@ function deployCodeCommandLine(flags, salt) {
528
540
  str('minter', flags.minter);
529
541
  str('primary-payee', flags['primary-payee']);
530
542
  str('royalty-bps', flags['royalty-bps']);
543
+ str('721c', flags['721c'] === 'true' ? 'recommended' : flags['721c']); // bare --721c ≡ recommended
531
544
  str('backend', flags.backend);
532
545
  str('gateway', flags.gateway);
533
546
  str('public-base-url', flags['public-base-url']);
@@ -1198,6 +1211,8 @@ async function cmdDeploy(flags, serveAfter) {
1198
1211
  ` Nothing was deployed. Stop that one (Ctrl-C), or run this on another port: \`abx demo --port ${wanted + 1}\`.`);
1199
1212
  }
1200
1213
  }
1214
+ // (demo keeps its own message: "Nothing was deployed" is the load-bearing part here, and it must
1215
+ // be true — this check runs before anything irreversible. assertPortFree covers serve/preview.)
1201
1216
  const dryRun = !serveAfter && !!flags['dry-run']; // preview only — no send, no custody, no factory deploy
1202
1217
  // A keyless preview needs `--for` (the address is a pure function of factory+salt+deployer). Check
1203
1218
  // it HERE, before the trust-anchor/content/plan steps print — hitting this after a wall of output
@@ -1210,6 +1225,8 @@ async function cmdDeploy(flags, serveAfter) {
1210
1225
  // a wrong-network RPC must still be caught with the clear mismatch message rather than failing
1211
1226
  // opaquely inside predict; `allowUnreachable` keeps a genuinely offline dry-run previewable.
1212
1227
  await assertChainId(CHAIN, { allowUnreachable: dryRun });
1228
+ // --721c (opt-in ERC-721C): absent → zeroAddress, a plain ERC-721 exactly as before.
1229
+ const transferValidator = await resolveTransferValidatorFlag(flags, publicClient, dryRun);
1213
1230
  // `demo` is exempt: its whole job is a zero-argument first token.
1214
1231
  if (!serveAfter)
1215
1232
  assertRealIdentity(flags, { name, symbol, dryRun });
@@ -1458,6 +1475,7 @@ async function cmdDeploy(flags, serveAfter) {
1458
1475
  contractURIRenderer: renderer,
1459
1476
  royaltyReceiver: deployer,
1460
1477
  royaltyBps,
1478
+ transferValidator,
1461
1479
  tokenFields,
1462
1480
  // authorship + rights (artist / license / …) are collection-scope on-chain fields.
1463
1481
  contractFields: authorshipContractFields(flags),
@@ -1512,7 +1530,8 @@ async function cmdDeploy(flags, serveAfter) {
1512
1530
  return;
1513
1531
  }
1514
1532
  // opt-in --confirm: a final y/N before the real send (no-op without --confirm; never blocks scripts)
1515
- await confirmSend(`About to deploy 1/1 "${name}" (${symbol})${flags.image ? ' with your image' : ' (generative demo art)'}; mint: ${noMint ? 'deferred' : 'token #0 at deploy'}; owner+royalty: your wallet @ ${royaltyBps / 100}%.`, flags);
1533
+ await confirmSend(`About to deploy 1/1 "${name}" (${symbol})${flags.image ? ' with your image' : ' (generative demo art)'}; mint: ${noMint ? 'deferred' : 'token #0 at deploy'}; owner+royalty: your wallet @ ${royaltyBps / 100}%.` +
1534
+ (transferValidator !== zeroAddress ? ` ERC-721C: enrolled at deploy, permanently (validator ${transferValidator}).` : ''), flags);
1516
1535
  if (lane === 'send') {
1517
1536
  // hot lane: the env key is the deployer, known up front.
1518
1537
  const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
@@ -1767,7 +1786,7 @@ async function cmdDeploySeries(flags) {
1767
1786
  if (!flags.dir) {
1768
1787
  throw new Error('abx deploy-series --dir <media-dir> [--count N] [--mint-all | --mint-count N | --no-mint] ' +
1769
1788
  '[--onchain-image [--compress fastlz] | --onchain-uri --backend ipfs|arweave|cloud (image off-chain, JSON on-chain; cloud needs --public-base) | --onchain-uri (inline SVG) | --public-base-url https://… (hosted resolver)] ' +
1770
- '[--minter 0x..] [--primary-payee 0x..] [--unpaused] --name "Title" --symbol SYM');
1789
+ '[--minter 0x..] [--primary-payee 0x..] [--unpaused] [--721c recommended|0x..] --name "Title" --symbol SYM');
1771
1790
  }
1772
1791
  const dirPath = resolvePath(flags.dir);
1773
1792
  const files = readdirSync(dirPath)
@@ -1790,6 +1809,8 @@ async function cmdDeploySeries(flags) {
1790
1809
  // Catch a wrong-network RPC with the clear mismatch message even on dry-run (which still reads
1791
1810
  // the chain to predict the address); tolerate an unreachable RPC so an offline preview still works.
1792
1811
  await assertChainId(CHAIN, { allowUnreachable: dryRun });
1812
+ // --721c (opt-in ERC-721C): absent → zeroAddress, a plain ERC-721 exactly as before.
1813
+ const transferValidator = await resolveTransferValidatorFlag(flags, publicClient, dryRun);
1793
1814
  // (identity guard already ran above, via the shared assertRealIdentity — before any RPC)
1794
1815
  // Mint timing: mint-all → the whole series; mint-count N → the first N; else deferred.
1795
1816
  const mintCount = flags['mint-all'] !== undefined ? count : flags['mint-count'] ? Number(flags['mint-count']) : 0;
@@ -2004,7 +2025,8 @@ async function cmdDeploySeries(flags) {
2004
2025
  : onChainUri
2005
2026
  ? 'inline SVG on-chain'
2006
2027
  : `off-chain custody → ${baseUrl}`;
2007
- await confirmSend(`About to deploy Series "${name}" (${symbol}) — ${count} token(s); ${custody}; mint: ${mintCount > 0 ? `${mintCount} at deploy` : 'deferred'}; owner+royalty: your wallet @ ${royaltyBps / 100}%.`, flags);
2028
+ await confirmSend(`About to deploy Series "${name}" (${symbol}) — ${count} token(s); ${custody}; mint: ${mintCount > 0 ? `${mintCount} at deploy` : 'deferred'}; owner+royalty: your wallet @ ${royaltyBps / 100}%.` +
2029
+ (transferValidator !== zeroAddress ? ` ERC-721C: enrolled at deploy, permanently (validator ${transferValidator}).` : ''), flags);
2008
2030
  }
2009
2031
  if (!onchainImage && !remoteEthUpload)
2010
2032
  await buildFields();
@@ -2023,6 +2045,7 @@ async function cmdDeploySeries(flags) {
2023
2045
  contractURIRenderer: renderer,
2024
2046
  royaltyReceiver: deployer,
2025
2047
  royaltyBps,
2048
+ transferValidator,
2026
2049
  maxInvocations: count,
2027
2050
  primaryPayee,
2028
2051
  minter,
@@ -2869,6 +2892,21 @@ async function walkthroughReadBack(state, baseUrl, onChainUri) {
2869
2892
  }
2870
2893
  console.log(` ${dim('prove the bytes match the chain:')} ${bold(`abx verify ${state.address}`)}`);
2871
2894
  }
2895
+ /**
2896
+ * Refuse a port that's already bound, with a formatted one-liner naming the port and the fix.
2897
+ *
2898
+ * Without this, `listen()` has no `'error'` handler and EADDRINUSE reaches Node's default handler:
2899
+ * the creator gets a raw stack trace through `node:net` and our own `dist/` paths, which reads as a
2900
+ * crash inside abx rather than "something else is on this port" — and it's the one unformatted error
2901
+ * surface in a CLI where every other error is formatted. `preview`'s default port colliding with a
2902
+ * studio left running in another terminal was reported as especially hard to diagnose.
2903
+ */
2904
+ async function assertPortFree(port, cmd) {
2905
+ if (!(await portInUse(port)))
2906
+ return;
2907
+ throw new Error(`port ${port} is already in use — most likely an \`abx ${cmd}\`/\`abx serve\`/\`abx preview\` still running in another terminal.\n` +
2908
+ ` Stop that one (Ctrl-C), or run this on a different port: \`abx ${cmd} --port ${port + 1}\`.`);
2909
+ }
2872
2910
  /** Is a TCP port already bound on localhost? Used to preflight a serve BEFORE spending a tx. */
2873
2911
  async function portInUse(port) {
2874
2912
  const { createServer } = await import('node:net');
@@ -2945,7 +2983,11 @@ async function cmdPreview(flags) {
2945
2983
  if (cfg.deps.some((d) => d.display.startsWith('0x'))) {
2946
2984
  warn('an on-chain data-contract dep is NOT loaded in preview — the sketch will run without it here. Use a name@version ref to preview against the CDN copy.');
2947
2985
  }
2948
- const server = await startPreviewServer(cfg, shootDir ? 0 : Number(flags.port ?? DEFAULT_PREVIEW_PORT));
2986
+ // `--shoot` takes an ephemeral port (0), so only the studio lane can collide.
2987
+ const previewPort = shootDir ? 0 : Number(flags.port ?? DEFAULT_PREVIEW_PORT);
2988
+ if (previewPort !== 0)
2989
+ await assertPortFree(previewPort, 'preview');
2990
+ const server = await startPreviewServer(cfg, previewPort);
2949
2991
  if (shootDir) {
2950
2992
  step(`Render ${count} seeds headlessly`);
2951
2993
  try {
@@ -3092,7 +3134,7 @@ const DEPLOY_CODE_FLAGS = new Set([
3092
3134
  ...AUTHORSHIP_DEPLOY_FIELDS.map(([flag]) => flag), // artist · display-notes · artist-links · license
3093
3135
  'image-base', 'attributes-renderer', 'image-renderer',
3094
3136
  // supply + mint + economics
3095
- 'max', 'mint-count', 'mint-all', 'no-mint', 'unpaused', 'minter', 'primary-payee', 'royalty-bps',
3137
+ 'max', 'mint-count', 'mint-all', 'no-mint', 'unpaused', 'minter', 'primary-payee', 'royalty-bps', '721c',
3096
3138
  // params + dependencies
3097
3139
  'schema', 'no-seed', 'no-delegation', 'dep', 'dep-registry',
3098
3140
  // serving lane
@@ -3108,6 +3150,7 @@ const DEPLOY_CODE_FLAGS = new Set([
3108
3150
  const SHARED_DEPLOY_FLAGS = [
3109
3151
  'backend', 'endpoint', 'bucket', 'region', 'prefix', 'public-base', 'gateway', 'mode', 'api-url', 'upload-url', 'provider', 'storage-signer',
3110
3152
  'sign', 'unsigned', 'for', 'sign-url-file', 'dry-run', 'confirm', 'yes', 'salt', 'factory', 'bootstrap-factory', 'port', 'renderer', 'public-base-url',
3153
+ '721c', // opt-in ERC-721C enrollment (recommended | 0x…) — every deploy path takes it
3111
3154
  ];
3112
3155
  // A 1/1 `abx deploy` accepts these (see `abx help deploy`). Anything else warns (typo'd/unsupported).
3113
3156
  const DEPLOY_FLAGS = new Set([
@@ -3188,7 +3231,7 @@ async function cmdDeployCode(flags) {
3188
3231
  '[--description "<s>"] [--external-url <url>] [--image-base <url> | --image-renderer 0x..] [--attributes-renderer 0x..] ' +
3189
3232
  '[--max N] [--mint-count N | --mint-all] [--schema key:Type:Auth,…] [--no-seed] ' +
3190
3233
  '[--dep <name@version|0x..>[,…]] [--dep-registry 0x..] ' +
3191
- '[--unpaused] [--minter 0x..] [--primary-payee 0x..] [--royalty-bps N] [--backend ipfs|arweave] [--dry-run] [--confirm] [--bootstrap-factory]';
3234
+ '[--unpaused] [--minter 0x..] [--primary-payee 0x..] [--royalty-bps N] [--721c recommended|0x..] [--backend ipfs|arweave] [--dry-run] [--confirm] [--bootstrap-factory]';
3192
3235
  const scriptPath = flags.script;
3193
3236
  const codeDir = flags['code-dir'];
3194
3237
  const hasProgram = !!(scriptPath || codeDir); // a JS program (script or built dir)
@@ -3261,6 +3304,8 @@ async function cmdDeployCode(flags) {
3261
3304
  // chain to resolve the factory/renderer and predict the address); tolerate an offline preview.
3262
3305
  await assertChainId(CHAIN, { allowUnreachable: dryRun });
3263
3306
  const chainId = dryRun ? resolveChain(CHAIN).id : (await publicClient.getChainId());
3307
+ // --721c (opt-in ERC-721C): absent → zeroAddress, a plain ERC-721 exactly as before.
3308
+ const transferValidator = await resolveTransferValidatorFlag(flags, publicClient, dryRun);
3264
3309
  step('Trust anchor');
3265
3310
  let factory;
3266
3311
  if (dryRun) {
@@ -3666,7 +3711,8 @@ async function cmdDeployCode(flags) {
3666
3711
  `dependencies: ${deps.length ? deps.map((d) => d.display).join(', ') + (depRegistry ? ` (registry ${depRegistry})` : '') : 'none'}; ` +
3667
3712
  `mint: ${mintCount > 0 ? `${mintCount} at deploy${foldIntoInit ? ' (in the deploy tx)' : ''}` : 'deferred'}; ` +
3668
3713
  `tx count: ${foldIntoInit ? (schemas.length ? '2 (deploy + enable your params)' : '1 (everything in the deploy tx)') : '2 (deploy + setup)'}; ` +
3669
- `owner+royalty: ${flags.for ? `pinned to ${String(flags.for)}` : flags.sign !== undefined ? '⚠ the wallet you connect (NOT pinned — pass --for to enforce)' : 'your wallet'} @ ${royaltyBps / 100}%.\n` +
3714
+ `owner+royalty: ${flags.for ? `pinned to ${String(flags.for)}` : flags.sign !== undefined ? '⚠ the wallet you connect (NOT pinned — pass --for to enforce)' : 'your wallet'} @ ${royaltyBps / 100}%.` +
3715
+ (transferValidator !== zeroAddress ? ` ERC-721C: enrolled at deploy, permanently (validator ${transferValidator}).` : '') + `\n` +
3670
3716
  ` Surfaces — thumbnail: ${imageDisposition}. traits: ${traitsDisposition}. postparams: ${paramsDisposition}.` +
3671
3717
  ((imageOrphaned || traitsBroken || undeclaredParams.length)
3672
3718
  ? `\n ⚠ One or more surfaces resolve to NOTHING marketplaces can see — these are DEPLOY-TIME decisions, not backfillable. Re-run --dry-run after fixing, or pass --yes to ship as-is.`
@@ -3784,7 +3830,13 @@ async function cmdDeployCode(flags) {
3784
3830
  catch { /* gas pricing unavailable — cost guidance is a bonus, skip silently */ }
3785
3831
  // ── Surfaces — every marketplace-facing dimension, resolved NOW (none is backfillable) ──────
3786
3832
  const renderHome = process.env.ABX_STORAGE_BACKEND || 'fs'; // empty string counts as unset (=fs)
3787
- const anySurfaceBroken = imageOrphaned || traitsBroken || undeclaredParams.length > 0;
3833
+ // A surface is BROKEN only when it resolves to nothing a marketplace can see. A dropped param is
3834
+ // NOT that: the token still renders, the param just takes its default. Folding it in here made
3835
+ // the block contradict itself two lines apart — "thumbnail: ON-CHAIN ✓ / traits: on-chain ✓"
3836
+ // followed by "one or more surfaces resolve to NOTHING", re-recommending the very flags that
3837
+ // were already set. The alarm now covers only the surfaces it can honestly speak for, and names
3838
+ // only the remedies for what is actually broken.
3839
+ const anySurfaceBroken = imageOrphaned || traitsBroken;
3788
3840
  step('Surfaces — what marketplaces will see (all DEPLOY-TIME; not backfillable)');
3789
3841
  (imageOrphaned ? warn : info)(`thumbnail: ${imageDisposition}`);
3790
3842
  // Render-mode / render-home guidance is for the RENDERED-still lanes only. The in-chain
@@ -3802,12 +3854,25 @@ async function cmdDeployCode(flags) {
3802
3854
  (traitsBroken ? warn : info)(`traits: ${traitsDisposition}`);
3803
3855
  const paramsNudge = !scriptAnalysis && !schemas.length && (hasImageRenderer || hasAttributesRenderer);
3804
3856
  ((undeclaredParams.length || paramsNudge ? warn : info))(`postparams: ${paramsDisposition}`);
3805
- if (anySurfaceBroken)
3806
- warn(`${bold('one or more surfaces resolve to NOTHING a marketplace can see')} — fix before deploy (these are on-chain decisions you can't add later without a re-point tx): thumbnail ⇒ ${bold('--image-renderer <Solidity SVG>')} / ${bold('--image-base <public bucket>')} / a resolver · traits ⇒ ${bold('--attributes-renderer')} or a resolver · dropped params ⇒ ${bold('--schema')}.`);
3807
- // The runner/verify line is for rendered-still lanes. Fully in-chain (image on-chain, no program)
3808
- // has nothing to run point at the from-chain check instead.
3809
- if (hasImageRenderer && !hasProgram)
3810
- info(` ${g('nothing to run')} verify from chain: ${bold('abx tokenuri ' + (predicted ?? '<address>'))} ${dim('(decodes name + on-chain SVG + traits)')}`);
3857
+ if (anySurfaceBroken) {
3858
+ const remedies = [
3859
+ imageOrphaned && `thumbnail ${bold('--image-renderer <Solidity SVG>')} / ${bold('--image-base <public bucket>')} / a resolver`,
3860
+ traitsBroken && `traits ${bold('--attributes-renderer')} or a resolver`,
3861
+ ].filter(Boolean).join(' · ');
3862
+ const which = imageOrphaned && traitsBroken ? 'the thumbnail and traits surfaces resolve' : `the ${imageOrphaned ? 'thumbnail' : 'traits'} surface resolves`;
3863
+ warn(`${bold(`${which} to NOTHING a marketplace can see`)} — fix before deploy (a deploy-time decision you can't add later without a re-point tx): ${remedies}.`);
3864
+ }
3865
+ // A dropped param is its own, milder problem: the piece renders, that input just takes its
3866
+ // default. Kept separate from the broken-surface alarm above (see the note there).
3867
+ if (undeclaredParams.length)
3868
+ info(` ${dim('dropped params render with their defaults — declare them to make them real:')} ${bold('--schema <key>:<Type>:<Auth>')}`);
3869
+ // The runner/verify line is for lanes that need an off-chain STILL. An on-chain image renderer has
3870
+ // no still to render, host, or refresh — whether or not there's also a program driving
3871
+ // animation_url — so pointing at a runner and a bucket backend there is simply wrong.
3872
+ if (hasImageRenderer) {
3873
+ info(` ${g('nothing to render')} — the thumbnail is computed on-chain${hasProgram ? ' and the animation assembles on-chain from your script' : ''}; no runner, no bucket, no refresh. ` +
3874
+ `verify from chain: ${bold('abx tokenuri ' + (predicted ?? '<address>'))} ${dim('(decodes name + on-chain SVG + traits)')}`);
3875
+ }
3811
3876
  else {
3812
3877
  const remoteRender = !(onChainUri && hasImageBase);
3813
3878
  info(` ${dim('stand up the runner:')} ${bold('abx deploy-effects --resolver-url ' + baseUrl)} · one-shot: ${bold('abx render ' + (predicted ?? '<address>') + (remoteRender ? ' --remote ' + baseUrl : ''))} · verify: ${bold('abx verify ' + (predicted ?? '<address>'))}`);
@@ -3863,6 +3928,7 @@ async function cmdDeployCode(flags) {
3863
3928
  contractURIRenderer: foldIntoInit ? metadataRenderer : zeroAddress,
3864
3929
  royaltyReceiver: owner,
3865
3930
  royaltyBps,
3931
+ transferValidator,
3866
3932
  maxInvocations: BigInt(max),
3867
3933
  primaryPayee: flags['primary-payee'] ?? zeroAddress,
3868
3934
  minter: flags.minter ?? zeroAddress,
@@ -4132,6 +4198,31 @@ async function cmdRender(address, tokenIds, flags) {
4132
4198
  warn(`${summary}\n ${dim(stats.errors[0] ?? 'see error above')}`);
4133
4199
  else
4134
4200
  ok(summary);
4201
+ if (stats.ran)
4202
+ noteArweavePropagation(flags);
4203
+ }
4204
+ /**
4205
+ * After an Arweave publish, say that a fresh 404 at the gateway is PROPAGATION, not a failed render.
4206
+ *
4207
+ * `arweave.net` lags Turbo uploads by minutes: a tester found 32/32 of their renders 404ing there
4208
+ * while Turbo reported CONFIRMED and 22/32 already served fine from other ar.io gateways. The bytes
4209
+ * were never in doubt — only the gateway was behind. Unexplained, a "broken" thumbnail on a fresh
4210
+ * drop reads as a failed render, and the natural next move is `abx render --force` on all of them:
4211
+ * a full re-upload that fixes nothing.
4212
+ *
4213
+ * Note this is advisory only, and deliberately so — the locator is baked into what the resolver
4214
+ * registers at publish time, so it cannot be repaired by a redirect later. Choosing the gateway is
4215
+ * the operator's call (`ABX_ARWEAVE_GATEWAY`), which is why this names it.
4216
+ */
4217
+ function noteArweavePropagation(flags) {
4218
+ const opts = storageOptions(storageOverrides(flags));
4219
+ if (resolveBackend(opts).id !== 'arweave')
4220
+ return;
4221
+ const gateway = opts.arweave?.gateway ?? 'https://arweave.net';
4222
+ if (!/(^|\/\/)([^/]*\.)?arweave\.net/.test(gateway))
4223
+ return; // a gateway they chose — don't lecture
4224
+ info(`${dim('arweave: the locator points at')} ${gateway}${dim(', which can 404 for several minutes after upload while it catches up. That is PROPAGATION, not a failed render — the bytes are already confirmed. Do NOT re-run with')} ${bold('--force')}${dim('; the URL starts working on its own.')}`);
4225
+ info(` ${dim('to bake a different gateway into the locator instead (it is fixed at publish time):')} ${bold('ABX_ARWEAVE_GATEWAY=https://<gateway>')} ${dim('before you render.')}`);
4135
4226
  }
4136
4227
  /**
4137
4228
  * The publish topology's one hard prerequisite, checked BEFORE any capture.
@@ -4344,14 +4435,27 @@ async function cmdVerify(address, flags) {
4344
4435
  // `abx verify <addr> --remote <resolver>` probes what the resolver actually serves (the truthful check).
4345
4436
  if (minted.length)
4346
4437
  info(dim(`render check is against THIS node's store; for a HOSTED drop use \`abx verify ${address} --remote <resolver>\``));
4438
+ // ONE line per outcome, not per token. This printed the same full-sentence advisory 32 times on a
4439
+ // 32-token project (~4KB of identical text) and pushed the four lines that answer "did my deploy
4440
+ // work" off the top of the screen; at a 1000-token supply it is unreadable. The per-token detail
4441
+ // that survives is the token LIST, which is the only part that differs.
4442
+ const missing = [];
4443
+ let present = 0;
4347
4444
  for (const token of minted) {
4348
4445
  const { found } = await currentRenderArtifact(client, state, token, storageForRender, 'image');
4349
4446
  if (found)
4350
- ok(`token #${token.tokenId} image: real render present (in this node's store)`);
4351
- else {
4352
- renderGap = true;
4353
- console.log(` ${c.orange}⚠${c.reset} token #${token.tokenId} image: no render in THIS node's store — if you published to a hosted resolver, check it with \`abx verify ${address} --remote <resolver>\`; else render it: \`abx render ${address}\` (once) or \`abx effects\` (continuous)`);
4354
- }
4447
+ present++;
4448
+ else
4449
+ missing.push(String(token.tokenId));
4450
+ }
4451
+ if (present)
4452
+ ok(`${present}/${minted.length} minted token(s): real render present (in this node's store)`);
4453
+ if (missing.length) {
4454
+ renderGap = true;
4455
+ const ids = missing.length > 12 ? `${missing.slice(0, 12).join(', ')}, …+${missing.length - 12} more` : missing.join(', ');
4456
+ console.log(` ${c.orange}⚠${c.reset} ${missing.length}/${minted.length} token(s) have no render in THIS node's store ${dim(`(#${ids})`)}`);
4457
+ console.log(` ${dim('published to a hosted resolver? check there:')} ${bold(`abx verify ${address} --remote <resolver>`)}`);
4458
+ console.log(` ${dim('else render them:')} ${bold(`abx render ${address}`)} ${dim('(once) ·')} ${bold('abx effects')} ${dim('(continuous)')}`);
4355
4459
  }
4356
4460
  }
4357
4461
  // The on-chain URI lane (a non-zero tokenURIRenderer, or an animation field pointing at the
@@ -4625,7 +4729,7 @@ function decodeOnChainJson(uri, verbatim = false) {
4625
4729
  return verbatim ? raw : raw.slice(0, 600);
4626
4730
  }
4627
4731
  }
4628
- async function cmdTokenUri(address, flags) {
4732
+ async function cmdTokenUri(address, flags, extra = []) {
4629
4733
  if (!address || address.startsWith('--')) {
4630
4734
  console.error('usage: abx tokenuri <address> [--token <id>] [--json]\n');
4631
4735
  process.exit(1);
@@ -4634,6 +4738,19 @@ async function cmdTokenUri(address, flags) {
4634
4738
  console.error(`abx tokenuri: '${address}' isn't a 0x contract address.\n`);
4635
4739
  process.exit(1);
4636
4740
  }
4741
+ // `abx tokenuri <addr> 0` silently ignored the `0` and printed token 0 — a COINCIDENTALLY correct
4742
+ // answer, which is the dangerous kind: `… <addr> 7` would have printed token 0 just as confidently
4743
+ // and exited 0. The token id is a flag here, so name it rather than guessing at intent.
4744
+ const strayPositionals = positionalArgs(extra);
4745
+ if (strayPositionals.length) {
4746
+ const first = strayPositionals[0];
4747
+ const looksLikeTokenId = /^\d+$/.test(first);
4748
+ console.error(`abx tokenuri: unexpected extra argument '${first}'.` +
4749
+ (looksLikeTokenId
4750
+ ? ` The token id is a flag — did you mean:\n abx tokenuri ${address} --token ${first}\n`
4751
+ : `\n usage: abx tokenuri <address> [--token <id>] [--json]\n`));
4752
+ process.exit(1);
4753
+ }
4637
4754
  const tokenId = BigInt(flags.token ?? '0');
4638
4755
  const publicClient = makePublicClient({ chainKey: CHAIN });
4639
4756
  // A creator verifying their work often runs this on a wrong / not-yet-mined address — turn viem's
@@ -4776,6 +4893,7 @@ async function cmdContractUri(address, _flags) {
4776
4893
  // ── serve ──────────────────────────────────────────────────────────────────--
4777
4894
  async function cmdServe(flags) {
4778
4895
  const port = Number(flags.port ?? process.env.ABX_PORT ?? DEFAULT_PORT);
4896
+ await assertPortFree(port, 'serve');
4779
4897
  const baseUrl = resolveBaseUrl(port);
4780
4898
  const indexer = new SelfHostIndexer();
4781
4899
  // Surface the most-recently-reconstructed project (what you just deployed), not
@@ -5045,6 +5163,9 @@ const STATE_ABI = [
5045
5163
  { type: 'function', name: 'primaryPayee', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
5046
5164
  { type: 'function', name: 'tokenURIRenderer', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
5047
5165
  { type: 'function', name: 'royaltyInfo', stateMutability: 'view', inputs: [{ type: 'uint256' }, { type: 'uint256' }], outputs: [{ type: 'address' }, { type: 'uint256' }] },
5166
+ // ERC-721C (creator token) — ERC-165 advertises ICreatorToken ONLY when enrolled at deploy.
5167
+ { type: 'function', name: 'supportsInterface', stateMutability: 'view', inputs: [{ type: 'bytes4' }], outputs: [{ type: 'bool' }] },
5168
+ { type: 'function', name: 'getTransferValidator', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
5048
5169
  ];
5049
5170
  async function cmdState(address, flags) {
5050
5171
  if (!address || address.startsWith('--')) {
@@ -5061,7 +5182,7 @@ async function cmdState(address, flags) {
5061
5182
  return undefined; // getter absent (extension not composed) or contract has no code
5062
5183
  }
5063
5184
  };
5064
- const [owner, totalSupply, nextTokenId, maxInvocations, paused, minter, primaryPayee, renderer, royalty] = await Promise.all([
5185
+ const [owner, totalSupply, nextTokenId, maxInvocations, paused, minter, primaryPayee, renderer, royalty, creatorToken, transferValidator] = await Promise.all([
5065
5186
  read('owner'),
5066
5187
  read('totalSupply'),
5067
5188
  read('nextTokenId'),
@@ -5071,6 +5192,8 @@ async function cmdState(address, flags) {
5071
5192
  read('primaryPayee'),
5072
5193
  read('tokenURIRenderer'),
5073
5194
  read('royaltyInfo', [0n, 10000n]),
5195
+ read('supportsInterface', [CREATOR_TOKEN_INTERFACE_ID]),
5196
+ read('getTransferValidator'),
5074
5197
  ]);
5075
5198
  if (owner === undefined && totalSupply === undefined) {
5076
5199
  throw new Error(`no ABX contract state at ${address} on ${CHAIN} (no code, or not an ABX token).`);
@@ -5095,6 +5218,11 @@ async function cmdState(address, flags) {
5095
5218
  if (royalty)
5096
5219
  info(`royalty ${Number(royalty[1]) / 100}% → ${royalty[0]}`);
5097
5220
  info(`renderer ${renderer && renderer !== zeroAddress ? `on-chain (${renderer})` : dim('off-chain (stored URI base / override)')}`);
5221
+ // ERC-721C: shown ONLY for an enrolled collection — an unenrolled token is a plain ERC-721
5222
+ // (indistinguishable from a pre-721C token) and shows nothing new here.
5223
+ if (creatorToken) {
5224
+ info(`721C validator ${transferValidator && transferValidator !== zeroAddress ? transferValidator : `${c.orange}suspended${c.reset} ${dim('(zero — enforcement off; re-enable with `abx set-transfer-validator`)')}`}`);
5225
+ }
5098
5226
  // The PostParam surface, read straight from chain: `paramSchemaKeys()` is the DECLARED (governed)
5099
5227
  // set — append-only, and it includes keys declared but never yet written, which nothing off-chain
5100
5228
  // could otherwise discover — and `contractParamKeys()` is the collection-scope set values. Both
@@ -6041,6 +6169,10 @@ const COMMAND_HELP = {
6041
6169
  ${g('--compress')} none|fastlz|gzip for --onchain-image (default none): ${g('fastlz')} = on-chain decode (stays renderable);
6042
6170
  ${g('gzip')} = smaller but off-chain decode only
6043
6171
  --royalty-bps <0-10000> default 500 (5%)
6172
+ ${g('--721c')} [recommended|0x..] opt-in ERC-721C: enroll at deploy, PERMANENTLY, with that transfer validator
6173
+ (${g('recommended')} = OpenSea's validator for creator-fee enforcement — only owner-initiated
6174
+ transfers + authorized sales move the token). Absent = plain ERC-721, forever (the default).
6175
+ Manage later with ${g('abx set-transfer-validator')} (re-point or suspend; never un-enroll).
6044
6176
  ${g('--no-mint')} deploy without minting (warm the resolver, then ${g('abx mint')})
6045
6177
  --salt 0x..<64hex> deploy to a reserved / vanity address (see ${g('abx predict')})
6046
6178
  --public-base-url <url> bake a public resolver URL on-chain (off-chain custody REQUIRES a public URL; localhost is refused)
@@ -6070,6 +6202,8 @@ const COMMAND_HELP = {
6070
6202
  a huge collection should set traits post-deploy via ${g('abx set-field')} under a gas budget.
6071
6203
  --minter 0x.. authorize a single minting contract --primary-payee 0x.. primary-sale payout
6072
6204
  --unpaused open the mint at deploy (default: paused ⇒ owner-only until ${g('abx unpause')})
6205
+ ${g('--721c')} [recommended|0x..] opt-in ERC-721C: enroll at deploy, PERMANENTLY, with that transfer validator (absent = plain
6206
+ ERC-721 forever). ${g('recommended')} = OpenSea's creator-fee-enforcement validator. Manage: ${g('abx set-transfer-validator')}
6073
6207
  --salt 0x.. · --backend … (storage override) · ${g('--storage-signer')} arweave|eth (who pays Turbo uploads) · signing: ${g('--send')} · ${g('--sign')} · ${g('--unsigned')}
6074
6208
  ${g('--dry-run')} preview (address · per-token plan · custody · mint plan) WITHOUT sending or storing
6075
6209
  ${dim('(a dry-run needs a deployer to compute the address — pass --for 0x.. if no signing key is set)')}
@@ -6151,6 +6285,8 @@ const COMMAND_HELP = {
6151
6285
  a CDN-served record is the normal production path, on-chain bytes are the durability floor).
6152
6286
  --max N supply cap (default 16) mint timing: ${g('--mint-all')} · ${g('--mint-count')} N · (default: defer, then ${g('abx mint')})
6153
6287
  --no-seed opt out of the canonical mint-time seed (seeds settle once assigned; curated pre-set seeds win)
6288
+ ${g('--721c')} [recommended|0x..] opt-in ERC-721C: enroll at deploy, PERMANENTLY, with that transfer validator (absent = plain
6289
+ ERC-721 forever). ${g('recommended')} = OpenSea's creator-fee-enforcement validator. Manage: ${g('abx set-transfer-validator')}
6154
6290
  --backend ipfs|arweave directory-mode custody for the build upload ${g('--unpaused')} · --minter 0x.. · --primary-payee 0x..
6155
6291
  ${dim('a fixed-price PRIMARY SALE is set up AFTER deploy, not by these flags: ')}${g('abx minter configure <addr> --price <eth>')}${dim(' → ')}${g('set-minter')}${dim(' → ')}${g('set-primary-payee')}${dim(' → ')}${g('unpause')}${dim(' (see `abx minter --help`). --minter/--primary-payee here only pre-authorize an already-known minter.')}
6156
6292
  signing: ${g('--send')} (default) · ${g('--sign')} · ${g('--unsigned')} ${dim('(needs --for 0x..)')} ${g('--bootstrap-factory')} ${dim('(private chains/sandboxes only)')}
@@ -6220,6 +6356,9 @@ const COMMAND_HELP = {
6220
6356
  like ${g('display.gateway')}. ≤31 printable-ASCII chars ride as a literal bytes32; longer takes the data path.
6221
6357
  A contract-scope param applies to every token, and enumerates on-chain like any other.
6222
6358
  --file <path> read the value from a file (String / Bytes payloads)
6359
+ ${dim('PAYLOAD TYPES:')} ${g('String')} ${dim('takes literal text (UTF-8).')} ${g('Bytes')} ${dim('takes')} ${bold('0x-prefixed hex')} ${dim(`or ${g('--file')} — a bare`)}
6360
+ ${dim('string is refused, because there is no safe guess between "these characters" and "these bytes".')}
6361
+ ${dim(`(The docs' "Bytes becomes base64" describes how your ${bold('program')} receives the value, not how you write it.)`)}
6223
6362
  ${g('--remote [name|url]')} nudge a REMOTE resolver to re-index IMMEDIATELY after the change (else ABX_PUBLIC_BASE_URL) — it pings
6224
6363
  the resolver's effect runner, so the thumbnail re-renders without waiting. Usually OPTIONAL now: a
6225
6364
  resolver running the chain watcher (the ${g('abx serve')} default) sees the change on its next poll (~12s)
@@ -6255,7 +6394,8 @@ const COMMAND_HELP = {
6255
6394
  signing: ${g('--send')} (default) · ${g('--sign')} · ${g('--unsigned')}`,
6256
6395
  state: `
6257
6396
  ${bold('abx state')} <address> ${dim('— read-only, on-chain operational snapshot (no tx, no local index).')}
6258
- Shows owner · supply (minted / max, nextTokenId) · paused · minter · primary payee · royalty · renderer.
6397
+ Shows owner · supply (minted / max, nextTokenId) · paused · minter · primary payee · royalty · renderer
6398
+ (+ the 721C transfer validator, for a collection that enrolled at deploy — plain ERC-721s show nothing new).
6259
6399
  Series-only fields are shown for a Series; a 1/1 shows just supply + royalty + renderer.
6260
6400
  Handy before/after owner ops (mint · pause/unpause · set-minter · set-primary-payee).
6261
6401
  ${dim('state = what the CHAIN says. For who is SERVING it and how fresh that is, see `abx status`.')}`,
@@ -6310,6 +6450,14 @@ const COMMAND_HELP = {
6310
6450
  deploy to Vercel). ${dim('Offer this after a sale is live (configure → set-minter → set-primary-payee → unpause).')}`,
6311
6451
  'set-royalty': `
6312
6452
  ${bold('abx set-royalty')} <address> --bps <0-10000> [--receiver 0x..] ${dim('— change the royalty. Sends a tx.')}`,
6453
+ 'set-transfer-validator': `
6454
+ ${bold('abx set-transfer-validator')} <address> <0x..|none|recommended> ${dim('— manage an ERC-721C collection\'s transfer validator. Owner-only. Sends a tx.')}
6455
+ <0x..> re-point enforcement at that validator (must be a DEPLOYED contract — checked before any gas)
6456
+ ${g('none')} suspend enforcement (validator → address(0); the collection STAYS enrolled)
6457
+ ${g('recommended')} the chain's recommended validator (OpenSea's, for creator-fee enforcement)
6458
+ ${dim('Only for a collection that enrolled AT DEPLOY (--721c on the deploy commands) — a plain ERC-721 is refused')}
6459
+ ${dim('up front: enrollment is a deploy-time decision and can never be added (or fully removed) later.')}
6460
+ signing: ${g('--send')} (default) · ${g('--sign')} · ${g('--unsigned')} · ${g('--dry-run')} preview (encodes + shows the tx, sends nothing)`,
6313
6461
  attach: `
6314
6462
  ${bold('abx attach')} <address> <key> <ipfs://… | ar://… | https://…> ${dim('— attach a named file to a token. Sends a tx.')}
6315
6463
  ${dim('The data-plane verb: the file joins the token\'s served')} ${bold('artifacts')} ${dim('manifest as {key, mimeType, uri}.')}
@@ -6339,8 +6487,12 @@ const COMMAND_HELP = {
6339
6487
  ${g('--dry-run')} preview the tx, send nothing
6340
6488
  --collection target collection (ERC-7572) scope · else --token <id> (default 0)`,
6341
6489
  'lock-field': `
6342
- ${bold('abx lock-field')} <address> --field <name> ${dim('— freeze a field FOREVER (irreversible). Sends a tx.')}
6343
- --collection | --token <id>`,
6490
+ ${bold('abx lock-field')} <address> --field <name> ${dim('— freeze a metadata FIELD forever (irreversible). Sends a tx.')}
6491
+ --collection | --token <id>
6492
+ --force-field proceed even if a PostParam shares this name (you mean the field)
6493
+ ${dim('FIELDS AND PARAMS ARE DIFFERENT NAMESPACES and may share a name. This locks the field only —')}
6494
+ ${dim(`to weld a PostParam use ${g('abx set-schema <addr> --schema <key>:<Type>:<Auth>:lock=now')}. Passing a`)}
6495
+ ${dim('declared param key here is refused, because locking the field leaves the param writable.')}`,
6344
6496
  'set-renderer': `
6345
6497
  ${bold('abx set-renderer')} <address> ${dim('— toggle URI resolution between off-chain and on-chain. Sends a tx.')}
6346
6498
  (default) point at the chain's canonical renderer (deploys it if needed) → resolve ON-CHAIN
@@ -6470,6 +6622,11 @@ const COMMAND_HELP = {
6470
6622
  project to the effects layer (${g('ABX_EFFECTS_URL')} + ${g('ABX_EFFECTS_TOKEN')}) → thumbnails auto-re-render.
6471
6623
  ${g('ABX_WATCH_INTERVAL_MS')} tunes the cadence; ${g('0')} disables (state then updates only on explicit add/index).
6472
6624
  ${dim('Reorg note: no lookback (post-PoS reorgs are rare); the repair is the deterministic full replay — `abx index <addr> --full`.')}`,
6625
+ changelog: `
6626
+ ${bold('abx changelog')} ${dim('— what changed in this and recent releases. Reads nothing from the network. No tx.')}
6627
+ ${g('--all')} the full history (default: the 3 most recent releases)
6628
+ ${dim('The notes ship INSIDE the package, so they always match the version you have and work offline.')}
6629
+ ${dim('Every published version is listed at https://www.npmjs.com/package/@artblocks/abx-cli?activeTab=versions')}`,
6473
6630
  };
6474
6631
  function printCommandHelp(cmd) {
6475
6632
  if (cmd && COMMAND_HELP[cmd]) {
@@ -6543,6 +6700,7 @@ function help() {
6543
6700
  ${g('abx set-contract-uri')} <addr> re-point the collection URI --uri <url>
6544
6701
  ${g('abx migrate')} <addr> move off-chain state to a NEW resolver (verifies parity; no cutover) --from <old> --to <new>
6545
6702
  ${g('abx set-royalty')} <addr> change royalty --bps <0-10000> [--receiver 0x..]
6703
+ ${g('abx set-transfer-validator')} <addr> <0x..|none|recommended> manage an ERC-721C collection's validator (enrolled-at-deploy only)
6546
6704
  ${g('abx attach')} <addr> <key> <ipfs://…|ar://…|https://…> attach a named file → the token's ${bold('artifacts')} manifest (data plane)
6547
6705
  ${g('abx set-field')} <addr> set an on-chain metadata field (low-level) --field <name> (--text "…" | --value 0x..) [--collection]
6548
6706
  ${g('abx lock-field')} <addr> freeze a field forever --field <name> [--collection]
@@ -6571,11 +6729,54 @@ function help() {
6571
6729
  ${g('abx doctor')} check environment (key, RPC, balance, factory, storage)
6572
6730
  ${g('abx skill install')} install the version-locked abx skill into your agent(s) [--agent <name>] [--global] [--target <dir>] · ${g('abx skill path')} prints the bundled skill
6573
6731
  ${g('abx version')} print the installed CLI version
6732
+ ${g('abx changelog')} what changed in this and recent releases (ships with the CLI; offline) [--all]
6574
6733
 
6575
6734
  ${dim('Run')} ${g('abx <command> --help')} ${dim('for per-command usage. --help / -h never executes — it only prints usage.')}
6576
6735
  ${dim('abx checks npm for a newer release (every 6h, notify-only). Silence with')} ${g('ABX_NO_UPDATE_CHECK=1')} ${dim('or')} ${g('--no-update-check')}${dim('.')}
6577
6736
  `);
6578
6737
  }
6738
+ /**
6739
+ * Resolve `--721c` into the InitParams `transferValidator`. Absent → `zeroAddress`: a plain
6740
+ * ERC-721, byte-for-byte the pre-721C deploy — no prompts, no output, no mention of 721C.
6741
+ * `--721c` / `--721c recommended` → the per-chain recommended validator (refused, naming the
6742
+ * chains that have one, when the manifest has no entry); `--721c 0x…` → checksum-validated AND
6743
+ * pre-checked for code on this chain (the factory would revert `InvalidTransferValidator()` —
6744
+ * surface it before any upload/staging/gas). When enrolling, prints the one plain statement of
6745
+ * what enforcement means — once, adapted to the chosen validator.
6746
+ */
6747
+ async function resolveTransferValidatorFlag(flags, publicClient, dryRun) {
6748
+ const raw = flags['721c'];
6749
+ if (raw === undefined)
6750
+ return zeroAddress;
6751
+ const chainId = resolveChain(CHAIN).id;
6752
+ const validator = parseTransferValidatorValue(raw, { chainId, chainLabel: CHAIN });
6753
+ // Has-code precheck — for the recommended constant too (presence on THIS chain is the fact that
6754
+ // matters; a sandbox/private chain won't have it). A dry run tolerates an unreachable RPC (it
6755
+ // sends nothing); a real deploy refuses to enroll blind.
6756
+ let code;
6757
+ try {
6758
+ code = await publicClient.getCode({ address: validator });
6759
+ }
6760
+ catch (err) {
6761
+ if (!dryRun)
6762
+ throw new Error(`--721c: couldn't verify the validator has code at ${validator} (${err.message}) — refusing to enroll blind; retry when the RPC answers.`);
6763
+ code = null; // offline preview — note it below
6764
+ }
6765
+ if (code === null) {
6766
+ info(dim(`--721c: RPC unreachable, so the validator code check is deferred — a real deploy verifies ${validator} has code first.`));
6767
+ }
6768
+ else if (!code || code === '0x') {
6769
+ const rec = resolveRecommendedTransferValidator(chainId);
6770
+ throw new Error(`--721c: no contract code at ${validator} on ${CHAIN} — the deploy would revert InvalidTransferValidator(). ` +
6771
+ `A transfer validator must be a DEPLOYED contract on this chain` +
6772
+ (rec && rec !== validator ? ` (the known-good one: --721c recommended → ${rec})` : '') + `.`);
6773
+ }
6774
+ // The one plain statement — printed once, only when enrolling. Never appears un-enrolled.
6775
+ info(validator === resolveRecommendedTransferValidator(chainId)
6776
+ ? `ERC-721C enforcement: only owner-initiated transfers and OpenSea-authorized sales will transfer; other marketplaces/operators are blocked. Manage with \`abx set-transfer-validator\`.`
6777
+ : `ERC-721C enforcement: every non-mint transfer is checked by validator ${validator} — its policy decides which operators may transfer. Manage with \`abx set-transfer-validator\`.`);
6778
+ return validator;
6779
+ }
6579
6780
  /** Validate an explicit --salt (a 32-byte hex). Undefined when absent. */
6580
6781
  function parseSaltFlag(raw) {
6581
6782
  if (!raw || raw === 'true')
@@ -6622,6 +6823,40 @@ function resolveBundledSkill() {
6622
6823
  return canonical;
6623
6824
  return null;
6624
6825
  }
6826
+ /**
6827
+ * `abx changelog [--all]` — print the release notes that ship with THIS install.
6828
+ *
6829
+ * The update banner used to point at `github.com/ArtBlocks/abx/releases`, which 404s for anyone
6830
+ * outside the org, and no changelog shipped in the package — so "what changed?" was unanswerable
6831
+ * without diffing two versions' dry-run output by hand. `CHANGELOG.md` now ships (package.json
6832
+ * `files`), which makes the notes offline, version-matched, and readable by an agent.
6833
+ *
6834
+ * Default: the most recent few entries (what an upgrade needs). `--all`: the whole file.
6835
+ */
6836
+ function cmdChangelog(flags) {
6837
+ const pkgDir = resolvePath(fileURLToPath(import.meta.url), '..', '..');
6838
+ const candidates = [joinPath(pkgDir, 'CHANGELOG.md')];
6839
+ const root = findRepoRoot();
6840
+ if (root)
6841
+ candidates.push(joinPath(root, 'packages', 'cli', 'CHANGELOG.md')); // dev checkout
6842
+ const path = candidates.find((p) => existsSync(p));
6843
+ if (!path) {
6844
+ throw new Error(`no CHANGELOG.md found beside this install (looked in ${candidates.join(', ')}). ` +
6845
+ 'Version history: https://www.npmjs.com/package/@artblocks/abx-cli?activeTab=versions');
6846
+ }
6847
+ const text = readFileSync(path, 'utf8');
6848
+ if (flags.all !== undefined) {
6849
+ console.log(text);
6850
+ return;
6851
+ }
6852
+ // Entries are `## <version>` sections; show the newest few and say how to see the rest.
6853
+ const lines = text.split('\n');
6854
+ const heads = lines.map((l, i) => (/^## /.test(l) ? i : -1)).filter((i) => i >= 0);
6855
+ const end = heads.length > 3 ? heads[3] : lines.length;
6856
+ console.log(`\n${lines.slice(0, end).join('\n').trimEnd()}\n`);
6857
+ if (heads.length > 3)
6858
+ info(`showing the ${3} most recent releases — ${bold('abx changelog --all')} for the full history ${dim(`(${path})`)}`);
6859
+ }
6625
6860
  /** Locate the in-chain renderer Foundry scaffold: bundled beside the CLI (published), else the
6626
6861
  * repo copy (dev). Same layout in both — it lives under the CLI package's `assets/`. */
6627
6862
  function resolveRendererScaffold() {
@@ -6650,12 +6885,9 @@ function cmdScaffoldRenderer(rest, flags) {
6650
6885
  if (existsSync(dir) && readdirSync(dir).length > 0 && flags.force === undefined) {
6651
6886
  throw new Error(`${dir} already exists and is not empty — pass a fresh path, or --force to write into it.`);
6652
6887
  }
6653
- mkdirSync(dir, { recursive: true });
6654
- // Copy the committed scaffold; never carry a stale build/deps dir if one somehow exists.
6655
- cpSync(src, dir, {
6656
- recursive: true,
6657
- filter: (s) => !/(^|\/)(out|cache|dependencies|broadcast|node_modules)(\/|$)/.test(s),
6658
- });
6888
+ // Copy + assert it landed. Throws rather than printing a success banner over an empty directory —
6889
+ // the alpha.9→alpha.14 failure mode. See src/scaffold.ts for why this is not inline.
6890
+ copyRendererScaffold(src, dir);
6659
6891
  ok(`in-chain renderer scaffold → ${dir}`);
6660
6892
  step('The in-chain Solidity art lane — nothing to run after deploy, tokenURI resolves from chain forever');
6661
6893
  info(`${bold('src/MyRenderer.sol')} draws an SVG from the token ${bold('seed')} + a ${bold('palette')} HexColor PostParam; ${bold('src/MyTraits.sol')} reads the SAME seed for coherent on-chain traits. Fork the art; keep the invariants in ${bold('src/interfaces/IAbxFieldRenderer.sol')} (above all: render() must NEVER revert).`);