@artblocks/abx-cli 0.1.0-alpha.20 → 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 (82) hide show
  1. package/CHANGELOG.md +1081 -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 +523 -117
  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 +171 -15
  14. package/dist/commands/project.js.map +1 -1
  15. package/dist/commands/reads.d.ts.map +1 -1
  16. package/dist/commands/reads.js +35 -5
  17. package/dist/commands/reads.js.map +1 -1
  18. package/dist/commands/scaffold.d.ts +3 -1
  19. package/dist/commands/scaffold.d.ts.map +1 -1
  20. package/dist/commands/scaffold.js +81 -29
  21. package/dist/commands/scaffold.js.map +1 -1
  22. package/dist/commands/service.js +1 -1
  23. package/dist/commands/service.js.map +1 -1
  24. package/dist/commands/storage.d.ts.map +1 -1
  25. package/dist/commands/storage.js +11 -3
  26. package/dist/commands/storage.js.map +1 -1
  27. package/dist/commands/submit-app.d.ts +58 -0
  28. package/dist/commands/submit-app.d.ts.map +1 -0
  29. package/dist/commands/submit-app.js +512 -0
  30. package/dist/commands/submit-app.js.map +1 -0
  31. package/dist/config.d.ts +1 -15
  32. package/dist/config.d.ts.map +1 -1
  33. package/dist/config.js +18 -1
  34. package/dist/config.js.map +1 -1
  35. package/dist/flag-allowlists.d.ts +53 -0
  36. package/dist/flag-allowlists.d.ts.map +1 -0
  37. package/dist/flag-allowlists.js +152 -0
  38. package/dist/flag-allowlists.js.map +1 -0
  39. package/dist/flags.d.ts +6 -2
  40. package/dist/flags.d.ts.map +1 -1
  41. package/dist/flags.js +23 -0
  42. package/dist/flags.js.map +1 -1
  43. package/dist/kind.d.ts +8 -3
  44. package/dist/kind.d.ts.map +1 -1
  45. package/dist/kind.js +25 -0
  46. package/dist/kind.js.map +1 -1
  47. package/dist/main.js +166 -53
  48. package/dist/main.js.map +1 -1
  49. package/dist/mintpage.d.ts.map +1 -1
  50. package/dist/mintpage.js +29 -4
  51. package/dist/mintpage.js.map +1 -1
  52. package/dist/output.d.ts +32 -1
  53. package/dist/output.d.ts.map +1 -1
  54. package/dist/output.js +74 -9
  55. package/dist/output.js.map +1 -1
  56. package/dist/ownerops.d.ts +101 -13
  57. package/dist/ownerops.d.ts.map +1 -1
  58. package/dist/ownerops.js +421 -71
  59. package/dist/ownerops.js.map +1 -1
  60. package/dist/preview.d.ts +1 -1
  61. package/dist/preview.js +1 -1
  62. package/dist/remote.d.ts.map +1 -1
  63. package/dist/remote.js +8 -2
  64. package/dist/remote.js.map +1 -1
  65. package/dist/schema.d.ts +18 -0
  66. package/dist/schema.d.ts.map +1 -1
  67. package/dist/schema.js +37 -2
  68. package/dist/schema.js.map +1 -1
  69. package/dist/served.js +1 -1
  70. package/dist/update-check.d.ts +25 -0
  71. package/dist/update-check.d.ts.map +1 -1
  72. package/dist/update-check.js +49 -9
  73. package/dist/update-check.js.map +1 -1
  74. package/package.json +6 -6
  75. package/skill/SKILL.md +64 -32
  76. package/skill/reference/code-projects.md +98 -22
  77. package/skill/reference/creator-token.md +16 -3
  78. package/skill/reference/decisions.md +83 -11
  79. package/skill/reference/hosting.md +1 -1
  80. package/skill/reference/operating.md +35 -12
  81. package/skill/reference/setup.md +1 -1
  82. 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';
@@ -23,12 +23,12 @@ 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 } 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'
@@ -64,6 +65,28 @@ function fitsLiteralBytes32(value) {
64
65
  }
65
66
  /** Flags `abx attach` recognizes — anything else warns (non-fatal), so a silent no-op flag surfaces. */
66
67
  const ATTACH_FLAGS = ['file', 'compress', 'collection', 'token', 'send', 'sign', 'unsigned', 'yes', 'dry-run', 'confirm', 'port', 'sign-url-file', 'remote'];
68
+ /**
69
+ * The flags EVERY owner write accepts, regardless of command — the signing lane (`laneFromFlags`),
70
+ * the opt-in confirm gate, the wallet-lane plumbing, the post-write reindex nudge, and `--json`.
71
+ * Factored out so a per-command allowlist below only has to name that command's OWN flags.
72
+ */
73
+ const SHARED_WRITE_FLAGS = ['send', 'sign', 'unsigned', 'dry-run', 'yes', 'confirm', 'port', 'sign-url-file', 'remote', 'remote-token', 'json'];
74
+ /**
75
+ * Per-command allowlists for the owner ops where a silently-ignored flag changes MONEY or SUPPLY.
76
+ *
77
+ * These commands took no stray-flag notice at all, and the ERC-1155 lane made that expensive: an
78
+ * edition's semantics live in its OPTIONAL flags, which default rather than fail. A typo'd
79
+ * `--amount 50` minted 1 copy; a typo'd `--quantity 5` bought 1 and paid 1×; and nothing said a word.
80
+ * (`deploy` refuses strays outright, but its allowlists are exhaustively maintained. Here we WARN —
81
+ * per `unknownFlags`' own contract and the owner's scriptability line: a false warning on a valid
82
+ * flag must never break someone's script, and a warning already ends the silence.)
83
+ */
84
+ const MINT_FLAGS = [...SHARED_WRITE_FLAGS, 'to', 'count', 'token-id', 'amount'];
85
+ const TRANSFER_FLAGS = [...SHARED_WRITE_FLAGS, 'to', 'token', 'token-id', 'amount', 'from'];
86
+ const SET_MAX_SUPPLY_FLAGS = [...SHARED_WRITE_FLAGS, 'token-id', 'cap'];
87
+ const MINTER_CONFIGURE_FLAGS = [...SHARED_WRITE_FLAGS, 'price', 'price-raw', 'allocation', 'erc20', 'token-id', 'minter-contract'];
88
+ const MINTER_SHOW_FLAGS = [...SHARED_WRITE_FLAGS, 'token-id', 'minter-contract'];
89
+ const MINTER_BUY_FLAGS = [...SHARED_WRITE_FLAGS, 'to', 'token-id', 'quantity', 'minter-contract'];
67
90
  /** Auto-detect the on-chain representation for an off-chain locator by its URI scheme. Returns null
68
91
  * for anything that isn't a recognized durable/http locator (so `attach` can refuse it loudly
69
92
  * rather than silently storing a bad value — the round-1 `--representation` guessing trap). */
@@ -196,7 +219,7 @@ export async function assertContractExists(address) {
196
219
  * and canonically ENCODES the human input per its type (`#rrggbb`, decimals ×1e10,
197
220
  * Select by label, …); `String`/`Bytes` schemas take the value as UTF-8 (or
198
221
  * `--file <path>` for bytes) via the data path. The signer must satisfy the schema's
199
- * auth (Artist = contract owner, TokenOwner — delegate.xyz honored — or the named
222
+ * auth (Creator = contract owner, TokenOwner — delegate.xyz honored — or the named
200
223
  * address); the chain enforces it either way.
201
224
  */
202
225
  /** Positional args only — drops `--flags` AND the single token each value-taking flag consumes
@@ -214,7 +237,7 @@ export async function assertContractExists(address) {
214
237
  * Nothing errored; the in-chain renderer read ASCII where it expected bytes and drew garbage. So a
215
238
  * `Bytes` value must state its encoding: `0x…` hex, or `--file` for real binary. A bare string is
216
239
  * refused rather than guessed at — there is no safe guess between "these characters" and "these
217
- * bytes", and the failure is invisible until an artwork renders wrong.
240
+ * bytes", and the failure is invisible until a work renders wrong.
218
241
  */
219
242
  export function encodePayloadParam(typeName, valueInput, key) {
220
243
  if (typeName === 'String')
@@ -331,7 +354,7 @@ const HOOK_ROLES = ['configure', 'augment', 'transfer'];
331
354
  const HOOK_NOTE = {
332
355
  configure: 'write-time veto/validator (a configure tx reverts if it reverts)',
333
356
  augment: 'read-time derivation folded into tokenData (live view reads chain per view)',
334
- 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)',
335
358
  };
336
359
  const eqAddr = (a, b) => a.toLowerCase() === b.toLowerCase();
337
360
  /** Parse a hook-address flag: a 0x address, or `none`/`zero`/`0`/`0x0` to clear that role. */
@@ -409,6 +432,13 @@ export async function cmdSetParamHooks(address, flags) {
409
432
  const changed = !eqAddr(cur[r], next[r]);
410
433
  console.log(` ${r.padEnd(10)} ${changed ? `${label(cur[r])} → ${label(next[r])}` : `${label(cur[r])} ${dim('(unchanged)')}`}`);
411
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
+ }
412
442
  const owner = await read(contract, 'owner');
413
443
  await runWrite(contract, prepareSetParamHooks({ contract, configureHook: next.configure, augmentHook: next.augment, transferHook: next.transfer, chainId: chainId() }), flags, owner);
414
444
  }
@@ -469,8 +499,56 @@ export async function cmdLockDependencies(address, flags) {
469
499
  const contract = requireAddress(address, 'abx lock-dependencies <address> [--sign|--unsigned]');
470
500
  const owner = await read(contract, 'owner');
471
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."));
472
503
  await runWrite(contract, prepareLockDependencies({ contract, chainId: chainId() }), flags, owner);
473
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
+ }
474
552
  // ── editions: shared small parsers ────────────────────────────────────────────
475
553
  /** A non-negative-integer edition flag (`--token-id`, `--amount`, …) — the shared parse +
476
554
  * bound-check so every edition command that takes "an id" or "a count" rejects the same way. */
@@ -514,6 +592,7 @@ export function parseTokenIdRange(raw) {
514
592
  export async function cmdMint(address, flags) {
515
593
  const usage = 'abx mint <address> [--to 0x…] [--count <n> | --token-id <id> --amount <n>] [--json] [--sign|--unsigned]';
516
594
  const contract = requireAddress(address, usage);
595
+ warnStrayFlags(flags, new Set(MINT_FLAGS), 'mint');
517
596
  // `--json`: the TOKEN ID is the value a program came for, and it was previously only obtainable by
518
597
  // regex-scraping coloured prose (B19). It is read from the mint's own Transfer logs rather than by
519
598
  // re-reading `nextTokenId` afterwards — a concurrent mint would make that answer wrong, and a
@@ -531,9 +610,9 @@ export async function cmdMint(address, flags) {
531
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.`);
532
611
  }
533
612
  // OneOfOneEdition's id space is fixed to {0}, so --token-id defaults there; EditionImage/
534
- // 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.
535
614
  if (kind.kind !== '1of1-edition' && flags['token-id'] === undefined) {
536
- 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. ` +
537
616
  `See existing ids with \`abx tokens ${contract}\`.`);
538
617
  }
539
618
  const tokenId = flags['token-id'] !== undefined ? parseEditionCountFlag(flags['token-id'], 'token-id') : 0n;
@@ -612,6 +691,17 @@ export async function cmdSetMaxInvocations(address, flags) {
612
691
  const usage = 'abx set-max-invocations <address> --max <N> [--sign|--unsigned]';
613
692
  const contract = requireAddress(address, usage);
614
693
  const max = BigInt(requireFlag(flags, 'max', usage));
694
+ // The cap only ever decreases, but `--dry-run` used to print "Set max invocations → 20" and state
695
+ // the new value as fact for a contract capped at 16 — then the real send reverted. A creator (or an
696
+ // agent) reads a clean dry run as permission to send, so the preview has to know what the chain
697
+ // knows. Best-effort: an unreadable getter does NOT block, since the chain enforces it regardless.
698
+ // `readSeries`, not `read`: the 1/1 ABI that `read` uses has no `maxInvocations` at all, so reading
699
+ // through it always threw and this guard silently never fired.
700
+ const capNow = await readSeries(contract, 'maxInvocations').catch(() => null);
701
+ if (capNow !== null && max > capNow) {
702
+ throw new Error(`${contract}'s cap is already ${capNow} and maxInvocations can only DECREASE — ${max} would raise it, which the chain refuses. ` +
703
+ `Pass a number at or below ${capNow} (a supply cap is one-way by design: it can be tightened, never reopened).`);
704
+ }
615
705
  const owner = await read(contract, 'owner');
616
706
  await runWrite(contract, prepareSetMaxInvocations({ contract, maxInvocations: max, chainId: chainId() }), flags, owner);
617
707
  }
@@ -627,6 +717,7 @@ export async function cmdSetMaxInvocations(address, flags) {
627
717
  export async function cmdSetMaxSupply(address, flags) {
628
718
  const usage = 'abx set-max-supply <address> --token-id <n> --cap <n> [--sign|--unsigned]';
629
719
  const contract = requireAddress(address, usage);
720
+ warnStrayFlags(flags, new Set(SET_MAX_SUPPLY_FLAGS), 'set-max-supply');
630
721
  const publicClient = makePublicClient({ chainKey: CHAIN });
631
722
  const kind = await detectTokenKind(publicClient, contract);
632
723
  if (!kind.isEdition) {
@@ -640,11 +731,29 @@ export async function cmdSetMaxSupply(address, flags) {
640
731
  `(the --copies default from deploy) — there's nothing to set. To lower an existing cap, pass the number.`);
641
732
  }
642
733
  const cap = parseEditionCountFlag(capRaw, 'cap');
734
+ // Both ways this reverts on chain were reachable through `--dry-run` unchanged: the preview printed
735
+ // "#0 supply cap → 50" and stated the new cap as fact, then the real send reverted. A dry run that
736
+ // green-lights an impossible write is worse than no dry run — the creator's next step is to send it.
737
+ // `maxSupply(id) === 0` reads as "open" (the un-overridden --copies default), so any finite cap is a
738
+ // decrease from open and allowed. Reads are best-effort: if either read fails we do NOT block, since
739
+ // the chain still enforces the invariant and a guard must not turn an RPC miss into a refusal.
740
+ const [capNow, supplyNow] = await Promise.all([
741
+ readEdition(contract, 'maxSupply', [tokenId]).catch(() => null),
742
+ readEdition(contract, 'totalSupply', [tokenId]).catch(() => null),
743
+ ]);
744
+ if (capNow !== null && capNow > 0n && cap > capNow) {
745
+ throw new Error(`#${tokenId}'s cap is already ${capNow} and a cap can only DECREASE — ${cap} would raise it, which the chain refuses. ` +
746
+ `Pass a number at or below ${capNow}${supplyNow !== null ? ` and at or above its ${supplyNow} live cop${supplyNow === 1n ? 'y' : 'ies'}` : ''}.`);
747
+ }
748
+ if (supplyNow !== null && cap < supplyNow) {
749
+ throw new Error(`#${tokenId} already has ${supplyNow} cop${supplyNow === 1n ? 'y' : 'ies'} minted, so a cap of ${cap} would sit BELOW live supply — the chain refuses that. ` +
750
+ `The lowest cap you can set is ${supplyNow} (which closes the id to further minting).`);
751
+ }
643
752
  const owner = await read(contract, 'owner');
644
753
  console.log(dim(` #${tokenId} supply cap → ${cap}`));
645
754
  await runWrite(contract, prepareSetMaxSupply({ contract, tokenId, cap, chainId: chainId() }), flags, owner);
646
755
  }
647
- // ── ping-uri (editions only) — permissionless URI re-emission ────────────────────────────────────
756
+ // ── ping-uri (editions only) — the OWNER's URI re-emission ───────────────────────────────────────
648
757
  /** How many ids one `pingURI` call carries per transaction — large enough that a normal collection
649
758
  * fits in one tx, small enough that a genuinely huge id list doesn't build one unbounded call. */
650
759
  const PING_URI_CHUNK_SIZE = 200;
@@ -663,13 +772,14 @@ export async function cmdPingUri(address, flags) {
663
772
  const kind = await detectTokenKind(publicClient, contract);
664
773
  if (!kind.isEdition) {
665
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 ` +
666
- `(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).`);
667
777
  }
668
778
  const ids = parseTokenIdRange(requireFlag(flags, 'token-ids', usage));
669
779
  const chunks = [];
670
780
  for (let i = 0; i < ids.length; i += PING_URI_CHUNK_SIZE)
671
781
  chunks.push(ids.slice(i, i + PING_URI_CHUNK_SIZE));
672
- 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`));
673
783
  for (const [i, chunk] of chunks.entries()) {
674
784
  if (chunks.length > 1)
675
785
  console.log(dim(` chunk ${i + 1}/${chunks.length}: ${chunk.length} id(s) (#${chunk[0]}–#${chunk[chunk.length - 1]})`));
@@ -695,9 +805,9 @@ export async function cmdUnpause(address, flags) {
695
805
  * scope, so one value covers the whole project — they project into `contractURI` on the resolver
696
806
  * AND the on-chain renderer. Works for every token type (1/1 · Series · code). */
697
807
  export const AUTHORSHIP_DEPLOY_FIELDS = [
698
- ['artist', F.artist],
808
+ ['creator', F.creator],
699
809
  ['display-notes', F.displayNotes],
700
- ['artist-links', F.artistLinks],
810
+ ['creator-links', F.creatorLinks],
701
811
  ['license', F.license],
702
812
  ];
703
813
  /** Build the on-chain (inline) collection fields for whichever authorship/rights deploy flags are
@@ -763,14 +873,29 @@ export async function cmdRefresh(address, flags) {
763
873
  console.log(` ${dim('OpenSea ')}${osBase}/assets/${osChain}/${contract}/${tokenId}`);
764
874
  if (explorer)
765
875
  console.log(` ${dim('Etherscan')} ${explorer}/token/${contract}?a=${tokenId}`);
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.
766
882
  console.log(dim(' (ERC-4906 already pings 4906-aware marketplaces on URI changes; this covers the genesis mint + the rest.)'));
767
883
  }
768
884
  // ── transfer ─────────────────────────────────────────────────────────────────
769
885
  export async function cmdTransfer(address, flags) {
770
- const usage = 'abx transfer <address> --to 0x… [--token 0] [--amount <n> --from 0x… (editions only)] [--sign|--unsigned]';
886
+ const usage = 'abx transfer <address> --to 0x… [--token 0 | --token-id 0] [--amount <n> --from 0x… (editions only)] [--sign|--unsigned]';
771
887
  const contract = requireAddress(address, usage);
888
+ warnStrayFlags(flags, new Set(TRANSFER_FLAGS), 'transfer');
772
889
  const to = requireFlag(flags, 'to', usage);
773
- const tokenId = BigInt(flags.token ?? '0');
890
+ // `--token-id` is accepted as an alias for `--token`: EVERY sibling id-taking command (`mint`,
891
+ // `set-max-supply`, `minter …`) spells it `--token-id`, so an agent that learned the name there
892
+ // passed it here — where it was silently ignored and id 0 moved instead of the id they named.
893
+ // On an edition that means transferring the WRONG WORK with no warning. Both spellings work now;
894
+ // disagreeing values are refused rather than silently preferring one.
895
+ if (flags.token !== undefined && flags['token-id'] !== undefined && flags.token !== flags['token-id']) {
896
+ throw new Error(`--token ${flags.token} and --token-id ${flags['token-id']} disagree — pass one (they are aliases for the same id).`);
897
+ }
898
+ const tokenId = BigInt(flags['token-id'] ?? flags.token ?? '0');
774
899
  const publicClient = makePublicClient({ chainKey: CHAIN });
775
900
  const kind = await detectTokenKind(publicClient, contract);
776
901
  if (kind.isEdition) {
@@ -937,21 +1062,26 @@ export async function cmdSetTransferValidator(address, rest, flags) {
937
1062
  `Enrollment can never be added to a live collection; if enforcement is required, redeploy with ` +
938
1063
  `--721c recommended (or --721c 0x…) on the deploy command.`);
939
1064
  }
940
- // Codeless-validator guard: the contract refuses a non-zero validator with no code
941
- // (`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.
942
1070
  if (validator !== zeroAddress) {
943
- let code;
944
- try {
945
- code = await publicClient.getCode({ address: validator });
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.`);
946
1074
  }
947
- catch (err) {
948
- throw new Error(`couldn't verify the validator has code at ${validator} (${err.message}) — refusing to re-point blind; retry when the RPC answers.`);
949
- }
950
- if (!code || code === '0x') {
1075
+ if (probe.verdict !== 'ok') {
951
1076
  const rec = resolveRecommendedTransferValidator(cid);
952
- throw new Error(`no contract code at ${validator} on ${CHAIN} — the token would revert InvalidTransferValidator(). ` +
953
- `A transfer validator must be a DEPLOYED contract on this chain` +
954
- (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.`);
955
1085
  }
956
1086
  }
957
1087
  if (eqAddr(status.validator, validator)) {
@@ -963,6 +1093,167 @@ export async function cmdSetTransferValidator(address, rest, flags) {
963
1093
  const owner = await read(contract, 'owner');
964
1094
  await runWrite(contract, prepareSetTransferValidator({ contract, validator, chainId: cid }), flags, owner);
965
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
+ }
966
1257
  // ── multi-chunk on-chain content (the `reader` path) ─────────────────────────
967
1258
  // The plan math (chunk count + tx shape), the cost-model constants, and the actual
968
1259
  // plan→resolve-store→stage→encode orchestration all live in the SDK's staging.ts, layered on
@@ -1026,13 +1317,55 @@ export function sessionStagingSender(session) {
1026
1317
  return async (tx) => (await session.send(tx)).receipt;
1027
1318
  }
1028
1319
  const kb = (n) => `${n < 10 * 1024 ? (n / 1024).toFixed(1) : Math.round(n / 1024)}KB`;
1029
- /** Warn (loudly) when content is too big for on-chain to be the cost-sane choice. Returns true if it warned. */
1030
- export function warnIfLargeForOnChain(bytesLen, label) {
1031
- if (!exceedsOnchainSoftLimit(bytesLen))
1032
- return false;
1033
- 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.`));
1034
- 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.`));
1035
- 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;
1036
1369
  }
1037
1370
  /**
1038
1371
  * Pure staging plan for a piece of content — chunk count + transaction shape — with NO chain
@@ -1079,8 +1412,9 @@ function planTxLine(p, finalLabel) {
1079
1412
  * These store writes use the env key (the data contracts are ownerless); the owner only
1080
1413
  * signs the field set that references the manifest.
1081
1414
  */
1082
- async function putContentOnChain(bytes, compress, field, send, finalLabel = '1 owner field-set', store) {
1083
- 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);
1084
1418
  const content = compress === 'gzip' ? new Uint8Array(gzipSync(bytes)) : new Uint8Array(bytes);
1085
1419
  const publicClient = makePublicClient({ chainKey: CHAIN });
1086
1420
  return stageFieldContent({
@@ -1110,10 +1444,12 @@ async function putContentOnChain(bytes, compress, field, send, finalLabel = '1 o
1110
1444
  * true count instead of leaving the agent to guess). The final tx here is the deploy that
1111
1445
  * bakes in the reader field, so the count is staging tx(s) + the deploy.
1112
1446
  */
1113
- export function previewImageStaging(imagePath, compress) {
1447
+ export function previewImageStaging(imagePath, compress, allowUnreadable = false) {
1114
1448
  const bytes = readFileSync(resolvePath(imagePath));
1115
1449
  const p = computeContentPlan(bytes, compress);
1116
- 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);
1117
1453
  return (`would stage ${basename(resolvePath(imagePath))} on-chain (chunk store): ` +
1118
1454
  `${planSizeLine(bytes.length, p, compress)} as ${p.representation}; ` +
1119
1455
  `${planTxLine(p, '1 deploy (bakes the reader field + mints)')}`);
@@ -1125,9 +1461,9 @@ export function previewImageStaging(imagePath, compress) {
1125
1461
  * image" is just those staging tx(s) + the single deploy — no post-deploy `set-field`.
1126
1462
  * The manifest is deployer-independent, so this is computed once and reused for any signer.
1127
1463
  */
1128
- export async function stageImageField(imagePath, compress, send) {
1464
+ export async function stageImageField(imagePath, compress, send, allowUnreadable = false) {
1129
1465
  const bytes = readFileSync(resolvePath(imagePath));
1130
- 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);
1131
1467
  return {
1132
1468
  field: { field: encodeTag(F.image), representation: encodeTag(representation), value },
1133
1469
  note: `image: ${bytes.length}B staged ON-CHAIN via reader (${representation}) — baked into the deploy`,
@@ -1137,15 +1473,15 @@ export async function stageImageField(imagePath, compress, send) {
1137
1473
  * Stage MANY images on-chain against a **single, shared** chunk store — the batch form of
1138
1474
  * {@link stageImageField} for a Series. The store is resolved/deployed **once** (not per token),
1139
1475
  * then every file is staged against it and returned as its own `reader`-backed `image` field.
1140
- * 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
1141
1477
  * share the store contract. Returns the fields in input order (token order).
1142
1478
  */
1143
- export async function stageImageFieldsBatch(imagePaths, compress, send, storeOverride) {
1479
+ export async function stageImageFieldsBatch(imagePaths, compress, send, storeOverride, allowUnreadable = false) {
1144
1480
  const store = await ensureChunkStore(send, storeOverride);
1145
1481
  const fields = [];
1146
1482
  for (const imagePath of imagePaths) {
1147
1483
  const bytes = readFileSync(resolvePath(imagePath));
1148
- 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);
1149
1485
  fields.push({ field: encodeTag(F.image), representation: encodeTag(representation), value });
1150
1486
  }
1151
1487
  return { fields, store };
@@ -1220,6 +1556,8 @@ export async function cmdSetField(address, flags) {
1220
1556
  if (staging && isDryRun(flags)) {
1221
1557
  const bytes = readFileSync(resolvePath(flags.file));
1222
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));
1223
1561
  console.log(`\n ${bold('◆ set-field ' + field)} ${dim('(dry run — nothing staged or sent)')}`);
1224
1562
  console.log(` ${dim('scope'.padEnd(12))} ${collection ? 'collection' : `token #${flags.token ?? '0'}`}`);
1225
1563
  console.log(` ${planSizeLine(bytes.length, p, parseCompress(flags.compress))}`);
@@ -1251,7 +1589,7 @@ export async function cmdSetField(address, flags) {
1251
1589
  });
1252
1590
  try {
1253
1591
  await session.connect();
1254
- 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));
1255
1593
  await session.send(buildTx(value, representation));
1256
1594
  }
1257
1595
  finally {
@@ -1265,7 +1603,7 @@ export async function cmdSetField(address, flags) {
1265
1603
  let representation;
1266
1604
  if (staging) {
1267
1605
  const compress = parseCompress(flags.compress);
1268
- ({ 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)));
1269
1607
  }
1270
1608
  else if (flags.text && flags.text !== 'true') {
1271
1609
  value = toHex(flags.text);
@@ -1396,13 +1734,13 @@ export async function cmdAttach(rest, flags) {
1396
1734
  const uriBase = await read(contract, 'tokenURIBase').catch(() => '');
1397
1735
  const artifactsPath = `/t/${chainId()}/${contract}/${flags.token ?? '0'}`;
1398
1736
  if (uriBase && uriBase.trim() !== '') {
1399
- 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).`));
1400
1738
  }
1401
1739
  else {
1402
1740
  console.log(yellow(' ⚠ ') +
1403
- `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 — ` +
1404
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. ` +
1405
- 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.)'));
1406
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}.`));
1407
1745
  }
1408
1746
  // ONE transaction for the whole set. `batchOps` folds a same-target run into a `multicall` and
@@ -1427,7 +1765,7 @@ export async function cmdAttach(rest, flags) {
1427
1765
  dim(` verify (a resolver serves the complete listing): `) +
1428
1766
  `abx tokenuri ${contract}${id === '0' ? '' : ` --token ${id}`} --fetch` +
1429
1767
  dim(` ${collection ? '' : `→ artifacts[].key ${pairs.map((p) => `"${p.key}"`).join(', ')}; /data/<key> fetches each`}\n`) +
1430
- 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`));
1431
1769
  }
1432
1770
  }
1433
1771
  // ── lock-field ───────────────────────────────────────────────────────────────
@@ -1435,9 +1773,9 @@ export async function cmdAttach(rest, flags) {
1435
1773
  * Refuse `lock-field` on a name that is a declared PARAM key.
1436
1774
  *
1437
1775
  * Fields and params are two separate namespaces that may share a name, and `lock-field` only ever
1438
- * 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
1439
1777
  * `lock-field --field grid`, got "permanent", got `tokenFieldLocked(0,"grid") == true`, and then
1440
- * 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
1441
1779
  * made was true; together they promised a protection that did not exist. Permanence is the pitch, so
1442
1780
  * this refuses rather than warns, and names the mechanism that actually welds a param.
1443
1781
  */
@@ -1502,13 +1840,14 @@ export async function cmdSetRenderer(address, flags) {
1502
1840
  }
1503
1841
  // ── lock-uri (freeze the URI config) ──────────────────────────────────────────
1504
1842
  /** Freeze a scope's URI config (pointer + renderer) forever. With the fields also
1505
- * locked, the resolved metadata is provably immutable — the on-chain twin of a
1506
- * 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`. */
1507
1846
  export async function cmdLockUri(address, flags) {
1508
1847
  const contract = requireAddress(address, 'abx lock-uri <address> [--collection] [--sign|--unsigned]');
1509
1848
  const collection = !!flags.collection;
1510
1849
  const owner = await read(contract, 'owner');
1511
- 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.`));
1512
1851
  const tx = collection
1513
1852
  ? prepareLockContractURI({ contract, chainId: chainId() })
1514
1853
  : prepareLockTokenURI({ contract, chainId: chainId() });
@@ -1631,12 +1970,13 @@ function parseSalePrice(flags, usage) {
1631
1970
  * the token, set a primary payee) and whether the token is paused.
1632
1971
  *
1633
1972
  * On an EDITION (OneOfOneEdition/EditionImage/EditionCode), `--token-id` is REQUIRED (sales are
1634
- * 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
1635
1974
  * shared `AbxFixedPriceMinter1155` instead. Refused on a 721 target.
1636
1975
  */
1637
1976
  export async function cmdMinterConfigure(address, flags) {
1638
1977
  const usage = 'abx minter configure <token> (--price <eth> | --price-raw <units>) --allocation <n> [--erc20 0x…] [--token-id <n> (editions)] [--minter-contract 0x…] [--sign|--unsigned]';
1639
1978
  const token = requireAddress(address, usage);
1979
+ warnStrayFlags(flags, new Set(MINTER_CONFIGURE_FLAGS), 'minter configure');
1640
1980
  const publicClient = makePublicClient({ chainKey: CHAIN });
1641
1981
  const kind = await detectTokenKind(publicClient, token);
1642
1982
  const owner = await read(token, 'owner');
@@ -1673,10 +2013,15 @@ export async function cmdMinterConfigure(address, flags) {
1673
2013
  console.log(dim(` price ${isEth ? `${formatEther(price)} ETH` : `${price} units of ${erc20}`} / copy`));
1674
2014
  console.log(dim(` allocation ${allocation}`));
1675
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.
1676
2021
  const [assignedMinter, payee, paused] = await Promise.all([
1677
- read(token, 'minter').catch(() => zeroAddress),
1678
- read(token, 'primaryPayee').catch(() => zeroAddress),
1679
- read(token, 'paused').catch(() => false),
2022
+ readEdition(token, 'minter').catch(() => zeroAddress),
2023
+ readEdition(token, 'primaryPayee').catch(() => zeroAddress),
2024
+ readEdition(token, 'paused').catch(() => false),
1680
2025
  ]);
1681
2026
  const assigned = assignedMinter.toLowerCase() === minter.toLowerCase();
1682
2027
  console.log('');
@@ -1741,6 +2086,7 @@ export async function cmdMinterConfigure(address, flags) {
1741
2086
  export async function cmdMinterShow(address, flags) {
1742
2087
  const usage = 'abx minter show <token> [--token-id <n> (editions)] [--minter-contract 0x…]';
1743
2088
  const token = requireAddress(address, usage);
2089
+ warnStrayFlags(flags, new Set(MINTER_SHOW_FLAGS), 'minter show');
1744
2090
  // Refuse a nonexistent contract like the sibling owner-ops (state/unpause/minter configure) do —
1745
2091
  // otherwise the minter singleton returns zero-config for the unconfigured token and we print a
1746
2092
  // plausible-but-fake `paused: no (open)` readout for an address that has no project at all.
@@ -1834,6 +2180,7 @@ export async function cmdMinterShow(address, flags) {
1834
2180
  export async function cmdMinterBuy(address, flags) {
1835
2181
  const usage = 'abx minter buy <token> [--to 0x…] [--token-id <n> --quantity <n> (editions)] [--minter-contract 0x…] [--sign|--unsigned]';
1836
2182
  const token = requireAddress(address, usage);
2183
+ warnStrayFlags(flags, new Set(MINTER_BUY_FLAGS), 'minter buy');
1837
2184
  const publicClient = makePublicClient({ chainKey: CHAIN });
1838
2185
  const kind = await detectTokenKind(publicClient, token);
1839
2186
  if (kind.isEdition) {
@@ -1849,15 +2196,17 @@ export async function cmdMinterBuy(address, flags) {
1849
2196
  throw new Error(`no sale configured for ${token} #${tokenId} — run: abx minter configure ${token} --token-id ${tokenId} …`);
1850
2197
  const isEth = sale.paymentToken === zeroAddress;
1851
2198
  const to = flags.to && flags.to !== 'true' ? flags.to : undefined;
1852
- // Payment math: price × quantity, computed ONCE and shown in the confirm/dry-run readout below
1853
- // (via runMinterWrite gatedSend's own preview) the exact ETH the tx attaches.
1854
- 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;
1855
2204
  if (!isEth) {
1856
- 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).`));
1857
2206
  }
1858
2207
  console.log(dim(` buying ${quantity} cop${quantity === 1n ? 'y' : 'ies'} of #${tokenId}${to ? ` → ${to}` : ' → signer'} for ` +
1859
- `${isEth ? `${formatEther(value)} ETH (${formatEther(sale.price)} × ${quantity})` : `${value} units (${sale.price} × ${quantity})`}`));
1860
- 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);
1861
2210
  console.log(dim(` next: \`abx refresh ${token}\` so marketplaces pick up the change.`));
1862
2211
  return;
1863
2212
  }
@@ -1872,12 +2221,13 @@ export async function cmdMinterBuy(address, flags) {
1872
2221
  throw new Error(`no sale configured for ${token} — run: abx minter configure ${token} …`);
1873
2222
  const isEth = sale.paymentToken === zeroAddress;
1874
2223
  const to = flags.to && flags.to !== 'true' ? flags.to : undefined;
1875
- const value = isEth ? sale.price : 0n;
1876
2224
  if (!isEth) {
1877
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).`));
1878
2226
  }
1879
2227
  console.log(dim(` buying 1 token${to ? ` → ${to}` : ' → signer'} for ${isEth ? `${formatEther(sale.price)} ETH` : `${sale.price} units`}`));
1880
- 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);
1881
2231
  console.log(dim(` next: \`abx refresh ${token}\` so marketplaces pick up the new token.`));
1882
2232
  }
1883
2233
  // ── PostParam schemas, after deploy ──────────────────────────────────────────