@artblocks/abx-cli 0.1.0-alpha.3 → 0.1.0-alpha.31

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 (152) hide show
  1. package/CHANGELOG.md +3694 -0
  2. package/assets/renderer-scaffold/README.md +2 -2
  3. package/assets/renderer-scaffold/src/MyRenderer.sol +2 -2
  4. package/assets/renderer-scaffold/src/interfaces/IAbxFieldRenderer.sol +1 -1
  5. package/assets/renderer-scaffold/src/interfaces/IAbxParams.sol +17 -0
  6. package/assets/renderer-scaffold/test/MyRenderer.t.sol +51 -2
  7. package/dist/bin.d.ts +27 -0
  8. package/dist/bin.d.ts.map +1 -0
  9. package/dist/bin.js +64 -0
  10. package/dist/bin.js.map +1 -0
  11. package/dist/commands/deploy.d.ts +242 -0
  12. package/dist/commands/deploy.d.ts.map +1 -0
  13. package/dist/commands/deploy.js +4764 -0
  14. package/dist/commands/deploy.js.map +1 -0
  15. package/dist/commands/project.d.ts +257 -0
  16. package/dist/commands/project.d.ts.map +1 -0
  17. package/dist/commands/project.js +1299 -0
  18. package/dist/commands/project.js.map +1 -0
  19. package/dist/commands/reads.d.ts +64 -0
  20. package/dist/commands/reads.d.ts.map +1 -0
  21. package/dist/commands/reads.js +701 -0
  22. package/dist/commands/reads.js.map +1 -0
  23. package/dist/commands/scaffold.d.ts +73 -0
  24. package/dist/commands/scaffold.d.ts.map +1 -0
  25. package/dist/commands/scaffold.js +679 -0
  26. package/dist/commands/scaffold.js.map +1 -0
  27. package/dist/commands/service.d.ts +41 -0
  28. package/dist/commands/service.d.ts.map +1 -0
  29. package/dist/commands/service.js +618 -0
  30. package/dist/commands/service.js.map +1 -0
  31. package/dist/commands/storage.d.ts +51 -0
  32. package/dist/commands/storage.d.ts.map +1 -0
  33. package/dist/commands/storage.js +370 -0
  34. package/dist/commands/storage.js.map +1 -0
  35. package/dist/commands/submit-app.d.ts +58 -0
  36. package/dist/commands/submit-app.d.ts.map +1 -0
  37. package/dist/commands/submit-app.js +512 -0
  38. package/dist/commands/submit-app.js.map +1 -0
  39. package/dist/config.d.ts +90 -2
  40. package/dist/config.d.ts.map +1 -1
  41. package/dist/config.js +285 -11
  42. package/dist/config.js.map +1 -1
  43. package/dist/conformance.d.ts +31 -0
  44. package/dist/conformance.d.ts.map +1 -0
  45. package/dist/conformance.js +393 -0
  46. package/dist/conformance.js.map +1 -0
  47. package/dist/deps.d.ts +6 -39
  48. package/dist/deps.d.ts.map +1 -1
  49. package/dist/deps.js +4 -68
  50. package/dist/deps.js.map +1 -1
  51. package/dist/errors.d.ts +20 -0
  52. package/dist/errors.d.ts.map +1 -0
  53. package/dist/errors.js +25 -0
  54. package/dist/errors.js.map +1 -0
  55. package/dist/flag-allowlists.d.ts +53 -0
  56. package/dist/flag-allowlists.d.ts.map +1 -0
  57. package/dist/flag-allowlists.js +157 -0
  58. package/dist/flag-allowlists.js.map +1 -0
  59. package/dist/flags.d.ts +45 -0
  60. package/dist/flags.d.ts.map +1 -1
  61. package/dist/flags.js +117 -1
  62. package/dist/flags.js.map +1 -1
  63. package/dist/jsonout.d.ts +37 -0
  64. package/dist/jsonout.d.ts.map +1 -0
  65. package/dist/jsonout.js +68 -0
  66. package/dist/jsonout.js.map +1 -0
  67. package/dist/kind.d.ts +61 -0
  68. package/dist/kind.d.ts.map +1 -0
  69. package/dist/kind.js +111 -0
  70. package/dist/kind.js.map +1 -0
  71. package/dist/main.js +699 -4736
  72. package/dist/main.js.map +1 -1
  73. package/dist/mintpage.d.ts +17 -2
  74. package/dist/mintpage.d.ts.map +1 -1
  75. package/dist/mintpage.js +241 -54
  76. package/dist/mintpage.js.map +1 -1
  77. package/dist/output.d.ts +179 -0
  78. package/dist/output.d.ts.map +1 -0
  79. package/dist/output.js +756 -0
  80. package/dist/output.js.map +1 -0
  81. package/dist/ownerops.d.ts +302 -51
  82. package/dist/ownerops.d.ts.map +1 -1
  83. package/dist/ownerops.js +1780 -357
  84. package/dist/ownerops.js.map +1 -1
  85. package/dist/preview.d.ts +81 -0
  86. package/dist/preview.d.ts.map +1 -0
  87. package/dist/preview.js +535 -0
  88. package/dist/preview.js.map +1 -0
  89. package/dist/prompt.d.ts +17 -0
  90. package/dist/prompt.d.ts.map +1 -0
  91. package/dist/prompt.js +19 -0
  92. package/dist/prompt.js.map +1 -0
  93. package/dist/provision.d.ts.map +1 -1
  94. package/dist/provision.js +16 -8
  95. package/dist/provision.js.map +1 -1
  96. package/dist/remote.d.ts +153 -52
  97. package/dist/remote.d.ts.map +1 -1
  98. package/dist/remote.js +406 -46
  99. package/dist/remote.js.map +1 -1
  100. package/dist/riskgate.d.ts +58 -0
  101. package/dist/riskgate.d.ts.map +1 -0
  102. package/dist/riskgate.js +212 -0
  103. package/dist/riskgate.js.map +1 -0
  104. package/dist/scaffold.d.ts +10 -0
  105. package/dist/scaffold.d.ts.map +1 -0
  106. package/dist/scaffold.js +52 -0
  107. package/dist/scaffold.js.map +1 -0
  108. package/dist/schema.d.ts +36 -1
  109. package/dist/schema.d.ts.map +1 -1
  110. package/dist/schema.js +121 -26
  111. package/dist/schema.js.map +1 -1
  112. package/dist/script-chunks.d.ts +8 -0
  113. package/dist/script-chunks.d.ts.map +1 -0
  114. package/dist/script-chunks.js +35 -0
  115. package/dist/script-chunks.js.map +1 -0
  116. package/dist/served.d.ts +30 -0
  117. package/dist/served.d.ts.map +1 -0
  118. package/dist/served.js +112 -0
  119. package/dist/served.js.map +1 -0
  120. package/dist/signer.d.ts +13 -0
  121. package/dist/signer.d.ts.map +1 -1
  122. package/dist/signer.js +84 -15
  123. package/dist/signer.js.map +1 -1
  124. package/dist/update-check.d.ts +83 -4
  125. package/dist/update-check.d.ts.map +1 -1
  126. package/dist/update-check.js +140 -19
  127. package/dist/update-check.js.map +1 -1
  128. package/package.json +12 -8
  129. package/skill/SKILL.md +351 -151
  130. package/skill/reference/code-projects.md +183 -26
  131. package/skill/reference/creator-token.md +99 -0
  132. package/skill/reference/decisions.md +174 -0
  133. package/skill/reference/hosting.md +63 -19
  134. package/skill/reference/operating.md +116 -12
  135. package/skill/reference/setup.md +57 -6
  136. package/skill/reference/troubleshooting.md +42 -5
  137. package/dist/inspect.d.ts +0 -48
  138. package/dist/inspect.d.ts.map +0 -1
  139. package/dist/inspect.js +0 -184
  140. package/dist/inspect.js.map +0 -1
  141. package/dist/migrate.d.ts +0 -65
  142. package/dist/migrate.d.ts.map +0 -1
  143. package/dist/migrate.js +0 -180
  144. package/dist/migrate.js.map +0 -1
  145. package/dist/onchain-uri.d.ts +0 -97
  146. package/dist/onchain-uri.d.ts.map +0 -1
  147. package/dist/onchain-uri.js +0 -243
  148. package/dist/onchain-uri.js.map +0 -1
  149. package/dist/upload.d.ts +0 -28
  150. package/dist/upload.d.ts.map +0 -1
  151. package/dist/upload.js +0 -41
  152. package/dist/upload.js.map +0 -1
@@ -0,0 +1,1299 @@
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, hasOnChainUriLane, isCodeProject, isCurrentFactory, isCurrentOneOfOneEditionFactory, makePublicClient, makeWalletClient, normalizeAttributes, onChainUriReport, parseTraitPairs, predictClone, readParamHooks, readParamHooksLocked, readParamSchema, readParamSchemaKeys, readSetParamKeys, resolveChain, resolveSeriesCodeFactory, saltFor, saltGuard, sleep, readCollectionPolicy, tryReadContract, } 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 { isDryRun, 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 { canonicalLabel, describeRemoteError, indexErrorAction, remoteFlag, reportRemoteIndexing, requireRemoteToken, rollUp, serviceClient, statusLabel, statusLine, statusRow, tokenSourceLabel, } from '../remote.js';
21
+ import { describeSchema } from '../schema.js';
22
+ import { looksPerTokenAttributes, parseSeriesTraitsById } from '../series-traits.js';
23
+ // ── predict ──────────────────────────────────────────────────────────────────
24
+ // Pre-compute a deploy address from a salt — so you can stand up the resolver and
25
+ // reserve a vanity/known address before signing. The address is a pure function of
26
+ // (factory, salt); the salt's leading 20 bytes are the front-run guard.
27
+ export async function cmdPredict(flags) {
28
+ const publicClient = makePublicClient({ chainKey: CHAIN });
29
+ // Lane-aware: each deploy command uses a DIFFERENT factory, so the deterministic address differs.
30
+ // Infer the lane from the content flags so `predict` matches what will actually be deployed —
31
+ // otherwise a code/Series creator gets the 1/1 address (a real mismatch, not just a wrong echo).
32
+ const chainId = resolveChain(CHAIN).id;
33
+ const content = flags.script ? `--script ${flags.script}` :
34
+ flags['code-dir'] ? `--code-dir ${flags['code-dir']}` :
35
+ flags['image-renderer'] ? `--image-renderer ${flags['image-renderer']}` :
36
+ flags.dir ? `--dir ${flags.dir}` :
37
+ flags.image ? `--image ${flags.image}` : '';
38
+ const baseCmd = (flags.script || flags['code-dir'] || flags['image-renderer'])
39
+ ? 'deploy-code'
40
+ : flags.dir
41
+ ? 'deploy-series'
42
+ : 'deploy';
43
+ // `--copies <n|open>` switches which factory's address is predicted — the SAME flag that
44
+ // switches which factory a real deploy uses (see commands/deploy.ts's `parseCopies`). Validated
45
+ // here too (not just deferred to the real deploy) so a malformed value fails before printing an
46
+ // address tied to it.
47
+ const editionSize = flags.copies !== undefined ? parseCopies(flags.copies) : undefined;
48
+ const isEdition = editionSize !== undefined;
49
+ const lane = isEdition
50
+ ? {
51
+ cmd: baseCmd,
52
+ factory: baseCmd === 'deploy-code'
53
+ ? editionCodeFactoryAddress(flags.factory)
54
+ : baseCmd === 'deploy-series'
55
+ ? editionFactoryAddress(flags.factory)
56
+ : oneOfOneEditionFactoryAddress(flags.factory),
57
+ }
58
+ : baseCmd === 'deploy-code'
59
+ ? { cmd: 'deploy-code', factory: resolveSeriesCodeFactory(chainId, flags.factory) }
60
+ : baseCmd === 'deploy-series'
61
+ ? { cmd: 'deploy-series', factory: seriesFactoryAddress(flags.factory) }
62
+ : { cmd: 'deploy', factory: factoryAddress(flags.factory) };
63
+ const factory = lane.factory;
64
+ if (!factory) {
65
+ throw new Error(`no canonical ${isEdition ? 'edition ' : ''}${lane.cmd} factory for '${CHAIN}' — run \`abx ${lane.cmd}${isEdition ? ` --copies ${flags.copies}` : ''}\` ` +
66
+ `once to deploy the trust anchor, or pass --factory 0x.. (or the ABX_* env).`);
67
+ }
68
+ // The current-factory guard is the 1/1 impl-version check (721 or its edition twin); the Series/
69
+ // Series-edition/code/code-edition lanes verify their own factory at deploy time instead —
70
+ // EditionCodeFactory mirrors SeriesCodeFactory's stance here (no version probe: a configured code
71
+ // factory with code is always accepted, and one found at its CREATE2 address is bytecode-bound to
72
+ // this build anyway, so there's nothing this check would catch that the real deploy doesn't
73
+ // already). predictDeterministicAddress works on any factory ABI.
74
+ if (lane.cmd === 'deploy') {
75
+ const current = isEdition ? await isCurrentOneOfOneEditionFactory(publicClient, factory) : await isCurrentFactory(publicClient, factory);
76
+ if (!current) {
77
+ throw new Error(`factory ${factory} is an older/incompatible version. Run \`abx deploy${isEdition ? ` --copies ${flags.copies}` : ''}\` once to deploy the current ` +
78
+ `trust anchor (ownerless + cheap), then predict again — or pass a current --factory.`);
79
+ }
80
+ }
81
+ let salt = parseSaltFlag(flags.salt);
82
+ if (!salt) {
83
+ // No explicit salt: reserve one to a deployer (front-run-proof). Default to the env key.
84
+ let deployer = flags.for;
85
+ if (!deployer)
86
+ deployer = makeWalletClient({ chainKey: CHAIN }).account.address;
87
+ salt = saltFor(deployer);
88
+ info(`reserved a fresh salt to ${deployer} (front-run-proof) — pass --salt to fix/vanity it`);
89
+ }
90
+ const guard = saltGuard(salt);
91
+ const clone = await predictClone(publicClient, { factory, salt });
92
+ console.log(`\n ${bold('predicted address')} ${g(clone)} ${dim(`(${lane.cmd} lane${isEdition ? ', edition' : ''})`)}`);
93
+ info(`salt ${salt}`);
94
+ info(guard === zeroAddress ? 'guard permissionless — anyone may deploy this salt' : `guard reserved to ${guard} (only this signer can deploy it)`);
95
+ const copiesFlag = isEdition ? ` --copies ${editionSize === 0n ? 'open' : editionSize.toString()}` : '';
96
+ info(`deploy: ${bold(`abx ${lane.cmd}${content ? ` ${content}` : ''}${copiesFlag} --salt ${salt}`)}`);
97
+ if (lane.cmd === 'deploy' && !isEdition)
98
+ 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)'));
99
+ console.log('');
100
+ }
101
+ // ── add ──────────────────────────────────────────────────────────────────────
102
+ // Register + index a project this node didn't deploy. LOCAL by default (this
103
+ // machine's store); `--remote [url]` instead tells a HOSTED resolver to index it —
104
+ // the bridge a local deploy can't make on its own (separate projection stores).
105
+ export async function cmdAdd(address, flags) {
106
+ if (!address || address.startsWith('--')) {
107
+ console.error('usage: abx add <address> [--from-block N] [--factory 0x..] [--label "..."] [--remote [name|url]]\n');
108
+ process.exitCode = 1;
109
+ return;
110
+ }
111
+ // REFUSE `--dry-run` rather than ignoring it. `add` is a write (a local registration + index, and
112
+ // with `--remote` a registration on someone else's service), and it has no preview mode — so
113
+ // silently proceeding to DO the thing when the caller explicitly asked to preview is the one
114
+ // outcome we must never produce. Name what's read-only instead.
115
+ if (isDryRun(flags)) {
116
+ throw new Error('`abx add` has no --dry-run: it registers + indexes for real (and with --remote it registers on that service). ' +
117
+ 'Nothing here touches the chain, but it does write. To look before acting: `abx state <address>` (on-chain snapshot) ' +
118
+ 'or `abx status <address> [--remote <name>]` (what a node already has). Re-run without --dry-run when you mean it.');
119
+ }
120
+ // `--attributes` is lane-aware here exactly as at deploy: a PER-TOKEN payload edits a Series'
121
+ // per-token off-chain traits; a flat payload (+ `--traits`) edits the collection/1-of-1 `attributes`.
122
+ // Ambiguity defaults to flat (see looksPerTokenAttributes), so a 1/1 add is never mis-read.
123
+ const attrRaw = flags.attributes ? readFileSync(resolvePath(process.cwd(), String(flags.attributes)), 'utf8') : undefined;
124
+ const perTokenEdit = attrRaw != null && looksPerTokenAttributes(attrRaw);
125
+ const flagTraits = [];
126
+ if (attrRaw != null && !perTokenEdit)
127
+ flagTraits.push(...normalizeAttributes(JSON.parse(attrRaw)));
128
+ if (flags.traits)
129
+ flagTraits.push(...parseTraitPairs(flags.traits));
130
+ const editedTokenAttributes = perTokenEdit ? parseSeriesTraitsById(attrRaw) : undefined;
131
+ const remote = remoteFlag(flags);
132
+ if (remote) {
133
+ requireRemoteToken(remote);
134
+ // Bridge what a remote resolver can't derive itself: the off-chain traits and the durable
135
+ // content locators (ipfs://…). Prefer flags; otherwise forward what the LOCAL deploy stored
136
+ // (the local registration), and compute locators from this machine's content index if needed.
137
+ const localReg = localIndexer().store.getRegistration(address);
138
+ const attributes = flagTraits.length
139
+ ? flagTraits
140
+ : localReg?.attributes
141
+ ? normalizeAttributes(JSON.parse(localReg.attributes))
142
+ : undefined;
143
+ // Bridge a Series' per-token off-chain traits to the remote resolver (the resolver has no other
144
+ // way to derive them — they're operator metadata, not chain state). A fresh per-token `--attributes`
145
+ // EDITS them; otherwise forward what the LOCAL deploy stored. Best-effort parse.
146
+ let tokenAttributes;
147
+ if (editedTokenAttributes && Object.keys(editedTokenAttributes).length) {
148
+ tokenAttributes = editedTokenAttributes;
149
+ }
150
+ else if (localReg?.tokenAttributes) {
151
+ try {
152
+ const obj = JSON.parse(localReg.tokenAttributes);
153
+ const norm = {};
154
+ for (const [id, v] of Object.entries(obj)) {
155
+ const a = normalizeAttributes(v);
156
+ if (a.length)
157
+ norm[id] = a;
158
+ }
159
+ if (Object.keys(norm).length)
160
+ tokenAttributes = norm;
161
+ }
162
+ catch { /* skip a malformed local column */ }
163
+ }
164
+ const contentLocators = await remoteLocators(address, localReg?.contentLocators, flags);
165
+ // Forward the deploy block, like every other field falls back to the local registration.
166
+ // (Its ABSENCE here was the bug: a hosted resolver defaulted to genesis and scanned the whole
167
+ // chain.) Re-sending the same floor stays incremental server-side, so a nudge ≠ a re-scan.
168
+ const body = {
169
+ chainId: resolveChain(CHAIN).id,
170
+ address,
171
+ fromBlock: await resolveScanFloor(address, localReg?.fromBlock, flags),
172
+ factory: await detectCanonicalFactory(address, flags.factory, localReg?.factory),
173
+ label: flags.label,
174
+ description: flags.description ?? localReg?.description,
175
+ externalUrl: flags['external-url'] ?? localReg?.externalUrl,
176
+ attributes,
177
+ tokenAttributes,
178
+ contentLocators: Object.keys(contentLocators).length ? contentLocators : undefined,
179
+ full: flags.full ? true : undefined,
180
+ };
181
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(registering with the remote resolver — NOT this machine)')}`);
182
+ if (body.contentLocators)
183
+ info(`bridging image locator → ${Object.values(body.contentLocators)[0]} ${dim('(so the resolver points at IPFS, not its own localhost)')}`);
184
+ let r;
185
+ try {
186
+ r = await serviceClient(remote).registerProject(body);
187
+ }
188
+ catch (err) {
189
+ throw describeRemoteError(err, remote, 'remote add');
190
+ }
191
+ await reportRemoteIndexing(remote, body.chainId, address, r, flags, 'indexed');
192
+ info(`it now serves ${remote.url}/t/${body.chainId}/${address.toLowerCase()}/0`);
193
+ // "Indexed" is not "correct". This line proves the service replayed the chain and will answer at
194
+ // that URL — it says nothing about whether the bytes it serves match the on-chain commitment, and
195
+ // two reviewers stopped here and reported a blank page as fixed. Name the step that checks.
196
+ info(`confirm what it actually serves (bytes vs. the on-chain hash): ${bold(`abx verify ${address} --remote ${remote.name?.toLowerCase() ?? remote.url}`)}`);
197
+ // Say what this registration did NOT buy. A project whose `tokenURIRenderer` is set answers
198
+ // `tokenURI` from the chain, so marketplaces and wallets read THAT document and never touch this
199
+ // service — the register is still useful (indexing, the live view, managed rendering for a code
200
+ // drop) but it changes nothing a collector sees. Without this line the readout is a list of
201
+ // successes that reads like a win, and a creator who was told "put it on a hosted service so it
202
+ // shows up properly" concludes their problem is solved when nothing about it moved.
203
+ const onChainRenderer = await tryReadContract(makePublicClient({ chainKey: CHAIN }), {
204
+ address,
205
+ abi: STATE_ABI,
206
+ functionName: 'tokenURIRenderer',
207
+ });
208
+ if (onChainRenderer && onChainRenderer !== zeroAddress) {
209
+ info(`${dim('note:')} this project resolves ${bold('tokenURI from the chain')} (renderer ${onChainRenderer}) — marketplaces read that, not this service. ` +
210
+ `Registering still gives you indexing, the live view and managed rendering, but it does not change the metadata a collector sees.`);
211
+ }
212
+ return;
213
+ }
214
+ // The local (non-`--remote`) path is shared with the deploy family's post-setup registration —
215
+ // see registerAndIndexLocally's doc comment (output.ts).
216
+ return registerAndIndexLocally(address, flags);
217
+ }
218
+ export async function cmdIndex(address, flags) {
219
+ const remote = remoteFlag(flags);
220
+ if (remote) {
221
+ if (!address || address.startsWith('--')) {
222
+ console.error('usage: abx index <address> --remote [name|url] (re-index one project on a remote resolver)\n');
223
+ process.exitCode = 1;
224
+ return;
225
+ }
226
+ requireRemoteToken(remote);
227
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(re-indexing on the remote resolver — the post-deploy nudge)')}`);
228
+ const chainId = resolveChain(CHAIN).id;
229
+ let r;
230
+ try {
231
+ r = await serviceClient(remote).registerProject({ chainId, address, full: flags.full ? true : undefined });
232
+ }
233
+ catch (err) {
234
+ throw describeRemoteError(err, remote, 'remote index');
235
+ }
236
+ await reportRemoteIndexing(remote, chainId, address, r, flags, 're-indexed');
237
+ return;
238
+ }
239
+ allowLargeScan(flags);
240
+ const full = !!flags.full; // force a full replay from the deploy block (durability proof)
241
+ const indexer = localIndexer();
242
+ const line = (state, elapsedMs, mode) => ok(`${state.name ?? state.address}: ${state.eventCount} events in ${elapsedMs}ms ${dim(`(${mode})`)}`);
243
+ if (address) {
244
+ const { state, elapsedMs, mode } = await indexer.reindex(address, { full });
245
+ line(state, elapsedMs, mode);
246
+ return;
247
+ }
248
+ const results = await indexer.reindexAll({ full });
249
+ if (results.length === 0) {
250
+ console.log(dim('No registered projects. Deploy one with `abx deploy`.'));
251
+ return;
252
+ }
253
+ for (const { state, elapsedMs, mode } of results)
254
+ line(state, elapsedMs, mode);
255
+ }
256
+ // ── verify ─────────────────────────────────────────────────────────────────--
257
+ // Re-hash a project's served bytes against its on-chain content commitment, from
258
+ // chain + custody alone — no running server. The CLI form of the `/verify` route,
259
+ // so an agent can confirm integrity right after deploy/mint without curling.
260
+ export async function cmdVerify(address, flags) {
261
+ if (!address || address.startsWith('--')) {
262
+ console.error('usage: abx verify <address> [--json] [--remote <name|url>]\n');
263
+ process.exitCode = 1;
264
+ return;
265
+ }
266
+ allowLargeScan(flags);
267
+ const remote = remoteFlag(flags);
268
+ if (remote)
269
+ return cmdVerifyRemote(address, remote);
270
+ // `--json` matters most here of all the read commands: verify already exits non-zero on a byte
271
+ // mismatch, so it is the one command a CI job would gate on — and gating on prose is what B19 is
272
+ // about. The payload's `ok` is the same boolean the exit code carries.
273
+ return withJson(flags, async (emit) => cmdVerifyBody(address, flags, emit));
274
+ }
275
+ export async function cmdVerifyBody(address, flags, emit) {
276
+ const indexer = localIndexer();
277
+ let state = indexer.getProject(address);
278
+ if (indexer.store.getRegistration(address)) {
279
+ ({ state } = await indexer.reindex(address)); // freshest state from chain
280
+ }
281
+ else if (!state) {
282
+ throw new Error(`${address} isn't indexed by this node. Run \`abx add ${address}\` first.`);
283
+ }
284
+ if (!state)
285
+ throw new Error(`could not load state for ${address}`);
286
+ // Accumulated as the checks run, re-emitted at each stage so a payload survives an early return.
287
+ const verifyReport = {
288
+ address,
289
+ chain: CHAIN,
290
+ name: state.name ?? null,
291
+ canonical: state.isCanonical, // TRUE tri-state: true | false | null (couldn't check)
292
+ owner: state.owner ?? null,
293
+ contentChecks: [],
294
+ contentIntegrity: 'no-commitments',
295
+ renders: null,
296
+ onChainUri: null,
297
+ ok: true,
298
+ };
299
+ emit(verifyReport);
300
+ console.log(bold(`\n verify ${state.name ?? address}`));
301
+ // isCanonical is a TRUE TRI-STATE (true | false | null) and collapsing it lost the only
302
+ // distinction that matters: "the chain says this is NOT a clone of the configured factory" is a
303
+ // trust finding; "we couldn't run the check" (no factory configured, or none deployed on this
304
+ // chain) is an environment note. Two reviewers read the collapsed word as a second failure.
305
+ info(`canonical: ${canonicalLabel(state.isCanonical)} · owner ${state.owner ?? '—'}`);
306
+ const storage = resolveBackend(storageOptions());
307
+ const result = await verifyProject(state, storage);
308
+ const tokens = result.tokens ?? [];
309
+ let anyCheck = false;
310
+ let allGood = true;
311
+ for (const t of tokens) {
312
+ const tok = state.tokens.find((s) => s.tokenId === t.tokenId);
313
+ const life = tok?.lifecycle === 'live'
314
+ ? 'minted'
315
+ : tok?.lifecycle === 'burned'
316
+ ? 'BURNED'
317
+ : tok?.lifecycle === 'no-live-copies'
318
+ ? 'no live copies'
319
+ : 'not yet minted';
320
+ info(`token #${t.tokenId}: ${life}`);
321
+ for (const ch of t.checks) {
322
+ verifyReport.contentChecks.push({
323
+ tokenId: t.tokenId,
324
+ lifecycle: tok?.lifecycle ?? 'unminted',
325
+ kind: ch.kind,
326
+ // tri-state again: true (re-hashed, matched) · false (MISMATCH) · null (pointer-only,
327
+ // nothing local to recompute). Collapsing null into false would report a normal state as a
328
+ // failure, which is the bug this command exists to not have.
329
+ verified: ch.verified,
330
+ });
331
+ anyCheck = true;
332
+ if (ch.verified === true)
333
+ ok(`${ch.kind} matches on-chain commitment — content is authentic`);
334
+ else if (ch.verified === false) {
335
+ allGood = false;
336
+ console.log(` ${c.red}✗${c.reset} ${ch.kind} MISMATCH — served bytes don't match the commitment`);
337
+ }
338
+ else
339
+ info(`${ch.kind} — pointer-only, not locally recomputable`);
340
+ }
341
+ }
342
+ if (!anyCheck && !isCodeProject(state))
343
+ info('no content commitments on this project');
344
+ verifyReport.contentIntegrity = !anyCheck ? 'no-commitments' : allGood ? 'ok' : 'mismatch';
345
+ verifyReport.ok = !anyCheck || allGood;
346
+ emit(verifyReport);
347
+ // Code projects have no on-chain `image` commitment (the still is rendered off-chain by the
348
+ // effect runner), so the hash checks above are silent for them. Instead report what actually
349
+ // matters after mint: does a REAL thumbnail exist yet, or is the marketplace image still the
350
+ // placeholder? Same seam the /…/image route serves from (currentRenderArtifact @ current
351
+ // inputsHash) — so this answers "did the render land?" with one command, right after token 0.
352
+ let renderGap = false;
353
+ if (isCodeProject(state)) {
354
+ const client = makePublicClient({ chainKey: CHAIN });
355
+ const storageForRender = resolveBackend(storageOptions());
356
+ const minted = state.tokens.filter((t) => t.lifecycle === 'live');
357
+ if (minted.length === 0)
358
+ info('no tokens minted yet — mint token #0, then re-run to check its thumbnail.');
359
+ // Live-data posture — the augment hook IS the opt-in: no hook ⇒ zero live reads (pure indexed
360
+ // params); hook set ⇒ the live view reads chain per view, and the STILL snapshots settled state
361
+ // (live data never re-addresses the render — that's the settled/full split).
362
+ info(state.paramHooks?.augmentHook
363
+ ? `live data: augment hook ${state.paramHooks.augmentHook} — live view reads chain per view; stills snapshot settled state`
364
+ : 'live data: none (settled params only — the live view makes zero extra chain reads)');
365
+ // Immutability of the WORK itself — the on-chain program (script chunks) and its library set.
366
+ // This is the gap `lock-field`/`lock-uri` don't cover: those freeze metadata, but the owner can
367
+ // still call `setScriptChunk`/`removeLastScriptChunk` until `lock-script` is sent. Report it so a
368
+ // "locked" drop isn't quietly still-mutable in the one place that carries the work.
369
+ verifyReport.script = state.script ? { locked: !!state.script.locked } : null;
370
+ verifyReport.dependencies = state.dependencies
371
+ ? { count: state.dependencies.list.length, locked: !!state.dependencies.locked }
372
+ : null;
373
+ emit(verifyReport);
374
+ if (state.script) {
375
+ if (state.script.locked)
376
+ ok('script: locked — the program bytes are frozen permanently (setScriptChunk reverts)');
377
+ else
378
+ 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)")}`);
379
+ }
380
+ if (state.dependencies && state.dependencies.list.length > 0) {
381
+ if (state.dependencies.locked)
382
+ ok('dependencies: locked — the library set is frozen');
383
+ else
384
+ 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)`);
385
+ }
386
+ // The other two param hooks (if wired) — surfaced here since this is where hooks show. Quiet when
387
+ // unset (the common case). Manage all three with `abx set-param-hooks`.
388
+ if (state.paramHooks?.configureHook || state.paramHooks?.transferHook) {
389
+ const parts = [];
390
+ if (state.paramHooks.configureHook)
391
+ parts.push(`configure ${state.paramHooks.configureHook}`);
392
+ if (state.paramHooks.transferHook)
393
+ parts.push(`transfer ${state.paramHooks.transferHook}`);
394
+ info(`param hooks: ${parts.join(' · ')} ${dim('(configure = write-time veto · transfer = a VETO over transfers AND mints; manage with `abx set-param-hooks`)')}`);
395
+ }
396
+ // The hook lock is reported for every project with the surface, set hooks or not: a frozen empty
397
+ // trio is a real guarantee ("this project can never add a transfer veto"), and an unfrozen one is
398
+ // a live power even when no hook is set today. Folded from `ParamHooksFrozen`.
399
+ if (state.paramHooks) {
400
+ verifyReport.paramHooks = {
401
+ configureHook: state.paramHooks.configureHook,
402
+ augmentHook: state.paramHooks.augmentHook,
403
+ transferHook: state.paramHooks.transferHook,
404
+ locked: state.paramHooks.locked,
405
+ };
406
+ if (state.paramHooks.locked)
407
+ ok('param hooks: frozen — no hook address can change again (no transfer veto can ever be armed)');
408
+ else if (state.paramHooks.transferHook) {
409
+ 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}`)}`);
410
+ }
411
+ else {
412
+ 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}\``);
413
+ }
414
+ }
415
+ // This checks THIS machine's store. A render PUBLISHED to a hosted resolver (locator bridge) lives
416
+ // on that resolver, NOT here — so a "not found" below can be a false negative for a hosted drop.
417
+ // `abx verify <addr> --remote <resolver>` probes what the resolver actually serves (the truthful check).
418
+ if (minted.length)
419
+ info(dim(`render check is against THIS node's store; for a HOSTED drop use \`abx verify ${address} --remote <resolver>\``));
420
+ // ONE line per outcome, not per token. This printed the same full-sentence advisory 32 times on a
421
+ // 32-token project (~4KB of identical text) and pushed the four lines that answer "did my deploy
422
+ // work" off the top of the screen; at a 1000-token supply it is unreadable. The per-token detail
423
+ // that survives is the token LIST, which is the only part that differs.
424
+ const missing = [];
425
+ let present = 0;
426
+ for (const token of minted) {
427
+ const { found } = await currentRenderArtifact(client, state, token, storageForRender, 'image');
428
+ if (found)
429
+ present++;
430
+ else
431
+ missing.push(String(token.tokenId));
432
+ }
433
+ verifyReport.renders = {
434
+ minted: minted.length,
435
+ present,
436
+ missing, // token ids, so a caller can re-render exactly these
437
+ // Named for what it IS: this node's store. A render PUBLISHED to a hosted resolver lives
438
+ // there, not here, so `missing` is a false negative for a hosted drop — hence the scope.
439
+ scope: "this node's store",
440
+ };
441
+ emit(verifyReport);
442
+ if (present)
443
+ ok(`${present}/${minted.length} minted token(s): real render present (in this node's store)`);
444
+ if (missing.length) {
445
+ renderGap = true;
446
+ const ids = missing.length > 12 ? `${missing.slice(0, 12).join(', ')}, …+${missing.length - 12} more` : missing.join(', ');
447
+ console.log(` ${c.orange}⚠${c.reset} ${missing.length}/${minted.length} token(s) have no render in THIS node's store ${dim(`(#${ids})`)}`);
448
+ console.log(` ${dim('published to a hosted resolver? check there:')} ${bold(`abx verify ${address} --remote <resolver>`)}`);
449
+ console.log(` ${dim('else render them:')} ${bold(`abx render ${address}`)} ${dim('(once) ·')} ${bold('abx effects')} ${dim('(continuous)')}`);
450
+ }
451
+ }
452
+ // The on-chain URI lane (a non-zero tokenURIRenderer, or an animation field pointing at the
453
+ // generator): read the generator's honesty surface — onChainStatus (branch · chain-complete ·
454
+ // unresolved refs · URL budget) — then decode tokenURI straight from the contract and report
455
+ // what the animation_url actually is. All eth_calls; no server, no local store involved.
456
+ if (hasOnChainUriLane(state)) {
457
+ const client = makePublicClient({ chainKey: CHAIN });
458
+ // Is this actually a PROGRAM, or a static image that merely resolves on-chain? `hasOnChainUriLane`
459
+ // is true whenever a tokenURI renderer is set — which it is for EVERY fully-on-chain project,
460
+ // image or code. So an image drop ran the code-project checks and ended in two ⚠ ("generator
461
+ // reports NO code", "no animation_url") that are the DESIGN for a static image, not a defect.
462
+ // Two cold agents in one sweep flagged those warnings as the worst thing about `verify`: the
463
+ // command the skill points at to answer "did my on-chain drop work?" was ending in warnings on a
464
+ // drop that is completely fine, which teaches people to discount its warnings generally.
465
+ const isCodeProject = state.contractType === 'code' || state.contractType === 'edition-code';
466
+ try {
467
+ const report = await onChainUriReport(client, state, flags.generator);
468
+ const { status, probe } = report;
469
+ verifyReport.onChainUri = jsonSafe({
470
+ generator: report.generator,
471
+ tokenURIRenderer: state.tokenURIRenderer ?? null,
472
+ branch: status.branchName,
473
+ chainComplete: !!status.chainComplete,
474
+ unresolvedRefs: [...status.unresolvedRefs],
475
+ urlOverBudget: !!status.urlOverBudget,
476
+ probe: probe
477
+ ? { tokenId: probe.tokenId, onChainJson: !!probe.onChainJson, animation: probe.animation }
478
+ : null,
479
+ });
480
+ emit(verifyReport);
481
+ 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)'}`);
482
+ if (status.branchName === 'template') {
483
+ ok(`generator branch: template — the document assembles from the on-chain script chunks`);
484
+ if (status.chainComplete) {
485
+ ok('chain-complete — every dependency resolves to on-chain bytes; no server, gateway, or CDN in the graph');
486
+ // chainComplete is a SERVING claim, not an immutability one. A `Registry` dependency is
487
+ // fetched live from the registry contract on every read, so its bytes can change after
488
+ // `lock-dependencies` froze the pointer — and this flag reported `true` on both sides of
489
+ // exactly that swap during the audit. Say what it means rather than letting "complete" read
490
+ // as "finished".
491
+ 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.'));
492
+ }
493
+ else
494
+ info('not chain-complete — CDN-served or unresolved dependencies (CDN entries serve fine; on-chain bytes are the durability floor)');
495
+ for (const ref of status.unresolvedRefs) {
496
+ console.log(` ${c.red}✗${c.reset} unresolved dependency ${bold(ref)} — the document carries an <!-- abx:unresolved --> marker; fix the ref or the registry pointer`);
497
+ }
498
+ }
499
+ else if (status.branchName === 'directory') {
500
+ ok('generator branch: directory — the code field emits a parameterized gateway URL');
501
+ if (status.urlOverBudget)
502
+ 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`);
503
+ else
504
+ info('URL within the 8KB budget');
505
+ }
506
+ else if (isCodeProject) {
507
+ 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`);
508
+ }
509
+ else {
510
+ // A static image drop has no program by design — stating that is fine, warning about it is not.
511
+ info('no program on this project — a static image drop, so the metadata carries no animation_url (expected, not a gap)');
512
+ }
513
+ if (!probe) {
514
+ info('tokenURI probe skipped — no token minted yet (mint token #0, then re-run)');
515
+ }
516
+ else if (!probe.onChainJson) {
517
+ console.log(` ${c.red}✗${c.reset} ${probe.accessor}(${probe.tokenId}) is NOT an on-chain data:application/json;base64 URI — got "${probe.uriPrefix}…"`);
518
+ }
519
+ else {
520
+ ok(`${probe.accessor}(${probe.tokenId}) resolves ON-CHAIN: data:application/json;base64 — decoded from the contract, no server`);
521
+ const a = probe.animation;
522
+ if (a.form === 'data-html') {
523
+ if (a.marker)
524
+ 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`);
525
+ else
526
+ ok(`animation_url: data:text/html;base64 — ${a.bytes} bytes of HTML, decoded (the full document, inline)`);
527
+ }
528
+ else if (a.form === 'url') {
529
+ ok(`animation_url: ${a.url} ${dim('(directory branch — the parameterized gateway URL, landed verbatim)')}`);
530
+ }
531
+ else if (a.form === 'data-other') {
532
+ console.log(` ${c.orange}⚠${c.reset} animation_url is a data: URI but not text/html — got "${a.prefix}…"`);
533
+ }
534
+ else if (isCodeProject) {
535
+ console.log(` ${c.orange}⚠${c.reset} no animation_url in the on-chain JSON — the generator field may be missing or unrenderable`);
536
+ }
537
+ // else: a static image has no animation_url and is not supposed to — say nothing.
538
+ }
539
+ // The READ-side envelope. Params enumerate on-chain, so the write side is unbounded — but
540
+ // tokenURI and tokenData assemble EVERY enumerated param per call, and that is what grows.
541
+ // ~64 keys is the documented design envelope; hard failure only nears at several hundred.
542
+ // Advisory (never a ✗) — a big surface is a legitimate choice, it just isn't free.
543
+ const set = await readSetParamKeys(client, state.address, probe ? BigInt(probe.tokenId) : undefined);
544
+ if (set) {
545
+ const total = new Set([...set.contract, ...set.token]).size;
546
+ if (total > 64) {
547
+ 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. ` +
548
+ dim('The documented design envelope is ~64; past a few hundred a public eth_call can hit its gas cap.'));
549
+ }
550
+ }
551
+ }
552
+ catch (e) {
553
+ // First line only. A viem read error carries a multi-line dump (Contract Call / args / Docs /
554
+ // Version) that is meaningless to a creator and buried the actual sentence when this fired.
555
+ const first = String(e.message ?? e).split('\n')[0].trim();
556
+ console.log(` ${c.orange}⚠${c.reset} on-chain URI check unavailable: ${first}`);
557
+ }
558
+ }
559
+ if (renderGap)
560
+ console.log(`\n ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}\n`);
561
+ else
562
+ console.log(anyCheck && allGood ? `\n ${g('✓ verified')} — the node serves exactly what the chain commits to.\n` : '\n');
563
+ // A byte-vs-chain mismatch is an integrity FAILURE, so fail the command. Verify's whole job is to
564
+ // answer "is what's served what the chain vouches for" — exiting 0 while printing ✗ meant nothing
565
+ // could gate on it (a script or CI would sail past a corrupted image). Deliberately narrow: a
566
+ // missing render / placeholder is a normal, expected state and still exits 0.
567
+ if (anyCheck && !allGood)
568
+ process.exitCode = 1;
569
+ }
570
+ // `abx verify <addr> --remote <url>` — verify what a HOSTED resolver actually serves (the local
571
+ // `verify` checks THIS machine's store/backend, the wrong store for a hosted drop). Probes the real
572
+ // `/…/image` route, so it accounts for the locator bridge (a 302 to ipfs/ar) exactly as a marketplace
573
+ // sees it — the truthful "did the thumbnail land?" check after a remote render.
574
+ export async function cmdVerifyRemote(address, remote) {
575
+ const base = remote.url.replace(/\/$/, '');
576
+ const chainId = resolveChain(CHAIN).id;
577
+ console.log(bold(`\n verify ${address} ${dim(`(remote → ${base})`)}`));
578
+ // A DOWN endpoint and a wrong-address endpoint are different problems with different fixes, and a
579
+ // bare `fetch failed` says neither. `abx status --remote` already gets this right — match it, or the
580
+ // two commands disagree about the same condition (a reviewer's top misdiagnosis risk).
581
+ let stateRes;
582
+ try {
583
+ stateRes = await fetch(`${base}/api/project/${address}`);
584
+ }
585
+ catch {
586
+ 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?`);
587
+ }
588
+ if (!stateRes.ok) {
589
+ throw new Error(`resolver ${base} doesn't serve ${address} (HTTP ${stateRes.status}) — register it first: abx add ${address} --remote ${remote.name?.toLowerCase() ?? base}`);
590
+ }
591
+ const state = (await stateRes.json());
592
+ info(`serving as "${state.name ?? address}"`);
593
+ // Is the resolver actively WATCHING the chain? Prove it from /api/watch (the meta the watcher
594
+ // stamps each tick) so a hosted operator who can't tail the log still sees liveness — and catches
595
+ // a silently-stalled watcher (pollAt gone stale) instead of assuming auto-updates still work.
596
+ const watch = (await fetch(`${base}/api/watch`)
597
+ .then((r) => (r.ok ? r.json() : null))
598
+ .catch(() => null));
599
+ if (watch?.watching) {
600
+ const heads = Object.entries(watch.chains).map(([ck, s]) => `${ck} @ block ${s.head}`).join(', ');
601
+ const ageS = watch.pollAt ? Math.round((Date.now() - Date.parse(watch.pollAt)) / 1000) : null;
602
+ const stalled = ageS !== null && ageS > Math.max(60, Math.round((Number(watch.intervalMs) / 1000) * 4));
603
+ const detail = `watching ${heads || 'chain'}${ageS !== null ? ` · last poll ${ageS}s ago` : ''}${watch.lastDeltaAt ? ` · last change ${watch.lastDeltaAt}` : ''}`;
604
+ if (stalled)
605
+ console.log(` ${c.orange}⚠${c.reset} ${detail} — watcher looks STALLED; restart \`abx serve\``);
606
+ else
607
+ ok(detail);
608
+ }
609
+ else if (watch) {
610
+ console.log(` ${dim('watcher OFF — changes land only via explicit add/index or a manual render (ABX_WATCH_INTERVAL_MS=0)')}`);
611
+ }
612
+ const minted = (state.tokens ?? []).filter((t) => t.lifecycle === 'live');
613
+ if (minted.length === 0) {
614
+ info('no tokens minted yet — mint token #0, then re-run.');
615
+ console.log('');
616
+ return;
617
+ }
618
+ // Prefer the resolver's effect-status API (the derived 4-state readout: up-to-date · rendering ·
619
+ // failed(error) · stale). Falls back to the raw /image probe for a resolver without the route.
620
+ const statusRes = await fetch(`${base}/api/project/${address}/effects`).catch(() => null);
621
+ if (statusRes?.ok) {
622
+ const report = (await statusRes.json());
623
+ for (const t of report.tokens) {
624
+ const label = `token #${t.tokenId} ${t.effectKey}`;
625
+ if (t.status === 'up-to-date')
626
+ ok(`${label}: up to date (real render at the current state)`);
627
+ else if (t.status === 'rendering')
628
+ info(`${label}: rendering — the effects runner is on it`);
629
+ else if (t.status === 'failed')
630
+ 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}\`)`)}`);
631
+ else
632
+ 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}\`)`);
633
+ }
634
+ const { upToDate, stale, rendering, failed } = report.counts;
635
+ // RENDERS ONLY — say so. This report answers "is there a current render for each token", never
636
+ // "do the served bytes match the on-chain commitment"; those are different questions and this
637
+ // command promises the second one too. A project with no renders at all (a 1/1, an image Series)
638
+ // has nothing to be "up to date" ABOUT, so don't print a 0/N fraction — but don't let a green ✓
639
+ // here read as "the image is verified" either. Byte integrity comes from the check below.
640
+ if (report.tokens.length === 0) {
641
+ info(`renders ${dim('none for this project (a static image needs no off-chain render)')}`);
642
+ }
643
+ else {
644
+ // Lead with the count that carries the polarity: "N of M current" never inverts on a skim the
645
+ // way "0/M up to date" does.
646
+ const summary = `${upToDate} of ${minted.length} token(s) current${rendering ? ` · ${rendering} rendering` : ''}${stale ? ` · ${stale} stale` : ''}${failed ? ` · ${failed} FAILED` : ''}`;
647
+ if (failed || stale)
648
+ console.log(` ${c.orange}⚠${c.reset} renders: ${summary} ${dim('— live view animates regardless; only the static thumbnail is affected.')}`);
649
+ else
650
+ ok(`renders: ${summary}`);
651
+ }
652
+ await reportRemoteByteIntegrity(address, remote, base);
653
+ return;
654
+ }
655
+ let gap = false;
656
+ for (const t of minted) {
657
+ const img = await fetch(`${base}/t/${chainId}/${address}/${t.tokenId}/image`, { redirect: 'manual' });
658
+ const loc = img.headers.get('location');
659
+ const ct = img.headers.get('content-type') ?? '';
660
+ if (img.status >= 300 && img.status < 400 && loc)
661
+ ok(`token #${t.tokenId} image: real render — resolver 302s to ${loc}`);
662
+ else if (img.status === 200 && !/svg/i.test(ct))
663
+ ok(`token #${t.tokenId} image: real render present (${ct})`);
664
+ else {
665
+ gap = true;
666
+ 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`);
667
+ }
668
+ }
669
+ console.log(gap
670
+ ? ` ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}`
671
+ : ` ${g('✓ thumbnails are real renders')} ${dim('— served straight from the resolver.')}`);
672
+ await reportRemoteByteIntegrity(address, remote, base);
673
+ }
674
+ /**
675
+ * The half `abx verify --remote` was missing: do the served BYTES still hash to the on-chain
676
+ * commitment? Everything above it checks renders (is a thumbnail current, is it a placeholder) — a
677
+ * different question, and a green ✓ there was reading as "the image is correct" when the bytes could
678
+ * genuinely mismatch.
679
+ *
680
+ * The service is the right place to answer it: it holds both the bytes and the chain, and it already
681
+ * exposes exactly this check (`GET /api/project/:addr/verify`, bearer-gated because it triggers
682
+ * outbound fetches). When we can't reach that — no credential, or an older node — say plainly that
683
+ * byte integrity was NOT checked rather than leaving the ✓ above to imply it was.
684
+ */
685
+ export async function reportRemoteByteIntegrity(address, remote, base) {
686
+ if (!remote.token) {
687
+ 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.`);
688
+ return;
689
+ }
690
+ let report;
691
+ try {
692
+ const res = await fetch(`${base}/api/project/${address}/verify`, { headers: { authorization: `Bearer ${remote.token}` } });
693
+ if (res.status === 401 || res.status === 403) {
694
+ 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).`);
695
+ return;
696
+ }
697
+ if (!res.ok) {
698
+ // Spec'd as part of `abx-token-api/v1`, so a conforming service has it — but say it neutrally:
699
+ // this is equally "an older self-hosted node" and "a provider that didn't implement it".
700
+ warn(`byte integrity NOT checked — ${base} serves no /api/project/…/verify route (HTTP ${res.status}). ` +
701
+ `Your own node? Redeploy it (\`abx deploy-resolver\`). A provider's? It's part of abx-token-api/v1 — ask them for it. ` +
702
+ `Meanwhile ${bold(`abx verify ${address}`)} checks the bytes on a node that holds them.`);
703
+ return;
704
+ }
705
+ report = (await res.json());
706
+ }
707
+ catch {
708
+ warn(`byte integrity NOT checked — couldn't reach ${base}'s verify route.`);
709
+ return;
710
+ }
711
+ const checked = (report.tokens ?? []).filter((t) => t.checks.length > 0);
712
+ if (checked.length === 0) {
713
+ info(`bytes ${dim('no on-chain byte commitment to check (this project commits no image hash)')}`);
714
+ return;
715
+ }
716
+ const bad = checked.filter((t) => t.checks.some((k) => !k.verified));
717
+ if (bad.length === 0) {
718
+ ok(`bytes: ${checked.length} token(s) hash-match their on-chain commitment ${dim('(what the service serves IS what the chain vouches for)')}`);
719
+ return;
720
+ }
721
+ const spec = remote.name?.toLowerCase() ?? base;
722
+ 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 ` +
723
+ `${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:`);
724
+ info(`durable bytes exist (ipfs://, ar://) but weren't bridged → ${bold(`abx add ${address} --remote ${spec}`)} forwards the locator, then re-run this.`);
725
+ 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.`);
726
+ process.exitCode = 1; // same rule as the local lane: an integrity mismatch fails the command
727
+ }
728
+ // ── state: a one-glance operational snapshot of a deployed contract, read straight from chain ──
729
+ // Read-only (no tx, no local index). Series-only getters revert on a 1/1, so each read is defensive
730
+ // → the same command works for both. The agent-friendly "what's the state before/after an op" call.
731
+ /**
732
+ * The `seed source` readout — three states, and the distinction between them is the whole reason the
733
+ * line exists. `0x0` is a code project that deliberately draws no mint seed; the canonical address is
734
+ * the shared pseudorandom `AbxSeedSource` (whose properties are documented and NOT lottery-grade);
735
+ * anything else is the creator's own contract, about which ABX knows and claims nothing. A buyer or an
736
+ * agent reading this needs to be able to tell "the standard one" from "someone's custom randomness"
737
+ * at a glance — flattening them to a bare address hides exactly the fact worth surfacing.
738
+ */
739
+ function describeSeedSource(source) {
740
+ if (source === zeroAddress)
741
+ return dim('none (0x0 — no mint-time seed drawn at mint)');
742
+ if (source.toLowerCase() === canonicalSeedSource(resolveChain(CHAIN).id).toLowerCase()) {
743
+ return `${source} ${dim('(canonical AbxSeedSource — pseudorandom, not lottery-grade)')}`;
744
+ }
745
+ return `${source} ${dim('— CUSTOM IAbxSeedSource (not the canonical one; its randomness properties are the project’s to state)')}`;
746
+ }
747
+ export const STATE_ABI = [
748
+ { type: 'function', name: 'owner', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
749
+ { type: 'function', name: 'totalSupply', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
750
+ { type: 'function', name: 'nextTokenId', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
751
+ { type: 'function', name: 'maxInvocations', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
752
+ { type: 'function', name: 'paused', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
753
+ { type: 'function', name: 'minter', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
754
+ { type: 'function', name: 'primaryPayee', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
755
+ { type: 'function', name: 'tokenURIRenderer', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
756
+ { type: 'function', name: 'royaltyInfo', stateMutability: 'view', inputs: [{ type: 'uint256' }, { type: 'uint256' }], outputs: [{ type: 'address' }, { type: 'uint256' }] },
757
+ // ERC-721C (creator token) — ERC-165 advertises ICreatorToken ONLY when enrolled at deploy.
758
+ { type: 'function', name: 'supportsInterface', stateMutability: 'view', inputs: [{ type: 'bytes4' }], outputs: [{ type: 'bool' }] },
759
+ { type: 'function', name: 'getTransferValidator', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
760
+ // The royalty ceiling + burn opt-in are read via the SDK's `readCollectionPolicy` (one owner of
761
+ // those two getters, shared with every other integrator) — deliberately not re-declared here.
762
+ // Seed Source extension — composed by CODE projects only, so absent (undefined) on an image token.
763
+ // Shown because it is the one setting an owner can re-point mid-sale that changes what a LATER
764
+ // buyer receives; `SeedSourceSet` puts it on the spine, and this makes it readable in one command.
765
+ { type: 'function', name: 'seedSource', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
766
+ ];
767
+ /** The edition twin of {@link STATE_ABI} — `owner`/`maxInvocations`/`paused`/`minter`/
768
+ * `primaryPayee`/`tokenURIRenderer`/`royaltyInfo` are shared, unchanged, function names (see
769
+ * `kind.ts`'s own note on why); `totalSupply`/`maxSupply` take an id. The creator-token probe is
770
+ * ALSO shared, unchanged: Limit Break uses the identical `ICreatorToken` ERC-165 id (0xad0d7f6c)
771
+ * and `getTransferValidator()` surface for 721C and 1155C, and `CreatorToken1155` advertises them
772
+ * the same way when enrolled — so an enrolled edition reports its validator exactly like a 721. */
773
+ export const EDITION_STATE_ABI = [
774
+ { type: 'function', name: 'owner', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
775
+ { type: 'function', name: 'maxInvocations', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
776
+ { type: 'function', name: 'totalSupply', stateMutability: 'view', inputs: [{ type: 'uint256' }], outputs: [{ type: 'uint256' }] },
777
+ { type: 'function', name: 'maxSupply', stateMutability: 'view', inputs: [{ type: 'uint256' }], outputs: [{ type: 'uint256' }] },
778
+ { type: 'function', name: 'paused', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
779
+ { type: 'function', name: 'minter', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
780
+ { type: 'function', name: 'primaryPayee', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
781
+ { type: 'function', name: 'tokenURIRenderer', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
782
+ { type: 'function', name: 'royaltyInfo', stateMutability: 'view', inputs: [{ type: 'uint256' }, { type: 'uint256' }], outputs: [{ type: 'address' }, { type: 'uint256' }] },
783
+ // ERC-1155C (creator token) — identical ERC-165 id + getter as 721C; advertised only when enrolled.
784
+ { type: 'function', name: 'supportsInterface', stateMutability: 'view', inputs: [{ type: 'bytes4' }], outputs: [{ type: 'bool' }] },
785
+ { type: 'function', name: 'getTransferValidator', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
786
+ // The royalty ceiling + burn opt-in are read via the SDK's `readCollectionPolicy` (one owner of
787
+ // those two getters, shared with every other integrator) — deliberately not re-declared here.
788
+ // Seed Source extension — EditionCode composes it; the image editions don't. See STATE_ABI's note.
789
+ { type: 'function', name: 'seedSource', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
790
+ ];
791
+ /**
792
+ * The royalty ceiling line — and the headroom, which is the part a creator has not been told.
793
+ *
794
+ * A ceiling above the live rate means the owner can raise the rate to it unilaterally, and nobody
795
+ * looking at a listing can see that. So the useful sentence is not "your cap is 10%": it is that
796
+ * reducing the cap TO the current rate is what turns "5% today" into "5%, provably, forever".
797
+ * (Named by abx-services, 2026-08-20, as their `royalty-headroom` flag — a marketplace can compute
798
+ * it, so a creator should hear it from us first.)
799
+ */
800
+ function royaltyCapLine(address, capBps, rateBps) {
801
+ const cap = `${capBps / 100}%`;
802
+ const tail = '(ceiling — owner-set at deploy, reduce-only)';
803
+ if (rateBps === null || capBps <= rateBps) {
804
+ return `${cap} ${dim(`${tail} — locked to the current rate: this royalty can never rise`)}`;
805
+ }
806
+ return (`${cap} ${dim(tail)}\n` +
807
+ ` ${dim(`↳ headroom: the rate is ${rateBps / 100}%, and the owner may raise it to ${cap} without asking anyone.`)}\n` +
808
+ ` ${dim(` \`abx set-royalty-cap ${address} --cap ${rateBps}\` makes today's rate permanent.`)}`);
809
+ }
810
+ /**
811
+ * What a `canonical: NO` actually costs, said once and shared by both state readouts.
812
+ *
813
+ * A contract deployed outside a trust anchor is a real, working NFT — this is not a failure — but the
814
+ * one property it can never gain is the one platforms allowlist against, and it cannot be granted
815
+ * later at any price: canonicity is fixed at deploy by WHICH contract created it. So the readout says
816
+ * what was lost and what the only remedy is (redeploy through the factory, migrate holders), rather
817
+ * than printing a bare "NO" the reader has to interpret.
818
+ */
819
+ function warnNotCanonical(address) {
820
+ warn(`${address} was not created by an ABX trust anchor, so ${bold('isAbxClone is false')} for it. It still works as ` +
821
+ `an NFT, but nothing that allowlists ABX collections — marketplaces, the App Store, this toolkit's own ` +
822
+ `provenance checks — can recognize it as one, and that cannot be added afterwards: canonicity is decided ` +
823
+ `by which contract deployed it. The only remedy is a fresh deploy through ${bold('abx deploy')} / ` +
824
+ `${bold('deploy-series')} / ${bold('deploy-code')} and moving holders to it. If this was deliberate ` +
825
+ `(a bespoke contract, a superseded factory), nothing here is broken — just make sure whoever owns the ` +
826
+ `project knows they chose it.`);
827
+ }
828
+ export async function cmdState(address, flags) {
829
+ if (!address || address.startsWith('--')) {
830
+ console.error('usage: abx state <address> — a read-only, on-chain operational snapshot\n');
831
+ process.exitCode = 1;
832
+ return;
833
+ }
834
+ // `--json` emits the same facts as data (B19: a value a program needs must be obtainable without
835
+ // parsing prose). The human narration below is unchanged and rides stderr under --json.
836
+ return withJson(flags, async (emit) => cmdStateBody(address, flags, emit));
837
+ }
838
+ export async function cmdStateBody(address, flags, emit) {
839
+ const publicClient = makePublicClient({ chainKey: CHAIN });
840
+ // Edition family branches to its own (leaner) readout — supply/maxSupply are per-id, so "supply
841
+ // X/Y minted" (a whole-contract fact on a 721) doesn't generalize; see cmdStateEditionBody.
842
+ const kind = await detectTokenKind(publicClient, address);
843
+ if (kind.isEdition)
844
+ return cmdStateEditionBody(address, publicClient, kind, emit);
845
+ // getter absent (extension not composed) or contract has no code → undefined, never a throw.
846
+ const read = (functionName, args = []) => tryReadContract(publicClient, { address, abi: STATE_ABI, functionName, args });
847
+ const [owner, totalSupply, nextTokenId, maxInvocations, paused, minter, primaryPayee, renderer, royalty, creatorToken, transferValidator, seedSource] = await Promise.all([
848
+ read('owner'),
849
+ read('totalSupply'),
850
+ read('nextTokenId'),
851
+ read('maxInvocations'),
852
+ read('paused'),
853
+ read('minter'),
854
+ read('primaryPayee'),
855
+ read('tokenURIRenderer'),
856
+ read('royaltyInfo', [0n, 10000n]),
857
+ read('supportsInterface', [CREATOR_TOKEN_INTERFACE_ID]),
858
+ read('getTransferValidator'),
859
+ read('seedSource'),
860
+ ]);
861
+ if (owner === undefined && totalSupply === undefined) {
862
+ throw new Error(`no ABX contract state at ${address} on ${CHAIN} (no code, or not an ABX token).`);
863
+ }
864
+ // Trust, not just shape. Everything above is readable off ANY ERC-721-ish address, so without this
865
+ // `state` described a hand-rolled contract exactly as confidently as a canonical clone. Canonicity
866
+ // is what platforms allowlist and it can never be added after deploy, so it belongs in the snapshot.
867
+ const provenance = await verifyProvenance(publicClient, address, resolveChain(CHAIN).id);
868
+ const isCanonical = provenance.canonical;
869
+ const isSeries = maxInvocations !== undefined; // series-only getter
870
+ const addrOrNone = (a) => (a && a !== zeroAddress ? a : dim('none'));
871
+ // The payload mirrors what is printed, with `undefined` (getter absent) kept distinct from a zero
872
+ // address (present and deliberately unset) — collapsing those is what makes a readout lie.
873
+ const zeroToNull = (a) => (a === undefined ? null : a === zeroAddress ? null : a);
874
+ const { maxRoyaltyBps: royaltyCap, burnable } = await readCollectionPolicy(publicClient, address);
875
+ const payload = jsonSafe({
876
+ address,
877
+ chain: CHAIN,
878
+ chainId: resolveChain(CHAIN).id,
879
+ type: isSeries ? 'series' : '1of1',
880
+ canonical: isCanonical, // tri-state: true | false | null (couldn't check) — never collapse it
881
+ // Provenance, not just trust: WHICH generation of the anchors stamped this clone, and the core
882
+ // version it therefore reports. `prior` is "canonically ABX, older" — a different fact from
883
+ // `canonical: false`, which the two used to share.
884
+ abxGeneration: provenance.generation,
885
+ abxCoreVersion: provenance.coreVersion,
886
+ owner: owner ?? null,
887
+ totalSupply: totalSupply ?? null,
888
+ nextTokenId: nextTokenId ?? null,
889
+ maxInvocations: maxInvocations ?? null,
890
+ paused: paused ?? null,
891
+ minter: zeroToNull(minter),
892
+ primaryPayee: zeroToNull(primaryPayee),
893
+ tokenURIRenderer: zeroToNull(renderer),
894
+ onChainTokenUri: !!(renderer && renderer !== zeroAddress),
895
+ royalty: royalty ? { receiver: royalty[0], bps: Number(royalty[1]) } : null,
896
+ royaltyCap,
897
+ burnable,
898
+ creatorToken: !!creatorToken,
899
+ transferValidator: creatorToken ? zeroToNull(transferValidator) : null,
900
+ // `undefined` (no extension — an image token) and `0x0` (a code project that opted out) are
901
+ // different facts; the JSON keeps them apart the same way every other getter here does.
902
+ seedSource: zeroToNull(seedSource),
903
+ hasSeedSource: seedSource !== undefined,
904
+ params: { governed: [], contractScopeSet: [] },
905
+ });
906
+ emit(payload);
907
+ console.log(bold(`\n ${isSeries ? 'Series' : '1/1'} state`) + dim(` ${address} · ${CHAIN}`));
908
+ info(`canonical ${canonicalLabel(isCanonical, provenance)}`);
909
+ if (isCanonical === false)
910
+ warnNotCanonical(address);
911
+ info(`owner ${owner ?? dim('?')}`);
912
+ if (isSeries) {
913
+ // Fully minted (totalSupply == maxInvocations) ⇒ fixed supply exhausted: the collection is
914
+ // COMPLETE and `unpause` is moot (nothing left for anyone to mint). Say so, so a fully-minted
915
+ // paused series doesn't read as "unfinished, go unpause".
916
+ const soldOut = totalSupply !== undefined && maxInvocations !== undefined && totalSupply >= maxInvocations;
917
+ info(`supply ${totalSupply ?? 0n} / ${maxInvocations} minted ${dim(soldOut ? '(complete — fixed supply fully minted)' : `(nextTokenId ${nextTokenId ?? 0n})`)}`);
918
+ 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')}`}`);
919
+ info(`minter ${addrOrNone(minter)}${minter && minter !== zeroAddress ? '' : dim(' (owner-only)')}`);
920
+ info(`primary payee ${addrOrNone(primaryPayee)}`);
921
+ }
922
+ else {
923
+ info(`supply ${totalSupply ?? 0n} / 1 minted`);
924
+ }
925
+ if (royalty)
926
+ info(`royalty ${Number(royalty[1]) / 100}% → ${royalty[0]}`);
927
+ if (royaltyCap !== null)
928
+ info(`royalty cap ${royaltyCapLine(address, royaltyCap, royalty ? Number(royalty[1]) : null)}`);
929
+ if (burnable !== null)
930
+ info(`burnable ${burnable ? `${c.orange}true${c.reset} ${dim('— holders may burn their own token')}` : dim('false — no token can be destroyed')}`);
931
+ info(`renderer ${renderer && renderer !== zeroAddress ? `on-chain (${renderer})` : dim('off-chain (stored URI base / override)')}`);
932
+ // ERC-721C: shown ONLY for an enrolled collection — an unenrolled token is a plain ERC-721
933
+ // (indistinguishable from a pre-721C token) and shows nothing new here.
934
+ if (creatorToken) {
935
+ info(`721C validator ${transferValidator && transferValidator !== zeroAddress ? transferValidator : `${c.orange}suspended${c.reset} ${dim('(zero — enforcement off; re-enable with `abx set-transfer-validator`)')}`}`);
936
+ }
937
+ if (seedSource !== undefined)
938
+ info(`seed source ${describeSeedSource(seedSource)}`);
939
+ // The PostParam surface, read straight from chain: `paramSchemaKeys()` is the DECLARED (governed)
940
+ // set — append-only, and it includes keys declared but never yet written, which nothing off-chain
941
+ // could otherwise discover — and `contractParamKeys()` is the collection-scope set values. Both
942
+ // are maintained by the contract's own write paths, so there is no list to drift. Read-only,
943
+ // best-effort, and skipped entirely for a project that has none (a 1/1, a legacy pre-enumeration
944
+ // project, or a code project with no params).
945
+ try {
946
+ const keys = (await readParamSchemaKeys(publicClient, address)) ?? [];
947
+ const rows = keys.length
948
+ ? await Promise.all(keys.map(async (key) => ({ key, s: await readParamSchema(publicClient, address, key) })))
949
+ : [];
950
+ const governed = rows.filter((r) => r.s.exists);
951
+ const now = Math.floor(Date.now() / 1000);
952
+ payload.params.governed = jsonSafe(governed.map(({ key, s }) => ({
953
+ key,
954
+ // Canonical NAMES, not the raw enum indices — a caller must not have to know the Solidity
955
+ // enum's ordering to read this (the same reason `retired` is a boolean below).
956
+ paramType: PARAM_TYPES[s.paramType] ?? String(s.paramType),
957
+ auth: AUTH_OPTIONS[s.auth] ?? String(s.auth),
958
+ authAddress: s.authAddress === zeroAddress ? null : s.authAddress,
959
+ lockAfter: s.lockAfter,
960
+ // A past lockAfter IS how a param is retired — surfaced as a boolean so a caller doesn't
961
+ // have to re-derive the convention from a timestamp.
962
+ retired: s.lockAfter !== 0 && s.lockAfter < now,
963
+ selectOptions: s.selectOptions,
964
+ })));
965
+ emit(payload);
966
+ if (governed.length) {
967
+ console.log(`\n ${bold('PostParams')} ${dim(`${governed.length} governed · collectors/creator set these; the generator injects them`)}`);
968
+ for (const { key, s } of governed) {
969
+ const retired = s.lockAfter !== 0 && s.lockAfter < now;
970
+ const locks = s.lockAfter !== 0 && !retired ? dim(` locks ${new Date(s.lockAfter * 1000).toISOString().slice(0, 10)}`) : '';
971
+ const tag = retired ? ` ${c.orange}retired${c.reset}${dim(' — writes revert')}` : locks;
972
+ 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}`);
973
+ }
974
+ }
975
+ // Collection-scope values (every token sees these). `seed` is per-token and never enumerated —
976
+ // it is a tokenData coordinate, read directly.
977
+ const set = await readSetParamKeys(publicClient, address);
978
+ if (set?.contract.length) {
979
+ payload.params.contractScopeSet = [...set.contract].sort();
980
+ emit(payload);
981
+ info(`${dim('contract-scope params set:')} ${[...set.contract].sort().join(', ')} ${dim('(apply to every token)')}`);
982
+ }
983
+ // The hooks, and whether they can still change. This is a BUYER's read, which is why it lives in
984
+ // `state` next to the validator rather than only in `verify`: the transfer hook is a veto over
985
+ // transfers and mints, so "which hooks, and are they frozen" is exactly the pair someone needs
986
+ // before buying. Printed whenever the surface exists — including with all three unset, because
987
+ // "none, and frozen" is the strongest thing this line can say and it must be visible.
988
+ const hooks = await readParamHooks(publicClient, address);
989
+ if (hooks) {
990
+ const locked = await readParamHooksLocked(publicClient, address);
991
+ const shown = [['configure', hooks.configureHook], ['augment', hooks.augmentHook], ['transfer', hooks.transferHook]]
992
+ .filter(([, a]) => a !== zeroAddress)
993
+ .map(([role, a]) => `${role} ${a}`);
994
+ payload.params.hooks = jsonSafe({
995
+ configureHook: hooks.configureHook === zeroAddress ? null : hooks.configureHook,
996
+ augmentHook: hooks.augmentHook === zeroAddress ? null : hooks.augmentHook,
997
+ transferHook: hooks.transferHook === zeroAddress ? null : hooks.transferHook,
998
+ // `null` = could not be established (see readParamHooksLocked). Never collapse it to false:
999
+ // "unknown" and "the owner can still arm a transfer veto" are different answers.
1000
+ locked: locked ?? null,
1001
+ });
1002
+ emit(payload);
1003
+ const lockNote = locked === true
1004
+ ? `${c.green}frozen${c.reset} ${dim('— no hook address can ever change again')}`
1005
+ : locked === false
1006
+ ? `${c.orange}not frozen${c.reset} ${dim('— the owner can re-point these (`abx lock-param-hooks` freezes them forever)')}`
1007
+ : dim('freeze state unknown (the node refused the check)');
1008
+ info(`param hooks ${shown.length ? shown.join(' · ') : dim('none set')} ${lockNote}`);
1009
+ if (hooks.transferHook !== zeroAddress) {
1010
+ info(dim(` the transfer hook is a VETO: if it reverts, the transfer fails — and mints too (mint = transfer from 0x0).`));
1011
+ }
1012
+ }
1013
+ }
1014
+ catch {
1015
+ /* best-effort — a non-code project has no params surface to read */
1016
+ }
1017
+ console.log('');
1018
+ }
1019
+ /**
1020
+ * The edition (ERC-1155) half of {@link cmdStateBody}: `owner`/`paused`/`minter`/`primaryPayee`/
1021
+ * `royalty`/`renderer` are the SAME facts, read the SAME way (shared, unchanged function names);
1022
+ * what differs is supply — there's no whole-contract `totalSupply()`, only a per-id one, so the
1023
+ * headline is id #0's copies (the flagship OneOfOneEdition case, and a representative sample for
1024
+ * EditionImage/EditionCode) with a pointer at `abx tokens` for the full per-id breakdown.
1025
+ */
1026
+ async function cmdStateEditionBody(address, publicClient, kind, emit) {
1027
+ const read = (functionName, args = []) => tryReadContract(publicClient, { address, abi: EDITION_STATE_ABI, functionName, args });
1028
+ const [owner, maxInvocations, paused, minter, primaryPayee, renderer, royalty, creatorToken, transferValidator, seedSource] = await Promise.all([
1029
+ read('owner'),
1030
+ read('maxInvocations'), // absent on a 1/1-edition — its id space is fixed to {0}
1031
+ read('paused'),
1032
+ read('minter'),
1033
+ read('primaryPayee'),
1034
+ read('tokenURIRenderer'),
1035
+ read('royaltyInfo', [0n, 10000n]),
1036
+ read('supportsInterface', [CREATOR_TOKEN_INTERFACE_ID]),
1037
+ read('getTransferValidator'),
1038
+ read('seedSource'),
1039
+ ]);
1040
+ if (owner === undefined)
1041
+ throw new Error(`no ABX contract state at ${address} on ${CHAIN} (no code, or not an ABX token).`);
1042
+ const [supply0, maxSupply0, provenance] = await Promise.all([
1043
+ read('totalSupply', [0n]),
1044
+ read('maxSupply', [0n]),
1045
+ verifyProvenance(publicClient, address, resolveChain(CHAIN).id), // the edition twin owes the same answer
1046
+ ]);
1047
+ const isCanonical = provenance.canonical;
1048
+ const addrOrNone = (a) => (a && a !== zeroAddress ? a : dim('none'));
1049
+ const zeroToNull = (a) => (a === undefined ? null : a === zeroAddress ? null : a);
1050
+ const { maxRoyaltyBps: royaltyCap, burnable } = await readCollectionPolicy(publicClient, address);
1051
+ const payload = jsonSafe({
1052
+ address,
1053
+ chain: CHAIN,
1054
+ chainId: resolveChain(CHAIN).id,
1055
+ type: kind.kind,
1056
+ canonical: isCanonical, // tri-state, same as the 721 readout
1057
+ abxGeneration: provenance.generation,
1058
+ abxCoreVersion: provenance.coreVersion,
1059
+ owner: owner ?? null,
1060
+ maxInvocations: maxInvocations ?? null,
1061
+ supply0: supply0 ?? null,
1062
+ maxSupply0: maxSupply0 ?? null,
1063
+ paused: paused ?? null,
1064
+ minter: zeroToNull(minter),
1065
+ primaryPayee: zeroToNull(primaryPayee),
1066
+ tokenURIRenderer: zeroToNull(renderer),
1067
+ onChainTokenUri: !!(renderer && renderer !== zeroAddress),
1068
+ royalty: royalty ? { receiver: royalty[0], bps: Number(royalty[1]) } : null,
1069
+ royaltyCap,
1070
+ burnable,
1071
+ creatorToken: !!creatorToken,
1072
+ transferValidator: creatorToken ? zeroToNull(transferValidator) : null,
1073
+ seedSource: zeroToNull(seedSource),
1074
+ hasSeedSource: seedSource !== undefined,
1075
+ });
1076
+ emit(payload);
1077
+ console.log(bold(`\n ${describeKind(kind)} state`) + dim(` ${address} · ${CHAIN}`));
1078
+ info(`canonical ${canonicalLabel(isCanonical, provenance)}`);
1079
+ if (isCanonical === false)
1080
+ warnNotCanonical(address);
1081
+ info(`owner ${owner ?? dim('?')}`);
1082
+ const capNote = (cap) => (cap !== undefined && cap > 0n ? `${cap} cap` : 'open — no cap');
1083
+ if (kind.kind === '1of1-edition') {
1084
+ info(`supply ${supply0 ?? 0n} cop${(supply0 ?? 0n) === 1n ? 'y' : 'ies'} of #0 ${dim(`(${capNote(maxSupply0)})`)}`);
1085
+ }
1086
+ else {
1087
+ info(`id space up to ${maxInvocations ?? '?'} distinct id(s)`);
1088
+ info(`#0 copies ${supply0 ?? 0n} ${dim(`(${capNote(maxSupply0)} — per-id breakdown: \`abx tokens ${address}\`)`)}`);
1089
+ }
1090
+ info(`paused ${paused ? `${c.orange}true${c.reset} ${dim('— owner-only minting until `abx unpause`')}` : `false ${dim('— open to minter/public')}`}`);
1091
+ info(`minter ${addrOrNone(minter)}${minter && minter !== zeroAddress ? '' : dim(' (owner-only)')}`);
1092
+ info(`primary payee ${addrOrNone(primaryPayee)}`);
1093
+ if (royalty)
1094
+ info(`royalty ${Number(royalty[1]) / 100}% → ${royalty[0]}`);
1095
+ if (royaltyCap !== null)
1096
+ info(`royalty cap ${royaltyCapLine(address, royaltyCap, royalty ? Number(royalty[1]) : null)}`);
1097
+ if (burnable !== null)
1098
+ info(`burnable ${burnable ? `${c.orange}true${c.reset} ${dim('— holders may burn their own token')}` : dim('false — no token can be destroyed')}`);
1099
+ info(`renderer ${renderer && renderer !== zeroAddress ? `on-chain (${renderer})` : dim('off-chain (stored URI base / override)')}`);
1100
+ // ERC-1155C: shown ONLY for an enrolled collection — same rule (and same interface id) as 721C.
1101
+ if (creatorToken) {
1102
+ info(`1155C validator ${transferValidator && transferValidator !== zeroAddress ? transferValidator : `${c.orange}suspended${c.reset} ${dim('(zero — enforcement off; re-enable with `abx set-transfer-validator`)')}`}`);
1103
+ }
1104
+ if (seedSource !== undefined)
1105
+ info(`seed source ${describeSeedSource(seedSource)}`);
1106
+ // EditionCode composes ConfigurableParams exactly like SeriesCode, so an edition has hooks too —
1107
+ // and the transfer hook's veto reaches a mint, which on this lane is a buyer picking an id. Report
1108
+ // the same pair as the 721 body (which hooks, and whether they can still change); silent for the
1109
+ // image/1-of-1 edition twins, which have no params surface at all.
1110
+ const hooks = await readParamHooks(publicClient, address);
1111
+ if (hooks) {
1112
+ const locked = await readParamHooksLocked(publicClient, address);
1113
+ const shown = [['configure', hooks.configureHook], ['augment', hooks.augmentHook], ['transfer', hooks.transferHook]]
1114
+ .filter(([, a]) => a !== zeroAddress)
1115
+ .map(([role, a]) => `${role} ${a}`);
1116
+ payload.paramHooks = jsonSafe({
1117
+ configureHook: hooks.configureHook === zeroAddress ? null : hooks.configureHook,
1118
+ augmentHook: hooks.augmentHook === zeroAddress ? null : hooks.augmentHook,
1119
+ transferHook: hooks.transferHook === zeroAddress ? null : hooks.transferHook,
1120
+ locked: locked ?? null, // null = unknown; never collapse it to false
1121
+ });
1122
+ emit(payload);
1123
+ const lockNote = locked === true
1124
+ ? `${c.green}frozen${c.reset} ${dim('— no hook address can ever change again')}`
1125
+ : locked === false
1126
+ ? `${c.orange}not frozen${c.reset} ${dim('— the owner can re-point these (`abx lock-param-hooks` freezes them forever)')}`
1127
+ : dim('freeze state unknown (the node refused the check)');
1128
+ info(`param hooks ${shown.length ? shown.join(' · ') : dim('none set')} ${lockNote}`);
1129
+ if (hooks.transferHook !== zeroAddress) {
1130
+ info(dim(` the transfer hook is a VETO: if it reverts, the transfer fails — and mints too (mint = transfer from 0x0).`));
1131
+ }
1132
+ }
1133
+ console.log('');
1134
+ }
1135
+ /**
1136
+ * `abx status [address] [--remote [name|url]] [--watch]` — INDEXING status: where a project sits in
1137
+ * the lifecycle (`queued | backfilling | live | stale | failed`) and how far behind head it is.
1138
+ *
1139
+ * One vocabulary for both sides of the membrane, which is the point: bare = this node, `--remote` =
1140
+ * ask the service, and the same five words either way. Distinct from `abx state <address>`, which
1141
+ * reads the CHAIN (owner, royalty, locks) and knows nothing about who is serving it.
1142
+ */
1143
+ export async function cmdStatus(address, flags) {
1144
+ // `abx status --remote <name>` has no address — without this guard the flag itself lands in
1145
+ // rest[0] and gets sent as the address path segment (the same guard every other command applies).
1146
+ if (address?.startsWith('--'))
1147
+ address = undefined;
1148
+ const remote = remoteFlag(flags);
1149
+ if (remote)
1150
+ return cmdStatusRemote(address, remote, flags);
1151
+ const indexer = localIndexer();
1152
+ const regs = indexer.store.listRegistrations();
1153
+ if (address) {
1154
+ const reg = indexer.store.getRegistration(address);
1155
+ if (!reg)
1156
+ 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>\``);
1157
+ const s = indexer.getProject(address);
1158
+ const row = indexer.indexStatus(address);
1159
+ const head = indexer.store.getMeta(`watch:${reg.chainKey}:head`);
1160
+ // Don't print the address twice when there's no name to lead with.
1161
+ console.log(s?.name ? `\n ${bold(s.name)} ${dim(address)}` : `\n ${bold(address)}`);
1162
+ statusRow('status', statusLine({
1163
+ chainId: resolveChain(reg.chainKey).id,
1164
+ address,
1165
+ status: row.status,
1166
+ fromBlock: reg.fromBlock,
1167
+ toBlock: s?.toBlock ?? null,
1168
+ headBlock: head,
1169
+ eventCount: s?.eventCount ?? 0,
1170
+ tokenCount: s?.tokens.length ?? 0,
1171
+ mintedCount: 0,
1172
+ reconstructedAt: s?.reconstructedAt ?? null,
1173
+ ...(row.errorClass ? { error: { class: row.errorClass, message: row.errorMessage ?? undefined } } : {}),
1174
+ }));
1175
+ statusRow('floor', `${reg.fromBlock}${row.attempts ? dim(` attempts ${row.attempts}`) : ''}`);
1176
+ statusRow('indexed', s ? `${s.eventCount} events · ${s.tokens.length} token(s) ${dim(`· ${s.reconstructedAt}`)}` : dim('no projection yet'));
1177
+ if (!head)
1178
+ statusRow('head', dim("unknown — this node isn't watching the chain (ABX_WATCH_INTERVAL_MS=0, or `abx serve` isn't running)"));
1179
+ console.log('');
1180
+ return;
1181
+ }
1182
+ console.log(bold(`\n ABX self-host node`));
1183
+ info(`chain: ${CHAIN} · factory: ${factoryAddress() ?? 'none'} · storage: ${activeBackendId()} · data: ${indexer.store.path}`);
1184
+ if (regs.length === 0) {
1185
+ console.log(dim('\n No projects yet. `abx demo` to create one.\n'));
1186
+ return;
1187
+ }
1188
+ console.log('');
1189
+ for (const reg of regs) {
1190
+ const s = indexer.getProject(reg.address);
1191
+ const st = indexer.indexStatus(reg.address).status;
1192
+ const mark = st === 'failed' || st === 'stale' ? `${c.orange}●${c.reset}` : s ? g('●') : dim('○');
1193
+ const tail = s ? `${s.eventCount} events` : dim('(registered, not indexed)');
1194
+ console.log(` ${mark} ${s?.name ?? reg.address} ${dim(reg.address)} ${tail} ${statusLabel(st)}`);
1195
+ }
1196
+ console.log(dim(`\n one project in detail: abx status <address>\n`));
1197
+ }
1198
+ /** The `--remote` half of {@link cmdStatus}: one project, or the roll-up for every project the token
1199
+ * can see. `--watch` tails until everything reaches a terminal state. */
1200
+ export async function cmdStatusRemote(address, remote, flags) {
1201
+ requireRemoteToken(remote);
1202
+ const client = serviceClient(remote);
1203
+ const chainId = resolveChain(CHAIN).id;
1204
+ const watch = flags.watch !== undefined;
1205
+ const spec = remote.name ? remote.name.toLowerCase() : remote.source === 'default' ? '' : remote.url;
1206
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(indexing status as the service reports it)')}`);
1207
+ if (address) {
1208
+ for (;;) {
1209
+ let s;
1210
+ try {
1211
+ s = await client.projectStatus(chainId, address);
1212
+ }
1213
+ catch (err) {
1214
+ throw describeRemoteError(err, remote, 'remote status');
1215
+ }
1216
+ console.log(`\n ${bold(address)}`);
1217
+ statusRow('status', statusLine(s));
1218
+ statusRow('floor', `${s.fromBlock}${s.attempts ? dim(` attempts ${s.attempts}`) : ''}`);
1219
+ statusRow('indexed', `${s.eventCount} events · ${s.tokenCount} token(s)${s.reconstructedAt ? dim(` · ${s.reconstructedAt}`) : ''}`);
1220
+ // Spell out what `watching` MEANS on the line itself — a bare `no` sent a reviewer to a
1221
+ // different command's output to find out whether it was a problem (it usually isn't).
1222
+ if (s.watcher) {
1223
+ statusRow('watching', s.watcher.watching
1224
+ ? `${g('yes')}${s.watcher.head ? dim(` · head ${s.watcher.head}`) : ''}${dim(' — it tails new blocks, so on-chain changes land on their own')}`
1225
+ : `${dim('no')} ${dim('— this service updates on an explicit add/index, not by tailing new blocks (normal for many providers)')}`);
1226
+ }
1227
+ // Don't leave a creator staring at a red word with no next move. `failed` especially reads as
1228
+ // terminal when it isn't — name what it means and the one command that follows it.
1229
+ if (!watch && s.status !== 'live') {
1230
+ if (s.status === 'failed' && s.error)
1231
+ statusRow('what now', indexErrorAction(s.error.class));
1232
+ statusRow('follow', dim(`this is not final — ${bold(`abx status ${address} --remote${spec ? ` ${spec}` : ''} --watch`)} tails it until it settles`));
1233
+ }
1234
+ console.log('');
1235
+ if (!watch || s.status === 'live' || s.status === 'failed')
1236
+ return;
1237
+ await sleep(3000);
1238
+ }
1239
+ }
1240
+ for (;;) {
1241
+ let projects;
1242
+ try {
1243
+ projects = await client.listProjects();
1244
+ }
1245
+ catch (err) {
1246
+ throw describeRemoteError(err, remote, 'remote status');
1247
+ }
1248
+ if (projects.length === 0) {
1249
+ console.log(dim('\n no projects visible to this token\n'));
1250
+ return;
1251
+ }
1252
+ console.log('');
1253
+ for (const p of projects) {
1254
+ console.log(` ${g('●')} ${p.name ?? p.label ?? p.address} ${dim(p.address)} ` +
1255
+ `${p.status ? statusLabel(p.status) : dim('status not reported')}` +
1256
+ `${p.error ? ` ${c.orange}${p.error.class}${c.reset}` : ''} ${dim(`${p.tokenCount ?? '?'} token(s)`)}`);
1257
+ }
1258
+ console.log(`\n ${rollUp(projects)}\n`);
1259
+ const settled = projects.every((p) => !p.status || p.status === 'live' || p.status === 'failed');
1260
+ if (!watch || settled)
1261
+ return;
1262
+ await sleep(3000);
1263
+ }
1264
+ }
1265
+ // ── forget ────────────────────────────────────────────────────────────────--
1266
+ // Drop a project this node tracks (registration + projection) — for cleaning up
1267
+ // test/junk deploys. On-chain data is untouched; `abx add` can re-register it.
1268
+ export async function cmdForget(address, flags) {
1269
+ if (!address || address.startsWith('--')) {
1270
+ console.error('usage: abx forget <address> [--remote [name|url]]\n');
1271
+ process.exitCode = 1;
1272
+ return;
1273
+ }
1274
+ const remote = remoteFlag(flags);
1275
+ if (remote) {
1276
+ requireRemoteToken(remote);
1277
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(deregistering on the remote resolver — NOT this machine)')}`);
1278
+ let removed;
1279
+ try {
1280
+ ({ removed } = await serviceClient(remote).removeProject(resolveChain(CHAIN).id, address));
1281
+ }
1282
+ catch (err) {
1283
+ throw describeRemoteError(err, remote, 'remote forget');
1284
+ }
1285
+ if (removed)
1286
+ ok(`remote resolver forgot ${address} — it will stop serving it. On-chain data is untouched.`);
1287
+ else
1288
+ console.log(dim(` ${address} wasn't registered on ${remote.url} — nothing to forget.`));
1289
+ return;
1290
+ }
1291
+ const indexer = localIndexer();
1292
+ if (!indexer.store.getRegistration(address)) {
1293
+ console.log(dim(` ${address} isn't tracked by this node — nothing to forget.`));
1294
+ return;
1295
+ }
1296
+ indexer.store.deregister(address);
1297
+ ok(`forgot ${address} — dropped its registration + projection. On-chain data is untouched.`);
1298
+ }
1299
+ //# sourceMappingURL=project.js.map