@artblocks/abx-cli 0.1.0-alpha.13 → 0.1.0-alpha.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ownerops.js CHANGED
@@ -6,18 +6,18 @@
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, prepareSetParamSchema, prepareMulticall, prepareRetireParam, readParamSchema, PARAM_TYPES, prepareSetRoyalty, prepareSetTokenField, prepareSetTokenURIBase, prepareSetTokenURIOverride, prepareSetTokenURIRenderer, prepareTransfer, prepareTransferOwnership, resolveChain, resolveRpcUrl, redactRpcUrl, DEFAULT_CHAIN_KEY, stageContent, storeSupportsWriteContent, encodeTag, METADATA_FIELD as F, METADATA_REPRESENTATION as R, } from '@artblocks/abx-sdk';
9
+ import { assertChainId, ensureChunkStore as sdkEnsureChunkStore, 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, prepareSetParamSchema, prepareRetireParam, readParamSchema, PARAM_TYPES, prepareSetRoyalty, prepareSetTokenField, prepareSetTokenURIBase, prepareSetTokenURIOverride, prepareSetTokenURIRenderer, prepareSetTransferValidator, prepareTransfer, prepareTransferOwnership, readCreatorTokenStatus, resolveRecommendedTransferValidator, RECOMMENDED_TRANSFER_VALIDATOR, KNOWN_CHAIN_KEYS, resolveChain, resolveRpcUrl, redactRpcUrl, DEFAULT_CHAIN_KEY, stageContent, encodeTag, METADATA_FIELD as F, METADATA_REPRESENTATION as R, } from '@artblocks/abx-sdk';
10
10
  import { SelfHostIndexer } from '@artblocks/abx-indexer';
11
11
  import { encodeScalarParam, encodeTag as encodeTagSdk, isAccepted, prepareConfigureTokenParam, prepareConfigureTokenParamData, prepareSetContractParam, prepareSetContractParamData, seriesCodeAbi, } from '@artblocks/abx-sdk';
12
- import { paramsKeysIsLiteral, paramsKeysNudge, readParamsKeys, PARAMS_KEYS } from './onchain-uri.js';
13
- import { DEP_RESOLUTION, parseDependencyRef, prepareLockDependencies, prepareRemoveLastDependency, prepareSetDependency, prepareSetDependencyRegistry, } from '@artblocks/abx-sdk';
12
+ import { hasParamEnumeration } from './onchain-uri.js';
13
+ import { decodeFieldRenderer, resolveGenerator, DEP_RESOLUTION, parseDependencyRef, prepareLockDependencies, prepareRemoveLastDependency, prepareSetDependency, prepareSetDependencyRegistry, } from '@artblocks/abx-sdk';
14
14
  import { contentTypeFromPath } from '@artblocks/abx-storage';
15
15
  import { toHex as toHexSdk } from 'viem';
16
16
  import { readFileSync } from 'node:fs';
17
17
  import { basename, resolve as resolvePath } from 'node:path';
18
18
  import { gzipSync } from 'node:zlib';
19
19
  import { formatEther, getAddress, isAddress, parseEther, toHex, zeroAddress } from 'viem';
20
- import { chunkStoreAddress, fixedPriceMinterAddress } from './config.js';
20
+ import { fixedPriceMinterAddress } from './config.js';
21
21
  import { openWalletSession, signTx } from './signer.js';
22
22
  import { resolveRemote, serviceClient } from './remote.js';
23
23
  import { unknownFlags } from './flags.js';
@@ -34,17 +34,28 @@ const bold = (s) => `${C.bold}${s}${C.reset}`;
34
34
  // COMPLETE set — but it's COMPUTED by the resolver/renderer from the token's fields + effect
35
35
  // outputs; it is never a field you set. These keys are that computed output, so setting them by
36
36
  // hand would only pollute the manifest with a bogus entry (a real round-1 agent trap). Refuse them
37
- // and point at the real verb. See specs/protocol/data-plane.md.
38
- const COMPUTED_FIELD_KEYS = new Set(['artifacts', 'abx_provenance']);
37
+ // and point at the real verb. `abx_params` joins them for the same reason: it is COMPUTED from the
38
+ // token's on-chain param enumeration, so a hand-set field of that name would be a decoy sitting
39
+ // beside the real block. See specs/protocol/data-plane.md + specs/protocol/onchain-metadata.md.
40
+ const COMPUTED_FIELD_KEYS = new Set(['artifacts', 'abx_params', 'abx_provenance']);
39
41
  export function assertSettableField(field) {
40
42
  if (COMPUTED_FIELD_KEYS.has(field)) {
41
43
  const what = field === 'artifacts'
42
44
  ? "the COMPUTED manifest (the complete list of this token's files), assembled by the resolver/renderer from your fields"
43
- : 'the COMPUTED provenance list, assembled by the resolver/renderer';
44
- throw new Error(`"${field}" is not a field you set — it's ${what}. To attach a file so it appears in the manifest, ` +
45
- `pick your OWN key:\n abx attach <address> <yourkey> <ipfs://… | ar://… | https://…>`);
45
+ : field === 'abx_params'
46
+ ? "the COMPUTED parameter block, enumerated from the token's on-chain params (set one with `abx configure-param`, declare one with `abx set-schema`)"
47
+ : 'the COMPUTED provenance list, assembled by the resolver/renderer';
48
+ throw new Error(`"${field}" is not a field you set — it's ${what}.` +
49
+ (field === 'abx_params'
50
+ ? `\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://…>)`
51
+ : ` To attach a file so it appears in the manifest, pick your OWN key:\n abx attach <address> <yourkey> <ipfs://… | ar://… | https://…>`));
46
52
  }
47
53
  }
54
+ /** Whether a value fits the literal `bytes32` lane of a raw contract-param write (printable ASCII,
55
+ * ≤ 31 bytes). Anything longer takes the data path — one blob, its hash evented. */
56
+ function fitsLiteralBytes32(value) {
57
+ return value.length <= 31 && /^[\x20-\x7e]+$/.test(value);
58
+ }
48
59
  /** Flags `abx attach` recognizes — anything else warns (non-fatal), so a silent no-op flag surfaces. */
49
60
  const ATTACH_FLAGS = ['file', 'compress', 'collection', 'token', 'send', 'sign', 'unsigned', 'yes', 'dry-run', 'port', 'sign-url-file', 'remote'];
50
61
  /** Auto-detect the on-chain representation for an off-chain locator by its URI scheme. Returns null
@@ -240,8 +251,9 @@ export async function cmdConfigureParam(address, rest, flags) {
240
251
  }));
241
252
  const [exists, paramTypeIdx, , , , , , selectOptions] = schema;
242
253
  // 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.
254
+ // well-known contract params like `display.gateway`. Schema-less keys only: a schema'd key closes
255
+ // the raw path on-chain (`SchemaGoverned`) and is per-token by design. No key is special here: a
256
+ // contract param is enumerated on-chain like any other and lands in tokenData for every token.
245
257
  if (tokenIdRaw === '-') {
246
258
  if (exists) {
247
259
  throw new Error(`"${key}" is schema-governed — it's a per-token PostParam (abx configure-param ${contract} <tokenId> ${key} …). ` +
@@ -254,9 +266,9 @@ export async function cmdConfigureParam(address, rest, flags) {
254
266
  await runWrite(contract, prepareSetContractParamData({ contract, key, data, chainId: cid }), flags, owner);
255
267
  return;
256
268
  }
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)) {
269
+ // Printable ASCII 31 chars rides as a literal readable bytes32; anything longer takes the
270
+ // data path (one blob, hash evented).
271
+ if (fitsLiteralBytes32(valueInput)) {
260
272
  console.log(` ${key} (contract scope) ← "${valueInput}" ${dim('(literal bytes32; owner-only raw setter)')}`);
261
273
  await runWrite(contract, prepareSetContractParam({ contract, key, value: encodeTagSdk(valueInput), display: valueInput, chainId: cid }), flags, owner);
262
274
  }
@@ -285,22 +297,7 @@ export async function cmdConfigureParam(address, rest, flags) {
285
297
  console.log(` ${key} (${typeName}) ← ${display} ${dim('(canonical encode; the chain enforces schema + auth)')}`);
286
298
  sent = await runWrite(contract, prepareConfigureTokenParam({ contract, tokenId, key, value, display, chainId: chainId() }), flags);
287
299
  }
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
300
  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
301
  // What happens to the IMAGE depends on how it's produced. If the `image` field is computed
305
302
  // on-chain (a `renderer` representation — the in-chain SVG lane), it re-tints AUTOMATICALLY: the
306
303
  // renderer reads the param live, so tokenURI's image is already updated, nothing to re-render.
@@ -551,15 +548,29 @@ export async function cmdSetPrimaryPayee(address, flags) {
551
548
  // marketplaces fetch the on-chain tokenURI; if the resolver wasn't warm they cache
552
549
  // a miss until refreshed. We emit ERC-4906 on URI changes (4906-aware marketplaces
553
550
  // 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']);
551
+ /**
552
+ * OpenSea's own chain slugs, keyed by ours. They are NOT our keys (`base-sepolia` is `base_sepolia`
553
+ * there) and not derivable from viem, so the map is unavoidable — but a chain missing from it must
554
+ * produce NO link rather than a wrong one, which is what `osChain ?? CHAIN` used to do.
555
+ *
556
+ * It previously listed only `sepolia` and `mainnet`, so the CLI's own DEFAULT chain (`base-sepolia`)
557
+ * fell through to the raw key: the refresh POST went to `/chain/base-sepolia/…` (a slug OpenSea does
558
+ * not know) and the printed link pointed at **mainnet** `opensea.io` for a testnet token. Same shape
559
+ * as the hardcoded explorer table that once sent every Base Sepolia link to Etherscan — hence
560
+ * `testnet` now comes from the chain registry instead of a second hand-maintained set.
561
+ */
562
+ const OPENSEA_CHAIN = { 'base-sepolia': 'base_sepolia', sepolia: 'sepolia' };
556
563
  export async function cmdRefresh(address, flags) {
557
564
  const contract = requireAddress(address, 'abx refresh <address> [--token 0]');
558
565
  const tokenId = flags.token ?? '0';
559
- const osChain = OPENSEA_CHAIN[CHAIN] ?? CHAIN;
560
- const explorer = resolveChain(CHAIN).blockExplorers?.default?.url ?? '';
566
+ const chain = resolveChain(CHAIN);
567
+ const osChain = OPENSEA_CHAIN[CHAIN];
568
+ const explorer = chain.blockExplorers?.default?.url ?? '';
561
569
  const apiKey = process.env.OPENSEA_API_KEY;
562
- if (apiKey) {
570
+ if (!osChain) {
571
+ 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).`));
572
+ }
573
+ else if (apiKey) {
563
574
  const url = `https://api.opensea.io/api/v2/chain/${osChain}/contract/${contract}/nfts/${tokenId}/refresh`;
564
575
  try {
565
576
  const res = await fetch(url, { method: 'POST', headers: { 'x-api-key': apiKey, accept: 'application/json' } });
@@ -575,8 +586,10 @@ export async function cmdRefresh(address, flags) {
575
586
  else {
576
587
  console.log(dim(' no OPENSEA_API_KEY set — open these and click “Refresh metadata”:'));
577
588
  }
578
- const osBase = TESTNET_CHAINS.has(CHAIN) ? 'https://testnets.opensea.io' : 'https://opensea.io';
579
- console.log(` ${dim('OpenSea ')}${osBase}/assets/${osChain}/${contract}/${tokenId}`);
589
+ // `testnet` from the chain registry (viem), never a local set — that is the drift this bug was.
590
+ const osBase = chain.testnet ? 'https://testnets.opensea.io' : 'https://opensea.io';
591
+ if (osChain)
592
+ console.log(` ${dim('OpenSea ')}${osBase}/assets/${osChain}/${contract}/${tokenId}`);
580
593
  if (explorer)
581
594
  console.log(` ${dim('Etherscan')} ${explorer}/token/${contract}?a=${tokenId}`);
582
595
  console.log(dim(' (ERC-4906 already pings 4906-aware marketplaces on URI changes; this covers the genesis mint + the rest.)'));
@@ -648,6 +661,112 @@ export async function cmdSetRoyalty(address, flags) {
648
661
  }
649
662
  await runWrite(contract, prepareSetRoyalty({ contract, receiver, bps, chainId: chainId() }), flags, owner);
650
663
  }
664
+ // ── ERC-721C (creator token) — the transfer-validator surface ─────────────────
665
+ // Enrollment is a DEPLOY-TIME decision (`--721c` on the deploy commands) and permanent in both
666
+ // directions: an unenrolled token can never gain a validator, an enrolled one never sheds the
667
+ // standard. Within an enrolled token the owner re-points or suspends (zero) the validator freely.
668
+ /** The chain keys the manifest recommends a transfer validator for — for refusal messages. */
669
+ function chainsWithRecommendedValidator() {
670
+ const ids = new Set(Object.keys(RECOMMENDED_TRANSFER_VALIDATOR).map(Number));
671
+ return KNOWN_CHAIN_KEYS.filter((k) => ids.has(resolveChain(k).id)).join(', ');
672
+ }
673
+ /**
674
+ * Parse a transfer-validator choice — the shared grammar of the deploy flag (`--721c`) and the
675
+ * owner op (`abx set-transfer-validator`):
676
+ * - `recommended` (or a bare `--721c`) → the per-chain recommended validator, refusing on a
677
+ * chain the manifest has no entry for (naming the chains that do — never guess one);
678
+ * - a `0x…` address → checksum-validated (EIP-55) and returned as its canonical form;
679
+ * - `none`/zero → `allowNone` decides: the owner op suspends with it; the DEPLOY flag refuses
680
+ * it (a zero validator at deploy never enrolls — that is already the default, so someone
681
+ * passing it either wants plain ERC-721 (drop the flag) or mistakenly believes "enrolled but
682
+ * suspended" is a deploy-time state — it isn't).
683
+ * Pure (no RPC) — callers do the has-code precheck themselves. Exported for the regression test.
684
+ */
685
+ export function parseTransferValidatorValue(raw, opts) {
686
+ const s = raw.trim().toLowerCase();
687
+ if (s === 'true' || s === '' || s === 'recommended') {
688
+ const rec = resolveRecommendedTransferValidator(opts.chainId);
689
+ if (!rec) {
690
+ throw new Error(`no recommended transfer validator is known for '${opts.chainLabel}' (chainId ${opts.chainId}) — ` +
691
+ `chains with one: ${chainsWithRecommendedValidator() || '(none shipped)'}. ` +
692
+ `Pass an explicit validator address instead (it must be a deployed contract on this chain).`);
693
+ }
694
+ return rec;
695
+ }
696
+ if (s === 'none' || s === 'zero' || s === '0' || s === '0x0' || s === zeroAddress) {
697
+ if (opts.allowNone)
698
+ return zeroAddress;
699
+ throw new Error(`a zero transfer validator never enrolls — plain ERC-721 is already the default, so drop --721c. ` +
700
+ `("enrolled but suspended" is not a deploy-time state: enroll with a real validator, then suspend ` +
701
+ `with \`abx set-transfer-validator <address> none\`.)`);
702
+ }
703
+ const trimmed = raw.trim();
704
+ if (!/^0x[0-9a-fA-F]{40}$/.test(trimmed)) {
705
+ throw new Error(`transfer validator must be 'recommended'${opts.allowNone ? ", 'none'," : ''} or a 0x address (0x + 40 hex); got '${raw}'`);
706
+ }
707
+ // `isAddress` strict-validates the EIP-55 checksum of a mixed-case address (all-lowercase carries
708
+ // no checksum and passes); `getAddress` alone only NORMALIZES — it would silently accept a
709
+ // mis-cased paste, which is exactly the transposition this check exists to catch.
710
+ if (!isAddress(trimmed, { strict: true })) {
711
+ throw new Error(`transfer validator address failed its EIP-55 checksum: '${raw}' — paste it exactly (or all-lowercase).`);
712
+ }
713
+ return getAddress(trimmed);
714
+ }
715
+ /**
716
+ * `abx set-transfer-validator <address> <0x…|none|recommended>` — re-point an ENROLLED (ERC-721C)
717
+ * collection's transfer validator, or suspend enforcement with `none` (address(0); the token
718
+ * STAYS enrolled). Refuses up front, before any signing: a plain ERC-721 (enrollment is
719
+ * deploy-time-only — the contract would revert `NotCreatorToken()`), and a codeless validator
720
+ * (would revert `InvalidTransferValidator()`). Owner-only, any lane, guards `--dry-run`.
721
+ */
722
+ export async function cmdSetTransferValidator(address, rest, flags) {
723
+ const usage = 'abx set-transfer-validator <address> <0x…|none|recommended> [--sign|--unsigned] [--dry-run]';
724
+ const contract = requireAddress(address, usage);
725
+ const [raw] = positionalArgs(rest);
726
+ if (!raw) {
727
+ console.error(`usage: ${usage}\n`);
728
+ process.exit(1);
729
+ }
730
+ const cid = chainId();
731
+ const validator = parseTransferValidatorValue(raw, { chainId: cid, chainLabel: CHAIN, allowNone: true });
732
+ const publicClient = makePublicClient({ chainKey: CHAIN });
733
+ // Enrollment guard FIRST — a read, not a send. An unenrolled token would revert
734
+ // `NotCreatorToken()`; refuse with the real story instead of letting the chain say it in hex.
735
+ // (readCreatorTokenStatus is defensive, so check the contract exists first — a typo'd address
736
+ // must not read as "plain ERC-721".)
737
+ await assertContractExists(contract);
738
+ const status = await readCreatorTokenStatus(publicClient, contract);
739
+ if (!status.enrolled) {
740
+ throw new Error(`${contract} is a plain ERC-721 — 721C is a deploy-time decision, and this collection didn't enroll. ` +
741
+ `Enrollment can never be added to a live collection; if enforcement is required, redeploy with ` +
742
+ `--721c recommended (or --721c 0x…) on the deploy command.`);
743
+ }
744
+ // Codeless-validator guard: the contract refuses a non-zero validator with no code
745
+ // (`InvalidTransferValidator()`) — surface it before gas is spent.
746
+ if (validator !== zeroAddress) {
747
+ let code;
748
+ try {
749
+ code = await publicClient.getCode({ address: validator });
750
+ }
751
+ catch (err) {
752
+ throw new Error(`couldn't verify the validator has code at ${validator} (${err.message}) — refusing to re-point blind; retry when the RPC answers.`);
753
+ }
754
+ if (!code || code === '0x') {
755
+ const rec = resolveRecommendedTransferValidator(cid);
756
+ throw new Error(`no contract code at ${validator} on ${CHAIN} — the token would revert InvalidTransferValidator(). ` +
757
+ `A transfer validator must be a DEPLOYED contract on this chain` +
758
+ (rec ? ` (the recommended one: \`abx set-transfer-validator ${contract} recommended\` → ${rec})` : '') + `.`);
759
+ }
760
+ }
761
+ if (eqAddr(status.validator, validator)) {
762
+ console.log(dim(` no change — the validator is already ${validator === zeroAddress ? 'suspended (0x0)' : validator}. Nothing sent.`));
763
+ return;
764
+ }
765
+ const label = (a) => (eqAddr(a, zeroAddress) ? 'suspended (0x0)' : a);
766
+ console.log(dim(` transfer validator: ${label(status.validator)} → ${label(validator)}${validator === zeroAddress ? ' — transfers go unvalidated until one is set again (the token stays enrolled)' : ''}`));
767
+ const owner = await read(contract, 'owner');
768
+ await runWrite(contract, prepareSetTransferValidator({ contract, validator, chainId: cid }), flags, owner);
769
+ }
651
770
  export function parseCompress(v) {
652
771
  const c = (v ?? 'none').toLowerCase();
653
772
  if (c === 'none' || c === 'fastlz' || c === 'gzip')
@@ -660,37 +779,27 @@ export function parseCompress(v) {
660
779
  * store is ownerless, so any funded signer can stand it up.
661
780
  */
662
781
  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);
782
+ // The resolution logic lives in the SDK (`ensureChunkStore`) so an SDK integrator bootstraps
783
+ // identically instead of hand-rolling the "is this store capable?" guard forget it and an
784
+ // incapable store fails deep inside a mint, after transactions have landed. The CLI keeps only the
785
+ // narration: the SDK reports progress through `onEvent` rather than printing.
666
786
  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;
787
+ return sdkEnsureChunkStore(publicClient, send, {
788
+ chainId: chainId(),
789
+ override,
790
+ onEvent: (e) => {
791
+ if (e.kind === 'stale')
792
+ console.log(yellow(` configured chunk store ${e.address} is a stale deployment (no writeContent) — deploying a current one`));
793
+ else if (e.kind === 'canonical')
794
+ console.log(dim(` using the canonical chunk store at its deterministic address ${e.address}`));
795
+ else if (e.kind === 'deploying')
796
+ console.log(dim(' deploying the canonical multi-chunk content store (AbxChunkStore) — CREATE2…'));
797
+ else {
798
+ console.log(` ${green('✓')} chunk store ${e.address}`);
799
+ 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)`));
800
+ }
801
+ },
802
+ });
694
803
  }
695
804
  /**
696
805
  * The hot-lane staging signer: the env key signs + broadcasts each chunk-store write. Built
@@ -845,6 +954,55 @@ export async function stageImageFieldsBatch(imagePaths, compress, send, storeOve
845
954
  }
846
955
  return { fields, store };
847
956
  }
957
+ // ── the generator repoint guard ───────────────────────────────────────────────
958
+ // The canonical AbxGenerator reads a token's param surface FROM CHAIN (`tokenParamKeys` /
959
+ // `contractParamKeys`). A LEGACY implementation — deployed before enumeration shipped — has neither
960
+ // getter, so the generator finds nothing: every configured param vanishes from tokenData and from
961
+ // the live view, silently, behind a tokenURI that still looks perfectly healthy. That is the exact
962
+ // failure class this toolkit refuses rather than warns about, so pointing a legacy token at the
963
+ // current generator is REFUSED. (Repointing the metadata RENDERER is safe and unguarded: a v4
964
+ // renderer on a legacy token just emits no params block.)
965
+ /** The field-renderer address a `--value` names: the canonical `abi.encode(address)` (32 bytes),
966
+ * or a bare 20-byte address. Null when it is neither. */
967
+ function fieldRendererTarget(value) {
968
+ const hex = value.trim();
969
+ if (!/^0x[0-9a-fA-F]*$/.test(hex))
970
+ return null;
971
+ if (hex.length === 42)
972
+ return getAddress(hex);
973
+ if (hex.length !== 66)
974
+ return null;
975
+ try {
976
+ return decodeFieldRenderer(hex);
977
+ }
978
+ catch {
979
+ return null;
980
+ }
981
+ }
982
+ export async function assertGeneratorRepointable(contract, field, flags,
983
+ /** Injected for tests; the real probe is one eth_call. */
984
+ probe = (c) => hasParamEnumeration(makePublicClient({ chainKey: CHAIN }), c)) {
985
+ if (flags.representation !== R.renderer || !flags.value || flags.value === 'true')
986
+ return;
987
+ const target = fieldRendererTarget(flags.value);
988
+ const generator = resolveGenerator(chainId());
989
+ // Only the CANONICAL generator is guarded — any other field renderer is the creator's own contract
990
+ // and none of our business.
991
+ if (!target || !generator || target.toLowerCase() !== generator.toLowerCase())
992
+ return;
993
+ // The `owner` read has already succeeded by the time this runs, so the RPC is proven reachable:
994
+ // a failing probe here means the getter is absent, not that the node is down.
995
+ if (await probe(contract))
996
+ return;
997
+ throw new Error(`refusing to point ${field} at the canonical generator ${generator} — ${contract} does not expose the param\n` +
998
+ ` enumeration surface (tokenParamKeys/contractParamKeys), so it is a LEGACY implementation. The current\n` +
999
+ ` generator reads params FROM CHAIN, so on this token it would read NOTHING: every configured param would\n` +
1000
+ ` silently disappear from tokenData, the live view, and every render — with a tokenURI that still looks fine.\n\n` +
1001
+ ` Two honest options:\n` +
1002
+ ` • stay on the generator this project already uses (pass that address as --value; it reads the project's\n` +
1003
+ ` params.keys list, which is how it has always worked here), or\n` +
1004
+ ` • redeploy the project with the current \`abx deploy-code\` — new projects enumerate on-chain and need no list.`);
1005
+ }
848
1006
  // ── set-field ──────────────────────────────────────────────────────────────--
849
1007
  // Set an on-chain metadata field. `--field` is what (e.g. image, description),
850
1008
  // `--representation` is how it's carried (default `inline` for --text, `keccak256`
@@ -859,6 +1017,7 @@ export async function cmdSetField(address, flags) {
859
1017
  const collection = !!flags.collection;
860
1018
  const lane = laneFromFlags(flags);
861
1019
  const owner = await read(contract, 'owner');
1020
+ await assertGeneratorRepointable(contract, field, flags); // legacy impl + the current generator = params silently invisible
862
1021
  const staging = !!(flags.file && flags.file !== 'true'); // large content ON-CHAIN via chunk store/reader
863
1022
  // --file + --dry-run: preview the on-chain staging plan and store/send NOTHING. (The locator/text
864
1023
  // path flows through runWrite, which previews there; staging must short-circuit before any upload.)
@@ -988,9 +1147,25 @@ export async function cmdAttach(rest, flags) {
988
1147
  dim('Point the URI at the file itself (…/master.tiff, …/coa.pdf) so collectors get the right type.'));
989
1148
  }
990
1149
  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.`));
1150
+ // Where it surfaces. This used to be one dim line, and it read as a footnote rather than as a
1151
+ // dependency: an integrator attached five audio stems to a fully-on-chain token, paid to store
1152
+ // them, and found `tokenURI` listed none of them "paid for, stored on-chain, and invisible".
1153
+ // The on-chain renderer deliberately omits locator-represented artifacts (they duplicate no
1154
+ // on-chain type information — see specs/protocol/data-plane.md), so the artifacts manifest comes
1155
+ // from a RESOLVER. When the project has no resolver baked in, that is not a footnote, it is the
1156
+ // difference between a feature working and not existing, so say it as a warning.
1157
+ const uriBase = await read(contract, 'tokenURIBase').catch(() => '');
1158
+ const artifactsPath = `/t/${chainId()}/${contract}/${flags.token ?? '0'}`;
1159
+ if (uriBase && uriBase.trim() !== '') {
1160
+ console.log(dim(` → listed in this project's resolver artifacts (${artifactsPath} and /data/${key}); a bare on-chain tokenURI carries reserved fields plus abx_params only.`));
1161
+ }
1162
+ else {
1163
+ console.log(yellow(' ⚠ ') +
1164
+ `this project resolves ON-CHAIN (no resolver base baked in), and the on-chain document carries reserved fields plus the computed ${bold('abx_params')} block only — ` +
1165
+ `so ${bold(key)} will NOT appear in ${bold('tokenURI')}. The bytes are stored and provable, but nothing surfaces them to a marketplace or wallet. ` +
1166
+ dim('(Configured params DO appear on-chain — attachments are the surface that needs a resolver.)'));
1167
+ 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}.`));
1168
+ }
994
1169
  const buildTx = () => collection
995
1170
  ? prepareSetContractField({ contract, field: key, representation, value: toHex(uri), chainId: chainId() })
996
1171
  : prepareSetTokenField({ contract, tokenId: BigInt(flags.token ?? '0'), field: key, representation, value: toHex(uri), chainId: chainId() });
@@ -1001,7 +1176,7 @@ export async function cmdAttach(rest, flags) {
1001
1176
  dim(` verify (a resolver serves the complete listing): `) +
1002
1177
  `curl <your-resolver>/t/${chainId()}/${contract}/${id}` +
1003
1178
  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`));
1179
+ dim(` (The complete file listing is a resolver surface — the bare on-chain tokenURI enumerates reserved fields plus abx_params. Params never need a resolver; attachments do.)\n`));
1005
1180
  }
1006
1181
  }
1007
1182
  // ── lock-field ───────────────────────────────────────────────────────────────
@@ -1273,25 +1448,6 @@ export async function cmdMinterBuy(address, flags) {
1273
1448
  // as a protocol limitation ("we must guess every param up front, or redeploy and lose the address").
1274
1449
  // Two commands close that: `set-schema` (attach or replace one key) and `retire-param` (stop all
1275
1450
  // further writes, the closest thing to a delete the protocol has).
1276
- /**
1277
- * The `params.keys` companion write for a newly governed key, or null when none is needed.
1278
- *
1279
- * Pure so it can be tested without a chain: the decision (does this project even use the on-chain
1280
- * generator? is the key already listed? does the new CSV still fit a literal bytes32?) is the part
1281
- * that goes wrong, not the RPC call. `csv === null` means the project isn't on the on-chain URI lane
1282
- * — a resolver reads schemas directly and needs no key list.
1283
- */
1284
- export function paramsKeysSyncOp(csv, key, contract, cid) {
1285
- const fixed = paramsKeysNudge(csv, key);
1286
- if (fixed === null)
1287
- return null;
1288
- // Past 31 chars the CSV can no longer ride as a literal bytes32 and takes the data path — the same
1289
- // rule `configure-param` and `deploy-code` apply, so all three agree on how a long list is stored.
1290
- const op = paramsKeysIsLiteral(fixed)
1291
- ? prepareSetContractParam({ contract, key: PARAMS_KEYS, value: encodeTagSdk(fixed), display: fixed, chainId: cid })
1292
- : prepareSetContractParamData({ contract, key: PARAMS_KEYS, data: toHexSdk(new TextEncoder().encode(fixed)), chainId: cid });
1293
- return { op, listed: fixed };
1294
- }
1295
1451
  /** Values already stored under a key can be stranded by a schema change — the contract does NOT
1296
1452
  * re-validate them. Compare old vs new and name what would break, so the guard can refuse. */
1297
1453
  export function strandingRisks(before, after) {
@@ -1343,48 +1499,35 @@ export async function cmdSetSchema(address, flags) {
1343
1499
  `\n\n The contract does NOT re-validate stored values against a new schema, so affected tokens would keep\n` +
1344
1500
  ` values their own schema no longer allows. Re-run with --force if that is what you intend.`);
1345
1501
  }
1502
+ // With --force, say what is being overridden. Silently applying a value-stranding change is the
1503
+ // one outcome worse than refusing it: the operator gets no record of which tokens they may have
1504
+ // just invalidated, and neither does anyone reading the terminal afterwards.
1505
+ if (risks.length) {
1506
+ console.log(` ${C.yellow}⚠${C.reset} ${bold('--force')} — applying a change that can strand stored values:`);
1507
+ for (const r of risks)
1508
+ console.log(` • ${r}`);
1509
+ console.log(dim(` any token already holding a value for "${next.key}" keeps it, now outside what its schema allows.`));
1510
+ }
1346
1511
  if (before.lockAfter && !next.lockAfter) {
1347
1512
  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.`);
1348
1513
  }
1349
1514
  }
1350
- const cid = chainId();
1351
- const ops = [
1352
- prepareSetParamSchema({
1353
- contract,
1354
- key: next.key,
1355
- paramType: next.paramType,
1356
- auth: next.auth,
1357
- authAddress: next.authAddress,
1358
- lockAfter: next.lockAfter,
1359
- min: next.min,
1360
- max: next.max,
1361
- selectOptions: next.selectOptions,
1362
- chainId: cid,
1363
- display: describeSchema(next),
1364
- }),
1365
- ];
1366
- // Keep `params.keys` in step, ATOMICALLY. On the on-chain URI lane the canonical generator builds
1367
- // tokenData from that CSV, so a key governed but not listed is silently omitted from every render —
1368
- // the schema exists, the collector can set it, and the art never sees it. `deploy-code` composes
1369
- // the list from `--schema` for exactly this reason; a schema added later has to do the same or
1370
- // `set-schema` is a second-class path that quietly half-works. `readParamsKeys` returns null when
1371
- // the project isn't on that lane (a resolver reads schemas directly), so this adds nothing there.
1372
- try {
1373
- const csv = await readParamsKeys(publicClient, contract);
1374
- const sync = paramsKeysSyncOp(csv, next.key, contract, cid);
1375
- if (sync) {
1376
- console.log(` ${dim('params.keys')} ${dim('←')} ${sync.listed} ${dim('(so the on-chain generator injects it; same tx)')}`);
1377
- ops.push(sync.op);
1378
- }
1379
- }
1380
- catch {
1381
- // Best-effort, never fails the command — but say so, because silence here looks like "no update
1382
- // was needed" when it actually means "we could not check".
1383
- console.log(` ${C.yellow}⚠${C.reset} couldn't read params.keys — if this project is on the on-chain URI lane, verify it lists "${next.key}".`);
1384
- }
1385
- await runWrite(contract, ops.length > 1
1386
- ? prepareMulticall({ ops, summary: `Set the schema for "${next.key}" and list it in params.keys` })
1387
- : ops[0], flags, owner);
1515
+ // ONE op. A schema write used to need a `params.keys` companion write in the same tx to keep the
1516
+ // on-chain generator's key list in step; the generator now enumerates params from the token
1517
+ // itself, so the schema write is the whole change.
1518
+ await runWrite(contract, prepareSetParamSchema({
1519
+ contract,
1520
+ key: next.key,
1521
+ paramType: next.paramType,
1522
+ auth: next.auth,
1523
+ authAddress: next.authAddress,
1524
+ lockAfter: next.lockAfter,
1525
+ min: next.min,
1526
+ max: next.max,
1527
+ selectOptions: next.selectOptions,
1528
+ chainId: chainId(),
1529
+ display: describeSchema(next),
1530
+ }), flags, owner);
1388
1531
  }
1389
1532
  /** Render an on-chain schema through the same formatter the CLI uses for a parsed one. */
1390
1533
  function onChainToParsed(key, s) {