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

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 (69) hide show
  1. package/CHANGELOG.md +852 -0
  2. package/assets/renderer-scaffold/README.md +2 -2
  3. package/assets/renderer-scaffold/src/MyRenderer.sol +2 -2
  4. package/assets/renderer-scaffold/src/interfaces/IAbxFieldRenderer.sol +1 -1
  5. package/assets/renderer-scaffold/src/interfaces/IAbxParams.sol +2 -2
  6. package/assets/renderer-scaffold/test/MyRenderer.t.sol +1 -1
  7. package/dist/commands/deploy.d.ts +30 -17
  8. package/dist/commands/deploy.d.ts.map +1 -1
  9. package/dist/commands/deploy.js +234 -97
  10. package/dist/commands/deploy.js.map +1 -1
  11. package/dist/commands/project.d.ts +16 -0
  12. package/dist/commands/project.d.ts.map +1 -1
  13. package/dist/commands/project.js +150 -10
  14. package/dist/commands/project.js.map +1 -1
  15. package/dist/commands/reads.js +2 -2
  16. package/dist/commands/reads.js.map +1 -1
  17. package/dist/commands/scaffold.d.ts +3 -1
  18. package/dist/commands/scaffold.d.ts.map +1 -1
  19. package/dist/commands/scaffold.js +64 -20
  20. package/dist/commands/scaffold.js.map +1 -1
  21. package/dist/commands/service.js +1 -1
  22. package/dist/commands/service.js.map +1 -1
  23. package/dist/commands/submit-app.d.ts +58 -0
  24. package/dist/commands/submit-app.d.ts.map +1 -0
  25. package/dist/commands/submit-app.js +512 -0
  26. package/dist/commands/submit-app.js.map +1 -0
  27. package/dist/config.d.ts +1 -15
  28. package/dist/config.d.ts.map +1 -1
  29. package/dist/config.js +18 -1
  30. package/dist/config.js.map +1 -1
  31. package/dist/flag-allowlists.d.ts.map +1 -1
  32. package/dist/flag-allowlists.js +5 -1
  33. package/dist/flag-allowlists.js.map +1 -1
  34. package/dist/flags.d.ts +4 -0
  35. package/dist/flags.d.ts.map +1 -1
  36. package/dist/flags.js +23 -0
  37. package/dist/flags.js.map +1 -1
  38. package/dist/main.js +125 -37
  39. package/dist/main.js.map +1 -1
  40. package/dist/mintpage.d.ts.map +1 -1
  41. package/dist/mintpage.js +29 -4
  42. package/dist/mintpage.js.map +1 -1
  43. package/dist/output.d.ts +32 -1
  44. package/dist/output.d.ts.map +1 -1
  45. package/dist/output.js +74 -9
  46. package/dist/output.js.map +1 -1
  47. package/dist/ownerops.d.ts +101 -13
  48. package/dist/ownerops.d.ts.map +1 -1
  49. package/dist/ownerops.js +357 -89
  50. package/dist/ownerops.js.map +1 -1
  51. package/dist/preview.d.ts +1 -1
  52. package/dist/preview.js +1 -1
  53. package/dist/schema.d.ts +18 -0
  54. package/dist/schema.d.ts.map +1 -1
  55. package/dist/schema.js +37 -2
  56. package/dist/schema.js.map +1 -1
  57. package/dist/served.js +1 -1
  58. package/dist/update-check.d.ts.map +1 -1
  59. package/dist/update-check.js +9 -3
  60. package/dist/update-check.js.map +1 -1
  61. package/package.json +6 -6
  62. package/skill/SKILL.md +62 -32
  63. package/skill/reference/code-projects.md +78 -24
  64. package/skill/reference/creator-token.md +16 -3
  65. package/skill/reference/decisions.md +59 -13
  66. package/skill/reference/hosting.md +1 -1
  67. package/skill/reference/operating.md +33 -13
  68. package/skill/reference/setup.md +1 -1
  69. package/skill/reference/troubleshooting.md +16 -6
package/dist/ownerops.js CHANGED
@@ -6,10 +6,10 @@
6
6
  * served state reflects the change. The agent picks the lane; the human only
7
7
  * approves (wallet lane) or it's the env key (hot lane).
8
8
  */
9
- import { assertChainId, ensureChunkStore as sdkEnsureChunkStore, predictFixedPriceMinter, predictFixedPriceMinter1155, makeHotSender, makePublicClient, makeWalletClient, oneOfOneImageAbi, oneOfOneEditionAbi, seriesImageAbi, deployFixedPriceMinter, deployFixedPriceMinter1155, prepareConfigureSale, prepareConfigureSale1155, preparePurchase, preparePurchase1155, readSaleConfig, readSaleConfig1155, prepareEditionMint, prepareEditionTransfer, prepareSetMaxSupply, preparePingURI, prepareLockContractField, prepareLockContractURI, prepareLockTokenField, prepareLockTokenURI, prepareMint, mintedTokenIds, prepareSeriesMintMany, prepareSetMinter, prepareSetMaxInvocations, prepareSetPrimaryPayee, prepareSetPaused, prepareSetContractField, prepareSetContractURIBase, prepareSetContractURIOverride, prepareSetContractURIRenderer, prepareSetParamHooks, prepareSetParamSchema, prepareRetireParam, readParamSchema, PARAM_TYPES, prepareSetRoyalty, prepareSetTokenField, batchOps, prepareSetTokenURIBase, prepareSetTokenURIOverride, prepareSetTokenURIRenderer, prepareSetTransferValidator, prepareTransfer, prepareTransferOwnership, readCreatorTokenStatus, resolveRecommendedTransferValidator, RECOMMENDED_TRANSFER_VALIDATOR, KNOWN_CHAIN_KEYS, resolveChain, resolveRpcUrl, redactRpcUrl, encodeTag, METADATA_FIELD as F, METADATA_REPRESENTATION as R, stageFieldContent, planStagedContent, exceedsOnchainSoftLimit, ONCHAIN_IMAGE_SOFT_LIMIT, ONCHAIN_PROJECT_SOFT_LIMIT, } from '@artblocks/abx-sdk';
9
+ import { assertChainId, ensureChunkStore as sdkEnsureChunkStore, predictFixedPriceMinter, predictFixedPriceMinter1155, makeHotSender, makePublicClient, makeWalletClient, oneOfOneImageAbi, oneOfOneEditionAbi, seriesImageAbi, deployFixedPriceMinter, deployFixedPriceMinter1155, prepareConfigureSale, prepareConfigureSale1155, preparePurchase, preparePurchase1155, readSaleConfig, readSaleConfig1155, prepareEditionMint, prepareEditionTransfer, prepareSetMaxSupply, preparePingURI, prepareLockContractField, prepareLockContractURI, prepareLockTokenField, prepareLockTokenURI, prepareMint, mintedTokenIds, prepareSeriesMintMany, prepareSetMinter, prepareSetMaxInvocations, prepareSetPrimaryPayee, prepareSetPaused, prepareSetContractField, prepareSetContractURIBase, prepareSetContractURIOverride, prepareSetContractURIRenderer, prepareSetParamHooks, prepareSetParamSchema, prepareRetireParam, readParamSchema, PARAM_TYPES, prepareSetRoyalty, prepareSetTokenField, batchOps, prepareSetTokenURIBase, prepareSetTokenURIOverride, prepareSetTokenURIRenderer, prepareSetTransferValidator, prepareSetSeedSource, probeSeedSource, readSeedSource, predictSeedSource, getDeployment, prepareTransfer, prepareTransferOwnership, probeTransferValidator, readCreatorTokenStatus, resolveRecommendedTransferValidator, RECOMMENDED_TRANSFER_VALIDATOR, KNOWN_CHAIN_KEYS, resolveChain, resolveRpcUrl, redactRpcUrl, encodeTag, METADATA_FIELD as F, METADATA_REPRESENTATION as R, stageFieldContent, planStagedContent, exceedsOnchainSoftLimit, classifyOnchainReadSize, tokenUriGasEstimate, ONCHAIN_IMAGE_SOFT_LIMIT, ONCHAIN_PROJECT_SOFT_LIMIT, ONCHAIN_READ_WARN_BYTES, ONCHAIN_READ_REFUSE_BYTES, } from '@artblocks/abx-sdk';
10
10
  import { encodeScalarParam, encodeTag as encodeTagSdk, isAccepted, prepareConfigureTokenParam, prepareConfigureTokenParamData, prepareSetContractParam, prepareSetContractParamData, seriesCodeAbi, tryReadContract, } from '@artblocks/abx-sdk';
11
11
  import { hasParamEnumeration } from '@artblocks/abx-sdk';
12
- import { decodeFieldRenderer, resolveGenerator, DEP_RESOLUTION, parseDependencyRef, prepareLockDependencies, prepareRemoveLastDependency, prepareSetDependency, prepareSetDependencyRegistry, } from '@artblocks/abx-sdk';
12
+ import { decodeFieldRenderer, resolveGenerator, DEP_RESOLUTION, parseDependencyRef, prepareLockDependencies, prepareLockParamHooks, prepareLockScript, prepareRemoveLastDependency, prepareSetDependency, prepareSetDependencyRegistry, } from '@artblocks/abx-sdk';
13
13
  import { contentTypeFromPath } from '@artblocks/abx-storage';
14
14
  import { toHex as toHexSdk } from 'viem';
15
15
  import { readFileSync } from 'node:fs';
@@ -18,17 +18,17 @@ import { gzipSync } from 'node:zlib';
18
18
  import { formatEther, getAddress, isAddress, parseEther, toHex, zeroAddress } from 'viem';
19
19
  import { CHAIN, chainId, fixedPriceMinterAddress, fixedPriceMinter1155Address, localIndexer } from './config.js';
20
20
  import { CliError } from './errors.js';
21
- import { detectTokenKind, isEditionContract } from './kind.js';
21
+ import { detectTokenKind } from './kind.js';
22
22
  import { openWalletSession } from './signer.js';
23
23
  import { gatedSend, laneFromFlags } from './riskgate.js';
24
24
  import { withJson } from './jsonout.js';
25
25
  import { resolveRemote, serviceClient } from './remote.js';
26
- import { isDryRun, positionalArgs, unknownFlags, warnStrayFlags } from './flags.js';
26
+ import { allowUnreadableOnchain, isDryRun, positionalArgs, unknownFlags, warnStrayFlags } from './flags.js';
27
27
  import { parseSchemaSpecs, describeSchema } from './schema.js';
28
28
  // Re-exported for main.ts (the on-chain-vs-off-chain cost model + the compression-mode type now
29
29
  // live in the SDK's staging.ts, layered on planChunks/planContentTxs — see the comment at their
30
30
  // definition below).
31
- export { ONCHAIN_PROJECT_SOFT_LIMIT };
31
+ export { ONCHAIN_PROJECT_SOFT_LIMIT, ONCHAIN_READ_WARN_BYTES, ONCHAIN_READ_REFUSE_BYTES, tokenUriGasEstimate };
32
32
  // ── ANSI (local) ─────────────────────────────────────────────────────────────
33
33
  const C = { reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m', green: '\x1b[38;5;115m', yellow: '\x1b[38;5;221m' };
34
34
  const dim = (s) => `${C.dim}${s}${C.reset}`;
@@ -40,16 +40,17 @@ const bold = (s) => `${C.bold}${s}${C.reset}`;
40
40
  // COMPLETE set — but it's COMPUTED by the resolver/renderer from the token's fields + effect
41
41
  // outputs; it is never a field you set. These keys are that computed output, so setting them by
42
42
  // hand would only pollute the manifest with a bogus entry (a real round-1 agent trap). Refuse them
43
- // and point at the real verb. `abx_params` joins them for the same reason: it is COMPUTED from the
44
- // token's on-chain param enumeration, so a hand-set field of that name would be a decoy sitting
45
- // beside the real block. See specs/protocol/data-plane.md + specs/protocol/onchain-metadata.md.
43
+ // and point at the real verb. `abx_params` stays in this set even though spec v8 removed the
44
+ // projection from `tokenURI`: the name is now free, which makes it MORE attractive as a decoy field
45
+ // and no more meaningful than before. Params are chain state; a field of that name would be a
46
+ // hand-set impostor of a read that lives on the contract. See specs/protocol/data-plane.md + specs/protocol/onchain-metadata.md.
46
47
  const COMPUTED_FIELD_KEYS = new Set(['artifacts', 'abx_params', 'abx_provenance']);
47
48
  export function assertSettableField(field) {
48
49
  if (COMPUTED_FIELD_KEYS.has(field)) {
49
50
  const what = field === 'artifacts'
50
51
  ? "the COMPUTED manifest (the complete list of this token's files), assembled by the resolver/renderer from your fields"
51
52
  : field === 'abx_params'
52
- ? "the COMPUTED parameter block, enumerated from the token's on-chain params (set one with `abx configure-param`, declare one with `abx set-schema`)"
53
+ ? "a reserved name, not a field params are chain state read straight off the contract (set one with `abx configure-param`, declare one with `abx set-schema`)"
53
54
  : 'the COMPUTED provenance list, assembled by the resolver/renderer';
54
55
  throw new Error(`"${field}" is not a field you set — it's ${what}.` +
55
56
  (field === 'abx_params'
@@ -218,7 +219,7 @@ export async function assertContractExists(address) {
218
219
  * and canonically ENCODES the human input per its type (`#rrggbb`, decimals ×1e10,
219
220
  * Select by label, …); `String`/`Bytes` schemas take the value as UTF-8 (or
220
221
  * `--file <path>` for bytes) via the data path. The signer must satisfy the schema's
221
- * auth (Artist = contract owner, TokenOwner — delegate.xyz honored — or the named
222
+ * auth (Creator = contract owner, TokenOwner — delegate.xyz honored — or the named
222
223
  * address); the chain enforces it either way.
223
224
  */
224
225
  /** Positional args only — drops `--flags` AND the single token each value-taking flag consumes
@@ -236,7 +237,7 @@ export async function assertContractExists(address) {
236
237
  * Nothing errored; the in-chain renderer read ASCII where it expected bytes and drew garbage. So a
237
238
  * `Bytes` value must state its encoding: `0x…` hex, or `--file` for real binary. A bare string is
238
239
  * refused rather than guessed at — there is no safe guess between "these characters" and "these
239
- * bytes", and the failure is invisible until an artwork renders wrong.
240
+ * bytes", and the failure is invisible until a work renders wrong.
240
241
  */
241
242
  export function encodePayloadParam(typeName, valueInput, key) {
242
243
  if (typeName === 'String')
@@ -353,7 +354,7 @@ const HOOK_ROLES = ['configure', 'augment', 'transfer'];
353
354
  const HOOK_NOTE = {
354
355
  configure: 'write-time veto/validator (a configure tx reverts if it reverts)',
355
356
  augment: 'read-time derivation folded into tokenData (live view reads chain per view)',
356
- transfer: 'ownership-change lifecycle, best-effort (a revert never blocks the transfer)',
357
+ transfer: 'ownership-change VETO — its revert fails the transfer, and a mint too (mint = transfer from 0x0)',
357
358
  };
358
359
  const eqAddr = (a, b) => a.toLowerCase() === b.toLowerCase();
359
360
  /** Parse a hook-address flag: a 0x address, or `none`/`zero`/`0`/`0x0` to clear that role. */
@@ -431,6 +432,13 @@ export async function cmdSetParamHooks(address, flags) {
431
432
  const changed = !eqAddr(cur[r], next[r]);
432
433
  console.log(` ${r.padEnd(10)} ${changed ? `${label(cur[r])} → ${label(next[r])}` : `${label(cur[r])} ${dim('(unchanged)')}`}`);
433
434
  }
435
+ // Arming a transfer hook is the one hook change with a consequence for COLLECTORS, so say it at
436
+ // the moment of arming rather than only in the docs. It is not a warning against doing it — an
437
+ // owner-dependent work needs it — it is the disclosure that comes with it.
438
+ if (!eqAddr(next.transfer, zeroAddress) && !eqAddr(next.transfer, cur.transfer)) {
439
+ console.log(` ${bold('the transfer hook is a VETO:')} if it reverts, the transfer fails. It also runs on mint and burn (mint = transfer from 0x0), so a reverting hook stops minting for this project too — including through the shared minter.`);
440
+ console.log(dim(` that is a standing power over whether a collector can sell. Disclose it, and if you want to prove you will never arm one, \`abx lock-param-hooks ${contract}\` freezes all three addresses forever.`));
441
+ }
434
442
  const owner = await read(contract, 'owner');
435
443
  await runWrite(contract, prepareSetParamHooks({ contract, configureHook: next.configure, augmentHook: next.augment, transferHook: next.transfer, chainId: chainId() }), flags, owner);
436
444
  }
@@ -491,8 +499,56 @@ export async function cmdLockDependencies(address, flags) {
491
499
  const contract = requireAddress(address, 'abx lock-dependencies <address> [--sign|--unsigned]');
492
500
  const owner = await read(contract, 'owner');
493
501
  console.log(dim(' note: locking the dependency set is permanent and irreversible (list AND registry pointer freeze).'));
502
+ console.log(dim(" this pins WHICH library each ref means, not the library's bytes: a Registry ref is fetched from the registry on every read, so those bytes stay in the registry owner's hands."));
494
503
  await runWrite(contract, prepareLockDependencies({ contract, chainId: chainId() }), flags, owner);
495
504
  }
505
+ /** `abx lock-script <address>` — freeze the on-chain program (script chunks) forever. This is the
506
+ * lock that actually freezes the WORK of a code project: after it, `setScriptChunk` /
507
+ * `removeLastScriptChunk` revert. `lock-field`/`lock-uri` only freeze metadata; the full set for a
508
+ * code drop is lock-script + lock-dependencies + lock-field/lock-uri — which freezes what THIS
509
+ * contract stores, not necessarily what the token renders (params have no lock, and a Registry
510
+ * dependency's bytes live in the registry). */
511
+ export async function cmdLockScript(address, flags) {
512
+ const contract = requireAddress(address, 'abx lock-script <address> [--sign|--unsigned]');
513
+ const owner = await read(contract, 'owner');
514
+ console.log(dim(' note: locking the script is permanent and irreversible (the program bytes can never change again).'));
515
+ console.log(dim(' this freezes the WORK — pair with lock-dependencies and lock-field/lock-uri to freeze everything this contract stores.'));
516
+ console.log(dim(' that is not the same as a frozen OUTPUT: params stay writable and a Registry dependency is re-fetched live, so say "locked metadata" to a buyer, not "immutable".'));
517
+ await runWrite(contract, prepareLockScript({ contract, chainId: chainId() }), flags, owner);
518
+ }
519
+ /** `abx lock-param-hooks <address>` — freeze the three param-lifecycle hook addresses forever.
520
+ * The sibling of lock-script/lock-dependencies/lock-uri, and the only one aimed at a BUYER rather
521
+ * than at metadata: the transfer hook is a veto over transfers and mints, so an unlocked hook set
522
+ * is a standing power over whether a collector can sell. After this, `set-param-hooks` reverts
523
+ * ParamHooksLocked. Owner-only, any lane, guards --dry-run. */
524
+ export async function cmdLockParamHooks(address, flags) {
525
+ const contract = requireAddress(address, 'abx lock-param-hooks <address> [--sign|--unsigned] [--dry-run]');
526
+ // Read the current trio first: it is the SeriesCode/EditionCode guard (paramHooks() exists only on
527
+ // the ConfigurableParams extension) AND the thing being frozen — nobody should sign a permanent
528
+ // freeze without seeing exactly what it freezes.
529
+ const publicClient = makePublicClient({ chainKey: CHAIN });
530
+ let current;
531
+ try {
532
+ current = (await publicClient.readContract({ address: contract, abi: seriesCodeAbi, functionName: 'paramHooks' }));
533
+ }
534
+ catch {
535
+ await assertContractExists(contract);
536
+ const kind = await detectTokenKind(publicClient, contract);
537
+ throw new Error(`${contract} exposes no param hooks — they are a SeriesCode/EditionCode (PostParams) feature, ` +
538
+ `so there is nothing to freeze. ${kind.label} has no configurable params to hook.`);
539
+ }
540
+ const cur = { configure: current[0], augment: current[1], transfer: current[2] };
541
+ const label = (a) => (eqAddr(a, zeroAddress) ? dim('none') : a);
542
+ console.log(` freezing these three addresses on ${contract}, permanently:`);
543
+ for (const r of HOOK_ROLES)
544
+ console.log(` ${r.padEnd(10)} ${label(cur[r])}`);
545
+ console.log(dim(' note: this is permanent and irreversible — no hook address can ever be set, re-pointed, or cleared again.'));
546
+ console.log(dim(` you are giving up: arming a transfer veto (a hook that can block transfers and mints), arming a write-time configure veto, and re-pointing or clearing the augment hook. ` +
547
+ `${eqAddr(cur.transfer, zeroAddress) ? 'With no transfer hook set, freezing is how you PROVE you can never add one — the guarantee a buyer can check.' : 'The transfer hook already set stays live and keeps its veto: freezing the set is not disarming what is in it.'}`));
548
+ console.log(dim(' this freezes only the hooks. Schemas, param values, script, dependencies and URIs keep their own locks (`abx verify` lists them).'));
549
+ const owner = await read(contract, 'owner');
550
+ await runWrite(contract, prepareLockParamHooks({ contract, chainId: chainId() }), flags, owner);
551
+ }
496
552
  // ── editions: shared small parsers ────────────────────────────────────────────
497
553
  /** A non-negative-integer edition flag (`--token-id`, `--amount`, …) — the shared parse +
498
554
  * bound-check so every edition command that takes "an id" or "a count" rejects the same way. */
@@ -554,9 +610,9 @@ export async function cmdMint(address, flags) {
554
610
  throw new Error(`--count is a 721 Series flag (mint N tokens in order) — ${contract} is a ${kind.label} (edition). Use --token-id <id> --amount <n> instead.`);
555
611
  }
556
612
  // OneOfOneEdition's id space is fixed to {0}, so --token-id defaults there; EditionImage/
557
- // EditionCode ids are caller-named artworks with no sensible default — require it.
613
+ // EditionCode ids are caller-named works with no sensible default — require it.
558
614
  if (kind.kind !== '1of1-edition' && flags['token-id'] === undefined) {
559
- throw new Error(`--token-id is required on a ${kind.label} — its ids are caller-named artworks, not a single default. ` +
615
+ throw new Error(`--token-id is required on a ${kind.label} — its ids are caller-named works, not a single default. ` +
560
616
  `See existing ids with \`abx tokens ${contract}\`.`);
561
617
  }
562
618
  const tokenId = flags['token-id'] !== undefined ? parseEditionCountFlag(flags['token-id'], 'token-id') : 0n;
@@ -697,7 +753,7 @@ export async function cmdSetMaxSupply(address, flags) {
697
753
  console.log(dim(` #${tokenId} supply cap → ${cap}`));
698
754
  await runWrite(contract, prepareSetMaxSupply({ contract, tokenId, cap, chainId: chainId() }), flags, owner);
699
755
  }
700
- // ── ping-uri (editions only) — permissionless URI re-emission ────────────────────────────────────
756
+ // ── ping-uri (editions only) — the OWNER's URI re-emission ───────────────────────────────────────
701
757
  /** How many ids one `pingURI` call carries per transaction — large enough that a normal collection
702
758
  * fits in one tx, small enough that a genuinely huge id list doesn't build one unbounded call. */
703
759
  const PING_URI_CHUNK_SIZE = 200;
@@ -716,13 +772,14 @@ export async function cmdPingUri(address, flags) {
716
772
  const kind = await detectTokenKind(publicClient, contract);
717
773
  if (!kind.isEdition) {
718
774
  throw new Error(`ping-uri is edition-only — it re-emits the ERC-1155 URI event, which a ${kind.label} (721) doesn't have ` +
719
- `(721 emits ERC-4906 MetadataUpdate automatically on a URI change nothing to re-emit by hand).`);
775
+ `(both lanes emit ERC-4906 automatically on a URI change; this command is for indexers that ` +
776
+ `honor only the native 1155 event).`);
720
777
  }
721
778
  const ids = parseTokenIdRange(requireFlag(flags, 'token-ids', usage));
722
779
  const chunks = [];
723
780
  for (let i = 0; i < ids.length; i += PING_URI_CHUNK_SIZE)
724
781
  chunks.push(ids.slice(i, i + PING_URI_CHUNK_SIZE));
725
- console.log(dim(` re-emitting URI for ${ids.length} id${ids.length === 1 ? '' : 's'} — permissionless (no owner check) — ${chunks.length} tx${chunks.length === 1 ? '' : '(s)'} of up to ${PING_URI_CHUNK_SIZE} id(s) each`));
782
+ console.log(dim(` re-emitting URI for ${ids.length} id${ids.length === 1 ? '' : 's'} — owner-only — ${chunks.length} tx${chunks.length === 1 ? '' : '(s)'} of up to ${PING_URI_CHUNK_SIZE} id(s) each`));
726
783
  for (const [i, chunk] of chunks.entries()) {
727
784
  if (chunks.length > 1)
728
785
  console.log(dim(` chunk ${i + 1}/${chunks.length}: ${chunk.length} id(s) (#${chunk[0]}–#${chunk[chunk.length - 1]})`));
@@ -748,9 +805,9 @@ export async function cmdUnpause(address, flags) {
748
805
  * scope, so one value covers the whole project — they project into `contractURI` on the resolver
749
806
  * AND the on-chain renderer. Works for every token type (1/1 · Series · code). */
750
807
  export const AUTHORSHIP_DEPLOY_FIELDS = [
751
- ['artist', F.artist],
808
+ ['creator', F.creator],
752
809
  ['display-notes', F.displayNotes],
753
- ['artist-links', F.artistLinks],
810
+ ['creator-links', F.creatorLinks],
754
811
  ['license', F.license],
755
812
  ];
756
813
  /** Build the on-chain (inline) collection fields for whichever authorship/rights deploy flags are
@@ -816,24 +873,13 @@ export async function cmdRefresh(address, flags) {
816
873
  console.log(` ${dim('OpenSea ')}${osBase}/assets/${osChain}/${contract}/${tokenId}`);
817
874
  if (explorer)
818
875
  console.log(` ${dim('Etherscan')} ${explorer}/token/${contract}?a=${tokenId}`);
819
- // This line used to claim ERC-4906 had already pinged 4906-aware marketplaces unconditionally.
820
- // An ERC-1155 edition emits NO ERC-4906 (that is exactly why `ping-uri` exists), so on an edition
821
- // the sentence was false in the one direction that costs the creator something: they read "already
822
- // handled", skipped `ping-uri`, and the native URI event was never re-emitted, so nothing
823
- // re-indexed. `ping-uri --help` said the opposite, leaving two commands contradicting each other.
824
- // Best-effort probe: `refresh` otherwise needs no chain read at all, so an RPC miss must still let
825
- // it print its links it just falls back to naming both cases.
826
- const isEdition = await isEditionContract(makePublicClient({ chainKey: CHAIN }), contract).catch(() => null);
827
- if (isEdition === true) {
828
- console.log(dim(' (an ERC-1155 edition emits NO ERC-4906 — nothing auto-pinged. After a URI/renderer re-point also run:'));
829
- console.log(dim(` \`abx ping-uri ${contract} --token-ids ${tokenId}\` to re-emit the native URI event for indexers that honor it.)`));
830
- }
831
- else if (isEdition === false) {
832
- console.log(dim(' (ERC-4906 already pings 4906-aware marketplaces on URI changes; this covers the genesis mint + the rest.)'));
833
- }
834
- else {
835
- console.log(dim(' (on a 721, ERC-4906 already pinged 4906-aware marketplaces; on an ERC-1155 edition nothing did — run `abx ping-uri` there.)'));
836
- }
876
+ // Both lanes now emit ERC-4906 on a URI change, so this is one sentence again. The 1155 lane
877
+ // used to emit only its own config event, which is why this branched and why `ping-uri` exists;
878
+ // an audit found a collection-wide re-point on an edition had NO refresh signal at all, and the
879
+ // fix was to emit the O(1) ERC-4906 range form there too. `ping-uri` is still useful on an
880
+ // edition, but for a narrower reason: it re-emits the NATIVE ERC-1155 `URI` event for indexers
881
+ // that honor only that one.
882
+ console.log(dim(' (ERC-4906 already pings 4906-aware marketplaces on URI changes; this covers the genesis mint + the rest.)'));
837
883
  }
838
884
  // ── transfer ─────────────────────────────────────────────────────────────────
839
885
  export async function cmdTransfer(address, flags) {
@@ -844,7 +890,7 @@ export async function cmdTransfer(address, flags) {
844
890
  // `--token-id` is accepted as an alias for `--token`: EVERY sibling id-taking command (`mint`,
845
891
  // `set-max-supply`, `minter …`) spells it `--token-id`, so an agent that learned the name there
846
892
  // passed it here — where it was silently ignored and id 0 moved instead of the id they named.
847
- // On an edition that means transferring the WRONG ARTWORK with no warning. Both spellings work now;
893
+ // On an edition that means transferring the WRONG WORK with no warning. Both spellings work now;
848
894
  // disagreeing values are refused rather than silently preferring one.
849
895
  if (flags.token !== undefined && flags['token-id'] !== undefined && flags.token !== flags['token-id']) {
850
896
  throw new Error(`--token ${flags.token} and --token-id ${flags['token-id']} disagree — pass one (they are aliases for the same id).`);
@@ -1016,21 +1062,26 @@ export async function cmdSetTransferValidator(address, rest, flags) {
1016
1062
  `Enrollment can never be added to a live collection; if enforcement is required, redeploy with ` +
1017
1063
  `--721c recommended (or --721c 0x…) on the deploy command.`);
1018
1064
  }
1019
- // Codeless-validator guard: the contract refuses a non-zero validator with no code
1020
- // (`InvalidTransferValidator()`) surface it before gas is spent.
1065
+ // Unusable-validator guard: the contract refuses a non-zero validator that fails its own probe —
1066
+ // no code, OR a permissive fallback that would enforce nothing (a Safe, an uninitialised proxy, a
1067
+ // 7702-delegated EOA). Both revert `InvalidTransferValidator()`, so the preflight has to ask the
1068
+ // same question the chain asks; a bare has-code check passed a creator's Safe and then let the
1069
+ // send fail on chain with an error whose documented meaning ("no code") was false for their case.
1021
1070
  if (validator !== zeroAddress) {
1022
- let code;
1023
- try {
1024
- code = await publicClient.getCode({ address: validator });
1025
- }
1026
- catch (err) {
1027
- throw new Error(`couldn't verify the validator has code at ${validator} (${err.message}) — refusing to re-point blind; retry when the RPC answers.`);
1071
+ const probe = await probeTransferValidator(publicClient, validator, { as: contract });
1072
+ if (probe.verdict === 'unreachable') {
1073
+ throw new Error(`couldn't verify the validator at ${validator} (${probe.error ?? 'RPC did not answer'}) — refusing to re-point blind; retry when the RPC answers.`);
1028
1074
  }
1029
- if (!code || code === '0x') {
1075
+ if (probe.verdict !== 'ok') {
1030
1076
  const rec = resolveRecommendedTransferValidator(cid);
1031
- throw new Error(`no contract code at ${validator} on ${CHAIN} — the token would revert InvalidTransferValidator(). ` +
1032
- `A transfer validator must be a DEPLOYED contract on this chain` +
1033
- (rec ? ` (the recommended one: \`abx set-transfer-validator ${contract} recommended\` ${rec})` : '') + `.`);
1077
+ const recHint = rec ? ` (the recommended one: \`abx set-transfer-validator ${contract} recommended\` ${rec})` : '';
1078
+ throw new Error(probe.verdict === 'no-code'
1079
+ ? `no contract code at ${validator} on ${CHAIN} the token would revert InvalidTransferValidator(). ` +
1080
+ `A transfer validator must be a DEPLOYED contract on this chain${recHint}.`
1081
+ : `${validator} has code on ${CHAIN}, but it is not a transfer validator — it answers ANY function call successfully ` +
1082
+ `(a Safe, an uninitialised proxy, or a 7702-delegated EOA does this), so every transfer would silently pass ` +
1083
+ `validation while ERC-165 and getTransferValidator() reported enforcement as ON. The token refuses it too ` +
1084
+ `(InvalidTransferValidator()). Pass a real validator contract${recHint}, or \`none\` to suspend enforcement.`);
1034
1085
  }
1035
1086
  }
1036
1087
  if (eqAddr(status.validator, validator)) {
@@ -1042,6 +1093,167 @@ export async function cmdSetTransferValidator(address, rest, flags) {
1042
1093
  const owner = await read(contract, 'owner');
1043
1094
  await runWrite(contract, prepareSetTransferValidator({ contract, validator, chainId: cid }), flags, owner);
1044
1095
  }
1096
+ // ── the seed source — where a code project's mint randomness comes from ───────
1097
+ // The canonical `AbxSeedSource` is deliberately pseudorandom: replayable after the mint (what makes
1098
+ // generative output verifiable) and computable DURING it (so a buyer can decline an outcome for the
1099
+ // price of gas — and on an EDITION, where the buyer names the id and the id is in the preimage, they
1100
+ // can shop the unminted ids rather than merely decline; bounded by how many remain)
1101
+ // price of gas). We tell creators that the escape hatch for anything lottery-like is "point
1102
+ // `seedSource` at your own IAbxSeedSource over commit-reveal or a VRF oracle" — this is the surface
1103
+ // that makes that promise real instead of a doc claim: `--seed-source` at deploy, `abx
1104
+ // set-seed-source` after, and one probe standing in front of both.
1105
+ /** The canonical `AbxSeedSource` for a chain — the manifest entry (the source of truth) with the
1106
+ * CREATE2 prediction as the cross-chain fallback (the singleton is address-identical everywhere).
1107
+ * Deliberately does NOT go through `resolveSeedSource`, which reads `ABX_SEED_SOURCE`: that env var
1108
+ * names the source you have CONFIGURED, which is exactly the thing `canonical` exists to name the
1109
+ * alternative to. Asking for `canonical` and silently getting your custom override would be the
1110
+ * worst possible answer. */
1111
+ export function canonicalSeedSource(chainId) {
1112
+ return getDeployment(chainId).seedSource ?? predictSeedSource();
1113
+ }
1114
+ /**
1115
+ * Parse a seed-source choice — the shared grammar of the deploy flag (`--seed-source`) and the owner
1116
+ * op (`abx set-seed-source`):
1117
+ * - `canonical` (or a bare `--seed-source`) → the chain's canonical `AbxSeedSource`, i.e. the
1118
+ * default; spelled out so a creator can state it rather than rely on absence meaning it;
1119
+ * - a `0x…` address → checksum-validated (EIP-55) and returned canonicalized. The caller MUST
1120
+ * probe it (see {@link refuseUnusableSeedSource}) — a bad address here is silent until the
1121
+ * first buyer, and then every mint reverts;
1122
+ * - `none`/zero → `allowNone` decides: the owner op clears with it (future mints draw no seed);
1123
+ * the DEPLOY flag refuses it and points at `--no-seed`, which already means exactly that. Two
1124
+ * spellings of one thing is how a creator ends up unsure which they used.
1125
+ * Pure (no RPC) — mirrors `parseTransferValidatorValue`, deliberately, so the two knobs read the
1126
+ * same. Exported for the regression test.
1127
+ */
1128
+ export function parseSeedSourceValue(raw, opts) {
1129
+ const s = raw.trim().toLowerCase();
1130
+ if (s === 'true' || s === '' || s === 'canonical' || s === 'default')
1131
+ return canonicalSeedSource(opts.chainId);
1132
+ if (s === 'none' || s === 'zero' || s === '0' || s === '0x0' || s === zeroAddress) {
1133
+ if (opts.allowNone)
1134
+ return zeroAddress;
1135
+ throw new Error(`--seed-source none is refused: "no mint-time seed" already has a flag — pass \`--no-seed\` instead. ` +
1136
+ `(One meaning, one spelling: --seed-source names WHERE the seed comes from, --no-seed says there isn't one.)`);
1137
+ }
1138
+ const trimmed = raw.trim();
1139
+ if (!/^0x[0-9a-fA-F]{40}$/.test(trimmed)) {
1140
+ throw new Error(`seed source must be 'canonical'${opts.allowNone ? ", 'none'," : ''} or a 0x address (0x + 40 hex); got '${raw}'`);
1141
+ }
1142
+ // Strict EIP-55 (see the identical note on parseTransferValidatorValue): `getAddress` alone only
1143
+ // normalizes, so a mis-cased paste would sail through — and a seed source pointed one nibble off
1144
+ // is a collection whose every mint reverts.
1145
+ if (!isAddress(trimmed, { strict: true })) {
1146
+ throw new Error(`seed source address failed its EIP-55 checksum: '${raw}' — paste it exactly (or all-lowercase).`);
1147
+ }
1148
+ return getAddress(trimmed);
1149
+ }
1150
+ /**
1151
+ * Turn a non-`ok` {@link SeedSourceProbe} into the refusal a creator can act on. One place, because
1152
+ * the deploy flag and the owner op hit the identical failure shapes and must say the identical
1153
+ * thing; `context` only names which surface asked (so the fix names the right flag).
1154
+ *
1155
+ * Every branch is a REFUSAL, not a warning. A seed source is the one setting where "looks fine, is
1156
+ * broken" is the normal outcome of a mistake: `seedSource()` reads back exactly what you set, the
1157
+ * `SeedSourceSet` event fires, `abx state` shows it — and then the first mint reverts in the ABI
1158
+ * decode of a return that isn't 32 bytes. There is no later moment at which this gets easier to
1159
+ * notice, so it is caught here or it is caught by a buyer.
1160
+ */
1161
+ export function refuseUnusableSeedSource(probe, context) {
1162
+ const { flag, chainLabel } = context;
1163
+ const head = `${flag}: ${probe.address} is not a usable seed source on ${chainLabel}`;
1164
+ const tail = `\n A seed source must answer \`seed(uint256 tokenId, address to)\` with 32 bytes — the token calls it ` +
1165
+ `SYNCHRONOUSLY inside every mint and decodes the result as \`bytes32\`, so anything else reverts the mint.`;
1166
+ switch (probe.verdict) {
1167
+ case 'no-code':
1168
+ throw new Error(`${head} — there is no contract code there.${tail}\n` +
1169
+ ` (A Solidity call to a codeless address succeeds with empty returndata, so the revert would land in the ` +
1170
+ `decode, at mint time, for every buyer. Check the address and the chain — ${flag} is chain-specific.)`);
1171
+ case 'empty-return':
1172
+ throw new Error(`${head} — it has code, answered, and returned NOTHING.${tail}\n` +
1173
+ ` This is the permissive-fallback shape: a Safe (its fallback returns empty for an unset handler), an ` +
1174
+ `uninitialised proxy, or an EIP-7702-delegated EOA. Pasting your own wallet/Safe here is the common way in. ` +
1175
+ `A seed source is a purpose-built contract implementing IAbxSeedSource — commit-reveal or a VRF oracle.`);
1176
+ case 'short-return':
1177
+ throw new Error(`${head} — it answered with only ${probe.returnedBytes} byte(s), not 32.${tail}\n` +
1178
+ ` Whatever is deployed there has a different ABI; it is not an IAbxSeedSource.`);
1179
+ case 'reverted':
1180
+ throw new Error(`${head} — the call reverted (${probe.error ?? 'no reason returned'}).${tail}\n` +
1181
+ ` Two ways in: (1) it isn't a seed source at all — a token, a renderer, a registry has no such function; ` +
1182
+ `(2) it IS one, but it refuses right now (gating callers, or not yet armed/committed). Both are refusals here: ` +
1183
+ `a source that cannot answer today cannot answer at mint either, and its revert would bubble through the mint. ` +
1184
+ `Arm the source first, then point at it.`);
1185
+ default:
1186
+ // `unreachable` and any verdict a future SDK adds: refuse rather than proceed. A `default` (not
1187
+ // a `case 'unreachable'`) so a new verdict cannot silently become "allowed" by falling through.
1188
+ throw new Error(`${head} — couldn't verify it (${probe.verdict}${probe.error ? `: ${probe.error}` : ''}). ` +
1189
+ `Refusing to configure a seed source blind; retry when the RPC answers.`);
1190
+ }
1191
+ }
1192
+ /**
1193
+ * `abx set-seed-source <address> <0x…|canonical|none>` — re-point (or clear) where a code project
1194
+ * draws its mint seeds. Owner-only; **future mints only**, because a seed settles the moment it is
1195
+ * assigned and nothing rewrites it. Refuses before any signing: a target with no Seed Source
1196
+ * extension (a 1/1 or an image Series — there is nothing to set), and any candidate the probe
1197
+ * rejects.
1198
+ *
1199
+ * This op is deliberately loud about one thing the chain cannot enforce: re-pointing mid-sale means
1200
+ * tokens minted before and after draw from **different** sources. That is legitimate (it is how you
1201
+ * fix a broken source, or hand over to a commit-reveal one), and it is public — `SeedSourceSet` is
1202
+ * on the event spine and `abx state` prints the current source — but a collector who bought early
1203
+ * has no way to know it will happen. So the print names the split when supply already exists.
1204
+ */
1205
+ export async function cmdSetSeedSource(address, rest, flags) {
1206
+ const usage = 'abx set-seed-source <address> <0x…|canonical|none> [--sign|--unsigned] [--dry-run]';
1207
+ const contract = requireAddress(address, usage);
1208
+ const [raw] = positionalArgs(rest);
1209
+ if (!raw) {
1210
+ console.error(`usage: ${usage}\n`);
1211
+ process.exitCode = 1;
1212
+ return;
1213
+ }
1214
+ const cid = chainId();
1215
+ const source = parseSeedSourceValue(raw, { chainId: cid, allowNone: true });
1216
+ const publicClient = makePublicClient({ chainKey: CHAIN });
1217
+ await assertContractExists(contract);
1218
+ // Extension guard FIRST — a read, not a send. `seedSource()` is absent on a 1/1 / image Series, so
1219
+ // `setSeedSource` there is a call to a selector the contract doesn't have: it would revert with no
1220
+ // reason bytes at all. Say what the target actually is instead.
1221
+ const current = await readSeedSource(publicClient, contract);
1222
+ if (current === undefined) {
1223
+ throw new Error(`${contract} has no seed source to set — the Seed Source extension is composed only by CODE projects ` +
1224
+ `(SeriesCode / EditionCode, i.e. \`abx deploy-code\`). An image 1/1 or Series has no mint-time seed at all, ` +
1225
+ `and the extension can't be added to a live contract.`);
1226
+ }
1227
+ if (eqAddr(current, source)) {
1228
+ console.log(dim(` no change — the seed source is already ${source === zeroAddress ? 'cleared (0x0 — no mint-time seed)' : source}. Nothing sent.`));
1229
+ return;
1230
+ }
1231
+ if (source !== zeroAddress) {
1232
+ const probe = await probeSeedSource(publicClient, source, { as: contract });
1233
+ if (probe.verdict !== 'ok')
1234
+ refuseUnusableSeedSource(probe, { flag: 'set-seed-source', chainLabel: CHAIN });
1235
+ }
1236
+ const label = (a) => (eqAddr(a, zeroAddress) ? 'none (0x0 — no mint-time seed)' : eqAddr(a, canonicalSeedSource(cid)) ? `${a} (canonical AbxSeedSource)` : a);
1237
+ console.log(dim(` seed source: ${label(current)} → ${label(source)}`));
1238
+ // Seeds already assigned are settled — say so, and say what the split means. `totalSupply()` is a
1239
+ // 721 getter (an edition's is per-id), so the count is best-effort; the note itself is not, because
1240
+ // "future mints only" is the part a creator has to understand before sending.
1241
+ const minted = await readSeries(contract, 'totalSupply').catch(() => null);
1242
+ if (minted === null || minted > 0n) {
1243
+ console.log(dim(` ${minted === null ? 'Tokens' : `${minted} token(s)`} already minted carry a seed drawn from ${label(current)} — settled, and unchanged by this. ` +
1244
+ `This applies to FUTURE mints only, so a part-sold collection ends up spanning two sources. The change is public ` +
1245
+ `(SeedSourceSet on the event spine; \`abx state ${contract}\` prints the current one), but a collector who ` +
1246
+ `already bought won't be told — if the drop is live, consider pausing and saying so.`));
1247
+ }
1248
+ if (source === zeroAddress) {
1249
+ console.log(dim(' clearing means future mints draw NO seed at all — a generative program that expects one will render blank/identical.'));
1250
+ }
1251
+ else if (!eqAddr(source, canonicalSeedSource(cid))) {
1252
+ console.log(dim(" custom source — its randomness properties are now yours to state to buyers; ABX makes no claim about them."));
1253
+ }
1254
+ const owner = await read(contract, 'owner');
1255
+ await runWrite(contract, prepareSetSeedSource({ contract, seedSource: source, chainId: cid }), flags, owner);
1256
+ }
1045
1257
  // ── multi-chunk on-chain content (the `reader` path) ─────────────────────────
1046
1258
  // The plan math (chunk count + tx shape), the cost-model constants, and the actual
1047
1259
  // plan→resolve-store→stage→encode orchestration all live in the SDK's staging.ts, layered on
@@ -1105,13 +1317,55 @@ export function sessionStagingSender(session) {
1105
1317
  return async (tx) => (await session.send(tx)).receipt;
1106
1318
  }
1107
1319
  const kb = (n) => `${n < 10 * 1024 ? (n / 1024).toFixed(1) : Math.round(n / 1024)}KB`;
1108
- /** Warn (loudly) when content is too big for on-chain to be the cost-sane choice. Returns true if it warned. */
1109
- export function warnIfLargeForOnChain(bytesLen, label) {
1110
- if (!exceedsOnchainSoftLimit(bytesLen))
1111
- return false;
1112
- console.log(yellow(` ⚠ ${label} is ${kb(bytesLen)} — large for ON-CHAIN storage (~200 gas/byte). Past ~${kb(ONCHAIN_IMAGE_SOFT_LIMIT)}/file, on-chain is FAR more expensive than off-chain, not cheaper.`));
1113
- console.log(dim(` For art this size prefer off-chain: --backend arweave (pay-once, permanent) or a hosted resolver + IPFS. Use on-chain only if self-resolution is a hard requirement.`));
1114
- return true;
1320
+ const mgas = (n) => `~${(n / 1_000_000).toFixed(0)}M gas`;
1321
+ /** The flag that overrides {@link guardOnChainSize}'s refusal — a creator who genuinely wants a
1322
+ * project no default RPC can read. Named for what accepting it means, not for the check it skips. */
1323
+ export const ALLOW_UNREADABLE_FLAG = 'allow-unreadable-onchain';
1324
+ /**
1325
+ * The size gate for content going on-chain, on the axis that actually binds: **render gas**, not
1326
+ * storage cost. `tokenURI` reassembles the whole document per call — measured at ~360,000-405,000 gas
1327
+ * per KB of on-chain content across 10-100 KB and climbing above that (the cost is superlinear: memory
1328
+ * expansion is quadratic), within ~1% for `inline` and `reader` up to 75 KB (it's the renderer's string
1329
+ * building, not the storage mechanism) — so the readable envelope is much tighter than the cost-sane one.
1330
+ *
1331
+ * - under ~40 KB: quiet, plus the cost note if it's past the point where off-chain is simply cheaper.
1332
+ * - from ~40 KB: **warn** — the read needs a deliberately high-gas RPC.
1333
+ * - past ~100 KB (~40M gas): **refuse** — a deliberate margin, not the wall. geth's own 50M
1334
+ * `--rpc.gascap` default holds to ~120 KB, but hosted providers commonly cap well below it, so past
1335
+ * 100 KB the token reads as a revert to marketplaces and indexers on ordinary RPCs.
1336
+ * `--allow-unreadable-onchain` ships it anyway.
1337
+ *
1338
+ * Deliberately a toolkit gate and not an on-chain one: bricking a contract over a read budget is
1339
+ * worse than needing a capable RPC, and this is the moment where the creator can still choose
1340
+ * differently. Returns true if it said anything.
1341
+ */
1342
+ export function guardOnChainSize(bytesLen, label, allowUnreadable = false) {
1343
+ const verdict = classifyOnchainReadSize(bytesLen);
1344
+ const gas = mgas(tokenUriGasEstimate(bytesLen));
1345
+ if (verdict === 'refuse' && !allowUnreadable) {
1346
+ throw new CliError(`${label} is ${kb(bytesLen)} of on-chain content — reading tokenURI would cost ${gas}, past what a default node will serve ` +
1347
+ `(geth's --rpc.gascap defaults to 50M; hosted providers commonly cap lower). Marketplaces, indexers, and other contracts ` +
1348
+ `would see a REVERT rather than your token, so this is refused by default.\n` +
1349
+ ` Three ways forward:\n` +
1350
+ ` • Off-chain custody: --backend arweave (pay-once, permanent) or ipfs|cloud — the bytes live off-chain, the JSON still resolves on-chain, and the read stays small.\n` +
1351
+ ` • Smaller SOURCE content: a leaner SVG rather than a raster. Note --compress fastlz does not help here — it cuts the write cost, and the renderer rebuilds the full uncompressed bytes on every read.\n` +
1352
+ ` • Ship it unreadable on purpose: re-run with --${ALLOW_UNREADABLE_FLAG}. That is a real choice (maximum durability, nothing to run, ever) — and what you accept is that rendering needs a deliberately high-gas endpoint, and most marketplaces and indexers will show nothing at all.`);
1353
+ }
1354
+ if (verdict === 'refuse') {
1355
+ console.log(yellow(` ⚠ ${label} is ${kb(bytesLen)} on-chain — tokenURI costs ${gas} to read, past a default node's eth_call cap. Shipping anyway (--${ALLOW_UNREADABLE_FLAG}).`));
1356
+ console.log(dim(` What you're accepting: marketplaces and indexers on ordinary RPCs will see a revert, not your token; rendering needs a deliberately high-gas endpoint. Assemble from the generator's piecewise getters (\`document\`, \`tokenDataJson\`, \`dependencyTag\`) when one eth_call won't fit.`));
1357
+ return true;
1358
+ }
1359
+ if (verdict === 'warn') {
1360
+ console.log(yellow(` ⚠ ${label} is ${kb(bytesLen)} on-chain — reading tokenURI costs ${gas} (~360-405k gas per KB of content and climbing, whatever the representation).`));
1361
+ console.log(dim(` That is served by a high-gas RPC but not by every hosted one, and it is past what another contract can spend inside a transaction. Under ~${kb(ONCHAIN_READ_WARN_BYTES)} of on-chain content per token is the routinely-readable range; off-chain custody (--backend arweave|ipfs|cloud) keeps the JSON on-chain and the read small.`));
1362
+ return true;
1363
+ }
1364
+ if (exceedsOnchainSoftLimit(bytesLen)) {
1365
+ console.log(dim(` ${label} is ${kb(bytesLen)} — past ~${kb(ONCHAIN_IMAGE_SOFT_LIMIT)}/file on-chain costs more to write than off-chain (~200 gas/byte), so pick it for self-resolution, not for price. Reading tokenURI: ${gas}.`));
1366
+ return true;
1367
+ }
1368
+ return false;
1115
1369
  }
1116
1370
  /**
1117
1371
  * Pure staging plan for a piece of content — chunk count + transaction shape — with NO chain
@@ -1158,8 +1412,9 @@ function planTxLine(p, finalLabel) {
1158
1412
  * These store writes use the env key (the data contracts are ownerless); the owner only
1159
1413
  * signs the field set that references the manifest.
1160
1414
  */
1161
- async function putContentOnChain(bytes, compress, field, send, finalLabel = '1 owner field-set', store) {
1162
- warnIfLargeForOnChain(bytes.length, `'${field}'`);
1415
+ async function putContentOnChain(bytes, compress, field, send, finalLabel = '1 owner field-set', store, // pre-resolved store (batch staging reuses one); else resolve/deploy here
1416
+ allowUnreadable = false) {
1417
+ guardOnChainSize(bytes.length, `'${field}'`, allowUnreadable);
1163
1418
  const content = compress === 'gzip' ? new Uint8Array(gzipSync(bytes)) : new Uint8Array(bytes);
1164
1419
  const publicClient = makePublicClient({ chainKey: CHAIN });
1165
1420
  return stageFieldContent({
@@ -1189,10 +1444,12 @@ async function putContentOnChain(bytes, compress, field, send, finalLabel = '1 o
1189
1444
  * true count instead of leaving the agent to guess). The final tx here is the deploy that
1190
1445
  * bakes in the reader field, so the count is staging tx(s) + the deploy.
1191
1446
  */
1192
- export function previewImageStaging(imagePath, compress) {
1447
+ export function previewImageStaging(imagePath, compress, allowUnreadable = false) {
1193
1448
  const bytes = readFileSync(resolvePath(imagePath));
1194
1449
  const p = computeContentPlan(bytes, compress);
1195
- warnIfLargeForOnChain(bytes.length, basename(resolvePath(imagePath)));
1450
+ // The gate runs on the dry-run too: a refusal a creator only meets after the first staging tx has
1451
+ // landed is a refusal that already cost them gas.
1452
+ guardOnChainSize(bytes.length, basename(resolvePath(imagePath)), allowUnreadable);
1196
1453
  return (`would stage ${basename(resolvePath(imagePath))} on-chain (chunk store): ` +
1197
1454
  `${planSizeLine(bytes.length, p, compress)} as ${p.representation}; ` +
1198
1455
  `${planTxLine(p, '1 deploy (bakes the reader field + mints)')}`);
@@ -1204,9 +1461,9 @@ export function previewImageStaging(imagePath, compress) {
1204
1461
  * image" is just those staging tx(s) + the single deploy — no post-deploy `set-field`.
1205
1462
  * The manifest is deployer-independent, so this is computed once and reused for any signer.
1206
1463
  */
1207
- export async function stageImageField(imagePath, compress, send) {
1464
+ export async function stageImageField(imagePath, compress, send, allowUnreadable = false) {
1208
1465
  const bytes = readFileSync(resolvePath(imagePath));
1209
- const { value, representation } = await putContentOnChain(bytes, compress, 'image', send, '1 deploy (bakes the reader field + mints)');
1466
+ const { value, representation } = await putContentOnChain(bytes, compress, 'image', send, '1 deploy (bakes the reader field + mints)', undefined, allowUnreadable);
1210
1467
  return {
1211
1468
  field: { field: encodeTag(F.image), representation: encodeTag(representation), value },
1212
1469
  note: `image: ${bytes.length}B staged ON-CHAIN via reader (${representation}) — baked into the deploy`,
@@ -1216,15 +1473,15 @@ export async function stageImageField(imagePath, compress, send) {
1216
1473
  * Stage MANY images on-chain against a **single, shared** chunk store — the batch form of
1217
1474
  * {@link stageImageField} for a Series. The store is resolved/deployed **once** (not per token),
1218
1475
  * then every file is staged against it and returned as its own `reader`-backed `image` field.
1219
- * Each field still points at its own manifest, so each token's artwork is independent; they just
1476
+ * Each field still points at its own manifest, so each token's work is independent; they just
1220
1477
  * share the store contract. Returns the fields in input order (token order).
1221
1478
  */
1222
- export async function stageImageFieldsBatch(imagePaths, compress, send, storeOverride) {
1479
+ export async function stageImageFieldsBatch(imagePaths, compress, send, storeOverride, allowUnreadable = false) {
1223
1480
  const store = await ensureChunkStore(send, storeOverride);
1224
1481
  const fields = [];
1225
1482
  for (const imagePath of imagePaths) {
1226
1483
  const bytes = readFileSync(resolvePath(imagePath));
1227
- const { value, representation } = await putContentOnChain(bytes, compress, 'image', send, '1 deploy (bakes the reader fields + mints)', store);
1484
+ const { value, representation } = await putContentOnChain(bytes, compress, 'image', send, '1 deploy (bakes the reader fields + mints)', store, allowUnreadable);
1228
1485
  fields.push({ field: encodeTag(F.image), representation: encodeTag(representation), value });
1229
1486
  }
1230
1487
  return { fields, store };
@@ -1299,6 +1556,8 @@ export async function cmdSetField(address, flags) {
1299
1556
  if (staging && isDryRun(flags)) {
1300
1557
  const bytes = readFileSync(resolvePath(flags.file));
1301
1558
  const p = computeContentPlan(bytes, parseCompress(flags.compress));
1559
+ // Same render-gas gate the real path applies, so a refusal costs a dry run rather than gas.
1560
+ guardOnChainSize(bytes.length, `'${field}'`, allowUnreadableOnchain(flags));
1302
1561
  console.log(`\n ${bold('◆ set-field ' + field)} ${dim('(dry run — nothing staged or sent)')}`);
1303
1562
  console.log(` ${dim('scope'.padEnd(12))} ${collection ? 'collection' : `token #${flags.token ?? '0'}`}`);
1304
1563
  console.log(` ${planSizeLine(bytes.length, p, parseCompress(flags.compress))}`);
@@ -1330,7 +1589,7 @@ export async function cmdSetField(address, flags) {
1330
1589
  });
1331
1590
  try {
1332
1591
  await session.connect();
1333
- const { value, representation } = await putContentOnChain(bytes, compress, field, sessionStagingSender(session));
1592
+ const { value, representation } = await putContentOnChain(bytes, compress, field, sessionStagingSender(session), undefined, undefined, allowUnreadableOnchain(flags));
1334
1593
  await session.send(buildTx(value, representation));
1335
1594
  }
1336
1595
  finally {
@@ -1344,7 +1603,7 @@ export async function cmdSetField(address, flags) {
1344
1603
  let representation;
1345
1604
  if (staging) {
1346
1605
  const compress = parseCompress(flags.compress);
1347
- ({ value, representation } = await putContentOnChain(readFileSync(resolvePath(flags.file)), compress, field, envStagingSender()));
1606
+ ({ value, representation } = await putContentOnChain(readFileSync(resolvePath(flags.file)), compress, field, envStagingSender(), undefined, undefined, allowUnreadableOnchain(flags)));
1348
1607
  }
1349
1608
  else if (flags.text && flags.text !== 'true') {
1350
1609
  value = toHex(flags.text);
@@ -1475,13 +1734,13 @@ export async function cmdAttach(rest, flags) {
1475
1734
  const uriBase = await read(contract, 'tokenURIBase').catch(() => '');
1476
1735
  const artifactsPath = `/t/${chainId()}/${contract}/${flags.token ?? '0'}`;
1477
1736
  if (uriBase && uriBase.trim() !== '') {
1478
- console.log(dim(` → listed in this project's resolver artifacts (${artifactsPath} and /data/<key>); a bare on-chain tokenURI carries reserved fields plus abx_params only.`));
1737
+ console.log(dim(` → listed in this project's resolver artifacts (${artifactsPath} and /data/<key>); a bare on-chain tokenURI carries reserved fields only (params are chain state — read them with abx tokens).`));
1479
1738
  }
1480
1739
  else {
1481
1740
  console.log(yellow(' ⚠ ') +
1482
- `this project resolves ON-CHAIN (no resolver base baked in), and the on-chain document carries reserved fields plus the computed ${bold('abx_params')} block only — ` +
1741
+ `this project resolves ON-CHAIN (no resolver base baked in), and the on-chain document carries reserved fields only — ` +
1483
1742
  `so ${pairs.map((p) => bold(p.key)).join(', ')} will NOT appear in ${bold('tokenURI')}. The bytes are stored and provable, but nothing surfaces them to a marketplace or wallet. ` +
1484
- dim('(Configured params DO appear on-chain — attachments are the surface that needs a resolver.)'));
1743
+ dim('(Params are chain state, readable with abx tokens and by any contract call — attachments are the surface that needs a resolver.)'));
1485
1744
  console.log(dim(` to make attached artifacts visible, point the project at a resolver (${bold('abx deploy-resolver')}, or a managed one via ${bold('abx add <addr> --remote <name>')}) — it serves the listing at ${artifactsPath}.`));
1486
1745
  }
1487
1746
  // ONE transaction for the whole set. `batchOps` folds a same-target run into a `multicall` and
@@ -1506,7 +1765,7 @@ export async function cmdAttach(rest, flags) {
1506
1765
  dim(` verify (a resolver serves the complete listing): `) +
1507
1766
  `abx tokenuri ${contract}${id === '0' ? '' : ` --token ${id}`} --fetch` +
1508
1767
  dim(` ${collection ? '' : `→ artifacts[].key ${pairs.map((p) => `"${p.key}"`).join(', ')}; /data/<key> fetches each`}\n`) +
1509
- dim(` (The complete file listing is a resolver surface — the bare on-chain tokenURI enumerates reserved fields plus abx_params. Params never need a resolver; attachments do.)\n`));
1768
+ dim(` (The complete file listing is a resolver surface — the bare on-chain tokenURI enumerates reserved fields. Params never need a resolver, they are read straight off the contract; attachments do.)\n`));
1510
1769
  }
1511
1770
  }
1512
1771
  // ── lock-field ───────────────────────────────────────────────────────────────
@@ -1514,9 +1773,9 @@ export async function cmdAttach(rest, flags) {
1514
1773
  * Refuse `lock-field` on a name that is a declared PARAM key.
1515
1774
  *
1516
1775
  * Fields and params are two separate namespaces that may share a name, and `lock-field` only ever
1517
- * locks the *field*. A tester welded `grid` — a `Bytes` param holding the artwork — with
1776
+ * locks the *field*. A tester welded `grid` — a `Bytes` param holding the work — with
1518
1777
  * `lock-field --field grid`, got "permanent", got `tokenFieldLocked(0,"grid") == true`, and then
1519
- * overwrote the artwork with `configure-param` on the next call. Every individual statement the CLI
1778
+ * overwrote the work with `configure-param` on the next call. Every individual statement the CLI
1520
1779
  * made was true; together they promised a protection that did not exist. Permanence is the pitch, so
1521
1780
  * this refuses rather than warns, and names the mechanism that actually welds a param.
1522
1781
  */
@@ -1581,13 +1840,14 @@ export async function cmdSetRenderer(address, flags) {
1581
1840
  }
1582
1841
  // ── lock-uri (freeze the URI config) ──────────────────────────────────────────
1583
1842
  /** Freeze a scope's URI config (pointer + renderer) forever. With the fields also
1584
- * locked, the resolved metadata is provably immutable — the on-chain twin of a
1585
- * frozen `tokenURI`. Scope: token (default) or `--collection`. */
1843
+ * locked, the stored metadata can never change again — the on-chain twin of a frozen
1844
+ * `tokenURI`. Not the same as a frozen OUTPUT: params have no lock and the renderer
1845
+ * projects them, and a Registry dependency resolves live. Scope: token or `--collection`. */
1586
1846
  export async function cmdLockUri(address, flags) {
1587
1847
  const contract = requireAddress(address, 'abx lock-uri <address> [--collection] [--sign|--unsigned]');
1588
1848
  const collection = !!flags.collection;
1589
1849
  const owner = await read(contract, 'owner');
1590
- console.log(dim(` note: locking the ${collection ? 'contractURI' : 'tokenURI'} config is permanent — pointer + renderer can never change again.`));
1850
+ console.log(dim(` note: locking the ${collection ? 'contractURI' : 'tokenURI'} config is permanent — pointer + renderer can never change again. Locked metadata is not a locked output: params stay writable and the renderer serves them.`));
1591
1851
  const tx = collection
1592
1852
  ? prepareLockContractURI({ contract, chainId: chainId() })
1593
1853
  : prepareLockTokenURI({ contract, chainId: chainId() });
@@ -1710,7 +1970,7 @@ function parseSalePrice(flags, usage) {
1710
1970
  * the token, set a primary payee) and whether the token is paused.
1711
1971
  *
1712
1972
  * On an EDITION (OneOfOneEdition/EditionImage/EditionCode), `--token-id` is REQUIRED (sales are
1713
- * keyed `(token, id)` — a per-artwork price, not one project-wide sale) and the sale routes to the
1973
+ * keyed `(token, id)` — a per-work price, not one project-wide sale) and the sale routes to the
1714
1974
  * shared `AbxFixedPriceMinter1155` instead. Refused on a 721 target.
1715
1975
  */
1716
1976
  export async function cmdMinterConfigure(address, flags) {
@@ -1753,10 +2013,15 @@ export async function cmdMinterConfigure(address, flags) {
1753
2013
  console.log(dim(` price ${isEth ? `${formatEther(price)} ETH` : `${price} units of ${erc20}`} / copy`));
1754
2014
  console.log(dim(` allocation ${allocation}`));
1755
2015
  await runMinterWrite(prepareConfigureSale1155({ minter, token, tokenId, paymentToken: erc20, price, allocation, chainId: chainId() }), flags, owner);
2016
+ // Through the EDITION ABI, for the same reason `minter show` spells out: the generic `read()`
2017
+ // is the 721 1/1 ABI, which has no minter()/primaryPayee() at all, so the call throws
2018
+ // client-side and the `.catch` swallows it into zeroAddress. That printed "⚠ assign this
2019
+ // minter" + "⚠ set a primary payee" on an edition that already had both — the same false
2020
+ // "not assigned" bug as the show path, surviving here because only show got fixed in 2026-08-05.
1756
2021
  const [assignedMinter, payee, paused] = await Promise.all([
1757
- read(token, 'minter').catch(() => zeroAddress),
1758
- read(token, 'primaryPayee').catch(() => zeroAddress),
1759
- read(token, 'paused').catch(() => false),
2022
+ readEdition(token, 'minter').catch(() => zeroAddress),
2023
+ readEdition(token, 'primaryPayee').catch(() => zeroAddress),
2024
+ readEdition(token, 'paused').catch(() => false),
1760
2025
  ]);
1761
2026
  const assigned = assignedMinter.toLowerCase() === minter.toLowerCase();
1762
2027
  console.log('');
@@ -1931,15 +2196,17 @@ export async function cmdMinterBuy(address, flags) {
1931
2196
  throw new Error(`no sale configured for ${token} #${tokenId} — run: abx minter configure ${token} --token-id ${tokenId} …`);
1932
2197
  const isEth = sale.paymentToken === zeroAddress;
1933
2198
  const to = flags.to && flags.to !== 'true' ? flags.to : undefined;
1934
- // Payment math: price × quantity, computed ONCE and shown in the confirm/dry-run readout below
1935
- // (via runMinterWrite gatedSend's own preview) the exact ETH the tx attaches.
1936
- const value = isEth ? sale.price * quantity : 0n;
2199
+ // Payment math AND the buyer's terms bound both come off `sale` inside the SDK op (one
2200
+ // computation of price × quantity, for the attached ETH and for `maxTotalPrice`) — the terms
2201
+ // just read are the terms the tx commits to, so a mid-flight `configure` reverts instead of
2202
+ // spending more. Shown in the confirm/dry-run readout below (runMinterWrite → gatedSend).
2203
+ const total = sale.price * quantity;
1937
2204
  if (!isEth) {
1938
- console.log(yellow(` ERC-20 sale: the buyer must have approved ${minter} to spend ${sale.price * quantity} units of ${sale.paymentToken} first (else the tx reverts).`));
2205
+ console.log(yellow(` ERC-20 sale: the buyer must have approved ${minter} to spend ${total} units of ${sale.paymentToken} first (else the tx reverts).`));
1939
2206
  }
1940
2207
  console.log(dim(` buying ${quantity} cop${quantity === 1n ? 'y' : 'ies'} of #${tokenId}${to ? ` → ${to}` : ' → signer'} for ` +
1941
- `${isEth ? `${formatEther(value)} ETH (${formatEther(sale.price)} × ${quantity})` : `${value} units (${sale.price} × ${quantity})`}`));
1942
- await runMinterWrite(preparePurchase1155({ minter, token, tokenId, quantity, to, value, chainId: chainId() }), flags);
2208
+ `${isEth ? `${formatEther(total)} ETH (${formatEther(sale.price)} × ${quantity})` : `${total} units (${sale.price} × ${quantity})`}`));
2209
+ await runMinterWrite(preparePurchase1155({ minter, token, tokenId, quantity, sale, to, chainId: chainId() }), flags);
1943
2210
  console.log(dim(` next: \`abx refresh ${token}\` so marketplaces pick up the change.`));
1944
2211
  return;
1945
2212
  }
@@ -1954,12 +2221,13 @@ export async function cmdMinterBuy(address, flags) {
1954
2221
  throw new Error(`no sale configured for ${token} — run: abx minter configure ${token} …`);
1955
2222
  const isEth = sale.paymentToken === zeroAddress;
1956
2223
  const to = flags.to && flags.to !== 'true' ? flags.to : undefined;
1957
- const value = isEth ? sale.price : 0n;
1958
2224
  if (!isEth) {
1959
2225
  console.log(yellow(` ERC-20 sale: the buyer must have approved ${minter} to spend ${sale.price} units of ${sale.paymentToken} first (else the tx reverts).`));
1960
2226
  }
1961
2227
  console.log(dim(` buying 1 token${to ? ` → ${to}` : ' → signer'} for ${isEth ? `${formatEther(sale.price)} ETH` : `${sale.price} units`}`));
1962
- await runMinterWrite(preparePurchase({ minter, token, to, value, chainId: chainId() }), flags);
2228
+ // The terms just read go into the tx as the buyer's bound (SDK op derives value + maxPrice from
2229
+ // `sale`), so an owner who re-`configure`s mid-flight gets a revert, not the buyer's allowance.
2230
+ await runMinterWrite(preparePurchase({ minter, token, sale, to, chainId: chainId() }), flags);
1963
2231
  console.log(dim(` next: \`abx refresh ${token}\` so marketplaces pick up the new token.`));
1964
2232
  }
1965
2233
  // ── PostParam schemas, after deploy ──────────────────────────────────────────