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

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,21 +6,22 @@
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, 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, prepareTransfer, prepareTransferOwnership, 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 } 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';
24
+ import { parseSchemaSpecs, describeSchema } from './schema.js';
24
25
  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)
25
26
  // ── ANSI (local) ─────────────────────────────────────────────────────────────
26
27
  const C = { reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m', green: '\x1b[38;5;115m', yellow: '\x1b[38;5;221m' };
@@ -33,17 +34,28 @@ const bold = (s) => `${C.bold}${s}${C.reset}`;
33
34
  // COMPLETE set — but it's COMPUTED by the resolver/renderer from the token's fields + effect
34
35
  // outputs; it is never a field you set. These keys are that computed output, so setting them by
35
36
  // 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']);
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']);
38
41
  export function assertSettableField(field) {
39
42
  if (COMPUTED_FIELD_KEYS.has(field)) {
40
43
  const what = field === 'artifacts'
41
44
  ? "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://…>`);
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://…>`));
45
52
  }
46
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
+ }
47
59
  /** Flags `abx attach` recognizes — anything else warns (non-fatal), so a silent no-op flag surfaces. */
48
60
  const ATTACH_FLAGS = ['file', 'compress', 'collection', 'token', 'send', 'sign', 'unsigned', 'yes', 'dry-run', 'port', 'sign-url-file', 'remote'];
49
61
  /** Auto-detect the on-chain representation for an off-chain locator by its URI scheme. Returns null
@@ -239,8 +251,9 @@ export async function cmdConfigureParam(address, rest, flags) {
239
251
  }));
240
252
  const [exists, paramTypeIdx, , , , , , selectOptions] = schema;
241
253
  // Contract scope (`-`): the raw owner setters (`setContractParam[Data]`) — the write path of
242
- // well-known contract params like `params.keys` / `display.gateway`. Schema-less keys only: a
243
- // 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.
244
257
  if (tokenIdRaw === '-') {
245
258
  if (exists) {
246
259
  throw new Error(`"${key}" is schema-governed — it's a per-token PostParam (abx configure-param ${contract} <tokenId> ${key} …). ` +
@@ -253,9 +266,9 @@ export async function cmdConfigureParam(address, rest, flags) {
253
266
  await runWrite(contract, prepareSetContractParamData({ contract, key, data, chainId: cid }), flags, owner);
254
267
  return;
255
268
  }
256
- // Same encoding rule as deploy-code's params.keys leg: printable ASCII 31 chars rides as a
257
- // literal readable bytes32; anything longer takes the data path (one blob, hash evented).
258
- 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)) {
259
272
  console.log(` ${key} (contract scope) ← "${valueInput}" ${dim('(literal bytes32; owner-only raw setter)')}`);
260
273
  await runWrite(contract, prepareSetContractParam({ contract, key, value: encodeTagSdk(valueInput), display: valueInput, chainId: cid }), flags, owner);
261
274
  }
@@ -267,9 +280,9 @@ export async function cmdConfigureParam(address, rest, flags) {
267
280
  }
268
281
  const tokenId = BigInt(tokenIdRaw);
269
282
  if (!exists) {
270
- throw new Error(`no PostParam schema for "${key}" on ${contract}. Schemas are declared at DEPLOY: ` +
271
- `abx deploy-code … --schema ${key}:<Type>:<Auth> (e.g. ${key}:HexColor:TokenOwner). ` +
272
- `Adding a param to an already-deployed contract isn't a CLI command yet.`);
283
+ throw new Error(`no PostParam schema for "${key}" on ${contract}. Declare one at deploy with ` +
284
+ `abx deploy-code … --schema ${key}:<Type>:<Auth>, or right now on the live contract with ` +
285
+ `abx set-schema ${contract} --schema ${key}:<Type>:<Auth> (e.g. ${key}:HexColor:TokenOwner).`);
273
286
  }
274
287
  const typeName = ['Bool', 'Select', 'Uint256Range', 'Int256Range', 'DecimalRange', 'HexColor', 'Timestamp', 'String', 'Bytes'][paramTypeIdx];
275
288
  let sent;
@@ -284,22 +297,7 @@ export async function cmdConfigureParam(address, rest, flags) {
284
297
  console.log(` ${key} (${typeName}) ← ${display} ${dim('(canonical encode; the chain enforces schema + auth)')}`);
285
298
  sent = await runWrite(contract, prepareConfigureTokenParam({ contract, tokenId, key, value, display, chainId: chainId() }), flags);
286
299
  }
287
- // params.keys drift nudge (best-effort, never fails the command): a project on the on-chain
288
- // URI lane enumerates its param surface in the `params.keys` contract param — the canonical
289
- // generator reads it to build on-chain tokenData. A configured key that isn't listed is
290
- // silently OMITTED there (byte-parity with the resolver breaks), so say it, with the fix.
291
300
  if (sent) {
292
- try {
293
- const csv = await readParamsKeys(publicClient, contract);
294
- const fixed = paramsKeysNudge(csv, key);
295
- if (fixed !== null) {
296
- console.log(` ${yellow('⚠')} params.keys doesn't list "${key}" — the on-chain generator's tokenData will omit it. Fix:\n` +
297
- ` ${bold(`abx configure-param ${contract} - params.keys ${fixed}`)}`);
298
- }
299
- }
300
- catch {
301
- /* nudge is advisory — an RPC hiccup or a non-Params contract must never fail the write */
302
- }
303
301
  // What happens to the IMAGE depends on how it's produced. If the `image` field is computed
304
302
  // on-chain (a `renderer` representation — the in-chain SVG lane), it re-tints AUTOMATICALLY: the
305
303
  // renderer reads the param live, so tokenURI's image is already updated, nothing to re-render.
@@ -550,15 +548,29 @@ export async function cmdSetPrimaryPayee(address, flags) {
550
548
  // marketplaces fetch the on-chain tokenURI; if the resolver wasn't warm they cache
551
549
  // a miss until refreshed. We emit ERC-4906 on URI changes (4906-aware marketplaces
552
550
  // auto-refresh) — this is the manual fallback for the rest, and after the genesis mint.
553
- const OPENSEA_CHAIN = { sepolia: 'sepolia', mainnet: 'ethereum' };
554
- 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' };
555
563
  export async function cmdRefresh(address, flags) {
556
564
  const contract = requireAddress(address, 'abx refresh <address> [--token 0]');
557
565
  const tokenId = flags.token ?? '0';
558
- const osChain = OPENSEA_CHAIN[CHAIN] ?? CHAIN;
559
- 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 ?? '';
560
569
  const apiKey = process.env.OPENSEA_API_KEY;
561
- 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) {
562
574
  const url = `https://api.opensea.io/api/v2/chain/${osChain}/contract/${contract}/nfts/${tokenId}/refresh`;
563
575
  try {
564
576
  const res = await fetch(url, { method: 'POST', headers: { 'x-api-key': apiKey, accept: 'application/json' } });
@@ -574,8 +586,10 @@ export async function cmdRefresh(address, flags) {
574
586
  else {
575
587
  console.log(dim(' no OPENSEA_API_KEY set — open these and click “Refresh metadata”:'));
576
588
  }
577
- const osBase = TESTNET_CHAINS.has(CHAIN) ? 'https://testnets.opensea.io' : 'https://opensea.io';
578
- 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}`);
579
593
  if (explorer)
580
594
  console.log(` ${dim('Etherscan')} ${explorer}/token/${contract}?a=${tokenId}`);
581
595
  console.log(dim(' (ERC-4906 already pings 4906-aware marketplaces on URI changes; this covers the genesis mint + the rest.)'));
@@ -659,37 +673,27 @@ export function parseCompress(v) {
659
673
  * store is ownerless, so any funded signer can stand it up.
660
674
  */
661
675
  async function ensureChunkStore(send, override) {
662
- // Resolve from the shipped manifest (flag ABX_CHUNK_STORE → manifest); on a chain with no
663
- // entry (or a stale deployment), deploy a current one and tell the operator how to reuse it.
664
- const known = chunkStoreAddress(override);
676
+ // The resolution logic lives in the SDK (`ensureChunkStore`) so an SDK integrator bootstraps
677
+ // identically instead of hand-rolling the "is this store capable?" guard forget it and an
678
+ // incapable store fails deep inside a mint, after transactions have landed. The CLI keeps only the
679
+ // narration: the SDK reports progress through `onEvent` rather than printing.
665
680
  const publicClient = makePublicClient({ chainKey: CHAIN });
666
- if (known) {
667
- const code = await publicClient.getCode({ address: known });
668
- if (code && code !== '0x') {
669
- // Has code, but is it the *current* store? A pre-`writeContent` deployment passes a
670
- // bare code check yet reverts the staging call — verify the ABI, don't just assume.
671
- if (await storeSupportsWriteContent(publicClient, known))
672
- return known;
673
- console.log(yellow(` configured chunk store ${known} is a stale deployment (no writeContent) — deploying a current one`));
674
- }
675
- }
676
- // No usable listed store. The canonical store is CREATE2-deterministic, so it may already exist at
677
- // its predicted address — deployed by the forge script (or a prior lazy deploy) with the manifest
678
- // not yet updated. Check there before deploying: self-healing, and never a duplicate at a random
679
- // CREATE address. `deployChunkStore` (below) also lands at exactly this address.
680
- const predicted = predictChunkStore();
681
- if (!known || known.toLowerCase() !== predicted.toLowerCase()) {
682
- const pcode = await publicClient.getCode({ address: predicted });
683
- if (pcode && pcode !== '0x' && (await storeSupportsWriteContent(publicClient, predicted))) {
684
- console.log(dim(` using the canonical chunk store at its deterministic address ${predicted}`));
685
- return predicted;
686
- }
687
- }
688
- console.log(dim(' deploying the canonical multi-chunk content store (AbxChunkStore) — CREATE2…'));
689
- const { chunkStore } = await deployChunkStore(send);
690
- console.log(` ${green('✓')} chunk store ${chunkStore}`);
691
- 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)`));
692
- return chunkStore;
681
+ return sdkEnsureChunkStore(publicClient, send, {
682
+ chainId: chainId(),
683
+ override,
684
+ onEvent: (e) => {
685
+ if (e.kind === 'stale')
686
+ console.log(yellow(` configured chunk store ${e.address} is a stale deployment (no writeContent) — deploying a current one`));
687
+ else if (e.kind === 'canonical')
688
+ console.log(dim(` using the canonical chunk store at its deterministic address ${e.address}`));
689
+ else if (e.kind === 'deploying')
690
+ console.log(dim(' deploying the canonical multi-chunk content store (AbxChunkStore) — CREATE2…'));
691
+ else {
692
+ console.log(` ${green('✓')} chunk store ${e.address}`);
693
+ 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)`));
694
+ }
695
+ },
696
+ });
693
697
  }
694
698
  /**
695
699
  * The hot-lane staging signer: the env key signs + broadcasts each chunk-store write. Built
@@ -844,6 +848,55 @@ export async function stageImageFieldsBatch(imagePaths, compress, send, storeOve
844
848
  }
845
849
  return { fields, store };
846
850
  }
851
+ // ── the generator repoint guard ───────────────────────────────────────────────
852
+ // The canonical AbxGenerator reads a token's param surface FROM CHAIN (`tokenParamKeys` /
853
+ // `contractParamKeys`). A LEGACY implementation — deployed before enumeration shipped — has neither
854
+ // getter, so the generator finds nothing: every configured param vanishes from tokenData and from
855
+ // the live view, silently, behind a tokenURI that still looks perfectly healthy. That is the exact
856
+ // failure class this toolkit refuses rather than warns about, so pointing a legacy token at the
857
+ // current generator is REFUSED. (Repointing the metadata RENDERER is safe and unguarded: a v4
858
+ // renderer on a legacy token just emits no params block.)
859
+ /** The field-renderer address a `--value` names: the canonical `abi.encode(address)` (32 bytes),
860
+ * or a bare 20-byte address. Null when it is neither. */
861
+ function fieldRendererTarget(value) {
862
+ const hex = value.trim();
863
+ if (!/^0x[0-9a-fA-F]*$/.test(hex))
864
+ return null;
865
+ if (hex.length === 42)
866
+ return getAddress(hex);
867
+ if (hex.length !== 66)
868
+ return null;
869
+ try {
870
+ return decodeFieldRenderer(hex);
871
+ }
872
+ catch {
873
+ return null;
874
+ }
875
+ }
876
+ export async function assertGeneratorRepointable(contract, field, flags,
877
+ /** Injected for tests; the real probe is one eth_call. */
878
+ probe = (c) => hasParamEnumeration(makePublicClient({ chainKey: CHAIN }), c)) {
879
+ if (flags.representation !== R.renderer || !flags.value || flags.value === 'true')
880
+ return;
881
+ const target = fieldRendererTarget(flags.value);
882
+ const generator = resolveGenerator(chainId());
883
+ // Only the CANONICAL generator is guarded — any other field renderer is the creator's own contract
884
+ // and none of our business.
885
+ if (!target || !generator || target.toLowerCase() !== generator.toLowerCase())
886
+ return;
887
+ // The `owner` read has already succeeded by the time this runs, so the RPC is proven reachable:
888
+ // a failing probe here means the getter is absent, not that the node is down.
889
+ if (await probe(contract))
890
+ return;
891
+ throw new Error(`refusing to point ${field} at the canonical generator ${generator} — ${contract} does not expose the param\n` +
892
+ ` enumeration surface (tokenParamKeys/contractParamKeys), so it is a LEGACY implementation. The current\n` +
893
+ ` generator reads params FROM CHAIN, so on this token it would read NOTHING: every configured param would\n` +
894
+ ` silently disappear from tokenData, the live view, and every render — with a tokenURI that still looks fine.\n\n` +
895
+ ` Two honest options:\n` +
896
+ ` • stay on the generator this project already uses (pass that address as --value; it reads the project's\n` +
897
+ ` params.keys list, which is how it has always worked here), or\n` +
898
+ ` • redeploy the project with the current \`abx deploy-code\` — new projects enumerate on-chain and need no list.`);
899
+ }
847
900
  // ── set-field ──────────────────────────────────────────────────────────────--
848
901
  // Set an on-chain metadata field. `--field` is what (e.g. image, description),
849
902
  // `--representation` is how it's carried (default `inline` for --text, `keccak256`
@@ -858,6 +911,7 @@ export async function cmdSetField(address, flags) {
858
911
  const collection = !!flags.collection;
859
912
  const lane = laneFromFlags(flags);
860
913
  const owner = await read(contract, 'owner');
914
+ await assertGeneratorRepointable(contract, field, flags); // legacy impl + the current generator = params silently invisible
861
915
  const staging = !!(flags.file && flags.file !== 'true'); // large content ON-CHAIN via chunk store/reader
862
916
  // --file + --dry-run: preview the on-chain staging plan and store/send NOTHING. (The locator/text
863
917
  // path flows through runWrite, which previews there; staging must short-circuit before any upload.)
@@ -987,9 +1041,25 @@ export async function cmdAttach(rest, flags) {
987
1041
  dim('Point the URI at the file itself (…/master.tiff, …/coa.pdf) so collectors get the right type.'));
988
1042
  }
989
1043
  console.log(` attaching ${bold(key)} ${dim(`(${mimeType}, ${representation})`)} to ${scope}: ${dim(uri)}`);
990
- // Where it surfaces say it on the preview path too (not just post-send), so a creator/agent
991
- // doesn't expect an attached custom key in a bare `abx tokenuri` (round-2 finding: they did).
992
- 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.`));
1044
+ // Where it surfaces. This used to be one dim line, and it read as a footnote rather than as a
1045
+ // dependency: an integrator attached five audio stems to a fully-on-chain token, paid to store
1046
+ // them, and found `tokenURI` listed none of them "paid for, stored on-chain, and invisible".
1047
+ // The on-chain renderer deliberately omits locator-represented artifacts (they duplicate no
1048
+ // on-chain type information — see specs/protocol/data-plane.md), so the artifacts manifest comes
1049
+ // from a RESOLVER. When the project has no resolver baked in, that is not a footnote, it is the
1050
+ // difference between a feature working and not existing, so say it as a warning.
1051
+ const uriBase = await read(contract, 'tokenURIBase').catch(() => '');
1052
+ const artifactsPath = `/t/${chainId()}/${contract}/${flags.token ?? '0'}`;
1053
+ if (uriBase && uriBase.trim() !== '') {
1054
+ 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.`));
1055
+ }
1056
+ else {
1057
+ console.log(yellow(' ⚠ ') +
1058
+ `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 — ` +
1059
+ `so ${bold(key)} will NOT appear in ${bold('tokenURI')}. The bytes are stored and provable, but nothing surfaces them to a marketplace or wallet. ` +
1060
+ dim('(Configured params DO appear on-chain — attachments are the surface that needs a resolver.)'));
1061
+ 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}.`));
1062
+ }
993
1063
  const buildTx = () => collection
994
1064
  ? prepareSetContractField({ contract, field: key, representation, value: toHex(uri), chainId: chainId() })
995
1065
  : prepareSetTokenField({ contract, tokenId: BigInt(flags.token ?? '0'), field: key, representation, value: toHex(uri), chainId: chainId() });
@@ -1000,7 +1070,7 @@ export async function cmdAttach(rest, flags) {
1000
1070
  dim(` verify (a resolver serves the complete listing): `) +
1001
1071
  `curl <your-resolver>/t/${chainId()}/${contract}/${id}` +
1002
1072
  dim(` ${collection ? '' : `→ artifacts[].key "${key}"; /data/${key} fetches it`}\n`) +
1003
- dim(` (The complete file listing is a resolver surface — the bare on-chain tokenURI enumerates reserved fields only.)\n`));
1073
+ 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`));
1004
1074
  }
1005
1075
  }
1006
1076
  // ── lock-field ───────────────────────────────────────────────────────────────
@@ -1266,4 +1336,129 @@ export async function cmdMinterBuy(address, flags) {
1266
1336
  await runMinterWrite(preparePurchase({ minter, token, to, value, chainId: chainId() }), flags);
1267
1337
  console.log(dim(` next: \`abx refresh ${token}\` so marketplaces pick up the new token.`));
1268
1338
  }
1339
+ // ── PostParam schemas, after deploy ──────────────────────────────────────────
1340
+ // `setParamSchema` is owner-gated with no deploy-time restriction, so a project's param surface was
1341
+ // never actually frozen at deploy — the toolkit just had no way to reach it, which read to creators
1342
+ // as a protocol limitation ("we must guess every param up front, or redeploy and lose the address").
1343
+ // Two commands close that: `set-schema` (attach or replace one key) and `retire-param` (stop all
1344
+ // further writes, the closest thing to a delete the protocol has).
1345
+ /** Values already stored under a key can be stranded by a schema change — the contract does NOT
1346
+ * re-validate them. Compare old vs new and name what would break, so the guard can refuse. */
1347
+ export function strandingRisks(before, after) {
1348
+ const risks = [];
1349
+ const typeName = (i) => PARAM_TYPES[i] ?? String(i);
1350
+ if (before.paramType !== after.paramType) {
1351
+ risks.push(`type ${typeName(before.paramType)} → ${typeName(after.paramType)} (a stored value keeps its old encoding)`);
1352
+ }
1353
+ const dropped = before.selectOptions.filter((o) => !after.selectOptions.includes(o));
1354
+ if (before.selectOptions.length && dropped.length) {
1355
+ risks.push(`Select option(s) removed: ${dropped.join(', ')} (a token already set to one keeps it)`);
1356
+ }
1357
+ // Narrowing either bound can strand a value that sat inside the old range.
1358
+ const asInt = (h) => (PARAM_TYPES[after.paramType] === 'Int256Range' ? BigInt.asIntN(256, BigInt(h)) : BigInt(h));
1359
+ if (before.min !== after.min && asInt(after.min) > asInt(before.min))
1360
+ risks.push(`min raised (${asInt(before.min)} → ${asInt(after.min)})`);
1361
+ if (before.max !== after.max && asInt(before.max) !== 0n && asInt(after.max) < asInt(before.max)) {
1362
+ risks.push(`max lowered (${asInt(before.max)} → ${asInt(after.max)})`);
1363
+ }
1364
+ return risks;
1365
+ }
1366
+ /** `abx set-schema <address> --schema key:Type:Auth[:lock=<when>]` — attach or replace ONE key's
1367
+ * on-chain schema, any time in a project's life. Owner-only. */
1368
+ export async function cmdSetSchema(address, flags) {
1369
+ const usage = 'abx set-schema <address> --schema key:Type:Auth[:lock=<when>] [--force] [--dry-run] [--sign|--unsigned]';
1370
+ const contract = requireAddress(address, usage);
1371
+ const specs = parseSchemaSpecs(flags.schema);
1372
+ if (specs.length !== 1) {
1373
+ 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`);
1374
+ process.exit(1);
1375
+ }
1376
+ const next = specs[0];
1377
+ const publicClient = makePublicClient({ chainKey: CHAIN });
1378
+ const owner = await read(contract, 'owner');
1379
+ const before = await readParamSchema(publicClient, contract, next.key);
1380
+ if (!before.exists) {
1381
+ console.log(` ${next.key} ${dim('— new governed key')} ${describeSchema(next)}`);
1382
+ }
1383
+ else {
1384
+ // The upsert hazard: this is a FULL row write, so anything not restated is overwritten. Show
1385
+ // both sides, and refuse a change that could strand values unless the caller insists.
1386
+ console.log(` ${next.key} ${dim('— replacing an existing schema')}`);
1387
+ console.log(` ${dim('before')} ${describeSchema(onChainToParsed(next.key, before))}`);
1388
+ console.log(` ${dim('after ')} ${describeSchema(next)}`);
1389
+ const risks = strandingRisks(before, next);
1390
+ if (risks.length && flags.force === undefined) {
1391
+ throw new Error(`refusing to replace "${next.key}" — this change can strand values already stored under it:\n` +
1392
+ risks.map((r) => ` • ${r}`).join('\n') +
1393
+ `\n\n The contract does NOT re-validate stored values against a new schema, so affected tokens would keep\n` +
1394
+ ` values their own schema no longer allows. Re-run with --force if that is what you intend.`);
1395
+ }
1396
+ // With --force, say what is being overridden. Silently applying a value-stranding change is the
1397
+ // one outcome worse than refusing it: the operator gets no record of which tokens they may have
1398
+ // just invalidated, and neither does anyone reading the terminal afterwards.
1399
+ if (risks.length) {
1400
+ console.log(` ${C.yellow}⚠${C.reset} ${bold('--force')} — applying a change that can strand stored values:`);
1401
+ for (const r of risks)
1402
+ console.log(` • ${r}`);
1403
+ console.log(dim(` any token already holding a value for "${next.key}" keeps it, now outside what its schema allows.`));
1404
+ }
1405
+ if (before.lockAfter && !next.lockAfter) {
1406
+ 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.`);
1407
+ }
1408
+ }
1409
+ // ONE op. A schema write used to need a `params.keys` companion write in the same tx to keep the
1410
+ // on-chain generator's key list in step; the generator now enumerates params from the token
1411
+ // itself, so the schema write is the whole change.
1412
+ await runWrite(contract, prepareSetParamSchema({
1413
+ contract,
1414
+ key: next.key,
1415
+ paramType: next.paramType,
1416
+ auth: next.auth,
1417
+ authAddress: next.authAddress,
1418
+ lockAfter: next.lockAfter,
1419
+ min: next.min,
1420
+ max: next.max,
1421
+ selectOptions: next.selectOptions,
1422
+ chainId: chainId(),
1423
+ display: describeSchema(next),
1424
+ }), flags, owner);
1425
+ }
1426
+ /** Render an on-chain schema through the same formatter the CLI uses for a parsed one. */
1427
+ function onChainToParsed(key, s) {
1428
+ return {
1429
+ key,
1430
+ paramType: s.paramType,
1431
+ auth: s.auth,
1432
+ authAddress: s.authAddress,
1433
+ lockAfter: s.lockAfter,
1434
+ min: s.min,
1435
+ max: s.max,
1436
+ selectOptions: s.selectOptions,
1437
+ };
1438
+ }
1439
+ /** `abx retire-param <address> <key>` — permanently stop further writes to a PostParam. */
1440
+ export async function cmdRetireParam(address, rest, flags) {
1441
+ const usage = 'abx retire-param <address> <key> [--dry-run] [--sign|--unsigned]';
1442
+ const contract = requireAddress(address, usage);
1443
+ const [key] = positionalArgs(rest);
1444
+ if (!key) {
1445
+ console.error(`usage: ${usage}\n`);
1446
+ process.exit(1);
1447
+ }
1448
+ const publicClient = makePublicClient({ chainKey: CHAIN });
1449
+ const owner = await read(contract, 'owner');
1450
+ const current = await readParamSchema(publicClient, contract, key);
1451
+ if (!current.exists)
1452
+ throw new Error(`no PostParam schema for "${key}" on ${contract} — nothing to retire.`);
1453
+ if (current.lockAfter && current.lockAfter < Math.floor(Date.now() / 1000)) {
1454
+ console.log(` ${C.green}✓${C.reset} "${key}" is already retired (locked ${new Date(current.lockAfter * 1000).toISOString().slice(0, 19)}Z). Nothing to do.`);
1455
+ return;
1456
+ }
1457
+ console.log(` ${key} ${dim(describeSchema(onChainToParsed(key, current)))}`);
1458
+ console.log(` ${dim('after this: every write reverts ParamLockExpired — permanently, with no way back.')}`);
1459
+ console.log(` ${dim('the schema stays (a key can never be un-governed), and any value ALREADY stored stays and keeps serving.')}`);
1460
+ // Read-modify-write: carry every field forward and change only the lock. Composing a fresh schema
1461
+ // here would silently reset type/auth/bounds/options — the upsert clobber this command exists to avoid.
1462
+ await runWrite(contract, prepareRetireParam({ contract, key, current, chainId: chainId() }), flags, owner);
1463
+ }
1269
1464
  //# sourceMappingURL=ownerops.js.map