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

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 (72) hide show
  1. package/CHANGELOG.md +145 -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.map +1 -1
  10. package/dist/commands/deploy.js +731 -65
  11. package/dist/commands/deploy.js.map +1 -1
  12. package/dist/commands/maintenance.d.ts +4 -0
  13. package/dist/commands/maintenance.d.ts.map +1 -0
  14. package/dist/commands/maintenance.js +114 -0
  15. package/dist/commands/maintenance.js.map +1 -0
  16. package/dist/commands/project.d.ts +175 -3
  17. package/dist/commands/project.d.ts.map +1 -1
  18. package/dist/commands/project.js +333 -27
  19. package/dist/commands/project.js.map +1 -1
  20. package/dist/commands/reads.d.ts +21 -0
  21. package/dist/commands/reads.d.ts.map +1 -1
  22. package/dist/commands/reads.js +171 -4
  23. package/dist/commands/reads.js.map +1 -1
  24. package/dist/commands/scaffold.d.ts.map +1 -1
  25. package/dist/commands/scaffold.js +5 -0
  26. package/dist/commands/scaffold.js.map +1 -1
  27. package/dist/commands/service.d.ts.map +1 -1
  28. package/dist/commands/service.js +7 -0
  29. package/dist/commands/service.js.map +1 -1
  30. package/dist/commands/storage.d.ts.map +1 -1
  31. package/dist/commands/storage.js +32 -3
  32. package/dist/commands/storage.js.map +1 -1
  33. package/dist/commands/submit-app.d.ts +43 -0
  34. package/dist/commands/submit-app.d.ts.map +1 -1
  35. package/dist/commands/submit-app.js +115 -6
  36. package/dist/commands/submit-app.js.map +1 -1
  37. package/dist/config.d.ts +16 -1
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/config.js +73 -7
  40. package/dist/config.js.map +1 -1
  41. package/dist/deploy-plan.d.ts +150 -0
  42. package/dist/deploy-plan.d.ts.map +1 -0
  43. package/dist/deploy-plan.js +31 -0
  44. package/dist/deploy-plan.js.map +1 -0
  45. package/dist/flag-allowlists.d.ts.map +1 -1
  46. package/dist/flag-allowlists.js +5 -1
  47. package/dist/flag-allowlists.js.map +1 -1
  48. package/dist/flags.d.ts +6 -0
  49. package/dist/flags.d.ts.map +1 -1
  50. package/dist/flags.js +14 -0
  51. package/dist/flags.js.map +1 -1
  52. package/dist/jsonout.d.ts.map +1 -1
  53. package/dist/jsonout.js +11 -0
  54. package/dist/jsonout.js.map +1 -1
  55. package/dist/main.js +46 -7
  56. package/dist/main.js.map +1 -1
  57. package/dist/ownerops.d.ts +69 -0
  58. package/dist/ownerops.d.ts.map +1 -1
  59. package/dist/ownerops.js +221 -17
  60. package/dist/ownerops.js.map +1 -1
  61. package/dist/script-chunks.d.ts +1 -1
  62. package/dist/script-chunks.d.ts.map +1 -1
  63. package/dist/script-chunks.js +2 -2
  64. package/dist/script-chunks.js.map +1 -1
  65. package/package.json +6 -6
  66. package/skill/SKILL.md +6 -4
  67. package/skill/reference/code.md +17 -8
  68. package/skill/reference/deploy.md +9 -2
  69. package/skill/reference/diagnose.md +15 -2
  70. package/skill/reference/hosting.md +26 -0
  71. package/skill/reference/operate.md +43 -4
  72. 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,13 +272,79 @@ 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);
@@ -296,6 +368,9 @@ export async function cmdVerifyBody(address, flags, emit) {
296
368
  renders: null,
297
369
  onChainUri: null,
298
370
  ok: true,
371
+ // A SIBLING of `ok`, not a rename — see `computeAvailability`'s doc comment for why the two
372
+ // must never collapse into one bit. Filled in below, once the facts it's computed from exist.
373
+ availability: null,
299
374
  };
300
375
  emit(verifyReport);
301
376
  console.log(bold(`\n verify ${state.name ?? address}`));
@@ -309,6 +384,12 @@ export async function cmdVerifyBody(address, flags, emit) {
309
384
  const tokens = result.tokens ?? [];
310
385
  let anyCheck = false;
311
386
  let allGood = true;
387
+ // Locator commitments (ipfs/arweave/url/url-template `image` fields) `verifyProject` never
388
+ // produces a check for — see `POINTER_REPRESENTATIONS`'s doc comment. Counted SEPARATELY from
389
+ // `anyCheck`/`allGood` on purpose: a pointer this command didn't re-fetch is not an integrity
390
+ // verdict either way, so it must never move `contentIntegrity`/`ok` — it feeds `availability`
391
+ // instead, below.
392
+ let unrecomputablePointers = 0;
312
393
  for (const t of tokens) {
313
394
  const tok = state.tokens.find((s) => s.tokenId === t.tokenId);
314
395
  const life = tok?.lifecycle === 'live'
@@ -339,8 +420,27 @@ export async function cmdVerifyBody(address, flags, emit) {
339
420
  else
340
421
  info(`${ch.kind} — pointer-only, not locally recomputable`);
341
422
  }
423
+ // `verifyProject` only ever emits a check for a keccak256/sha256-committed `image` field, so a
424
+ // token whose image is a bare LOCATOR (ipfs/arweave/url/url-template) gets `t.checks === []` —
425
+ // indistinguishable, before this, from "no commitment at all". That was the real gap #112
426
+ // named: a real, independently-lockable commitment silently read as nothing. Surface it as the
427
+ // same tri-state slot a hash check uses (`verified: null`), so a caller sees one consistent
428
+ // shape instead of two different kinds of silence.
429
+ if (t.checks.length === 0 && tok) {
430
+ const pointerKind = pointerOnlyImageCheck(tok.fields);
431
+ if (pointerKind) {
432
+ unrecomputablePointers++;
433
+ verifyReport.contentChecks.push({
434
+ tokenId: t.tokenId,
435
+ lifecycle: tok.lifecycle,
436
+ kind: pointerKind,
437
+ verified: null,
438
+ });
439
+ info(`${pointerKind} — pointer-only, not locally recomputable (no outbound fetch from this command)`);
440
+ }
441
+ }
342
442
  }
343
- if (!anyCheck && !isCodeProject(state))
443
+ if (!anyCheck && unrecomputablePointers === 0 && !isCodeProject(state))
344
444
  info('no content commitments on this project');
345
445
  verifyReport.contentIntegrity = !anyCheck ? 'no-commitments' : allGood ? 'ok' : 'mismatch';
346
446
  verifyReport.ok = !anyCheck || allGood;
@@ -351,6 +451,9 @@ export async function cmdVerifyBody(address, flags, emit) {
351
451
  // placeholder? Same seam the /…/image route serves from (currentRenderArtifact @ current
352
452
  // inputsHash) — so this answers "did the render land?" with one command, right after token 0.
353
453
  let renderGap = false;
454
+ // Hoisted above the `isCodeProject` branch: `availability` (computed right after it, whether or
455
+ // not this project turns out to be a code project) needs the render counts that branch produces.
456
+ let codeRenders = null;
354
457
  if (isCodeProject(state)) {
355
458
  const client = makePublicClient({ chainKey: CHAIN });
356
459
  const storageForRender = resolveBackend(storageOptions());
@@ -439,6 +542,7 @@ export async function cmdVerifyBody(address, flags, emit) {
439
542
  // there, not here, so `missing` is a false negative for a hosted drop — hence the scope.
440
543
  scope: "this node's store",
441
544
  };
545
+ codeRenders = { minted: minted.length, present };
442
546
  emit(verifyReport);
443
547
  if (present)
444
548
  ok(`${present}/${minted.length} minted token(s): real render present (in this node's store)`);
@@ -450,6 +554,16 @@ export async function cmdVerifyBody(address, flags, emit) {
450
554
  console.log(` ${dim('else render them:')} ${bold(`abx render ${address}`)} ${dim('(once) ·')} ${bold('abx effects')} ${dim('(continuous)')}`);
451
555
  }
452
556
  }
557
+ // The availability verdict — computed here, after both the byte-integrity pass and the render
558
+ // pass above have run, from facts they already gathered (no new reads). See `computeAvailability`.
559
+ verifyReport.availability = jsonSafe(computeAvailability({
560
+ isCode: isCodeProject(state),
561
+ minted: codeRenders?.minted ?? 0,
562
+ present: codeRenders?.present ?? 0,
563
+ anyCheck,
564
+ unrecomputablePointers,
565
+ }));
566
+ emit(verifyReport);
453
567
  // The on-chain URI lane (a non-zero tokenURIRenderer, or an animation field pointing at the
454
568
  // generator): read the generator's honesty surface — onChainStatus (branch · chain-complete ·
455
569
  // unresolved refs · URL budget) — then decode tokenURI straight from the contract and report
@@ -465,9 +579,23 @@ export async function cmdVerifyBody(address, flags, emit) {
465
579
  try {
466
580
  const report = await onChainUriReport(client, state, flags.generator);
467
581
  const { status, probe } = report;
582
+ // #128: the factory-generation half of "is this pinned to a superseded deployment" already
583
+ // ships (ANCHOR_GENERATIONS + verifyCanonical, in the `canonical`/`abxGeneration` line above);
584
+ // this is the SINGLETON half — these two pointers were already read and printed here, just
585
+ // never compared. Binary by design (see `isCurrentGenerator`'s doc): current / not-current,
586
+ // never "superseded-but-known", because neither manifest keeps a prior address to recognize a
587
+ // mismatch against. A `false` here is NOT a defect report — an older-but-working renderer or
588
+ // generator still serves; it just isn't what a fresh deploy would get today.
589
+ const generatorCurrent = isCurrentGenerator(state.chainId, report.generator);
590
+ const rendererCurrent = state.tokenURIRenderer ? await isCurrentRenderer(client, state.tokenURIRenderer) : null;
468
591
  verifyReport.onChainUri = jsonSafe({
469
592
  generator: report.generator,
593
+ generatorCurrent, // true | false | null (no canonical generator recorded for this chain)
470
594
  tokenURIRenderer: state.tokenURIRenderer ?? null,
595
+ // null ⇒ no renderer set at all (nothing to compare) — distinct from `false` (set, but not
596
+ // spec v11). See `isCurrentRenderer`: a read failure ALSO reports `false`, same as a stale
597
+ // renderer — the underlying probe cannot tell those apart, so neither can this field.
598
+ tokenURIRendererCurrent: rendererCurrent,
471
599
  branch: status.branchName,
472
600
  chainComplete: !!status.chainComplete,
473
601
  unresolvedRefs: [...status.unresolvedRefs],
@@ -478,6 +606,16 @@ export async function cmdVerifyBody(address, flags, emit) {
478
606
  });
479
607
  emit(verifyReport);
480
608
  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)'}`);
609
+ if (generatorCurrent === true)
610
+ ok(`generator is the CURRENT canonical one for ${CHAIN}`);
611
+ else if (generatorCurrent === false) {
612
+ 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(')')}`);
613
+ } // null: no canonical generator recorded for this chain at all — nothing to compare against, so say nothing rather than a false "NOT current".
614
+ if (rendererCurrent === true)
615
+ ok(`tokenURI renderer is CURRENT (spec v11)`);
616
+ else if (rendererCurrent === false) {
617
+ 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)')}`);
618
+ }
481
619
  if (status.branchName === 'template') {
482
620
  ok(`generator branch: template — the document assembles from the on-chain script chunks`);
483
621
  if (status.chainComplete) {
@@ -569,9 +707,25 @@ export async function cmdVerifyBody(address, flags, emit) {
569
707
  // `verify` checks THIS machine's store/backend, the wrong store for a hosted drop). Probes the real
570
708
  // `/…/image` route, so it accounts for the locator bridge (a 302 to ipfs/ar) exactly as a marketplace
571
709
  // sees it — the truthful "did the thumbnail land?" check after a remote render.
572
- export async function cmdVerifyRemote(address, remote) {
710
+ export async function cmdVerifyRemote(address, remote, emit) {
573
711
  const base = remote.url.replace(/\/$/, '');
574
712
  const chainId = resolveChain(CHAIN).id;
713
+ // Same shape, same field names as the local lane's `verifyReport` (cmdVerifyBody) wherever the
714
+ // two lanes answer the same question, so a caller doesn't need a second parser for `--remote`.
715
+ // `--remote --json` used to emit NOTHING at all — this command routed around `withJson` entirely
716
+ // (see `cmdVerify`) — so every field here is new, not a rename of something that already worked.
717
+ const verifyReport = {
718
+ address,
719
+ remote: base,
720
+ name: null,
721
+ watching: null,
722
+ contentChecks: [],
723
+ contentIntegrity: 'no-commitments',
724
+ renders: null,
725
+ availability: null,
726
+ ok: true,
727
+ };
728
+ emit(verifyReport);
575
729
  console.log(bold(`\n verify ${address} ${dim(`(remote → ${base})`)}`));
576
730
  // A DOWN endpoint and a wrong-address endpoint are different problems with different fixes, and a
577
731
  // bare `fetch failed` says neither. Match `abx status --remote` so both commands describe the same
@@ -587,6 +741,7 @@ export async function cmdVerifyRemote(address, remote) {
587
741
  throw new Error(`resolver ${base} doesn't serve ${address} (HTTP ${stateRes.status}) — register it first: abx add ${address} --remote ${remote.name?.toLowerCase() ?? base}`);
588
742
  }
589
743
  const state = (await stateRes.json());
744
+ verifyReport.name = state.name ?? null;
590
745
  info(`serving as "${state.name ?? address}"`);
591
746
  // Is the resolver actively WATCHING the chain? Prove it from /api/watch (the meta the watcher
592
747
  // stamps each tick) so a hosted operator who can't tail the log still sees liveness — and catches
@@ -594,6 +749,8 @@ export async function cmdVerifyRemote(address, remote) {
594
749
  const watch = (await fetch(`${base}/api/watch`)
595
750
  .then((r) => (r.ok ? r.json() : null))
596
751
  .catch(() => null));
752
+ verifyReport.watching = watch?.watching ?? null;
753
+ emit(verifyReport);
597
754
  if (watch?.watching) {
598
755
  const heads = Object.entries(watch.chains).map(([ck, s]) => `${ck} @ block ${s.head}`).join(', ');
599
756
  const ageS = watch.pollAt ? Math.round((Date.now() - Date.parse(watch.pollAt)) / 1000) : null;
@@ -610,6 +767,8 @@ export async function cmdVerifyRemote(address, remote) {
610
767
  const minted = (state.tokens ?? []).filter((t) => t.lifecycle === 'live');
611
768
  if (minted.length === 0) {
612
769
  info('no tokens minted yet — mint token #0, then re-run.');
770
+ verifyReport.availability = jsonSafe({ status: 'unknown', note: 'no tokens minted yet' });
771
+ emit(verifyReport);
613
772
  console.log('');
614
773
  return;
615
774
  }
@@ -647,27 +806,48 @@ export async function cmdVerifyRemote(address, remote) {
647
806
  else
648
807
  ok(`renders: ${summary}`);
649
808
  }
650
- await reportRemoteByteIntegrity(address, remote, base);
809
+ verifyReport.renders = jsonSafe({ minted: minted.length, upToDate, stale, rendering, failed, tokens: report.tokens });
810
+ verifyReport.availability = jsonSafe(computeAvailability({ isCode: report.tokens.length > 0, minted: minted.length, present: upToDate, anyCheck: false, unrecomputablePointers: 0 }));
811
+ emit(verifyReport);
812
+ const integrity = await reportRemoteByteIntegrity(address, remote, base);
813
+ verifyReport.contentIntegrity = integrity.status;
814
+ verifyReport.contentChecks = integrity.checks;
815
+ verifyReport.ok = integrity.status !== 'mismatch';
816
+ emit(verifyReport);
651
817
  return;
652
818
  }
653
819
  let gap = false;
820
+ let present = 0;
821
+ const missing = [];
654
822
  for (const t of minted) {
655
823
  const img = await fetch(`${base}/t/${chainId}/${address}/${t.tokenId}/image`, { redirect: 'manual' });
656
824
  const loc = img.headers.get('location');
657
825
  const ct = img.headers.get('content-type') ?? '';
658
- if (img.status >= 300 && img.status < 400 && loc)
826
+ if (img.status >= 300 && img.status < 400 && loc) {
827
+ present++;
659
828
  ok(`token #${t.tokenId} image: real render — resolver 302s to ${loc}`);
660
- else if (img.status === 200 && !/svg/i.test(ct))
829
+ }
830
+ else if (img.status === 200 && !/svg/i.test(ct)) {
831
+ present++;
661
832
  ok(`token #${t.tokenId} image: real render present (${ct})`);
833
+ }
662
834
  else {
663
835
  gap = true;
836
+ missing.push(t.tokenId);
664
837
  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
838
  }
666
839
  }
667
840
  console.log(gap
668
841
  ? ` ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}`
669
842
  : ` ${g('✓ thumbnails are real renders')} ${dim('— served straight from the resolver.')}`);
670
- await reportRemoteByteIntegrity(address, remote, base);
843
+ verifyReport.renders = jsonSafe({ minted: minted.length, present, missing, scope: 'the resolver (raw image probe — no /effects route)' });
844
+ verifyReport.availability = jsonSafe(computeAvailability({ isCode: true, minted: minted.length, present, anyCheck: false, unrecomputablePointers: 0 }));
845
+ emit(verifyReport);
846
+ const integrity = await reportRemoteByteIntegrity(address, remote, base);
847
+ verifyReport.contentIntegrity = integrity.status;
848
+ verifyReport.contentChecks = integrity.checks;
849
+ verifyReport.ok = integrity.status !== 'mismatch';
850
+ emit(verifyReport);
671
851
  }
672
852
  /**
673
853
  * The half `abx verify --remote` was missing: do the served BYTES still hash to the on-chain
@@ -683,14 +863,14 @@ export async function cmdVerifyRemote(address, remote) {
683
863
  export async function reportRemoteByteIntegrity(address, remote, base) {
684
864
  if (!remote.token) {
685
865
  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;
866
+ return { status: 'not-checked', checks: [] };
687
867
  }
688
868
  let report;
689
869
  try {
690
870
  const res = await fetch(`${base}/api/project/${address}/verify`, { headers: { authorization: `Bearer ${remote.token}` } });
691
871
  if (res.status === 401 || res.status === 403) {
692
872
  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;
873
+ return { status: 'not-checked', checks: [] };
694
874
  }
695
875
  if (!res.ok) {
696
876
  // Spec'd as part of `abx-token-api/v1`, so a conforming service has it — but say it neutrally:
@@ -698,23 +878,24 @@ export async function reportRemoteByteIntegrity(address, remote, base) {
698
878
  warn(`byte integrity NOT checked — ${base} serves no /api/project/…/verify route (HTTP ${res.status}). ` +
699
879
  `Your own node? Redeploy it (\`abx deploy-resolver\`). A provider's? It's part of abx-token-api/v1 — ask them for it. ` +
700
880
  `Meanwhile ${bold(`abx verify ${address}`)} checks the bytes on a node that holds them.`);
701
- return;
881
+ return { status: 'not-checked', checks: [] };
702
882
  }
703
883
  report = (await res.json());
704
884
  }
705
885
  catch {
706
886
  warn(`byte integrity NOT checked — couldn't reach ${base}'s verify route.`);
707
- return;
887
+ return { status: 'not-checked', checks: [] };
708
888
  }
709
889
  const checked = (report.tokens ?? []).filter((t) => t.checks.length > 0);
710
890
  if (checked.length === 0) {
711
891
  info(`bytes ${dim('no on-chain byte commitment to check (this project commits no image hash)')}`);
712
- return;
892
+ return { status: 'no-commitments', checks: [] };
713
893
  }
894
+ const flatChecks = checked.flatMap((t) => t.checks.map((k) => ({ tokenId: t.tokenId, kind: k.kind, verified: k.verified })));
714
895
  const bad = checked.filter((t) => t.checks.some((k) => !k.verified));
715
896
  if (bad.length === 0) {
716
897
  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;
898
+ return { status: 'ok', checks: flatChecks };
718
899
  }
719
900
  const spec = remote.name?.toLowerCase() ?? base;
720
901
  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 +903,7 @@ export async function reportRemoteByteIntegrity(address, remote, base) {
722
903
  info(`durable bytes exist (ipfs://, ar://) but weren't bridged → ${bold(`abx add ${address} --remote ${spec}`)} forwards the locator, then re-run this.`);
723
904
  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
905
  process.exitCode = 1; // same rule as the local lane: an integrity mismatch fails the command
906
+ return { status: 'mismatch', checks: flatChecks };
725
907
  }
726
908
  // ── state: a one-glance operational snapshot of a deployed contract, read straight from chain ──
727
909
  // Read-only (no tx, no local index). Series-only getters revert on a 1/1, so each read is defensive
@@ -761,6 +943,16 @@ export const STATE_ABI = [
761
943
  // Shown because it is the one setting an owner can re-point mid-sale that changes what a LATER
762
944
  // buyer receives; `SeedSourceSet` puts it on the spine, and this makes it readable in one command.
763
945
  { type: 'function', name: 'seedSource', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
946
+ // The irreversible locks #122 asked `state` to report in full (previously only `paramHooksLocked`
947
+ // showed here). `tokenURILocked`/`contractURILocked`/`contractFieldLocked` exist on every ABX
948
+ // token (baseline URI + On-Chain Metadata surface); `scriptLocked`/`dependenciesLocked` exist only
949
+ // on the two code twins — see `readCollectionLocks`, which gates those two on `isCode` rather than
950
+ // trusting a revert to mean "unknown" for a contract that never had the surface at all.
951
+ { type: 'function', name: 'tokenURILocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
952
+ { type: 'function', name: 'contractURILocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
953
+ { type: 'function', name: 'scriptLocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
954
+ { type: 'function', name: 'dependenciesLocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
955
+ { type: 'function', name: 'contractFieldLocked', stateMutability: 'view', inputs: [{ type: 'bytes32' }], outputs: [{ type: 'bool' }] },
764
956
  ];
765
957
  /** The edition twin of {@link STATE_ABI} — `owner`/`maxInvocations`/`paused`/`minter`/
766
958
  * `primaryPayee`/`tokenURIRenderer`/`royaltyInfo` are shared, unchanged, function names (see
@@ -785,6 +977,13 @@ export const EDITION_STATE_ABI = [
785
977
  // those two getters, shared with every other integrator) — deliberately not re-declared here.
786
978
  // Seed Source extension — EditionCode composes it; the image editions don't. See STATE_ABI's note.
787
979
  { type: 'function', name: 'seedSource', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
980
+ // The same lock surface as STATE_ABI's edit — see the note there. Identical getter names on both
981
+ // standards (verified against the generated ABI: every edition twin exposes the same five).
982
+ { type: 'function', name: 'tokenURILocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
983
+ { type: 'function', name: 'contractURILocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
984
+ { type: 'function', name: 'scriptLocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
985
+ { type: 'function', name: 'dependenciesLocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
986
+ { type: 'function', name: 'contractFieldLocked', stateMutability: 'view', inputs: [{ type: 'bytes32' }], outputs: [{ type: 'bool' }] },
788
987
  ];
789
988
  /**
790
989
  * The royalty ceiling line — and the headroom, which is the part a creator has not been told.
@@ -876,6 +1075,101 @@ function warnNotCanonical(address) {
876
1075
  `(a bespoke contract, a superseded factory), nothing here is broken — just make sure whoever owns the ` +
877
1076
  `project knows they chose it.`);
878
1077
  }
1078
+ /**
1079
+ * The bounded field-lock scan #122's triage settled on: every field this node can enumerate is one
1080
+ * of the protocol registry's `METADATA_FIELD` keys (site/content/docs/protocol/metadata.mdx), read
1081
+ * with `contractFieldLocked`. A creator's own custom field (`stems`, `palette`, …) is a real,
1082
+ * independently-lockable key — but this node can only learn its NAME from the spine's
1083
+ * `ContractFieldLocked` event, which needs an indexer, not a bare head read. So this reports "none
1084
+ * of the STANDARD fields are locked", never "no fields are locked" — the caveat has to travel with
1085
+ * the data, not live only in a comment a caller never sees.
1086
+ */
1087
+ async function readFieldLocks(publicClient, address, abi) {
1088
+ return Promise.all(Object.values(METADATA_FIELD).map(async (field) => ({
1089
+ field,
1090
+ locked: (await tryReadContract(publicClient, { address, abi, functionName: 'contractFieldLocked', args: [encodeTag(field)] })) ?? null,
1091
+ })));
1092
+ }
1093
+ /**
1094
+ * Read every applicable irreversible lock for one collection: token/contract URI config, the
1095
+ * bounded standard field set, and — for a code project — the script and dependency freezes.
1096
+ * `paramHooks.locked` is deliberately NOT read here: `cmdStateBody`/`cmdStateEditionBody` already
1097
+ * read it (`readParamHooksLocked`) as part of the existing param-hooks readout, and re-reading it
1098
+ * would cost a second `eth_call` for a fact already on hand — see the callers, which pass it into
1099
+ * {@link printLocks} instead.
1100
+ *
1101
+ * `isCode` gates `scriptLocked`/`dependenciesLocked`: those getters exist ONLY on the two code
1102
+ * twins (SeriesCode/EditionCode), so asking a plain image/Series contract for them isn't a failed
1103
+ * read to report as "unknown" — it's a surface that was never there, reported as `null` (not
1104
+ * applicable) via the OUTER object rather than the boolean inside it.
1105
+ */
1106
+ export async function readCollectionLocks(publicClient, address, abi, isCode) {
1107
+ const read = (fn) => tryReadContract(publicClient, { address, abi, functionName: fn });
1108
+ const [tokenURI, contractURI, fields, script, dependencies] = await Promise.all([
1109
+ read('tokenURILocked'),
1110
+ read('contractURILocked'),
1111
+ readFieldLocks(publicClient, address, abi),
1112
+ isCode ? read('scriptLocked') : Promise.resolve(undefined),
1113
+ isCode ? read('dependenciesLocked') : Promise.resolve(undefined),
1114
+ ]);
1115
+ return {
1116
+ tokenURI: tokenURI ?? null,
1117
+ contractURI: contractURI ?? null,
1118
+ fields,
1119
+ script: isCode ? { locked: script ?? null } : null,
1120
+ dependencies: isCode ? { locked: dependencies ?? null } : null,
1121
+ };
1122
+ }
1123
+ /** One lock's human verdict — the same three words everywhere this prints, so a reader learns the
1124
+ * vocabulary once. */
1125
+ function lockVerdict(locked) {
1126
+ return locked === true
1127
+ ? `${c.green}frozen${c.reset}`
1128
+ : locked === false
1129
+ ? `${c.orange}unlocked${c.reset}`
1130
+ : dim('unknown (node refused the check)');
1131
+ }
1132
+ /**
1133
+ * Print + emit the "Locks" section shared by both `state` bodies (721 and edition) — the one
1134
+ * coherent summary #122 asked for, in place of a lone `paramHooks.locked` line. Every lock is its
1135
+ * own independent switch (freezing one does nothing to any other), which is easy to miss when each
1136
+ * only ever appeared alone — so the header says so explicitly, and every line names the exact
1137
+ * command that freezes THAT lock and nothing else.
1138
+ */
1139
+ function printLocks(address, locks, hasParamHooks, paramHooksLock, payload, emit) {
1140
+ const lockedFieldNames = locks.fields.filter((f) => f.locked === true).map((f) => f.field);
1141
+ const unknownFieldCount = locks.fields.filter((f) => f.locked === null).length;
1142
+ payload.locks = jsonSafe({
1143
+ tokenURI: locks.tokenURI,
1144
+ contractURI: locks.contractURI,
1145
+ script: locks.script,
1146
+ dependencies: locks.dependencies,
1147
+ // `null` ⇒ no ConfigurableParams surface on this contract (not a code project) — matching the
1148
+ // `script`/`dependencies` null-means-N/A convention rather than reusing LockRead's "unknown".
1149
+ paramHooks: hasParamHooks ? (paramHooksLock ?? null) : null,
1150
+ fields: {
1151
+ // The bounded scan, keyed by name — automation gets the same three-way distinction the human
1152
+ // line does (true/false/null), never a collapsed "none locked".
1153
+ checked: locks.fields,
1154
+ 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.",
1155
+ },
1156
+ });
1157
+ emit(payload);
1158
+ console.log(`\n ${bold('Locks')} ${dim('each is INDEPENDENT — freezing one does nothing to the others')}`);
1159
+ info(`tokenURI ${lockVerdict(locks.tokenURI)} ${dim(`\`abx lock-uri ${address}\` freezes the pointer + renderer forever`)}`);
1160
+ info(`contractURI ${lockVerdict(locks.contractURI)} ${dim(`\`abx lock-uri ${address} --collection\` freezes the pointer + renderer forever`)}`);
1161
+ if (locks.script)
1162
+ info(`script ${lockVerdict(locks.script.locked)} ${dim(`\`abx lock-script ${address}\` freezes the program bytes forever`)}`);
1163
+ if (locks.dependencies)
1164
+ info(`dependencies ${lockVerdict(locks.dependencies.locked)} ${dim(`\`abx lock-dependencies ${address}\` freezes the library set forever`)}`);
1165
+ if (hasParamHooks)
1166
+ info(`param hooks ${lockVerdict(paramHooksLock ?? null)} ${dim(`\`abx lock-param-hooks ${address}\` freezes all three hook addresses forever (detail above)`)}`);
1167
+ info(lockedFieldNames.length
1168
+ ? `fields ${c.green}locked${c.reset}: ${lockedFieldNames.join(', ')}`
1169
+ : `fields ${dim('none of the standard fields are locked')}`);
1170
+ 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. ` +
1171
+ `\`abx lock-field ${address} --field <name> [--collection | --token 0]\` freezes one.`));
1172
+ }
879
1173
  export async function cmdState(address, flags) {
880
1174
  if (!address || address.startsWith('--')) {
881
1175
  console.error('usage: abx state <address> — a read-only, on-chain operational snapshot\n');
@@ -1001,6 +1295,10 @@ export async function cmdStateBody(address, flags, emit) {
1001
1295
  // are maintained by the contract's own write paths, so there is no list to drift. Read-only,
1002
1296
  // best-effort, and skipped entirely for a project that has none (a 1/1, a legacy pre-enumeration
1003
1297
  // project, or a code project with no params).
1298
+ // Hoisted above the try block: the Locks section (printed after it, see `printLocks`) reuses
1299
+ // whatever this reads rather than paying for a second `eth_call` on the same fact.
1300
+ let hooks = null;
1301
+ let paramHooksLock;
1004
1302
  try {
1005
1303
  const rawKeys = await readParamSchemaKeys(publicClient, address);
1006
1304
  const enumerable = rawKeys !== null;
@@ -1054,9 +1352,9 @@ export async function cmdStateBody(address, flags, emit) {
1054
1352
  // transfers and mints, so "which hooks, and are they frozen" is exactly the pair someone needs
1055
1353
  // before buying. Printed whenever the surface exists — including with all three unset, because
1056
1354
  // "none, and frozen" is the strongest thing this line can say and it must be visible.
1057
- const hooks = await readParamHooks(publicClient, address);
1355
+ hooks = await readParamHooks(publicClient, address);
1058
1356
  if (hooks) {
1059
- const locked = await readParamHooksLocked(publicClient, address);
1357
+ paramHooksLock = await readParamHooksLocked(publicClient, address);
1060
1358
  const shown = [['configure', hooks.configureHook], ['augment', hooks.augmentHook], ['transfer', hooks.transferHook]]
1061
1359
  .filter(([, a]) => a !== zeroAddress)
1062
1360
  .map(([role, a]) => `${role} ${a}`);
@@ -1066,12 +1364,12 @@ export async function cmdStateBody(address, flags, emit) {
1066
1364
  transferHook: hooks.transferHook === zeroAddress ? null : hooks.transferHook,
1067
1365
  // `null` = could not be established (see readParamHooksLocked). Never collapse it to false:
1068
1366
  // "unknown" and "the owner can still arm a transfer veto" are different answers.
1069
- locked: locked ?? null,
1367
+ locked: paramHooksLock ?? null,
1070
1368
  });
1071
1369
  emit(payload);
1072
- const lockNote = locked === true
1370
+ const lockNote = paramHooksLock === true
1073
1371
  ? `${c.green}frozen${c.reset} ${dim('— no hook address can ever change again')}`
1074
- : locked === false
1372
+ : paramHooksLock === false
1075
1373
  ? `${c.orange}not frozen${c.reset} ${dim('— the owner can re-point these (`abx lock-param-hooks` freezes them forever)')}`
1076
1374
  : dim('freeze state unknown (the node refused the check)');
1077
1375
  info(`param hooks ${shown.length ? shown.join(' · ') : dim('none set')} ${lockNote}`);
@@ -1083,6 +1381,10 @@ export async function cmdStateBody(address, flags, emit) {
1083
1381
  catch {
1084
1382
  /* best-effort — a non-code project has no params surface to read */
1085
1383
  }
1384
+ // The coherent lock summary #122 asked for — every irreversible switch this node can read, side
1385
+ // by side, independent of whether this project happens to compose the params surface above.
1386
+ const locks = await readCollectionLocks(publicClient, address, STATE_ABI, kind.kind === 'code');
1387
+ printLocks(address, locks, !!hooks, paramHooksLock, payload, emit);
1086
1388
  console.log('');
1087
1389
  }
1088
1390
  /**
@@ -1223,8 +1525,9 @@ async function cmdStateEditionBody(address, publicClient, kind, emit) {
1223
1525
  // the same pair as the 721 body (which hooks, and whether they can still change); silent for the
1224
1526
  // image/1-of-1 edition twins, which have no params surface at all.
1225
1527
  const hooks = await readParamHooks(publicClient, address);
1528
+ let paramHooksLock;
1226
1529
  if (hooks) {
1227
- const locked = await readParamHooksLocked(publicClient, address);
1530
+ paramHooksLock = await readParamHooksLocked(publicClient, address);
1228
1531
  const shown = [['configure', hooks.configureHook], ['augment', hooks.augmentHook], ['transfer', hooks.transferHook]]
1229
1532
  .filter(([, a]) => a !== zeroAddress)
1230
1533
  .map(([role, a]) => `${role} ${a}`);
@@ -1232,12 +1535,12 @@ async function cmdStateEditionBody(address, publicClient, kind, emit) {
1232
1535
  configureHook: hooks.configureHook === zeroAddress ? null : hooks.configureHook,
1233
1536
  augmentHook: hooks.augmentHook === zeroAddress ? null : hooks.augmentHook,
1234
1537
  transferHook: hooks.transferHook === zeroAddress ? null : hooks.transferHook,
1235
- locked: locked ?? null, // null = unknown; never collapse it to false
1538
+ locked: paramHooksLock ?? null, // null = unknown; never collapse it to false
1236
1539
  });
1237
1540
  emit(payload);
1238
- const lockNote = locked === true
1541
+ const lockNote = paramHooksLock === true
1239
1542
  ? `${c.green}frozen${c.reset} ${dim('— no hook address can ever change again')}`
1240
- : locked === false
1543
+ : paramHooksLock === false
1241
1544
  ? `${c.orange}not frozen${c.reset} ${dim('— the owner can re-point these (`abx lock-param-hooks` freezes them forever)')}`
1242
1545
  : dim('freeze state unknown (the node refused the check)');
1243
1546
  info(`param hooks ${shown.length ? shown.join(' · ') : dim('none set')} ${lockNote}`);
@@ -1245,6 +1548,9 @@ async function cmdStateEditionBody(address, publicClient, kind, emit) {
1245
1548
  info(dim(` the transfer hook is a VETO: if it reverts, the transfer fails — and mints too (mint = transfer from 0x0).`));
1246
1549
  }
1247
1550
  }
1551
+ // The coherent lock summary #122 asked for — see cmdStateBody's identical call for why.
1552
+ const locks = await readCollectionLocks(publicClient, address, EDITION_STATE_ABI, kind.kind === 'edition-code');
1553
+ printLocks(address, locks, !!hooks, paramHooksLock, payload, emit);
1248
1554
  console.log('');
1249
1555
  }
1250
1556
  /**