@artblocks/abx-cli 0.1.0-alpha.9 → 0.1.0

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 (201) hide show
  1. package/CHANGELOG.md +402 -0
  2. package/assets/renderer-scaffold/README.md +44 -13
  3. package/assets/renderer-scaffold/foundry.toml +5 -0
  4. package/assets/renderer-scaffold/remappings.txt +1 -1
  5. package/assets/renderer-scaffold/script/DeployHooks.s.sol +24 -0
  6. package/assets/renderer-scaffold/script/Preview.s.sol +99 -0
  7. package/assets/renderer-scaffold/src/MyHooks.sol +20 -0
  8. package/assets/renderer-scaffold/src/MyRenderer.sol +4 -4
  9. package/assets/renderer-scaffold/src/MyTraits.sol +2 -2
  10. package/assets/renderer-scaffold/test/MyRenderer.t.sol +60 -3
  11. package/dist/bin.d.ts +26 -0
  12. package/dist/bin.d.ts.map +1 -0
  13. package/dist/bin.js +63 -0
  14. package/dist/bin.js.map +1 -0
  15. package/dist/capabilities.d.ts +132 -0
  16. package/dist/capabilities.d.ts.map +1 -0
  17. package/dist/capabilities.js +175 -0
  18. package/dist/capabilities.js.map +1 -0
  19. package/dist/commands/auth.d.ts +54 -0
  20. package/dist/commands/auth.d.ts.map +1 -0
  21. package/dist/commands/auth.js +483 -0
  22. package/dist/commands/auth.js.map +1 -0
  23. package/dist/commands/contract.d.ts +19 -0
  24. package/dist/commands/contract.d.ts.map +1 -0
  25. package/dist/commands/contract.js +171 -0
  26. package/dist/commands/contract.js.map +1 -0
  27. package/dist/commands/deploy.d.ts +296 -0
  28. package/dist/commands/deploy.d.ts.map +1 -0
  29. package/dist/commands/deploy.js +5736 -0
  30. package/dist/commands/deploy.js.map +1 -0
  31. package/dist/commands/feedback.d.ts +7 -0
  32. package/dist/commands/feedback.d.ts.map +1 -0
  33. package/dist/commands/feedback.js +154 -0
  34. package/dist/commands/feedback.js.map +1 -0
  35. package/dist/commands/maintenance.d.ts +4 -0
  36. package/dist/commands/maintenance.d.ts.map +1 -0
  37. package/dist/commands/maintenance.js +114 -0
  38. package/dist/commands/maintenance.js.map +1 -0
  39. package/dist/commands/project.d.ts +458 -0
  40. package/dist/commands/project.d.ts.map +1 -0
  41. package/dist/commands/project.js +1837 -0
  42. package/dist/commands/project.js.map +1 -0
  43. package/dist/commands/reads.d.ts +85 -0
  44. package/dist/commands/reads.d.ts.map +1 -0
  45. package/dist/commands/reads.js +881 -0
  46. package/dist/commands/reads.js.map +1 -0
  47. package/dist/commands/scaffold.d.ts +89 -0
  48. package/dist/commands/scaffold.d.ts.map +1 -0
  49. package/dist/commands/scaffold.js +741 -0
  50. package/dist/commands/scaffold.js.map +1 -0
  51. package/dist/commands/service.d.ts +67 -0
  52. package/dist/commands/service.d.ts.map +1 -0
  53. package/dist/commands/service.js +761 -0
  54. package/dist/commands/service.js.map +1 -0
  55. package/dist/commands/storage.d.ts +51 -0
  56. package/dist/commands/storage.d.ts.map +1 -0
  57. package/dist/commands/storage.js +399 -0
  58. package/dist/commands/storage.js.map +1 -0
  59. package/dist/commands/submit-app.d.ts +102 -0
  60. package/dist/commands/submit-app.d.ts.map +1 -0
  61. package/dist/commands/submit-app.js +638 -0
  62. package/dist/commands/submit-app.js.map +1 -0
  63. package/dist/config.d.ts +94 -2
  64. package/dist/config.d.ts.map +1 -1
  65. package/dist/config.js +332 -12
  66. package/dist/config.js.map +1 -1
  67. package/dist/conformance.d.ts +31 -0
  68. package/dist/conformance.d.ts.map +1 -0
  69. package/dist/conformance.js +400 -0
  70. package/dist/conformance.js.map +1 -0
  71. package/dist/contract-read-error.d.ts +5 -0
  72. package/dist/contract-read-error.d.ts.map +1 -0
  73. package/dist/contract-read-error.js +37 -0
  74. package/dist/contract-read-error.js.map +1 -0
  75. package/dist/creator-agent.d.ts +49 -0
  76. package/dist/creator-agent.d.ts.map +1 -0
  77. package/dist/creator-agent.js +363 -0
  78. package/dist/creator-agent.js.map +1 -0
  79. package/dist/creator-keyring.d.ts +26 -0
  80. package/dist/creator-keyring.d.ts.map +1 -0
  81. package/dist/creator-keyring.js +57 -0
  82. package/dist/creator-keyring.js.map +1 -0
  83. package/dist/creator-signer.d.ts +52 -0
  84. package/dist/creator-signer.d.ts.map +1 -0
  85. package/dist/creator-signer.js +267 -0
  86. package/dist/creator-signer.js.map +1 -0
  87. package/dist/deploy-plan.d.ts +218 -0
  88. package/dist/deploy-plan.d.ts.map +1 -0
  89. package/dist/deploy-plan.js +20 -0
  90. package/dist/deploy-plan.js.map +1 -0
  91. package/dist/deps.d.ts +6 -39
  92. package/dist/deps.d.ts.map +1 -1
  93. package/dist/deps.js +4 -68
  94. package/dist/deps.js.map +1 -1
  95. package/dist/errors.d.ts +22 -0
  96. package/dist/errors.d.ts.map +1 -0
  97. package/dist/errors.js +32 -0
  98. package/dist/errors.js.map +1 -0
  99. package/dist/flag-allowlists.d.ts +53 -0
  100. package/dist/flag-allowlists.d.ts.map +1 -0
  101. package/dist/flag-allowlists.js +188 -0
  102. package/dist/flag-allowlists.js.map +1 -0
  103. package/dist/flags.d.ts +53 -0
  104. package/dist/flags.d.ts.map +1 -1
  105. package/dist/flags.js +151 -1
  106. package/dist/flags.js.map +1 -1
  107. package/dist/jsonout.d.ts +37 -0
  108. package/dist/jsonout.d.ts.map +1 -0
  109. package/dist/jsonout.js +79 -0
  110. package/dist/jsonout.js.map +1 -0
  111. package/dist/kind.d.ts +57 -0
  112. package/dist/kind.d.ts.map +1 -0
  113. package/dist/kind.js +122 -0
  114. package/dist/kind.js.map +1 -0
  115. package/dist/main.js +859 -5374
  116. package/dist/main.js.map +1 -1
  117. package/dist/mintpage.d.ts +17 -2
  118. package/dist/mintpage.d.ts.map +1 -1
  119. package/dist/mintpage.js +241 -54
  120. package/dist/mintpage.js.map +1 -1
  121. package/dist/output.d.ts +184 -0
  122. package/dist/output.d.ts.map +1 -0
  123. package/dist/output.js +774 -0
  124. package/dist/output.js.map +1 -0
  125. package/dist/ownerops.d.ts +450 -57
  126. package/dist/ownerops.d.ts.map +1 -1
  127. package/dist/ownerops.js +2219 -358
  128. package/dist/ownerops.js.map +1 -1
  129. package/dist/preview.d.ts +23 -5
  130. package/dist/preview.d.ts.map +1 -1
  131. package/dist/preview.js +95 -43
  132. package/dist/preview.js.map +1 -1
  133. package/dist/provision.d.ts +3 -13
  134. package/dist/provision.d.ts.map +1 -1
  135. package/dist/provision.js +19 -21
  136. package/dist/provision.js.map +1 -1
  137. package/dist/remote.d.ts +116 -9
  138. package/dist/remote.d.ts.map +1 -1
  139. package/dist/remote.js +371 -14
  140. package/dist/remote.js.map +1 -1
  141. package/dist/riskgate.d.ts +101 -0
  142. package/dist/riskgate.d.ts.map +1 -0
  143. package/dist/riskgate.js +343 -0
  144. package/dist/riskgate.js.map +1 -0
  145. package/dist/scaffold.d.ts +12 -0
  146. package/dist/scaffold.d.ts.map +1 -0
  147. package/dist/scaffold.js +56 -0
  148. package/dist/scaffold.js.map +1 -0
  149. package/dist/schema.d.ts +36 -1
  150. package/dist/schema.d.ts.map +1 -1
  151. package/dist/schema.js +121 -26
  152. package/dist/schema.js.map +1 -1
  153. package/dist/script-chunks.d.ts +8 -0
  154. package/dist/script-chunks.d.ts.map +1 -0
  155. package/dist/script-chunks.js +35 -0
  156. package/dist/script-chunks.js.map +1 -0
  157. package/dist/served.d.ts +30 -0
  158. package/dist/served.d.ts.map +1 -0
  159. package/dist/served.js +112 -0
  160. package/dist/served.js.map +1 -0
  161. package/dist/signer.d.ts +20 -1
  162. package/dist/signer.d.ts.map +1 -1
  163. package/dist/signer.js +110 -18
  164. package/dist/signer.js.map +1 -1
  165. package/dist/update-check.d.ts +60 -9
  166. package/dist/update-check.d.ts.map +1 -1
  167. package/dist/update-check.js +105 -18
  168. package/dist/update-check.js.map +1 -1
  169. package/package.json +17 -12
  170. package/skill/SKILL.md +211 -354
  171. package/skill/agents/openai.yaml +4 -0
  172. package/skill/reference/capabilities.md +195 -0
  173. package/skill/reference/code.md +236 -0
  174. package/skill/reference/creator-token.md +94 -0
  175. package/skill/reference/deploy.md +177 -0
  176. package/skill/reference/diagnose.md +178 -0
  177. package/skill/reference/hosting.md +191 -102
  178. package/skill/reference/operate.md +237 -0
  179. package/skill/reference/services.md +173 -0
  180. package/skill/reference/setup.md +221 -42
  181. package/assets/renderer-scaffold/src/interfaces/IAbxFieldRenderer.sol +0 -32
  182. package/assets/renderer-scaffold/src/interfaces/IAbxParams.sol +0 -26
  183. package/dist/inspect.d.ts +0 -48
  184. package/dist/inspect.d.ts.map +0 -1
  185. package/dist/inspect.js +0 -184
  186. package/dist/inspect.js.map +0 -1
  187. package/dist/migrate.d.ts +0 -65
  188. package/dist/migrate.d.ts.map +0 -1
  189. package/dist/migrate.js +0 -180
  190. package/dist/migrate.js.map +0 -1
  191. package/dist/onchain-uri.d.ts +0 -97
  192. package/dist/onchain-uri.d.ts.map +0 -1
  193. package/dist/onchain-uri.js +0 -243
  194. package/dist/onchain-uri.js.map +0 -1
  195. package/dist/upload.d.ts +0 -28
  196. package/dist/upload.d.ts.map +0 -1
  197. package/dist/upload.js +0 -41
  198. package/dist/upload.js.map +0 -1
  199. package/skill/reference/code-projects.md +0 -246
  200. package/skill/reference/operating.md +0 -118
  201. package/skill/reference/troubleshooting.md +0 -28
@@ -0,0 +1,1837 @@
1
+ /**
2
+ * Project lifecycle commands: `predict` (pre-compute a deploy address), `add` (register + index a
3
+ * project this node didn't deploy), `index` (re-index from chain), `verify` (re-hash served bytes
4
+ * vs. the on-chain commitment), `state`/`status` (on-chain snapshot / indexed-project rollup), and
5
+ * `forget` (drop the local registration; on-chain data is untouched).
6
+ */
7
+ import { readFileSync } from 'node:fs';
8
+ import { resolve as resolvePath } from 'node:path';
9
+ import { verifyProvenance, AUTH_OPTIONS, CREATOR_TOKEN_INTERFACE_ID, PARAM_TYPES, TOKEN_API_INTERFACE, 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
+ import { resolveBackend } from '@artblocks/abx-storage';
11
+ import { currentRenderArtifact, verifyProject } from '@artblocks/abx-token-api';
12
+ import { zeroAddress } from 'viem';
13
+ import { CHAIN, activeBackendId, factoryAddress, localIndexer, remoteLocators, seriesFactoryAddress, oneOfOneEditionFactoryAddress, editionFactoryAddress, editionCodeFactoryAddress, storageOptions, } from '../config.js';
14
+ import { assertSaltGuardForDeployer, isDryRun, parseBlockTagFlag, parseSaltFlag } from '../flags.js';
15
+ import { jsonSafe, withJson } from '../jsonout.js';
16
+ import { allowLargeScan, bold, c, detectCanonicalFactory, dim, g, info, ok, registerAndIndexLocally, resolveScanFloor, warn } from '../output.js';
17
+ import { parseCopies } from './deploy.js';
18
+ import { canonicalSeedSource } from '../ownerops.js';
19
+ import { detectTokenKind, describeKind } from '../kind.js';
20
+ import { isRpcReadFailure } from '../contract-read-error.js';
21
+ import { canonicalLabel, describeRemoteError, indexErrorAction, remoteFlag, reportRemoteIndexing, requireRemoteToken, rollUp, controlPlaneClient, serviceInterfaceClient, statusLabel, statusLine, statusRow, tokenSourceLabel, } from '../remote.js';
22
+ import { describeSchema } from '../schema.js';
23
+ import { looksPerTokenAttributes, parseSeriesTraitsById } from '../series-traits.js';
24
+ // ── predict ──────────────────────────────────────────────────────────────────
25
+ // Pre-compute a deploy address from a salt — so you can stand up the resolver and
26
+ // reserve a vanity/known address before signing. The address is a pure function of
27
+ // (factory, salt); the salt's leading 20 bytes are the front-run guard.
28
+ export async function cmdPredict(flags) {
29
+ const publicClient = makePublicClient({ chainKey: CHAIN });
30
+ // Lane-aware: each deploy command uses a DIFFERENT factory, so the deterministic address differs.
31
+ // Infer the lane from the content flags so `predict` matches what will actually be deployed —
32
+ // otherwise a code/Series creator gets the 1/1 address (a real mismatch, not just a wrong echo).
33
+ const chainId = resolveChain(CHAIN).id;
34
+ const content = flags.script ? `--script ${flags.script}` :
35
+ flags['code-dir'] ? `--code-dir ${flags['code-dir']}` :
36
+ flags['image-renderer'] ? `--image-renderer ${flags['image-renderer']}` :
37
+ flags.dir ? `--dir ${flags.dir}` :
38
+ flags.image ? `--image ${flags.image}` : '';
39
+ const baseCmd = (flags.script || flags['code-dir'] || flags['image-renderer'])
40
+ ? 'deploy-code'
41
+ : flags.dir
42
+ ? 'deploy-series'
43
+ : 'deploy';
44
+ // `--copies <n|open>` switches which factory's address is predicted — the SAME flag that
45
+ // switches which factory a real deploy uses (see commands/deploy.ts's `parseCopies`). Validated
46
+ // here too (not just deferred to the real deploy) so a malformed value fails before printing an
47
+ // address tied to it.
48
+ const editionSize = flags.copies !== undefined ? parseCopies(flags.copies) : undefined;
49
+ const isEdition = editionSize !== undefined;
50
+ const lane = isEdition
51
+ ? {
52
+ cmd: baseCmd,
53
+ factory: baseCmd === 'deploy-code'
54
+ ? editionCodeFactoryAddress(flags.factory)
55
+ : baseCmd === 'deploy-series'
56
+ ? editionFactoryAddress(flags.factory)
57
+ : oneOfOneEditionFactoryAddress(flags.factory),
58
+ }
59
+ : baseCmd === 'deploy-code'
60
+ ? { cmd: 'deploy-code', factory: resolveSeriesCodeFactory(chainId, flags.factory) }
61
+ : baseCmd === 'deploy-series'
62
+ ? { cmd: 'deploy-series', factory: seriesFactoryAddress(flags.factory) }
63
+ : { cmd: 'deploy', factory: factoryAddress(flags.factory) };
64
+ const factory = lane.factory;
65
+ if (!factory) {
66
+ throw new Error(`no canonical ${isEdition ? 'edition ' : ''}${lane.cmd} factory for '${CHAIN}' — run \`abx ${lane.cmd}${isEdition ? ` --copies ${flags.copies}` : ''}\` ` +
67
+ `once to deploy the trust anchor, or pass --factory 0x.. (or the ABX_* env).`);
68
+ }
69
+ // The current-factory guard is the 1/1 impl-version check (721 or its edition twin); the Series/
70
+ // Series-edition/code/code-edition lanes verify their own factory at deploy time instead —
71
+ // EditionCodeFactory mirrors SeriesCodeFactory's stance here (no version probe: a configured code
72
+ // factory with code is always accepted, and one found at its CREATE2 address is bytecode-bound to
73
+ // this build anyway, so there's nothing this check would catch that the real deploy doesn't
74
+ // already). predictDeterministicAddress works on any factory ABI.
75
+ if (lane.cmd === 'deploy') {
76
+ const current = isEdition ? await isCurrentOneOfOneEditionFactory(publicClient, factory) : await isCurrentFactory(publicClient, factory);
77
+ if (!current) {
78
+ throw new Error(`factory ${factory} is an older/incompatible version. Run \`abx deploy${isEdition ? ` --copies ${flags.copies}` : ''}\` once to deploy the current ` +
79
+ `trust anchor (ownerless + cheap), then predict again — or pass a current --factory.`);
80
+ }
81
+ }
82
+ let salt = parseSaltFlag(flags.salt);
83
+ if (salt && flags.for)
84
+ assertSaltGuardForDeployer(salt, flags.for);
85
+ if (!salt) {
86
+ // No explicit salt: reserve one to a deployer (front-run-proof). Default to the env key.
87
+ let deployer = flags.for;
88
+ if (!deployer)
89
+ deployer = makeWalletClient({ chainKey: CHAIN }).account.address;
90
+ salt = saltFor(deployer);
91
+ info(`reserved a fresh salt to ${deployer} (front-run-proof) — pass --salt to fix/vanity it`);
92
+ }
93
+ const guard = saltGuard(salt);
94
+ const clone = await predictClone(publicClient, { factory, salt });
95
+ console.log(`\n ${bold('predicted address')} ${g(clone)} ${dim(`(${lane.cmd} lane${isEdition ? ', edition' : ''})`)}`);
96
+ info(`salt ${salt}`);
97
+ info(guard === zeroAddress ? 'guard permissionless — anyone may deploy this salt' : `guard reserved to ${guard} (only this signer can deploy it)`);
98
+ const copiesFlag = isEdition ? ` --copies ${editionSize === 0n ? 'open' : editionSize.toString()}` : '';
99
+ info(`deploy: ${bold(`abx ${lane.cmd}${content ? ` ${content}` : ''}${copiesFlag} --salt ${salt}`)}`);
100
+ if (lane.cmd === 'deploy' && !isEdition)
101
+ info(dim('(this is the 1/1 lane; a Series/code drop uses a different factory → a different address — pass --dir / --script to predict those, or use that command\'s --dry-run)'));
102
+ console.log('');
103
+ }
104
+ /** Keep the local lane's machine contract independent of the indexer's larger state object. */
105
+ export function localAddJsonResult(input) {
106
+ return {
107
+ target: { surface: 'local', name: null, url: null },
108
+ chainId: input.chainId,
109
+ address: input.address,
110
+ status: input.eventCount > 0 ? 'live' : 'empty',
111
+ scanFloor: input.scanFloor,
112
+ completed: true,
113
+ backfilling: false,
114
+ eventCount: input.eventCount,
115
+ tokenCount: input.tokenCount,
116
+ };
117
+ }
118
+ export async function cmdAdd(address, flags) {
119
+ if (!address || address.startsWith('--')) {
120
+ console.error('usage: abx add <address> [--from-block N] [--factory 0x..] [--label "..."] [--remote [name|url]]\n');
121
+ process.exitCode = 1;
122
+ return;
123
+ }
124
+ // REFUSE `--dry-run` rather than ignoring it. `add` is a write (a local registration + index, and
125
+ // with `--remote` a registration on someone else's service), and it has no preview mode — so
126
+ // silently proceeding to DO the thing when the caller explicitly asked to preview is the one
127
+ // outcome we must never produce. Name what's read-only instead.
128
+ if (isDryRun(flags)) {
129
+ throw new Error('`abx add` has no --dry-run: it registers + indexes for real (and with --remote it registers on that service). ' +
130
+ 'Nothing here touches the chain, but it does write. To look before acting: `abx state <address>` (on-chain snapshot) ' +
131
+ 'or `abx status <address> [--remote <name>]` (what a node already has). Re-run without --dry-run when you mean it.');
132
+ }
133
+ return withJson(flags, async (emit) => {
134
+ // `--attributes` is lane-aware here exactly as at deploy: a PER-TOKEN payload edits a Series'
135
+ // per-token off-chain traits; a flat payload (+ `--traits`) edits the collection/1-of-1 `attributes`.
136
+ // Ambiguity defaults to flat (see looksPerTokenAttributes), so a 1/1 add is never mis-read.
137
+ const attrRaw = flags.attributes ? readFileSync(resolvePath(process.cwd(), String(flags.attributes)), 'utf8') : undefined;
138
+ const perTokenEdit = attrRaw != null && looksPerTokenAttributes(attrRaw);
139
+ const flagTraits = [];
140
+ if (attrRaw != null && !perTokenEdit)
141
+ flagTraits.push(...normalizeAttributes(JSON.parse(attrRaw)));
142
+ if (flags.traits)
143
+ flagTraits.push(...parseTraitPairs(flags.traits));
144
+ const editedTokenAttributes = perTokenEdit ? parseSeriesTraitsById(attrRaw) : undefined;
145
+ const remote = remoteFlag(flags);
146
+ if (remote) {
147
+ requireRemoteToken(remote);
148
+ // Bridge what a remote resolver can't derive itself: the off-chain traits and the durable
149
+ // content locators (ipfs://…). Prefer flags; otherwise forward what the LOCAL deploy stored
150
+ // (the local registration), and compute locators from this machine's content index if needed.
151
+ const localReg = localIndexer().store.getRegistration(address);
152
+ const attributes = flagTraits.length
153
+ ? flagTraits
154
+ : localReg?.attributes
155
+ ? normalizeAttributes(JSON.parse(localReg.attributes))
156
+ : undefined;
157
+ // Bridge a Series' per-token off-chain traits to the remote resolver (the resolver has no other
158
+ // way to derive them — they're operator metadata, not chain state). A fresh per-token `--attributes`
159
+ // EDITS them; otherwise forward what the LOCAL deploy stored. Best-effort parse.
160
+ let tokenAttributes;
161
+ if (editedTokenAttributes && Object.keys(editedTokenAttributes).length) {
162
+ tokenAttributes = editedTokenAttributes;
163
+ }
164
+ else if (localReg?.tokenAttributes) {
165
+ try {
166
+ const obj = JSON.parse(localReg.tokenAttributes);
167
+ const norm = {};
168
+ for (const [id, v] of Object.entries(obj)) {
169
+ const a = normalizeAttributes(v);
170
+ if (a.length)
171
+ norm[id] = a;
172
+ }
173
+ if (Object.keys(norm).length)
174
+ tokenAttributes = norm;
175
+ }
176
+ catch { /* skip a malformed local column */ }
177
+ }
178
+ const contentLocators = await remoteLocators(address, localReg?.contentLocators, flags);
179
+ // Forward the deploy block, like every other field falls back to the local registration.
180
+ // (Its ABSENCE here was the bug: a hosted resolver defaulted to genesis and scanned the whole
181
+ // chain.) Re-sending the same floor stays incremental server-side, so a nudge ≠ a re-scan.
182
+ const body = {
183
+ chainId: resolveChain(CHAIN).id,
184
+ address,
185
+ fromBlock: await resolveScanFloor(address, localReg?.fromBlock, flags),
186
+ factory: await detectCanonicalFactory(address, flags.factory, localReg?.factory),
187
+ label: flags.label,
188
+ description: flags.description ?? localReg?.description,
189
+ externalUrl: flags['external-url'] ?? localReg?.externalUrl,
190
+ attributes,
191
+ tokenAttributes,
192
+ contentLocators: Object.keys(contentLocators).length ? contentLocators : undefined,
193
+ full: flags.full ? true : undefined,
194
+ };
195
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(registering with the remote resolver — NOT this machine)')}`);
196
+ if (body.contentLocators)
197
+ info(`bridging image locator → ${Object.values(body.contentLocators)[0]} ${dim('(so the resolver points at IPFS, not its own localhost)')}`);
198
+ const [control, r] = await (async () => {
199
+ try {
200
+ const client = await controlPlaneClient(remote);
201
+ return [client, await client.registerProject(body)];
202
+ }
203
+ catch (err) {
204
+ throw describeRemoteError(err, remote, 'remote add');
205
+ }
206
+ })();
207
+ const outcome = await reportRemoteIndexing(remote, body.chainId, address, r, flags, 'indexed', control);
208
+ const tokenApi = await serviceInterfaceClient(remote, TOKEN_API_INTERFACE);
209
+ info(`it now serves ${tokenApi.baseUrl}/t/${body.chainId}/${address.toLowerCase()}/0`);
210
+ // "Indexed" is not "correct". This line proves the service replayed the chain and will answer at
211
+ // that URL; it says nothing about whether the bytes it serves match the on-chain commitment.
212
+ // Name the step that checks.
213
+ info(`confirm what it actually serves (bytes vs. the on-chain hash): ${bold(`abx verify ${address} --remote ${remote.name?.toLowerCase() ?? remote.url}`)}`);
214
+ // Say what this registration did NOT buy. A project whose `tokenURIRenderer` is set answers
215
+ // `tokenURI` from the chain, so marketplaces and wallets read THAT document and never touch this
216
+ // service — the register is still useful (indexing, the live view, managed rendering for a code
217
+ // drop) but it changes nothing a collector sees. Without this line the readout is a list of
218
+ // successes that reads like a win, and a creator who was told "put it on a hosted service so it
219
+ // shows up properly" concludes their problem is solved when nothing about it moved.
220
+ const onChainRenderer = await tryReadContract(makePublicClient({ chainKey: CHAIN }), {
221
+ address,
222
+ abi: STATE_ABI,
223
+ functionName: 'tokenURIRenderer',
224
+ });
225
+ if (onChainRenderer && onChainRenderer !== zeroAddress) {
226
+ info(`${dim('note:')} this project resolves ${bold('tokenURI from the chain')} (renderer ${onChainRenderer}) — marketplaces read that, not this service. ` +
227
+ `Registering still gives you indexing, the live view and managed rendering, but it does not change the metadata a collector sees.`);
228
+ }
229
+ emit({
230
+ target: { surface: 'remote', name: remote.name ?? null, url: remote.url },
231
+ chainId: body.chainId,
232
+ address,
233
+ status: outcome.status,
234
+ scanFloor: body.fromBlock ?? '0',
235
+ completed: outcome.completed,
236
+ backfilling: outcome.backfilling,
237
+ ...(outcome.eventCount === undefined ? {} : { eventCount: outcome.eventCount }),
238
+ ...(outcome.tokenCount === undefined ? {} : { tokenCount: outcome.tokenCount }),
239
+ });
240
+ return;
241
+ }
242
+ // The local (non-`--remote`) path is shared with the deploy family's post-setup registration —
243
+ // see registerAndIndexLocally's doc comment (output.ts).
244
+ const outcome = await registerAndIndexLocally(address, flags);
245
+ emit(localAddJsonResult({
246
+ chainId: outcome.state.chainId,
247
+ address,
248
+ scanFloor: outcome.scanFloor,
249
+ eventCount: outcome.state.eventCount,
250
+ tokenCount: outcome.state.tokens.length,
251
+ }));
252
+ });
253
+ }
254
+ export async function cmdIndex(address, flags) {
255
+ const remote = remoteFlag(flags);
256
+ if (remote) {
257
+ if (!address || address.startsWith('--')) {
258
+ console.error('usage: abx index <address> --remote [name|url] (re-index one project on a remote resolver)\n');
259
+ process.exitCode = 1;
260
+ return;
261
+ }
262
+ requireRemoteToken(remote);
263
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(re-indexing on the remote resolver — the post-deploy nudge)')}`);
264
+ const chainId = resolveChain(CHAIN).id;
265
+ const [control, r] = await (async () => {
266
+ try {
267
+ const client = await controlPlaneClient(remote);
268
+ return [client, await client.registerProject({ chainId, address, full: flags.full ? true : undefined })];
269
+ }
270
+ catch (err) {
271
+ throw describeRemoteError(err, remote, 'remote index');
272
+ }
273
+ })();
274
+ await reportRemoteIndexing(remote, chainId, address, r, flags, 're-indexed', control);
275
+ return;
276
+ }
277
+ allowLargeScan(flags);
278
+ const full = !!flags.full; // force a full replay from the deploy block (durability proof)
279
+ // Stop the scan at a REORG-SAFE boundary instead of the head. `latest` (the default) can include
280
+ // blocks that a reorg later replaces, which would persist a watermark for history that no longer
281
+ // exists; `safe`/`finalized` trade freshness for a boundary the chain won't take back. Resolved to
282
+ // a concrete block number before scanning (see resolveBlockTag) — a stored watermark is always a
283
+ // number, never a tag.
284
+ const blockTag = parseBlockTagFlag(flags['to-block']);
285
+ const indexer = localIndexer();
286
+ const line = (state, elapsedMs, mode) => ok(`${state.name ?? state.address}: ${state.eventCount} events in ${elapsedMs}ms ${dim(`(${mode})`)}`);
287
+ if (address) {
288
+ const { state, elapsedMs, mode } = await indexer.reindex(address, { full, blockTag });
289
+ line(state, elapsedMs, mode);
290
+ return;
291
+ }
292
+ const results = await indexer.reindexAll({ full, blockTag });
293
+ if (results.length === 0) {
294
+ console.log(dim('No registered projects. Deploy one with `abx deploy`.'));
295
+ return;
296
+ }
297
+ for (const { state, elapsedMs, mode } of results)
298
+ line(state, elapsedMs, mode);
299
+ }
300
+ // ── verify ─────────────────────────────────────────────────────────────────--
301
+ // Re-hash a project's served bytes against its on-chain content commitment, from
302
+ // chain + custody alone — no running server. The CLI form of the `/verify` route,
303
+ // so an agent can confirm integrity right after deploy/mint without curling.
304
+ export async function cmdVerify(address, flags) {
305
+ if (!address || address.startsWith('--')) {
306
+ console.error('usage: abx verify <address> [--json] [--remote <name|url>]\n');
307
+ process.exitCode = 1;
308
+ return;
309
+ }
310
+ allowLargeScan(flags);
311
+ const remote = remoteFlag(flags);
312
+ // `--json` matters most here of all the read commands: verify already exits non-zero on a byte
313
+ // mismatch, so it is the one command a CI job would gate on. The payload's `ok` is the same
314
+ // boolean the exit code carries; callers never need to parse prose.
315
+ //
316
+ // The remote lane used to route around `withJson` entirely (`return cmdVerifyRemote(address,
317
+ // remote)`), so `abx verify <addr> --remote <r> --json` printed the same human prose as without
318
+ // the flag and emitted NOTHING on stdout — the one lane of this command a CI job is most likely
319
+ // to run (checking what a HOSTED resolver serves) had no machine-readable output at all. Both
320
+ // lanes now go through the same `withJson` wrapper and emit the same payload shape.
321
+ if (remote)
322
+ return withJson(flags, async (emit) => cmdVerifyRemote(address, remote, emit));
323
+ return withJson(flags, async (emit) => cmdVerifyBody(address, flags, emit));
324
+ }
325
+ /**
326
+ * Pure by design — every caller (`cmdVerifyBody`'s local lane and `cmdVerifyRemote`'s hosted lane)
327
+ * feeds it facts it already gathered rather than each re-deriving its own verdict, so the two
328
+ * lanes' JSON payloads read the same word for the same situation.
329
+ *
330
+ * `isCode` selects which signal decides the verdict: a code project's render presence
331
+ * (`minted`/`present`), or — for everything else — whether a byte-check actually ran
332
+ * (`anyCheck`) versus a locator this run could not independently re-hash (`unrecomputablePointers`).
333
+ * The two signals are never combined into one number on purpose: "3 of 5 renders present" and "2
334
+ * locator commitments unconfirmed" are different facts about different projects, and mixing them
335
+ * would produce a verdict that answers neither question honestly.
336
+ */
337
+ export function computeAvailability(opts) {
338
+ if (opts.isCode) {
339
+ if (opts.minted === 0)
340
+ return { status: 'unknown', note: 'no tokens minted yet — nothing to render' };
341
+ if (opts.present === opts.minted)
342
+ return { status: 'available', note: `${opts.present}/${opts.minted} minted token(s) have a real render` };
343
+ if (opts.present === 0)
344
+ return { status: 'unavailable', note: `0/${opts.minted} minted token(s) have a real render — every one is still a placeholder` };
345
+ return { status: 'partial', note: `${opts.present}/${opts.minted} minted token(s) have a real render` };
346
+ }
347
+ if (opts.anyCheck) {
348
+ return { status: 'available', note: 'served bytes were fetched and compared against the on-chain commitment (see `ok` for whether they matched)' };
349
+ }
350
+ if (opts.unrecomputablePointers > 0) {
351
+ return {
352
+ status: 'unknown',
353
+ note: `${opts.unrecomputablePointers} locator commitment(s) (ipfs/arweave/url) exist but were not independently re-fetched by this command`,
354
+ };
355
+ }
356
+ if ((opts.onChainContent ?? 0) > 0) {
357
+ return {
358
+ status: 'available',
359
+ note: `${opts.onChainContent} image source(s) are stored on chain; no external custody to probe`,
360
+ };
361
+ }
362
+ return { status: 'available', note: 'no content commitments on this project — nothing to serve' };
363
+ }
364
+ /** Chain-resident image representations carry the bytes themselves rather than a hash or locator.
365
+ * They are available without external custody, but they are deliberately NOT counted as a hash
366
+ * verification: the chain is the source, so there is no second copy to compare against it. */
367
+ const ON_CHAIN_CONTENT_REPRESENTATIONS = new Set([
368
+ METADATA_REPRESENTATION.inline,
369
+ METADATA_REPRESENTATION.inlineGzip,
370
+ METADATA_REPRESENTATION.reader,
371
+ METADATA_REPRESENTATION.readerGzip,
372
+ ]);
373
+ export function onChainImageSource(fields) {
374
+ const image = fieldOf(fields, METADATA_FIELD.image);
375
+ if (image && ON_CHAIN_CONTENT_REPRESENTATIONS.has(image.representation))
376
+ return image.representation;
377
+ return null;
378
+ }
379
+ /** Locator representations `verifyProject` never emits a check for (only `keccak256`/`sha256`
380
+ * fields get one) — a real commitment this command cannot re-hash from the field alone: ipfs/
381
+ * arweave carry a content-addressed locator this run doesn't fetch, and url/url-template carry
382
+ * no hash at all. Reported as a `verified: null` entry, same tri-state slot a hash check uses,
383
+ * so a caller sees ONE consistent shape rather than silence for these tokens. */
384
+ const POINTER_REPRESENTATIONS = new Set([
385
+ METADATA_REPRESENTATION.ipfs,
386
+ METADATA_REPRESENTATION.arweave,
387
+ METADATA_REPRESENTATION.url,
388
+ METADATA_REPRESENTATION.urlTemplate,
389
+ ]);
390
+ /**
391
+ * Does this token's `image` field carry a locator commitment `verifyProject` never produced a
392
+ * check for? Exported and pure (a plain `fields` array in, a verdict out — no chain, no indexer)
393
+ * so the three-way distinction is unit-testable directly, rather than only reachable through a
394
+ * full `cmdVerifyBody` run against real chain state.
395
+ *
396
+ * Returns the representation name to report (`ipfs`/`arweave`/`url`/`url-template`) when the image
397
+ * field is a bare locator, else `null` — including when there's no `image` field at all, which is
398
+ * "no commitment", a different fact from "an unrecomputable one".
399
+ */
400
+ export function pointerOnlyImageCheck(fields) {
401
+ const image = fieldOf(fields, METADATA_FIELD.image);
402
+ if (image && POINTER_REPRESENTATIONS.has(image.representation))
403
+ return image.representation;
404
+ return null;
405
+ }
406
+ /** `chainComplete` describes a code template's dependency graph. Static projects have no code
407
+ * graph to classify, so their structured verification result must say "not applicable" instead
408
+ * of reporting a confident `false` that reads like a failed durability check. */
409
+ export function verifyChainComplete(isCodeProject, chainComplete) {
410
+ return isCodeProject ? chainComplete : null;
411
+ }
412
+ export async function cmdVerifyBody(address, flags, emit) {
413
+ const indexer = localIndexer();
414
+ let state = indexer.getProject(address);
415
+ // FAIL FAST, before any scan. `abx verify`'s own help already admits it needs the project
416
+ // registered on THIS node — this is that check enforced, not just documented. Without it, a
417
+ // caller who follows the previous version of this message's advice literally (`abx add <addr>`
418
+ // with no thought to whether the project is actually served by a REMOTE resolver instead) walks
419
+ // straight into a full historical log scan on a public RPC — exactly what a cold-agent sweep hit
420
+ // (repeated HTTP 429s, minutes wasted, reads like an outage). Naming `--remote <name>` here as a
421
+ // real alternative is the fix: it steers a resolver-served project away from a needless local
422
+ // backfill instead of just failing fast into the same trap one command later.
423
+ if (indexer.store.getRegistration(address)) {
424
+ ({ state } = await indexer.reindex(address)); // freshest state from chain
425
+ }
426
+ else if (!state) {
427
+ 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. ` +
428
+ `Run \`abx add ${address}\` to index it here, or if a resolver already serves it, check that instead: \`abx verify ${address} --remote <name>\`.`);
429
+ }
430
+ if (!state)
431
+ throw new Error(`could not load state for ${address}`);
432
+ // Accumulated as the checks run, re-emitted at each stage so a payload survives an early return.
433
+ const verifyReport = {
434
+ address,
435
+ chain: CHAIN,
436
+ name: state.name ?? null,
437
+ canonical: state.isCanonical, // TRUE tri-state: true | false | null (couldn't check)
438
+ owner: state.owner ?? null,
439
+ contentChecks: [],
440
+ onChainContent: [],
441
+ contentIntegrity: 'no-commitments',
442
+ renders: null,
443
+ onChainUri: null,
444
+ // Fail closed for the same reason as the remote lane below: this object is emitted early and
445
+ // printed in its FINAL state, so a run that dies partway must not leave `ok: true` standing.
446
+ // The verdict is assigned unconditionally further down, before the second `emit`.
447
+ ok: false,
448
+ // A SIBLING of `ok`, not a rename — see `computeAvailability`'s doc comment for why the two
449
+ // must never collapse into one bit. Filled in below, once the facts it's computed from exist.
450
+ availability: null,
451
+ };
452
+ emit(verifyReport);
453
+ console.log(bold(`\n verify ${state.name ?? address}`));
454
+ // isCanonical is a TRUE TRI-STATE (true | false | null) and collapsing it lost the only
455
+ // distinction that matters: "the chain says this is NOT a clone of the configured factory" is a
456
+ // trust finding; "we couldn't run the check" (no factory configured, or none deployed on this
457
+ // chain) is an environment note. Keep those states visibly distinct.
458
+ info(`canonical: ${canonicalLabel(state.isCanonical)} · owner ${state.owner ?? '—'}`);
459
+ const storage = resolveBackend(storageOptions());
460
+ const result = await verifyProject(state, storage);
461
+ const tokens = result.tokens ?? [];
462
+ let anyCheck = false;
463
+ let allGood = true;
464
+ // Locator commitments (ipfs/arweave/url/url-template `image` fields) `verifyProject` never
465
+ // produces a check for — see `POINTER_REPRESENTATIONS`'s doc comment. Counted SEPARATELY from
466
+ // `anyCheck`/`allGood` on purpose: a pointer this command didn't re-fetch is not an integrity
467
+ // verdict either way, so it must never move `contentIntegrity`/`ok` — it feeds `availability`
468
+ // instead, below.
469
+ let unrecomputablePointers = 0;
470
+ const onChainContent = [];
471
+ for (const t of tokens) {
472
+ const tok = state.tokens.find((s) => s.tokenId === t.tokenId);
473
+ const life = tok?.lifecycle === 'live'
474
+ ? 'minted'
475
+ : tok?.lifecycle === 'burned'
476
+ ? 'BURNED'
477
+ : tok?.lifecycle === 'no-live-copies'
478
+ ? 'no live copies'
479
+ : 'not yet minted';
480
+ info(`token #${t.tokenId}: ${life}`);
481
+ for (const ch of t.checks) {
482
+ verifyReport.contentChecks.push({
483
+ tokenId: t.tokenId,
484
+ lifecycle: tok?.lifecycle ?? 'unminted',
485
+ kind: ch.kind,
486
+ // tri-state again: true (re-hashed, matched) · false (MISMATCH) · null (pointer-only,
487
+ // nothing local to recompute). Collapsing null into false would report a normal state as a
488
+ // failure, which is the bug this command exists to not have.
489
+ verified: ch.verified,
490
+ });
491
+ anyCheck = true;
492
+ if (ch.verified === true)
493
+ ok(`${ch.kind} matches on-chain commitment — content is authentic`);
494
+ else if (ch.verified === false) {
495
+ allGood = false;
496
+ console.log(` ${c.red}✗${c.reset} ${ch.kind} MISMATCH — served bytes don't match the commitment`);
497
+ }
498
+ else
499
+ info(`${ch.kind} — pointer-only, not locally recomputable`);
500
+ }
501
+ // `verifyProject` only ever emits a check for a keccak256/sha256-committed `image` field, so a
502
+ // token whose image is a bare LOCATOR (ipfs/arweave/url/url-template) gets `t.checks === []` —
503
+ // indistinguishable, before this, from "no commitment at all". That was the real gap
504
+ // named: a real, independently-lockable commitment silently read as nothing. Surface it as the
505
+ // same tri-state slot a hash check uses (`verified: null`), so a caller sees one consistent
506
+ // shape instead of two different kinds of silence.
507
+ if (t.checks.length === 0 && tok) {
508
+ const pointerKind = pointerOnlyImageCheck(tok.fields);
509
+ if (pointerKind) {
510
+ unrecomputablePointers++;
511
+ verifyReport.contentChecks.push({
512
+ tokenId: t.tokenId,
513
+ lifecycle: tok.lifecycle,
514
+ kind: pointerKind,
515
+ verified: null,
516
+ });
517
+ info(`${pointerKind} — pointer-only, not locally recomputable (no outbound fetch from this command)`);
518
+ }
519
+ else {
520
+ const representation = onChainImageSource(tok.fields);
521
+ if (representation) {
522
+ onChainContent.push({ tokenId: t.tokenId, lifecycle: tok.lifecycle, field: 'image', representation });
523
+ info(`${representation} image — bytes are stored on chain; no separate hash commitment to compare`);
524
+ }
525
+ }
526
+ }
527
+ }
528
+ verifyReport.onChainContent = onChainContent;
529
+ if (!anyCheck && unrecomputablePointers === 0 && onChainContent.length === 0 && !isCodeProject(state))
530
+ info('no content commitments on this project');
531
+ verifyReport.contentIntegrity = !anyCheck ? 'no-commitments' : allGood ? 'ok' : 'mismatch';
532
+ verifyReport.ok = !anyCheck || allGood;
533
+ emit(verifyReport);
534
+ // Code projects have no on-chain `image` commitment (the still is rendered off-chain by the
535
+ // effect runner), so the hash checks above are silent for them. Instead report what actually
536
+ // matters after mint: does a REAL thumbnail exist yet, or is the marketplace image still the
537
+ // placeholder? Same seam the /…/image route serves from (currentRenderArtifact @ current
538
+ // inputsHash) — so this answers "did the render land?" with one command, right after token 0.
539
+ let renderGap = false;
540
+ // Hoisted above the `isCodeProject` branch: `availability` (computed right after it, whether or
541
+ // not this project turns out to be a code project) needs the render counts that branch produces.
542
+ let codeRenders = null;
543
+ if (isCodeProject(state)) {
544
+ const client = makePublicClient({ chainKey: CHAIN });
545
+ const storageForRender = resolveBackend(storageOptions());
546
+ const minted = state.tokens.filter((t) => t.lifecycle === 'live');
547
+ if (minted.length === 0)
548
+ info('no tokens minted yet — mint token #0, then re-run to check its thumbnail.');
549
+ // Live-data posture — the augment hook IS the opt-in: no hook ⇒ zero live reads (pure indexed
550
+ // params); hook set ⇒ the live view reads chain per view, and the STILL snapshots settled state
551
+ // (live data never re-addresses the render — that's the settled/full split).
552
+ info(state.paramHooks?.augmentHook
553
+ ? `live data: augment hook ${state.paramHooks.augmentHook} — live view reads chain per view; stills snapshot settled state`
554
+ : 'live data: none (settled params only — the live view makes zero extra chain reads)');
555
+ // Immutability of the WORK itself — the on-chain program (script chunks) and its library set.
556
+ // This is the gap `lock-field`/`lock-uri` don't cover: those freeze metadata, but the owner can
557
+ // still call `setScriptChunk`/`removeLastScriptChunk` until `lock-script` is sent. Report it so a
558
+ // "locked" drop isn't quietly still-mutable in the one place that carries the work.
559
+ verifyReport.script = state.script ? { locked: !!state.script.locked } : null;
560
+ verifyReport.dependencies = state.dependencies
561
+ ? { count: state.dependencies.list.length, locked: !!state.dependencies.locked }
562
+ : null;
563
+ emit(verifyReport);
564
+ if (state.script) {
565
+ if (state.script.locked)
566
+ ok('script: locked — the program bytes are frozen permanently (setScriptChunk reverts)');
567
+ else
568
+ console.log(` ${c.orange}⚠${c.reset} script: UNLOCKED — the owner can still change the program. Freeze the work with ${bold(`abx lock-script ${address}`)} ${dim("(lock-field/lock-uri don't cover the script)")}`);
569
+ }
570
+ if (state.dependencies && state.dependencies.list.length > 0) {
571
+ if (state.dependencies.locked)
572
+ ok('dependencies: locked — the library set is frozen');
573
+ else
574
+ info(`dependencies: unlocked — freeze the list + registry pointer with \`abx lock-dependencies ${address}\` (that pins WHICH library each ref means; a Registry ref's bytes still live in the registry)`);
575
+ }
576
+ // The other two param hooks (if wired) — surfaced here since this is where hooks show. Quiet when
577
+ // unset (the common case). Manage all three with `abx set-param-hooks`.
578
+ if (state.paramHooks?.configureHook || state.paramHooks?.transferHook) {
579
+ const parts = [];
580
+ if (state.paramHooks.configureHook)
581
+ parts.push(`configure ${state.paramHooks.configureHook}`);
582
+ if (state.paramHooks.transferHook)
583
+ parts.push(`transfer ${state.paramHooks.transferHook}`);
584
+ info(`param hooks: ${parts.join(' · ')} ${dim('(configure = write-time veto · transfer = a VETO over transfers AND mints; manage with `abx set-param-hooks`)')}`);
585
+ }
586
+ // The hook lock is reported for every project with the surface, set hooks or not: a frozen empty
587
+ // trio is a real guarantee ("this project can never add a transfer veto"), and an unfrozen one is
588
+ // a live power even when no hook is set today. Folded from `ParamHooksFrozen`.
589
+ if (state.paramHooks) {
590
+ verifyReport.paramHooks = {
591
+ configureHook: state.paramHooks.configureHook,
592
+ augmentHook: state.paramHooks.augmentHook,
593
+ transferHook: state.paramHooks.transferHook,
594
+ locked: state.paramHooks.locked,
595
+ };
596
+ if (state.paramHooks.locked)
597
+ ok('param hooks: frozen — no hook address can change again (no transfer veto can ever be armed)');
598
+ else if (state.paramHooks.transferHook) {
599
+ console.log(` ${c.orange}⚠${c.reset} param hooks: UNLOCKED, and a transfer hook is armed — it can block transfers and mints, and the owner can re-point it. Freeze the set with ${bold(`abx lock-param-hooks ${address}`)}`);
600
+ }
601
+ else {
602
+ info(`param hooks: unlocked — no hook is set, but the owner can still add one (a transfer hook can block transfers/mints). Freeze with \`abx lock-param-hooks ${address}\``);
603
+ }
604
+ }
605
+ // This checks THIS machine's store. A render PUBLISHED to a hosted resolver (locator bridge) lives
606
+ // on that resolver, NOT here — so a "not found" below can be a false negative for a hosted drop.
607
+ // `abx verify <addr> --remote <resolver>` probes what the resolver actually serves (the truthful check).
608
+ if (minted.length)
609
+ info(dim(`render check is against THIS node's store; for a HOSTED drop use \`abx verify ${address} --remote <resolver>\``));
610
+ // ONE line per outcome, not per token. This printed the same full-sentence advisory 32 times on a
611
+ // 32-token project (~4KB of identical text) and pushed the four lines that answer "did my deploy
612
+ // work" off the top of the screen; at a 1000-token supply it is unreadable. The per-token detail
613
+ // that survives is the token LIST, which is the only part that differs.
614
+ const missing = [];
615
+ let present = 0;
616
+ for (const token of minted) {
617
+ const { found } = await currentRenderArtifact(client, state, token, storageForRender, 'image');
618
+ if (found)
619
+ present++;
620
+ else
621
+ missing.push(String(token.tokenId));
622
+ }
623
+ verifyReport.renders = {
624
+ minted: minted.length,
625
+ present,
626
+ missing, // token ids, so a caller can re-render exactly these
627
+ // Named for what it IS: this node's store. A render PUBLISHED to a hosted resolver lives
628
+ // there, not here, so `missing` is a false negative for a hosted drop — hence the scope.
629
+ scope: "this node's store",
630
+ };
631
+ codeRenders = { minted: minted.length, present };
632
+ emit(verifyReport);
633
+ if (present)
634
+ ok(`${present}/${minted.length} minted token(s): real render present (in this node's store)`);
635
+ if (missing.length) {
636
+ renderGap = true;
637
+ const ids = missing.length > 12 ? `${missing.slice(0, 12).join(', ')}, …+${missing.length - 12} more` : missing.join(', ');
638
+ console.log(` ${c.orange}⚠${c.reset} ${missing.length}/${minted.length} token(s) have no render in THIS node's store ${dim(`(#${ids})`)}`);
639
+ console.log(` ${dim('published to a hosted resolver? check there:')} ${bold(`abx verify ${address} --remote <resolver>`)}`);
640
+ console.log(` ${dim('else render them:')} ${bold(`abx render ${address}`)} ${dim('(once) ·')} ${bold('abx effects')} ${dim('(continuous)')}`);
641
+ }
642
+ }
643
+ // The availability verdict — computed here, after both the byte-integrity pass and the render
644
+ // pass above have run, from facts they already gathered (no new reads). See `computeAvailability`.
645
+ verifyReport.availability = jsonSafe(computeAvailability({
646
+ isCode: isCodeProject(state),
647
+ minted: codeRenders?.minted ?? 0,
648
+ present: codeRenders?.present ?? 0,
649
+ anyCheck,
650
+ unrecomputablePointers,
651
+ onChainContent: onChainContent.length,
652
+ }));
653
+ emit(verifyReport);
654
+ // The on-chain URI lane (a non-zero tokenURIRenderer, or an animation field pointing at the
655
+ // generator): read the generator's honesty surface — onChainStatus (branch · chain-complete ·
656
+ // unresolved refs · URL budget) — then decode tokenURI straight from the contract and report
657
+ // what the animation_url actually is. All eth_calls; no server, no local store involved.
658
+ if (hasOnChainUriLane(state)) {
659
+ const client = makePublicClient({ chainKey: CHAIN });
660
+ // Is this actually a PROGRAM, or a static image that merely resolves on-chain? `hasOnChainUriLane`
661
+ // is true whenever a tokenURI renderer is set — which it is for EVERY fully-on-chain project,
662
+ // image or code. So an image drop ran the code-project checks and ended in two ⚠ ("generator
663
+ // reports NO code", "no animation_url") that are the DESIGN for a static image, not a defect.
664
+ // Static on-chain images should not receive code-project warnings for their intentional shape.
665
+ const isCodeProject = state.contractType === 'code' || state.contractType === 'edition-code';
666
+ try {
667
+ const report = await onChainUriReport(client, state, flags.generator);
668
+ const { status, probe } = report;
669
+ // The factory-generation half of "is this pinned to a superseded deployment" already
670
+ // ships (ANCHOR_GENERATIONS + verifyCanonical, in the `canonical`/`abxGeneration` line above);
671
+ // this is the SINGLETON half — these two pointers were already read and printed here, just
672
+ // never compared. Binary by design (see `isCurrentGenerator`'s doc): current / not-current,
673
+ // never "superseded-but-known", because neither manifest keeps a prior address to recognize a
674
+ // mismatch against. A `false` here is NOT a defect report — an older-but-working renderer or
675
+ // generator still serves; it just isn't what a fresh deploy would get today.
676
+ const generatorCurrent = isCurrentGenerator(state.chainId, report.generator);
677
+ const rendererCurrent = state.tokenURIRenderer ? await isCurrentRenderer(client, state.tokenURIRenderer) : null;
678
+ verifyReport.onChainUri = jsonSafe({
679
+ generator: report.generator,
680
+ generatorCurrent, // true | false | null (no canonical generator recorded for this chain)
681
+ tokenURIRenderer: state.tokenURIRenderer ?? null,
682
+ // null ⇒ no renderer set at all (nothing to compare) — distinct from `false` (set, but not
683
+ // spec v11). See `isCurrentRenderer`: a read failure ALSO reports `false`, same as a stale
684
+ // renderer — the underlying probe cannot tell those apart, so neither can this field.
685
+ tokenURIRendererCurrent: rendererCurrent,
686
+ branch: status.branchName,
687
+ chainComplete: verifyChainComplete(isCodeProject, !!status.chainComplete),
688
+ unresolvedRefs: [...status.unresolvedRefs],
689
+ urlOverBudget: !!status.urlOverBudget,
690
+ probe: probe
691
+ ? { tokenId: probe.tokenId, onChainJson: !!probe.onChainJson, animation: probe.animation }
692
+ : null,
693
+ });
694
+ emit(verifyReport);
695
+ 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)'}`);
696
+ if (generatorCurrent === true)
697
+ ok(`generator is the CURRENT canonical one for ${CHAIN}`);
698
+ else if (generatorCurrent === false) {
699
+ 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(')')}`);
700
+ } // null: no canonical generator recorded for this chain at all — nothing to compare against, so say nothing rather than a false "NOT current".
701
+ if (rendererCurrent === true)
702
+ ok(`tokenURI renderer is CURRENT (spec v11)`);
703
+ else if (rendererCurrent === false) {
704
+ 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)')}`);
705
+ }
706
+ if (status.branchName === 'template') {
707
+ ok(`generator branch: template — the document assembles from the on-chain script chunks`);
708
+ if (status.chainComplete) {
709
+ ok('chain-complete — every dependency resolves to on-chain bytes; no server, gateway, or CDN in the graph');
710
+ // chainComplete is a SERVING claim, not an immutability one. A `Registry` dependency is
711
+ // fetched live from the registry contract on every read, so its bytes can change after
712
+ // `lock-dependencies` froze the pointer — and this flag reported `true` on both sides of
713
+ // a registry update. Say what it means rather than letting "complete" read as "finished".
714
+ info(dim(' chain-complete describes WHERE the bytes come from, not that they are frozen: a Registry dependency is re-fetched from the registry on every read, so its bytes can change even with lock-dependencies set. Locks freeze this contract; they cannot freeze another one.'));
715
+ }
716
+ else
717
+ info('not chain-complete — CDN-served or unresolved dependencies (CDN entries serve fine; on-chain bytes are the durability floor)');
718
+ for (const ref of status.unresolvedRefs) {
719
+ console.log(` ${c.red}✗${c.reset} unresolved dependency ${bold(ref)} — the document carries an <!-- abx:unresolved --> marker; fix the ref or the registry pointer`);
720
+ }
721
+ }
722
+ else if (status.branchName === 'directory') {
723
+ ok('generator branch: directory — the code field emits a parameterized gateway URL');
724
+ if (status.urlOverBudget)
725
+ console.log(` ${c.orange}⚠${c.reset} the emitted URL exceeds the 8KB budget — gateway front-ends may drop it; prefer template mode, trim the param surface, or carry big values as locator params`);
726
+ else
727
+ info('URL within the 8KB budget');
728
+ }
729
+ else if (isCodeProject) {
730
+ console.log(` ${c.orange}⚠${c.reset} generator reports NO code (neither script chunks nor a code field) — animation_url degrades to an <!-- abx:no-code --> marker`);
731
+ }
732
+ else {
733
+ // A static image drop has no program by design — stating that is fine, warning about it is not.
734
+ info('no program on this project — a static image drop, so the metadata carries no animation_url (expected, not a gap)');
735
+ }
736
+ if (!probe) {
737
+ info('tokenURI probe skipped — no token minted yet (mint token #0, then re-run)');
738
+ }
739
+ else if (!probe.onChainJson) {
740
+ console.log(` ${c.red}✗${c.reset} ${probe.accessor}(${probe.tokenId}) is NOT an on-chain data:application/json;base64 URI — got "${probe.uriPrefix}…"`);
741
+ }
742
+ else {
743
+ ok(`${probe.accessor}(${probe.tokenId}) resolves ON-CHAIN: data:application/json;base64 — decoded from the contract, no server`);
744
+ const a = probe.animation;
745
+ if (a.form === 'data-html') {
746
+ if (a.marker)
747
+ console.log(` ${c.orange}⚠${c.reset} animation_url: data:text/html;base64 (${a.bytes} bytes decoded) but the document carries ${bold(a.marker)} — it degraded honestly instead of reverting`);
748
+ else
749
+ ok(`animation_url: data:text/html;base64 — ${a.bytes} bytes of HTML, decoded (the full document, inline)`);
750
+ }
751
+ else if (a.form === 'url') {
752
+ ok(`animation_url: ${a.url} ${dim('(directory branch — the parameterized gateway URL, landed verbatim)')}`);
753
+ }
754
+ else if (a.form === 'data-other') {
755
+ console.log(` ${c.orange}⚠${c.reset} animation_url is a data: URI but not text/html — got "${a.prefix}…"`);
756
+ }
757
+ else if (isCodeProject) {
758
+ console.log(` ${c.orange}⚠${c.reset} no animation_url in the on-chain JSON — the generator field may be missing or unrenderable`);
759
+ }
760
+ // else: a static image has no animation_url and is not supposed to — say nothing.
761
+ }
762
+ // The READ-side envelope. Params enumerate on-chain, so the write side is unbounded — but
763
+ // tokenURI and tokenData assemble EVERY enumerated param per call, and that is what grows.
764
+ // ~64 keys is the documented design envelope; hard failure only nears at several hundred.
765
+ // Advisory (never a ✗) — a big surface is a legitimate choice, it just isn't free.
766
+ const set = await readSetParamKeys(client, state.address, probe ? BigInt(probe.tokenId) : undefined);
767
+ if (set) {
768
+ const total = new Set([...set.contract, ...set.token]).size;
769
+ if (total > 64) {
770
+ console.log(` ${c.orange}⚠${c.reset} ${total} params enumerate on-chain${probe ? ` for token #${probe.tokenId}` : ''} — tokenURI and tokenData assemble every one of them per call, so both grow with this count. ` +
771
+ dim('The documented design envelope is ~64; past a few hundred a public eth_call can hit its gas cap.'));
772
+ }
773
+ }
774
+ }
775
+ catch (e) {
776
+ // First line only. A viem read error carries a multi-line dump (Contract Call / args / Docs /
777
+ // Version) that is meaningless to a creator and buried the actual sentence when this fired.
778
+ const first = String(e.message ?? e).split('\n')[0].trim();
779
+ console.log(` ${c.orange}⚠${c.reset} on-chain URI check unavailable: ${first}`);
780
+ }
781
+ }
782
+ if (renderGap)
783
+ console.log(`\n ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}\n`);
784
+ else
785
+ console.log(anyCheck && allGood ? `\n ${g('✓ verified')} — the node serves exactly what the chain commits to.\n` : '\n');
786
+ // A byte-vs-chain mismatch is an integrity FAILURE, so fail the command. Verify's whole job is to
787
+ // answer "is what's served what the chain vouches for" — exiting 0 while printing ✗ meant nothing
788
+ // could gate on it (a script or CI would sail past a corrupted image). Deliberately narrow: a
789
+ // missing render / placeholder is a normal, expected state and still exits 0.
790
+ if (anyCheck && !allGood)
791
+ process.exitCode = 1;
792
+ }
793
+ // `abx verify <addr> --remote <url>` — verify what a HOSTED resolver actually serves (the local
794
+ // `verify` checks THIS machine's store/backend, the wrong store for a hosted drop). Probes the real
795
+ // `/…/image` route, so it accounts for the locator bridge (a 302 to ipfs/ar) exactly as a marketplace
796
+ // sees it — the truthful "did the thumbnail land?" check after a remote render.
797
+ export async function cmdVerifyRemote(address, remote, emit) {
798
+ const base = (await serviceInterfaceClient(remote, TOKEN_API_INTERFACE)).baseUrl;
799
+ const chainId = resolveChain(CHAIN).id;
800
+ // Same shape, same field names as the local lane's `verifyReport` (cmdVerifyBody) wherever the
801
+ // two lanes answer the same question, so a caller doesn't need a second parser for `--remote`.
802
+ // `--remote --json` used to emit NOTHING at all — this command routed around `withJson` entirely
803
+ // (see `cmdVerify`) — so every field here is new, not a rename of something that already worked.
804
+ const verifyReport = {
805
+ address,
806
+ remote: base,
807
+ name: null,
808
+ watching: null,
809
+ contentChecks: [],
810
+ onChainContent: [],
811
+ contentIntegrity: 'not-checked',
812
+ renders: null,
813
+ availability: null,
814
+ // FAIL CLOSED. `emit` registers this object and `withJson` prints its FINAL state, so every
815
+ // terminal path must earn its verdict — and a path that throws (a down endpoint, a 404 from a
816
+ // resolver that doesn't serve this project) never reaches one. This used to initialize `true`,
817
+ // which meant `verify --remote --json` on an unregistered project printed `"ok": true` on
818
+ // stdout while exiting 1 and saying "register it first" on stderr. `ok` is documented as the
819
+ // field CI gates on, so an un-earned `true` is the one wrong answer this payload can give:
820
+ // "we could not check" is not "it is fine". Every early return below sets it explicitly.
821
+ ok: false,
822
+ };
823
+ emit(verifyReport);
824
+ console.log(bold(`\n verify ${address} ${dim(`(remote → ${base})`)}`));
825
+ // A DOWN endpoint and a wrong-address endpoint are different problems with different fixes, and a
826
+ // bare `fetch failed` says neither. Match `abx status --remote` so both commands describe the same
827
+ // condition consistently.
828
+ let stateRes;
829
+ try {
830
+ stateRes = await fetch(`${base}/api/project/${address}`);
831
+ }
832
+ catch {
833
+ throw new Error(`verify: nothing responded at ${base}${remote.source === 'named' ? ` (from ABX_REMOTE_${remote.name}_URL)` : ''}. Is it running, and is that the right address?`);
834
+ }
835
+ if (!stateRes.ok) {
836
+ throw new Error(`resolver ${base} doesn't serve ${address} (HTTP ${stateRes.status}) — register it first: abx add ${address} --remote ${remote.name?.toLowerCase() ?? base}`);
837
+ }
838
+ const state = (await stateRes.json());
839
+ verifyReport.name = state.name ?? null;
840
+ const onChainContent = (state.tokens ?? []).flatMap((token) => {
841
+ const representation = onChainImageSource(token.fields ?? []);
842
+ return representation
843
+ ? [{ tokenId: token.tokenId, lifecycle: token.lifecycle, field: 'image', representation }]
844
+ : [];
845
+ });
846
+ verifyReport.onChainContent = onChainContent;
847
+ info(`serving as "${state.name ?? address}"`);
848
+ // Is the resolver actively WATCHING the chain? Prove it from /api/watch (the meta the watcher
849
+ // stamps each tick) so a hosted operator who can't tail the log still sees liveness — and catches
850
+ // a silently-stalled watcher (pollAt gone stale) instead of assuming auto-updates still work.
851
+ const watch = (await fetch(`${base}/api/watch`)
852
+ .then((r) => (r.ok ? r.json() : null))
853
+ .catch(() => null));
854
+ verifyReport.watching = watch?.watching ?? null;
855
+ emit(verifyReport);
856
+ if (watch?.watching) {
857
+ const heads = Object.entries(watch.chains).map(([ck, s]) => `${ck} @ block ${s.head}`).join(', ');
858
+ const ageS = watch.pollAt ? Math.round((Date.now() - Date.parse(watch.pollAt)) / 1000) : null;
859
+ const stalled = ageS !== null && ageS > Math.max(60, Math.round((Number(watch.intervalMs) / 1000) * 4));
860
+ const detail = `watching ${heads || 'chain'}${ageS !== null ? ` · last poll ${ageS}s ago` : ''}${watch.lastDeltaAt ? ` · last change ${watch.lastDeltaAt}` : ''}`;
861
+ if (stalled)
862
+ console.log(` ${c.orange}⚠${c.reset} ${detail} — watcher looks STALLED; restart \`abx serve\``);
863
+ else
864
+ ok(detail);
865
+ }
866
+ else if (watch) {
867
+ console.log(` ${dim('watcher OFF — changes land only via explicit add/index or a manual render (ABX_WATCH_INTERVAL_MS=0)')}`);
868
+ }
869
+ const minted = (state.tokens ?? []).filter((t) => t.lifecycle === 'live');
870
+ if (minted.length === 0) {
871
+ info('no tokens minted yet — mint token #0, then re-run.');
872
+ verifyReport.availability = jsonSafe({ status: 'unknown', note: 'no tokens minted yet' });
873
+ // Nothing committed yet is not a FAILURE — there is simply nothing to hash, and this path exits
874
+ // 0. Said explicitly because the initializer is now fail-closed: an un-minted project is the one
875
+ // early return that legitimately earns `ok`.
876
+ verifyReport.contentIntegrity = 'no-commitments';
877
+ verifyReport.ok = true;
878
+ emit(verifyReport);
879
+ console.log('');
880
+ return;
881
+ }
882
+ // Prefer the resolver's effect-status API (the derived 4-state readout: up-to-date · rendering ·
883
+ // failed(error) · stale). Falls back to the raw /image probe for a resolver without the route.
884
+ const statusRes = await fetch(`${base}/api/project/${address}/effects`).catch(() => null);
885
+ if (statusRes?.ok) {
886
+ const report = (await statusRes.json());
887
+ for (const t of report.tokens) {
888
+ const label = `token #${t.tokenId} ${t.effectKey}`;
889
+ if (t.status === 'up-to-date')
890
+ ok(`${label}: up to date (real render at the current state)`);
891
+ else if (t.status === 'rendering')
892
+ info(`${label}: rendering — the effects runner is on it`);
893
+ else if (t.status === 'failed')
894
+ console.log(` ${c.red}✗${c.reset} ${label}: FAILED${t.attempts ? ` after ${t.attempts} attempt(s)` : ''} — ${t.error ?? 'see runner logs'} ${dim(`(fix, then \`abx render ${address} ${t.tokenId} --force --remote ${base}\`)`)}`);
895
+ else
896
+ console.log(` ${c.orange}⚠${c.reset} ${label}: stale — no render at the current state yet (the runner's next notify/sweep picks it up, or \`abx render ${address} --remote ${base}\`)`);
897
+ }
898
+ const { upToDate, stale, rendering, failed } = report.counts;
899
+ // RENDERS ONLY — say so. This report answers "is there a current render for each token", never
900
+ // "do the served bytes match the on-chain commitment"; those are different questions and this
901
+ // command promises the second one too. A project with no renders at all (a 1/1, an image Series)
902
+ // has nothing to be "up to date" ABOUT, so don't print a 0/N fraction — but don't let a green ✓
903
+ // here read as "the image is verified" either. Byte integrity comes from the check below.
904
+ if (report.tokens.length === 0) {
905
+ info(`renders ${dim('none for this project (a static image needs no off-chain render)')}`);
906
+ }
907
+ else {
908
+ // Lead with the count that carries the polarity: "N of M current" never inverts on a skim the
909
+ // way "0/M up to date" does.
910
+ const summary = `${upToDate} of ${minted.length} token(s) current${rendering ? ` · ${rendering} rendering` : ''}${stale ? ` · ${stale} stale` : ''}${failed ? ` · ${failed} FAILED` : ''}`;
911
+ if (failed || stale)
912
+ console.log(` ${c.orange}⚠${c.reset} renders: ${summary} ${dim('— live view animates regardless; only the static thumbnail is affected.')}`);
913
+ else
914
+ ok(`renders: ${summary}`);
915
+ }
916
+ verifyReport.renders = jsonSafe({ minted: minted.length, upToDate, stale, rendering, failed, tokens: report.tokens });
917
+ verifyReport.availability = jsonSafe(computeAvailability({
918
+ isCode: report.tokens.length > 0,
919
+ minted: minted.length,
920
+ present: upToDate,
921
+ anyCheck: false,
922
+ unrecomputablePointers: 0,
923
+ onChainContent: onChainContent.length,
924
+ }));
925
+ emit(verifyReport);
926
+ const integrity = await reportRemoteByteIntegrity(address, remote, base, onChainContent.map((item) => item.representation));
927
+ verifyReport.contentIntegrity = integrity.status;
928
+ verifyReport.contentChecks = integrity.checks;
929
+ verifyReport.ok = integrity.status !== 'mismatch';
930
+ emit(verifyReport);
931
+ return;
932
+ }
933
+ let gap = false;
934
+ let present = 0;
935
+ const missing = [];
936
+ for (const t of minted) {
937
+ const img = await fetch(`${base}/t/${chainId}/${address}/${t.tokenId}/image`, { redirect: 'manual' });
938
+ const loc = img.headers.get('location');
939
+ const ct = img.headers.get('content-type') ?? '';
940
+ if (img.status >= 300 && img.status < 400 && loc) {
941
+ present++;
942
+ ok(`token #${t.tokenId} image: real render — resolver 302s to ${loc}`);
943
+ }
944
+ else if (img.status === 200 && !/svg/i.test(ct)) {
945
+ present++;
946
+ ok(`token #${t.tokenId} image: real render present (${ct})`);
947
+ }
948
+ else {
949
+ gap = true;
950
+ missing.push(t.tokenId);
951
+ 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`);
952
+ }
953
+ }
954
+ console.log(gap
955
+ ? ` ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}`
956
+ : ` ${g('✓ thumbnails are real renders')} ${dim('— served straight from the resolver.')}`);
957
+ verifyReport.renders = jsonSafe({ minted: minted.length, present, missing, scope: 'the resolver (raw image probe — no /effects route)' });
958
+ verifyReport.availability = jsonSafe(computeAvailability({ isCode: true, minted: minted.length, present, anyCheck: false, unrecomputablePointers: 0, onChainContent: onChainContent.length }));
959
+ emit(verifyReport);
960
+ const integrity = await reportRemoteByteIntegrity(address, remote, base, onChainContent.map((item) => item.representation));
961
+ verifyReport.contentIntegrity = integrity.status;
962
+ verifyReport.contentChecks = integrity.checks;
963
+ verifyReport.ok = integrity.status !== 'mismatch';
964
+ emit(verifyReport);
965
+ }
966
+ /**
967
+ * The half `abx verify --remote` was missing: do the served BYTES still hash to the on-chain
968
+ * commitment? Everything above it checks renders (is a thumbnail current, is it a placeholder) — a
969
+ * different question, and a green ✓ there was reading as "the image is correct" when the bytes could
970
+ * genuinely mismatch.
971
+ *
972
+ * The service is the right place to answer it: it holds both the bytes and the chain, and it already
973
+ * exposes exactly this check (`GET /api/project/:addr/verify`, bearer-gated because it triggers
974
+ * outbound fetches). When we can't reach that — no credential, or an older node — say plainly that
975
+ * byte integrity was NOT checked rather than leaving the ✓ above to imply it was.
976
+ */
977
+ export async function reportRemoteByteIntegrity(address, remote, base, onChainRepresentations = []) {
978
+ if (!remote.token) {
979
+ 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.`);
980
+ return { status: 'not-checked', checks: [] };
981
+ }
982
+ let report;
983
+ try {
984
+ const res = await fetch(`${base}/api/project/${address}/verify`, { headers: { authorization: `Bearer ${remote.token}` } });
985
+ if (res.status === 401 || res.status === 403) {
986
+ 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).`);
987
+ return { status: 'not-checked', checks: [] };
988
+ }
989
+ if (!res.ok) {
990
+ // Spec'd as part of `abx-token-api/v1`, so a conforming service has it — but say it neutrally:
991
+ // this is equally "an older self-hosted node" and "a provider that didn't implement it".
992
+ warn(`byte integrity NOT checked — ${base} serves no /api/project/…/verify route (HTTP ${res.status}). ` +
993
+ `Your own node? Redeploy it (\`abx deploy-resolver\`). A provider's? It's part of abx-token-api/v1 — ask them for it. ` +
994
+ `Meanwhile ${bold(`abx verify ${address}`)} checks the bytes on a node that holds them.`);
995
+ return { status: 'not-checked', checks: [] };
996
+ }
997
+ report = (await res.json());
998
+ }
999
+ catch {
1000
+ warn(`byte integrity NOT checked — couldn't reach ${base}'s verify route.`);
1001
+ return { status: 'not-checked', checks: [] };
1002
+ }
1003
+ const checked = (report.tokens ?? []).filter((t) => t.checks.length > 0);
1004
+ if (checked.length === 0) {
1005
+ const representations = [...new Set(onChainRepresentations)];
1006
+ info(`bytes ${dim(representations.length
1007
+ ? `stored on chain (${representations.join(', ')}) — no separate hash commitment to check`
1008
+ : 'no on-chain byte commitment to check (this project commits no image hash)')}`);
1009
+ return { status: 'no-commitments', checks: [] };
1010
+ }
1011
+ const flatChecks = checked.flatMap((t) => t.checks.map((k) => ({ tokenId: t.tokenId, kind: k.kind, verified: k.verified })));
1012
+ const bad = checked.filter((t) => t.checks.some((k) => !k.verified));
1013
+ if (bad.length === 0) {
1014
+ ok(`bytes: ${checked.length} token(s) hash-match their on-chain commitment ${dim('(what the service serves IS what the chain vouches for)')}`);
1015
+ return { status: 'ok', checks: flatChecks };
1016
+ }
1017
+ const spec = remote.name?.toLowerCase() ?? base;
1018
+ 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 ` +
1019
+ `${dim('(this is what a marketplace shows as a blank or placeholder image)')}. The chain is the truth, so the served copy is the wrong one. Two causes, two fixes:`);
1020
+ info(`durable bytes exist (ipfs://, ar://) but weren't bridged → ${bold(`abx add ${address} --remote ${spec}`)} forwards the locator, then re-run this.`);
1021
+ 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.`);
1022
+ process.exitCode = 1; // same rule as the local lane: an integrity mismatch fails the command
1023
+ return { status: 'mismatch', checks: flatChecks };
1024
+ }
1025
+ // ── state: a one-glance operational snapshot of a deployed contract, read straight from chain ──
1026
+ // Read-only (no tx, no local index). Series-only getters revert on a 1/1, so each read is defensive
1027
+ // → the same command works for both. The agent-friendly "what's the state before/after an op" call.
1028
+ /**
1029
+ * The `seed source` readout — three states, and the distinction between them is the whole reason the
1030
+ * line exists. `0x0` is a code project that deliberately draws no mint seed; the canonical address is
1031
+ * the shared pseudorandom `AbxSeedSource` (whose properties are documented and NOT lottery-grade);
1032
+ * anything else is the creator's own contract, about which ABX knows and claims nothing. A buyer or an
1033
+ * agent reading this needs to be able to tell "the standard one" from "someone's custom randomness"
1034
+ * at a glance — flattening them to a bare address hides exactly the fact worth surfacing.
1035
+ */
1036
+ function describeSeedSource(source) {
1037
+ if (source === zeroAddress)
1038
+ return dim('none (0x0 — no mint-time seed drawn at mint)');
1039
+ if (source.toLowerCase() === canonicalSeedSource(resolveChain(CHAIN).id).toLowerCase()) {
1040
+ return `${source} ${dim('(canonical AbxSeedSource — pseudorandom, not lottery-grade)')}`;
1041
+ }
1042
+ return `${source} ${dim('— CUSTOM IAbxSeedSource (not the canonical one; its randomness properties are the project’s to state)')}`;
1043
+ }
1044
+ export const STATE_ABI = [
1045
+ { type: 'function', name: 'owner', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
1046
+ { type: 'function', name: 'totalSupply', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
1047
+ { type: 'function', name: 'nextTokenId', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
1048
+ { type: 'function', name: 'maxInvocations', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
1049
+ { type: 'function', name: 'paused', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
1050
+ { type: 'function', name: 'minter', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
1051
+ { type: 'function', name: 'primaryPayee', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
1052
+ { type: 'function', name: 'tokenURIRenderer', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
1053
+ { type: 'function', name: 'royaltyInfo', stateMutability: 'view', inputs: [{ type: 'uint256' }, { type: 'uint256' }], outputs: [{ type: 'address' }, { type: 'uint256' }] },
1054
+ // ERC-721C (creator token) — ERC-165 advertises ICreatorToken ONLY when enrolled at deploy.
1055
+ { type: 'function', name: 'supportsInterface', stateMutability: 'view', inputs: [{ type: 'bytes4' }], outputs: [{ type: 'bool' }] },
1056
+ { type: 'function', name: 'getTransferValidator', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
1057
+ // The royalty ceiling + burn opt-in are read via the SDK's `readCollectionPolicy` (one owner of
1058
+ // those two getters, shared with every other integrator) — deliberately not re-declared here.
1059
+ // Seed Source extension — composed by CODE projects only, so absent (undefined) on an image token.
1060
+ // Shown because it is the one setting an owner can re-point mid-sale that changes what a LATER
1061
+ // buyer receives; `SeedSourceSet` puts it on the spine, and this makes it readable in one command.
1062
+ { type: 'function', name: 'seedSource', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
1063
+ // Report every irreversible lock in full (previously only `paramHooksLocked`
1064
+ // showed here). `tokenURILocked`/`contractURILocked`/`contractFieldLocked` exist on every ABX
1065
+ // token (baseline URI + On-Chain Metadata surface); `scriptLocked`/`dependenciesLocked` exist only
1066
+ // on the two code twins — see `readCollectionLocks`, which gates those two on `isCode` rather than
1067
+ // trusting a revert to mean "unknown" for a contract that never had the surface at all.
1068
+ { type: 'function', name: 'tokenURILocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
1069
+ { type: 'function', name: 'contractURILocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
1070
+ { type: 'function', name: 'scriptLocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
1071
+ { type: 'function', name: 'dependenciesLocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
1072
+ { type: 'function', name: 'contractFieldLocked', stateMutability: 'view', inputs: [{ type: 'bytes32' }], outputs: [{ type: 'bool' }] },
1073
+ ];
1074
+ /** The edition twin of {@link STATE_ABI} — `owner`/`maxInvocations`/`paused`/`minter`/
1075
+ * `primaryPayee`/`tokenURIRenderer`/`royaltyInfo` are shared, unchanged, function names (see
1076
+ * `kind.ts`'s own note on why); `totalSupply`/`maxSupply` take an id. The creator-token probe is
1077
+ * ALSO shared, unchanged: Limit Break uses the identical `ICreatorToken` ERC-165 id (0xad0d7f6c)
1078
+ * and `getTransferValidator()` surface for 721C and 1155C, and `CreatorToken1155` advertises them
1079
+ * the same way when enrolled — so an enrolled edition reports its validator exactly like a 721. */
1080
+ export const EDITION_STATE_ABI = [
1081
+ { type: 'function', name: 'owner', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
1082
+ { type: 'function', name: 'maxInvocations', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
1083
+ { type: 'function', name: 'totalSupply', stateMutability: 'view', inputs: [{ type: 'uint256' }], outputs: [{ type: 'uint256' }] },
1084
+ { type: 'function', name: 'maxSupply', stateMutability: 'view', inputs: [{ type: 'uint256' }], outputs: [{ type: 'uint256' }] },
1085
+ { type: 'function', name: 'paused', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
1086
+ { type: 'function', name: 'minter', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
1087
+ { type: 'function', name: 'primaryPayee', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
1088
+ { type: 'function', name: 'tokenURIRenderer', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
1089
+ { type: 'function', name: 'royaltyInfo', stateMutability: 'view', inputs: [{ type: 'uint256' }, { type: 'uint256' }], outputs: [{ type: 'address' }, { type: 'uint256' }] },
1090
+ // ERC-1155C (creator token) — identical ERC-165 id + getter as 721C; advertised only when enrolled.
1091
+ { type: 'function', name: 'supportsInterface', stateMutability: 'view', inputs: [{ type: 'bytes4' }], outputs: [{ type: 'bool' }] },
1092
+ { type: 'function', name: 'getTransferValidator', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
1093
+ // The royalty ceiling + burn opt-in are read via the SDK's `readCollectionPolicy` (one owner of
1094
+ // those two getters, shared with every other integrator) — deliberately not re-declared here.
1095
+ // Seed Source extension — EditionCode composes it; the image editions don't. See STATE_ABI's note.
1096
+ { type: 'function', name: 'seedSource', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
1097
+ // The same lock surface as STATE_ABI's edit — see the note there. Identical getter names on both
1098
+ // standards (verified against the generated ABI: every edition twin exposes the same five).
1099
+ { type: 'function', name: 'tokenURILocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
1100
+ { type: 'function', name: 'contractURILocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
1101
+ { type: 'function', name: 'scriptLocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
1102
+ { type: 'function', name: 'dependenciesLocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
1103
+ { type: 'function', name: 'contractFieldLocked', stateMutability: 'view', inputs: [{ type: 'bytes32' }], outputs: [{ type: 'bool' }] },
1104
+ ];
1105
+ /**
1106
+ * The royalty ceiling line — and the headroom, which is the part a creator has not been told.
1107
+ *
1108
+ * A ceiling above the live rate means the owner can raise the rate to it unilaterally, and nobody
1109
+ * looking at a listing can see that. So the useful sentence is not "your cap is 10%": it is that
1110
+ * reducing the cap TO the current rate is what turns "5% today" into "5%, provably, forever".
1111
+ */
1112
+ function royaltyCapLine(address, capBps, rateBps) {
1113
+ const cap = `${capBps / 100}%`;
1114
+ const tail = '(ceiling — owner-set at deploy, reduce-only)';
1115
+ if (rateBps === null || capBps <= rateBps) {
1116
+ return `${cap} ${dim(`${tail} — locked to the current rate: this royalty can never rise`)}`;
1117
+ }
1118
+ return (`${cap} ${dim(tail)}\n` +
1119
+ ` ${dim(`↳ headroom: the rate is ${rateBps / 100}%, and the owner may raise it to ${cap} without asking anyone.`)}\n` +
1120
+ ` ${dim(` \`abx set-royalty-cap ${address} --cap ${rateBps}\` makes today's rate permanent.`)}`);
1121
+ }
1122
+ /**
1123
+ * Refuse an obsolete RPC variable before silently falling back to the public default. In the exact
1124
+ * failure this guards, ABX_RPC_URL held the reporter's dead endpoint, `state` ignored it, reached a
1125
+ * different node, and then described the contract instead of naming the misspelling. A valid
1126
+ * chain-specific or shared plural variable wins and suppresses this refusal.
1127
+ */
1128
+ function assertNoIgnoredRpcEnv(address, env = process.env) {
1129
+ const chainVariable = rpcEnvVar(CHAIN);
1130
+ if (!env.ABX_RPC_URL || env.ABX_RPC_URLS || env[chainVariable])
1131
+ return;
1132
+ throw new Error(`ABX_RPC_URL (singular) is set but is not read. Rename it to ${chainVariable} or ABX_RPC_URLS; ` +
1133
+ `refusing to ignore an RPC setting while reading ${address}.`);
1134
+ }
1135
+ /**
1136
+ * Distinguish "the RPC is down" from "this address isn't an ABX token" before contract reads.
1137
+ * This catches endpoints that cannot answer even a head query; the required-getter diagnostics
1138
+ * below separately catch nodes that answer block/code probes but refuse `eth_call`.
1139
+ */
1140
+ async function assertRpcReachable(publicClient, address) {
1141
+ try {
1142
+ await publicClient.getBlockNumber();
1143
+ }
1144
+ catch {
1145
+ throw new Error(`could not reach the ${CHAIN} RPC (check ${rpcEnvVar(CHAIN)} or ABX_RPC_URLS).` +
1146
+ ` This is not a fact about ${address}.`);
1147
+ }
1148
+ }
1149
+ async function diagnosticStateRead(publicClient, address, abi, functionName, args = []) {
1150
+ try {
1151
+ const value = (await publicClient.readContract({ address, abi, functionName, args }));
1152
+ return { value };
1153
+ }
1154
+ catch (error) {
1155
+ return { error };
1156
+ }
1157
+ }
1158
+ async function describeMissingAbxState(publicClient, address, requiredReadErrors) {
1159
+ let code;
1160
+ try {
1161
+ code = await publicClient.getCode({ address });
1162
+ }
1163
+ catch {
1164
+ return (`could not read ${address} on ${CHAIN} — the RPC failed mid-call ` +
1165
+ `(check ${rpcEnvVar(CHAIN)} or ABX_RPC_URLS).`);
1166
+ }
1167
+ if (!code || code === '0x') {
1168
+ return `no code at ${address} on ${CHAIN} — not an ABX token, or the wrong chain.`;
1169
+ }
1170
+ if (requiredReadErrors.some(isRpcReadFailure)) {
1171
+ return (`could not read ABX state for ${address} on ${CHAIN} — the RPC answered block/code requests ` +
1172
+ `but failed eth_call (rate-limited or backend unhealthy; check ${rpcEnvVar(CHAIN)} or ABX_RPC_URLS). ` +
1173
+ `This is not a contract verdict.`);
1174
+ }
1175
+ return `${address} has code on ${CHAIN} but is not an ABX token (no usable owner/supply getters).`;
1176
+ }
1177
+ /**
1178
+ * What a `canonical: NO` actually costs, said once and shared by both state readouts.
1179
+ *
1180
+ * A contract deployed outside a trust anchor is a real, working NFT — this is not a failure — but the
1181
+ * one property it can never gain is the one platforms allowlist against, and it cannot be granted
1182
+ * later at any price: canonicity is fixed at deploy by WHICH contract created it. So the readout says
1183
+ * what was lost and what the only remedy is (redeploy through the factory, migrate holders), rather
1184
+ * than printing a bare "NO" the reader has to interpret.
1185
+ */
1186
+ function warnNotCanonical(address) {
1187
+ warn(`${address} was not created by an ABX trust anchor, so ${bold('isAbxClone is false')} for it. It still works as ` +
1188
+ `an NFT, but nothing that allowlists ABX collections — marketplaces, the App Store, this toolkit's own ` +
1189
+ `provenance checks — can recognize it as one, and that cannot be added afterwards: canonicity is decided ` +
1190
+ `by which contract deployed it. The only remedy is a fresh deploy through ${bold('abx deploy')} / ` +
1191
+ `${bold('deploy-series')} / ${bold('deploy-code')} and moving holders to it. If this was deliberate ` +
1192
+ `(a bespoke contract, a superseded factory), nothing here is broken — just make sure whoever owns the ` +
1193
+ `project knows they chose it.`);
1194
+ }
1195
+ /**
1196
+ * The bounded field-lock scan: every field this node can enumerate is one
1197
+ * of the protocol registry's `METADATA_FIELD` keys (site/content/docs/protocol/metadata.mdx), read
1198
+ * with `contractFieldLocked`. A creator's own custom field (`stems`, `palette`, …) is a real,
1199
+ * independently-lockable key — but this node can only learn its NAME from the spine's
1200
+ * `ContractFieldLocked` event, which needs an indexer, not a bare head read. So this reports "none
1201
+ * of the STANDARD fields are locked", never "no fields are locked" — the caveat has to travel with
1202
+ * the data, not live only in a comment a caller never sees.
1203
+ */
1204
+ async function readFieldLocks(publicClient, address, abi) {
1205
+ return Promise.all(Object.values(METADATA_FIELD).map(async (field) => ({
1206
+ field,
1207
+ locked: (await tryReadContract(publicClient, { address, abi, functionName: 'contractFieldLocked', args: [encodeTag(field)] })) ?? null,
1208
+ })));
1209
+ }
1210
+ /**
1211
+ * Read every applicable irreversible lock for one collection: token/contract URI config, the
1212
+ * bounded standard field set, and — for a code project — the script and dependency freezes.
1213
+ * `paramHooks.locked` is deliberately NOT read here: `cmdStateBody`/`cmdStateEditionBody` already
1214
+ * read it (`readParamHooksLocked`) as part of the existing param-hooks readout, and re-reading it
1215
+ * would cost a second `eth_call` for a fact already on hand — see the callers, which pass it into
1216
+ * {@link printLocks} instead.
1217
+ *
1218
+ * `isCode` gates `scriptLocked`/`dependenciesLocked`: those getters exist ONLY on the two code
1219
+ * twins (SeriesCode/EditionCode), so asking a plain image/Series contract for them isn't a failed
1220
+ * read to report as "unknown" — it's a surface that was never there, reported as `null` (not
1221
+ * applicable) via the OUTER object rather than the boolean inside it.
1222
+ */
1223
+ export async function readCollectionLocks(publicClient, address, abi, isCode) {
1224
+ const read = (fn) => tryReadContract(publicClient, { address, abi, functionName: fn });
1225
+ const [tokenURI, contractURI, fields, script, dependencies] = await Promise.all([
1226
+ read('tokenURILocked'),
1227
+ read('contractURILocked'),
1228
+ readFieldLocks(publicClient, address, abi),
1229
+ isCode ? read('scriptLocked') : Promise.resolve(undefined),
1230
+ isCode ? read('dependenciesLocked') : Promise.resolve(undefined),
1231
+ ]);
1232
+ return {
1233
+ tokenURI: tokenURI ?? null,
1234
+ contractURI: contractURI ?? null,
1235
+ fields,
1236
+ script: isCode ? { locked: script ?? null } : null,
1237
+ dependencies: isCode ? { locked: dependencies ?? null } : null,
1238
+ };
1239
+ }
1240
+ /** One lock's human verdict — the same three words everywhere this prints, so a reader learns the
1241
+ * vocabulary once. */
1242
+ function lockVerdict(locked) {
1243
+ return locked === true
1244
+ ? `${c.green}frozen${c.reset}`
1245
+ : locked === false
1246
+ ? `${c.orange}unlocked${c.reset}`
1247
+ : dim('unknown (node refused the check)');
1248
+ }
1249
+ /**
1250
+ * Print + emit the "Locks" section shared by both `state` bodies (721 and edition) — the one
1251
+ * coherent summary, in place of a lone `paramHooks.locked` line. Every lock is its
1252
+ * own independent switch (freezing one does nothing to any other), which is easy to miss when each
1253
+ * only ever appeared alone — so the header says so explicitly, and every line names the exact
1254
+ * command that freezes THAT lock and nothing else.
1255
+ */
1256
+ function printLocks(address, locks, hasParamHooks, paramHooksLock, payload, emit) {
1257
+ const lockedFieldNames = locks.fields.filter((f) => f.locked === true).map((f) => f.field);
1258
+ const unknownFieldCount = locks.fields.filter((f) => f.locked === null).length;
1259
+ payload.locks = jsonSafe({
1260
+ tokenURI: locks.tokenURI,
1261
+ contractURI: locks.contractURI,
1262
+ script: locks.script,
1263
+ dependencies: locks.dependencies,
1264
+ // `null` ⇒ no ConfigurableParams surface on this contract (not a code project) — matching the
1265
+ // `script`/`dependencies` null-means-N/A convention rather than reusing LockRead's "unknown".
1266
+ paramHooks: hasParamHooks ? (paramHooksLock ?? null) : null,
1267
+ fields: {
1268
+ // The bounded scan, keyed by name — automation gets the same three-way distinction the human
1269
+ // line does (true/false/null), never a collapsed "none locked".
1270
+ checked: locks.fields,
1271
+ 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.",
1272
+ },
1273
+ });
1274
+ emit(payload);
1275
+ console.log(`\n ${bold('Locks')} ${dim('each is INDEPENDENT — freezing one does nothing to the others')}`);
1276
+ info(`tokenURI ${lockVerdict(locks.tokenURI)} ${dim(`\`abx lock-uri ${address}\` freezes the pointer + renderer forever`)}`);
1277
+ info(`contractURI ${lockVerdict(locks.contractURI)} ${dim(`\`abx lock-uri ${address} --collection\` freezes the pointer + renderer forever`)}`);
1278
+ if (locks.script)
1279
+ info(`script ${lockVerdict(locks.script.locked)} ${dim(`\`abx lock-script ${address}\` freezes the program bytes forever`)}`);
1280
+ if (locks.dependencies)
1281
+ info(`dependencies ${lockVerdict(locks.dependencies.locked)} ${dim(`\`abx lock-dependencies ${address}\` freezes the library set forever`)}`);
1282
+ if (hasParamHooks)
1283
+ info(`param hooks ${lockVerdict(paramHooksLock ?? null)} ${dim(`\`abx lock-param-hooks ${address}\` freezes all three hook addresses forever (detail above)`)}`);
1284
+ info(lockedFieldNames.length
1285
+ ? `fields ${c.green}locked${c.reset}: ${lockedFieldNames.join(', ')}`
1286
+ : `fields ${dim('none of the standard fields are locked')}`);
1287
+ 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. ` +
1288
+ `\`abx lock-field ${address} --field <name> [--collection | --token 0]\` freezes one.`));
1289
+ }
1290
+ export async function cmdState(address, flags) {
1291
+ if (!address || address.startsWith('--')) {
1292
+ console.error('usage: abx state <address> — a read-only, on-chain operational snapshot\n');
1293
+ process.exitCode = 1;
1294
+ return;
1295
+ }
1296
+ // `--json` emits the same facts as data, so programs never need to parse prose. The human
1297
+ // narration below is unchanged and rides stderr under --json.
1298
+ return withJson(flags, async (emit) => cmdStateBody(address, flags, emit));
1299
+ }
1300
+ export async function cmdStateBody(address, flags, emit) {
1301
+ assertNoIgnoredRpcEnv(address);
1302
+ const publicClient = makePublicClient({ chainKey: CHAIN });
1303
+ await assertRpcReachable(publicClient, address);
1304
+ // Edition family branches to its own (leaner) readout — supply/maxSupply are per-id, so "supply
1305
+ // X/Y minted" (a whole-contract fact on a 721) doesn't generalize; see cmdStateEditionBody.
1306
+ const kind = await detectTokenKind(publicClient, address);
1307
+ if (kind.isEdition)
1308
+ return cmdStateEditionBody(address, publicClient, kind, emit);
1309
+ // getter absent (extension not composed) or contract has no code → undefined, never a throw.
1310
+ const read = (functionName, args = []) => tryReadContract(publicClient, { address, abi: STATE_ABI, functionName, args });
1311
+ const [ownerRead, totalSupplyRead, nextTokenId, maxInvocations, paused, minter, primaryPayee, renderer, royalty, creatorToken, transferValidator, seedSource] = await Promise.all([
1312
+ diagnosticStateRead(publicClient, address, STATE_ABI, 'owner'),
1313
+ diagnosticStateRead(publicClient, address, STATE_ABI, 'totalSupply'),
1314
+ read('nextTokenId'),
1315
+ read('maxInvocations'),
1316
+ read('paused'),
1317
+ read('minter'),
1318
+ read('primaryPayee'),
1319
+ read('tokenURIRenderer'),
1320
+ read('royaltyInfo', [0n, 10000n]),
1321
+ read('supportsInterface', [CREATOR_TOKEN_INTERFACE_ID]),
1322
+ read('getTransferValidator'),
1323
+ read('seedSource'),
1324
+ ]);
1325
+ const owner = ownerRead.value;
1326
+ const totalSupply = totalSupplyRead.value;
1327
+ if (owner === undefined && totalSupply === undefined) {
1328
+ throw new Error(await describeMissingAbxState(publicClient, address, [ownerRead.error, totalSupplyRead.error]));
1329
+ }
1330
+ // Trust, not just shape. Everything above is readable off ANY ERC-721-ish address, so without this
1331
+ // `state` described a hand-rolled contract exactly as confidently as a canonical clone. Canonicity
1332
+ // is what platforms allowlist and it can never be added after deploy, so it belongs in the snapshot.
1333
+ const provenance = await verifyProvenance(publicClient, address, resolveChain(CHAIN).id);
1334
+ const isCanonical = provenance.canonical;
1335
+ const isSeries = maxInvocations !== undefined; // series-only getter
1336
+ const addrOrNone = (a) => (a && a !== zeroAddress ? a : dim('none'));
1337
+ // The payload mirrors what is printed, with `undefined` (getter absent) kept distinct from a zero
1338
+ // address (present and deliberately unset) — collapsing those is what makes a readout lie.
1339
+ const zeroToNull = (a) => (a === undefined ? null : a === zeroAddress ? null : a);
1340
+ const { maxRoyaltyBps: royaltyCap, burnable } = await readCollectionPolicy(publicClient, address);
1341
+ const payload = jsonSafe({
1342
+ address,
1343
+ chain: CHAIN,
1344
+ chainId: resolveChain(CHAIN).id,
1345
+ type: isSeries ? 'series' : '1of1',
1346
+ canonical: isCanonical, // tri-state: true | false | null (couldn't check) — never collapse it
1347
+ // Provenance, not just trust: WHICH generation of the anchors stamped this clone, and the core
1348
+ // version it therefore reports. `prior` is "canonically ABX, older" — a different fact from
1349
+ // `canonical: false`, which the two used to share.
1350
+ abxGeneration: provenance.generation,
1351
+ abxCoreVersion: provenance.coreVersion,
1352
+ owner: owner ?? null,
1353
+ totalSupply: totalSupply ?? null,
1354
+ nextTokenId: nextTokenId ?? null,
1355
+ maxInvocations: maxInvocations ?? null,
1356
+ paused: paused ?? null,
1357
+ minter: zeroToNull(minter),
1358
+ primaryPayee: zeroToNull(primaryPayee),
1359
+ tokenURIRenderer: zeroToNull(renderer),
1360
+ onChainTokenUri: !!(renderer && renderer !== zeroAddress),
1361
+ royalty: royalty ? { receiver: royalty[0], bps: Number(royalty[1]) } : null,
1362
+ royaltyCap,
1363
+ burnable,
1364
+ creatorToken: !!creatorToken,
1365
+ transferValidator: creatorToken ? zeroToNull(transferValidator) : null,
1366
+ // `undefined` (no extension — an image token) and `0x0` (a code project that opted out) are
1367
+ // different facts; the JSON keeps them apart the same way every other getter here does.
1368
+ seedSource: zeroToNull(seedSource),
1369
+ hasSeedSource: seedSource !== undefined,
1370
+ // `enumerable: false` means "no governed keys were FOUND", never "none exist" — a legacy /
1371
+ // pre-enumeration / non-canonical contract can hold real PostParams this node cannot list at
1372
+ // all. Kept apart from `governed` so a reader cannot collapse "confirmed empty" and "unprovable"
1373
+ // into the same silence.
1374
+ params: { governed: [], contractScopeSet: [], enumerable: true },
1375
+ });
1376
+ emit(payload);
1377
+ console.log(bold(`\n ${isSeries ? 'Series' : '1/1'} state`) + dim(` ${address} · ${CHAIN}`));
1378
+ info(`canonical ${canonicalLabel(isCanonical, provenance)}`);
1379
+ if (isCanonical === false)
1380
+ warnNotCanonical(address);
1381
+ info(`owner ${owner ?? dim('?')}`);
1382
+ if (isSeries) {
1383
+ // Fully minted (totalSupply == maxInvocations) ⇒ fixed supply exhausted: the collection is
1384
+ // COMPLETE and `unpause` is moot (nothing left for anyone to mint). Say so, so a fully-minted
1385
+ // paused series doesn't read as "unfinished, go unpause".
1386
+ const soldOut = totalSupply !== undefined && maxInvocations !== undefined && totalSupply >= maxInvocations;
1387
+ info(`supply ${totalSupply ?? 0n} / ${maxInvocations} minted ${dim(soldOut ? '(complete — fixed supply fully minted)' : `(nextTokenId ${nextTokenId ?? 0n})`)}`);
1388
+ info(`paused ${paused ? `${c.orange}true${c.reset} ${dim(soldOut ? '— moot: all tokens minted, nothing left to mint' : '— owner-only minting until `abx unpause`')}` : `false ${dim('— open to minter/public')}`}`);
1389
+ info(`minter ${addrOrNone(minter)}${minter && minter !== zeroAddress ? '' : dim(' (owner-only)')}`);
1390
+ info(`primary payee ${addrOrNone(primaryPayee)}`);
1391
+ }
1392
+ else {
1393
+ info(`supply ${totalSupply ?? 0n} / 1 minted`);
1394
+ }
1395
+ if (royalty)
1396
+ info(`royalty ${Number(royalty[1]) / 100}% → ${royalty[0]}`);
1397
+ if (royaltyCap !== null)
1398
+ info(`royalty cap ${royaltyCapLine(address, royaltyCap, royalty ? Number(royalty[1]) : null)}`);
1399
+ if (burnable !== null)
1400
+ info(`burnable ${burnable ? `${c.orange}true${c.reset} ${dim('— holders may burn their own token')}` : dim('false — no token can be destroyed')}`);
1401
+ info(`renderer ${renderer && renderer !== zeroAddress ? `on-chain (${renderer})` : dim('off-chain (stored URI base / override)')}`);
1402
+ // ERC-721C: shown ONLY for an enrolled collection — an unenrolled token is a plain ERC-721
1403
+ // (indistinguishable from a pre-721C token) and shows nothing new here.
1404
+ if (creatorToken) {
1405
+ info(`721C validator ${transferValidator && transferValidator !== zeroAddress ? transferValidator : `${c.orange}suspended${c.reset} ${dim('(zero — enforcement off; re-enable with `abx set-transfer-validator`)')}`}`);
1406
+ }
1407
+ if (seedSource !== undefined)
1408
+ info(`seed source ${describeSeedSource(seedSource)}`);
1409
+ // The PostParam surface, read straight from chain: `paramSchemaKeys()` is the DECLARED (governed)
1410
+ // set — append-only, and it includes keys declared but never yet written, which nothing off-chain
1411
+ // could otherwise discover — and `contractParamKeys()` is the collection-scope set values. Both
1412
+ // are maintained by the contract's own write paths, so there is no list to drift. Read-only,
1413
+ // best-effort, and skipped entirely for a project that has none (a 1/1, a legacy pre-enumeration
1414
+ // project, or a code project with no params).
1415
+ // Hoisted above the try block: the Locks section (printed after it, see `printLocks`) reuses
1416
+ // whatever this reads rather than paying for a second `eth_call` on the same fact.
1417
+ let hooks = null;
1418
+ let paramHooksLock;
1419
+ try {
1420
+ const rawKeys = await readParamSchemaKeys(publicClient, address);
1421
+ const enumerable = rawKeys !== null;
1422
+ const keys = rawKeys ?? [];
1423
+ const rows = keys.length
1424
+ ? await Promise.all(keys.map(async (key) => ({ key, s: await readParamSchema(publicClient, address, key) })))
1425
+ : [];
1426
+ const governed = rows.filter((r) => r.s.exists);
1427
+ const now = Math.floor(Date.now() / 1000);
1428
+ payload.params.enumerable = enumerable;
1429
+ payload.params.governed = jsonSafe(governed.map(({ key, s }) => ({
1430
+ key,
1431
+ // Canonical NAMES, not the raw enum indices — a caller must not have to know the Solidity
1432
+ // enum's ordering to read this (the same reason `retired` is a boolean below).
1433
+ paramType: PARAM_TYPES[s.paramType] ?? String(s.paramType),
1434
+ auth: AUTH_OPTIONS[s.auth] ?? String(s.auth),
1435
+ authAddress: s.authAddress === zeroAddress ? null : s.authAddress,
1436
+ lockAfter: s.lockAfter,
1437
+ // A past lockAfter IS how a param is retired — surfaced as a boolean so a caller doesn't
1438
+ // have to re-derive the convention from a timestamp.
1439
+ retired: s.lockAfter !== 0 && s.lockAfter < now,
1440
+ selectOptions: s.selectOptions,
1441
+ })));
1442
+ emit(payload);
1443
+ if (governed.length) {
1444
+ console.log(`\n ${bold('PostParams')} ${dim(`${governed.length} governed · collectors/creator set these; the generator injects them`)}`);
1445
+ for (const { key, s } of governed) {
1446
+ const retired = s.lockAfter !== 0 && s.lockAfter < now;
1447
+ const locks = s.lockAfter !== 0 && !retired ? dim(` locks ${new Date(s.lockAfter * 1000).toISOString().slice(0, 10)}`) : '';
1448
+ const tag = retired ? ` ${c.orange}retired${c.reset}${dim(' — writes revert')}` : locks;
1449
+ console.log(` ${describeSchema({ key, paramType: s.paramType, auth: s.auth, authAddress: s.authAddress, lockAfter: 0, min: s.min, max: s.max, selectOptions: s.selectOptions })}${tag}`);
1450
+ }
1451
+ }
1452
+ else if (!enumerable) {
1453
+ // NOT "no PostParams" — this contract predates (or never implemented) the enumeration
1454
+ // extension, so `paramSchemaKeys()` itself is unreadable. Zero governed keys found here is
1455
+ // not proof zero exist; say so plainly rather than printing nothing, which reads as "confirmed
1456
+ // none" to a creator about to lock.
1457
+ console.log(`\n ${bold('PostParams')} ${c.orange}cannot be enumerated on this contract${c.reset} ${dim('(pre-enumeration or non-canonical) — this does NOT prove none exist, only that this node could not list them')}`);
1458
+ }
1459
+ // Collection-scope values (every token sees these). `seed` is per-token and never enumerated —
1460
+ // it is a tokenData coordinate, read directly.
1461
+ const set = await readSetParamKeys(publicClient, address);
1462
+ if (set?.contract.length) {
1463
+ payload.params.contractScopeSet = [...set.contract].sort();
1464
+ emit(payload);
1465
+ info(`${dim('contract-scope params set:')} ${[...set.contract].sort().join(', ')} ${dim('(apply to every token)')}`);
1466
+ }
1467
+ // The hooks, and whether they can still change. This is a BUYER's read, which is why it lives in
1468
+ // `state` next to the validator rather than only in `verify`: the transfer hook is a veto over
1469
+ // transfers and mints, so "which hooks, and are they frozen" is exactly the pair someone needs
1470
+ // before buying. Printed whenever the surface exists — including with all three unset, because
1471
+ // "none, and frozen" is the strongest thing this line can say and it must be visible.
1472
+ hooks = await readParamHooks(publicClient, address);
1473
+ if (hooks) {
1474
+ paramHooksLock = await readParamHooksLocked(publicClient, address);
1475
+ const shown = [['configure', hooks.configureHook], ['augment', hooks.augmentHook], ['transfer', hooks.transferHook]]
1476
+ .filter(([, a]) => a !== zeroAddress)
1477
+ .map(([role, a]) => `${role} ${a}`);
1478
+ payload.params.hooks = jsonSafe({
1479
+ configureHook: hooks.configureHook === zeroAddress ? null : hooks.configureHook,
1480
+ augmentHook: hooks.augmentHook === zeroAddress ? null : hooks.augmentHook,
1481
+ transferHook: hooks.transferHook === zeroAddress ? null : hooks.transferHook,
1482
+ // `null` = could not be established (see readParamHooksLocked). Never collapse it to false:
1483
+ // "unknown" and "the owner can still arm a transfer veto" are different answers.
1484
+ locked: paramHooksLock ?? null,
1485
+ });
1486
+ emit(payload);
1487
+ const lockNote = paramHooksLock === true
1488
+ ? `${c.green}frozen${c.reset} ${dim('— no hook address can ever change again')}`
1489
+ : paramHooksLock === false
1490
+ ? `${c.orange}not frozen${c.reset} ${dim('— the owner can re-point these (`abx lock-param-hooks` freezes them forever)')}`
1491
+ : dim('freeze state unknown (the node refused the check)');
1492
+ info(`param hooks ${shown.length ? shown.join(' · ') : dim('none set')} ${lockNote}`);
1493
+ if (hooks.transferHook !== zeroAddress) {
1494
+ info(dim(` the transfer hook is a VETO: if it reverts, the transfer fails — and mints too (mint = transfer from 0x0).`));
1495
+ }
1496
+ }
1497
+ }
1498
+ catch {
1499
+ /* best-effort — a non-code project has no params surface to read */
1500
+ }
1501
+ // The coherent lock summary — every irreversible switch this node can read, side
1502
+ // by side, independent of whether this project happens to compose the params surface above.
1503
+ const locks = await readCollectionLocks(publicClient, address, STATE_ABI, kind.kind === 'code');
1504
+ printLocks(address, locks, !!hooks, paramHooksLock, payload, emit);
1505
+ console.log('');
1506
+ }
1507
+ /**
1508
+ * The edition (ERC-1155) half of {@link cmdStateBody}: `owner`/`paused`/`minter`/`primaryPayee`/
1509
+ * `royalty`/`renderer` are the SAME facts, read the SAME way (shared, unchanged function names);
1510
+ * what differs is supply — there's no whole-contract `totalSupply()`, only a per-id one, so the
1511
+ * headline is id #0's copies (the flagship OneOfOneEdition case, and a representative sample for
1512
+ * EditionImage/EditionCode) with a pointer at `abx tokens` for the full per-id breakdown.
1513
+ */
1514
+ async function cmdStateEditionBody(address, publicClient, kind, emit) {
1515
+ const read = (functionName, args = []) => tryReadContract(publicClient, { address, abi: EDITION_STATE_ABI, functionName, args });
1516
+ const [ownerRead, maxInvocations, paused, minter, primaryPayee, renderer, royalty, creatorToken, transferValidator, seedSource] = await Promise.all([
1517
+ diagnosticStateRead(publicClient, address, EDITION_STATE_ABI, 'owner'),
1518
+ read('maxInvocations'), // absent on a 1/1-edition — its id space is fixed to {0}
1519
+ read('paused'),
1520
+ read('minter'),
1521
+ read('primaryPayee'),
1522
+ read('tokenURIRenderer'),
1523
+ read('royaltyInfo', [0n, 10000n]),
1524
+ read('supportsInterface', [CREATOR_TOKEN_INTERFACE_ID]),
1525
+ read('getTransferValidator'),
1526
+ read('seedSource'),
1527
+ ]);
1528
+ const owner = ownerRead.value;
1529
+ if (owner === undefined) {
1530
+ throw new Error(await describeMissingAbxState(publicClient, address, [ownerRead.error]));
1531
+ }
1532
+ const [supply0, maxSupply0, provenance] = await Promise.all([
1533
+ read('totalSupply', [0n]),
1534
+ read('maxSupply', [0n]),
1535
+ verifyProvenance(publicClient, address, resolveChain(CHAIN).id), // the edition twin owes the same answer
1536
+ ]);
1537
+ const isCanonical = provenance.canonical;
1538
+ const addrOrNone = (a) => (a && a !== zeroAddress ? a : dim('none'));
1539
+ const zeroToNull = (a) => (a === undefined ? null : a === zeroAddress ? null : a);
1540
+ const { maxRoyaltyBps: royaltyCap, burnable } = await readCollectionPolicy(publicClient, address);
1541
+ const payload = jsonSafe({
1542
+ address,
1543
+ chain: CHAIN,
1544
+ chainId: resolveChain(CHAIN).id,
1545
+ type: kind.kind,
1546
+ canonical: isCanonical, // tri-state, same as the 721 readout
1547
+ abxGeneration: provenance.generation,
1548
+ abxCoreVersion: provenance.coreVersion,
1549
+ owner: owner ?? null,
1550
+ maxInvocations: maxInvocations ?? null,
1551
+ supply0: supply0 ?? null,
1552
+ maxSupply0: maxSupply0 ?? null,
1553
+ paused: paused ?? null,
1554
+ minter: zeroToNull(minter),
1555
+ primaryPayee: zeroToNull(primaryPayee),
1556
+ tokenURIRenderer: zeroToNull(renderer),
1557
+ onChainTokenUri: !!(renderer && renderer !== zeroAddress),
1558
+ royalty: royalty ? { receiver: royalty[0], bps: Number(royalty[1]) } : null,
1559
+ royaltyCap,
1560
+ burnable,
1561
+ creatorToken: !!creatorToken,
1562
+ transferValidator: creatorToken ? zeroToNull(transferValidator) : null,
1563
+ seedSource: zeroToNull(seedSource),
1564
+ hasSeedSource: seedSource !== undefined,
1565
+ params: { governed: [], contractScopeSet: [], enumerable: true },
1566
+ });
1567
+ emit(payload);
1568
+ console.log(bold(`\n ${describeKind(kind)} state`) + dim(` ${address} · ${CHAIN}`));
1569
+ info(`canonical ${canonicalLabel(isCanonical, provenance)}`);
1570
+ if (isCanonical === false)
1571
+ warnNotCanonical(address);
1572
+ info(`owner ${owner ?? dim('?')}`);
1573
+ const capNote = (cap) => (cap !== undefined && cap > 0n ? `${cap} cap` : 'open — no cap');
1574
+ if (kind.kind === '1of1-edition') {
1575
+ info(`supply ${supply0 ?? 0n} cop${(supply0 ?? 0n) === 1n ? 'y' : 'ies'} of #0 ${dim(`(${capNote(maxSupply0)})`)}`);
1576
+ }
1577
+ else {
1578
+ info(`id space up to ${maxInvocations ?? '?'} distinct id(s)`);
1579
+ info(`#0 copies ${supply0 ?? 0n} ${dim(`(${capNote(maxSupply0)} — per-id breakdown: \`abx tokens ${address}\`)`)}`);
1580
+ }
1581
+ info(`paused ${paused ? `${c.orange}true${c.reset} ${dim('— owner-only minting until `abx unpause`')}` : `false ${dim('— open to minter/public')}`}`);
1582
+ info(`minter ${addrOrNone(minter)}${minter && minter !== zeroAddress ? '' : dim(' (owner-only)')}`);
1583
+ info(`primary payee ${addrOrNone(primaryPayee)}`);
1584
+ if (royalty)
1585
+ info(`royalty ${Number(royalty[1]) / 100}% → ${royalty[0]}`);
1586
+ if (royaltyCap !== null)
1587
+ info(`royalty cap ${royaltyCapLine(address, royaltyCap, royalty ? Number(royalty[1]) : null)}`);
1588
+ if (burnable !== null)
1589
+ info(`burnable ${burnable ? `${c.orange}true${c.reset} ${dim('— holders may burn their own token')}` : dim('false — no token can be destroyed')}`);
1590
+ info(`renderer ${renderer && renderer !== zeroAddress ? `on-chain (${renderer})` : dim('off-chain (stored URI base / override)')}`);
1591
+ // ERC-1155C: shown ONLY for an enrolled collection — same rule (and same interface id) as 721C.
1592
+ if (creatorToken) {
1593
+ info(`1155C validator ${transferValidator && transferValidator !== zeroAddress ? transferValidator : `${c.orange}suspended${c.reset} ${dim('(zero — enforcement off; re-enable with `abx set-transfer-validator`)')}`}`);
1594
+ }
1595
+ if (seedSource !== undefined)
1596
+ info(`seed source ${describeSeedSource(seedSource)}`);
1597
+ // EditionCode has the same ConfigurableParams surface as SeriesCode. Keep the JSON shape and
1598
+ // human readout identical; ERC-1155 changes ownership semantics, not param discoverability.
1599
+ try {
1600
+ const rawKeys = await readParamSchemaKeys(publicClient, address);
1601
+ const enumerable = rawKeys !== null;
1602
+ const rows = rawKeys?.length
1603
+ ? await Promise.all(rawKeys.map(async (key) => ({ key, s: await readParamSchema(publicClient, address, key) })))
1604
+ : [];
1605
+ const governed = rows.filter((r) => r.s.exists);
1606
+ const now = Math.floor(Date.now() / 1000);
1607
+ payload.params.enumerable = enumerable;
1608
+ payload.params.governed = jsonSafe(governed.map(({ key, s }) => ({
1609
+ key,
1610
+ paramType: PARAM_TYPES[s.paramType] ?? String(s.paramType),
1611
+ auth: AUTH_OPTIONS[s.auth] ?? String(s.auth),
1612
+ authAddress: s.authAddress === zeroAddress ? null : s.authAddress,
1613
+ lockAfter: s.lockAfter,
1614
+ retired: s.lockAfter !== 0 && s.lockAfter < now,
1615
+ selectOptions: s.selectOptions,
1616
+ })));
1617
+ emit(payload);
1618
+ if (governed.length) {
1619
+ console.log(`\n ${bold('PostParams')} ${dim(`${governed.length} governed · shared by every copy of an id`)}`);
1620
+ for (const { key, s } of governed) {
1621
+ const retired = s.lockAfter !== 0 && s.lockAfter < now;
1622
+ const locks = s.lockAfter !== 0 && !retired ? dim(` locks ${new Date(s.lockAfter * 1000).toISOString().slice(0, 10)}`) : '';
1623
+ const tag = retired ? ` ${c.orange}retired${c.reset}${dim(' — writes revert')}` : locks;
1624
+ console.log(` ${describeSchema({ key, paramType: s.paramType, auth: s.auth, authAddress: s.authAddress, lockAfter: 0, min: s.min, max: s.max, selectOptions: s.selectOptions })}${tag}`);
1625
+ }
1626
+ }
1627
+ else if (!enumerable) {
1628
+ console.log(`\n ${bold('PostParams')} ${c.orange}cannot be enumerated on this contract${c.reset} ${dim('(pre-enumeration or non-canonical) — this does NOT prove none exist')}`);
1629
+ }
1630
+ const set = await readSetParamKeys(publicClient, address);
1631
+ if (set?.contract.length) {
1632
+ payload.params.contractScopeSet = [...set.contract].sort();
1633
+ emit(payload);
1634
+ info(`${dim('contract-scope params set:')} ${[...set.contract].sort().join(', ')} ${dim('(apply to every id)')}`);
1635
+ }
1636
+ }
1637
+ catch {
1638
+ /* best-effort — image edition families have no params surface */
1639
+ }
1640
+ // EditionCode composes ConfigurableParams exactly like SeriesCode, so an edition has hooks too —
1641
+ // and the transfer hook's veto reaches a mint, which on this lane is a buyer picking an id. Report
1642
+ // the same pair as the 721 body (which hooks, and whether they can still change); silent for the
1643
+ // image/1-of-1 edition twins, which have no params surface at all.
1644
+ const hooks = await readParamHooks(publicClient, address);
1645
+ let paramHooksLock;
1646
+ if (hooks) {
1647
+ paramHooksLock = await readParamHooksLocked(publicClient, address);
1648
+ const shown = [['configure', hooks.configureHook], ['augment', hooks.augmentHook], ['transfer', hooks.transferHook]]
1649
+ .filter(([, a]) => a !== zeroAddress)
1650
+ .map(([role, a]) => `${role} ${a}`);
1651
+ payload.params.hooks = jsonSafe({
1652
+ configureHook: hooks.configureHook === zeroAddress ? null : hooks.configureHook,
1653
+ augmentHook: hooks.augmentHook === zeroAddress ? null : hooks.augmentHook,
1654
+ transferHook: hooks.transferHook === zeroAddress ? null : hooks.transferHook,
1655
+ locked: paramHooksLock ?? null, // null = unknown; never collapse it to false
1656
+ });
1657
+ emit(payload);
1658
+ const lockNote = paramHooksLock === true
1659
+ ? `${c.green}frozen${c.reset} ${dim('— no hook address can ever change again')}`
1660
+ : paramHooksLock === false
1661
+ ? `${c.orange}not frozen${c.reset} ${dim('— the owner can re-point these (`abx lock-param-hooks` freezes them forever)')}`
1662
+ : dim('freeze state unknown (the node refused the check)');
1663
+ info(`param hooks ${shown.length ? shown.join(' · ') : dim('none set')} ${lockNote}`);
1664
+ if (hooks.transferHook !== zeroAddress) {
1665
+ info(dim(` the transfer hook is a VETO: if it reverts, the transfer fails — and mints too (mint = transfer from 0x0).`));
1666
+ }
1667
+ }
1668
+ // The coherent lock summary — see cmdStateBody's identical call for why.
1669
+ const locks = await readCollectionLocks(publicClient, address, EDITION_STATE_ABI, kind.kind === 'edition-code');
1670
+ printLocks(address, locks, !!hooks, paramHooksLock, payload, emit);
1671
+ console.log('');
1672
+ }
1673
+ /**
1674
+ * `abx status [address] [--remote [name|url]] [--watch]` — INDEXING status: where a project sits in
1675
+ * the lifecycle (`queued | backfilling | live | stale | failed`) and how far behind head it is.
1676
+ *
1677
+ * One vocabulary for both sides of the membrane, which is the point: bare = this node, `--remote` =
1678
+ * ask the service, and the same five words either way. Distinct from `abx state <address>`, which
1679
+ * reads the CHAIN (owner, royalty, locks) and knows nothing about who is serving it.
1680
+ */
1681
+ export async function cmdStatus(address, flags) {
1682
+ // `abx status --remote <name>` has no address — without this guard the flag itself lands in
1683
+ // rest[0] and gets sent as the address path segment (the same guard every other command applies).
1684
+ if (address?.startsWith('--'))
1685
+ address = undefined;
1686
+ const remote = remoteFlag(flags);
1687
+ if (remote)
1688
+ return cmdStatusRemote(address, remote, flags);
1689
+ const indexer = localIndexer();
1690
+ const regs = indexer.store.listRegistrations();
1691
+ if (address) {
1692
+ const reg = indexer.store.getRegistration(address);
1693
+ if (!reg)
1694
+ throw new Error(`${address} isn't tracked by this node. Add it with \`abx add ${address}\`, or ask a service: \`abx status ${address} --remote <name>\``);
1695
+ const s = indexer.getProject(address);
1696
+ const row = indexer.indexStatus(address);
1697
+ const head = indexer.store.getMeta(`watch:${reg.chainKey}:head`);
1698
+ // Don't print the address twice when there's no name to lead with.
1699
+ console.log(s?.name ? `\n ${bold(s.name)} ${dim(address)}` : `\n ${bold(address)}`);
1700
+ statusRow('status', statusLine({
1701
+ chainId: resolveChain(reg.chainKey).id,
1702
+ address,
1703
+ status: row.status,
1704
+ fromBlock: reg.fromBlock,
1705
+ toBlock: s?.toBlock ?? null,
1706
+ headBlock: head,
1707
+ eventCount: s?.eventCount ?? 0,
1708
+ tokenCount: s?.tokens.length ?? 0,
1709
+ mintedCount: 0,
1710
+ reconstructedAt: s?.reconstructedAt ?? null,
1711
+ ...(row.errorClass ? { error: { class: row.errorClass, message: row.errorMessage ?? undefined } } : {}),
1712
+ }));
1713
+ statusRow('floor', `${reg.fromBlock}${row.attempts ? dim(` attempts ${row.attempts}`) : ''}`);
1714
+ statusRow('indexed', s ? `${s.eventCount} events · ${s.tokens.length} token(s) ${dim(`· ${s.reconstructedAt}`)}` : dim('no projection yet'));
1715
+ if (!head)
1716
+ statusRow('head', dim("unknown — this node isn't watching the chain (ABX_WATCH_INTERVAL_MS=0, or `abx serve` isn't running)"));
1717
+ console.log('');
1718
+ return;
1719
+ }
1720
+ console.log(bold(`\n ABX self-host node`));
1721
+ info(`chain: ${CHAIN} · factory: ${factoryAddress() ?? 'none'} · storage: ${activeBackendId()} · data: ${indexer.store.path}`);
1722
+ if (regs.length === 0) {
1723
+ console.log(dim('\n No projects yet. `abx demo` to create one.\n'));
1724
+ return;
1725
+ }
1726
+ console.log('');
1727
+ for (const reg of regs) {
1728
+ const s = indexer.getProject(reg.address);
1729
+ const st = indexer.indexStatus(reg.address).status;
1730
+ const mark = st === 'failed' || st === 'stale' ? `${c.orange}●${c.reset}` : s ? g('●') : dim('○');
1731
+ const tail = s ? `${s.eventCount} events` : dim('(registered, not indexed)');
1732
+ console.log(` ${mark} ${s?.name ?? reg.address} ${dim(reg.address)} ${tail} ${statusLabel(st)}`);
1733
+ }
1734
+ console.log(dim(`\n one project in detail: abx status <address>\n`));
1735
+ }
1736
+ /** The `--remote` half of {@link cmdStatus}: one project, or the roll-up for every project the token
1737
+ * can see. `--watch` tails until everything reaches a terminal state. */
1738
+ export async function cmdStatusRemote(address, remote, flags) {
1739
+ requireRemoteToken(remote);
1740
+ const client = await controlPlaneClient(remote);
1741
+ const chainId = resolveChain(CHAIN).id;
1742
+ const watch = flags.watch !== undefined;
1743
+ const spec = remote.name ? remote.name.toLowerCase() : remote.source === 'default' ? '' : remote.url;
1744
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(indexing status as the service reports it)')}`);
1745
+ if (address) {
1746
+ for (;;) {
1747
+ let s;
1748
+ try {
1749
+ s = await client.projectStatus(chainId, address);
1750
+ }
1751
+ catch (err) {
1752
+ throw describeRemoteError(err, remote, 'remote status');
1753
+ }
1754
+ console.log(`\n ${bold(address)}`);
1755
+ statusRow('status', statusLine(s));
1756
+ statusRow('floor', `${s.fromBlock}${s.attempts ? dim(` attempts ${s.attempts}`) : ''}`);
1757
+ statusRow('indexed', `${s.eventCount} events · ${s.tokenCount} token(s)${s.reconstructedAt ? dim(` · ${s.reconstructedAt}`) : ''}`);
1758
+ // Spell out what `watching` means on the line itself; a bare `no` does not say whether it is a
1759
+ // problem (it usually is not).
1760
+ if (s.watcher) {
1761
+ statusRow('watching', s.watcher.watching
1762
+ ? `${g('yes')}${s.watcher.head ? dim(` · head ${s.watcher.head}`) : ''}${dim(' — it tails new blocks, so on-chain changes land on their own')}`
1763
+ : `${dim('no')} ${dim('— this service updates on an explicit add/index, not by tailing new blocks (normal for many providers)')}`);
1764
+ }
1765
+ // Don't leave a creator staring at a red word with no next move. `failed` especially reads as
1766
+ // terminal when it isn't — name what it means and the one command that follows it.
1767
+ if (!watch && s.status !== 'live') {
1768
+ if (s.status === 'failed' && s.error)
1769
+ statusRow('what now', indexErrorAction(s.error.class));
1770
+ statusRow('follow', dim(`this is not final — ${bold(`abx status ${address} --remote${spec ? ` ${spec}` : ''} --watch`)} tails it until it settles`));
1771
+ }
1772
+ console.log('');
1773
+ if (!watch || s.status === 'live' || s.status === 'failed')
1774
+ return;
1775
+ await sleep(3000);
1776
+ }
1777
+ }
1778
+ for (;;) {
1779
+ let projects;
1780
+ try {
1781
+ projects = await client.listProjects();
1782
+ }
1783
+ catch (err) {
1784
+ throw describeRemoteError(err, remote, 'remote status');
1785
+ }
1786
+ if (projects.length === 0) {
1787
+ console.log(dim('\n no projects visible to this token\n'));
1788
+ return;
1789
+ }
1790
+ console.log('');
1791
+ for (const p of projects) {
1792
+ console.log(` ${g('●')} ${p.name ?? p.label ?? p.address} ${dim(p.address)} ` +
1793
+ `${p.status ? statusLabel(p.status) : dim('status not reported')}` +
1794
+ `${p.error ? ` ${c.orange}${p.error.class}${c.reset}` : ''} ${dim(`${p.tokenCount ?? '?'} token(s)`)}`);
1795
+ }
1796
+ console.log(`\n ${rollUp(projects)}\n`);
1797
+ const settled = projects.every((p) => !p.status || p.status === 'live' || p.status === 'failed');
1798
+ if (!watch || settled)
1799
+ return;
1800
+ await sleep(3000);
1801
+ }
1802
+ }
1803
+ // ── forget ────────────────────────────────────────────────────────────────--
1804
+ // Drop a project this node tracks (registration + projection) — for cleaning up
1805
+ // test/junk deploys. On-chain data is untouched; `abx add` can re-register it.
1806
+ export async function cmdForget(address, flags) {
1807
+ if (!address || address.startsWith('--')) {
1808
+ console.error('usage: abx forget <address> [--remote [name|url]]\n');
1809
+ process.exitCode = 1;
1810
+ return;
1811
+ }
1812
+ const remote = remoteFlag(flags);
1813
+ if (remote) {
1814
+ requireRemoteToken(remote);
1815
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(deregistering on the remote resolver — NOT this machine)')}`);
1816
+ let removed;
1817
+ try {
1818
+ ({ removed } = await (await controlPlaneClient(remote)).removeProject(resolveChain(CHAIN).id, address));
1819
+ }
1820
+ catch (err) {
1821
+ throw describeRemoteError(err, remote, 'remote forget');
1822
+ }
1823
+ if (removed)
1824
+ ok(`remote resolver forgot ${address} — it will stop serving it. On-chain data is untouched.`);
1825
+ else
1826
+ console.log(dim(` ${address} wasn't registered on ${remote.url} — nothing to forget.`));
1827
+ return;
1828
+ }
1829
+ const indexer = localIndexer();
1830
+ if (!indexer.store.getRegistration(address)) {
1831
+ console.log(dim(` ${address} isn't tracked by this node — nothing to forget.`));
1832
+ return;
1833
+ }
1834
+ indexer.store.deregister(address);
1835
+ ok(`forgot ${address} — dropped its registration + projection. On-chain data is untouched.`);
1836
+ }
1837
+ //# sourceMappingURL=project.js.map