@artblocks/abx-cli 0.1.0-alpha.4 → 0.1.0-alpha.40

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 (180) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/assets/renderer-scaffold/README.md +29 -11
  3. package/assets/renderer-scaffold/foundry.toml +1 -0
  4. package/assets/renderer-scaffold/remappings.txt +1 -1
  5. package/assets/renderer-scaffold/script/DeployHooks.s.sol +24 -0
  6. package/assets/renderer-scaffold/src/MyHooks.sol +20 -0
  7. package/assets/renderer-scaffold/src/MyRenderer.sol +4 -4
  8. package/assets/renderer-scaffold/src/MyTraits.sol +2 -2
  9. package/assets/renderer-scaffold/test/MyRenderer.t.sol +60 -3
  10. package/dist/bin.d.ts +26 -0
  11. package/dist/bin.d.ts.map +1 -0
  12. package/dist/bin.js +63 -0
  13. package/dist/bin.js.map +1 -0
  14. package/dist/capabilities.d.ts +94 -0
  15. package/dist/capabilities.d.ts.map +1 -0
  16. package/dist/capabilities.js +135 -0
  17. package/dist/capabilities.js.map +1 -0
  18. package/dist/commands/auth.d.ts +54 -0
  19. package/dist/commands/auth.d.ts.map +1 -0
  20. package/dist/commands/auth.js +447 -0
  21. package/dist/commands/auth.js.map +1 -0
  22. package/dist/commands/deploy.d.ts +242 -0
  23. package/dist/commands/deploy.d.ts.map +1 -0
  24. package/dist/commands/deploy.js +4763 -0
  25. package/dist/commands/deploy.js.map +1 -0
  26. package/dist/commands/feedback.d.ts +7 -0
  27. package/dist/commands/feedback.d.ts.map +1 -0
  28. package/dist/commands/feedback.js +147 -0
  29. package/dist/commands/feedback.js.map +1 -0
  30. package/dist/commands/project.d.ts +257 -0
  31. package/dist/commands/project.d.ts.map +1 -0
  32. package/dist/commands/project.js +1414 -0
  33. package/dist/commands/project.js.map +1 -0
  34. package/dist/commands/reads.d.ts +64 -0
  35. package/dist/commands/reads.d.ts.map +1 -0
  36. package/dist/commands/reads.js +701 -0
  37. package/dist/commands/reads.js.map +1 -0
  38. package/dist/commands/scaffold.d.ts +89 -0
  39. package/dist/commands/scaffold.d.ts.map +1 -0
  40. package/dist/commands/scaffold.js +733 -0
  41. package/dist/commands/scaffold.js.map +1 -0
  42. package/dist/commands/service.d.ts +67 -0
  43. package/dist/commands/service.d.ts.map +1 -0
  44. package/dist/commands/service.js +741 -0
  45. package/dist/commands/service.js.map +1 -0
  46. package/dist/commands/storage.d.ts +51 -0
  47. package/dist/commands/storage.d.ts.map +1 -0
  48. package/dist/commands/storage.js +370 -0
  49. package/dist/commands/storage.js.map +1 -0
  50. package/dist/commands/submit-app.d.ts +59 -0
  51. package/dist/commands/submit-app.d.ts.map +1 -0
  52. package/dist/commands/submit-app.js +513 -0
  53. package/dist/commands/submit-app.js.map +1 -0
  54. package/dist/config.d.ts +90 -2
  55. package/dist/config.d.ts.map +1 -1
  56. package/dist/config.js +284 -11
  57. package/dist/config.js.map +1 -1
  58. package/dist/conformance.d.ts +31 -0
  59. package/dist/conformance.d.ts.map +1 -0
  60. package/dist/conformance.js +390 -0
  61. package/dist/conformance.js.map +1 -0
  62. package/dist/contract-read-error.d.ts +5 -0
  63. package/dist/contract-read-error.d.ts.map +1 -0
  64. package/dist/contract-read-error.js +37 -0
  65. package/dist/contract-read-error.js.map +1 -0
  66. package/dist/deps.d.ts +6 -39
  67. package/dist/deps.d.ts.map +1 -1
  68. package/dist/deps.js +4 -68
  69. package/dist/deps.js.map +1 -1
  70. package/dist/errors.d.ts +20 -0
  71. package/dist/errors.d.ts.map +1 -0
  72. package/dist/errors.js +25 -0
  73. package/dist/errors.js.map +1 -0
  74. package/dist/flag-allowlists.d.ts +53 -0
  75. package/dist/flag-allowlists.d.ts.map +1 -0
  76. package/dist/flag-allowlists.js +180 -0
  77. package/dist/flag-allowlists.js.map +1 -0
  78. package/dist/flags.d.ts +41 -0
  79. package/dist/flags.d.ts.map +1 -1
  80. package/dist/flags.js +111 -1
  81. package/dist/flags.js.map +1 -1
  82. package/dist/jsonout.d.ts +37 -0
  83. package/dist/jsonout.d.ts.map +1 -0
  84. package/dist/jsonout.js +68 -0
  85. package/dist/jsonout.js.map +1 -0
  86. package/dist/kind.d.ts +57 -0
  87. package/dist/kind.d.ts.map +1 -0
  88. package/dist/kind.js +122 -0
  89. package/dist/kind.js.map +1 -0
  90. package/dist/main.js +747 -4838
  91. package/dist/main.js.map +1 -1
  92. package/dist/mintpage.d.ts +17 -2
  93. package/dist/mintpage.d.ts.map +1 -1
  94. package/dist/mintpage.js +241 -54
  95. package/dist/mintpage.js.map +1 -1
  96. package/dist/output.d.ts +179 -0
  97. package/dist/output.d.ts.map +1 -0
  98. package/dist/output.js +780 -0
  99. package/dist/output.js.map +1 -0
  100. package/dist/ownerops.d.ts +312 -57
  101. package/dist/ownerops.d.ts.map +1 -1
  102. package/dist/ownerops.js +1808 -357
  103. package/dist/ownerops.js.map +1 -1
  104. package/dist/preview.d.ts +23 -5
  105. package/dist/preview.d.ts.map +1 -1
  106. package/dist/preview.js +95 -43
  107. package/dist/preview.js.map +1 -1
  108. package/dist/prompt.d.ts +17 -0
  109. package/dist/prompt.d.ts.map +1 -0
  110. package/dist/prompt.js +19 -0
  111. package/dist/prompt.js.map +1 -0
  112. package/dist/provision.d.ts +3 -13
  113. package/dist/provision.d.ts.map +1 -1
  114. package/dist/provision.js +19 -21
  115. package/dist/provision.js.map +1 -1
  116. package/dist/remote.d.ts +157 -52
  117. package/dist/remote.d.ts.map +1 -1
  118. package/dist/remote.js +435 -46
  119. package/dist/remote.js.map +1 -1
  120. package/dist/riskgate.d.ts +62 -0
  121. package/dist/riskgate.d.ts.map +1 -0
  122. package/dist/riskgate.js +234 -0
  123. package/dist/riskgate.js.map +1 -0
  124. package/dist/scaffold.d.ts +12 -0
  125. package/dist/scaffold.d.ts.map +1 -0
  126. package/dist/scaffold.js +56 -0
  127. package/dist/scaffold.js.map +1 -0
  128. package/dist/schema.d.ts +36 -1
  129. package/dist/schema.d.ts.map +1 -1
  130. package/dist/schema.js +121 -26
  131. package/dist/schema.js.map +1 -1
  132. package/dist/script-chunks.d.ts +8 -0
  133. package/dist/script-chunks.d.ts.map +1 -0
  134. package/dist/script-chunks.js +35 -0
  135. package/dist/script-chunks.js.map +1 -0
  136. package/dist/served.d.ts +30 -0
  137. package/dist/served.d.ts.map +1 -0
  138. package/dist/served.js +112 -0
  139. package/dist/served.js.map +1 -0
  140. package/dist/signer.d.ts +13 -0
  141. package/dist/signer.d.ts.map +1 -1
  142. package/dist/signer.js +84 -15
  143. package/dist/signer.js.map +1 -1
  144. package/dist/update-check.d.ts +86 -5
  145. package/dist/update-check.d.ts.map +1 -1
  146. package/dist/update-check.js +161 -20
  147. package/dist/update-check.js.map +1 -1
  148. package/package.json +13 -12
  149. package/skill/SKILL.md +179 -347
  150. package/skill/agents/openai.yaml +4 -0
  151. package/skill/reference/capabilities.md +188 -0
  152. package/skill/reference/code.md +211 -0
  153. package/skill/reference/creator-token.md +94 -0
  154. package/skill/reference/deploy.md +167 -0
  155. package/skill/reference/diagnose.md +165 -0
  156. package/skill/reference/hosting.md +161 -94
  157. package/skill/reference/operate.md +181 -0
  158. package/skill/reference/services.md +121 -0
  159. package/skill/reference/setup.md +140 -36
  160. package/assets/renderer-scaffold/src/interfaces/IAbxFieldRenderer.sol +0 -32
  161. package/assets/renderer-scaffold/src/interfaces/IAbxParams.sol +0 -26
  162. package/dist/inspect.d.ts +0 -48
  163. package/dist/inspect.d.ts.map +0 -1
  164. package/dist/inspect.js +0 -184
  165. package/dist/inspect.js.map +0 -1
  166. package/dist/migrate.d.ts +0 -65
  167. package/dist/migrate.d.ts.map +0 -1
  168. package/dist/migrate.js +0 -180
  169. package/dist/migrate.js.map +0 -1
  170. package/dist/onchain-uri.d.ts +0 -97
  171. package/dist/onchain-uri.d.ts.map +0 -1
  172. package/dist/onchain-uri.js +0 -243
  173. package/dist/onchain-uri.js.map +0 -1
  174. package/dist/upload.d.ts +0 -28
  175. package/dist/upload.d.ts.map +0 -1
  176. package/dist/upload.js +0 -41
  177. package/dist/upload.js.map +0 -1
  178. package/skill/reference/code-projects.md +0 -246
  179. package/skill/reference/operating.md +0 -116
  180. package/skill/reference/troubleshooting.md +0 -28
package/dist/ownerops.js CHANGED
@@ -6,22 +6,29 @@
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, deployChunkStore, predictChunkStore, predictFixedPriceMinter, encodeReader, makePublicClient, makeWalletClient, oneOfOneImageAbi, seriesImageAbi, abxFixedPriceMinterAbi, deployFixedPriceMinter, prepareConfigureSale, preparePurchase, planChunks, planContentTxs, prepareLockContractField, prepareLockContractURI, prepareLockTokenField, prepareLockTokenURI, prepareMint, prepareSeriesMintMany, prepareSetMinter, prepareSetMaxInvocations, prepareSetPrimaryPayee, prepareSetPaused, prepareSetContractField, prepareSetContractURIBase, prepareSetContractURIOverride, prepareSetContractURIRenderer, prepareSetParamHooks, prepareSetRoyalty, prepareSetTokenField, prepareSetTokenURIBase, prepareSetTokenURIOverride, prepareSetTokenURIRenderer, prepareTransfer, prepareTransferOwnership, resolveChain, DEFAULT_CHAIN_KEY, stageContent, storeSupportsWriteContent, encodeTag, METADATA_FIELD as F, METADATA_REPRESENTATION as R, } from '@artblocks/abx-sdk';
10
- import { SelfHostIndexer } from '@artblocks/abx-indexer';
11
- import { encodeScalarParam, encodeTag as encodeTagSdk, prepareConfigureTokenParam, prepareConfigureTokenParamData, prepareSetContractParam, prepareSetContractParamData, seriesCodeAbi, } from '@artblocks/abx-sdk';
12
- import { paramsKeysIsLiteral, paramsKeysNudge, readParamsKeys } from './onchain-uri.js';
13
- import { DEP_RESOLUTION, parseDependencyRef, prepareLockDependencies, prepareRemoveLastDependency, prepareSetDependency, prepareSetDependencyRegistry, } from '@artblocks/abx-sdk';
9
+ import { MAX_ROYALTY_BPS, 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, prepareReduceMaxRoyaltyBps, 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, ETH_CALL_GAS_FLOOR, readableBytesAtGas, probeBestEthCallGasCap, } from '@artblocks/abx-sdk';
10
+ import { encodeScalarParam, encodeTag as encodeTagSdk, isAccepted, prepareConfigureTokenParam, prepareConfigureTokenParamData, prepareSetContractParam, prepareSetContractParamData, seriesCodeAbi, tryReadContract, } from '@artblocks/abx-sdk';
11
+ import { hasParamEnumeration, GATEWAY_FIELD, GATEWAY_FLOOR, gatewayPrefixFrom, readCollectionPolicy, readEnv } from '@artblocks/abx-sdk';
12
+ import { decodeFieldRenderer, encodeFieldRenderer, resolveGenerator, DEP_RESOLUTION, parseDependencyRef, prepareLockDependencies, prepareLockParamHooks, prepareLockScript, prepareRemoveLastDependency, prepareSetDependency, prepareSetDependencyRegistry, } from '@artblocks/abx-sdk';
14
13
  import { contentTypeFromPath } from '@artblocks/abx-storage';
15
14
  import { toHex as toHexSdk } from 'viem';
16
15
  import { readFileSync } from 'node:fs';
17
16
  import { basename, resolve as resolvePath } from 'node:path';
18
17
  import { gzipSync } from 'node:zlib';
19
18
  import { formatEther, getAddress, isAddress, parseEther, toHex, zeroAddress } from 'viem';
20
- import { chunkStoreAddress, fixedPriceMinterAddress } from './config.js';
21
- import { openWalletSession, signTx } from './signer.js';
22
- import { remoteAddProject } from './remote.js';
23
- import { unknownFlags } from './flags.js';
24
- const CHAIN = process.env.ABX_CHAIN ?? DEFAULT_CHAIN_KEY; // base-sepolia default — MUST match main.ts/config.ts (a stale 'sepolia' here silently ran every owner-op on the wrong chain)
19
+ import { backendResolution, CHAIN, chainId, fixedPriceMinterAddress, fixedPriceMinter1155Address, localIndexer } from './config.js';
20
+ import { CliError } from './errors.js';
21
+ import { detectTokenKind, isEditionContract, assertHasParamsSurface } from './kind.js';
22
+ import { openWalletSession } from './signer.js';
23
+ import { gatedSend, laneFromFlags } from './riskgate.js';
24
+ import { withJson } from './jsonout.js';
25
+ import { resolveRemote, serviceClient } from './remote.js';
26
+ import { isDryRun, positionalArgs, unknownFlags, warnStrayFlags } from './flags.js';
27
+ import { parseSchemaSpecs, describeSchema } from './schema.js';
28
+ // Re-exported for main.ts (the on-chain-vs-off-chain cost model + the compression-mode type now
29
+ // live in the SDK's staging.ts, layered on planChunks/planContentTxs — see the comment at their
30
+ // definition below).
31
+ export { ONCHAIN_PROJECT_SOFT_LIMIT, ONCHAIN_READ_WARN_BYTES, ETH_CALL_GAS_FLOOR, tokenUriGasEstimate, readableBytesAtGas };
25
32
  // ── ANSI (local) ─────────────────────────────────────────────────────────────
26
33
  const C = { reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m', green: '\x1b[38;5;115m', yellow: '\x1b[38;5;221m' };
27
34
  const dim = (s) => `${C.dim}${s}${C.reset}`;
@@ -33,19 +40,64 @@ const bold = (s) => `${C.bold}${s}${C.reset}`;
33
40
  // COMPLETE set — but it's COMPUTED by the resolver/renderer from the token's fields + effect
34
41
  // outputs; it is never a field you set. These keys are that computed output, so setting them by
35
42
  // hand would only pollute the manifest with a bogus entry (a real round-1 agent trap). Refuse them
36
- // and point at the real verb. See specs/protocol/data-plane.md.
37
- const COMPUTED_FIELD_KEYS = new Set(['artifacts', 'abx_provenance']);
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 site/content/docs/protocol/data-plane.mdx + site/content/docs/protocol/metadata.mdx.
47
+ const COMPUTED_FIELD_KEYS = new Set(['artifacts', 'abx_params', 'abx_provenance']);
38
48
  export function assertSettableField(field) {
49
+ // The gateway keys ARE stored in the field store, but every way to get them wrong is silent: a
50
+ // token-scope write (a gateway is one answer per project, so nothing reads it), a representation
51
+ // other than `inline` (ignored), or a prefix missing its trailing path (`https://ipfs.io` + a CID
52
+ // is a 404 nobody attributes to this command). So the generic verb refuses and names the one that
53
+ // enforces all three, rather than warning and letting the write land.
54
+ if (field === GATEWAY_FIELD.ipfs || field === GATEWAY_FIELD.arweave) {
55
+ const network = field === GATEWAY_FIELD.ipfs ? 'ipfs' : 'arweave';
56
+ throw new Error(`"${field}" is a serving preference, not a metadata field — set it with the command that validates it:\n` +
57
+ ` abx set-gateway <address> --${network} ${GATEWAY_FLOOR[network]}\n` +
58
+ ` (collection scope, \`inline\`, and a prefix that ends in its path — set-field would let you get all three wrong silently.)`);
59
+ }
39
60
  if (COMPUTED_FIELD_KEYS.has(field)) {
40
61
  const what = field === 'artifacts'
41
62
  ? "the COMPUTED manifest (the complete list of this token's files), assembled by the resolver/renderer from your fields"
42
- : 'the COMPUTED provenance list, assembled by the resolver/renderer';
43
- throw new Error(`"${field}" is not a field you set it's ${what}. To attach a file so it appears in the manifest, ` +
44
- `pick your OWN key:\n abx attach <address> <yourkey> <ipfs://… | ar://… | https://…>`);
63
+ : field === 'abx_params'
64
+ ? "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`)"
65
+ : 'the COMPUTED provenance list, assembled by the resolver/renderer';
66
+ throw new Error(`"${field}" is not a field you set — it's ${what}.` +
67
+ (field === 'abx_params'
68
+ ? `\n Set a param instead:\n abx configure-param <address> <tokenId|-> <key> <value>\n (or attach a FILE under your own key: abx attach <address> <yourkey> <ipfs://… | ar://… | https://…>)`
69
+ : ` To attach a file so it appears in the manifest, pick your OWN key:\n abx attach <address> <yourkey> <ipfs://… | ar://… | https://…>`));
45
70
  }
46
71
  }
72
+ /** Whether a value fits the literal `bytes32` lane of a raw contract-param write (printable ASCII,
73
+ * ≤ 31 bytes). Anything longer takes the data path — one blob, its hash evented. */
74
+ function fitsLiteralBytes32(value) {
75
+ return value.length <= 31 && /^[\x20-\x7e]+$/.test(value);
76
+ }
47
77
  /** Flags `abx attach` recognizes — anything else warns (non-fatal), so a silent no-op flag surfaces. */
48
- const ATTACH_FLAGS = ['file', 'compress', 'collection', 'token', 'send', 'sign', 'unsigned', 'yes', 'dry-run', 'port', 'sign-url-file', 'remote'];
78
+ const ATTACH_FLAGS = ['file', 'compress', 'collection', 'token', 'send', 'sign', 'unsigned', 'yes', 'dry-run', 'confirm', 'port', 'sign-url-file', 'remote'];
79
+ /**
80
+ * The flags EVERY owner write accepts, regardless of command — the signing lane (`laneFromFlags`),
81
+ * the opt-in confirm gate, the wallet-lane plumbing, the post-write reindex nudge, and `--json`.
82
+ * Factored out so a per-command allowlist below only has to name that command's OWN flags.
83
+ */
84
+ const SHARED_WRITE_FLAGS = ['send', 'sign', 'unsigned', 'dry-run', 'yes', 'confirm', 'port', 'sign-url-file', 'remote', 'remote-token', 'json'];
85
+ /**
86
+ * Per-command allowlists for the owner ops where a silently-ignored flag changes MONEY or SUPPLY.
87
+ *
88
+ * These commands took no stray-flag notice at all, and the ERC-1155 lane made that expensive: an
89
+ * edition's semantics live in its OPTIONAL flags, which default rather than fail. A typo'd
90
+ * `--amount 50` minted 1 copy; a typo'd `--quantity 5` bought 1 and paid 1×; and nothing said a word.
91
+ * (`deploy` refuses strays outright, but its allowlists are exhaustively maintained. Here we WARN —
92
+ * per `unknownFlags`' own contract and the owner's scriptability line: a false warning on a valid
93
+ * flag must never break someone's script, and a warning already ends the silence.)
94
+ */
95
+ const MINT_FLAGS = [...SHARED_WRITE_FLAGS, 'to', 'count', 'token-id', 'amount'];
96
+ const TRANSFER_FLAGS = [...SHARED_WRITE_FLAGS, 'to', 'token', 'token-id', 'amount', 'from'];
97
+ const SET_MAX_SUPPLY_FLAGS = [...SHARED_WRITE_FLAGS, 'token-id', 'cap'];
98
+ const MINTER_CONFIGURE_FLAGS = [...SHARED_WRITE_FLAGS, 'price', 'price-raw', 'allocation', 'erc20', 'token-id', 'minter-contract'];
99
+ const MINTER_SHOW_FLAGS = [...SHARED_WRITE_FLAGS, 'token-id', 'minter-contract'];
100
+ const MINTER_BUY_FLAGS = [...SHARED_WRITE_FLAGS, 'to', 'token-id', 'quantity', 'minter-contract'];
49
101
  /** Auto-detect the on-chain representation for an off-chain locator by its URI scheme. Returns null
50
102
  * for anything that isn't a recognized durable/http locator (so `attach` can refuse it loudly
51
103
  * rather than silently storing a bad value — the round-1 `--representation` guessing trap). */
@@ -59,21 +111,10 @@ export function representationForLocator(uri) {
59
111
  return R.url;
60
112
  return null;
61
113
  }
62
- function chainId() {
63
- return resolveChain(CHAIN).id;
64
- }
65
- /** Resolve the signing lane from flags: --unsigned (cold) · --sign (wallet) · default hot. */
66
- export function laneFromFlags(flags) {
67
- if (flags.unsigned)
68
- return 'unsigned';
69
- if (flags.sign)
70
- return 'sign';
71
- return 'send';
72
- }
73
114
  function requireAddress(address, usage) {
74
115
  if (!address || address.startsWith('--')) {
75
116
  console.error(`usage: ${usage}\n`);
76
- process.exit(1);
117
+ throw new CliError('', 1, true); // already printed above — see CliError's alreadyPrinted doc
77
118
  }
78
119
  return address;
79
120
  }
@@ -81,55 +122,32 @@ function requireFlag(flags, name, usage) {
81
122
  const v = flags[name];
82
123
  if (!v || v === 'true') {
83
124
  console.error(`missing --${name}\nusage: ${usage}\n`);
84
- process.exit(1);
125
+ throw new CliError('', 1, true); // already printed above — see CliError's alreadyPrinted doc
85
126
  }
86
127
  return v;
87
128
  }
88
- /** Run a prepared write through the chosen lane, then re-index if the project is known.
89
- * Returns whether anything was BROADCAST (false on the cold lane the tx was only printed). */
129
+ /**
130
+ * Every owner-op's send choke point. Returns the {@link SignResult} when a tx was broadcast, else
131
+ * `null` (dry run, or the cold lane which prints a tx instead of sending one).
132
+ *
133
+ * It used to return a bare boolean. It returns the result now because a caller sometimes needs the
134
+ * *receipt* — `abx mint --json` has to report which token id was actually minted, and the only
135
+ * authoritative answer is the Transfer log the mint emitted. Callers that only asked "did it send?"
136
+ * keep working: `null` is falsy, and a result object is truthy.
137
+ *
138
+ * The dry-run preview / `--confirm` prompt / lane selection live in {@link gatedSend} — the SAME
139
+ * choke point the deploy family's resumed-setup send routes through — so this is now just "gate,
140
+ * then re-index if the project is known."
141
+ */
90
142
  async function runWrite(address, provider, flags, expectedSigner) {
91
- // --dry-run: preview the exact tx and send NOTHING. This is the shared choke point for every
92
- // owner-op, so --dry-run is uniform across them — it must never fall through to a real send just
93
- // because the flag isn't wired per-command (a round-1 finding: `set-field --dry-run` silently
94
- // reached the send lane and only stopped for lack of a key).
95
- if (flags['dry-run']) {
96
- const prepared = typeof provider === 'function' ? await provider(expectedSigner ?? zeroAddress) : provider;
97
- console.log(`\n ${C.bold}◆ ${prepared.summary}${C.reset} ${dim('(dry run — nothing sent)')}`);
98
- for (const [k, v] of Object.entries(prepared.fields))
99
- console.log(` ${dim(k.padEnd(12))} ${v}`);
100
- console.log(` ${dim('to'.padEnd(12))} ${prepared.to ?? dim('(contract deploy)')}`);
101
- if (expectedSigner)
102
- console.log(` ${dim('owner'.padEnd(12))} ${expectedSigner}`);
103
- console.log(dim(`\n Re-run without --dry-run to send (lane: ${laneFromFlags(flags)}).\n`));
104
- return false;
105
- }
106
- await assertChainId(CHAIN); // verify the RPC really is CHAIN before any irreversible write
107
- const result = await signTx(provider, {
108
- lane: laneFromFlags(flags),
109
- chainKey: CHAIN,
110
- expectedSigner,
111
- yes: !!flags.yes,
112
- port: flags.port ? Number(flags.port) : undefined,
113
- signUrlFile: flags['sign-url-file'],
114
- });
143
+ const result = await gatedSend(provider, flags, { chainKey: CHAIN, expectedSigner });
115
144
  if (!result)
116
- return false; // cold lane — nothing broadcast
145
+ return null; // dry run, or the cold lane — nothing broadcast
117
146
  await reindexIfKnown(address, flags);
118
- return true;
119
- }
120
- /** Resolve a `--remote [url]` owner-op target: explicit URL, else ABX_PUBLIC_BASE_URL / ABX_RESOLVER_URL. */
121
- function remoteResolverTarget(flags) {
122
- const v = flags.remote;
123
- if (v === undefined)
124
- return null;
125
- const base = v !== 'true' && v !== '' ? String(v) : (process.env.ABX_PUBLIC_BASE_URL ?? process.env.ABX_RESOLVER_URL);
126
- if (!base) {
127
- throw new Error('`--remote` needs a resolver URL: pass `--remote https://host` or set ABX_PUBLIC_BASE_URL in .env');
128
- }
129
- return base.replace(/\/+$/, '');
147
+ return result;
130
148
  }
131
149
  async function reindexIfKnown(address, flags) {
132
- const indexer = new SelfHostIndexer();
150
+ const indexer = localIndexer();
133
151
  if (indexer.store.getRegistration(address)) {
134
152
  const { state, elapsedMs } = await indexer.reindex(address);
135
153
  console.log(` ${green('✓')} re-indexed ${state.name ?? address}: ${state.eventCount} events in ${elapsedMs}ms`);
@@ -137,46 +155,55 @@ async function reindexIfKnown(address, flags) {
137
155
  else {
138
156
  console.log(dim(` (not indexed by this node — run \`abx add ${address}\` then \`abx index\` to serve it)`));
139
157
  }
140
- // Hosted-resolver nudge. A hosted resolver serves from ITS OWN store, so the LOCAL reindex above
158
+ // Remote-resolver nudge. A remote resolver serves from ITS OWN store, so the LOCAL reindex above
141
159
  // never reaches it: after an owner-op — especially a PostParam change — it stays stale, and its
142
160
  // effect runner never sees the new inputsHash, so the marketplace thumbnail never auto-updates.
143
- // `--remote <url>` re-indexes the hosted resolver (incremental), which pings its effect runner
144
- // (ABX_EFFECTS_URL) → the automatic re-render. This is what makes a hosted `configure-param` a
145
- // one-command change (no separate `abx index --remote` + no stale thumbnail).
146
- const remote = remoteResolverTarget(flags);
161
+ // `--remote <name|url>` re-indexes the remote resolver (incremental), which pings its effect
162
+ // runner (ABX_EFFECTS_URL) → the automatic re-render. This is what makes a remote
163
+ // `configure-param` a one-command change (no separate `abx index --remote` + no stale thumbnail).
164
+ // Best-effort BY DESIGN: the signed tx already landed, so a missing token soft-skips (a hard
165
+ // error here would turn an on-chain success into a CLI failure) — worse only than stale-until-reindex.
166
+ const remote = resolveRemote(flags.remote, flags['remote-token']);
147
167
  if (!remote)
148
168
  return;
149
- const token = process.env.ABX_RESOLVER_ADMIN_TOKEN;
150
- if (!token) {
151
- console.log(dim(` (--remote: set ABX_RESOLVER_ADMIN_TOKEN in .env to nudge ${remote}; skipped — it will serve stale state until it re-indexes)`));
169
+ if (!remote.token) {
170
+ console.log(dim(` (--remote: set ${remote.tokenVar} in .env to nudge ${remote.url}; skipped — it will serve stale state until it re-indexes)`));
152
171
  return;
153
172
  }
154
173
  try {
155
- const r = await remoteAddProject(remote, token, { address }); // no fromBlock ⇒ incremental nudge
156
- console.log(` ${green('✓')} nudged hosted resolver ${dim(remote)}: ${r.project.eventCount} events (${r.mode}) its effect runner picks up the change`);
174
+ // no fromBlock ⇒ incremental nudge
175
+ const r = await serviceClient(remote).registerProject({ chainId: chainId(), address });
176
+ // A deferred nudge (202) is NOT waited on here: the signed tx has already landed, and blocking a
177
+ // completed owner-op behind someone else's backfill would be the wrong trade. Say where it got to.
178
+ if (isAccepted(r)) {
179
+ console.log(` ${green('✓')} nudged remote resolver ${dim(remote.url)}: ${r.project.status} — it will pick up the change (check: abx status ${address} --remote ${flags.remote})`);
180
+ }
181
+ else {
182
+ console.log(` ${green('✓')} nudged remote resolver ${dim(remote.url)}: ${r.project.eventCount} events (${r.mode}) — its effect runner picks up the change`);
183
+ }
157
184
  }
158
185
  catch (e) {
159
- console.log(dim(` (couldn't nudge ${remote}: ${e.message} — run \`abx index ${address} --remote ${remote}\` to refresh it)`));
186
+ console.log(dim(` (couldn't nudge ${remote.url}: ${e.message} — run \`abx index ${address} --remote ${flags.remote}\` to refresh it)`));
160
187
  }
161
188
  }
162
189
  async function read(address, functionName, args = []) {
163
190
  const publicClient = makePublicClient({ chainKey: CHAIN });
164
- try {
165
- return (await publicClient.readContract({
166
- address,
167
- abi: oneOfOneImageAbi,
168
- functionName: functionName,
169
- args: args,
170
- }));
171
- }
172
- catch (err) {
173
- // A read that "returned no data" is usually not a real revert but an address with NO CONTRACT —
174
- // previewing before deploy, a typo'd address, or the wrong chain. Every owner-op reads the
175
- // owner/ownerOf here FIRST, so this one guard turns the opaque viem error into an actionable one
176
- // across all of them. Only runs on the error path (zero cost when the contract exists).
177
- await assertContractExists(address);
178
- throw err; // the contract DOES exist → surface the original error (a genuine revert)
179
- }
191
+ const result = await tryReadContract(publicClient, { address, abi: oneOfOneImageAbi, functionName, args });
192
+ if (result !== undefined)
193
+ return result;
194
+ // A read that "returned no data" is usually not a real revert but an address with NO CONTRACT —
195
+ // previewing before deploy, a typo'd address, or the wrong chain. Every owner-op reads the
196
+ // owner/ownerOf here FIRST, so this one guard turns the opaque viem error into an actionable one
197
+ // across all of them. Only runs on the error path (zero cost when the contract exists).
198
+ await assertContractExists(address);
199
+ // The contract DOES exist → a genuine revert. tryReadContract already swallowed that error, so
200
+ // re-read once (uncaught) to surface it rather than a generic "read failed".
201
+ return (await publicClient.readContract({
202
+ address,
203
+ abi: oneOfOneImageAbi,
204
+ functionName: functionName,
205
+ args: args,
206
+ }));
180
207
  }
181
208
  /** Throw a clear, actionable error when `address` has no contract on the active chain — the common
182
209
  * cause of an owner-op's raw `returned no data ("0x")`. No-op when code is present or unknowable. */
@@ -192,7 +219,8 @@ export async function assertContractExists(address) {
192
219
  if (!code || code === '0x') {
193
220
  throw new Error(`no contract at ${address} on ${CHAIN} — nothing to operate on. Owner ops run AFTER deploy: ` +
194
221
  `deploy it first (e.g. abx deploy / deploy-series / deploy-code), or check the address and that ` +
195
- `ABX_CHAIN='${CHAIN}' is the right network (a wrong-chain address reads as empty).`);
222
+ `ABX_CHAIN='${CHAIN}' is the right network — we asked ${redactRpcUrl(resolveRpcUrl(CHAIN))}, and a ` +
223
+ `wrong-network endpoint reads as empty even when the address is right.`);
196
224
  }
197
225
  }
198
226
  // ── configure-param ──────────────────────────────────────────────────────────
@@ -202,25 +230,42 @@ export async function assertContractExists(address) {
202
230
  * and canonically ENCODES the human input per its type (`#rrggbb`, decimals ×1e10,
203
231
  * Select by label, …); `String`/`Bytes` schemas take the value as UTF-8 (or
204
232
  * `--file <path>` for bytes) via the data path. The signer must satisfy the schema's
205
- * auth (Artist = contract owner, TokenOwner — delegate.xyz honored — or the named
233
+ * auth (Creator = contract owner, TokenOwner — delegate.xyz honored — or the named
206
234
  * address); the chain enforces it either way.
207
235
  */
208
236
  /** Positional args only — drops `--flags` AND the single token each value-taking flag consumes
209
237
  * (mirrors parseFlags). `rest.filter(r => !r.startsWith('--'))` was NOT enough: a flag's VALUE
210
238
  * (e.g. the URL after `--remote`, or the path after `--file`) isn't `--`-prefixed, so it leaked
211
239
  * into the positional value (`configure-param … #ff0000 --remote http://h` → value "#ff0000 http://h"). */
212
- function positionalArgs(args) {
213
- const out = [];
214
- for (let i = 0; i < args.length; i++) {
215
- const a = args[i];
216
- if (a.startsWith('--')) {
217
- if (a.indexOf('=') === -1 && args[i + 1] && !args[i + 1].startsWith('--'))
218
- i++; // this flag consumes the next token
219
- continue;
240
+ /**
241
+ * Encode a command-line value for a payload-typed param (`String` / `Bytes`).
242
+ *
243
+ * `String` is UTF-8 text — the literal characters are the value, which is what a user means.
244
+ *
245
+ * `Bytes` is NOT text, and the old code UTF-8'd whatever string it was handed. A tester passed
246
+ * base64 (the encoding the params docs mention — which describes the *canonical decode* a program
247
+ * receives, not what you type here), and 128 packed bytes were stored as 172 bytes of base64 ASCII.
248
+ * Nothing errored; the in-chain renderer read ASCII where it expected bytes and drew garbage. So a
249
+ * `Bytes` value must state its encoding: `0x…` hex, or `--file` for real binary. A bare string is
250
+ * refused rather than guessed at — there is no safe guess between "these characters" and "these
251
+ * bytes", and the failure is invisible until a work renders wrong.
252
+ */
253
+ export function encodePayloadParam(typeName, valueInput, key) {
254
+ if (typeName === 'String')
255
+ return toHexSdk(new TextEncoder().encode(valueInput));
256
+ const v = valueInput.trim();
257
+ if (/^0x[0-9a-fA-F]*$/.test(v)) {
258
+ if (v.length % 2 !== 0) {
259
+ throw new Error(`--${key} hex value has an odd number of digits (${v.length - 2}) — a byte is two hex digits.`);
220
260
  }
221
- out.push(a);
222
- }
223
- return out;
261
+ return v;
262
+ }
263
+ throw new Error(`"${key}" is a Bytes param, so its value must say what its bytes ARE — this looks like text.\n` +
264
+ ` Pass 0x-prefixed hex: abx configure-param <addr> <id> ${key} 0x00112233…\n` +
265
+ ` Or the bytes in a file: abx configure-param <addr> <id> ${key} --file ./payload.bin\n` +
266
+ ` (Base64 is how a Bytes param is DECODED for your program — not how you write it here. ` +
267
+ `Storing base64 text would put ASCII on-chain where a renderer expects bytes, silently. ` +
268
+ `To store these literal characters on purpose, declare the key as String instead.)`);
224
269
  }
225
270
  export async function cmdConfigureParam(address, rest, flags) {
226
271
  const usage = 'abx configure-param <address> <tokenId|-> <key> <value> [--file <path>] [--remote [url]] [--sign|--unsigned] (tokenId "-" = contract scope, schema-less keys only)';
@@ -229,9 +274,11 @@ export async function cmdConfigureParam(address, rest, flags) {
229
274
  const valueInput = valueParts.join(' ');
230
275
  if (!tokenIdRaw || !key || (!valueInput && !flags.file)) {
231
276
  console.error(`usage: ${usage}\n`);
232
- process.exit(1);
277
+ process.exitCode = 1;
278
+ return;
233
279
  }
234
280
  const publicClient = makePublicClient({ chainKey: CHAIN });
281
+ await assertHasParamsSurface(publicClient, contract, 'abx configure-param');
235
282
  const schema = (await publicClient.readContract({
236
283
  address: contract,
237
284
  abi: seriesCodeAbi,
@@ -240,8 +287,9 @@ export async function cmdConfigureParam(address, rest, flags) {
240
287
  }));
241
288
  const [exists, paramTypeIdx, , , , , , selectOptions] = schema;
242
289
  // Contract scope (`-`): the raw owner setters (`setContractParam[Data]`) — the write path of
243
- // well-known contract params like `params.keys` / `display.gateway`. Schema-less keys only: a
244
- // schema'd key closes the raw path on-chain (`SchemaGoverned`) and is per-token by design.
290
+ // well-known contract params like `display.animation`. Schema-less keys only: a schema'd key closes
291
+ // the raw path on-chain (`SchemaGoverned`) and is per-token by design. No key is special here: a
292
+ // contract param is enumerated on-chain like any other and lands in tokenData for every token.
245
293
  if (tokenIdRaw === '-') {
246
294
  if (exists) {
247
295
  throw new Error(`"${key}" is schema-governed — it's a per-token PostParam (abx configure-param ${contract} <tokenId> ${key} …). ` +
@@ -254,9 +302,9 @@ export async function cmdConfigureParam(address, rest, flags) {
254
302
  await runWrite(contract, prepareSetContractParamData({ contract, key, data, chainId: cid }), flags, owner);
255
303
  return;
256
304
  }
257
- // Same encoding rule as deploy-code's params.keys leg: printable ASCII 31 chars rides as a
258
- // literal readable bytes32; anything longer takes the data path (one blob, hash evented).
259
- if (paramsKeysIsLiteral(valueInput)) {
305
+ // Printable ASCII 31 chars rides as a literal readable bytes32; anything longer takes the
306
+ // data path (one blob, hash evented).
307
+ if (fitsLiteralBytes32(valueInput)) {
260
308
  console.log(` ${key} (contract scope) ← "${valueInput}" ${dim('(literal bytes32; owner-only raw setter)')}`);
261
309
  await runWrite(contract, prepareSetContractParam({ contract, key, value: encodeTagSdk(valueInput), display: valueInput, chainId: cid }), flags, owner);
262
310
  }
@@ -268,39 +316,33 @@ export async function cmdConfigureParam(address, rest, flags) {
268
316
  }
269
317
  const tokenId = BigInt(tokenIdRaw);
270
318
  if (!exists) {
271
- throw new Error(`no PostParam schema for "${key}" on ${contract}. Schemas are declared at DEPLOY: ` +
272
- `abx deploy-code … --schema ${key}:<Type>:<Auth> (e.g. ${key}:HexColor:TokenOwner). ` +
273
- `Adding a param to an already-deployed contract isn't a CLI command yet.`);
319
+ throw new Error(`no PostParam schema for "${key}" on ${contract}. Declare one at deploy with ` +
320
+ `abx deploy-code … --schema ${key}:<Type>:<Auth>, or right now on the live contract with ` +
321
+ `abx set-schema ${contract} --schema ${key}:<Type>:<Auth> (e.g. ${key}:HexColor:TokenOwner).`);
274
322
  }
275
323
  const typeName = ['Bool', 'Select', 'Uint256Range', 'Int256Range', 'DecimalRange', 'HexColor', 'Timestamp', 'String', 'Bytes'][paramTypeIdx];
276
324
  let sent;
277
325
  if (typeName === 'String' || typeName === 'Bytes') {
278
326
  const data = flags.file
279
327
  ? toHexSdk(new Uint8Array(readFileSync(flags.file)))
280
- : toHexSdk(new TextEncoder().encode(valueInput));
328
+ : encodePayloadParam(typeName, valueInput, key);
329
+ const bytes = (data.length - 2) / 2;
330
+ // Echo the DECODED byte count. The old code echoed the string's length, which is precisely how a
331
+ // wrong encoding announced itself and was missed: 128 packed bytes passed as base64 printed
332
+ // "172 bytes".
333
+ console.log(` ${key} (${typeName}) ← ${bytes} bytes ${dim(flags.file ? '(file contents, verbatim)' : typeName === 'Bytes' ? '(decoded from hex)' : '(UTF-8 text)')}`);
281
334
  sent = await runWrite(contract, prepareConfigureTokenParamData({ contract, tokenId, key, data, chainId: chainId() }), flags);
282
335
  }
283
336
  else {
284
337
  const { value, display } = encodeScalarParam(typeName, valueInput, [...selectOptions]);
285
- console.log(` ${key} (${typeName}) ${display} ${dim('(canonical encode; the chain enforces schema + auth)')}`);
338
+ // The canonical `display` can silently differ from what was typed (HexColor accepts a bare
339
+ // `ff7f50` and normalizes to `#ff7f50`; a Select index resolves to its label; a hex digit's case
340
+ // can flip) — say so rather than silently correcting a value.
341
+ const normalized = display !== valueInput.trim() ? ` ${dim(`(normalized from "${valueInput}")`)}` : '';
342
+ console.log(` ${key} (${typeName}) ← ${display}${normalized} ${dim('(canonical encode; the chain enforces schema + auth)')}`);
286
343
  sent = await runWrite(contract, prepareConfigureTokenParam({ contract, tokenId, key, value, display, chainId: chainId() }), flags);
287
344
  }
288
- // params.keys drift nudge (best-effort, never fails the command): a project on the on-chain
289
- // URI lane enumerates its param surface in the `params.keys` contract param — the canonical
290
- // generator reads it to build on-chain tokenData. A configured key that isn't listed is
291
- // silently OMITTED there (byte-parity with the resolver breaks), so say it, with the fix.
292
345
  if (sent) {
293
- try {
294
- const csv = await readParamsKeys(publicClient, contract);
295
- const fixed = paramsKeysNudge(csv, key);
296
- if (fixed !== null) {
297
- console.log(` ${yellow('⚠')} params.keys doesn't list "${key}" — the on-chain generator's tokenData will omit it. Fix:\n` +
298
- ` ${bold(`abx configure-param ${contract} - params.keys ${fixed}`)}`);
299
- }
300
- }
301
- catch {
302
- /* nudge is advisory — an RPC hiccup or a non-Params contract must never fail the write */
303
- }
304
346
  // What happens to the IMAGE depends on how it's produced. If the `image` field is computed
305
347
  // on-chain (a `renderer` representation — the in-chain SVG lane), it re-tints AUTOMATICALLY: the
306
348
  // renderer reads the param live, so tokenURI's image is already updated, nothing to re-render.
@@ -328,7 +370,7 @@ const HOOK_ROLES = ['configure', 'augment', 'transfer'];
328
370
  const HOOK_NOTE = {
329
371
  configure: 'write-time veto/validator (a configure tx reverts if it reverts)',
330
372
  augment: 'read-time derivation folded into tokenData (live view reads chain per view)',
331
- transfer: 'ownership-change lifecycle, best-effort (a revert never blocks the transfer)',
373
+ transfer: 'ownership-change VETO — its revert fails the transfer, and a mint too (mint = transfer from 0x0)',
332
374
  };
333
375
  const eqAddr = (a, b) => a.toLowerCase() === b.toLowerCase();
334
376
  /** Parse a hook-address flag: a 0x address, or `none`/`zero`/`0`/`0x0` to clear that role. */
@@ -344,11 +386,11 @@ export function parseHookAddress(role, v) {
344
386
  }
345
387
  /**
346
388
  * `abx set-param-hooks <address> [--configure 0x|none] [--augment 0x|none] [--transfer 0x|none] [--clear]`
347
- * — wire (or clear) a SeriesCode project's three param-lifecycle hooks. The contract has NO per-hook
389
+ * — wire (or clear) a SeriesCode/EditionCode project's three param-lifecycle hooks. The contract has NO per-hook
348
390
  * setter (`setParamHooks` writes all three at once), so this READS the current trio (`paramHooks()`)
349
391
  * and re-sends it with your changes applied: omit a role to KEEP it, pass an address to set it, or
350
- * `none` to clear it (`--clear` clears all three). Owner-only, any lane, guards `--dry-run`. SeriesCode
351
- * only — a 1/1 or plain Series has no configurable params to hook. Bare (no flags) prints the current
392
+ * `none` to clear it (`--clear` clears all three). Owner-only, any lane, guards `--dry-run`. Code kinds
393
+ * only — static 1/1, Series, and edition kinds have no configurable params to hook. Bare (no flags) prints the current
352
394
  * hooks and does nothing.
353
395
  */
354
396
  export async function cmdSetParamHooks(address, flags) {
@@ -360,8 +402,8 @@ export async function cmdSetParamHooks(address, flags) {
360
402
  // Refuse the contradictory combo rather than silently pick one (enforce, don't warn).
361
403
  throw new Error(`--clear clears ALL three hooks; don't combine it with --${roleFlags.join('/--')}. Use either --clear, or per-role flags.`);
362
404
  }
363
- // Read the current trio FIRST — this doubles as the SeriesCode guard (paramHooks() exists only on
364
- // the ConfigurableParams extension; a 1/1 / plain Series reverts "no data").
405
+ // Read the current trio FIRST — this doubles as the SeriesCode/EditionCode guard (paramHooks()
406
+ // exists only on the ConfigurableParams extension; a static kind reverts "no data").
365
407
  const publicClient = makePublicClient({ chainKey: CHAIN });
366
408
  let current;
367
409
  try {
@@ -373,8 +415,12 @@ export async function cmdSetParamHooks(address, flags) {
373
415
  }
374
416
  catch (err) {
375
417
  await assertContractExists(contract); // no contract at all → the actionable no-contract error
376
- throw new Error(`${contract} exposes no param hooksthe configure/augment/transfer hooks are a SeriesCode ` +
377
- `(PostParams) feature. A 1/1 or a plain Series has no configurable params to hook.`);
418
+ // Name the ACTUAL kind rather than assuming 721 a 1/1-edition or EditionImage has no param
419
+ // hooks either (only EditionCode composes ConfigurableParams, mirroring SeriesCode), so the old
420
+ // blanket "a 1/1 or a plain Series" message misnamed an edition target's real kind.
421
+ const kind = await detectTokenKind(publicClient, contract);
422
+ throw new Error(`${contract} exposes no param hooks — the configure/augment/transfer hooks are a SeriesCode/EditionCode ` +
423
+ `(PostParams) feature. ${kind.label} has no configurable params to hook.`);
378
424
  }
379
425
  const cur = { configure: current[0], augment: current[1], transfer: current[2] };
380
426
  const label = (a) => (eqAddr(a, zeroAddress) ? dim('none') : a);
@@ -402,6 +448,13 @@ export async function cmdSetParamHooks(address, flags) {
402
448
  const changed = !eqAddr(cur[r], next[r]);
403
449
  console.log(` ${r.padEnd(10)} ${changed ? `${label(cur[r])} → ${label(next[r])}` : `${label(cur[r])} ${dim('(unchanged)')}`}`);
404
450
  }
451
+ // Arming a transfer hook is the one hook change with a consequence for COLLECTORS, so say it at
452
+ // the moment of arming rather than only in the docs. It is not a warning against doing it — an
453
+ // owner-dependent work needs it — it is the disclosure that comes with it.
454
+ if (!eqAddr(next.transfer, zeroAddress) && !eqAddr(next.transfer, cur.transfer)) {
455
+ 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.`);
456
+ 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.`));
457
+ }
405
458
  const owner = await read(contract, 'owner');
406
459
  await runWrite(contract, prepareSetParamHooks({ contract, configureHook: next.configure, augmentHook: next.augment, transferHook: next.transfer, chainId: chainId() }), flags, owner);
407
460
  }
@@ -421,7 +474,8 @@ export async function cmdSetDependency(address, rest, flags) {
421
474
  const [indexRaw, refRaw] = positionalArgs(rest);
422
475
  if (indexRaw === undefined || !refRaw) {
423
476
  console.error(`usage: ${usage}\n`);
424
- process.exit(1);
477
+ process.exitCode = 1;
478
+ return;
425
479
  }
426
480
  if (!/^\d+$/.test(indexRaw))
427
481
  throw new Error(`<index> must be a non-negative integer (got '${indexRaw}') — the list is dense: index ≤ dependencyCount`);
@@ -445,7 +499,8 @@ export async function cmdSetDependencyRegistry(address, rest, flags) {
445
499
  const [raw] = positionalArgs(rest);
446
500
  if (!raw) {
447
501
  console.error(`usage: ${usage}\n`);
448
- process.exit(1);
502
+ process.exitCode = 1;
503
+ return;
449
504
  }
450
505
  if (raw !== 'none' && !/^0x[0-9a-fA-F]{40}$/.test(raw)) {
451
506
  throw new Error(`<registry> must be an address (0x + 40 hex) or 'none' to clear; got '${raw}'`);
@@ -460,32 +515,179 @@ export async function cmdLockDependencies(address, flags) {
460
515
  const contract = requireAddress(address, 'abx lock-dependencies <address> [--sign|--unsigned]');
461
516
  const owner = await read(contract, 'owner');
462
517
  console.log(dim(' note: locking the dependency set is permanent and irreversible (list AND registry pointer freeze).'));
518
+ 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."));
463
519
  await runWrite(contract, prepareLockDependencies({ contract, chainId: chainId() }), flags, owner);
464
520
  }
521
+ /** `abx lock-script <address>` — freeze the on-chain program (script chunks) forever. This is the
522
+ * lock that actually freezes the WORK of a code project: after it, `setScriptChunk` /
523
+ * `removeLastScriptChunk` revert. `lock-field`/`lock-uri` only freeze metadata; the full set for a
524
+ * code drop is lock-script + lock-dependencies + lock-field/lock-uri — which freezes what THIS
525
+ * contract stores, not necessarily what the token renders (params have no lock, and a Registry
526
+ * dependency's bytes live in the registry). */
527
+ export async function cmdLockScript(address, flags) {
528
+ const contract = requireAddress(address, 'abx lock-script <address> [--sign|--unsigned]');
529
+ const owner = await read(contract, 'owner');
530
+ console.log(dim(' note: locking the script is permanent and irreversible (the program bytes can never change again).'));
531
+ console.log(dim(' this freezes the WORK — pair with lock-dependencies and lock-field/lock-uri to freeze everything this contract stores.'));
532
+ 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".'));
533
+ await runWrite(contract, prepareLockScript({ contract, chainId: chainId() }), flags, owner);
534
+ }
535
+ /** `abx lock-param-hooks <address>` — freeze the three param-lifecycle hook addresses forever.
536
+ * The sibling of lock-script/lock-dependencies/lock-uri, and the only one aimed at a BUYER rather
537
+ * than at metadata: the transfer hook is a veto over transfers and mints, so an unlocked hook set
538
+ * is a standing power over whether a collector can sell. After this, `set-param-hooks` reverts
539
+ * ParamHooksLocked. Owner-only, any lane, guards --dry-run. */
540
+ export async function cmdLockParamHooks(address, flags) {
541
+ const contract = requireAddress(address, 'abx lock-param-hooks <address> [--sign|--unsigned] [--dry-run]');
542
+ // Read the current trio first: it is the SeriesCode/EditionCode guard (paramHooks() exists only on
543
+ // the ConfigurableParams extension) AND the thing being frozen — nobody should sign a permanent
544
+ // freeze without seeing exactly what it freezes.
545
+ const publicClient = makePublicClient({ chainKey: CHAIN });
546
+ let current;
547
+ try {
548
+ current = (await publicClient.readContract({ address: contract, abi: seriesCodeAbi, functionName: 'paramHooks' }));
549
+ }
550
+ catch {
551
+ await assertContractExists(contract);
552
+ const kind = await detectTokenKind(publicClient, contract);
553
+ throw new Error(`${contract} exposes no param hooks — they are a SeriesCode/EditionCode (PostParams) feature, ` +
554
+ `so there is nothing to freeze. ${kind.label} has no configurable params to hook.`);
555
+ }
556
+ const cur = { configure: current[0], augment: current[1], transfer: current[2] };
557
+ const label = (a) => (eqAddr(a, zeroAddress) ? dim('none') : a);
558
+ console.log(` freezing these three addresses on ${contract}, permanently:`);
559
+ for (const r of HOOK_ROLES)
560
+ console.log(` ${r.padEnd(10)} ${label(cur[r])}`);
561
+ console.log(dim(' note: this is permanent and irreversible — no hook address can ever be set, re-pointed, or cleared again.'));
562
+ 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. ` +
563
+ `${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.'}`));
564
+ console.log(dim(' this freezes only the hooks. Schemas, param values, script, dependencies and URIs keep their own locks (`abx verify` lists them).'));
565
+ const owner = await read(contract, 'owner');
566
+ await runWrite(contract, prepareLockParamHooks({ contract, chainId: chainId() }), flags, owner);
567
+ }
568
+ // ── editions: shared small parsers ────────────────────────────────────────────
569
+ /** A non-negative-integer edition flag (`--token-id`, `--amount`, …) — the shared parse +
570
+ * bound-check so every edition command that takes "an id" or "a count" rejects the same way. */
571
+ export function parseEditionCountFlag(raw, flag) {
572
+ if (raw === 'true' || !/^\d+$/.test(raw.trim()))
573
+ throw new Error(`--${flag} must be a non-negative integer; got '${raw}'.`);
574
+ return BigInt(raw.trim());
575
+ }
576
+ /** `--token-ids <csv|range>` — the shape `ping-uri` (and any future batch-by-id command) takes: a
577
+ * comma-separated list of ids and/or `lo-hi` ranges (`0-9,20,25-30`), de-duplicated and sorted. */
578
+ export function parseTokenIdRange(raw) {
579
+ const ids = new Set();
580
+ for (const part of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
581
+ const range = part.match(/^(\d+)-(\d+)$/);
582
+ if (range) {
583
+ const [lo, hi] = [BigInt(range[1]), BigInt(range[2])];
584
+ if (lo > hi)
585
+ throw new Error(`--token-ids: range '${part}' has lo > hi.`);
586
+ for (let i = lo; i <= hi; i++)
587
+ ids.add(i);
588
+ }
589
+ else if (/^\d+$/.test(part)) {
590
+ ids.add(BigInt(part));
591
+ }
592
+ else {
593
+ throw new Error(`--token-ids: '${part}' isn't a token id or a 'lo-hi' range.`);
594
+ }
595
+ }
596
+ if (!ids.size)
597
+ throw new Error("--token-ids needs at least one id (e.g. '0,1,2' or '0-99').");
598
+ return [...ids].sort((a, b) => Number(a - b));
599
+ }
465
600
  // ── mint ─────────────────────────────────────────────────────────────────────
466
601
  // Issue a token — a deferred mint (deploy → warm resolver → mint) or a primary sale
467
602
  // (mint straight to the buyer). For a 1/1 this is the one-shot token #0. For a Series
468
603
  // it mints the next sequential token id (metadata = token id): bare (one), or `--count <n>`
469
604
  // (n in order). Owner or an authorized minter signs.
605
+ // For an EDITION (OneOfOneEdition/EditionImage/EditionCode) this mints copies of ONE id:
606
+ // --token-id (required unless the target is a 1/1-edition, whose id space is fixed to {0}) +
607
+ // --amount (default 1). --count is the 721 Series primitive and is refused on an edition.
470
608
  export async function cmdMint(address, flags) {
471
- const usage = 'abx mint <address> [--to 0x…] [--count <n>] [--sign|--unsigned]';
609
+ const usage = 'abx mint <address> [--to 0x…] [--count <n> | --token-id <id> --amount <n>] [--json] [--sign|--unsigned]';
472
610
  const contract = requireAddress(address, usage);
473
- const owner = await read(contract, 'owner');
474
- const to = flags.to ?? owner; // default: pre-mint to the admin
475
- let tx;
476
- if (flags.count !== undefined && flags.count !== 'true') {
477
- const count = BigInt(flags.count);
478
- console.log(dim(` minting ${count} Series tokens in order → ${to}`));
479
- tx = prepareSeriesMintMany({ contract, to, count, chainId: chainId() });
480
- }
481
- else {
482
- // Bare mint: the `mint(address)` selector is shared by the 1/1 (token #0) and a
483
- // Series (next sequential token) — one path serves both.
484
- console.log(dim(` minting token ${to}${flags.to ? '' : ' (owner pass --to for a buyer)'}`));
485
- tx = prepareMint({ contract, to, chainId: chainId() });
486
- }
487
- await runWrite(contract, tx, flags, owner);
488
- console.log(dim(` next: \`abx refresh ${contract}\` so marketplaces pick up the new token.`));
611
+ warnStrayFlags(flags, new Set(MINT_FLAGS), 'mint');
612
+ // `--json`: the TOKEN ID is the value a program came for, and it was previously only obtainable by
613
+ // regex-scraping coloured prose. It is read from the mint's own Transfer logs rather than by
614
+ // re-reading `nextTokenId` afterwards a concurrent mint would make that answer wrong, and a
615
+ // number that is usually right is worse than no number.
616
+ return withJson(flags, async () => {
617
+ const publicClient = makePublicClient({ chainKey: CHAIN });
618
+ const kind = await detectTokenKind(publicClient, contract);
619
+ const owner = await read(contract, 'owner');
620
+ const to = flags.to ?? owner; // default: pre-mint to the admin
621
+ if (kind.isEdition) {
622
+ // `--count` is the 721 Series primitive (N sequential ids); an edition mint always names its
623
+ // id and an amount — there is no "next in order" to count through. Refuse, don't silently
624
+ // reinterpret it as something else.
625
+ if (flags.count !== undefined) {
626
+ 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.`);
627
+ }
628
+ // OneOfOneEdition's id space is fixed to {0}, so --token-id defaults there; EditionImage/
629
+ // EditionCode ids are caller-named works with no sensible default — require it.
630
+ if (kind.kind !== '1of1-edition' && flags['token-id'] === undefined) {
631
+ throw new Error(`--token-id is required on a ${kind.label} — its ids are caller-named works, not a single default. ` +
632
+ `See existing ids with \`abx tokens ${contract}\`.`);
633
+ }
634
+ const tokenId = flags['token-id'] !== undefined ? parseEditionCountFlag(flags['token-id'], 'token-id') : 0n;
635
+ const amount = flags.amount !== undefined ? parseEditionCountFlag(flags.amount, 'amount') : 1n;
636
+ console.log(dim(` minting ${amount} cop${amount === 1n ? 'y' : 'ies'} of #${tokenId} → ${to}${flags.to ? '' : ' (owner — pass --to for a buyer)'}`));
637
+ const tx = prepareEditionMint({ contract, to, tokenId, amount, chainId: chainId() });
638
+ const result = await runWrite(contract, tx, flags, owner);
639
+ if (result)
640
+ console.log(` ${green('✓')} minted ${amount} cop${amount === 1n ? 'y' : 'ies'} of ${bold('#' + tokenId)} → ${to}`);
641
+ console.log(dim(` next: \`abx refresh ${contract}\` so marketplaces pick up the change.`));
642
+ return {
643
+ contract,
644
+ chainId: chainId(),
645
+ to,
646
+ sent: !!result,
647
+ txHash: result?.txHash ?? null,
648
+ blockNumber: result ? String(result.blockNumber) : null,
649
+ tokenIds: [tokenId.toString()],
650
+ amount: amount.toString(),
651
+ };
652
+ }
653
+ // 721 path (unchanged): --token-id/--amount have no meaning here — refuse rather than silently
654
+ // ignore (the membrane rule: enforce, don't warn).
655
+ if (flags['token-id'] !== undefined || flags.amount !== undefined) {
656
+ throw new Error(`--token-id/--amount are edition-only (ERC-1155 copies) — ${contract} is a ${kind.label} (721). ` +
657
+ `Mint the next token with \`abx mint ${contract}\`${kind.kind !== '1of1' ? ' (or --count N for several in order)' : ''}.`);
658
+ }
659
+ let tx;
660
+ let count = 1n;
661
+ if (flags.count !== undefined && flags.count !== 'true') {
662
+ count = BigInt(flags.count);
663
+ console.log(dim(` minting ${count} Series tokens in order → ${to}`));
664
+ tx = prepareSeriesMintMany({ contract, to, count, chainId: chainId() });
665
+ }
666
+ else {
667
+ // Bare mint: the `mint(address)` selector is shared by the 1/1 (token #0) and a
668
+ // Series (next sequential token) — one path serves both.
669
+ console.log(dim(` minting token → ${to}${flags.to ? '' : ' (owner — pass --to for a buyer)'}`));
670
+ tx = prepareMint({ contract, to, chainId: chainId() });
671
+ }
672
+ const result = await runWrite(contract, tx, flags, owner);
673
+ const tokenIds = result ? await mintedTokenIds(makePublicClient({ chainKey: CHAIN }), contract, result.txHash) : [];
674
+ if (result && tokenIds.length) {
675
+ console.log(` ${green('✓')} minted token${tokenIds.length > 1 ? 's' : ''} ${bold(tokenIds.map((t) => '#' + t).join(', '))} → ${to}`);
676
+ }
677
+ console.log(dim(` next: \`abx refresh ${contract}\` so marketplaces pick up the new token.`));
678
+ return {
679
+ contract,
680
+ chainId: chainId(),
681
+ to,
682
+ // `sent: false` is the dry-run and cold-lane answer — an empty tokenIds with no explanation
683
+ // would read as a failed mint.
684
+ sent: !!result,
685
+ txHash: result?.txHash ?? null,
686
+ blockNumber: result ? String(result.blockNumber) : null,
687
+ tokenIds,
688
+ requestedCount: Number(count),
689
+ };
690
+ });
489
691
  }
490
692
  // ── series owner ops (minter set · supply cap · primary payee) ────────────────
491
693
  // The multi-token knobs. All owner-only; each runs through the same signing lane +
@@ -505,9 +707,101 @@ export async function cmdSetMaxInvocations(address, flags) {
505
707
  const usage = 'abx set-max-invocations <address> --max <N> [--sign|--unsigned]';
506
708
  const contract = requireAddress(address, usage);
507
709
  const max = BigInt(requireFlag(flags, 'max', usage));
710
+ // The cap only ever decreases, but `--dry-run` used to print "Set max invocations → 20" and state
711
+ // the new value as fact for a contract capped at 16 — then the real send reverted. A creator (or an
712
+ // agent) reads a clean dry run as permission to send, so the preview has to know what the chain
713
+ // knows. Best-effort: an unreadable getter does NOT block, since the chain enforces it regardless.
714
+ // `readSeries`, not `read`: the 1/1 ABI that `read` uses has no `maxInvocations` at all, so reading
715
+ // through it always threw and this guard silently never fired.
716
+ const capNow = await readSeries(contract, 'maxInvocations').catch(() => null);
717
+ if (capNow !== null && max > capNow) {
718
+ throw new Error(`${contract}'s cap is already ${capNow} and maxInvocations can only DECREASE — ${max} would raise it, which the chain refuses. ` +
719
+ `Pass a number at or below ${capNow} (a supply cap is one-way by design: it can be tightened, never reopened).`);
720
+ }
508
721
  const owner = await read(contract, 'owner');
509
722
  await runWrite(contract, prepareSetMaxInvocations({ contract, maxInvocations: max, chainId: chainId() }), flags, owner);
510
723
  }
724
+ // ── set-max-supply (editions only) — the per-id twin of set-max-invocations ──────────────────────
725
+ /**
726
+ * `abx set-max-supply <address> --token-id <n> --cap <n>` — owner overrides ONE id's ERC-1155
727
+ * supply cap (Edition Supply extension). Monotonically non-increasing once used for that id — the
728
+ * chain enforces it (`MaxSupplyIncreaseForbidden`/`MaxSupplyBelowFloor`), and `--cap open` is
729
+ * refused HERE, before any gas: the contract's own "0" means "closed forever" once an id has been
730
+ * overridden (not "reopen to uncapped" — `EditionSupply.sol`'s own doc calls this out), so accepting
731
+ * `open` as an alias for 0 would silently promise the opposite of what it does on a re-run.
732
+ */
733
+ export async function cmdSetMaxSupply(address, flags) {
734
+ const usage = 'abx set-max-supply <address> --token-id <n> --cap <n> [--sign|--unsigned]';
735
+ const contract = requireAddress(address, usage);
736
+ warnStrayFlags(flags, new Set(SET_MAX_SUPPLY_FLAGS), 'set-max-supply');
737
+ const publicClient = makePublicClient({ chainKey: CHAIN });
738
+ const kind = await detectTokenKind(publicClient, contract);
739
+ if (!kind.isEdition) {
740
+ throw new Error(`set-max-supply is edition-only (a per-id ERC-1155 cap) — ${contract} is a ${kind.label} (721). Use \`abx set-max-invocations\` instead (the whole-project cap).`);
741
+ }
742
+ const tokenId = parseEditionCountFlag(requireFlag(flags, 'token-id', usage), 'token-id');
743
+ const capRaw = requireFlag(flags, 'cap', usage);
744
+ if (capRaw.trim().toLowerCase() === 'open') {
745
+ throw new Error(`--cap open is refused: once you set a cap for #${tokenId} it can only DECREASE (never increase back to uncapped) — ` +
746
+ `"open" would ask for exactly the increase the chain forbids. If this id has never been overridden, it's already open ` +
747
+ `(the --copies default from deploy) — there's nothing to set. To lower an existing cap, pass the number.`);
748
+ }
749
+ const cap = parseEditionCountFlag(capRaw, 'cap');
750
+ // Both ways this reverts on chain were reachable through `--dry-run` unchanged: the preview printed
751
+ // "#0 supply cap → 50" and stated the new cap as fact, then the real send reverted. A dry run that
752
+ // green-lights an impossible write is worse than no dry run — the creator's next step is to send it.
753
+ // `maxSupply(id) === 0` reads as "open" (the un-overridden --copies default), so any finite cap is a
754
+ // decrease from open and allowed. Reads are best-effort: if either read fails we do NOT block, since
755
+ // the chain still enforces the invariant and a guard must not turn an RPC miss into a refusal.
756
+ const [capNow, supplyNow] = await Promise.all([
757
+ readEdition(contract, 'maxSupply', [tokenId]).catch(() => null),
758
+ readEdition(contract, 'totalSupply', [tokenId]).catch(() => null),
759
+ ]);
760
+ if (capNow !== null && capNow > 0n && cap > capNow) {
761
+ throw new Error(`#${tokenId}'s cap is already ${capNow} and a cap can only DECREASE — ${cap} would raise it, which the chain refuses. ` +
762
+ `Pass a number at or below ${capNow}${supplyNow !== null ? ` and at or above its ${supplyNow} live cop${supplyNow === 1n ? 'y' : 'ies'}` : ''}.`);
763
+ }
764
+ if (supplyNow !== null && cap < supplyNow) {
765
+ 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. ` +
766
+ `The lowest cap you can set is ${supplyNow} (which closes the id to further minting).`);
767
+ }
768
+ const owner = await read(contract, 'owner');
769
+ console.log(dim(` #${tokenId} supply cap → ${cap}`));
770
+ await runWrite(contract, prepareSetMaxSupply({ contract, tokenId, cap, chainId: chainId() }), flags, owner);
771
+ }
772
+ // ── ping-uri (editions only) — the OWNER's URI re-emission ───────────────────────────────────────
773
+ /** How many ids one `pingURI` call carries per transaction — large enough that a normal collection
774
+ * fits in one tx, small enough that a genuinely huge id list doesn't build one unbounded call. */
775
+ const PING_URI_CHUNK_SIZE = 200;
776
+ /**
777
+ * `abx ping-uri <address> --token-ids <csv|range>` — re-emit the native ERC-1155 `URI` event for
778
+ * the given ids after a contract-wide re-point (`set-token-uri`/`set-renderer`), so marketplaces/
779
+ * indexers that only honor the native event re-index. Permissionless (anyone may call it — it only
780
+ * re-emits already-public, current truth), so there is no owner check and no `expectedSigner` to
781
+ * pin; whichever signer the active lane resolves sends it. Chunks a large id list into several
782
+ * transactions, each through the SAME `--dry-run`/`--confirm` gate as any other write.
783
+ */
784
+ export async function cmdPingUri(address, flags) {
785
+ const usage = 'abx ping-uri <address> --token-ids <csv|range> [--sign|--unsigned]';
786
+ const contract = requireAddress(address, usage);
787
+ const publicClient = makePublicClient({ chainKey: CHAIN });
788
+ const kind = await detectTokenKind(publicClient, contract);
789
+ if (!kind.isEdition) {
790
+ throw new Error(`ping-uri is edition-only — it re-emits the ERC-1155 URI event, which a ${kind.label} (721) doesn't have ` +
791
+ `(both lanes emit ERC-4906 automatically on a URI change; this command is for indexers that ` +
792
+ `honor only the native 1155 event).`);
793
+ }
794
+ const ids = parseTokenIdRange(requireFlag(flags, 'token-ids', usage));
795
+ const chunks = [];
796
+ for (let i = 0; i < ids.length; i += PING_URI_CHUNK_SIZE)
797
+ chunks.push(ids.slice(i, i + PING_URI_CHUNK_SIZE));
798
+ 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`));
799
+ for (const [i, chunk] of chunks.entries()) {
800
+ if (chunks.length > 1)
801
+ console.log(dim(` chunk ${i + 1}/${chunks.length}: ${chunk.length} id(s) (#${chunk[0]}–#${chunk[chunk.length - 1]})`));
802
+ await runWrite(contract, preparePingURI({ contract, tokenIds: chunk, chainId: chainId() }), flags);
803
+ }
804
+ }
511
805
  /** Pause minting — restrict it to the owner (config/reserves) until unpaused. */
512
806
  export async function cmdPause(address, flags) {
513
807
  const contract = requireAddress(address, 'abx pause <address> [--sign|--unsigned]');
@@ -527,9 +821,9 @@ export async function cmdUnpause(address, flags) {
527
821
  * scope, so one value covers the whole project — they project into `contractURI` on the resolver
528
822
  * AND the on-chain renderer. Works for every token type (1/1 · Series · code). */
529
823
  export const AUTHORSHIP_DEPLOY_FIELDS = [
530
- ['artist', F.artist],
824
+ ['creator', F.creator],
531
825
  ['display-notes', F.displayNotes],
532
- ['artist-links', F.artistLinks],
826
+ ['creator-links', F.creatorLinks],
533
827
  ['license', F.license],
534
828
  ];
535
829
  /** Build the on-chain (inline) collection fields for whichever authorship/rights deploy flags are
@@ -537,6 +831,125 @@ export const AUTHORSHIP_DEPLOY_FIELDS = [
537
831
  export function authorshipContractFields(flags) {
538
832
  return AUTHORSHIP_DEPLOY_FIELDS.flatMap(([flag, field]) => flags[flag] ? [{ field: encodeTag(field), representation: encodeTag(R.inline), value: toHex(String(flags[flag])) }] : []);
539
833
  }
834
+ // ── preferred gateways ───────────────────────────────────────────────────────
835
+ // The two reserved COLLECTION-scope fields that turn a content-addressed `ipfs`/`arweave` value
836
+ // into the `https://` a marketplace can render. Identity (the CID) stays in the field; the serving
837
+ // prefix lives here, project-wide, so a dead or slow gateway is a REPOINT and never a rewrite.
838
+ /** Refuse a prefix that cannot serve. Two rules only — a URL parser here would be a liability, but
839
+ * a value that is not http(s) simply cannot be concatenated into a working URL by any consumer. */
840
+ export function assertGatewayPrefix(network, prefix) {
841
+ const value = prefix.trim();
842
+ if (!value)
843
+ throw new Error(`--${network} needs a gateway prefix (e.g. ${GATEWAY_FLOOR[network]}), or 'none' to clear it`);
844
+ if (!/^https?:\/\//i.test(value)) {
845
+ throw new Error(`gateway prefix "${value}" must start with https:// (or http://) — it is concatenated directly ` +
846
+ `onto the CID, so it has to be the whole serving prefix INCLUDING the trailing path.\n` +
847
+ ` e.g. ${GATEWAY_FLOOR[network]}` +
848
+ (network === 'ipfs' ? ' or https://<your>.mypinata.cloud/ipfs/' : ''));
849
+ }
850
+ return value;
851
+ }
852
+ /**
853
+ * The gateway fields a deploy should write.
854
+ *
855
+ * Precedence, per scheme: `--ipfs-gateway` / `--arweave-gateway` → `--gateway` (only for the backend
856
+ * actually in use) → `ABX_IPFS_GATEWAY` / `ABX_ARWEAVE_GATEWAY` → nothing.
857
+ *
858
+ * `--gateway` counts because it has to. It is the STORAGE gateway — where the CLI uploads, probes,
859
+ * and builds `locator()` — and before this it was also, accidentally, the serving gateway, because
860
+ * deploy baked `locator()` straight into a `url` field. Now that the field holds a bare CID, a
861
+ * creator who passed `--gateway https://mine.mypinata.cloud` and nothing else would have had their
862
+ * gateway stripped out and their tokens served from `ipfs.io`: strictly worse than before, for a
863
+ * flag they did pass. Someone who genuinely wants to upload through one gateway and serve through
864
+ * another says so with the scheme-specific flag.
865
+ *
866
+ * Deliberately writes NOTHING when no source names one. Persisting the public floor would make a
867
+ * project that simply took the default look like it chose `ipfs.io` — and would freeze it there,
868
+ * since a floor only improves for collections that stayed silent. Silence is a live default; an
869
+ * explicit value is a decision.
870
+ */
871
+ export function gatewayContractFields(flags) {
872
+ const out = [];
873
+ for (const network of ['ipfs', 'arweave']) {
874
+ const { prefix, chosen } = servingGateway(network, flags);
875
+ if (!chosen)
876
+ continue;
877
+ out.push({
878
+ field: encodeTag(GATEWAY_FIELD[network]),
879
+ representation: encodeTag(R.inline),
880
+ value: toHex(prefix),
881
+ });
882
+ }
883
+ return out;
884
+ }
885
+ /**
886
+ * The prefix this run will actually be SERVED from for one scheme — the same precedence
887
+ * `gatewayContractFields` writes, but resolved all the way down to the public floor so a caller can
888
+ * *show* the creator the URL their token will really carry.
889
+ *
890
+ * Split out because a deploy used to narrate the backend's UPLOAD locator (`gateway.pinata.cloud/…`)
891
+ * while committing a bare CID served from somewhere else entirely — a progress line that contradicted
892
+ * the chain. `chosen` distinguishes "the project picked this" (written on chain) from "the floor is
893
+ * filling a silence" (nothing written, and a later floor change moves the project with it).
894
+ *
895
+ * `--gateway` and the env vars are HOST-shaped (`https://my.gw`); the on-chain/served form is a full
896
+ * prefix including the trailing path, so normalize once, here.
897
+ */
898
+ export function servingGateway(network, flags) {
899
+ const activeBackend = backendResolution({ backend: flags.backend }).backend;
900
+ const named = flags[`${network}-gateway`] ??
901
+ (activeBackend === network ? flags.gateway : undefined) ??
902
+ readEnv(network === 'ipfs' ? 'ABX_IPFS_GATEWAY' : 'ABX_ARWEAVE_GATEWAY');
903
+ if (!named || named === 'true')
904
+ return { prefix: GATEWAY_FLOOR[network], chosen: false };
905
+ return { prefix: gatewayPrefixFrom(network, assertGatewayPrefix(network, String(named))), chosen: true };
906
+ }
907
+ /**
908
+ * `abx set-gateway <address> [--ipfs <prefix>] [--arweave <prefix>]` — repoint where this
909
+ * collection's content-addressed fields are served from. The CID never moves.
910
+ *
911
+ * A separate command rather than two `set-field` invocations because the failure modes are all
912
+ * silent: a token-scope write (ignored — a gateway is one answer per project), a non-`inline`
913
+ * representation (ignored), a prefix missing its trailing path (`https://ipfs.io` + `<cid>` is a
914
+ * 404). `set-field` refuses these keys and points here instead.
915
+ */
916
+ export async function cmdSetGateway(address, flags) {
917
+ const usage = 'abx set-gateway <address> [--ipfs <prefix>|none] [--arweave <prefix>|none] [--sign|--unsigned]';
918
+ const contract = requireAddress(address, usage);
919
+ const writes = [];
920
+ for (const network of ['ipfs', 'arweave']) {
921
+ const raw = flags[network];
922
+ if (raw === undefined)
923
+ continue;
924
+ if (raw === 'true')
925
+ throw new Error(`--${network} needs a value: a prefix like ${GATEWAY_FLOOR[network]}, or 'none' to fall back to the public default`);
926
+ // Clearing is a real operation: it returns the collection to the floor, which is what a project
927
+ // wants after a dedicated gateway subscription lapses.
928
+ writes.push({ network, value: raw === 'none' ? '' : gatewayPrefixFrom(network, assertGatewayPrefix(network, String(raw))) });
929
+ }
930
+ if (!writes.length)
931
+ throw new Error(`${usage}\n Pass at least one of --ipfs / --arweave. They are separate so a project can pay for a dedicated IPFS gateway and leave Arweave on the public one.`);
932
+ const owner = await read(contract, 'owner');
933
+ for (const { network, value } of writes) {
934
+ console.log(value
935
+ ? ` ${bold(network)} → ${value}`
936
+ : ` ${bold(network)} → ${dim(`cleared (falls back to ${GATEWAY_FLOOR[network]})`)}`);
937
+ await runWrite(contract, prepareSetContractField({
938
+ contract,
939
+ field: GATEWAY_FIELD[network],
940
+ representation: R.inline,
941
+ // An empty value clears the field, so the read falls through to the floor. The store refuses
942
+ // an empty write (`EmptyFieldValue`), so clearing writes a single space — which
943
+ // `projectGatewayPrefix` trims to nothing and treats as "no preference stated".
944
+ value: toHex(value || ' '),
945
+ chainId: chainId(),
946
+ }), flags, owner);
947
+ }
948
+ if (!isDryRun(flags)) {
949
+ console.log(dim('\n Every token whose image is a content-addressed field now resolves through the new prefix.'));
950
+ console.log(dim(' ERC-4906 fired, so 4906-aware marketplaces refresh on their own; `abx refresh <address>` nudges the rest.\n'));
951
+ }
952
+ }
540
953
  /** Set (or clear, with `--payee none`) the primary-sale payout destination. */
541
954
  export async function cmdSetPrimaryPayee(address, flags) {
542
955
  const usage = 'abx set-primary-payee <address> --payee 0x…|none [--sign|--unsigned]';
@@ -551,15 +964,29 @@ export async function cmdSetPrimaryPayee(address, flags) {
551
964
  // marketplaces fetch the on-chain tokenURI; if the resolver wasn't warm they cache
552
965
  // a miss until refreshed. We emit ERC-4906 on URI changes (4906-aware marketplaces
553
966
  // auto-refresh) — this is the manual fallback for the rest, and after the genesis mint.
554
- const OPENSEA_CHAIN = { sepolia: 'sepolia', mainnet: 'ethereum' };
555
- const TESTNET_CHAINS = new Set(['sepolia']);
967
+ /**
968
+ * OpenSea's own chain slugs, keyed by ours. They are NOT our keys (`base-sepolia` is `base_sepolia`
969
+ * there) and not derivable from viem, so the map is unavoidable — but a chain missing from it must
970
+ * produce NO link rather than a wrong one, which is what `osChain ?? CHAIN` used to do.
971
+ *
972
+ * It previously listed only `sepolia` and `mainnet`, so the CLI's own DEFAULT chain (`base-sepolia`)
973
+ * fell through to the raw key: the refresh POST went to `/chain/base-sepolia/…` (a slug OpenSea does
974
+ * not know) and the printed link pointed at **mainnet** `opensea.io` for a testnet token. Same shape
975
+ * as the hardcoded explorer table that once sent every Base Sepolia link to Etherscan — hence
976
+ * `testnet` now comes from the chain registry instead of a second hand-maintained set.
977
+ */
978
+ const OPENSEA_CHAIN = { 'base-sepolia': 'base_sepolia', sepolia: 'sepolia' };
556
979
  export async function cmdRefresh(address, flags) {
557
980
  const contract = requireAddress(address, 'abx refresh <address> [--token 0]');
558
981
  const tokenId = flags.token ?? '0';
559
- const osChain = OPENSEA_CHAIN[CHAIN] ?? CHAIN;
560
- const explorer = resolveChain(CHAIN).blockExplorers?.default?.url ?? '';
982
+ const chain = resolveChain(CHAIN);
983
+ const osChain = OPENSEA_CHAIN[CHAIN];
984
+ const explorer = chain.blockExplorers?.default?.url ?? '';
561
985
  const apiKey = process.env.OPENSEA_API_KEY;
562
- if (apiKey) {
986
+ if (!osChain) {
987
+ console.log(dim(` no OpenSea slug known for '${CHAIN}' — skipping the OpenSea refresh (a guessed slug 404s, and a guessed link would point at the wrong network).`));
988
+ }
989
+ else if (apiKey) {
563
990
  const url = `https://api.opensea.io/api/v2/chain/${osChain}/contract/${contract}/nfts/${tokenId}/refresh`;
564
991
  try {
565
992
  const res = await fetch(url, { method: 'POST', headers: { 'x-api-key': apiKey, accept: 'application/json' } });
@@ -575,17 +1002,58 @@ export async function cmdRefresh(address, flags) {
575
1002
  else {
576
1003
  console.log(dim(' no OPENSEA_API_KEY set — open these and click “Refresh metadata”:'));
577
1004
  }
578
- const osBase = TESTNET_CHAINS.has(CHAIN) ? 'https://testnets.opensea.io' : 'https://opensea.io';
579
- console.log(` ${dim('OpenSea ')}${osBase}/assets/${osChain}/${contract}/${tokenId}`);
1005
+ // `testnet` from the chain registry (viem), never a local set — that is the drift this bug was.
1006
+ const osBase = chain.testnet ? 'https://testnets.opensea.io' : 'https://opensea.io';
1007
+ if (osChain)
1008
+ console.log(` ${dim('OpenSea ')}${osBase}/assets/${osChain}/${contract}/${tokenId}`);
580
1009
  if (explorer)
581
1010
  console.log(` ${dim('Etherscan')} ${explorer}/token/${contract}?a=${tokenId}`);
1011
+ // Both lanes now emit ERC-4906 on a URI change, so this is one sentence again. The 1155 lane
1012
+ // used to emit only its own config event, which is why this branched and why `ping-uri` exists;
1013
+ // a collection-wide re-point on an edition must emit a refresh signal, and the
1014
+ // fix was to emit the O(1) ERC-4906 range form there too. `ping-uri` is still useful on an
1015
+ // edition, but for a narrower reason: it re-emits the NATIVE ERC-1155 `URI` event for indexers
1016
+ // that honor only that one.
582
1017
  console.log(dim(' (ERC-4906 already pings 4906-aware marketplaces on URI changes; this covers the genesis mint + the rest.)'));
583
1018
  }
584
1019
  // ── transfer ─────────────────────────────────────────────────────────────────
585
1020
  export async function cmdTransfer(address, flags) {
586
- const contract = requireAddress(address, 'abx transfer <address> --to 0x… [--token 0] [--sign|--unsigned]');
587
- const to = requireFlag(flags, 'to', 'abx transfer <address> --to 0x…');
588
- const tokenId = BigInt(flags.token ?? '0');
1021
+ const usage = 'abx transfer <address> --to 0x… [--token 0 | --token-id 0] [--amount <n> --from 0x… (editions only)] [--sign|--unsigned]';
1022
+ const contract = requireAddress(address, usage);
1023
+ warnStrayFlags(flags, new Set(TRANSFER_FLAGS), 'transfer');
1024
+ const to = requireFlag(flags, 'to', usage);
1025
+ // `--token-id` is accepted as an alias for `--token`: EVERY sibling id-taking command (`mint`,
1026
+ // `set-max-supply`, `minter …`) spells it `--token-id`, so an agent that learned the name there
1027
+ // passed it here — where it was silently ignored and id 0 moved instead of the id they named.
1028
+ // On an edition that means transferring the WRONG WORK with no warning. Both spellings work now;
1029
+ // disagreeing values are refused rather than silently preferring one.
1030
+ if (flags.token !== undefined && flags['token-id'] !== undefined && flags.token !== flags['token-id']) {
1031
+ throw new Error(`--token ${flags.token} and --token-id ${flags['token-id']} disagree — pass one (they are aliases for the same id).`);
1032
+ }
1033
+ const tokenId = BigInt(flags['token-id'] ?? flags.token ?? '0');
1034
+ const publicClient = makePublicClient({ chainKey: CHAIN });
1035
+ const kind = await detectTokenKind(publicClient, contract);
1036
+ if (kind.isEdition) {
1037
+ const amount = flags.amount !== undefined ? parseEditionCountFlag(flags.amount, 'amount') : 1n;
1038
+ // Unlike a 721 (one holder, read on-chain via ownerOf), an edition id can have MANY concurrent
1039
+ // holders — there is no single "the owner" to move copies from, so the holder must be named.
1040
+ const fromRaw = flags.from;
1041
+ if (!fromRaw || fromRaw === 'true') {
1042
+ throw new Error(`--from 0x.. is required on a ${kind.label} — an edition id can have many concurrent holders, so ` +
1043
+ `there is no single current owner to read on-chain (unlike a 721). Pass the holder's address.`);
1044
+ }
1045
+ if (!isAddress(fromRaw))
1046
+ throw new Error(`--from must be a 0x address; got '${fromRaw}'.`);
1047
+ const from = getAddress(fromRaw);
1048
+ const tx = prepareEditionTransfer({ contract, from, to, tokenId, amount, chainId: chainId() });
1049
+ await runWrite(contract, tx, flags, from);
1050
+ return;
1051
+ }
1052
+ // 721 path (unchanged): --amount has no meaning — a token transfers as a whole. Refuse rather
1053
+ // than silently ignore.
1054
+ if (flags.amount !== undefined) {
1055
+ throw new Error(`--amount is edition-only (ERC-1155 copies) — ${contract} is a ${kind.label} (721), where a token transfers as a whole. Drop --amount.`);
1056
+ }
589
1057
  const from = await read(contract, 'ownerOf', [tokenId]);
590
1058
  const tx = prepareTransfer({ contract, from, to, tokenId, chainId: chainId() });
591
1059
  await runWrite(contract, tx, flags, from);
@@ -620,19 +1088,21 @@ export async function cmdSetContractUri(address, flags) {
620
1088
  await runWrite(contract, prepareSetContractURIBase({ contract, base, chainId: chainId() }), flags, owner);
621
1089
  }
622
1090
  // ── set-royalty ──────────────────────────────────────────────────────────────
623
- /** Parse + bound-check a royalty `--bps` (basis points, 0–10000 = 0–100%). Throws a plain-language
1091
+ /** Parse + bound-check a royalty `--bps` (basis points). The bound is the CONTRACT's cap, not ERC-2981's
1092
+ * range — see {@link MAX_ROYALTY_BPS}. Throws a plain-language
624
1093
  * error on a non-integer or out-of-range value instead of letting it hit the chain (or a cryptic
625
1094
  * ABI read). Exported for the regression test. */
626
1095
  export function parseRoyaltyBps(raw) {
627
1096
  const bps = Number(raw);
628
- if (!Number.isInteger(bps) || bps < 0 || bps > 10000) {
629
- throw new Error(`--bps must be a whole number 0–10000 (100 = 1%, 750 = 7.5%, 10000 = 100%); got '${raw}'.`);
1097
+ if (!Number.isInteger(bps) || bps < 0 || bps > MAX_ROYALTY_BPS) {
1098
+ throw new Error(`--bps must be a whole number 0–${MAX_ROYALTY_BPS} (100 = 1%, 750 = 7.5%, ${MAX_ROYALTY_BPS} = ${MAX_ROYALTY_BPS / 100}%); got '${raw}'. ` +
1099
+ `A collection's own royalty cap (owner-set at deploy, reduce-only) may be lower and is enforced on chain.`);
630
1100
  }
631
1101
  return bps;
632
1102
  }
633
1103
  export async function cmdSetRoyalty(address, flags) {
634
- const contract = requireAddress(address, 'abx set-royalty <address> --bps <0-10000> [--receiver 0x…] [--sign|--unsigned]');
635
- const bps = parseRoyaltyBps(requireFlag(flags, 'bps', 'abx set-royalty <address> --bps <0-10000>'));
1104
+ const contract = requireAddress(address, 'abx set-royalty <address> --bps <0-1000> [--receiver 0x…] [--sign|--unsigned]');
1105
+ const bps = parseRoyaltyBps(requireFlag(flags, 'bps', 'abx set-royalty <address> --bps <0-1000>'));
636
1106
  const owner = await read(contract, 'owner');
637
1107
  // Default the receiver to the CURRENT on-chain receiver so `--bps` alone just changes the rate.
638
1108
  // Read it via the ERC-2981 `royaltyInfo` view (there is NO bare `royaltyReceiver()` getter —
@@ -648,126 +1118,464 @@ export async function cmdSetRoyalty(address, flags) {
648
1118
  }
649
1119
  await runWrite(contract, prepareSetRoyalty({ contract, receiver, bps, chainId: chainId() }), flags, owner);
650
1120
  }
1121
+ /**
1122
+ * `abx set-royalty-cap <address> --cap <0-10000>` — lower the collection's royalty ceiling.
1123
+ * Owner-only and REDUCE-ONLY: the ceiling was fixed at deploy (default 10%, or the royalty rate if
1124
+ * higher) and can only ever come down, so buyers can trust a stated maximum. Both ways the chain
1125
+ * refuses are checked HERE, before any signing, so the refusal is instant and free rather than a paid
1126
+ * revert (reads are best-effort — an unreadable getter does NOT block, since the chain enforces
1127
+ * both invariants regardless):
1128
+ * - not a reduction (`newCap >= currentCap`) → `RoyaltyCapNotReduced()`;
1129
+ * - below the live royalty rate (`newCap < currentRoyaltyBps`) → `RoyaltyCapBelowRoyalty()`.
1130
+ */
1131
+ export async function cmdSetRoyaltyCap(address, flags) {
1132
+ const usage = 'abx set-royalty-cap <address> --cap <0-10000> [--sign|--unsigned]';
1133
+ const contract = requireAddress(address, usage);
1134
+ const cap = parseRoyaltyBps(requireFlag(flags, 'cap', usage));
1135
+ // The ceiling comes from the SDK's `readCollectionPolicy` — one owner of that getter, shared with
1136
+ // `abx state` and with any other integrator, rather than a second local read of the same fact.
1137
+ const [{ maxRoyaltyBps: capNow }, royaltyNow] = await Promise.all([
1138
+ readCollectionPolicy(makePublicClient({ chainKey: CHAIN }), contract),
1139
+ read(contract, 'royaltyInfo', [0n, 10000n]).then((r) => Number(r[1])).catch(() => null),
1140
+ ]);
1141
+ if (capNow !== null && cap >= capNow) {
1142
+ throw new Error(`${contract}'s royalty cap is already ${capNow / 100}% and a cap can only DECREASE — ${cap / 100}% would ` +
1143
+ `${cap === capNow ? 'leave it unchanged' : 'raise it'}, which the chain refuses (RoyaltyCapNotReduced). ` +
1144
+ `Pass a value below ${capNow / 100}% (a royalty cap is one-way by design: it can be tightened, never reopened).`);
1145
+ }
1146
+ if (royaltyNow !== null && cap < royaltyNow) {
1147
+ throw new Error(`the current royalty rate is ${royaltyNow / 100}%, so a cap of ${cap / 100}% would sit BELOW it — the chain refuses ` +
1148
+ `that (RoyaltyCapBelowRoyalty: a cap can never drop under the live royalty). Lower the royalty first ` +
1149
+ `(\`abx set-royalty ${contract} --bps <=${cap}\`), or set a cap at or above ${royaltyNow / 100}%.`);
1150
+ }
1151
+ const owner = await read(contract, 'owner');
1152
+ console.log(dim(` royalty cap → ${(cap / 100).toFixed(2)}% (reduce-only)`));
1153
+ await runWrite(contract, prepareReduceMaxRoyaltyBps({ contract, newMaxBps: cap, chainId: chainId() }), flags, owner);
1154
+ }
1155
+ // ── ERC-721C (creator token) — the transfer-validator surface ─────────────────
1156
+ // Enrollment is a DEPLOY-TIME decision (`--721c` on the deploy commands) and permanent in both
1157
+ // directions: an unenrolled token can never gain a validator, an enrolled one never sheds the
1158
+ // standard. Within an enrolled token the owner re-points or suspends (zero) the validator freely.
1159
+ /** The chain keys the manifest recommends a transfer validator for — for refusal messages. */
1160
+ function chainsWithRecommendedValidator() {
1161
+ const ids = new Set(Object.keys(RECOMMENDED_TRANSFER_VALIDATOR).map(Number));
1162
+ return KNOWN_CHAIN_KEYS.filter((k) => ids.has(resolveChain(k).id)).join(', ');
1163
+ }
1164
+ /**
1165
+ * Parse a transfer-validator choice — the shared grammar of the deploy flag (`--721c`) and the
1166
+ * owner op (`abx set-transfer-validator`):
1167
+ * - `recommended` (or a bare `--721c`) → the per-chain recommended validator, refusing on a
1168
+ * chain the manifest has no entry for (naming the chains that do — never guess one);
1169
+ * - a `0x…` address → checksum-validated (EIP-55) and returned as its canonical form;
1170
+ * - `none`/zero → `allowNone` decides: the owner op suspends with it; the DEPLOY flag refuses
1171
+ * it (a zero validator at deploy never enrolls — that is already the default, so someone
1172
+ * passing it either wants plain ERC-721 (drop the flag) or mistakenly believes "enrolled but
1173
+ * suspended" is a deploy-time state — it isn't).
1174
+ * Pure (no RPC) — callers do the has-code precheck themselves. Exported for the regression test.
1175
+ */
1176
+ export function parseTransferValidatorValue(raw, opts) {
1177
+ const s = raw.trim().toLowerCase();
1178
+ if (s === 'true' || s === '' || s === 'recommended') {
1179
+ const rec = resolveRecommendedTransferValidator(opts.chainId);
1180
+ if (!rec) {
1181
+ throw new Error(`no recommended transfer validator is known for '${opts.chainLabel}' (chainId ${opts.chainId}) — ` +
1182
+ `chains with one: ${chainsWithRecommendedValidator() || '(none shipped)'}. ` +
1183
+ `Pass an explicit validator address instead (it must be a deployed contract on this chain).`);
1184
+ }
1185
+ return rec;
1186
+ }
1187
+ if (s === 'none' || s === 'zero' || s === '0' || s === '0x0' || s === zeroAddress) {
1188
+ if (opts.allowNone)
1189
+ return zeroAddress;
1190
+ throw new Error(`a zero transfer validator never enrolls — a plain (unenrolled) token is already the default, so drop --721c. ` +
1191
+ `("enrolled but suspended" is not a deploy-time state: enroll with a real validator, then suspend ` +
1192
+ `with \`abx set-transfer-validator <address> none\`.)`);
1193
+ }
1194
+ const trimmed = raw.trim();
1195
+ if (!/^0x[0-9a-fA-F]{40}$/.test(trimmed)) {
1196
+ throw new Error(`transfer validator must be 'recommended'${opts.allowNone ? ", 'none'," : ''} or a 0x address (0x + 40 hex); got '${raw}'`);
1197
+ }
1198
+ // `isAddress` strict-validates the EIP-55 checksum of a mixed-case address (all-lowercase carries
1199
+ // no checksum and passes); `getAddress` alone only NORMALIZES — it would silently accept a
1200
+ // mis-cased paste, which is exactly the transposition this check exists to catch.
1201
+ if (!isAddress(trimmed, { strict: true })) {
1202
+ throw new Error(`transfer validator address failed its EIP-55 checksum: '${raw}' — paste it exactly (or all-lowercase).`);
1203
+ }
1204
+ return getAddress(trimmed);
1205
+ }
1206
+ /**
1207
+ * `abx set-transfer-validator <address> <0x…|none|recommended>` — re-point an ENROLLED (ERC-721C)
1208
+ * collection's transfer validator, or suspend enforcement with `none` (address(0); the token
1209
+ * STAYS enrolled). Refuses up front, before any signing: a plain ERC-721 (enrollment is
1210
+ * deploy-time-only — the contract would revert `NotCreatorToken()`), and a codeless validator
1211
+ * (would revert `InvalidTransferValidator()`). Owner-only, any lane, guards `--dry-run`.
1212
+ */
1213
+ export async function cmdSetTransferValidator(address, rest, flags) {
1214
+ const usage = 'abx set-transfer-validator <address> <0x…|none|recommended> [--sign|--unsigned] [--dry-run]';
1215
+ const contract = requireAddress(address, usage);
1216
+ const [raw] = positionalArgs(rest);
1217
+ if (!raw) {
1218
+ console.error(`usage: ${usage}\n`);
1219
+ process.exitCode = 1;
1220
+ return;
1221
+ }
1222
+ const cid = chainId();
1223
+ const validator = parseTransferValidatorValue(raw, { chainId: cid, chainLabel: CHAIN, allowNone: true });
1224
+ const publicClient = makePublicClient({ chainKey: CHAIN });
1225
+ // Enrollment guard FIRST — a read, not a send. An unenrolled token would revert
1226
+ // `NotCreatorToken()`; refuse with the real story instead of letting the chain say it in hex.
1227
+ // (readCreatorTokenStatus is defensive, so check the contract exists first — a typo'd address
1228
+ // must not read as "plain ERC-721".)
1229
+ await assertContractExists(contract);
1230
+ const status = await readCreatorTokenStatus(publicClient, contract);
1231
+ if (!status.enrolled) {
1232
+ throw new Error(`${contract} did not enroll as a creator token — 721C/1155C enrollment is a deploy-time decision. ` +
1233
+ `Enrollment can never be added to a live collection; if enforcement is required, redeploy with ` +
1234
+ `--721c recommended (or --721c 0x…) on the deploy command.`);
1235
+ }
1236
+ // Unusable-validator guard: the contract refuses a non-zero validator that fails its own probe —
1237
+ // no code, OR a permissive fallback that would enforce nothing (a Safe, an uninitialised proxy, a
1238
+ // 7702-delegated EOA). Both revert `InvalidTransferValidator()`, so the preflight has to ask the
1239
+ // same question the chain asks; a bare has-code check passed a creator's Safe and then let the
1240
+ // send fail on chain with an error whose documented meaning ("no code") was false for their case.
1241
+ if (validator !== zeroAddress) {
1242
+ const probe = await probeTransferValidator(publicClient, validator, { as: contract });
1243
+ if (probe.verdict === 'unreachable') {
1244
+ 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.`);
1245
+ }
1246
+ if (probe.verdict !== 'ok') {
1247
+ const rec = resolveRecommendedTransferValidator(cid);
1248
+ const recHint = rec ? ` (the recommended one: \`abx set-transfer-validator ${contract} recommended\` → ${rec})` : '';
1249
+ throw new Error(probe.verdict === 'no-code'
1250
+ ? `no contract code at ${validator} on ${CHAIN} — the token would revert InvalidTransferValidator(). ` +
1251
+ `A transfer validator must be a DEPLOYED contract on this chain${recHint}.`
1252
+ : `${validator} has code on ${CHAIN}, but it is not a transfer validator — it answers ANY function call successfully ` +
1253
+ `(a Safe, an uninitialised proxy, or a 7702-delegated EOA does this), so every transfer would silently pass ` +
1254
+ `validation while ERC-165 and getTransferValidator() reported enforcement as ON. The token refuses it too ` +
1255
+ `(InvalidTransferValidator()). Pass a real validator contract${recHint}, or \`none\` to suspend enforcement.`);
1256
+ }
1257
+ }
1258
+ if (eqAddr(status.validator, validator)) {
1259
+ console.log(dim(` no change — the validator is already ${validator === zeroAddress ? 'suspended (0x0)' : validator}. Nothing sent.`));
1260
+ return;
1261
+ }
1262
+ const label = (a) => (eqAddr(a, zeroAddress) ? 'suspended (0x0)' : a);
1263
+ console.log(dim(` transfer validator: ${label(status.validator)} → ${label(validator)}${validator === zeroAddress ? ' — transfers go unvalidated until one is set again (the token stays enrolled)' : ''}`));
1264
+ const owner = await read(contract, 'owner');
1265
+ await runWrite(contract, prepareSetTransferValidator({ contract, validator, chainId: cid }), flags, owner);
1266
+ }
1267
+ // ── the seed source — where a code project's mint randomness comes from ───────
1268
+ // The canonical `AbxSeedSource` is deliberately pseudorandom: replayable after the mint (what makes
1269
+ // generative output verifiable) and computable DURING it (so a buyer can decline an outcome for the
1270
+ // price of gas — and on an EDITION, where the buyer names the id and the id is in the preimage, they
1271
+ // can shop the unminted ids rather than merely decline; bounded by how many remain)
1272
+ // price of gas). We tell creators that the escape hatch for anything lottery-like is "point
1273
+ // `seedSource` at your own IAbxSeedSource over commit-reveal or a VRF oracle" — this is the surface
1274
+ // that makes that promise real instead of a doc claim: `--seed-source` at deploy, `abx
1275
+ // set-seed-source` after, and one probe standing in front of both.
1276
+ /** The canonical `AbxSeedSource` for a chain — the manifest entry (the source of truth) with the
1277
+ * CREATE2 prediction as the cross-chain fallback (the singleton is address-identical everywhere).
1278
+ * Deliberately does NOT go through `resolveSeedSource`, which reads `ABX_SEED_SOURCE`: that env var
1279
+ * names the source you have CONFIGURED, which is exactly the thing `canonical` exists to name the
1280
+ * alternative to. Asking for `canonical` and silently getting your custom override would be the
1281
+ * worst possible answer. */
1282
+ export function canonicalSeedSource(chainId) {
1283
+ return getDeployment(chainId).seedSource ?? predictSeedSource();
1284
+ }
1285
+ /**
1286
+ * Parse a seed-source choice — the shared grammar of the deploy flag (`--seed-source`) and the owner
1287
+ * op (`abx set-seed-source`):
1288
+ * - `canonical` (or a bare `--seed-source`) → the chain's canonical `AbxSeedSource`, i.e. the
1289
+ * default; spelled out so a creator can state it rather than rely on absence meaning it;
1290
+ * - a `0x…` address → checksum-validated (EIP-55) and returned canonicalized. The caller MUST
1291
+ * probe it (see {@link refuseUnusableSeedSource}) — a bad address here is silent until the
1292
+ * first buyer, and then every mint reverts;
1293
+ * - `none`/zero → `allowNone` decides: the owner op clears with it (future mints draw no seed);
1294
+ * the DEPLOY flag refuses it and points at `--no-seed`, which already means exactly that. Two
1295
+ * spellings of one thing is how a creator ends up unsure which they used.
1296
+ * Pure (no RPC) — mirrors `parseTransferValidatorValue`, deliberately, so the two knobs read the
1297
+ * same. Exported for the regression test.
1298
+ */
1299
+ export function parseSeedSourceValue(raw, opts) {
1300
+ const s = raw.trim().toLowerCase();
1301
+ if (s === 'true' || s === '' || s === 'canonical' || s === 'default')
1302
+ return canonicalSeedSource(opts.chainId);
1303
+ if (s === 'none' || s === 'zero' || s === '0' || s === '0x0' || s === zeroAddress) {
1304
+ if (opts.allowNone)
1305
+ return zeroAddress;
1306
+ throw new Error(`--seed-source none is refused: "no mint-time seed" already has a flag — pass \`--no-seed\` instead. ` +
1307
+ `(One meaning, one spelling: --seed-source names WHERE the seed comes from, --no-seed says there isn't one.)`);
1308
+ }
1309
+ const trimmed = raw.trim();
1310
+ if (!/^0x[0-9a-fA-F]{40}$/.test(trimmed)) {
1311
+ throw new Error(`seed source must be 'canonical'${opts.allowNone ? ", 'none'," : ''} or a 0x address (0x + 40 hex); got '${raw}'`);
1312
+ }
1313
+ // Strict EIP-55 (see the identical note on parseTransferValidatorValue): `getAddress` alone only
1314
+ // normalizes, so a mis-cased paste would sail through — and a seed source pointed one nibble off
1315
+ // is a collection whose every mint reverts.
1316
+ if (!isAddress(trimmed, { strict: true })) {
1317
+ throw new Error(`seed source address failed its EIP-55 checksum: '${raw}' — paste it exactly (or all-lowercase).`);
1318
+ }
1319
+ return getAddress(trimmed);
1320
+ }
1321
+ /**
1322
+ * Turn a non-`ok` {@link SeedSourceProbe} into the refusal a creator can act on. One place, because
1323
+ * the deploy flag and the owner op hit the identical failure shapes and must say the identical
1324
+ * thing; `context` only names which surface asked (so the fix names the right flag).
1325
+ *
1326
+ * Every branch is a REFUSAL, not a warning. A seed source is the one setting where "looks fine, is
1327
+ * broken" is the normal outcome of a mistake: `seedSource()` reads back exactly what you set, the
1328
+ * `SeedSourceSet` event fires, `abx state` shows it — and then the first mint reverts in the ABI
1329
+ * decode of a return that isn't 32 bytes. There is no later moment at which this gets easier to
1330
+ * notice, so it is caught here or it is caught by a buyer.
1331
+ */
1332
+ export function refuseUnusableSeedSource(probe, context) {
1333
+ const { flag, chainLabel } = context;
1334
+ const head = `${flag}: ${probe.address} is not a usable seed source on ${chainLabel}`;
1335
+ const tail = `\n A seed source must answer \`seed(uint256 tokenId, address to)\` with 32 bytes — the token calls it ` +
1336
+ `SYNCHRONOUSLY inside every mint and decodes the result as \`bytes32\`, so anything else reverts the mint.`;
1337
+ switch (probe.verdict) {
1338
+ case 'no-code':
1339
+ throw new Error(`${head} — there is no contract code there.${tail}\n` +
1340
+ ` (A Solidity call to a codeless address succeeds with empty returndata, so the revert would land in the ` +
1341
+ `decode, at mint time, for every buyer. Check the address and the chain — ${flag} is chain-specific.)`);
1342
+ case 'empty-return':
1343
+ throw new Error(`${head} — it has code, answered, and returned NOTHING.${tail}\n` +
1344
+ ` This is the permissive-fallback shape: a Safe (its fallback returns empty for an unset handler), an ` +
1345
+ `uninitialised proxy, or an EIP-7702-delegated EOA. Pasting your own wallet/Safe here is the common way in. ` +
1346
+ `A seed source is a purpose-built contract implementing IAbxSeedSource — commit-reveal or a VRF oracle.`);
1347
+ case 'short-return':
1348
+ throw new Error(`${head} — it answered with only ${probe.returnedBytes} byte(s), not 32.${tail}\n` +
1349
+ ` Whatever is deployed there has a different ABI; it is not an IAbxSeedSource.`);
1350
+ case 'reverted':
1351
+ throw new Error(`${head} — the call reverted (${probe.error ?? 'no reason returned'}).${tail}\n` +
1352
+ ` Two ways in: (1) it isn't a seed source at all — a token, a renderer, a registry has no such function; ` +
1353
+ `(2) it IS one, but it refuses right now (gating callers, or not yet armed/committed). Both are refusals here: ` +
1354
+ `a source that cannot answer today cannot answer at mint either, and its revert would bubble through the mint. ` +
1355
+ `Arm the source first, then point at it.`);
1356
+ default:
1357
+ // `unreachable` and any verdict a future SDK adds: refuse rather than proceed. A `default` (not
1358
+ // a `case 'unreachable'`) so a new verdict cannot silently become "allowed" by falling through.
1359
+ throw new Error(`${head} — couldn't verify it (${probe.verdict}${probe.error ? `: ${probe.error}` : ''}). ` +
1360
+ `Refusing to configure a seed source blind; retry when the RPC answers.`);
1361
+ }
1362
+ }
1363
+ /**
1364
+ * `abx set-seed-source <address> <0x…|canonical|none>` — re-point (or clear) where a code project
1365
+ * draws its mint seeds. Owner-only; **future mints only**, because a seed settles the moment it is
1366
+ * assigned and nothing rewrites it. Refuses before any signing: a target with no Seed Source
1367
+ * extension (a 1/1 or an image Series — there is nothing to set), and any candidate the probe
1368
+ * rejects.
1369
+ *
1370
+ * This op is deliberately loud about one thing the chain cannot enforce: re-pointing mid-sale means
1371
+ * tokens minted before and after draw from **different** sources. That is legitimate (it is how you
1372
+ * fix a broken source, or hand over to a commit-reveal one), and it is public — `SeedSourceSet` is
1373
+ * on the event spine and `abx state` prints the current source — but a collector who bought early
1374
+ * has no way to know it will happen. So the print names the split when supply already exists.
1375
+ */
1376
+ export async function cmdSetSeedSource(address, rest, flags) {
1377
+ const usage = 'abx set-seed-source <address> <0x…|canonical|none> [--sign|--unsigned] [--dry-run]';
1378
+ const contract = requireAddress(address, usage);
1379
+ const [raw] = positionalArgs(rest);
1380
+ if (!raw) {
1381
+ console.error(`usage: ${usage}\n`);
1382
+ process.exitCode = 1;
1383
+ return;
1384
+ }
1385
+ const cid = chainId();
1386
+ const source = parseSeedSourceValue(raw, { chainId: cid, allowNone: true });
1387
+ const publicClient = makePublicClient({ chainKey: CHAIN });
1388
+ await assertContractExists(contract);
1389
+ // Extension guard FIRST — a read, not a send. `seedSource()` is absent on a 1/1 / image Series, so
1390
+ // `setSeedSource` there is a call to a selector the contract doesn't have: it would revert with no
1391
+ // reason bytes at all. Say what the target actually is instead.
1392
+ const current = await readSeedSource(publicClient, contract);
1393
+ if (current === undefined) {
1394
+ throw new Error(`${contract} has no seed source to set — the Seed Source extension is composed only by CODE projects ` +
1395
+ `(SeriesCode / EditionCode, i.e. \`abx deploy-code\`). An image 1/1 or Series has no mint-time seed at all, ` +
1396
+ `and the extension can't be added to a live contract.`);
1397
+ }
1398
+ if (eqAddr(current, source)) {
1399
+ console.log(dim(` no change — the seed source is already ${source === zeroAddress ? 'cleared (0x0 — no mint-time seed)' : source}. Nothing sent.`));
1400
+ return;
1401
+ }
1402
+ if (source !== zeroAddress) {
1403
+ const probe = await probeSeedSource(publicClient, source, { as: contract });
1404
+ if (probe.verdict !== 'ok')
1405
+ refuseUnusableSeedSource(probe, { flag: 'set-seed-source', chainLabel: CHAIN });
1406
+ }
1407
+ const label = (a) => (eqAddr(a, zeroAddress) ? 'none (0x0 — no mint-time seed)' : eqAddr(a, canonicalSeedSource(cid)) ? `${a} (canonical AbxSeedSource)` : a);
1408
+ console.log(dim(` seed source: ${label(current)} → ${label(source)}`));
1409
+ // Seeds already assigned are settled — say so, and say what the split means. `totalSupply()` is a
1410
+ // 721 getter (an edition's is per-id), so the count is best-effort; the note itself is not, because
1411
+ // "future mints only" is the part a creator has to understand before sending.
1412
+ const minted = await readSeries(contract, 'totalSupply').catch(() => null);
1413
+ if (minted === null || minted > 0n) {
1414
+ console.log(dim(` ${minted === null ? 'Tokens' : `${minted} token(s)`} already minted carry a seed drawn from ${label(current)} — settled, and unchanged by this. ` +
1415
+ `This applies to FUTURE mints only, so a part-sold collection ends up spanning two sources. The change is public ` +
1416
+ `(SeedSourceSet on the event spine; \`abx state ${contract}\` prints the current one), but a collector who ` +
1417
+ `already bought won't be told — if the drop is live, consider pausing and saying so.`));
1418
+ }
1419
+ if (source === zeroAddress) {
1420
+ console.log(dim(' clearing means future mints draw NO seed at all — a generative program that expects one will render blank/identical.'));
1421
+ }
1422
+ else if (!eqAddr(source, canonicalSeedSource(cid))) {
1423
+ console.log(dim(" custom source — its randomness properties are now yours to state to buyers; ABX makes no claim about them."));
1424
+ }
1425
+ const owner = await read(contract, 'owner');
1426
+ await runWrite(contract, prepareSetSeedSource({ contract, seedSource: source, chainId: cid }), flags, owner);
1427
+ }
1428
+ // ── multi-chunk on-chain content (the `reader` path) ─────────────────────────
1429
+ // The plan math (chunk count + tx shape), the cost-model constants, and the actual
1430
+ // plan→resolve-store→stage→encode orchestration all live in the SDK's staging.ts, layered on
1431
+ // planChunks/planContentTxs/stageContent/encodeReader/ensureChunkStore — see its module doc. What
1432
+ // stays here is Node-only (reading the file, gzip) plus the narration: every console.log below is
1433
+ // this file's onEvent handler for the SDK's `StagingEvent`, so the lines are unchanged from before
1434
+ // the move.
651
1435
  export function parseCompress(v) {
652
1436
  const c = (v ?? 'none').toLowerCase();
653
1437
  if (c === 'none' || c === 'fastlz' || c === 'gzip')
654
1438
  return c;
655
1439
  throw new Error(`--compress must be none | fastlz | gzip (got '${v}')`);
656
1440
  }
1441
+ /** The chunk-store bootstrap narration shared by every content-staging path below — one place so
1442
+ * the lines are identical whether the store is resolved for a single field ({@link putContentOnChain}
1443
+ * → the SDK's `stageFieldContent`) or once up front for a whole batch ({@link ensureChunkStore}). */
1444
+ function printChunkStoreEvent(e) {
1445
+ if (e.kind === 'stale')
1446
+ console.log(yellow(` configured chunk store ${e.address} is a stale deployment (no writeContent) — deploying a current one`));
1447
+ else if (e.kind === 'canonical')
1448
+ console.log(dim(` using the canonical chunk store at its deterministic address ${e.address}`));
1449
+ else if (e.kind === 'deploying')
1450
+ console.log(dim(' deploying the canonical multi-chunk content store (AbxChunkStore) — CREATE2…'));
1451
+ else {
1452
+ console.log(` ${green('✓')} chunk store ${e.address}`);
1453
+ console.log(dim(` not in the shipped manifest for ${CHAIN} — to reuse it set ABX_CHUNK_STORE=${e.address} (or add it to packages/sdk/src/deployments.ts)`));
1454
+ }
1455
+ }
657
1456
  /**
658
1457
  * The chain's shared multi-chunk store/reader; deploy it once if missing, signing through
659
1458
  * `send` (the env key on the hot lane, or the connected wallet on the wallet lane) — the
660
- * store is ownerless, so any funded signer can stand it up.
1459
+ * store is ownerless, so any funded signer can stand it up. Used directly only by
1460
+ * {@link stageImageFieldsBatch} (resolving ONE store up front for every token); a single-field
1461
+ * stage ({@link putContentOnChain}) resolves its store through the SDK's `stageFieldContent`
1462
+ * instead, which calls the very same SDK `ensureChunkStore` internally.
661
1463
  */
662
1464
  async function ensureChunkStore(send, override) {
663
- // Resolve from the shipped manifest (flag ABX_CHUNK_STORE → manifest); on a chain with no
664
- // entry (or a stale deployment), deploy a current one and tell the operator how to reuse it.
665
- const known = chunkStoreAddress(override);
1465
+ // The resolution logic lives in the SDK (`ensureChunkStore`) so an SDK integrator bootstraps
1466
+ // identically instead of hand-rolling the "is this store capable?" guard forget it and an
1467
+ // incapable store fails deep inside a mint, after transactions have landed. The CLI keeps only the
1468
+ // narration: the SDK reports progress through `onEvent` rather than printing.
666
1469
  const publicClient = makePublicClient({ chainKey: CHAIN });
667
- if (known) {
668
- const code = await publicClient.getCode({ address: known });
669
- if (code && code !== '0x') {
670
- // Has code, but is it the *current* store? A pre-`writeContent` deployment passes a
671
- // bare code check yet reverts the staging call — verify the ABI, don't just assume.
672
- if (await storeSupportsWriteContent(publicClient, known))
673
- return known;
674
- console.log(yellow(` configured chunk store ${known} is a stale deployment (no writeContent) — deploying a current one`));
675
- }
676
- }
677
- // No usable listed store. The canonical store is CREATE2-deterministic, so it may already exist at
678
- // its predicted address — deployed by the forge script (or a prior lazy deploy) with the manifest
679
- // not yet updated. Check there before deploying: self-healing, and never a duplicate at a random
680
- // CREATE address. `deployChunkStore` (below) also lands at exactly this address.
681
- const predicted = predictChunkStore();
682
- if (!known || known.toLowerCase() !== predicted.toLowerCase()) {
683
- const pcode = await publicClient.getCode({ address: predicted });
684
- if (pcode && pcode !== '0x' && (await storeSupportsWriteContent(publicClient, predicted))) {
685
- console.log(dim(` using the canonical chunk store at its deterministic address ${predicted}`));
686
- return predicted;
687
- }
688
- }
689
- console.log(dim(' deploying the canonical multi-chunk content store (AbxChunkStore) — CREATE2…'));
690
- const { chunkStore } = await deployChunkStore(send);
691
- console.log(` ${green('✓')} chunk store ${chunkStore}`);
692
- console.log(dim(` not in the shipped manifest for ${CHAIN} — to reuse it set ABX_CHUNK_STORE=${chunkStore} (or add it to packages/sdk/src/deployments.ts)`));
693
- return chunkStore;
1470
+ return sdkEnsureChunkStore(publicClient, send, { chainId: chainId(), override, onEvent: printChunkStoreEvent });
694
1471
  }
695
1472
  /**
696
- * The hot-lane staging signer: the env key signs + broadcasts each chunk-store write. Built
697
- * lazily so commands that never stage on-chain don't require a key. The wallet lane passes a
698
- * session-backed sender instead (see {@link openWalletSession}); the cold lane can't stage
699
- * interactively, so it's rejected up front by the deploy/set-field commands.
1473
+ * The hot-lane staging signer: the env key signs + broadcasts each chunk-store write, via the
1474
+ * SDK's `makeHotSender` (pinned nonce, pinned gas, typed revert). Built lazily so commands that
1475
+ * never stage on-chain don't require a key. The wallet lane passes a session-backed sender
1476
+ * instead (see {@link openWalletSession}); the cold lane can't stage interactively, so it's
1477
+ * rejected up front by the deploy/set-field commands.
700
1478
  */
701
1479
  export function envStagingSender() {
702
1480
  const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
703
1481
  const publicClient = makePublicClient({ chainKey: CHAIN });
704
- return async (tx) => {
705
- const hash = await wallet.sendTransaction({
706
- account,
707
- chain: wallet.chain,
708
- to: tx.to ?? undefined,
709
- data: tx.data,
710
- value: 0n,
711
- });
712
- return publicClient.waitForTransactionReceipt({ hash });
713
- };
1482
+ return makeHotSender({ wallet, account, publicClient });
714
1483
  }
715
1484
  /** Wallet-lane staging signer: each chunk-store write is approved in the human's browser
716
- * wallet through the open {@link WalletSession}, sharing one connection with the final tx. */
1485
+ * wallet through the open {@link WalletSession}, sharing one connection with the final tx. A
1486
+ * {@link PreparedTx} already carries everything `session.send` needs, so this is a thin adapter. */
717
1487
  export function sessionStagingSender(session) {
718
- const id = chainId();
719
- return async (tx) => (await session.send({
720
- op: 'stage',
721
- to: tx.to,
722
- data: tx.data,
723
- value: '0x0',
724
- chainId: id,
725
- summary: tx.summary,
726
- fields: tx.fields,
727
- })).receipt;
1488
+ return async (tx) => (await session.send(tx)).receipt;
728
1489
  }
1490
+ const kb = (n) => `${n < 10 * 1024 ? (n / 1024).toFixed(1) : Math.round(n / 1024)}KB`;
1491
+ const mgas = (n) => `~${(n / 1_000_000).toFixed(0)}M gas`;
729
1492
  /**
730
- * Put `content` on-chain as SSTORE2 chunks behind the shared reader, and return the field's
731
- * `(representation, value)`. `fastlz` compresses per chunk (reader decodes on read stays
732
- * on-chain renderable); `gzip` compresses the whole content (off-chain decode the
733
- * `reader-gzip` representation, not on-chain renderable).
1493
+ * The measured `eth_call` gas allowance of the best configured endpoint, cached for the process.
1494
+ * `null` = could not be measured (an endpoint that rejects state overrides, or is unreachable),
1495
+ * which is a normal outcome and never blocks anything the report degrades to the conservative
1496
+ * {@link ETH_CALL_GAS_FLOOR} instead.
734
1497
  *
735
- * Staging is one atomic `writeContent` when the content fits a single tx's gas, else
736
- * gas-bounded chunk-write `multicall`s plus a final manifest write — {@link stageContent}
737
- * picks, and we print the tx plan up front so the human knows the count before signing.
738
- * These store writes use the env key (the data contracts are ownerless); the owner only
739
- * signs the field set that references the manifest.
1498
+ * Measured once per run because it is a property of the endpoint, not of the content: a 40-file
1499
+ * Series should not fire 40 probes.
740
1500
  */
1501
+ let cachedGasCap;
1502
+ /** Measure (once) what the configured RPCs will actually serve an `eth_call`. Never throws. */
1503
+ export async function ensureRpcGasCap() {
1504
+ if (cachedGasCap === undefined) {
1505
+ try {
1506
+ cachedGasCap = await probeBestEthCallGasCap();
1507
+ }
1508
+ catch {
1509
+ cachedGasCap = { gasCap: null, label: null };
1510
+ }
1511
+ }
1512
+ return cachedGasCap;
1513
+ }
1514
+ /** Test seam: force the cached cap (or clear it with `undefined`). */
1515
+ export function __setRpcGasCapForTest(v) {
1516
+ cachedGasCap = v;
1517
+ }
741
1518
  /**
742
- * Pure staging plan for a piece of content chunk count + transaction shapewith NO
743
- * chain writes, no signer, no `ensureChunkStore`. Shared by {@link putContentOnChain}
744
- * (which then actually stages) and the deploy dry-run preview, so the count the human is
745
- * told up front is the same one the real staging will produce. `chunkSize` defaults to the
746
- * SDK's {@link planChunks} default (currently 22 KB), so e.g. a 2.8 KB SVG is 1 chunk.
1519
+ * Report what putting `bytesLen` of content on-chain costs, on both axes that matter and REFUSE
1520
+ * NOTHING at any size.
1521
+ *
1522
+ * Readability depends on the configured RPC's `eth_call` cap, so the toolkit reports the measured
1523
+ * allowance instead of refusing content at a fixed byte threshold.
1524
+ *
1525
+ * Two axes, and they fail differently:
1526
+ * • WRITE is chunked (22,000-byte SSTORE2 chunks across separate transactions), so no block gas
1527
+ * limit ever binds it. It is purely a money question at ~200 gas/byte.
1528
+ * • READ is one `eth_call`, and whether it succeeds is a property of WHOSE endpoint is asking.
1529
+ * Ours is measurable; a marketplace's is not, and that distinction survives into the output.
1530
+ *
1531
+ * Returns true if it printed anything.
747
1532
  */
748
- // On-chain image storage costs ~200 gas/byte (SSTORE2 code deposit) + per-chunk tx overhead, so it
749
- // is cost-effective ONLY for tiny art. Past these soft limits, off-chain (Arweave pay-once, or a
750
- // hosted resolver + IPFS) is FAR cheaper — on-chain's remaining edge is self-resolution, not cost.
751
- // Per-file limit ≈ one 22 KB storage chunk; project limit caps the whole collection's gas.
752
- export const ONCHAIN_IMAGE_SOFT_LIMIT = 24 * 1024; // per file
753
- export const ONCHAIN_PROJECT_SOFT_LIMIT = 256 * 1024; // whole collection
754
- const kb = (n) => `${n < 10 * 1024 ? (n / 1024).toFixed(1) : Math.round(n / 1024)}KB`;
755
- /** Warn (loudly) when content is too big for on-chain to be the cost-sane choice. Returns true if it warned. */
756
- export function warnIfLargeForOnChain(bytesLen, label) {
757
- if (bytesLen <= ONCHAIN_IMAGE_SOFT_LIMIT)
758
- return false;
759
- 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.`));
760
- 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.`));
761
- return true;
1533
+ export async function guardOnChainSize(bytesLen, label) {
1534
+ const { gasCap, label: rpcLabel } = await ensureRpcGasCap();
1535
+ const verdict = classifyOnchainReadSize(bytesLen, gasCap);
1536
+ const readGas = tokenUriGasEstimate(bytesLen);
1537
+ const writeGas = bytesLen * 200;
1538
+ const floorKb = kb(readableBytesAtGas(ETH_CALL_GAS_FLOOR));
1539
+ const uncapped = gasCap === Number.POSITIVE_INFINITY;
1540
+ const capText = uncapped ? 'no eth_call cap at all' : `${mgas(gasCap ?? 0)}`;
1541
+ const servesText = uncapped ? 'any size this toolkit can write' : `~${kb(readableBytesAtGas(gasCap ?? 0))}`;
1542
+ // The route that dissolves the reach problem entirely, and the reason none of this is a refusal:
1543
+ // a resolver reads the on-chain bytes with ITS rpc and serves them as ordinary HTTP, so the
1544
+ // marketplace never makes the big eth_call. The bytes stay on-chain and permanent either way.
1545
+ const resolverRoute = ' Reach is fixable after the fact and the bytes are permanent regardless: a resolver reads on-chain content and serves it over plain HTTP' +
1546
+ ' (abx deploy-resolver, or a hosted one), so marketplaces fetch a URL instead of making this call — point tokenURI at it with `abx set-renderer <addr> --off`.' +
1547
+ ' On-chain storage is preservation; serving is a separate, swappable choice.';
1548
+ if (verdict === 'beyond-local-rpc') {
1549
+ console.log(yellow(` ⚠ ${label} is ${kb(bytesLen)} on-chain — reading tokenURI in ONE call costs ${mgas(readGas)}, past what your own RPC serves (${capText}${rpcLabel ? `, ${rpcLabel}` : ''}).`));
1550
+ console.log(dim(` Writing is unaffected — ${mgas(writeGas)}, chunked, and the bytes are permanent the moment they land.${resolverRoute}`));
1551
+ return true;
1552
+ }
1553
+ if (verdict === 'endpoint-dependent') {
1554
+ console.log(yellow(` ⚠ ${label} is ${kb(bytesLen)} on-chain — reading tokenURI in ONE call costs ${mgas(readGas)}, past the ${mgas(ETH_CALL_GAS_FLOOR)} floor every endpoint serves.`));
1555
+ console.log(dim(` ${gasCap != null ? `Measured just now, your RPC allows ${capText}${rpcLabel ? ` (${rpcLabel})` : ''} — it serves ${servesText}.` : `Your RPC's cap could not be measured, so the floor below is the reference.`} We can measure yours; we CANNOT know a marketplace's or an indexer's, and those are the ones that decide whether your token displays. On a 50M-capped provider the ceiling is ~${floorKb}, and past it they see a revert. Writing is unaffected: ${mgas(writeGas)}, chunked.${resolverRoute}`));
1556
+ return true;
1557
+ }
1558
+ if (bytesLen >= ONCHAIN_READ_WARN_BYTES) {
1559
+ console.log(dim(` ${label} is ${kb(bytesLen)} on-chain — ${mgas(writeGas)} to write (chunked), ${mgas(readGas)} to read. Under the ${mgas(ETH_CALL_GAS_FLOOR)} every endpoint serves, so it renders anywhere.`));
1560
+ return true;
1561
+ }
1562
+ if (exceedsOnchainSoftLimit(bytesLen)) {
1563
+ 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: ${mgas(readGas)}.`));
1564
+ return true;
1565
+ }
1566
+ return false;
762
1567
  }
1568
+ /**
1569
+ * Pure staging plan for a piece of content — chunk count + transaction shape — with NO chain
1570
+ * writes, no signer, no `ensureChunkStore`. The CLI-facing wrapper over the SDK's pure
1571
+ * {@link planStagedContent}: `gzip` is a Node-only transform, so it happens here (not in the SDK —
1572
+ * see staging.ts's module doc) before handing the result off to the SDK's chunk/tx-shape math.
1573
+ * Shared by {@link putContentOnChain} (which then actually stages) and the deploy dry-run preview,
1574
+ * so the count the human is told up front is the same one the real staging will produce.
1575
+ */
763
1576
  export function computeContentPlan(bytes, compress) {
764
1577
  const content = compress === 'gzip' ? new Uint8Array(gzipSync(bytes)) : new Uint8Array(bytes);
765
- const fastlz = compress === 'fastlz';
766
- const planned = planChunks(content, { fastlz });
767
- const plan = planContentTxs(planned);
768
- const representation = compress === 'gzip' ? R.readerGzip : R.reader;
769
- const stagedBytes = planned.reduce((n, c) => n + c.data.length, 0);
770
- return { content, fastlz, chunks: planned.length, stagedBytes, plan, representation };
1578
+ return planStagedContent(content, compress);
771
1579
  }
772
1580
  /** Human-readable size line for a content plan, e.g. `2812B → 1 chunk [fastlz 2812→904B]`. */
773
1581
  function planSizeLine(bytes, p, compress) {
@@ -789,15 +1597,50 @@ function planTxLine(p, finalLabel) {
789
1597
  ? `plan: 1 staging tx (atomic writeContent) + ${finalLabel} = 2 transactions`
790
1598
  : `plan: ${p.plan.batches.length} chunk-batch tx(s) + 1 manifest tx + ${finalLabel} = ${p.plan.txCount + 1} transactions`;
791
1599
  }
1600
+ /**
1601
+ * Put `bytes` on-chain as SSTORE2 chunks behind the shared reader, and return the field's
1602
+ * `(representation, value)`. `fastlz` compresses per chunk (reader decodes on read → stays
1603
+ * on-chain renderable); `gzip` compresses the whole content (off-chain decode → the
1604
+ * `reader-gzip` representation, not on-chain renderable) — done here, not in the SDK's
1605
+ * `stageFieldContent` (a Node-only transform; see staging.ts's module doc).
1606
+ *
1607
+ * Staging is one atomic `writeContent` when the content fits a single tx's gas, else
1608
+ * gas-bounded chunk-write `multicall`s plus a final manifest write — the SDK picks, and we
1609
+ * print the tx plan up front (via its `onEvent`) so the human knows the count before signing.
1610
+ * These store writes use the env key (the data contracts are ownerless); the owner only
1611
+ * signs the field set that references the manifest.
1612
+ */
792
1613
  async function putContentOnChain(bytes, compress, field, send, finalLabel = '1 owner field-set', store) {
793
- const p = computeContentPlan(bytes, compress);
794
- warnIfLargeForOnChain(bytes.length, `'${field}'`);
795
- const resolvedStore = store ?? (await ensureChunkStore(send));
796
- console.log(dim(` '${field}' on-chain: ${planSizeLine(bytes.length, p, compress)} via reader ${resolvedStore}`));
797
- console.log(dim(` ${planTxLine(p, finalLabel)}`));
798
- const { manifest, txHashes } = await stageContent(send, { store: resolvedStore, content: p.content, fastlz: p.fastlz });
799
- console.log(dim(` staged in ${txHashes.length} tx(s); manifest → ${manifest}`));
800
- return { value: encodeReader(resolvedStore, manifest), representation: p.representation };
1614
+ await guardOnChainSize(bytes.length, `'${field}'`);
1615
+ const content = compress === 'gzip' ? new Uint8Array(gzipSync(bytes)) : new Uint8Array(bytes);
1616
+ const publicClient = makePublicClient({ chainKey: CHAIN });
1617
+ return stageFieldContent({
1618
+ content,
1619
+ compress,
1620
+ field,
1621
+ send,
1622
+ publicClient,
1623
+ chainId: chainId(),
1624
+ store,
1625
+ onEvent: (e) => {
1626
+ if (e.kind === 'chunk-store')
1627
+ printChunkStoreEvent(e.event);
1628
+ else if (e.kind === 'planned') {
1629
+ console.log(dim(` '${field}' on-chain: ${planSizeLine(bytes.length, e.contentPlan, compress)} via reader ${e.store}`));
1630
+ console.log(dim(` ${planTxLine(e.contentPlan, finalLabel)}`));
1631
+ }
1632
+ else {
1633
+ console.log(dim(` staged in ${e.txHashes.length} tx(s); manifest → ${e.manifest}`));
1634
+ }
1635
+ },
1636
+ });
1637
+ }
1638
+ export function refusePrewrappedImage(bytes, label) {
1639
+ const head = typeof bytes === 'string' ? bytes.slice(0, 40) : new TextDecoder().decode(bytes.subarray(0, 40));
1640
+ if (/^\s*data:/i.test(head)) {
1641
+ throw new Error(`${label} is already a data URI. The on-chain renderer wraps inline image bytes as a data URI ` +
1642
+ 'itself — storing one produces a blank (double-wrapped) token. Pass the raw SVG, starting with <svg or <?xml.');
1643
+ }
801
1644
  }
802
1645
  /**
803
1646
  * Dry-run preview of staging an image fully on-chain — the REAL chunk + transaction plan,
@@ -805,10 +1648,13 @@ async function putContentOnChain(bytes, compress, field, send, finalLabel = '1 o
805
1648
  * true count instead of leaving the agent to guess). The final tx here is the deploy that
806
1649
  * bakes in the reader field, so the count is staging tx(s) + the deploy.
807
1650
  */
808
- export function previewImageStaging(imagePath, compress) {
1651
+ export async function previewImageStaging(imagePath, compress) {
809
1652
  const bytes = readFileSync(resolvePath(imagePath));
1653
+ refusePrewrappedImage(bytes, basename(resolvePath(imagePath)));
810
1654
  const p = computeContentPlan(bytes, compress);
811
- warnIfLargeForOnChain(bytes.length, basename(resolvePath(imagePath)));
1655
+ // The gate runs on the dry-run too: a refusal a creator only meets after the first staging tx has
1656
+ // landed is a refusal that already cost them gas.
1657
+ await guardOnChainSize(bytes.length, basename(resolvePath(imagePath)));
812
1658
  return (`would stage ${basename(resolvePath(imagePath))} on-chain (chunk store): ` +
813
1659
  `${planSizeLine(bytes.length, p, compress)} as ${p.representation}; ` +
814
1660
  `${planTxLine(p, '1 deploy (bakes the reader field + mints)')}`);
@@ -822,6 +1668,7 @@ export function previewImageStaging(imagePath, compress) {
822
1668
  */
823
1669
  export async function stageImageField(imagePath, compress, send) {
824
1670
  const bytes = readFileSync(resolvePath(imagePath));
1671
+ refusePrewrappedImage(bytes, basename(resolvePath(imagePath)));
825
1672
  const { value, representation } = await putContentOnChain(bytes, compress, 'image', send, '1 deploy (bakes the reader field + mints)');
826
1673
  return {
827
1674
  field: { field: encodeTag(F.image), representation: encodeTag(representation), value },
@@ -832,7 +1679,7 @@ export async function stageImageField(imagePath, compress, send) {
832
1679
  * Stage MANY images on-chain against a **single, shared** chunk store — the batch form of
833
1680
  * {@link stageImageField} for a Series. The store is resolved/deployed **once** (not per token),
834
1681
  * then every file is staged against it and returned as its own `reader`-backed `image` field.
835
- * Each field still points at its own manifest, so each token's artwork is independent; they just
1682
+ * Each field still points at its own manifest, so each token's work is independent; they just
836
1683
  * share the store contract. Returns the fields in input order (token order).
837
1684
  */
838
1685
  export async function stageImageFieldsBatch(imagePaths, compress, send, storeOverride) {
@@ -845,6 +1692,150 @@ export async function stageImageFieldsBatch(imagePaths, compress, send, storeOve
845
1692
  }
846
1693
  return { fields, store };
847
1694
  }
1695
+ // ── the generator repoint guard ───────────────────────────────────────────────
1696
+ // The canonical AbxGenerator reads a token's param surface FROM CHAIN (`tokenParamKeys` /
1697
+ // `contractParamKeys`). A LEGACY implementation — deployed before enumeration shipped — has neither
1698
+ // getter, so the generator finds nothing: every configured param vanishes from tokenData and from
1699
+ // the live view, silently, behind a tokenURI that still looks perfectly healthy. That is the exact
1700
+ // failure class this toolkit refuses rather than warns about, so pointing a legacy token at the
1701
+ // current generator is REFUSED. (Repointing the metadata RENDERER is safe and unguarded: a v4
1702
+ // renderer on a legacy token just emits no params block.)
1703
+ // ── structured representations: `--value` is not raw bytes ───────────────────
1704
+ // Two representations carry a STRUCTURE rather than content, and the metadata renderer decodes
1705
+ // them before it can resolve the field: `renderer` is `abi.encode(address fieldRenderer)` (32
1706
+ // bytes) and `reader`/`reader-gzip` is `abi.encode(address reader, address pointer)` (64 bytes).
1707
+ //
1708
+ // `--value` used to be written to chain VERBATIM. So the natural input — a bare 20-byte address,
1709
+ // exactly what `--image-renderer` takes at deploy — landed as 20 bytes, and `abi.decode(v,
1710
+ // (address))` reverts on anything shorter than a word. The field itself read back fine, which is
1711
+ // what made this so hard to see from outside: `contractField("image")` returned the right
1712
+ // representation and the right-looking address, and `tokenURI` reverted for EVERY token in the
1713
+ // collection. Re-pointing at the renderer the collection was deployed with didn't fix it either —
1714
+ // every post-deploy write had the same wrong shape, so it looked permanent and looked like the
1715
+ // protocol's fault. Reported from the field on alpha.29 after four builds on Base Sepolia.
1716
+ //
1717
+ // Enforce, don't warn: normalize what is unambiguous (a bare address IS the field renderer),
1718
+ // refuse what is not, and never let a shape the renderer cannot decode reach the chain.
1719
+ /** Canonical `abi.encode(address)` — 24 zero nibbles then the 40 address nibbles. */
1720
+ const ABI_WORD_PAD = '0'.repeat(24);
1721
+ /**
1722
+ * The on-chain bytes for `--value` under `representation`. For a structured representation this
1723
+ * normalizes (a bare address → `abi.encode(address)`) and REFUSES anything the renderer could not
1724
+ * decode; every other representation carries raw bytes and passes through untouched.
1725
+ */
1726
+ export function encodeStructuredFieldValue(representation, value) {
1727
+ const hex = value.trim();
1728
+ if (representation === R.renderer) {
1729
+ // A bare 20-byte address: the shape `--image-renderer` takes, and what anyone reading
1730
+ // `contractField` back sees. Encode it rather than making the creator pad it by hand.
1731
+ if (/^0x[0-9a-fA-F]{40}$/.test(hex))
1732
+ return encodeFieldRenderer(getAddress(hex));
1733
+ if (/^0x[0-9a-fA-F]{64}$/.test(hex)) {
1734
+ if (hex.slice(2, 26).toLowerCase() !== ABI_WORD_PAD) {
1735
+ throw new CliError(`--representation renderer needs abi.encode(address) — a 32-byte word whose first 12 bytes are zero.\n` +
1736
+ ` ${hex} is 32 bytes but its high bytes are not zero, so it decodes to a garbage address and every\n` +
1737
+ ` tokenURI in the collection would revert. Pass the field renderer's plain 0x address instead.`);
1738
+ }
1739
+ return hex.toLowerCase();
1740
+ }
1741
+ throw new CliError(`--representation renderer expects the field renderer's 0x address (20 bytes) — not ${byteLen(hex)}.\n` +
1742
+ ` The chain stores this field as abi.encode(address) and the metadata renderer abi.decode()s it before\n` +
1743
+ ` it can call render(); any other length reverts tokenURI for the WHOLE collection.\n` +
1744
+ ` Example: abx set-field <token> --field image --value 0xYourFieldRenderer --representation renderer --collection`);
1745
+ }
1746
+ if (representation === R.reader || representation === R.readerGzip) {
1747
+ const ok = /^0x[0-9a-fA-F]{128}$/.test(hex) &&
1748
+ hex.slice(2, 26).toLowerCase() === ABI_WORD_PAD &&
1749
+ hex.slice(66, 90).toLowerCase() === ABI_WORD_PAD;
1750
+ if (!ok) {
1751
+ throw new CliError(`--representation ${representation} expects abi.encode(address reader, address pointer) — 64 bytes, two\n` +
1752
+ ` zero-padded address words — not ${byteLen(hex)}. Hand-encoding this is rarely what you want:\n` +
1753
+ ` \`abx set-field <token> --field <name> --file <path>\` stages the bytes on chain and writes the\n` +
1754
+ ` reader value for you, correctly.`);
1755
+ }
1756
+ return hex.toLowerCase();
1757
+ }
1758
+ return hex;
1759
+ }
1760
+ /** "20 bytes" / "not hex" — the half of the message that says what the input actually was. */
1761
+ function byteLen(hex) {
1762
+ if (!/^0x([0-9a-fA-F]{2})*$/.test(hex))
1763
+ return `'${hex}' (not an even-length 0x hex string)`;
1764
+ const n = (hex.length - 2) / 2;
1765
+ return `${n} byte${n === 1 ? '' : 's'}`;
1766
+ }
1767
+ /**
1768
+ * Refuse a `renderer` field that points at an address with NO CODE — the same guard
1769
+ * `deploy-code --image-renderer` applies, now on the post-deploy path it was missing from. A
1770
+ * codeless target passes every static check and still reverts every tokenURI.
1771
+ */
1772
+ export async function assertFieldRendererDeployed(value) {
1773
+ let target;
1774
+ try {
1775
+ target = decodeFieldRenderer(value);
1776
+ }
1777
+ catch {
1778
+ return; // not decodable as an address — encodeStructuredFieldValue already refused those
1779
+ }
1780
+ if (target === zeroAddress) {
1781
+ throw new CliError('a `renderer` field pointing at the zero address reverts every tokenURI. To stop computing this field on\n' +
1782
+ ' chain, set it to a real representation (--text / --value with `inline`, `url`, `ipfs`, …) instead.');
1783
+ }
1784
+ const publicClient = makePublicClient({ chainKey: CHAIN });
1785
+ let code;
1786
+ try {
1787
+ code = await publicClient.getCode({ address: target });
1788
+ }
1789
+ catch {
1790
+ return; // node unreachable — the owner read upstream already proved it was, so don't invent a failure
1791
+ }
1792
+ if (!code || code === '0x') {
1793
+ throw new CliError(`${target} has NO code on '${CHAIN}' — that is not a deployed IAbxFieldRenderer, and pointing a field at it\n` +
1794
+ ` reverts tokenURI for every token in the collection. Deploy the renderer first (\`abx scaffold solidity\`\n` +
1795
+ ` → forge test → forge script), then pass the address it printed.`);
1796
+ }
1797
+ }
1798
+ /** The field-renderer address a `--value` names: the canonical `abi.encode(address)` (32 bytes),
1799
+ * or a bare 20-byte address. Null when it is neither. */
1800
+ function fieldRendererTarget(value) {
1801
+ const hex = value.trim();
1802
+ if (!/^0x[0-9a-fA-F]*$/.test(hex))
1803
+ return null;
1804
+ if (hex.length === 42)
1805
+ return getAddress(hex);
1806
+ if (hex.length !== 66)
1807
+ return null;
1808
+ try {
1809
+ return decodeFieldRenderer(hex);
1810
+ }
1811
+ catch {
1812
+ return null;
1813
+ }
1814
+ }
1815
+ export async function assertGeneratorRepointable(contract, field, flags,
1816
+ /** Injected for tests; the real probe is one eth_call. */
1817
+ probe = (c) => hasParamEnumeration(makePublicClient({ chainKey: CHAIN }), c)) {
1818
+ if (flags.representation !== R.renderer || !flags.value || flags.value === 'true')
1819
+ return;
1820
+ const target = fieldRendererTarget(flags.value);
1821
+ const generator = resolveGenerator(chainId());
1822
+ // Only the CANONICAL generator is guarded — any other field renderer is the creator's own contract
1823
+ // and none of our business.
1824
+ if (!target || !generator || target.toLowerCase() !== generator.toLowerCase())
1825
+ return;
1826
+ // The `owner` read has already succeeded by the time this runs, so the RPC is proven reachable:
1827
+ // a failing probe here means the getter is absent, not that the node is down.
1828
+ if (await probe(contract))
1829
+ return;
1830
+ throw new Error(`refusing to point ${field} at the canonical generator ${generator} — ${contract} does not expose the param\n` +
1831
+ ` enumeration surface (tokenParamKeys/contractParamKeys), so it is a LEGACY implementation. The current\n` +
1832
+ ` generator reads params FROM CHAIN, so on this token it would read NOTHING: every configured param would\n` +
1833
+ ` silently disappear from tokenData, the live view, and every render — with a tokenURI that still looks fine.\n\n` +
1834
+ ` Two honest options:\n` +
1835
+ ` • stay on the generator this project already uses (pass that address as --value; it reads the project's\n` +
1836
+ ` params.keys list, which is how it has always worked here), or\n` +
1837
+ ` • redeploy the project with the current \`abx deploy-code\` — new projects enumerate on-chain and need no list.`);
1838
+ }
848
1839
  // ── set-field ──────────────────────────────────────────────────────────────--
849
1840
  // Set an on-chain metadata field. `--field` is what (e.g. image, description),
850
1841
  // `--representation` is how it's carried (default `inline` for --text, `keccak256`
@@ -859,12 +1850,18 @@ export async function cmdSetField(address, flags) {
859
1850
  const collection = !!flags.collection;
860
1851
  const lane = laneFromFlags(flags);
861
1852
  const owner = await read(contract, 'owner');
1853
+ await assertGeneratorRepointable(contract, field, flags); // legacy impl + the current generator = params silently invisible
862
1854
  const staging = !!(flags.file && flags.file !== 'true'); // large content ON-CHAIN via chunk store/reader
1855
+ if (staging && field === 'image') {
1856
+ refusePrewrappedImage(readFileSync(resolvePath(flags.file)), `--file`);
1857
+ }
863
1858
  // --file + --dry-run: preview the on-chain staging plan and store/send NOTHING. (The locator/text
864
1859
  // path flows through runWrite, which previews there; staging must short-circuit before any upload.)
865
- if (staging && flags['dry-run']) {
1860
+ if (staging && isDryRun(flags)) {
866
1861
  const bytes = readFileSync(resolvePath(flags.file));
867
1862
  const p = computeContentPlan(bytes, parseCompress(flags.compress));
1863
+ // Same render-gas gate the real path applies, so a refusal costs a dry run rather than gas.
1864
+ await guardOnChainSize(bytes.length, `'${field}'`);
868
1865
  console.log(`\n ${bold('◆ set-field ' + field)} ${dim('(dry run — nothing staged or sent)')}`);
869
1866
  console.log(` ${dim('scope'.padEnd(12))} ${collection ? 'collection' : `token #${flags.token ?? '0'}`}`);
870
1867
  console.log(` ${planSizeLine(bytes.length, p, parseCompress(flags.compress))}`);
@@ -913,6 +1910,8 @@ export async function cmdSetField(address, flags) {
913
1910
  ({ value, representation } = await putContentOnChain(readFileSync(resolvePath(flags.file)), compress, field, envStagingSender()));
914
1911
  }
915
1912
  else if (flags.text && flags.text !== 'true') {
1913
+ if (field === 'image')
1914
+ refusePrewrappedImage(flags.text, `--text`);
916
1915
  value = toHex(flags.text);
917
1916
  representation = flags.representation ?? R.inline;
918
1917
  console.log(dim(` inline on-chain '${field}'${collection ? ' (collection)' : ` on token #${flags.token ?? '0'}`}: ${JSON.stringify(flags.text.slice(0, 60))}${flags.text.length > 60 ? '…' : ''}`));
@@ -921,13 +1920,68 @@ export async function cmdSetField(address, flags) {
921
1920
  value = requireFlag(flags, 'value', 'abx set-field <address> --field <name> --text … | --value 0x…');
922
1921
  representation = flags.representation ?? R.keccak256;
923
1922
  }
924
- await runWrite(contract, buildTx(value, representation), flags, owner);
1923
+ // A structured representation is decoded on chain before the field can resolve — normalize the
1924
+ // shape and refuse an undecodable one HERE, where it costs nothing, rather than on the read path
1925
+ // where it costs the whole collection's tokenURI. (Staged content already arrives correctly
1926
+ // encoded from putContentOnChain; this only ever changes a hand-passed --value.)
1927
+ value = encodeStructuredFieldValue(representation, value);
1928
+ if (representation === R.renderer)
1929
+ await assertFieldRendererDeployed(value);
1930
+ const sent = await runWrite(contract, buildTx(value, representation), flags, owner);
1931
+ // Then prove the thing the creator actually cares about: that the served document still reads.
1932
+ // A field renderer is the project's OWN contract — it can revert for reasons no static check
1933
+ // sees — so the honest confirmation is to call tokenURI once, after the write.
1934
+ if (sent)
1935
+ await reportUriAfterFieldWrite(contract, collection, flags);
1936
+ }
1937
+ /**
1938
+ * After a field write lands, staticcall what a marketplace calls and say plainly whether it still
1939
+ * resolves. The write is already on chain, so this never throws — a reverting `tokenURI` is
1940
+ * reported with the read that proves it and the write that undoes it.
1941
+ *
1942
+ * This is the check that would have caught the `renderer` encoding bug on the first collection
1943
+ * instead of the fourth: `abx verify` surfaced the reverting tokenURI, but only when it was next
1944
+ * run, and nothing tied it back to the write that caused it.
1945
+ */
1946
+ async function reportUriAfterFieldWrite(contract, collection, flags) {
1947
+ const publicClient = makePublicClient({ chainKey: CHAIN });
1948
+ const tokenId = BigInt(flags.token ?? '0');
1949
+ // An ERC-1155 edition serves `uri(id)`, not `tokenURI(id)` — read the one this contract has, or the
1950
+ // check would report a healthy edition as broken.
1951
+ const edition = await isEditionContract(publicClient, contract).catch(() => false);
1952
+ const fn = edition ? 'uri' : 'tokenURI';
1953
+ const abi = edition ? oneOfOneEditionAbi : oneOfOneImageAbi;
1954
+ const uri = await tryReadContract(publicClient, { address: contract, abi, functionName: fn, args: [tokenId] });
1955
+ if (uri !== undefined) {
1956
+ console.log(` ${green('✓')} ${fn}(${tokenId}) still resolves`);
1957
+ return;
1958
+ }
1959
+ {
1960
+ // A collection with nothing minted yet has no token to read — not a failure, just nothing to
1961
+ // prove. Separate that from a real revert before alarming anyone.
1962
+ const supply = await tryReadContract(publicClient, {
1963
+ address: contract,
1964
+ abi: oneOfOneImageAbi,
1965
+ functionName: 'totalSupply',
1966
+ args: [],
1967
+ });
1968
+ if (supply === 0n) {
1969
+ console.log(dim(` (no tokens minted yet — nothing to read back; run \`abx verify ${contract}\` after the first mint)`));
1970
+ return;
1971
+ }
1972
+ console.log(yellow(' ⚠ ') +
1973
+ `${fn}(${tokenId}) REVERTS after this write — the field is on chain but the served document no longer reads.\n` +
1974
+ ` confirm: abx tokenuri ${contract} ${tokenId}\n` +
1975
+ ` This is recoverable: the field is not locked until you \`abx lock-field\` it, so re-setting '${flags.field}'\n` +
1976
+ ` to a working value${collection ? ' --collection' : ''} restores it. Check the renderer's own render() first —\n` +
1977
+ ` a field renderer must NEVER revert.`);
1978
+ }
925
1979
  }
926
1980
  // ── attach (the data-plane verb: put a named file on a token) ─────────────────
927
1981
  /**
928
1982
  * `abx attach <address> <key> <uri>` — attach a named, typed file to a token (or the collection),
929
1983
  * so it appears in the token's served `artifacts` manifest as `{key, mimeType, uri}`. This is the
930
- * creator-facing verb for the token data plane (specs/protocol/data-plane.md); it's a thin,
1984
+ * creator-facing verb for the token data plane (site/content/docs/protocol/data-plane.mdx); it's a thin,
931
1985
  * correct-by-construction wrapper over `set-field`:
932
1986
  * - the on-chain representation is AUTO-DETECTED from the URI scheme (ipfs:// · ar:// · https://)
933
1987
  * — no `--representation` to guess wrong;
@@ -938,9 +1992,25 @@ export async function cmdSetField(address, flags) {
938
1992
  * - computed keys (`artifacts`, `abx_provenance`) are refused — they're the manifest, not inputs.
939
1993
  * Scope: token (`--token`, default 0) or `--collection`. Any signing lane. Owner-only.
940
1994
  */
1995
+ /**
1996
+ * Attach one or more artifacts. Several `<key> <uri>` pairs in one invocation become **one
1997
+ * transaction**.
1998
+ *
1999
+ * That batching is a safety fix, not a convenience. The documented flow —
2000
+ * mint, then attach each artifact, then refresh — sent one transaction per step with no all-or-nothing
2001
+ * boundary, so a failure partway through left a permanently half-written token that cannot be
2002
+ * un-minted. An integrator hit exactly this and folded 8 operations into 1 transaction using the SDK's
2003
+ * `batchOps`, which the CLI already shipped and did not use. Now it does: N pairs are one
2004
+ * `multicall`, so either every artifact lands or none does.
2005
+ *
2006
+ * A single pair passes through `batchOps` untouched, so the one-artifact case sends the identical
2007
+ * transaction it always did.
2008
+ */
941
2009
  export async function cmdAttach(rest, flags) {
942
- const usage = 'abx attach <address> <key> <ipfs://… | ar://… | https://…> [--file <path>] [--collection | --token 0] [--sign|--unsigned] [--dry-run]';
943
- const [address, key, uri] = positionalArgs(rest);
2010
+ const usage = 'abx attach <address> <key> <ipfs://… | ar://… | https://…> [<key> <uri> …] [--file <path>] [--collection | --token 0] [--sign|--unsigned] [--dry-run]';
2011
+ const [address, ...pairArgs] = positionalArgs(rest);
2012
+ const key = pairArgs[0];
2013
+ const uri = pairArgs[1];
944
2014
  // Warn (never throw) on an unrecognized flag — a typo or a hopeful `--mime-type` otherwise no-ops
945
2015
  // INVISIBLY (round-1: an agent passed `--mime-type` and it was silently swallowed). mimeType is
946
2016
  // declared from the URL extension, not a flag; say so.
@@ -955,13 +2025,15 @@ export async function cmdAttach(rest, flags) {
955
2025
  const contract = requireAddress(address, usage);
956
2026
  if (!key) {
957
2027
  console.error(`usage: ${usage}\n the <key> is the name your file appears under in the manifest (e.g. print, certificate, stems, readme).\n`);
958
- process.exit(1);
2028
+ process.exitCode = 1;
2029
+ return;
959
2030
  }
960
2031
  assertSettableField(key);
961
2032
  const hasFile = !!(flags.file && flags.file !== 'true');
962
2033
  if (!uri && !hasFile) {
963
2034
  console.error(`usage: ${usage}\n pass a locator URI (ipfs://… / ar://… / https://…) or --file <path> to store bytes on-chain.\n`);
964
- process.exit(1);
2035
+ process.exitCode = 1;
2036
+ return;
965
2037
  }
966
2038
  if (uri && hasFile)
967
2039
  throw new Error('pass EITHER a locator URI OR --file <path>, not both.');
@@ -975,40 +2047,133 @@ export async function cmdAttach(rest, flags) {
975
2047
  // Locator path (the common case): auto-detect the representation, refuse an unrecognized scheme
976
2048
  // loudly (never silently store a bad value) — fail fast before any RPC. Derive the declared
977
2049
  // mimeType from the extension.
978
- const representation = representationForLocator(uri);
979
- if (!representation) {
980
- throw new Error(`"${uri}" isn't a recognized file locator. Use ipfs://… (pinned/IPFS), ar://… (Arweave), or https://… . ` +
981
- 'To store literal text or raw bytes on-chain instead, use `abx set-field`.');
2050
+ // Every `<key> <uri>` pair, validated BEFORE anything is sent — a batch that would revert partway
2051
+ // is exactly what this command now exists to prevent, so a bad locator in pair 5 must stop pair 1.
2052
+ if (pairArgs.length % 2 !== 0) {
2053
+ throw new Error(`attach takes <key> <uri> PAIRS; got ${pairArgs.length} positional argument(s) after the address. ` +
2054
+ `Last one seen: "${pairArgs[pairArgs.length - 1]}".`);
2055
+ }
2056
+ const pairs = [];
2057
+ for (let i = 0; i < pairArgs.length; i += 2) {
2058
+ const k = pairArgs[i];
2059
+ const u = pairArgs[i + 1];
2060
+ assertSettableField(k);
2061
+ const rep = representationForLocator(u);
2062
+ if (!rep) {
2063
+ throw new Error(`"${u}" isn't a recognized file locator. Use ipfs://… (pinned/IPFS), ar://… (Arweave), or https://… . ` +
2064
+ 'To store literal text or raw bytes on-chain instead, use `abx set-field`.');
2065
+ }
2066
+ pairs.push({ key: k, uri: u, representation: rep, mimeType: contentTypeFromPath(u) });
982
2067
  }
2068
+ // A key repeated within one batch would have the later write silently win — the same
2069
+ // full-column-upsert hazard that bit `register` and `lock-field`. Refuse instead.
2070
+ const dupes = pairs.map((p) => p.key).filter((k, i, a) => a.indexOf(k) !== i);
2071
+ if (dupes.length) {
2072
+ throw new Error(`the same key appears twice in one batch: ${[...new Set(dupes)].join(', ')}. ` +
2073
+ 'Each field holds ONE active value, so the last write would silently win — attach them separately if that is really what you want.');
2074
+ }
2075
+ const representation = pairs[0].representation;
983
2076
  const owner = await read(contract, 'owner');
984
- const mimeType = contentTypeFromPath(uri);
985
- if (mimeType === 'application/octet-stream') {
2077
+ for (const p of pairs) {
2078
+ if (p.mimeType === 'application/octet-stream') {
2079
+ console.log(yellow(' ⚠ ') +
2080
+ dim(`no file extension in "${p.uri.slice(0, 64)}" → declared type will be application/octet-stream. `) +
2081
+ dim('Point the URI at the file itself (…/master.tiff, …/coa.pdf) so collectors get the right type.'));
2082
+ }
2083
+ }
2084
+ const mimeType = pairs[0].mimeType;
2085
+ for (const p of pairs) {
2086
+ console.log(` attaching ${bold(p.key)} ${dim(`(${p.mimeType}, ${p.representation})`)} to ${scope}: ${dim(p.uri)}`);
2087
+ }
2088
+ // Where it surfaces. This used to be one dim line, and it read as a footnote rather than as a
2089
+ // dependency: an integrator attached five audio stems to a fully-on-chain token, paid to store
2090
+ // them, and found `tokenURI` listed none of them — "paid for, stored on-chain, and invisible".
2091
+ // The on-chain renderer deliberately omits locator-represented artifacts (they duplicate no
2092
+ // on-chain type information — see site/content/docs/protocol/data-plane.mdx), so the artifacts manifest comes
2093
+ // from a RESOLVER. When the project has no resolver baked in, that is not a footnote, it is the
2094
+ // difference between a feature working and not existing, so say it as a warning.
2095
+ const uriBase = await read(contract, 'tokenURIBase').catch(() => '');
2096
+ const artifactsPath = `/t/${chainId()}/${contract}/${flags.token ?? '0'}`;
2097
+ if (uriBase && uriBase.trim() !== '') {
2098
+ 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).`));
2099
+ }
2100
+ else {
986
2101
  console.log(yellow(' ⚠ ') +
987
- dim(`no file extension in "${uri.slice(0, 64)}" declared type will be application/octet-stream. `) +
988
- dim('Point the URI at the file itself (…/master.tiff, …/coa.pdf) so collectors get the right type.'));
989
- }
990
- console.log(` attaching ${bold(key)} ${dim(`(${mimeType}, ${representation})`)} to ${scope}: ${dim(uri)}`);
991
- // Where it surfaces — say it on the preview path too (not just post-send), so a creator/agent
992
- // doesn't expect an attached custom key in a bare `abx tokenuri` (round-2 finding: they did).
993
- console.log(dim(` → appears in a resolver's artifacts listing (/t/${chainId()}/${contract}/${flags.token ?? '0'} and /data/${key}); a bare on-chain tokenURI shows reserved fields only.`));
994
- const buildTx = () => collection
995
- ? prepareSetContractField({ contract, field: key, representation, value: toHex(uri), chainId: chainId() })
996
- : prepareSetTokenField({ contract, tokenId: BigInt(flags.token ?? '0'), field: key, representation, value: toHex(uri), chainId: chainId() });
997
- const sent = await runWrite(contract, buildTx(), flags, owner);
2102
+ `this project resolves ON-CHAIN (no resolver base baked in), and the on-chain document carries reserved fields only — ` +
2103
+ `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. ` +
2104
+ dim('(Params are chain state, readable with abx tokens and by any contract call — attachments are the surface that needs a resolver.)'));
2105
+ 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}.`));
2106
+ }
2107
+ // ONE transaction for the whole set. `batchOps` folds a same-target run into a `multicall` and
2108
+ // passes a lone op through untouched, so a single attach is byte-identical to before.
2109
+ const ops = pairs.map((p) => collection
2110
+ ? prepareSetContractField({ contract, field: p.key, representation: p.representation, value: toHex(p.uri), chainId: chainId() })
2111
+ : prepareSetTokenField({ contract, tokenId: BigInt(flags.token ?? '0'), field: p.key, representation: p.representation, value: toHex(p.uri), chainId: chainId() }));
2112
+ const batched = batchOps(ops);
2113
+ if (batched.length !== 1) {
2114
+ // Defensive: every op here targets the same contract and carries no value, so batchOps must
2115
+ // return exactly one tx. If that ever changes, fail loudly rather than send a partial set.
2116
+ throw new Error(`attach expected to batch ${ops.length} op(s) into one transaction, got ${batched.length}`);
2117
+ }
2118
+ if (pairs.length > 1) {
2119
+ console.log(dim(` ${pairs.length} artifacts → ONE transaction (all-or-nothing: a revert lands none of them, so no half-written token).`));
2120
+ }
2121
+ const sent = await runWrite(contract, batched[0], flags, owner);
998
2122
  if (sent) {
999
2123
  const id = flags.token ?? '0';
1000
- console.log(` ${green('✓')} attached — ${bold(key)} joins ${scope}'s ${bold('artifacts')} manifest (stored on-chain, anchored).\n` +
2124
+ const names = pairs.map((p) => bold(p.key)).join(', ');
2125
+ console.log(` ${green('✓')} attached — ${names} join${pairs.length > 1 ? '' : 's'} ${scope}'s ${bold('artifacts')} manifest (stored on-chain, anchored).\n` +
1001
2126
  dim(` verify (a resolver serves the complete listing): `) +
1002
- `curl <your-resolver>/t/${chainId()}/${contract}/${id}` +
1003
- dim(` ${collection ? '' : `→ artifacts[].key "${key}"; /data/${key} fetches it`}\n`) +
1004
- dim(` (The complete file listing is a resolver surface — the bare on-chain tokenURI enumerates reserved fields only.)\n`));
2127
+ `abx tokenuri ${contract}${id === '0' ? '' : ` --token ${id}`} --fetch` +
2128
+ dim(` ${collection ? '' : `→ artifacts[].key ${pairs.map((p) => `"${p.key}"`).join(', ')}; /data/<key> fetches each`}\n`) +
2129
+ 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`));
1005
2130
  }
1006
2131
  }
1007
2132
  // ── lock-field ───────────────────────────────────────────────────────────────
2133
+ /**
2134
+ * Refuse `lock-field` on a name that is a declared PARAM key.
2135
+ *
2136
+ * Fields and params are two separate namespaces that may share a name, and `lock-field` only ever
2137
+ * locks the *field*. A tester welded `grid` — a `Bytes` param holding the work — with
2138
+ * `lock-field --field grid`, got "permanent", got `tokenFieldLocked(0,"grid") == true`, and then
2139
+ * overwrote the work with `configure-param` on the next call. Every individual statement the CLI
2140
+ * made was true; together they promised a protection that did not exist. Permanence is the pitch, so
2141
+ * this refuses rather than warns, and names the mechanism that actually welds a param.
2142
+ */
2143
+ async function refuseIfParamKey(contract, field) {
2144
+ let exists = false;
2145
+ try {
2146
+ const schema = (await makePublicClient({ chainKey: CHAIN }).readContract({
2147
+ address: contract,
2148
+ abi: seriesCodeAbi,
2149
+ functionName: 'paramSchema',
2150
+ args: [encodeTagSdk(field)],
2151
+ }));
2152
+ exists = !!schema[0];
2153
+ }
2154
+ catch {
2155
+ // No param surface at all (a 1/1 or plain Series) — nothing to confuse the name with.
2156
+ return;
2157
+ }
2158
+ if (!exists)
2159
+ return;
2160
+ throw new Error(`"${field}" is a declared PostParam key on ${contract}, and lock-field does NOT lock params — ` +
2161
+ `it locks the metadata FIELD of the same name. They are separate namespaces, so this would have ` +
2162
+ `reported "permanent" while configure-param stayed free to overwrite the value.\n` +
2163
+ ` To weld the param, lock its schema instead:\n` +
2164
+ ` abx set-schema ${contract} --schema ${field}:<Type>:<Auth>:lock=now\n` +
2165
+ ` (after that every configure-param on "${field}" reverts ParamLockExpired — check with ` +
2166
+ `\`abx inspect ${contract}\`.)\n` +
2167
+ ` If you really did mean the metadata field "${field}" and not the param, re-run with --force-field.`);
2168
+ }
1008
2169
  export async function cmdLockField(address, flags) {
1009
2170
  const contract = requireAddress(address, 'abx lock-field <address> --field <name> [--collection | --token 0] [--sign|--unsigned]');
1010
2171
  const field = requireFlag(flags, 'field', 'abx lock-field <address> --field <name>');
1011
2172
  const collection = !!flags.collection;
2173
+ // The metadata field and a same-named param are different things; only an explicit --force-field
2174
+ // says "yes, I mean the field". See refuseIfParamKey.
2175
+ if (flags['force-field'] === undefined)
2176
+ await refuseIfParamKey(contract, field);
1012
2177
  const owner = await read(contract, 'owner');
1013
2178
  console.log(dim(` note: locking the '${field}' field is permanent and irreversible (freezes all its representations).`));
1014
2179
  const tx = collection
@@ -1036,13 +2201,14 @@ export async function cmdSetRenderer(address, flags) {
1036
2201
  }
1037
2202
  // ── lock-uri (freeze the URI config) ──────────────────────────────────────────
1038
2203
  /** Freeze a scope's URI config (pointer + renderer) forever. With the fields also
1039
- * locked, the resolved metadata is provably immutable — the on-chain twin of a
1040
- * frozen `tokenURI`. Scope: token (default) or `--collection`. */
2204
+ * locked, the stored metadata can never change again — the on-chain twin of a frozen
2205
+ * `tokenURI`. Not the same as a frozen OUTPUT: params have no lock and the renderer
2206
+ * projects them, and a Registry dependency resolves live. Scope: token or `--collection`. */
1041
2207
  export async function cmdLockUri(address, flags) {
1042
2208
  const contract = requireAddress(address, 'abx lock-uri <address> [--collection] [--sign|--unsigned]');
1043
2209
  const collection = !!flags.collection;
1044
2210
  const owner = await read(contract, 'owner');
1045
- console.log(dim(` note: locking the ${collection ? 'contractURI' : 'tokenURI'} config is permanent — pointer + renderer can never change again.`));
2211
+ 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.`));
1046
2212
  const tx = collection
1047
2213
  ? prepareLockContractURI({ contract, chainId: chainId() })
1048
2214
  : prepareLockTokenURI({ contract, chainId: chainId() });
@@ -1058,7 +2224,7 @@ export async function cmdSetAdmin(address, flags) {
1058
2224
  // ── fixed-price minter (the Minter spine) ─────────────────────────────────────
1059
2225
  // A shared, ownerless, multi-tenant sale singleton. `configure` defers to the token owner;
1060
2226
  // `buy` is public. Sale config is keyed by token address; proceeds route to the token's
1061
- // primaryPayee; the token's Paused extension is the on/off switch. See specs/protocol/minter-spine.md.
2227
+ // primaryPayee; the token's Paused extension is the on/off switch. See site/content/docs/protocol/minting.mdx.
1062
2228
  /** A Series read (minter/primaryPayee/paused/…) via the Series ABI (the 1/1 ABI lacks these). */
1063
2229
  async function readSeries(token, fn, args = []) {
1064
2230
  const publicClient = makePublicClient({ chainKey: CHAIN });
@@ -1069,15 +2235,16 @@ async function readSeries(token, fn, args = []) {
1069
2235
  args: args,
1070
2236
  }));
1071
2237
  }
1072
- async function readSale(minter, token) {
2238
+ /** The edition twin of {@link readSeries} — reads a per-id getter (`totalSupply(id)`,
2239
+ * `maxSupply(id)`, `uri(id)`, …) via the narrowest-common-superset edition ABI. */
2240
+ async function readEdition(token, fn, args = []) {
1073
2241
  const publicClient = makePublicClient({ chainKey: CHAIN });
1074
- const [configured, paymentToken, price, allocation, sold] = (await publicClient.readContract({
1075
- address: minter,
1076
- abi: abxFixedPriceMinterAbi,
1077
- functionName: 'sales',
1078
- args: [token],
2242
+ return (await publicClient.readContract({
2243
+ address: token,
2244
+ abi: oneOfOneEditionAbi,
2245
+ functionName: fn,
2246
+ args: args,
1079
2247
  }));
1080
- return { configured, paymentToken, price, allocation, sold };
1081
2248
  }
1082
2249
  /** Resolve the shared minter for the chain (flag → env → manifest), or deploy it if none exists.
1083
2250
  * It's ownerless, so any funded signer can stand it up — the sibling of `ensureChunkStore`. */
@@ -1101,38 +2268,51 @@ async function ensureFixedPriceMinter(override) {
1101
2268
  }
1102
2269
  }
1103
2270
  const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
2271
+ const send = makeHotSender({ wallet, account, publicClient });
1104
2272
  console.log(dim(' deploying the canonical fixed-price minter (shared, ownerless) — CREATE2…'));
1105
- const { minter, txHash } = await deployFixedPriceMinter(wallet, account, publicClient);
2273
+ const { minter, txHash } = await deployFixedPriceMinter(send, publicClient);
1106
2274
  console.log(` ${green('✓')} fixed-price minter ${minter}`);
1107
2275
  console.log(dim(` tx ${txHash}`));
1108
2276
  console.log(dim(` not in the shipped manifest for ${CHAIN} — to reuse it set ${bold(`ABX_FIXED_PRICE_MINTER=${minter}`)} (or add it to packages/sdk/src/deployments.ts)`));
1109
2277
  return minter;
1110
2278
  }
2279
+ /** The edition twin of {@link ensureFixedPriceMinter} — resolves (or deploys) the chain's shared
2280
+ * `AbxFixedPriceMinter1155`, the Minter spine's per-`(token, id)` sale singleton. */
2281
+ async function ensureFixedPriceMinter1155(override) {
2282
+ const known = fixedPriceMinter1155Address(override);
2283
+ const publicClient = makePublicClient({ chainKey: CHAIN });
2284
+ if (known) {
2285
+ const code = await publicClient.getCode({ address: known });
2286
+ if (code && code !== '0x')
2287
+ return known;
2288
+ console.log(yellow(` configured edition minter ${known} has no code on ${CHAIN} — deploying a fresh one`));
2289
+ }
2290
+ const predicted = predictFixedPriceMinter1155();
2291
+ if (!known || known.toLowerCase() !== predicted.toLowerCase()) {
2292
+ const pcode = await publicClient.getCode({ address: predicted });
2293
+ if (pcode && pcode !== '0x') {
2294
+ console.log(dim(` using the canonical fixed-price edition minter at its deterministic address ${predicted}`));
2295
+ return predicted;
2296
+ }
2297
+ }
2298
+ const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
2299
+ const send = makeHotSender({ wallet, account, publicClient });
2300
+ console.log(dim(' deploying the canonical fixed-price edition minter (shared, ownerless) — CREATE2…'));
2301
+ const { minter, txHash } = await deployFixedPriceMinter1155(send, publicClient);
2302
+ console.log(` ${green('✓')} fixed-price edition minter ${minter}`);
2303
+ console.log(dim(` tx ${txHash}`));
2304
+ console.log(dim(` not in the shipped manifest for ${CHAIN} — to reuse it set ${bold(`ABX_FIXED_PRICE_MINTER_1155=${minter}`)} (or add it to packages/sdk/src/deployments.ts)`));
2305
+ return minter;
2306
+ }
1111
2307
  /** A minter write (configure/buy). No re-index — the sale lives on the minter, not the token
1112
- * projection; the token's own state is unchanged by a sale config. */
2308
+ * projection; the token's own state is unchanged by a sale config.
2309
+ *
2310
+ * Used to hand-roll its own dry-run guard — "the minter path does not go through runWrite, so it
2311
+ * needs its OWN" — which is exactly the duplication {@link gatedSend} exists to end: this now shares
2312
+ * the identical choke point `runWrite` does, so `minter configure`/`minter buy` also pick up
2313
+ * `--confirm` (previously only the deploy family had it) with no extra code here. */
1113
2314
  async function runMinterWrite(provider, flags, expectedSigner) {
1114
- // --dry-run: preview the sale-config / purchase tx and send NOTHING. The minter path does not go
1115
- // through runWrite, so it needs its OWN dry-run guard — without it, `minter configure --dry-run`
1116
- // silently signs + broadcasts a real sale (a critical footgun: a mainnet creator "previewing"
1117
- // their sale would send it). Mirrors runWrite's guard.
1118
- if (flags['dry-run']) {
1119
- const prepared = typeof provider === 'function' ? await provider(expectedSigner ?? zeroAddress) : provider;
1120
- console.log(`\n ${bold('◆ ' + prepared.summary)} ${dim('(dry run — nothing sent)')}`);
1121
- for (const [k, v] of Object.entries(prepared.fields))
1122
- console.log(` ${dim(k.padEnd(12))} ${v}`);
1123
- console.log(` ${dim('to'.padEnd(12))} ${prepared.to ?? dim('(contract deploy)')}`);
1124
- console.log(dim(`\n Re-run without --dry-run to send (lane: ${laneFromFlags(flags)}).\n`));
1125
- return;
1126
- }
1127
- await assertChainId(CHAIN);
1128
- await signTx(provider, {
1129
- lane: laneFromFlags(flags),
1130
- chainKey: CHAIN,
1131
- expectedSigner,
1132
- yes: !!flags.yes,
1133
- port: flags.port ? Number(flags.port) : undefined,
1134
- signUrlFile: flags['sign-url-file'],
1135
- });
2315
+ await gatedSend(provider, flags, { chainKey: CHAIN, expectedSigner });
1136
2316
  }
1137
2317
  /** Parse the sale price: `--price-raw <units>` (exact base units) or the friendly `--price <ether>`
1138
2318
  * (18-decimals). ETH sales use `--price`; non-18-dp ERC-20s should use `--price-raw`. */
@@ -1142,30 +2322,88 @@ function parseSalePrice(flags, usage) {
1142
2322
  if (flags.price && flags.price !== 'true')
1143
2323
  return parseEther(flags.price);
1144
2324
  console.error(`missing --price <ether> or --price-raw <units>\nusage: ${usage}\n`);
1145
- process.exit(1);
2325
+ throw new CliError('', 1, true); // already printed above — see CliError's alreadyPrinted doc
1146
2326
  }
1147
2327
  /**
1148
2328
  * `abx minter configure <token> --price <eth> --allocation <n> [--erc20 0x…]` — set the fixed-price
1149
2329
  * sale for a project on the shared minter. Defers to the token owner. Resolves (or deploys) the
1150
2330
  * shared minter, then reports the two remaining grants the owner still needs (assign the minter on
1151
2331
  * the token, set a primary payee) and whether the token is paused.
2332
+ *
2333
+ * On an EDITION (OneOfOneEdition/EditionImage/EditionCode), `--token-id` is REQUIRED (sales are
2334
+ * keyed `(token, id)` — a per-work price, not one project-wide sale) and the sale routes to the
2335
+ * shared `AbxFixedPriceMinter1155` instead. Refused on a 721 target.
1152
2336
  */
1153
2337
  export async function cmdMinterConfigure(address, flags) {
1154
- const usage = 'abx minter configure <token> (--price <eth> | --price-raw <units>) --allocation <n> [--erc20 0x…] [--minter-contract 0x…] [--sign|--unsigned]';
2338
+ const usage = 'abx minter configure <token> (--price <eth> | --price-raw <units>) --allocation <n> [--erc20 0x…] [--token-id <n> (editions)] [--minter-contract 0x…] [--sign|--unsigned]';
1155
2339
  const token = requireAddress(address, usage);
2340
+ warnStrayFlags(flags, new Set(MINTER_CONFIGURE_FLAGS), 'minter configure');
2341
+ const publicClient = makePublicClient({ chainKey: CHAIN });
2342
+ const kind = await detectTokenKind(publicClient, token);
1156
2343
  const owner = await read(token, 'owner');
1157
2344
  const erc20 = flags.erc20 && flags.erc20 !== 'true' ? flags.erc20 : zeroAddress;
1158
2345
  const allocation = BigInt(requireFlag(flags, 'allocation', usage));
1159
2346
  const price = parseSalePrice(flags, usage);
1160
- // On --dry-run resolve the minter WITHOUT deploying — ensureFixedPriceMinter would deploy the
1161
- // shared singleton if absent, and a preview must never send. Fall back to a label if none exists.
1162
- const dryRun = !!flags['dry-run'];
2347
+ const isEth = erc20 === zeroAddress;
2348
+ // On --dry-run resolve the minter WITHOUT deploying ensure* would deploy the shared singleton
2349
+ // if absent, and a preview must never send. Fall back to a label if none exists.
2350
+ const dryRun = isDryRun(flags);
2351
+ if (kind.isEdition) {
2352
+ const tokenId = parseEditionCountFlag(requireFlag(flags, 'token-id', usage), 'token-id');
2353
+ const knownMinter = fixedPriceMinter1155Address(flags['minter-contract']);
2354
+ if (dryRun && !knownMinter) {
2355
+ console.log(yellow(` ⚠ no shared edition minter deployed on ${CHAIN} yet — a real run deploys it once (a separate tx) before configuring.`));
2356
+ }
2357
+ const minter = dryRun ? (knownMinter ?? zeroAddress) : await ensureFixedPriceMinter1155(flags['minter-contract']);
2358
+ // Sanity-check the allocation against what THIS id can still mint. `maxSupply(id) === 0` reads
2359
+ // as "open" (the un-overridden --copies default, or an id nobody has ever capped) — the same
2360
+ // convention `tokens.ts`'s TokenRow.maxSupply documents; see set-max-supply's own note on why a
2361
+ // bare 0 can't be told apart from "explicitly closed" without more than this one read.
2362
+ const [maxSupply, supplyNow] = await Promise.all([
2363
+ readEdition(token, 'maxSupply', [tokenId]).catch(() => 0n),
2364
+ readEdition(token, 'totalSupply', [tokenId]).catch(() => 0n),
2365
+ ]);
2366
+ const remaining = maxSupply > 0n ? (maxSupply > supplyNow ? maxSupply - supplyNow : 0n) : null;
2367
+ if (remaining !== null && allocation > remaining) {
2368
+ console.log(yellow(` ⚠ allocation ${allocation} exceeds the ${remaining} #${tokenId} can still mint `) +
2369
+ dim(`(cap ${maxSupply} − ${supplyNow} minted). Only ${remaining} will actually sell.`));
2370
+ console.log(dim(` Selling ${allocation} isn't reachable: this id's cap can only ever DECREASE — set --allocation ${remaining} (or less, to hold reserves).`));
2371
+ }
2372
+ console.log(dim(` configuring sale on ${minter}`));
2373
+ console.log(dim(` token ${token} · id #${tokenId}`));
2374
+ console.log(dim(` price ${isEth ? `${formatEther(price)} ETH` : `${price} units of ${erc20}`} / copy`));
2375
+ console.log(dim(` allocation ${allocation}`));
2376
+ await runMinterWrite(prepareConfigureSale1155({ minter, token, tokenId, paymentToken: erc20, price, allocation, chainId: chainId() }), flags, owner);
2377
+ // Through the EDITION ABI, for the same reason `minter show` spells out: the generic `read()`
2378
+ // is the 721 1/1 ABI, which has no minter()/primaryPayee() at all, so the call throws
2379
+ // client-side and the `.catch` swallows it into zeroAddress. That printed "⚠ assign this
2380
+ // minter" + "⚠ set a primary payee" on an edition that already had both.
2381
+ const [assignedMinter, payee, paused] = await Promise.all([
2382
+ readEdition(token, 'minter').catch(() => zeroAddress),
2383
+ readEdition(token, 'primaryPayee').catch(() => zeroAddress),
2384
+ readEdition(token, 'paused').catch(() => false),
2385
+ ]);
2386
+ const assigned = assignedMinter.toLowerCase() === minter.toLowerCase();
2387
+ console.log('');
2388
+ console.log(assigned ? dim(' ✓ minter is assigned on the token') : yellow(` ⚠ assign this minter on the token: abx set-minter ${token} --minter ${minter}`));
2389
+ if (payee === zeroAddress)
2390
+ console.log(yellow(` ⚠ set a primary payee (sales revert without one): abx set-primary-payee ${token} --payee 0x…`));
2391
+ else
2392
+ console.log(dim(` ✓ proceeds → ${payee}`));
2393
+ if (paused)
2394
+ console.log(yellow(` • token is paused — open the sale when ready: abx unpause ${token}`));
2395
+ console.log(dim(` buyers then run: abx minter buy ${token} --token-id ${tokenId} --quantity <n>`));
2396
+ return;
2397
+ }
2398
+ // 721 path (unchanged): --token-id has no meaning — a 721 sale is one project-wide price.
2399
+ if (flags['token-id'] !== undefined) {
2400
+ throw new Error(`--token-id is edition-only (per-(token,id) sales) — ${token} is a ${kind.label} (721), sold as a single project-wide sale. Drop --token-id.`);
2401
+ }
1163
2402
  const knownMinter = fixedPriceMinterAddress(flags['minter-contract']);
1164
2403
  if (dryRun && !knownMinter) {
1165
2404
  console.log(yellow(` ⚠ no shared minter deployed on ${CHAIN} yet — a real run deploys it once (a separate tx) before configuring.`));
1166
2405
  }
1167
2406
  const minter = dryRun ? (knownMinter ?? zeroAddress) : await ensureFixedPriceMinter(flags['minter-contract']);
1168
- const isEth = erc20 === zeroAddress;
1169
2407
  // Sanity-check the allocation against what the contract can actually mint. A creator who sets
1170
2408
  // --allocation 100 on a 16-supply Series would only ever sell the remainder (maxInvocations binds
1171
2409
  // tighter than the minter's allocation) and discover it when mint #16 reverts. Warn loudly — but
@@ -1203,19 +2441,67 @@ export async function cmdMinterConfigure(address, flags) {
1203
2441
  console.log(dim(` buyers then run: abx minter buy ${token}`));
1204
2442
  }
1205
2443
  /** `abx minter show <token>` — the sale terms + the token's readiness (assigned? payee? paused?).
1206
- * Read-only; the agent runs it to see exactly what's configured before/after a sale. */
2444
+ * Read-only; the agent runs it to see exactly what's configured before/after a sale. On an
2445
+ * edition, `--token-id` is required and the readout is per-id (copies, not a whole-contract cap). */
1207
2446
  export async function cmdMinterShow(address, flags) {
1208
- const token = requireAddress(address, 'abx minter show <token> [--minter-contract 0x…]');
2447
+ const usage = 'abx minter show <token> [--token-id <n> (editions)] [--minter-contract 0x…]';
2448
+ const token = requireAddress(address, usage);
2449
+ warnStrayFlags(flags, new Set(MINTER_SHOW_FLAGS), 'minter show');
1209
2450
  // Refuse a nonexistent contract like the sibling owner-ops (state/unpause/minter configure) do —
1210
2451
  // otherwise the minter singleton returns zero-config for the unconfigured token and we print a
1211
2452
  // plausible-but-fake `paused: no (open)` readout for an address that has no project at all.
1212
2453
  await assertContractExists(token);
2454
+ const publicClient = makePublicClient({ chainKey: CHAIN });
2455
+ const kind = await detectTokenKind(publicClient, token);
2456
+ if (kind.isEdition) {
2457
+ const tokenId = parseEditionCountFlag(requireFlag(flags, 'token-id', usage), 'token-id');
2458
+ const minter = fixedPriceMinter1155Address(flags['minter-contract']);
2459
+ if (!minter) {
2460
+ console.log(yellow(` no shared edition minter known for ${CHAIN} — configure a sale (deploys it) or set ABX_FIXED_PRICE_MINTER_1155`));
2461
+ return;
2462
+ }
2463
+ const sale = await readSaleConfig1155(publicClient, minter, token, tokenId);
2464
+ // Every read here goes through the EDITION ABI: the generic `read()` is the 721 1/1 ABI,
2465
+ // which has no minter()/primaryPayee() at all; a swallowed client-side throw would print a false
2466
+ // "not assigned / no payee" for a correctly configured edition.
2467
+ const [assignedMinter, payee, paused, maxSupply, supply] = await Promise.all([
2468
+ readEdition(token, 'minter').catch(() => zeroAddress),
2469
+ readEdition(token, 'primaryPayee').catch(() => zeroAddress),
2470
+ readEdition(token, 'paused').catch(() => false),
2471
+ readEdition(token, 'maxSupply', [tokenId]).catch(() => 0n),
2472
+ readEdition(token, 'totalSupply', [tokenId]).catch(() => 0n),
2473
+ ]);
2474
+ const isEth = sale.paymentToken === zeroAddress;
2475
+ const assigned = assignedMinter.toLowerCase() === minter.toLowerCase();
2476
+ console.log(bold(`\n minter sale — ${token} #${tokenId}`));
2477
+ console.log(` shared minter: ${minter}`);
2478
+ if (!sale.configured) {
2479
+ console.log(yellow(` not configured — abx minter configure ${token} --token-id ${tokenId} --price <eth> --allocation <n>`));
2480
+ }
2481
+ else {
2482
+ console.log(` price: ${isEth ? `${formatEther(sale.price)} ETH` : `${sale.price} units of ${sale.paymentToken}`}`);
2483
+ console.log(` allocation: ${sale.sold}/${sale.allocation} sold`);
2484
+ }
2485
+ console.log(` assigned on token: ${assigned ? green('yes') : yellow(`no — abx set-minter ${token} --minter ${minter}`)}`);
2486
+ console.log(` primary payee: ${payee === zeroAddress ? yellow('none — abx set-primary-payee …') : payee}`);
2487
+ console.log(` paused: ${paused ? yellow(`yes — abx unpause ${token}`) : green('no (open)')}`);
2488
+ console.log(` copies: ${supply}${maxSupply > 0n ? `/${maxSupply}` : dim(' (open — no cap)')}`);
2489
+ const remaining = maxSupply > 0n ? (maxSupply > supply ? maxSupply - supply : 0n) : null;
2490
+ if (sale.configured && remaining !== null && sale.allocation > remaining) {
2491
+ console.log(yellow(` ⚠ allocation ${sale.allocation} exceeds the ${remaining} still mintable for #${tokenId} (cap ${maxSupply} − ${supply} minted) — only ${remaining} can actually sell.`));
2492
+ }
2493
+ console.log('');
2494
+ return;
2495
+ }
2496
+ if (flags['token-id'] !== undefined) {
2497
+ throw new Error(`--token-id is edition-only — ${token} is a ${kind.label} (721). Drop --token-id.`);
2498
+ }
1213
2499
  const minter = fixedPriceMinterAddress(flags['minter-contract']);
1214
2500
  if (!minter) {
1215
2501
  console.log(yellow(` no shared minter known for ${CHAIN} — configure a sale (deploys it) or set ABX_FIXED_PRICE_MINTER`));
1216
2502
  return;
1217
2503
  }
1218
- const sale = await readSale(minter, token);
2504
+ const sale = await readSaleConfig(publicClient, minter, token);
1219
2505
  const [assignedMinter, payee, paused, max, supply] = await Promise.all([
1220
2506
  readSeries(token, 'minter').catch(() => zeroAddress),
1221
2507
  readSeries(token, 'primaryPayee').catch(() => zeroAddress),
@@ -1247,24 +2533,189 @@ export async function cmdMinterShow(address, flags) {
1247
2533
  console.log('');
1248
2534
  }
1249
2535
  /** `abx minter buy <token> [--to 0x…]` — buy one token through the shared minter (public; any
1250
- * funded signer). ETH sales attach the price; ERC-20 sales need a prior approval to the minter. */
2536
+ * funded signer). ETH sales attach the price; ERC-20 sales need a prior approval to the minter.
2537
+ * On an edition, `--token-id` is required and `--quantity` (default 1) buys several copies in the
2538
+ * same purchase — the total ETH attached is `price × quantity`. */
1251
2539
  export async function cmdMinterBuy(address, flags) {
1252
- const usage = 'abx minter buy <token> [--to 0x…] [--minter-contract 0x…] [--sign|--unsigned]';
2540
+ const usage = 'abx minter buy <token> [--to 0x…] [--token-id <n> --quantity <n> (editions)] [--minter-contract 0x…] [--sign|--unsigned]';
1253
2541
  const token = requireAddress(address, usage);
2542
+ warnStrayFlags(flags, new Set(MINTER_BUY_FLAGS), 'minter buy');
2543
+ const publicClient = makePublicClient({ chainKey: CHAIN });
2544
+ const kind = await detectTokenKind(publicClient, token);
2545
+ if (kind.isEdition) {
2546
+ const tokenId = parseEditionCountFlag(requireFlag(flags, 'token-id', usage), 'token-id');
2547
+ const quantity = flags.quantity !== undefined ? parseEditionCountFlag(flags.quantity, 'quantity') : 1n;
2548
+ if (quantity === 0n)
2549
+ throw new Error('--quantity must be at least 1 (0 copies is not a purchase).');
2550
+ const minter = fixedPriceMinter1155Address(flags['minter-contract']);
2551
+ if (!minter)
2552
+ throw new Error(`no shared edition minter known for ${CHAIN} — configure a sale first, or set ABX_FIXED_PRICE_MINTER_1155`);
2553
+ const sale = await readSaleConfig1155(publicClient, minter, token, tokenId);
2554
+ if (!sale.configured)
2555
+ throw new Error(`no sale configured for ${token} #${tokenId} — run: abx minter configure ${token} --token-id ${tokenId} …`);
2556
+ const isEth = sale.paymentToken === zeroAddress;
2557
+ const to = flags.to && flags.to !== 'true' ? flags.to : undefined;
2558
+ // Payment math AND the buyer's terms bound both come off `sale` inside the SDK op (one
2559
+ // computation of price × quantity, for the attached ETH and for `maxTotalPrice`) — the terms
2560
+ // just read are the terms the tx commits to, so a mid-flight `configure` reverts instead of
2561
+ // spending more. Shown in the confirm/dry-run readout below (runMinterWrite → gatedSend).
2562
+ const total = sale.price * quantity;
2563
+ if (!isEth) {
2564
+ console.log(yellow(` ERC-20 sale: the buyer must have approved ${minter} to spend ${total} units of ${sale.paymentToken} first (else the tx reverts).`));
2565
+ }
2566
+ console.log(dim(` buying ${quantity} cop${quantity === 1n ? 'y' : 'ies'} of #${tokenId}${to ? ` → ${to}` : ' → signer'} for ` +
2567
+ `${isEth ? `${formatEther(total)} ETH (${formatEther(sale.price)} × ${quantity})` : `${total} units (${sale.price} × ${quantity})`}`));
2568
+ await runMinterWrite(preparePurchase1155({ minter, token, tokenId, quantity, sale, to, chainId: chainId() }), flags);
2569
+ console.log(dim(` next: \`abx refresh ${token}\` so marketplaces pick up the change.`));
2570
+ return;
2571
+ }
2572
+ if (flags['token-id'] !== undefined || flags.quantity !== undefined) {
2573
+ throw new Error(`--token-id/--quantity are edition-only — ${token} is a ${kind.label} (721), where a sale buys exactly one token. Drop them.`);
2574
+ }
1254
2575
  const minter = fixedPriceMinterAddress(flags['minter-contract']);
1255
2576
  if (!minter)
1256
2577
  throw new Error(`no shared minter known for ${CHAIN} — configure a sale first, or set ABX_FIXED_PRICE_MINTER`);
1257
- const sale = await readSale(minter, token);
2578
+ const sale = await readSaleConfig(publicClient, minter, token);
1258
2579
  if (!sale.configured)
1259
2580
  throw new Error(`no sale configured for ${token} — run: abx minter configure ${token} …`);
1260
2581
  const isEth = sale.paymentToken === zeroAddress;
1261
2582
  const to = flags.to && flags.to !== 'true' ? flags.to : undefined;
1262
- const value = isEth ? sale.price : 0n;
1263
2583
  if (!isEth) {
1264
2584
  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).`));
1265
2585
  }
1266
2586
  console.log(dim(` buying 1 token${to ? ` → ${to}` : ' → signer'} for ${isEth ? `${formatEther(sale.price)} ETH` : `${sale.price} units`}`));
1267
- await runMinterWrite(preparePurchase({ minter, token, to, value, chainId: chainId() }), flags);
2587
+ // The terms just read go into the tx as the buyer's bound (SDK op derives value + maxPrice from
2588
+ // `sale`), so an owner who re-`configure`s mid-flight gets a revert, not the buyer's allowance.
2589
+ await runMinterWrite(preparePurchase({ minter, token, sale, to, chainId: chainId() }), flags);
1268
2590
  console.log(dim(` next: \`abx refresh ${token}\` so marketplaces pick up the new token.`));
1269
2591
  }
2592
+ // ── PostParam schemas, after deploy ──────────────────────────────────────────
2593
+ // `setParamSchema` is owner-gated with no deploy-time restriction, so a project's param surface was
2594
+ // never actually frozen at deploy — the toolkit just had no way to reach it, which read to creators
2595
+ // as a protocol limitation ("we must guess every param up front, or redeploy and lose the address").
2596
+ // Two commands close that: `set-schema` (attach or replace one key) and `retire-param` (stop all
2597
+ // further writes, the closest thing to a delete the protocol has).
2598
+ /** Values already stored under a key can be stranded by a schema change — the contract does NOT
2599
+ * re-validate them. Compare old vs new and name what would break, so the guard can refuse. */
2600
+ export function strandingRisks(before, after) {
2601
+ const risks = [];
2602
+ const typeName = (i) => PARAM_TYPES[i] ?? String(i);
2603
+ if (before.paramType !== after.paramType) {
2604
+ risks.push(`type ${typeName(before.paramType)} → ${typeName(after.paramType)} (a stored value keeps its old encoding)`);
2605
+ }
2606
+ const dropped = before.selectOptions.filter((o) => !after.selectOptions.includes(o));
2607
+ if (before.selectOptions.length && dropped.length) {
2608
+ risks.push(`Select option(s) removed: ${dropped.join(', ')} (a token already set to one keeps it)`);
2609
+ }
2610
+ // Narrowing either bound can strand a value that sat inside the old range.
2611
+ const asInt = (h) => (PARAM_TYPES[after.paramType] === 'Int256Range' ? BigInt.asIntN(256, BigInt(h)) : BigInt(h));
2612
+ if (before.min !== after.min && asInt(after.min) > asInt(before.min))
2613
+ risks.push(`min raised (${asInt(before.min)} → ${asInt(after.min)})`);
2614
+ if (before.max !== after.max && asInt(before.max) !== 0n && asInt(after.max) < asInt(before.max)) {
2615
+ risks.push(`max lowered (${asInt(before.max)} → ${asInt(after.max)})`);
2616
+ }
2617
+ return risks;
2618
+ }
2619
+ /** `abx set-schema <address> --schema key:Type:Auth[:lock=<when>]` — attach or replace ONE key's
2620
+ * on-chain schema, any time in a project's life. Owner-only. */
2621
+ export async function cmdSetSchema(address, flags) {
2622
+ const usage = 'abx set-schema <address> --schema key:Type:Auth[:lock=<when>] [--force] [--dry-run] [--sign|--unsigned]';
2623
+ const contract = requireAddress(address, usage);
2624
+ const specs = parseSchemaSpecs(flags.schema);
2625
+ if (specs.length !== 1) {
2626
+ console.error(`usage: ${usage}\n\n One key per call — a schema write is a full-row upsert, so batching them hides which one changed.\n`);
2627
+ process.exitCode = 1;
2628
+ return;
2629
+ }
2630
+ const next = specs[0];
2631
+ const publicClient = makePublicClient({ chainKey: CHAIN });
2632
+ await assertHasParamsSurface(publicClient, contract, 'abx set-schema');
2633
+ const owner = await read(contract, 'owner');
2634
+ const before = await readParamSchema(publicClient, contract, next.key);
2635
+ if (!before.exists) {
2636
+ console.log(` ${next.key} ${dim('— new governed key')} ${describeSchema(next)}`);
2637
+ }
2638
+ else {
2639
+ // The upsert hazard: this is a FULL row write, so anything not restated is overwritten. Show
2640
+ // both sides, and refuse a change that could strand values unless the caller insists.
2641
+ console.log(` ${next.key} ${dim('— replacing an existing schema')}`);
2642
+ console.log(` ${dim('before')} ${describeSchema(onChainToParsed(next.key, before))}`);
2643
+ console.log(` ${dim('after ')} ${describeSchema(next)}`);
2644
+ const risks = strandingRisks(before, next);
2645
+ if (risks.length && flags.force === undefined) {
2646
+ throw new Error(`refusing to replace "${next.key}" — this change can strand values already stored under it:\n` +
2647
+ risks.map((r) => ` • ${r}`).join('\n') +
2648
+ `\n\n The contract does NOT re-validate stored values against a new schema, so affected tokens would keep\n` +
2649
+ ` values their own schema no longer allows. Re-run with --force if that is what you intend.`);
2650
+ }
2651
+ // With --force, say what is being overridden. Silently applying a value-stranding change is the
2652
+ // one outcome worse than refusing it: the operator gets no record of which tokens they may have
2653
+ // just invalidated, and neither does anyone reading the terminal afterwards.
2654
+ if (risks.length) {
2655
+ console.log(` ${C.yellow}⚠${C.reset} ${bold('--force')} — applying a change that can strand stored values:`);
2656
+ for (const r of risks)
2657
+ console.log(` • ${r}`);
2658
+ console.log(dim(` any token already holding a value for "${next.key}" keeps it, now outside what its schema allows.`));
2659
+ }
2660
+ if (before.lockAfter && !next.lockAfter) {
2661
+ console.log(` ${C.yellow}⚠${C.reset} the existing lock (${new Date(before.lockAfter * 1000).toISOString().slice(0, 19)}Z) is being REMOVED — restate it with :lock= to keep it.`);
2662
+ }
2663
+ }
2664
+ // ONE op. A schema write used to need a `params.keys` companion write in the same tx to keep the
2665
+ // on-chain generator's key list in step; the generator now enumerates params from the token
2666
+ // itself, so the schema write is the whole change.
2667
+ await runWrite(contract, prepareSetParamSchema({
2668
+ contract,
2669
+ key: next.key,
2670
+ paramType: next.paramType,
2671
+ auth: next.auth,
2672
+ authAddress: next.authAddress,
2673
+ lockAfter: next.lockAfter,
2674
+ min: next.min,
2675
+ max: next.max,
2676
+ selectOptions: next.selectOptions,
2677
+ chainId: chainId(),
2678
+ display: describeSchema(next),
2679
+ }), flags, owner);
2680
+ }
2681
+ /** Render an on-chain schema through the same formatter the CLI uses for a parsed one. */
2682
+ function onChainToParsed(key, s) {
2683
+ return {
2684
+ key,
2685
+ paramType: s.paramType,
2686
+ auth: s.auth,
2687
+ authAddress: s.authAddress,
2688
+ lockAfter: s.lockAfter,
2689
+ min: s.min,
2690
+ max: s.max,
2691
+ selectOptions: s.selectOptions,
2692
+ };
2693
+ }
2694
+ /** `abx retire-param <address> <key>` — permanently stop further writes to a PostParam. */
2695
+ export async function cmdRetireParam(address, rest, flags) {
2696
+ const usage = 'abx retire-param <address> <key> [--dry-run] [--sign|--unsigned]';
2697
+ const contract = requireAddress(address, usage);
2698
+ const [key] = positionalArgs(rest);
2699
+ if (!key) {
2700
+ console.error(`usage: ${usage}\n`);
2701
+ process.exitCode = 1;
2702
+ return;
2703
+ }
2704
+ const publicClient = makePublicClient({ chainKey: CHAIN });
2705
+ await assertHasParamsSurface(publicClient, contract, 'abx retire-param');
2706
+ const owner = await read(contract, 'owner');
2707
+ const current = await readParamSchema(publicClient, contract, key);
2708
+ if (!current.exists)
2709
+ throw new Error(`no PostParam schema for "${key}" on ${contract} — nothing to retire.`);
2710
+ if (current.lockAfter && current.lockAfter < Math.floor(Date.now() / 1000)) {
2711
+ console.log(` ${C.green}✓${C.reset} "${key}" is already retired (locked ${new Date(current.lockAfter * 1000).toISOString().slice(0, 19)}Z). Nothing to do.`);
2712
+ return;
2713
+ }
2714
+ console.log(` ${key} ${dim(describeSchema(onChainToParsed(key, current)))}`);
2715
+ console.log(` ${dim('after this: every write reverts ParamLockExpired — permanently, with no way back.')}`);
2716
+ console.log(` ${dim('the schema stays (a key can never be un-governed), and any value ALREADY stored stays and keeps serving.')}`);
2717
+ // Read-modify-write: carry every field forward and change only the lock. Composing a fresh schema
2718
+ // here would silently reset type/auth/bounds/options — the upsert clobber this command exists to avoid.
2719
+ await runWrite(contract, prepareRetireParam({ contract, key, current, chainId: chainId() }), flags, owner);
2720
+ }
1270
2721
  //# sourceMappingURL=ownerops.js.map