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

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 (77) hide show
  1. package/CHANGELOG.md +222 -0
  2. package/assets/renderer-scaffold/README.md +12 -0
  3. package/assets/renderer-scaffold/foundry.toml +4 -0
  4. package/assets/renderer-scaffold/script/Preview.s.sol +99 -0
  5. package/dist/capabilities.d.ts +9 -4
  6. package/dist/capabilities.d.ts.map +1 -1
  7. package/dist/capabilities.js +13 -4
  8. package/dist/capabilities.js.map +1 -1
  9. package/dist/commands/deploy.d.ts +51 -1
  10. package/dist/commands/deploy.d.ts.map +1 -1
  11. package/dist/commands/deploy.js +1008 -172
  12. package/dist/commands/deploy.js.map +1 -1
  13. package/dist/commands/maintenance.d.ts +4 -0
  14. package/dist/commands/maintenance.d.ts.map +1 -0
  15. package/dist/commands/maintenance.js +114 -0
  16. package/dist/commands/maintenance.js.map +1 -0
  17. package/dist/commands/project.d.ts +175 -3
  18. package/dist/commands/project.d.ts.map +1 -1
  19. package/dist/commands/project.js +359 -29
  20. package/dist/commands/project.js.map +1 -1
  21. package/dist/commands/reads.d.ts +21 -0
  22. package/dist/commands/reads.d.ts.map +1 -1
  23. package/dist/commands/reads.js +171 -4
  24. package/dist/commands/reads.js.map +1 -1
  25. package/dist/commands/scaffold.d.ts.map +1 -1
  26. package/dist/commands/scaffold.js +5 -0
  27. package/dist/commands/scaffold.js.map +1 -1
  28. package/dist/commands/service.d.ts.map +1 -1
  29. package/dist/commands/service.js +7 -0
  30. package/dist/commands/service.js.map +1 -1
  31. package/dist/commands/storage.d.ts.map +1 -1
  32. package/dist/commands/storage.js +32 -3
  33. package/dist/commands/storage.js.map +1 -1
  34. package/dist/commands/submit-app.d.ts +43 -0
  35. package/dist/commands/submit-app.d.ts.map +1 -1
  36. package/dist/commands/submit-app.js +115 -6
  37. package/dist/commands/submit-app.js.map +1 -1
  38. package/dist/config.d.ts +16 -1
  39. package/dist/config.d.ts.map +1 -1
  40. package/dist/config.js +73 -7
  41. package/dist/config.js.map +1 -1
  42. package/dist/deploy-plan.d.ts +221 -0
  43. package/dist/deploy-plan.d.ts.map +1 -0
  44. package/dist/deploy-plan.js +20 -0
  45. package/dist/deploy-plan.js.map +1 -0
  46. package/dist/flag-allowlists.d.ts.map +1 -1
  47. package/dist/flag-allowlists.js +5 -1
  48. package/dist/flag-allowlists.js.map +1 -1
  49. package/dist/flags.d.ts +6 -0
  50. package/dist/flags.d.ts.map +1 -1
  51. package/dist/flags.js +14 -0
  52. package/dist/flags.js.map +1 -1
  53. package/dist/jsonout.d.ts.map +1 -1
  54. package/dist/jsonout.js +11 -0
  55. package/dist/jsonout.js.map +1 -1
  56. package/dist/main.js +62 -13
  57. package/dist/main.js.map +1 -1
  58. package/dist/ownerops.d.ts +139 -1
  59. package/dist/ownerops.d.ts.map +1 -1
  60. package/dist/ownerops.js +382 -19
  61. package/dist/ownerops.js.map +1 -1
  62. package/dist/riskgate.d.ts +36 -0
  63. package/dist/riskgate.d.ts.map +1 -1
  64. package/dist/riskgate.js +91 -17
  65. package/dist/riskgate.js.map +1 -1
  66. package/dist/script-chunks.d.ts +1 -1
  67. package/dist/script-chunks.d.ts.map +1 -1
  68. package/dist/script-chunks.js +2 -2
  69. package/dist/script-chunks.js.map +1 -1
  70. package/package.json +6 -6
  71. package/skill/SKILL.md +6 -4
  72. package/skill/reference/code.md +31 -8
  73. package/skill/reference/deploy.md +9 -2
  74. package/skill/reference/diagnose.md +15 -2
  75. package/skill/reference/hosting.md +26 -0
  76. package/skill/reference/operate.md +55 -4
  77. package/skill/reference/setup.md +14 -0
@@ -6,12 +6,12 @@
6
6
  */
7
7
  import { readFileSync } from 'node:fs';
8
8
  import { resolve as resolvePath } from 'node:path';
9
- import { verifyProvenance, AUTH_OPTIONS, CREATOR_TOKEN_INTERFACE_ID, PARAM_TYPES, hasOnChainUriLane, isCodeProject, isCurrentFactory, isCurrentOneOfOneEditionFactory, makePublicClient, makeWalletClient, normalizeAttributes, onChainUriReport, parseTraitPairs, predictClone, readParamHooks, readParamHooksLocked, readParamSchema, readParamSchemaKeys, readSetParamKeys, resolveChain, resolveSeriesCodeFactory, saltFor, saltGuard, sleep, readCollectionPolicy, rpcEnvVar, tryReadContract, } from '@artblocks/abx-sdk';
9
+ import { verifyProvenance, AUTH_OPTIONS, CREATOR_TOKEN_INTERFACE_ID, PARAM_TYPES, hasOnChainUriLane, isCodeProject, isCurrentFactory, isCurrentOneOfOneEditionFactory, makePublicClient, makeWalletClient, normalizeAttributes, onChainUriReport, parseTraitPairs, predictClone, readParamHooks, readParamHooksLocked, readParamSchema, readParamSchemaKeys, readSetParamKeys, resolveChain, resolveSeriesCodeFactory, saltFor, saltGuard, sleep, readCollectionPolicy, rpcEnvVar, tryReadContract, encodeTag, METADATA_FIELD, METADATA_REPRESENTATION, fieldOf, isCurrentRenderer, isCurrentGenerator, } from '@artblocks/abx-sdk';
10
10
  import { resolveBackend } from '@artblocks/abx-storage';
11
11
  import { currentRenderArtifact, verifyProject } from '@artblocks/abx-token-api';
12
12
  import { zeroAddress } from 'viem';
13
13
  import { CHAIN, activeBackendId, factoryAddress, localIndexer, remoteLocators, seriesFactoryAddress, oneOfOneEditionFactoryAddress, editionFactoryAddress, editionCodeFactoryAddress, storageOptions, } from '../config.js';
14
- import { isDryRun, parseSaltFlag } from '../flags.js';
14
+ import { isDryRun, parseBlockTagFlag, parseSaltFlag } from '../flags.js';
15
15
  import { jsonSafe, withJson } from '../jsonout.js';
16
16
  import { allowLargeScan, bold, c, detectCanonicalFactory, dim, g, info, ok, registerAndIndexLocally, resolveScanFloor, warn } from '../output.js';
17
17
  import { parseCopies } from './deploy.js';
@@ -239,14 +239,20 @@ export async function cmdIndex(address, flags) {
239
239
  }
240
240
  allowLargeScan(flags);
241
241
  const full = !!flags.full; // force a full replay from the deploy block (durability proof)
242
+ // Stop the scan at a REORG-SAFE boundary instead of the head. `latest` (the default) can include
243
+ // blocks that a reorg later replaces, which would persist a watermark for history that no longer
244
+ // exists; `safe`/`finalized` trade freshness for a boundary the chain won't take back. Resolved to
245
+ // a concrete block number before scanning (see resolveBlockTag) — a stored watermark is always a
246
+ // number, never a tag.
247
+ const blockTag = parseBlockTagFlag(flags['to-block']);
242
248
  const indexer = localIndexer();
243
249
  const line = (state, elapsedMs, mode) => ok(`${state.name ?? state.address}: ${state.eventCount} events in ${elapsedMs}ms ${dim(`(${mode})`)}`);
244
250
  if (address) {
245
- const { state, elapsedMs, mode } = await indexer.reindex(address, { full });
251
+ const { state, elapsedMs, mode } = await indexer.reindex(address, { full, blockTag });
246
252
  line(state, elapsedMs, mode);
247
253
  return;
248
254
  }
249
- const results = await indexer.reindexAll({ full });
255
+ const results = await indexer.reindexAll({ full, blockTag });
250
256
  if (results.length === 0) {
251
257
  console.log(dim('No registered projects. Deploy one with `abx deploy`.'));
252
258
  return;
@@ -266,21 +272,96 @@ export async function cmdVerify(address, flags) {
266
272
  }
267
273
  allowLargeScan(flags);
268
274
  const remote = remoteFlag(flags);
269
- if (remote)
270
- return cmdVerifyRemote(address, remote);
271
275
  // `--json` matters most here of all the read commands: verify already exits non-zero on a byte
272
276
  // mismatch, so it is the one command a CI job would gate on. The payload's `ok` is the same
273
277
  // boolean the exit code carries; callers never need to parse prose.
278
+ //
279
+ // The remote lane used to route around `withJson` entirely (`return cmdVerifyRemote(address,
280
+ // remote)`), so `abx verify <addr> --remote <r> --json` printed the same human prose as without
281
+ // the flag and emitted NOTHING on stdout — the one lane of this command a CI job is most likely
282
+ // to run (checking what a HOSTED resolver serves) had no machine-readable output at all. Both
283
+ // lanes now go through the same `withJson` wrapper and emit the same payload shape.
284
+ if (remote)
285
+ return withJson(flags, async (emit) => cmdVerifyRemote(address, remote, emit));
274
286
  return withJson(flags, async (emit) => cmdVerifyBody(address, flags, emit));
275
287
  }
288
+ /**
289
+ * Pure by design — every caller (`cmdVerifyBody`'s local lane and `cmdVerifyRemote`'s hosted lane)
290
+ * feeds it facts it already gathered rather than each re-deriving its own verdict, so the two
291
+ * lanes' JSON payloads read the same word for the same situation.
292
+ *
293
+ * `isCode` selects which signal decides the verdict: a code project's render presence
294
+ * (`minted`/`present`), or — for everything else — whether a byte-check actually ran
295
+ * (`anyCheck`) versus a locator this run could not independently re-hash (`unrecomputablePointers`).
296
+ * The two signals are never combined into one number on purpose: "3 of 5 renders present" and "2
297
+ * locator commitments unconfirmed" are different facts about different projects, and mixing them
298
+ * would produce a verdict that answers neither question honestly.
299
+ */
300
+ export function computeAvailability(opts) {
301
+ if (opts.isCode) {
302
+ if (opts.minted === 0)
303
+ return { status: 'unknown', note: 'no tokens minted yet — nothing to render' };
304
+ if (opts.present === opts.minted)
305
+ return { status: 'available', note: `${opts.present}/${opts.minted} minted token(s) have a real render` };
306
+ if (opts.present === 0)
307
+ return { status: 'unavailable', note: `0/${opts.minted} minted token(s) have a real render — every one is still a placeholder` };
308
+ return { status: 'partial', note: `${opts.present}/${opts.minted} minted token(s) have a real render` };
309
+ }
310
+ if (opts.anyCheck) {
311
+ return { status: 'available', note: 'served bytes were fetched and compared against the on-chain commitment (see `ok` for whether they matched)' };
312
+ }
313
+ if (opts.unrecomputablePointers > 0) {
314
+ return {
315
+ status: 'unknown',
316
+ note: `${opts.unrecomputablePointers} locator commitment(s) (ipfs/arweave/url) exist but were not independently re-fetched by this command`,
317
+ };
318
+ }
319
+ return { status: 'available', note: 'no content commitments on this project — nothing to serve' };
320
+ }
321
+ /** Locator representations `verifyProject` never emits a check for (only `keccak256`/`sha256`
322
+ * fields get one) — a real commitment this command cannot re-hash from the field alone: ipfs/
323
+ * arweave carry a content-addressed locator this run doesn't fetch, and url/url-template carry
324
+ * no hash at all. Reported as a `verified: null` entry, same tri-state slot a hash check uses,
325
+ * so a caller sees ONE consistent shape rather than silence for these tokens. */
326
+ const POINTER_REPRESENTATIONS = new Set([
327
+ METADATA_REPRESENTATION.ipfs,
328
+ METADATA_REPRESENTATION.arweave,
329
+ METADATA_REPRESENTATION.url,
330
+ METADATA_REPRESENTATION.urlTemplate,
331
+ ]);
332
+ /**
333
+ * Does this token's `image` field carry a locator commitment `verifyProject` never produced a
334
+ * check for? Exported and pure (a plain `fields` array in, a verdict out — no chain, no indexer)
335
+ * so #112's three-way distinction is unit-testable directly, rather than only reachable through a
336
+ * full `cmdVerifyBody` run against real chain state.
337
+ *
338
+ * Returns the representation name to report (`ipfs`/`arweave`/`url`/`url-template`) when the image
339
+ * field is a bare locator, else `null` — including when there's no `image` field at all, which is
340
+ * "no commitment", a different fact from "an unrecomputable one".
341
+ */
342
+ export function pointerOnlyImageCheck(fields) {
343
+ const image = fieldOf(fields, METADATA_FIELD.image);
344
+ if (image && POINTER_REPRESENTATIONS.has(image.representation))
345
+ return image.representation;
346
+ return null;
347
+ }
276
348
  export async function cmdVerifyBody(address, flags, emit) {
277
349
  const indexer = localIndexer();
278
350
  let state = indexer.getProject(address);
351
+ // FAIL FAST, before any scan. `abx verify`'s own help already admits it needs the project
352
+ // registered on THIS node — this is that check enforced, not just documented. Without it, a
353
+ // caller who follows the previous version of this message's advice literally (`abx add <addr>`
354
+ // with no thought to whether the project is actually served by a REMOTE resolver instead) walks
355
+ // straight into a full historical log scan on a public RPC — exactly what a cold-agent sweep hit
356
+ // (repeated HTTP 429s, minutes wasted, reads like an outage). Naming `--remote <name>` here as a
357
+ // real alternative is the fix: it steers a resolver-served project away from a needless local
358
+ // backfill instead of just failing fast into the same trap one command later.
279
359
  if (indexer.store.getRegistration(address)) {
280
360
  ({ state } = await indexer.reindex(address)); // freshest state from chain
281
361
  }
282
362
  else if (!state) {
283
- throw new Error(`${address} isn't indexed by this node. Run \`abx add ${address}\` first.`);
363
+ throw new Error(`${address} isn't registered on this node verify checks a local projection, not the chain directly, and never scans until this check passes. ` +
364
+ `Run \`abx add ${address}\` to index it here, or if a resolver already serves it, check that instead: \`abx verify ${address} --remote <name>\`.`);
284
365
  }
285
366
  if (!state)
286
367
  throw new Error(`could not load state for ${address}`);
@@ -295,7 +376,13 @@ export async function cmdVerifyBody(address, flags, emit) {
295
376
  contentIntegrity: 'no-commitments',
296
377
  renders: null,
297
378
  onChainUri: null,
298
- ok: true,
379
+ // Fail closed for the same reason as the remote lane below: this object is emitted early and
380
+ // printed in its FINAL state, so a run that dies partway must not leave `ok: true` standing.
381
+ // The verdict is assigned unconditionally further down, before the second `emit`.
382
+ ok: false,
383
+ // A SIBLING of `ok`, not a rename — see `computeAvailability`'s doc comment for why the two
384
+ // must never collapse into one bit. Filled in below, once the facts it's computed from exist.
385
+ availability: null,
299
386
  };
300
387
  emit(verifyReport);
301
388
  console.log(bold(`\n verify ${state.name ?? address}`));
@@ -309,6 +396,12 @@ export async function cmdVerifyBody(address, flags, emit) {
309
396
  const tokens = result.tokens ?? [];
310
397
  let anyCheck = false;
311
398
  let allGood = true;
399
+ // Locator commitments (ipfs/arweave/url/url-template `image` fields) `verifyProject` never
400
+ // produces a check for — see `POINTER_REPRESENTATIONS`'s doc comment. Counted SEPARATELY from
401
+ // `anyCheck`/`allGood` on purpose: a pointer this command didn't re-fetch is not an integrity
402
+ // verdict either way, so it must never move `contentIntegrity`/`ok` — it feeds `availability`
403
+ // instead, below.
404
+ let unrecomputablePointers = 0;
312
405
  for (const t of tokens) {
313
406
  const tok = state.tokens.find((s) => s.tokenId === t.tokenId);
314
407
  const life = tok?.lifecycle === 'live'
@@ -339,8 +432,27 @@ export async function cmdVerifyBody(address, flags, emit) {
339
432
  else
340
433
  info(`${ch.kind} — pointer-only, not locally recomputable`);
341
434
  }
435
+ // `verifyProject` only ever emits a check for a keccak256/sha256-committed `image` field, so a
436
+ // token whose image is a bare LOCATOR (ipfs/arweave/url/url-template) gets `t.checks === []` —
437
+ // indistinguishable, before this, from "no commitment at all". That was the real gap #112
438
+ // named: a real, independently-lockable commitment silently read as nothing. Surface it as the
439
+ // same tri-state slot a hash check uses (`verified: null`), so a caller sees one consistent
440
+ // shape instead of two different kinds of silence.
441
+ if (t.checks.length === 0 && tok) {
442
+ const pointerKind = pointerOnlyImageCheck(tok.fields);
443
+ if (pointerKind) {
444
+ unrecomputablePointers++;
445
+ verifyReport.contentChecks.push({
446
+ tokenId: t.tokenId,
447
+ lifecycle: tok.lifecycle,
448
+ kind: pointerKind,
449
+ verified: null,
450
+ });
451
+ info(`${pointerKind} — pointer-only, not locally recomputable (no outbound fetch from this command)`);
452
+ }
453
+ }
342
454
  }
343
- if (!anyCheck && !isCodeProject(state))
455
+ if (!anyCheck && unrecomputablePointers === 0 && !isCodeProject(state))
344
456
  info('no content commitments on this project');
345
457
  verifyReport.contentIntegrity = !anyCheck ? 'no-commitments' : allGood ? 'ok' : 'mismatch';
346
458
  verifyReport.ok = !anyCheck || allGood;
@@ -351,6 +463,9 @@ export async function cmdVerifyBody(address, flags, emit) {
351
463
  // placeholder? Same seam the /…/image route serves from (currentRenderArtifact @ current
352
464
  // inputsHash) — so this answers "did the render land?" with one command, right after token 0.
353
465
  let renderGap = false;
466
+ // Hoisted above the `isCodeProject` branch: `availability` (computed right after it, whether or
467
+ // not this project turns out to be a code project) needs the render counts that branch produces.
468
+ let codeRenders = null;
354
469
  if (isCodeProject(state)) {
355
470
  const client = makePublicClient({ chainKey: CHAIN });
356
471
  const storageForRender = resolveBackend(storageOptions());
@@ -439,6 +554,7 @@ export async function cmdVerifyBody(address, flags, emit) {
439
554
  // there, not here, so `missing` is a false negative for a hosted drop — hence the scope.
440
555
  scope: "this node's store",
441
556
  };
557
+ codeRenders = { minted: minted.length, present };
442
558
  emit(verifyReport);
443
559
  if (present)
444
560
  ok(`${present}/${minted.length} minted token(s): real render present (in this node's store)`);
@@ -450,6 +566,16 @@ export async function cmdVerifyBody(address, flags, emit) {
450
566
  console.log(` ${dim('else render them:')} ${bold(`abx render ${address}`)} ${dim('(once) ·')} ${bold('abx effects')} ${dim('(continuous)')}`);
451
567
  }
452
568
  }
569
+ // The availability verdict — computed here, after both the byte-integrity pass and the render
570
+ // pass above have run, from facts they already gathered (no new reads). See `computeAvailability`.
571
+ verifyReport.availability = jsonSafe(computeAvailability({
572
+ isCode: isCodeProject(state),
573
+ minted: codeRenders?.minted ?? 0,
574
+ present: codeRenders?.present ?? 0,
575
+ anyCheck,
576
+ unrecomputablePointers,
577
+ }));
578
+ emit(verifyReport);
453
579
  // The on-chain URI lane (a non-zero tokenURIRenderer, or an animation field pointing at the
454
580
  // generator): read the generator's honesty surface — onChainStatus (branch · chain-complete ·
455
581
  // unresolved refs · URL budget) — then decode tokenURI straight from the contract and report
@@ -465,9 +591,23 @@ export async function cmdVerifyBody(address, flags, emit) {
465
591
  try {
466
592
  const report = await onChainUriReport(client, state, flags.generator);
467
593
  const { status, probe } = report;
594
+ // #128: the factory-generation half of "is this pinned to a superseded deployment" already
595
+ // ships (ANCHOR_GENERATIONS + verifyCanonical, in the `canonical`/`abxGeneration` line above);
596
+ // this is the SINGLETON half — these two pointers were already read and printed here, just
597
+ // never compared. Binary by design (see `isCurrentGenerator`'s doc): current / not-current,
598
+ // never "superseded-but-known", because neither manifest keeps a prior address to recognize a
599
+ // mismatch against. A `false` here is NOT a defect report — an older-but-working renderer or
600
+ // generator still serves; it just isn't what a fresh deploy would get today.
601
+ const generatorCurrent = isCurrentGenerator(state.chainId, report.generator);
602
+ const rendererCurrent = state.tokenURIRenderer ? await isCurrentRenderer(client, state.tokenURIRenderer) : null;
468
603
  verifyReport.onChainUri = jsonSafe({
469
604
  generator: report.generator,
605
+ generatorCurrent, // true | false | null (no canonical generator recorded for this chain)
470
606
  tokenURIRenderer: state.tokenURIRenderer ?? null,
607
+ // null ⇒ no renderer set at all (nothing to compare) — distinct from `false` (set, but not
608
+ // spec v11). See `isCurrentRenderer`: a read failure ALSO reports `false`, same as a stale
609
+ // renderer — the underlying probe cannot tell those apart, so neither can this field.
610
+ tokenURIRendererCurrent: rendererCurrent,
471
611
  branch: status.branchName,
472
612
  chainComplete: !!status.chainComplete,
473
613
  unresolvedRefs: [...status.unresolvedRefs],
@@ -478,6 +618,16 @@ export async function cmdVerifyBody(address, flags, emit) {
478
618
  });
479
619
  emit(verifyReport);
480
620
  info(`on-chain URI lane: generator ${report.generator}${state.tokenURIRenderer ? ` · tokenURI renderer ${state.tokenURIRenderer}` : ' · tokenURIRenderer NOT set (animation field only — tokenURI still resolves off-chain)'}`);
621
+ if (generatorCurrent === true)
622
+ ok(`generator is the CURRENT canonical one for ${CHAIN}`);
623
+ else if (generatorCurrent === false) {
624
+ info(`generator is NOT the current canonical one for ${CHAIN} ${dim('(a prior deployment this project is pinned to, or a fully custom field renderer — it still works; repoint with')} ${bold(`abx set-field ${address} --field animation_url --representation renderer --value <generator> --collection`)}${dim(')')}`);
625
+ } // null: no canonical generator recorded for this chain at all — nothing to compare against, so say nothing rather than a false "NOT current".
626
+ if (rendererCurrent === true)
627
+ ok(`tokenURI renderer is CURRENT (spec v11)`);
628
+ else if (rendererCurrent === false) {
629
+ info(`tokenURI renderer is NOT current (spec v11) ${dim(`— an older renderer still serves fine; it just doesn't have v11's capabilities (see the renderer's changelog in deployments.ts). Repoint with`)} ${bold(`abx set-renderer ${address} --collection`)}${dim(' (and without --collection for the per-token pointer)')}`);
630
+ }
481
631
  if (status.branchName === 'template') {
482
632
  ok(`generator branch: template — the document assembles from the on-chain script chunks`);
483
633
  if (status.chainComplete) {
@@ -569,9 +719,32 @@ export async function cmdVerifyBody(address, flags, emit) {
569
719
  // `verify` checks THIS machine's store/backend, the wrong store for a hosted drop). Probes the real
570
720
  // `/…/image` route, so it accounts for the locator bridge (a 302 to ipfs/ar) exactly as a marketplace
571
721
  // sees it — the truthful "did the thumbnail land?" check after a remote render.
572
- export async function cmdVerifyRemote(address, remote) {
722
+ export async function cmdVerifyRemote(address, remote, emit) {
573
723
  const base = remote.url.replace(/\/$/, '');
574
724
  const chainId = resolveChain(CHAIN).id;
725
+ // Same shape, same field names as the local lane's `verifyReport` (cmdVerifyBody) wherever the
726
+ // two lanes answer the same question, so a caller doesn't need a second parser for `--remote`.
727
+ // `--remote --json` used to emit NOTHING at all — this command routed around `withJson` entirely
728
+ // (see `cmdVerify`) — so every field here is new, not a rename of something that already worked.
729
+ const verifyReport = {
730
+ address,
731
+ remote: base,
732
+ name: null,
733
+ watching: null,
734
+ contentChecks: [],
735
+ contentIntegrity: 'not-checked',
736
+ renders: null,
737
+ availability: null,
738
+ // FAIL CLOSED. `emit` registers this object and `withJson` prints its FINAL state, so every
739
+ // terminal path must earn its verdict — and a path that throws (a down endpoint, a 404 from a
740
+ // resolver that doesn't serve this project) never reaches one. This used to initialize `true`,
741
+ // which meant `verify --remote --json` on an unregistered project printed `"ok": true` on
742
+ // stdout while exiting 1 and saying "register it first" on stderr. `ok` is documented as the
743
+ // field CI gates on, so an un-earned `true` is the one wrong answer this payload can give:
744
+ // "we could not check" is not "it is fine". Every early return below sets it explicitly.
745
+ ok: false,
746
+ };
747
+ emit(verifyReport);
575
748
  console.log(bold(`\n verify ${address} ${dim(`(remote → ${base})`)}`));
576
749
  // A DOWN endpoint and a wrong-address endpoint are different problems with different fixes, and a
577
750
  // bare `fetch failed` says neither. Match `abx status --remote` so both commands describe the same
@@ -587,6 +760,7 @@ export async function cmdVerifyRemote(address, remote) {
587
760
  throw new Error(`resolver ${base} doesn't serve ${address} (HTTP ${stateRes.status}) — register it first: abx add ${address} --remote ${remote.name?.toLowerCase() ?? base}`);
588
761
  }
589
762
  const state = (await stateRes.json());
763
+ verifyReport.name = state.name ?? null;
590
764
  info(`serving as "${state.name ?? address}"`);
591
765
  // Is the resolver actively WATCHING the chain? Prove it from /api/watch (the meta the watcher
592
766
  // stamps each tick) so a hosted operator who can't tail the log still sees liveness — and catches
@@ -594,6 +768,8 @@ export async function cmdVerifyRemote(address, remote) {
594
768
  const watch = (await fetch(`${base}/api/watch`)
595
769
  .then((r) => (r.ok ? r.json() : null))
596
770
  .catch(() => null));
771
+ verifyReport.watching = watch?.watching ?? null;
772
+ emit(verifyReport);
597
773
  if (watch?.watching) {
598
774
  const heads = Object.entries(watch.chains).map(([ck, s]) => `${ck} @ block ${s.head}`).join(', ');
599
775
  const ageS = watch.pollAt ? Math.round((Date.now() - Date.parse(watch.pollAt)) / 1000) : null;
@@ -610,6 +786,13 @@ export async function cmdVerifyRemote(address, remote) {
610
786
  const minted = (state.tokens ?? []).filter((t) => t.lifecycle === 'live');
611
787
  if (minted.length === 0) {
612
788
  info('no tokens minted yet — mint token #0, then re-run.');
789
+ verifyReport.availability = jsonSafe({ status: 'unknown', note: 'no tokens minted yet' });
790
+ // Nothing committed yet is not a FAILURE — there is simply nothing to hash, and this path exits
791
+ // 0. Said explicitly because the initializer is now fail-closed: an un-minted project is the one
792
+ // early return that legitimately earns `ok`.
793
+ verifyReport.contentIntegrity = 'no-commitments';
794
+ verifyReport.ok = true;
795
+ emit(verifyReport);
613
796
  console.log('');
614
797
  return;
615
798
  }
@@ -647,27 +830,48 @@ export async function cmdVerifyRemote(address, remote) {
647
830
  else
648
831
  ok(`renders: ${summary}`);
649
832
  }
650
- await reportRemoteByteIntegrity(address, remote, base);
833
+ verifyReport.renders = jsonSafe({ minted: minted.length, upToDate, stale, rendering, failed, tokens: report.tokens });
834
+ verifyReport.availability = jsonSafe(computeAvailability({ isCode: report.tokens.length > 0, minted: minted.length, present: upToDate, anyCheck: false, unrecomputablePointers: 0 }));
835
+ emit(verifyReport);
836
+ const integrity = await reportRemoteByteIntegrity(address, remote, base);
837
+ verifyReport.contentIntegrity = integrity.status;
838
+ verifyReport.contentChecks = integrity.checks;
839
+ verifyReport.ok = integrity.status !== 'mismatch';
840
+ emit(verifyReport);
651
841
  return;
652
842
  }
653
843
  let gap = false;
844
+ let present = 0;
845
+ const missing = [];
654
846
  for (const t of minted) {
655
847
  const img = await fetch(`${base}/t/${chainId}/${address}/${t.tokenId}/image`, { redirect: 'manual' });
656
848
  const loc = img.headers.get('location');
657
849
  const ct = img.headers.get('content-type') ?? '';
658
- if (img.status >= 300 && img.status < 400 && loc)
850
+ if (img.status >= 300 && img.status < 400 && loc) {
851
+ present++;
659
852
  ok(`token #${t.tokenId} image: real render — resolver 302s to ${loc}`);
660
- else if (img.status === 200 && !/svg/i.test(ct))
853
+ }
854
+ else if (img.status === 200 && !/svg/i.test(ct)) {
855
+ present++;
661
856
  ok(`token #${t.tokenId} image: real render present (${ct})`);
857
+ }
662
858
  else {
663
859
  gap = true;
860
+ missing.push(t.tokenId);
664
861
  console.log(` ${c.orange}⚠${c.reset} token #${t.tokenId} image: PLACEHOLDER (${ct || 'svg'}) — run \`abx render ${address} --remote ${base}\`, or stand up the effects runner`);
665
862
  }
666
863
  }
667
864
  console.log(gap
668
865
  ? ` ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}`
669
866
  : ` ${g('✓ thumbnails are real renders')} ${dim('— served straight from the resolver.')}`);
670
- await reportRemoteByteIntegrity(address, remote, base);
867
+ verifyReport.renders = jsonSafe({ minted: minted.length, present, missing, scope: 'the resolver (raw image probe — no /effects route)' });
868
+ verifyReport.availability = jsonSafe(computeAvailability({ isCode: true, minted: minted.length, present, anyCheck: false, unrecomputablePointers: 0 }));
869
+ emit(verifyReport);
870
+ const integrity = await reportRemoteByteIntegrity(address, remote, base);
871
+ verifyReport.contentIntegrity = integrity.status;
872
+ verifyReport.contentChecks = integrity.checks;
873
+ verifyReport.ok = integrity.status !== 'mismatch';
874
+ emit(verifyReport);
671
875
  }
672
876
  /**
673
877
  * The half `abx verify --remote` was missing: do the served BYTES still hash to the on-chain
@@ -683,14 +887,14 @@ export async function cmdVerifyRemote(address, remote) {
683
887
  export async function reportRemoteByteIntegrity(address, remote, base) {
684
888
  if (!remote.token) {
685
889
  warn(`byte integrity NOT checked — that check is credentialed on the service. Set ${remote.tokenVar} (or pass --remote-token) and re-run, or run ${bold(`abx verify ${address}`)} against a node that holds the bytes.`);
686
- return;
890
+ return { status: 'not-checked', checks: [] };
687
891
  }
688
892
  let report;
689
893
  try {
690
894
  const res = await fetch(`${base}/api/project/${address}/verify`, { headers: { authorization: `Bearer ${remote.token}` } });
691
895
  if (res.status === 401 || res.status === 403) {
692
896
  warn(`byte integrity NOT checked — ${base} rejected ${tokenSourceLabel(remote)} for its verify route (the read plane served fine, so this is a credential/scoping issue, not a broken project).`);
693
- return;
897
+ return { status: 'not-checked', checks: [] };
694
898
  }
695
899
  if (!res.ok) {
696
900
  // Spec'd as part of `abx-token-api/v1`, so a conforming service has it — but say it neutrally:
@@ -698,23 +902,24 @@ export async function reportRemoteByteIntegrity(address, remote, base) {
698
902
  warn(`byte integrity NOT checked — ${base} serves no /api/project/…/verify route (HTTP ${res.status}). ` +
699
903
  `Your own node? Redeploy it (\`abx deploy-resolver\`). A provider's? It's part of abx-token-api/v1 — ask them for it. ` +
700
904
  `Meanwhile ${bold(`abx verify ${address}`)} checks the bytes on a node that holds them.`);
701
- return;
905
+ return { status: 'not-checked', checks: [] };
702
906
  }
703
907
  report = (await res.json());
704
908
  }
705
909
  catch {
706
910
  warn(`byte integrity NOT checked — couldn't reach ${base}'s verify route.`);
707
- return;
911
+ return { status: 'not-checked', checks: [] };
708
912
  }
709
913
  const checked = (report.tokens ?? []).filter((t) => t.checks.length > 0);
710
914
  if (checked.length === 0) {
711
915
  info(`bytes ${dim('no on-chain byte commitment to check (this project commits no image hash)')}`);
712
- return;
916
+ return { status: 'no-commitments', checks: [] };
713
917
  }
918
+ const flatChecks = checked.flatMap((t) => t.checks.map((k) => ({ tokenId: t.tokenId, kind: k.kind, verified: k.verified })));
714
919
  const bad = checked.filter((t) => t.checks.some((k) => !k.verified));
715
920
  if (bad.length === 0) {
716
921
  ok(`bytes: ${checked.length} token(s) hash-match their on-chain commitment ${dim('(what the service serves IS what the chain vouches for)')}`);
717
- return;
922
+ return { status: 'ok', checks: flatChecks };
718
923
  }
719
924
  const spec = remote.name?.toLowerCase() ?? base;
720
925
  console.log(` ${c.red}✗${c.reset} ${bold('BYTE MISMATCH')} on token(s) ${bad.map((t) => `#${t.tokenId}`).join(', ')} — ${base} does NOT serve bytes that hash to the on-chain commitment ` +
@@ -722,6 +927,7 @@ export async function reportRemoteByteIntegrity(address, remote, base) {
722
927
  info(`durable bytes exist (ipfs://, ar://) but weren't bridged → ${bold(`abx add ${address} --remote ${spec}`)} forwards the locator, then re-run this.`);
723
928
  info(`the bytes only exist on THIS machine (local fs custody) → a hosted resolver can never serve them: ${bold('abx storage upload')} to a durable backend + re-point the field, or serve the project from a node that holds them.`);
724
929
  process.exitCode = 1; // same rule as the local lane: an integrity mismatch fails the command
930
+ return { status: 'mismatch', checks: flatChecks };
725
931
  }
726
932
  // ── state: a one-glance operational snapshot of a deployed contract, read straight from chain ──
727
933
  // Read-only (no tx, no local index). Series-only getters revert on a 1/1, so each read is defensive
@@ -761,6 +967,16 @@ export const STATE_ABI = [
761
967
  // Shown because it is the one setting an owner can re-point mid-sale that changes what a LATER
762
968
  // buyer receives; `SeedSourceSet` puts it on the spine, and this makes it readable in one command.
763
969
  { type: 'function', name: 'seedSource', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
970
+ // The irreversible locks #122 asked `state` to report in full (previously only `paramHooksLocked`
971
+ // showed here). `tokenURILocked`/`contractURILocked`/`contractFieldLocked` exist on every ABX
972
+ // token (baseline URI + On-Chain Metadata surface); `scriptLocked`/`dependenciesLocked` exist only
973
+ // on the two code twins — see `readCollectionLocks`, which gates those two on `isCode` rather than
974
+ // trusting a revert to mean "unknown" for a contract that never had the surface at all.
975
+ { type: 'function', name: 'tokenURILocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
976
+ { type: 'function', name: 'contractURILocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
977
+ { type: 'function', name: 'scriptLocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
978
+ { type: 'function', name: 'dependenciesLocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
979
+ { type: 'function', name: 'contractFieldLocked', stateMutability: 'view', inputs: [{ type: 'bytes32' }], outputs: [{ type: 'bool' }] },
764
980
  ];
765
981
  /** The edition twin of {@link STATE_ABI} — `owner`/`maxInvocations`/`paused`/`minter`/
766
982
  * `primaryPayee`/`tokenURIRenderer`/`royaltyInfo` are shared, unchanged, function names (see
@@ -785,6 +1001,13 @@ export const EDITION_STATE_ABI = [
785
1001
  // those two getters, shared with every other integrator) — deliberately not re-declared here.
786
1002
  // Seed Source extension — EditionCode composes it; the image editions don't. See STATE_ABI's note.
787
1003
  { type: 'function', name: 'seedSource', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
1004
+ // The same lock surface as STATE_ABI's edit — see the note there. Identical getter names on both
1005
+ // standards (verified against the generated ABI: every edition twin exposes the same five).
1006
+ { type: 'function', name: 'tokenURILocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
1007
+ { type: 'function', name: 'contractURILocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
1008
+ { type: 'function', name: 'scriptLocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
1009
+ { type: 'function', name: 'dependenciesLocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
1010
+ { type: 'function', name: 'contractFieldLocked', stateMutability: 'view', inputs: [{ type: 'bytes32' }], outputs: [{ type: 'bool' }] },
788
1011
  ];
789
1012
  /**
790
1013
  * The royalty ceiling line — and the headroom, which is the part a creator has not been told.
@@ -876,6 +1099,101 @@ function warnNotCanonical(address) {
876
1099
  `(a bespoke contract, a superseded factory), nothing here is broken — just make sure whoever owns the ` +
877
1100
  `project knows they chose it.`);
878
1101
  }
1102
+ /**
1103
+ * The bounded field-lock scan #122's triage settled on: every field this node can enumerate is one
1104
+ * of the protocol registry's `METADATA_FIELD` keys (site/content/docs/protocol/metadata.mdx), read
1105
+ * with `contractFieldLocked`. A creator's own custom field (`stems`, `palette`, …) is a real,
1106
+ * independently-lockable key — but this node can only learn its NAME from the spine's
1107
+ * `ContractFieldLocked` event, which needs an indexer, not a bare head read. So this reports "none
1108
+ * of the STANDARD fields are locked", never "no fields are locked" — the caveat has to travel with
1109
+ * the data, not live only in a comment a caller never sees.
1110
+ */
1111
+ async function readFieldLocks(publicClient, address, abi) {
1112
+ return Promise.all(Object.values(METADATA_FIELD).map(async (field) => ({
1113
+ field,
1114
+ locked: (await tryReadContract(publicClient, { address, abi, functionName: 'contractFieldLocked', args: [encodeTag(field)] })) ?? null,
1115
+ })));
1116
+ }
1117
+ /**
1118
+ * Read every applicable irreversible lock for one collection: token/contract URI config, the
1119
+ * bounded standard field set, and — for a code project — the script and dependency freezes.
1120
+ * `paramHooks.locked` is deliberately NOT read here: `cmdStateBody`/`cmdStateEditionBody` already
1121
+ * read it (`readParamHooksLocked`) as part of the existing param-hooks readout, and re-reading it
1122
+ * would cost a second `eth_call` for a fact already on hand — see the callers, which pass it into
1123
+ * {@link printLocks} instead.
1124
+ *
1125
+ * `isCode` gates `scriptLocked`/`dependenciesLocked`: those getters exist ONLY on the two code
1126
+ * twins (SeriesCode/EditionCode), so asking a plain image/Series contract for them isn't a failed
1127
+ * read to report as "unknown" — it's a surface that was never there, reported as `null` (not
1128
+ * applicable) via the OUTER object rather than the boolean inside it.
1129
+ */
1130
+ export async function readCollectionLocks(publicClient, address, abi, isCode) {
1131
+ const read = (fn) => tryReadContract(publicClient, { address, abi, functionName: fn });
1132
+ const [tokenURI, contractURI, fields, script, dependencies] = await Promise.all([
1133
+ read('tokenURILocked'),
1134
+ read('contractURILocked'),
1135
+ readFieldLocks(publicClient, address, abi),
1136
+ isCode ? read('scriptLocked') : Promise.resolve(undefined),
1137
+ isCode ? read('dependenciesLocked') : Promise.resolve(undefined),
1138
+ ]);
1139
+ return {
1140
+ tokenURI: tokenURI ?? null,
1141
+ contractURI: contractURI ?? null,
1142
+ fields,
1143
+ script: isCode ? { locked: script ?? null } : null,
1144
+ dependencies: isCode ? { locked: dependencies ?? null } : null,
1145
+ };
1146
+ }
1147
+ /** One lock's human verdict — the same three words everywhere this prints, so a reader learns the
1148
+ * vocabulary once. */
1149
+ function lockVerdict(locked) {
1150
+ return locked === true
1151
+ ? `${c.green}frozen${c.reset}`
1152
+ : locked === false
1153
+ ? `${c.orange}unlocked${c.reset}`
1154
+ : dim('unknown (node refused the check)');
1155
+ }
1156
+ /**
1157
+ * Print + emit the "Locks" section shared by both `state` bodies (721 and edition) — the one
1158
+ * coherent summary #122 asked for, in place of a lone `paramHooks.locked` line. Every lock is its
1159
+ * own independent switch (freezing one does nothing to any other), which is easy to miss when each
1160
+ * only ever appeared alone — so the header says so explicitly, and every line names the exact
1161
+ * command that freezes THAT lock and nothing else.
1162
+ */
1163
+ function printLocks(address, locks, hasParamHooks, paramHooksLock, payload, emit) {
1164
+ const lockedFieldNames = locks.fields.filter((f) => f.locked === true).map((f) => f.field);
1165
+ const unknownFieldCount = locks.fields.filter((f) => f.locked === null).length;
1166
+ payload.locks = jsonSafe({
1167
+ tokenURI: locks.tokenURI,
1168
+ contractURI: locks.contractURI,
1169
+ script: locks.script,
1170
+ dependencies: locks.dependencies,
1171
+ // `null` ⇒ no ConfigurableParams surface on this contract (not a code project) — matching the
1172
+ // `script`/`dependencies` null-means-N/A convention rather than reusing LockRead's "unknown".
1173
+ paramHooks: hasParamHooks ? (paramHooksLock ?? null) : null,
1174
+ fields: {
1175
+ // The bounded scan, keyed by name — automation gets the same three-way distinction the human
1176
+ // line does (true/false/null), never a collapsed "none locked".
1177
+ checked: locks.fields,
1178
+ note: "checks only the standard METADATA_FIELD set (site/content/docs/protocol/metadata.mdx); a project's own custom field keys are real and independently lockable but are NOT enumerated here — that needs an indexer over ContractFieldLocked events.",
1179
+ },
1180
+ });
1181
+ emit(payload);
1182
+ console.log(`\n ${bold('Locks')} ${dim('each is INDEPENDENT — freezing one does nothing to the others')}`);
1183
+ info(`tokenURI ${lockVerdict(locks.tokenURI)} ${dim(`\`abx lock-uri ${address}\` freezes the pointer + renderer forever`)}`);
1184
+ info(`contractURI ${lockVerdict(locks.contractURI)} ${dim(`\`abx lock-uri ${address} --collection\` freezes the pointer + renderer forever`)}`);
1185
+ if (locks.script)
1186
+ info(`script ${lockVerdict(locks.script.locked)} ${dim(`\`abx lock-script ${address}\` freezes the program bytes forever`)}`);
1187
+ if (locks.dependencies)
1188
+ info(`dependencies ${lockVerdict(locks.dependencies.locked)} ${dim(`\`abx lock-dependencies ${address}\` freezes the library set forever`)}`);
1189
+ if (hasParamHooks)
1190
+ info(`param hooks ${lockVerdict(paramHooksLock ?? null)} ${dim(`\`abx lock-param-hooks ${address}\` freezes all three hook addresses forever (detail above)`)}`);
1191
+ info(lockedFieldNames.length
1192
+ ? `fields ${c.green}locked${c.reset}: ${lockedFieldNames.join(', ')}`
1193
+ : `fields ${dim('none of the standard fields are locked')}`);
1194
+ info(dim(` checked the standard METADATA_FIELD set only (${locks.fields.length} keys${unknownFieldCount ? `, ${unknownFieldCount} unreadable` : ''}) — a custom field key is real and lockable but not enumerable without an indexer. ` +
1195
+ `\`abx lock-field ${address} --field <name> [--collection | --token 0]\` freezes one.`));
1196
+ }
879
1197
  export async function cmdState(address, flags) {
880
1198
  if (!address || address.startsWith('--')) {
881
1199
  console.error('usage: abx state <address> — a read-only, on-chain operational snapshot\n');
@@ -1001,6 +1319,10 @@ export async function cmdStateBody(address, flags, emit) {
1001
1319
  // are maintained by the contract's own write paths, so there is no list to drift. Read-only,
1002
1320
  // best-effort, and skipped entirely for a project that has none (a 1/1, a legacy pre-enumeration
1003
1321
  // project, or a code project with no params).
1322
+ // Hoisted above the try block: the Locks section (printed after it, see `printLocks`) reuses
1323
+ // whatever this reads rather than paying for a second `eth_call` on the same fact.
1324
+ let hooks = null;
1325
+ let paramHooksLock;
1004
1326
  try {
1005
1327
  const rawKeys = await readParamSchemaKeys(publicClient, address);
1006
1328
  const enumerable = rawKeys !== null;
@@ -1054,9 +1376,9 @@ export async function cmdStateBody(address, flags, emit) {
1054
1376
  // transfers and mints, so "which hooks, and are they frozen" is exactly the pair someone needs
1055
1377
  // before buying. Printed whenever the surface exists — including with all three unset, because
1056
1378
  // "none, and frozen" is the strongest thing this line can say and it must be visible.
1057
- const hooks = await readParamHooks(publicClient, address);
1379
+ hooks = await readParamHooks(publicClient, address);
1058
1380
  if (hooks) {
1059
- const locked = await readParamHooksLocked(publicClient, address);
1381
+ paramHooksLock = await readParamHooksLocked(publicClient, address);
1060
1382
  const shown = [['configure', hooks.configureHook], ['augment', hooks.augmentHook], ['transfer', hooks.transferHook]]
1061
1383
  .filter(([, a]) => a !== zeroAddress)
1062
1384
  .map(([role, a]) => `${role} ${a}`);
@@ -1066,12 +1388,12 @@ export async function cmdStateBody(address, flags, emit) {
1066
1388
  transferHook: hooks.transferHook === zeroAddress ? null : hooks.transferHook,
1067
1389
  // `null` = could not be established (see readParamHooksLocked). Never collapse it to false:
1068
1390
  // "unknown" and "the owner can still arm a transfer veto" are different answers.
1069
- locked: locked ?? null,
1391
+ locked: paramHooksLock ?? null,
1070
1392
  });
1071
1393
  emit(payload);
1072
- const lockNote = locked === true
1394
+ const lockNote = paramHooksLock === true
1073
1395
  ? `${c.green}frozen${c.reset} ${dim('— no hook address can ever change again')}`
1074
- : locked === false
1396
+ : paramHooksLock === false
1075
1397
  ? `${c.orange}not frozen${c.reset} ${dim('— the owner can re-point these (`abx lock-param-hooks` freezes them forever)')}`
1076
1398
  : dim('freeze state unknown (the node refused the check)');
1077
1399
  info(`param hooks ${shown.length ? shown.join(' · ') : dim('none set')} ${lockNote}`);
@@ -1083,6 +1405,10 @@ export async function cmdStateBody(address, flags, emit) {
1083
1405
  catch {
1084
1406
  /* best-effort — a non-code project has no params surface to read */
1085
1407
  }
1408
+ // The coherent lock summary #122 asked for — every irreversible switch this node can read, side
1409
+ // by side, independent of whether this project happens to compose the params surface above.
1410
+ const locks = await readCollectionLocks(publicClient, address, STATE_ABI, kind.kind === 'code');
1411
+ printLocks(address, locks, !!hooks, paramHooksLock, payload, emit);
1086
1412
  console.log('');
1087
1413
  }
1088
1414
  /**
@@ -1223,8 +1549,9 @@ async function cmdStateEditionBody(address, publicClient, kind, emit) {
1223
1549
  // the same pair as the 721 body (which hooks, and whether they can still change); silent for the
1224
1550
  // image/1-of-1 edition twins, which have no params surface at all.
1225
1551
  const hooks = await readParamHooks(publicClient, address);
1552
+ let paramHooksLock;
1226
1553
  if (hooks) {
1227
- const locked = await readParamHooksLocked(publicClient, address);
1554
+ paramHooksLock = await readParamHooksLocked(publicClient, address);
1228
1555
  const shown = [['configure', hooks.configureHook], ['augment', hooks.augmentHook], ['transfer', hooks.transferHook]]
1229
1556
  .filter(([, a]) => a !== zeroAddress)
1230
1557
  .map(([role, a]) => `${role} ${a}`);
@@ -1232,12 +1559,12 @@ async function cmdStateEditionBody(address, publicClient, kind, emit) {
1232
1559
  configureHook: hooks.configureHook === zeroAddress ? null : hooks.configureHook,
1233
1560
  augmentHook: hooks.augmentHook === zeroAddress ? null : hooks.augmentHook,
1234
1561
  transferHook: hooks.transferHook === zeroAddress ? null : hooks.transferHook,
1235
- locked: locked ?? null, // null = unknown; never collapse it to false
1562
+ locked: paramHooksLock ?? null, // null = unknown; never collapse it to false
1236
1563
  });
1237
1564
  emit(payload);
1238
- const lockNote = locked === true
1565
+ const lockNote = paramHooksLock === true
1239
1566
  ? `${c.green}frozen${c.reset} ${dim('— no hook address can ever change again')}`
1240
- : locked === false
1567
+ : paramHooksLock === false
1241
1568
  ? `${c.orange}not frozen${c.reset} ${dim('— the owner can re-point these (`abx lock-param-hooks` freezes them forever)')}`
1242
1569
  : dim('freeze state unknown (the node refused the check)');
1243
1570
  info(`param hooks ${shown.length ? shown.join(' · ') : dim('none set')} ${lockNote}`);
@@ -1245,6 +1572,9 @@ async function cmdStateEditionBody(address, publicClient, kind, emit) {
1245
1572
  info(dim(` the transfer hook is a VETO: if it reverts, the transfer fails — and mints too (mint = transfer from 0x0).`));
1246
1573
  }
1247
1574
  }
1575
+ // The coherent lock summary #122 asked for — see cmdStateBody's identical call for why.
1576
+ const locks = await readCollectionLocks(publicClient, address, EDITION_STATE_ABI, kind.kind === 'edition-code');
1577
+ printLocks(address, locks, !!hooks, paramHooksLock, payload, emit);
1248
1578
  console.log('');
1249
1579
  }
1250
1580
  /**