@artblocks/abx-cli 0.1.0-alpha.16 → 0.1.0-alpha.18

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 (116) hide show
  1. package/CHANGELOG.md +385 -0
  2. package/dist/commands/deploy.d.ts +181 -0
  3. package/dist/commands/deploy.d.ts.map +1 -0
  4. package/dist/commands/deploy.js +2974 -0
  5. package/dist/commands/deploy.js.map +1 -0
  6. package/dist/commands/project.d.ts +134 -0
  7. package/dist/commands/project.d.ts.map +1 -0
  8. package/dist/commands/project.js +919 -0
  9. package/dist/commands/project.js.map +1 -0
  10. package/dist/commands/reads.d.ts +64 -0
  11. package/dist/commands/reads.d.ts.map +1 -0
  12. package/dist/commands/reads.js +635 -0
  13. package/dist/commands/reads.js.map +1 -0
  14. package/dist/commands/scaffold.d.ts +55 -0
  15. package/dist/commands/scaffold.d.ts.map +1 -0
  16. package/dist/commands/scaffold.js +470 -0
  17. package/dist/commands/scaffold.js.map +1 -0
  18. package/dist/commands/service.d.ts +41 -0
  19. package/dist/commands/service.d.ts.map +1 -0
  20. package/dist/commands/service.js +572 -0
  21. package/dist/commands/service.js.map +1 -0
  22. package/dist/commands/storage.d.ts +37 -0
  23. package/dist/commands/storage.d.ts.map +1 -0
  24. package/dist/commands/storage.js +335 -0
  25. package/dist/commands/storage.js.map +1 -0
  26. package/dist/config.d.ts +72 -2
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +201 -4
  29. package/dist/config.js.map +1 -1
  30. package/dist/deps.d.ts +6 -39
  31. package/dist/deps.d.ts.map +1 -1
  32. package/dist/deps.js +4 -68
  33. package/dist/deps.js.map +1 -1
  34. package/dist/errors.d.ts +20 -0
  35. package/dist/errors.d.ts.map +1 -0
  36. package/dist/errors.js +25 -0
  37. package/dist/errors.js.map +1 -0
  38. package/dist/flags.d.ts +34 -0
  39. package/dist/flags.d.ts.map +1 -1
  40. package/dist/flags.js +74 -0
  41. package/dist/flags.js.map +1 -1
  42. package/dist/jsonout.d.ts +37 -0
  43. package/dist/jsonout.d.ts.map +1 -0
  44. package/dist/jsonout.js +68 -0
  45. package/dist/jsonout.js.map +1 -0
  46. package/dist/main.js +133 -6223
  47. package/dist/main.js.map +1 -1
  48. package/dist/mintpage.d.ts.map +1 -1
  49. package/dist/mintpage.js +34 -40
  50. package/dist/mintpage.js.map +1 -1
  51. package/dist/output.d.ts +137 -0
  52. package/dist/output.d.ts.map +1 -0
  53. package/dist/output.js +504 -0
  54. package/dist/output.js.map +1 -0
  55. package/dist/ownerops.d.ts +37 -44
  56. package/dist/ownerops.d.ts.map +1 -1
  57. package/dist/ownerops.js +274 -233
  58. package/dist/ownerops.js.map +1 -1
  59. package/dist/preview.d.ts +8 -4
  60. package/dist/preview.d.ts.map +1 -1
  61. package/dist/preview.js +8 -17
  62. package/dist/preview.js.map +1 -1
  63. package/dist/provision.d.ts.map +1 -1
  64. package/dist/provision.js +10 -7
  65. package/dist/provision.js.map +1 -1
  66. package/dist/remote.d.ts +72 -7
  67. package/dist/remote.d.ts.map +1 -1
  68. package/dist/remote.js +211 -9
  69. package/dist/remote.js.map +1 -1
  70. package/dist/riskgate.d.ts +29 -0
  71. package/dist/riskgate.d.ts.map +1 -0
  72. package/dist/riskgate.js +91 -0
  73. package/dist/riskgate.js.map +1 -0
  74. package/dist/schema.d.ts +6 -0
  75. package/dist/schema.d.ts.map +1 -1
  76. package/dist/schema.js +12 -16
  77. package/dist/schema.js.map +1 -1
  78. package/dist/served.d.ts +30 -0
  79. package/dist/served.d.ts.map +1 -0
  80. package/dist/served.js +112 -0
  81. package/dist/served.js.map +1 -0
  82. package/dist/signer.d.ts +13 -0
  83. package/dist/signer.d.ts.map +1 -1
  84. package/dist/signer.js +42 -19
  85. package/dist/signer.js.map +1 -1
  86. package/dist/update-check.d.ts +27 -0
  87. package/dist/update-check.d.ts.map +1 -1
  88. package/dist/update-check.js +30 -0
  89. package/dist/update-check.js.map +1 -1
  90. package/package.json +6 -6
  91. package/skill/SKILL.md +9 -13
  92. package/skill/reference/code-projects.md +7 -6
  93. package/skill/reference/hosting.md +6 -6
  94. package/skill/reference/operating.md +2 -5
  95. package/skill/reference/setup.md +4 -3
  96. package/skill/reference/troubleshooting.md +14 -1
  97. package/dist/gas.d.ts +0 -63
  98. package/dist/gas.d.ts.map +0 -1
  99. package/dist/gas.js +0 -76
  100. package/dist/gas.js.map +0 -1
  101. package/dist/inspect.d.ts +0 -48
  102. package/dist/inspect.d.ts.map +0 -1
  103. package/dist/inspect.js +0 -290
  104. package/dist/inspect.js.map +0 -1
  105. package/dist/migrate.d.ts +0 -65
  106. package/dist/migrate.d.ts.map +0 -1
  107. package/dist/migrate.js +0 -180
  108. package/dist/migrate.js.map +0 -1
  109. package/dist/onchain-uri.d.ts +0 -81
  110. package/dist/onchain-uri.d.ts.map +0 -1
  111. package/dist/onchain-uri.js +0 -251
  112. package/dist/onchain-uri.js.map +0 -1
  113. package/dist/upload.d.ts +0 -28
  114. package/dist/upload.d.ts.map +0 -1
  115. package/dist/upload.js +0 -41
  116. package/dist/upload.js.map +0 -1
@@ -0,0 +1,919 @@
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 { AUTH_OPTIONS, CREATOR_TOKEN_INTERFACE_ID, PARAM_TYPES, hasOnChainUriLane, isCodeProject, isCurrentFactory, makePublicClient, makeWalletClient, normalizeAttributes, onChainUriReport, parseTraitPairs, predictClone, readParamSchema, readParamSchemaKeys, readSetParamKeys, resolveChain, resolveSeriesCodeFactory, saltFor, saltGuard, sleep, 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, 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 { canonicalLabel, describeRemoteError, indexErrorAction, remoteFlag, reportRemoteIndexing, requireRemoteToken, rollUp, serviceClient, statusLabel, statusLine, statusRow, tokenSourceLabel, } from '../remote.js';
18
+ import { describeSchema } from '../schema.js';
19
+ import { looksPerTokenAttributes, parseSeriesTraitsById } from '../series-traits.js';
20
+ // ── predict ──────────────────────────────────────────────────────────────────
21
+ // Pre-compute a deploy address from a salt — so you can stand up the resolver and
22
+ // reserve a vanity/known address before signing. The address is a pure function of
23
+ // (factory, salt); the salt's leading 20 bytes are the front-run guard.
24
+ export async function cmdPredict(flags) {
25
+ const publicClient = makePublicClient({ chainKey: CHAIN });
26
+ // Lane-aware: each deploy command uses a DIFFERENT factory, so the deterministic address differs.
27
+ // Infer the lane from the content flags so `predict` matches what will actually be deployed —
28
+ // otherwise a code/Series creator gets the 1/1 address (a real mismatch, not just a wrong echo).
29
+ const chainId = resolveChain(CHAIN).id;
30
+ const content = flags.script ? `--script ${flags.script}` :
31
+ flags['code-dir'] ? `--code-dir ${flags['code-dir']}` :
32
+ flags['image-renderer'] ? `--image-renderer ${flags['image-renderer']}` :
33
+ flags.dir ? `--dir ${flags.dir}` :
34
+ flags.image ? `--image ${flags.image}` : '';
35
+ const lane = (flags.script || flags['code-dir'] || flags['image-renderer'])
36
+ ? { cmd: 'deploy-code', factory: resolveSeriesCodeFactory(chainId, flags.factory) }
37
+ : flags.dir
38
+ ? { cmd: 'deploy-series', factory: seriesFactoryAddress(flags.factory) }
39
+ : { cmd: 'deploy', factory: factoryAddress(flags.factory) };
40
+ const factory = lane.factory;
41
+ if (!factory)
42
+ throw new Error(`no canonical ${lane.cmd} factory for '${CHAIN}' — run \`abx ${lane.cmd}\` once to deploy the trust anchor, or pass --factory 0x.. (or the ABX_* env).`);
43
+ // The current-factory guard is the 1/1 impl-version check; the Series/code lanes verify their own
44
+ // factory at deploy time. predictDeterministicAddress works on any factory ABI.
45
+ if (lane.cmd === 'deploy' && !(await isCurrentFactory(publicClient, factory))) {
46
+ throw new Error(`factory ${factory} is an older/incompatible version. Run \`abx deploy\` once to deploy the current ` +
47
+ `trust anchor (ownerless + cheap), then predict again — or pass a current --factory.`);
48
+ }
49
+ let salt = parseSaltFlag(flags.salt);
50
+ if (!salt) {
51
+ // No explicit salt: reserve one to a deployer (front-run-proof). Default to the env key.
52
+ let deployer = flags.for;
53
+ if (!deployer)
54
+ deployer = makeWalletClient({ chainKey: CHAIN }).account.address;
55
+ salt = saltFor(deployer);
56
+ info(`reserved a fresh salt to ${deployer} (front-run-proof) — pass --salt to fix/vanity it`);
57
+ }
58
+ const guard = saltGuard(salt);
59
+ const clone = await predictClone(publicClient, { factory, salt });
60
+ console.log(`\n ${bold('predicted address')} ${g(clone)} ${dim(`(${lane.cmd} lane)`)}`);
61
+ info(`salt ${salt}`);
62
+ info(guard === zeroAddress ? 'guard permissionless — anyone may deploy this salt' : `guard reserved to ${guard} (only this signer can deploy it)`);
63
+ info(`deploy: ${bold(`abx ${lane.cmd}${content ? ` ${content}` : ''} --salt ${salt}`)}`);
64
+ if (lane.cmd === 'deploy')
65
+ 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)'));
66
+ console.log('');
67
+ }
68
+ // ── add ──────────────────────────────────────────────────────────────────────
69
+ // Register + index a project this node didn't deploy. LOCAL by default (this
70
+ // machine's store); `--remote [url]` instead tells a HOSTED resolver to index it —
71
+ // the bridge a local deploy can't make on its own (separate projection stores).
72
+ export async function cmdAdd(address, flags) {
73
+ if (!address || address.startsWith('--')) {
74
+ console.error('usage: abx add <address> [--from-block N] [--factory 0x..] [--label "..."] [--remote [name|url]]\n');
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+ // REFUSE `--dry-run` rather than ignoring it. `add` is a write (a local registration + index, and
79
+ // with `--remote` a registration on someone else's service), and it has no preview mode — so
80
+ // silently proceeding to DO the thing when the caller explicitly asked to preview is the one
81
+ // outcome we must never produce. Name what's read-only instead.
82
+ if (isDryRun(flags)) {
83
+ throw new Error('`abx add` has no --dry-run: it registers + indexes for real (and with --remote it registers on that service). ' +
84
+ 'Nothing here touches the chain, but it does write. To look before acting: `abx state <address>` (on-chain snapshot) ' +
85
+ 'or `abx status <address> [--remote <name>]` (what a node already has). Re-run without --dry-run when you mean it.');
86
+ }
87
+ // `--attributes` is lane-aware here exactly as at deploy: a PER-TOKEN payload edits a Series'
88
+ // per-token off-chain traits; a flat payload (+ `--traits`) edits the collection/1-of-1 `attributes`.
89
+ // Ambiguity defaults to flat (see looksPerTokenAttributes), so a 1/1 add is never mis-read.
90
+ const attrRaw = flags.attributes ? readFileSync(resolvePath(process.cwd(), String(flags.attributes)), 'utf8') : undefined;
91
+ const perTokenEdit = attrRaw != null && looksPerTokenAttributes(attrRaw);
92
+ const flagTraits = [];
93
+ if (attrRaw != null && !perTokenEdit)
94
+ flagTraits.push(...normalizeAttributes(JSON.parse(attrRaw)));
95
+ if (flags.traits)
96
+ flagTraits.push(...parseTraitPairs(flags.traits));
97
+ const editedTokenAttributes = perTokenEdit ? parseSeriesTraitsById(attrRaw) : undefined;
98
+ const remote = remoteFlag(flags);
99
+ if (remote) {
100
+ requireRemoteToken(remote);
101
+ // Bridge what a remote resolver can't derive itself: the off-chain traits and the durable
102
+ // content locators (ipfs://…). Prefer flags; otherwise forward what the LOCAL deploy stored
103
+ // (the local registration), and compute locators from this machine's content index if needed.
104
+ const localReg = localIndexer().store.getRegistration(address);
105
+ const attributes = flagTraits.length
106
+ ? flagTraits
107
+ : localReg?.attributes
108
+ ? normalizeAttributes(JSON.parse(localReg.attributes))
109
+ : undefined;
110
+ // Bridge a Series' per-token off-chain traits to the remote resolver (the resolver has no other
111
+ // way to derive them — they're operator metadata, not chain state). A fresh per-token `--attributes`
112
+ // EDITS them; otherwise forward what the LOCAL deploy stored. Best-effort parse.
113
+ let tokenAttributes;
114
+ if (editedTokenAttributes && Object.keys(editedTokenAttributes).length) {
115
+ tokenAttributes = editedTokenAttributes;
116
+ }
117
+ else if (localReg?.tokenAttributes) {
118
+ try {
119
+ const obj = JSON.parse(localReg.tokenAttributes);
120
+ const norm = {};
121
+ for (const [id, v] of Object.entries(obj)) {
122
+ const a = normalizeAttributes(v);
123
+ if (a.length)
124
+ norm[id] = a;
125
+ }
126
+ if (Object.keys(norm).length)
127
+ tokenAttributes = norm;
128
+ }
129
+ catch { /* skip a malformed local column */ }
130
+ }
131
+ const contentLocators = await remoteLocators(address, localReg?.contentLocators, flags);
132
+ // Forward the deploy block, like every other field falls back to the local registration.
133
+ // (Its ABSENCE here was the bug: a hosted resolver defaulted to genesis and scanned the whole
134
+ // chain.) Re-sending the same floor stays incremental server-side, so a nudge ≠ a re-scan.
135
+ const body = {
136
+ chainId: resolveChain(CHAIN).id,
137
+ address,
138
+ fromBlock: await resolveScanFloor(address, localReg?.fromBlock, flags),
139
+ factory: await detectCanonicalFactory(address, flags.factory, localReg?.factory),
140
+ label: flags.label,
141
+ description: flags.description ?? localReg?.description,
142
+ externalUrl: flags['external-url'] ?? localReg?.externalUrl,
143
+ attributes,
144
+ tokenAttributes,
145
+ contentLocators: Object.keys(contentLocators).length ? contentLocators : undefined,
146
+ full: flags.full ? true : undefined,
147
+ };
148
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(registering with the remote resolver — NOT this machine)')}`);
149
+ if (body.contentLocators)
150
+ info(`bridging image locator → ${Object.values(body.contentLocators)[0]} ${dim('(so the resolver points at IPFS, not its own localhost)')}`);
151
+ let r;
152
+ try {
153
+ r = await serviceClient(remote).registerProject(body);
154
+ }
155
+ catch (err) {
156
+ throw describeRemoteError(err, remote, 'remote add');
157
+ }
158
+ await reportRemoteIndexing(remote, body.chainId, address, r, flags, 'indexed');
159
+ info(`it now serves ${remote.url}/t/${body.chainId}/${address.toLowerCase()}/0`);
160
+ // "Indexed" is not "correct". This line proves the service replayed the chain and will answer at
161
+ // that URL — it says nothing about whether the bytes it serves match the on-chain commitment, and
162
+ // two reviewers stopped here and reported a blank page as fixed. Name the step that checks.
163
+ info(`confirm what it actually serves (bytes vs. the on-chain hash): ${bold(`abx verify ${address} --remote ${remote.name?.toLowerCase() ?? remote.url}`)}`);
164
+ return;
165
+ }
166
+ // The local (non-`--remote`) path is shared with the deploy family's post-setup registration —
167
+ // see registerAndIndexLocally's doc comment (output.ts).
168
+ return registerAndIndexLocally(address, flags);
169
+ }
170
+ export async function cmdIndex(address, flags) {
171
+ const remote = remoteFlag(flags);
172
+ if (remote) {
173
+ if (!address || address.startsWith('--')) {
174
+ console.error('usage: abx index <address> --remote [name|url] (re-index one project on a remote resolver)\n');
175
+ process.exitCode = 1;
176
+ return;
177
+ }
178
+ requireRemoteToken(remote);
179
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(re-indexing on the remote resolver — the post-deploy nudge)')}`);
180
+ const chainId = resolveChain(CHAIN).id;
181
+ let r;
182
+ try {
183
+ r = await serviceClient(remote).registerProject({ chainId, address, full: flags.full ? true : undefined });
184
+ }
185
+ catch (err) {
186
+ throw describeRemoteError(err, remote, 'remote index');
187
+ }
188
+ await reportRemoteIndexing(remote, chainId, address, r, flags, 're-indexed');
189
+ return;
190
+ }
191
+ allowLargeScan(flags);
192
+ const full = !!flags.full; // force a full replay from the deploy block (durability proof)
193
+ const indexer = localIndexer();
194
+ const line = (state, elapsedMs, mode) => ok(`${state.name ?? state.address}: ${state.eventCount} events in ${elapsedMs}ms ${dim(`(${mode})`)}`);
195
+ if (address) {
196
+ const { state, elapsedMs, mode } = await indexer.reindex(address, { full });
197
+ line(state, elapsedMs, mode);
198
+ return;
199
+ }
200
+ const results = await indexer.reindexAll({ full });
201
+ if (results.length === 0) {
202
+ console.log(dim('No registered projects. Deploy one with `abx deploy`.'));
203
+ return;
204
+ }
205
+ for (const { state, elapsedMs, mode } of results)
206
+ line(state, elapsedMs, mode);
207
+ }
208
+ // ── verify ─────────────────────────────────────────────────────────────────--
209
+ // Re-hash a project's served bytes against its on-chain content commitment, from
210
+ // chain + custody alone — no running server. The CLI form of the `/verify` route,
211
+ // so an agent can confirm integrity right after deploy/mint without curling.
212
+ export async function cmdVerify(address, flags) {
213
+ if (!address || address.startsWith('--')) {
214
+ console.error('usage: abx verify <address> [--json] [--remote <name|url>]\n');
215
+ process.exitCode = 1;
216
+ return;
217
+ }
218
+ allowLargeScan(flags);
219
+ const remote = remoteFlag(flags);
220
+ if (remote)
221
+ return cmdVerifyRemote(address, remote);
222
+ // `--json` matters most here of all the read commands: verify already exits non-zero on a byte
223
+ // mismatch, so it is the one command a CI job would gate on — and gating on prose is what B19 is
224
+ // about. The payload's `ok` is the same boolean the exit code carries.
225
+ return withJson(flags, async (emit) => cmdVerifyBody(address, flags, emit));
226
+ }
227
+ export async function cmdVerifyBody(address, flags, emit) {
228
+ const indexer = localIndexer();
229
+ let state = indexer.getProject(address);
230
+ if (indexer.store.getRegistration(address)) {
231
+ ({ state } = await indexer.reindex(address)); // freshest state from chain
232
+ }
233
+ else if (!state) {
234
+ throw new Error(`${address} isn't indexed by this node. Run \`abx add ${address}\` first.`);
235
+ }
236
+ if (!state)
237
+ throw new Error(`could not load state for ${address}`);
238
+ // Accumulated as the checks run, re-emitted at each stage so a payload survives an early return.
239
+ const verifyReport = {
240
+ address,
241
+ chain: CHAIN,
242
+ name: state.name ?? null,
243
+ canonical: state.isCanonical, // TRUE tri-state: true | false | null (couldn't check)
244
+ owner: state.owner ?? null,
245
+ contentChecks: [],
246
+ contentIntegrity: 'no-commitments',
247
+ renders: null,
248
+ onChainUri: null,
249
+ ok: true,
250
+ };
251
+ emit(verifyReport);
252
+ console.log(bold(`\n verify ${state.name ?? address}`));
253
+ // isCanonical is a TRUE TRI-STATE (true | false | null) and collapsing it lost the only
254
+ // distinction that matters: "the chain says this is NOT a clone of the configured factory" is a
255
+ // trust finding; "we couldn't run the check" (no factory configured, or none deployed on this
256
+ // chain) is an environment note. Two reviewers read the collapsed word as a second failure.
257
+ info(`canonical: ${canonicalLabel(state.isCanonical)} · owner ${state.owner ?? '—'}`);
258
+ const storage = resolveBackend(storageOptions());
259
+ const result = await verifyProject(state, storage);
260
+ const tokens = result.tokens ?? [];
261
+ let anyCheck = false;
262
+ let allGood = true;
263
+ for (const t of tokens) {
264
+ const tok = state.tokens.find((s) => s.tokenId === t.tokenId);
265
+ info(`token #${t.tokenId}: ${tok?.minted ? 'minted' : 'not yet minted'}`);
266
+ for (const ch of t.checks) {
267
+ verifyReport.contentChecks.push({
268
+ tokenId: t.tokenId,
269
+ minted: !!tok?.minted,
270
+ kind: ch.kind,
271
+ // tri-state again: true (re-hashed, matched) · false (MISMATCH) · null (pointer-only,
272
+ // nothing local to recompute). Collapsing null into false would report a normal state as a
273
+ // failure, which is the bug this command exists to not have.
274
+ verified: ch.verified,
275
+ });
276
+ anyCheck = true;
277
+ if (ch.verified === true)
278
+ ok(`${ch.kind} matches on-chain commitment — content is authentic`);
279
+ else if (ch.verified === false) {
280
+ allGood = false;
281
+ console.log(` ${c.red}✗${c.reset} ${ch.kind} MISMATCH — served bytes don't match the commitment`);
282
+ }
283
+ else
284
+ info(`${ch.kind} — pointer-only, not locally recomputable`);
285
+ }
286
+ }
287
+ if (!anyCheck && !isCodeProject(state))
288
+ info('no content commitments on this project');
289
+ verifyReport.contentIntegrity = !anyCheck ? 'no-commitments' : allGood ? 'ok' : 'mismatch';
290
+ verifyReport.ok = !anyCheck || allGood;
291
+ emit(verifyReport);
292
+ // Code projects have no on-chain `image` commitment (the still is rendered off-chain by the
293
+ // effect runner), so the hash checks above are silent for them. Instead report what actually
294
+ // matters after mint: does a REAL thumbnail exist yet, or is the marketplace image still the
295
+ // placeholder? Same seam the /…/image route serves from (currentRenderArtifact @ current
296
+ // inputsHash) — so this answers "did the render land?" with one command, right after token 0.
297
+ let renderGap = false;
298
+ if (isCodeProject(state)) {
299
+ const client = makePublicClient({ chainKey: CHAIN });
300
+ const storageForRender = resolveBackend(storageOptions());
301
+ const minted = state.tokens.filter((t) => t.minted);
302
+ if (minted.length === 0)
303
+ info('no tokens minted yet — mint token #0, then re-run to check its thumbnail.');
304
+ // Live-data posture — the augment hook IS the opt-in: no hook ⇒ zero live reads (pure indexed
305
+ // params); hook set ⇒ the live view reads chain per view, and the STILL snapshots settled state
306
+ // (live data never re-addresses the render — that's the settled/full split).
307
+ info(state.paramHooks?.augmentHook
308
+ ? `live data: augment hook ${state.paramHooks.augmentHook} — live view reads chain per view; stills snapshot settled state`
309
+ : 'live data: none (settled params only — the live view makes zero extra chain reads)');
310
+ // The other two param hooks (if wired) — surfaced here since this is where hooks show. Quiet when
311
+ // unset (the common case). Manage all three with `abx set-param-hooks`.
312
+ if (state.paramHooks?.configureHook || state.paramHooks?.transferHook) {
313
+ const parts = [];
314
+ if (state.paramHooks.configureHook)
315
+ parts.push(`configure ${state.paramHooks.configureHook}`);
316
+ if (state.paramHooks.transferHook)
317
+ parts.push(`transfer ${state.paramHooks.transferHook}`);
318
+ info(`param hooks: ${parts.join(' · ')} ${dim('(configure = write-time veto · transfer = ownership-change lifecycle; manage with `abx set-param-hooks`)')}`);
319
+ }
320
+ // This checks THIS machine's store. A render PUBLISHED to a hosted resolver (locator bridge) lives
321
+ // on that resolver, NOT here — so a "not found" below can be a false negative for a hosted drop.
322
+ // `abx verify <addr> --remote <resolver>` probes what the resolver actually serves (the truthful check).
323
+ if (minted.length)
324
+ info(dim(`render check is against THIS node's store; for a HOSTED drop use \`abx verify ${address} --remote <resolver>\``));
325
+ // ONE line per outcome, not per token. This printed the same full-sentence advisory 32 times on a
326
+ // 32-token project (~4KB of identical text) and pushed the four lines that answer "did my deploy
327
+ // work" off the top of the screen; at a 1000-token supply it is unreadable. The per-token detail
328
+ // that survives is the token LIST, which is the only part that differs.
329
+ const missing = [];
330
+ let present = 0;
331
+ for (const token of minted) {
332
+ const { found } = await currentRenderArtifact(client, state, token, storageForRender, 'image');
333
+ if (found)
334
+ present++;
335
+ else
336
+ missing.push(String(token.tokenId));
337
+ }
338
+ verifyReport.renders = {
339
+ minted: minted.length,
340
+ present,
341
+ missing, // token ids, so a caller can re-render exactly these
342
+ // Named for what it IS: this node's store. A render PUBLISHED to a hosted resolver lives
343
+ // there, not here, so `missing` is a false negative for a hosted drop — hence the scope.
344
+ scope: "this node's store",
345
+ };
346
+ emit(verifyReport);
347
+ if (present)
348
+ ok(`${present}/${minted.length} minted token(s): real render present (in this node's store)`);
349
+ if (missing.length) {
350
+ renderGap = true;
351
+ const ids = missing.length > 12 ? `${missing.slice(0, 12).join(', ')}, …+${missing.length - 12} more` : missing.join(', ');
352
+ console.log(` ${c.orange}⚠${c.reset} ${missing.length}/${minted.length} token(s) have no render in THIS node's store ${dim(`(#${ids})`)}`);
353
+ console.log(` ${dim('published to a hosted resolver? check there:')} ${bold(`abx verify ${address} --remote <resolver>`)}`);
354
+ console.log(` ${dim('else render them:')} ${bold(`abx render ${address}`)} ${dim('(once) ·')} ${bold('abx effects')} ${dim('(continuous)')}`);
355
+ }
356
+ }
357
+ // The on-chain URI lane (a non-zero tokenURIRenderer, or an animation field pointing at the
358
+ // generator): read the generator's honesty surface — onChainStatus (branch · chain-complete ·
359
+ // unresolved refs · URL budget) — then decode tokenURI straight from the contract and report
360
+ // what the animation_url actually is. All eth_calls; no server, no local store involved.
361
+ if (hasOnChainUriLane(state)) {
362
+ const client = makePublicClient({ chainKey: CHAIN });
363
+ try {
364
+ const report = await onChainUriReport(client, state, flags.generator);
365
+ const { status, probe } = report;
366
+ verifyReport.onChainUri = jsonSafe({
367
+ generator: report.generator,
368
+ tokenURIRenderer: state.tokenURIRenderer ?? null,
369
+ branch: status.branchName,
370
+ chainComplete: !!status.chainComplete,
371
+ unresolvedRefs: [...status.unresolvedRefs],
372
+ urlOverBudget: !!status.urlOverBudget,
373
+ probe: probe
374
+ ? { tokenId: probe.tokenId, onChainJson: !!probe.onChainJson, animation: probe.animation }
375
+ : null,
376
+ });
377
+ emit(verifyReport);
378
+ 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)'}`);
379
+ if (status.branchName === 'template') {
380
+ ok(`generator branch: template — the document assembles from the on-chain script chunks`);
381
+ if (status.chainComplete)
382
+ ok('chain-complete — every dependency resolves to proven on-chain bytes; no server, gateway, or CDN in the graph');
383
+ else
384
+ info('not chain-complete — CDN-served or unresolved dependencies (CDN entries serve fine; on-chain bytes are the durability floor)');
385
+ for (const ref of status.unresolvedRefs) {
386
+ console.log(` ${c.red}✗${c.reset} unresolved dependency ${bold(ref)} — the document carries an <!-- abx:unresolved --> marker; fix the ref or the registry pointer`);
387
+ }
388
+ }
389
+ else if (status.branchName === 'directory') {
390
+ ok('generator branch: directory — the code field emits a parameterized gateway URL');
391
+ if (status.urlOverBudget)
392
+ 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`);
393
+ else
394
+ info('URL within the 8KB budget');
395
+ }
396
+ else {
397
+ 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`);
398
+ }
399
+ if (!probe) {
400
+ info('tokenURI probe skipped — no token minted yet (mint token #0, then re-run)');
401
+ }
402
+ else if (!probe.onChainJson) {
403
+ console.log(` ${c.red}✗${c.reset} tokenURI(${probe.tokenId}) is NOT an on-chain data:application/json;base64 URI — got "${probe.uriPrefix}…"`);
404
+ }
405
+ else {
406
+ ok(`tokenURI(${probe.tokenId}) resolves ON-CHAIN: data:application/json;base64 — decoded from the contract, no server`);
407
+ const a = probe.animation;
408
+ if (a.form === 'data-html') {
409
+ if (a.marker)
410
+ 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`);
411
+ else
412
+ ok(`animation_url: data:text/html;base64 — ${a.bytes} bytes of HTML, decoded (the full document, inline)`);
413
+ }
414
+ else if (a.form === 'url') {
415
+ ok(`animation_url: ${a.url} ${dim('(directory branch — the parameterized gateway URL, landed verbatim)')}`);
416
+ }
417
+ else if (a.form === 'data-other') {
418
+ console.log(` ${c.orange}⚠${c.reset} animation_url is a data: URI but not text/html — got "${a.prefix}…"`);
419
+ }
420
+ else {
421
+ console.log(` ${c.orange}⚠${c.reset} no animation_url in the on-chain JSON — the generator field may be missing or unrenderable`);
422
+ }
423
+ }
424
+ // The READ-side envelope. Params enumerate on-chain, so the write side is unbounded — but
425
+ // tokenURI and tokenData assemble EVERY enumerated param per call, and that is what grows.
426
+ // ~64 keys is the documented design envelope; hard failure only nears at several hundred.
427
+ // Advisory (never a ✗) — a big surface is a legitimate choice, it just isn't free.
428
+ const set = await readSetParamKeys(client, state.address, probe ? BigInt(probe.tokenId) : undefined);
429
+ if (set) {
430
+ const total = new Set([...set.contract, ...set.token]).size;
431
+ if (total > 64) {
432
+ 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. ` +
433
+ dim('The documented design envelope is ~64; past a few hundred a public eth_call can hit its gas cap.'));
434
+ }
435
+ }
436
+ }
437
+ catch (e) {
438
+ console.log(` ${c.orange}⚠${c.reset} on-chain URI check unavailable: ${e.message}`);
439
+ }
440
+ }
441
+ if (renderGap)
442
+ console.log(`\n ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}\n`);
443
+ else
444
+ console.log(anyCheck && allGood ? `\n ${g('✓ verified')} — the node serves exactly what the chain commits to.\n` : '\n');
445
+ // A byte-vs-chain mismatch is an integrity FAILURE, so fail the command. Verify's whole job is to
446
+ // answer "is what's served what the chain vouches for" — exiting 0 while printing ✗ meant nothing
447
+ // could gate on it (a script or CI would sail past a corrupted image). Deliberately narrow: a
448
+ // missing render / placeholder is a normal, expected state and still exits 0.
449
+ if (anyCheck && !allGood)
450
+ process.exitCode = 1;
451
+ }
452
+ // `abx verify <addr> --remote <url>` — verify what a HOSTED resolver actually serves (the local
453
+ // `verify` checks THIS machine's store/backend, the wrong store for a hosted drop). Probes the real
454
+ // `/…/image` route, so it accounts for the locator bridge (a 302 to ipfs/ar) exactly as a marketplace
455
+ // sees it — the truthful "did the thumbnail land?" check after a remote render.
456
+ export async function cmdVerifyRemote(address, remote) {
457
+ const base = remote.url.replace(/\/$/, '');
458
+ const chainId = resolveChain(CHAIN).id;
459
+ console.log(bold(`\n verify ${address} ${dim(`(remote → ${base})`)}`));
460
+ // A DOWN endpoint and a wrong-address endpoint are different problems with different fixes, and a
461
+ // bare `fetch failed` says neither. `abx status --remote` already gets this right — match it, or the
462
+ // two commands disagree about the same condition (a reviewer's top misdiagnosis risk).
463
+ let stateRes;
464
+ try {
465
+ stateRes = await fetch(`${base}/api/project/${address}`);
466
+ }
467
+ catch {
468
+ 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?`);
469
+ }
470
+ if (!stateRes.ok) {
471
+ throw new Error(`resolver ${base} doesn't serve ${address} (HTTP ${stateRes.status}) — register it first: abx add ${address} --remote ${remote.name?.toLowerCase() ?? base}`);
472
+ }
473
+ const state = (await stateRes.json());
474
+ info(`serving as "${state.name ?? address}"`);
475
+ // Is the resolver actively WATCHING the chain? Prove it from /api/watch (the meta the watcher
476
+ // stamps each tick) so a hosted operator who can't tail the log still sees liveness — and catches
477
+ // a silently-stalled watcher (pollAt gone stale) instead of assuming auto-updates still work.
478
+ const watch = (await fetch(`${base}/api/watch`)
479
+ .then((r) => (r.ok ? r.json() : null))
480
+ .catch(() => null));
481
+ if (watch?.watching) {
482
+ const heads = Object.entries(watch.chains).map(([ck, s]) => `${ck} @ block ${s.head}`).join(', ');
483
+ const ageS = watch.pollAt ? Math.round((Date.now() - Date.parse(watch.pollAt)) / 1000) : null;
484
+ const stalled = ageS !== null && ageS > Math.max(60, Math.round((Number(watch.intervalMs) / 1000) * 4));
485
+ const detail = `watching ${heads || 'chain'}${ageS !== null ? ` · last poll ${ageS}s ago` : ''}${watch.lastDeltaAt ? ` · last change ${watch.lastDeltaAt}` : ''}`;
486
+ if (stalled)
487
+ console.log(` ${c.orange}⚠${c.reset} ${detail} — watcher looks STALLED; restart \`abx serve\``);
488
+ else
489
+ ok(detail);
490
+ }
491
+ else if (watch) {
492
+ console.log(` ${dim('watcher OFF — changes land only via explicit add/index or a manual render (ABX_WATCH_INTERVAL_MS=0)')}`);
493
+ }
494
+ const minted = (state.tokens ?? []).filter((t) => t.minted);
495
+ if (minted.length === 0) {
496
+ info('no tokens minted yet — mint token #0, then re-run.');
497
+ console.log('');
498
+ return;
499
+ }
500
+ // Prefer the resolver's effect-status API (the derived 4-state readout: up-to-date · rendering ·
501
+ // failed(error) · stale). Falls back to the raw /image probe for a resolver without the route.
502
+ const statusRes = await fetch(`${base}/api/project/${address}/effects`).catch(() => null);
503
+ if (statusRes?.ok) {
504
+ const report = (await statusRes.json());
505
+ for (const t of report.tokens) {
506
+ const label = `token #${t.tokenId} ${t.effectKey}`;
507
+ if (t.status === 'up-to-date')
508
+ ok(`${label}: up to date (real render at the current state)`);
509
+ else if (t.status === 'rendering')
510
+ info(`${label}: rendering — the effects runner is on it`);
511
+ else if (t.status === 'failed')
512
+ 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}\`)`)}`);
513
+ else
514
+ 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}\`)`);
515
+ }
516
+ const { upToDate, stale, rendering, failed } = report.counts;
517
+ // RENDERS ONLY — say so. This report answers "is there a current render for each token", never
518
+ // "do the served bytes match the on-chain commitment"; those are different questions and this
519
+ // command promises the second one too. A project with no renders at all (a 1/1, an image Series)
520
+ // has nothing to be "up to date" ABOUT, so don't print a 0/N fraction — but don't let a green ✓
521
+ // here read as "the image is verified" either. Byte integrity comes from the check below.
522
+ if (report.tokens.length === 0) {
523
+ info(`renders ${dim('none for this project (a static image needs no off-chain render)')}`);
524
+ }
525
+ else {
526
+ // Lead with the count that carries the polarity: "N of M current" never inverts on a skim the
527
+ // way "0/M up to date" does.
528
+ const summary = `${upToDate} of ${minted.length} token(s) current${rendering ? ` · ${rendering} rendering` : ''}${stale ? ` · ${stale} stale` : ''}${failed ? ` · ${failed} FAILED` : ''}`;
529
+ if (failed || stale)
530
+ console.log(` ${c.orange}⚠${c.reset} renders: ${summary} ${dim('— live view animates regardless; only the static thumbnail is affected.')}`);
531
+ else
532
+ ok(`renders: ${summary}`);
533
+ }
534
+ await reportRemoteByteIntegrity(address, remote, base);
535
+ return;
536
+ }
537
+ let gap = false;
538
+ for (const t of minted) {
539
+ const img = await fetch(`${base}/t/${chainId}/${address}/${t.tokenId}/image`, { redirect: 'manual' });
540
+ const loc = img.headers.get('location');
541
+ const ct = img.headers.get('content-type') ?? '';
542
+ if (img.status >= 300 && img.status < 400 && loc)
543
+ ok(`token #${t.tokenId} image: real render — resolver 302s to ${loc}`);
544
+ else if (img.status === 200 && !/svg/i.test(ct))
545
+ ok(`token #${t.tokenId} image: real render present (${ct})`);
546
+ else {
547
+ gap = true;
548
+ 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`);
549
+ }
550
+ }
551
+ console.log(gap
552
+ ? ` ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}`
553
+ : ` ${g('✓ thumbnails are real renders')} ${dim('— served straight from the resolver.')}`);
554
+ await reportRemoteByteIntegrity(address, remote, base);
555
+ }
556
+ /**
557
+ * The half `abx verify --remote` was missing: do the served BYTES still hash to the on-chain
558
+ * commitment? Everything above it checks renders (is a thumbnail current, is it a placeholder) — a
559
+ * different question, and a green ✓ there was reading as "the image is correct" when the bytes could
560
+ * genuinely mismatch.
561
+ *
562
+ * The service is the right place to answer it: it holds both the bytes and the chain, and it already
563
+ * exposes exactly this check (`GET /api/project/:addr/verify`, bearer-gated because it triggers
564
+ * outbound fetches). When we can't reach that — no credential, or an older node — say plainly that
565
+ * byte integrity was NOT checked rather than leaving the ✓ above to imply it was.
566
+ */
567
+ export async function reportRemoteByteIntegrity(address, remote, base) {
568
+ if (!remote.token) {
569
+ 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.`);
570
+ return;
571
+ }
572
+ let report;
573
+ try {
574
+ const res = await fetch(`${base}/api/project/${address}/verify`, { headers: { authorization: `Bearer ${remote.token}` } });
575
+ if (res.status === 401 || res.status === 403) {
576
+ 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).`);
577
+ return;
578
+ }
579
+ if (!res.ok) {
580
+ // Spec'd as part of `abx-token-api/v1`, so a conforming service has it — but say it neutrally:
581
+ // this is equally "an older self-hosted node" and "a provider that didn't implement it".
582
+ warn(`byte integrity NOT checked — ${base} serves no /api/project/…/verify route (HTTP ${res.status}). ` +
583
+ `Your own node? Redeploy it (\`abx deploy-resolver\`). A provider's? It's part of abx-token-api/v1 — ask them for it. ` +
584
+ `Meanwhile ${bold(`abx verify ${address}`)} checks the bytes on a node that holds them.`);
585
+ return;
586
+ }
587
+ report = (await res.json());
588
+ }
589
+ catch {
590
+ warn(`byte integrity NOT checked — couldn't reach ${base}'s verify route.`);
591
+ return;
592
+ }
593
+ const checked = (report.tokens ?? []).filter((t) => t.checks.length > 0);
594
+ if (checked.length === 0) {
595
+ info(`bytes ${dim('no on-chain byte commitment to check (this project commits no image hash)')}`);
596
+ return;
597
+ }
598
+ const bad = checked.filter((t) => t.checks.some((k) => !k.verified));
599
+ if (bad.length === 0) {
600
+ ok(`bytes: ${checked.length} token(s) hash-match their on-chain commitment ${dim('(what the service serves IS what the chain vouches for)')}`);
601
+ return;
602
+ }
603
+ const spec = remote.name?.toLowerCase() ?? base;
604
+ 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 ` +
605
+ `${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:`);
606
+ info(`durable bytes exist (ipfs://, ar://) but weren't bridged → ${bold(`abx add ${address} --remote ${spec}`)} forwards the locator, then re-run this.`);
607
+ 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.`);
608
+ process.exitCode = 1; // same rule as the local lane: an integrity mismatch fails the command
609
+ }
610
+ // ── state: a one-glance operational snapshot of a deployed contract, read straight from chain ──
611
+ // Read-only (no tx, no local index). Series-only getters revert on a 1/1, so each read is defensive
612
+ // → the same command works for both. The agent-friendly "what's the state before/after an op" call.
613
+ export const STATE_ABI = [
614
+ { type: 'function', name: 'owner', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
615
+ { type: 'function', name: 'totalSupply', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
616
+ { type: 'function', name: 'nextTokenId', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
617
+ { type: 'function', name: 'maxInvocations', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
618
+ { type: 'function', name: 'paused', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
619
+ { type: 'function', name: 'minter', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
620
+ { type: 'function', name: 'primaryPayee', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
621
+ { type: 'function', name: 'tokenURIRenderer', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
622
+ { type: 'function', name: 'royaltyInfo', stateMutability: 'view', inputs: [{ type: 'uint256' }, { type: 'uint256' }], outputs: [{ type: 'address' }, { type: 'uint256' }] },
623
+ // ERC-721C (creator token) — ERC-165 advertises ICreatorToken ONLY when enrolled at deploy.
624
+ { type: 'function', name: 'supportsInterface', stateMutability: 'view', inputs: [{ type: 'bytes4' }], outputs: [{ type: 'bool' }] },
625
+ { type: 'function', name: 'getTransferValidator', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
626
+ ];
627
+ export async function cmdState(address, flags) {
628
+ if (!address || address.startsWith('--')) {
629
+ console.error('usage: abx state <address> — a read-only, on-chain operational snapshot\n');
630
+ process.exitCode = 1;
631
+ return;
632
+ }
633
+ // `--json` emits the same facts as data (B19: a value a program needs must be obtainable without
634
+ // parsing prose). The human narration below is unchanged and rides stderr under --json.
635
+ return withJson(flags, async (emit) => cmdStateBody(address, flags, emit));
636
+ }
637
+ export async function cmdStateBody(address, flags, emit) {
638
+ const publicClient = makePublicClient({ chainKey: CHAIN });
639
+ // getter absent (extension not composed) or contract has no code → undefined, never a throw.
640
+ const read = (functionName, args = []) => tryReadContract(publicClient, { address, abi: STATE_ABI, functionName, args });
641
+ const [owner, totalSupply, nextTokenId, maxInvocations, paused, minter, primaryPayee, renderer, royalty, creatorToken, transferValidator] = await Promise.all([
642
+ read('owner'),
643
+ read('totalSupply'),
644
+ read('nextTokenId'),
645
+ read('maxInvocations'),
646
+ read('paused'),
647
+ read('minter'),
648
+ read('primaryPayee'),
649
+ read('tokenURIRenderer'),
650
+ read('royaltyInfo', [0n, 10000n]),
651
+ read('supportsInterface', [CREATOR_TOKEN_INTERFACE_ID]),
652
+ read('getTransferValidator'),
653
+ ]);
654
+ if (owner === undefined && totalSupply === undefined) {
655
+ throw new Error(`no ABX contract state at ${address} on ${CHAIN} (no code, or not an ABX token).`);
656
+ }
657
+ const isSeries = maxInvocations !== undefined; // series-only getter
658
+ const addrOrNone = (a) => (a && a !== zeroAddress ? a : dim('none'));
659
+ // The payload mirrors what is printed, with `undefined` (getter absent) kept distinct from a zero
660
+ // address (present and deliberately unset) — collapsing those is what makes a readout lie.
661
+ const zeroToNull = (a) => (a === undefined ? null : a === zeroAddress ? null : a);
662
+ const payload = jsonSafe({
663
+ address,
664
+ chain: CHAIN,
665
+ chainId: resolveChain(CHAIN).id,
666
+ type: isSeries ? 'series' : '1of1',
667
+ owner: owner ?? null,
668
+ totalSupply: totalSupply ?? null,
669
+ nextTokenId: nextTokenId ?? null,
670
+ maxInvocations: maxInvocations ?? null,
671
+ paused: paused ?? null,
672
+ minter: zeroToNull(minter),
673
+ primaryPayee: zeroToNull(primaryPayee),
674
+ tokenURIRenderer: zeroToNull(renderer),
675
+ onChainTokenUri: !!(renderer && renderer !== zeroAddress),
676
+ royalty: royalty ? { receiver: royalty[0], bps: Number(royalty[1]) } : null,
677
+ creatorToken: !!creatorToken,
678
+ transferValidator: creatorToken ? zeroToNull(transferValidator) : null,
679
+ params: { governed: [], contractScopeSet: [] },
680
+ });
681
+ emit(payload);
682
+ console.log(bold(`\n ${isSeries ? 'Series' : '1/1'} state`) + dim(` ${address} · ${CHAIN}`));
683
+ info(`owner ${owner ?? dim('?')}`);
684
+ if (isSeries) {
685
+ // Fully minted (totalSupply == maxInvocations) ⇒ fixed supply exhausted: the collection is
686
+ // COMPLETE and `unpause` is moot (nothing left for anyone to mint). Say so, so a fully-minted
687
+ // paused series doesn't read as "unfinished, go unpause".
688
+ const soldOut = totalSupply !== undefined && maxInvocations !== undefined && totalSupply >= maxInvocations;
689
+ info(`supply ${totalSupply ?? 0n} / ${maxInvocations} minted ${dim(soldOut ? '(complete — fixed supply fully minted)' : `(nextTokenId ${nextTokenId ?? 0n})`)}`);
690
+ 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')}`}`);
691
+ info(`minter ${addrOrNone(minter)}${minter && minter !== zeroAddress ? '' : dim(' (owner-only)')}`);
692
+ info(`primary payee ${addrOrNone(primaryPayee)}`);
693
+ }
694
+ else {
695
+ info(`supply ${totalSupply ?? 0n} / 1 minted`);
696
+ }
697
+ if (royalty)
698
+ info(`royalty ${Number(royalty[1]) / 100}% → ${royalty[0]}`);
699
+ info(`renderer ${renderer && renderer !== zeroAddress ? `on-chain (${renderer})` : dim('off-chain (stored URI base / override)')}`);
700
+ // ERC-721C: shown ONLY for an enrolled collection — an unenrolled token is a plain ERC-721
701
+ // (indistinguishable from a pre-721C token) and shows nothing new here.
702
+ if (creatorToken) {
703
+ info(`721C validator ${transferValidator && transferValidator !== zeroAddress ? transferValidator : `${c.orange}suspended${c.reset} ${dim('(zero — enforcement off; re-enable with `abx set-transfer-validator`)')}`}`);
704
+ }
705
+ // The PostParam surface, read straight from chain: `paramSchemaKeys()` is the DECLARED (governed)
706
+ // set — append-only, and it includes keys declared but never yet written, which nothing off-chain
707
+ // could otherwise discover — and `contractParamKeys()` is the collection-scope set values. Both
708
+ // are maintained by the contract's own write paths, so there is no list to drift. Read-only,
709
+ // best-effort, and skipped entirely for a project that has none (a 1/1, a legacy pre-enumeration
710
+ // project, or a code project with no params).
711
+ try {
712
+ const keys = (await readParamSchemaKeys(publicClient, address)) ?? [];
713
+ const rows = keys.length
714
+ ? await Promise.all(keys.map(async (key) => ({ key, s: await readParamSchema(publicClient, address, key) })))
715
+ : [];
716
+ const governed = rows.filter((r) => r.s.exists);
717
+ const now = Math.floor(Date.now() / 1000);
718
+ payload.params.governed = jsonSafe(governed.map(({ key, s }) => ({
719
+ key,
720
+ // Canonical NAMES, not the raw enum indices — a caller must not have to know the Solidity
721
+ // enum's ordering to read this (the same reason `retired` is a boolean below).
722
+ paramType: PARAM_TYPES[s.paramType] ?? String(s.paramType),
723
+ auth: AUTH_OPTIONS[s.auth] ?? String(s.auth),
724
+ authAddress: s.authAddress === zeroAddress ? null : s.authAddress,
725
+ lockAfter: s.lockAfter,
726
+ // A past lockAfter IS how a param is retired — surfaced as a boolean so a caller doesn't
727
+ // have to re-derive the convention from a timestamp.
728
+ retired: s.lockAfter !== 0 && s.lockAfter < now,
729
+ selectOptions: s.selectOptions,
730
+ })));
731
+ emit(payload);
732
+ if (governed.length) {
733
+ console.log(`\n ${bold('PostParams')} ${dim(`${governed.length} governed · collectors/artist set these; the generator injects them`)}`);
734
+ for (const { key, s } of governed) {
735
+ const retired = s.lockAfter !== 0 && s.lockAfter < now;
736
+ const locks = s.lockAfter !== 0 && !retired ? dim(` locks ${new Date(s.lockAfter * 1000).toISOString().slice(0, 10)}`) : '';
737
+ const tag = retired ? ` ${c.orange}retired${c.reset}${dim(' — writes revert')}` : locks;
738
+ 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}`);
739
+ }
740
+ }
741
+ // Collection-scope values (every token sees these). `seed` is per-token and never enumerated —
742
+ // it is a tokenData coordinate, read directly.
743
+ const set = await readSetParamKeys(publicClient, address);
744
+ if (set?.contract.length) {
745
+ payload.params.contractScopeSet = [...set.contract].sort();
746
+ emit(payload);
747
+ info(`${dim('contract-scope params set:')} ${[...set.contract].sort().join(', ')} ${dim('(apply to every token)')}`);
748
+ }
749
+ }
750
+ catch {
751
+ /* best-effort — a non-code project has no params surface to read */
752
+ }
753
+ console.log('');
754
+ }
755
+ /**
756
+ * `abx status [address] [--remote [name|url]] [--watch]` — INDEXING status: where a project sits in
757
+ * the lifecycle (`queued | backfilling | live | stale | failed`) and how far behind head it is.
758
+ *
759
+ * One vocabulary for both sides of the membrane, which is the point: bare = this node, `--remote` =
760
+ * ask the service, and the same five words either way. Distinct from `abx state <address>`, which
761
+ * reads the CHAIN (owner, royalty, locks) and knows nothing about who is serving it.
762
+ */
763
+ export async function cmdStatus(address, flags) {
764
+ // `abx status --remote <name>` has no address — without this guard the flag itself lands in
765
+ // rest[0] and gets sent as the address path segment (the same guard every other command applies).
766
+ if (address?.startsWith('--'))
767
+ address = undefined;
768
+ const remote = remoteFlag(flags);
769
+ if (remote)
770
+ return cmdStatusRemote(address, remote, flags);
771
+ const indexer = localIndexer();
772
+ const regs = indexer.store.listRegistrations();
773
+ if (address) {
774
+ const reg = indexer.store.getRegistration(address);
775
+ if (!reg)
776
+ 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>\``);
777
+ const s = indexer.getProject(address);
778
+ const row = indexer.indexStatus(address);
779
+ const head = indexer.store.getMeta(`watch:${reg.chainKey}:head`);
780
+ // Don't print the address twice when there's no name to lead with.
781
+ console.log(s?.name ? `\n ${bold(s.name)} ${dim(address)}` : `\n ${bold(address)}`);
782
+ statusRow('status', statusLine({
783
+ chainId: resolveChain(reg.chainKey).id,
784
+ address,
785
+ status: row.status,
786
+ fromBlock: reg.fromBlock,
787
+ toBlock: s?.toBlock ?? null,
788
+ headBlock: head,
789
+ eventCount: s?.eventCount ?? 0,
790
+ tokenCount: s?.tokens.length ?? 0,
791
+ mintedCount: 0,
792
+ reconstructedAt: s?.reconstructedAt ?? null,
793
+ ...(row.errorClass ? { error: { class: row.errorClass, message: row.errorMessage ?? undefined } } : {}),
794
+ }));
795
+ statusRow('floor', `${reg.fromBlock}${row.attempts ? dim(` attempts ${row.attempts}`) : ''}`);
796
+ statusRow('indexed', s ? `${s.eventCount} events · ${s.tokens.length} token(s) ${dim(`· ${s.reconstructedAt}`)}` : dim('no projection yet'));
797
+ if (!head)
798
+ statusRow('head', dim("unknown — this node isn't watching the chain (ABX_WATCH_INTERVAL_MS=0, or `abx serve` isn't running)"));
799
+ console.log('');
800
+ return;
801
+ }
802
+ console.log(bold(`\n ABX self-host node`));
803
+ info(`chain: ${CHAIN} · factory: ${factoryAddress() ?? 'none'} · storage: ${activeBackendId()} · data: ${indexer.store.path}`);
804
+ if (regs.length === 0) {
805
+ console.log(dim('\n No projects yet. `abx demo` to create one.\n'));
806
+ return;
807
+ }
808
+ console.log('');
809
+ for (const reg of regs) {
810
+ const s = indexer.getProject(reg.address);
811
+ const st = indexer.indexStatus(reg.address).status;
812
+ const mark = st === 'failed' || st === 'stale' ? `${c.orange}●${c.reset}` : s ? g('●') : dim('○');
813
+ const tail = s ? `${s.eventCount} events` : dim('(registered, not indexed)');
814
+ console.log(` ${mark} ${s?.name ?? reg.address} ${dim(reg.address)} ${tail} ${statusLabel(st)}`);
815
+ }
816
+ console.log(dim(`\n one project in detail: abx status <address>\n`));
817
+ }
818
+ /** The `--remote` half of {@link cmdStatus}: one project, or the roll-up for every project the token
819
+ * can see. `--watch` tails until everything reaches a terminal state. */
820
+ export async function cmdStatusRemote(address, remote, flags) {
821
+ requireRemoteToken(remote);
822
+ const client = serviceClient(remote);
823
+ const chainId = resolveChain(CHAIN).id;
824
+ const watch = flags.watch !== undefined;
825
+ const spec = remote.name ? remote.name.toLowerCase() : remote.source === 'default' ? '' : remote.url;
826
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(indexing status as the service reports it)')}`);
827
+ if (address) {
828
+ for (;;) {
829
+ let s;
830
+ try {
831
+ s = await client.projectStatus(chainId, address);
832
+ }
833
+ catch (err) {
834
+ throw describeRemoteError(err, remote, 'remote status');
835
+ }
836
+ console.log(`\n ${bold(address)}`);
837
+ statusRow('status', statusLine(s));
838
+ statusRow('floor', `${s.fromBlock}${s.attempts ? dim(` attempts ${s.attempts}`) : ''}`);
839
+ statusRow('indexed', `${s.eventCount} events · ${s.tokenCount} token(s)${s.reconstructedAt ? dim(` · ${s.reconstructedAt}`) : ''}`);
840
+ // Spell out what `watching` MEANS on the line itself — a bare `no` sent a reviewer to a
841
+ // different command's output to find out whether it was a problem (it usually isn't).
842
+ if (s.watcher) {
843
+ statusRow('watching', s.watcher.watching
844
+ ? `${g('yes')}${s.watcher.head ? dim(` · head ${s.watcher.head}`) : ''}${dim(' — it tails new blocks, so on-chain changes land on their own')}`
845
+ : `${dim('no')} ${dim('— this service updates on an explicit add/index, not by tailing new blocks (normal for many providers)')}`);
846
+ }
847
+ // Don't leave a creator staring at a red word with no next move. `failed` especially reads as
848
+ // terminal when it isn't — name what it means and the one command that follows it.
849
+ if (!watch && s.status !== 'live') {
850
+ if (s.status === 'failed' && s.error)
851
+ statusRow('what now', indexErrorAction(s.error.class));
852
+ statusRow('follow', dim(`this is not final — ${bold(`abx status ${address} --remote${spec ? ` ${spec}` : ''} --watch`)} tails it until it settles`));
853
+ }
854
+ console.log('');
855
+ if (!watch || s.status === 'live' || s.status === 'failed')
856
+ return;
857
+ await sleep(3000);
858
+ }
859
+ }
860
+ for (;;) {
861
+ let projects;
862
+ try {
863
+ projects = await client.listProjects();
864
+ }
865
+ catch (err) {
866
+ throw describeRemoteError(err, remote, 'remote status');
867
+ }
868
+ if (projects.length === 0) {
869
+ console.log(dim('\n no projects visible to this token\n'));
870
+ return;
871
+ }
872
+ console.log('');
873
+ for (const p of projects) {
874
+ console.log(` ${g('●')} ${p.name ?? p.label ?? p.address} ${dim(p.address)} ` +
875
+ `${p.status ? statusLabel(p.status) : dim('status not reported')}` +
876
+ `${p.error ? ` ${c.orange}${p.error.class}${c.reset}` : ''} ${dim(`${p.tokenCount ?? '?'} token(s)`)}`);
877
+ }
878
+ console.log(`\n ${rollUp(projects)}\n`);
879
+ const settled = projects.every((p) => !p.status || p.status === 'live' || p.status === 'failed');
880
+ if (!watch || settled)
881
+ return;
882
+ await sleep(3000);
883
+ }
884
+ }
885
+ // ── forget ────────────────────────────────────────────────────────────────--
886
+ // Drop a project this node tracks (registration + projection) — for cleaning up
887
+ // test/junk deploys. On-chain data is untouched; `abx add` can re-register it.
888
+ export async function cmdForget(address, flags) {
889
+ if (!address || address.startsWith('--')) {
890
+ console.error('usage: abx forget <address> [--remote [name|url]]\n');
891
+ process.exitCode = 1;
892
+ return;
893
+ }
894
+ const remote = remoteFlag(flags);
895
+ if (remote) {
896
+ requireRemoteToken(remote);
897
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(deregistering on the remote resolver — NOT this machine)')}`);
898
+ let removed;
899
+ try {
900
+ ({ removed } = await serviceClient(remote).removeProject(resolveChain(CHAIN).id, address));
901
+ }
902
+ catch (err) {
903
+ throw describeRemoteError(err, remote, 'remote forget');
904
+ }
905
+ if (removed)
906
+ ok(`remote resolver forgot ${address} — it will stop serving it. On-chain data is untouched.`);
907
+ else
908
+ console.log(dim(` ${address} wasn't registered on ${remote.url} — nothing to forget.`));
909
+ return;
910
+ }
911
+ const indexer = localIndexer();
912
+ if (!indexer.store.getRegistration(address)) {
913
+ console.log(dim(` ${address} isn't tracked by this node — nothing to forget.`));
914
+ return;
915
+ }
916
+ indexer.store.deregister(address);
917
+ ok(`forgot ${address} — dropped its registration + projection. On-chain data is untouched.`);
918
+ }
919
+ //# sourceMappingURL=project.js.map