@artblocks/abx-cli 0.1.0-alpha.10 → 0.1.0-alpha.12

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.
package/dist/main.js CHANGED
@@ -21,6 +21,8 @@
21
21
  * abx remote [<name|url>] inspect a remote service (descriptor · chains · managed rendering · your projects there)
22
22
  * abx index [<address>] re-index a project from chain (replay; --remote to nudge a remote resolver)
23
23
  * abx verify <address> re-hash served bytes vs the on-chain commitment (no server)
24
+ * abx tokenuri <address> read tokenURI(id) from the contract + decode · contracturi: the ERC-7572 collection JSON
25
+ * (the contract holds the URL — never hand-build a resolver path)
24
26
  * abx configure-param <addr> <id> <key> <value> set a governed PostParam (typed encode; any lane)
25
27
  * abx set-param-hooks <addr> wire/clear a SeriesCode's configure/augment/transfer param hooks
26
28
  * abx render <addr> [id…] effect-runner repair lane: render missing stills/traits
@@ -177,6 +179,7 @@ async function main() {
177
179
  case 'render': return cmdRender(rest[0], rest.slice(1), flags);
178
180
  case 'effects': return cmdEffects(flags);
179
181
  case 'tokenuri': return cmdTokenUri(rest[0], flags);
182
+ case 'contracturi': return cmdContractUri(rest[0], flags);
180
183
  case 'serve': return cmdServe(flags);
181
184
  // owner operations — write + sign (hot/wallet/cold lane), then re-index
182
185
  case 'mint': return cmdMint(rest[0], flags);
@@ -3610,8 +3613,15 @@ async function cmdDeployCode(flags) {
3610
3613
  // has nothing to run — point at the from-chain check instead.
3611
3614
  if (hasImageRenderer && !hasProgram)
3612
3615
  info(` ${g('nothing to run')} — verify from chain: ${bold('abx tokenuri ' + (predicted ?? '<address>'))} ${dim('(decodes name + on-chain SVG + traits)')}`);
3613
- else
3614
- info(` ${dim('stand up the runner:')} ${bold('abx deploy-effects --resolver-url ' + baseUrl)} · one-shot: ${bold('abx render ' + (predicted ?? '<address>') + (onChainUri && hasImageBase ? '' : ' --remote ' + baseUrl))} · verify: ${bold('abx verify ' + (predicted ?? '<address>'))}`);
3616
+ else {
3617
+ const remoteRender = !(onChainUri && hasImageBase);
3618
+ info(` ${dim('stand up the runner:')} ${bold('abx deploy-effects --resolver-url ' + baseUrl)} · one-shot: ${bold('abx render ' + (predicted ?? '<address>') + (remoteRender ? ' --remote ' + baseUrl : ''))} · verify: ${bold('abx verify ' + (predicted ?? '<address>'))}`);
3619
+ // Rendering against a resolver you don't share a disk with means YOU hold the bytes and it
3620
+ // holds the URL — so say now which backend that needs, rather than letting the render fail.
3621
+ if (remoteRender && !resolveBackend(storageOptions(storageOverrides(flags))).locator) {
3622
+ info(` ${dim('that render publishes a URL, so it needs a backend that can name one:')} ${bold('--backend cloud')} ${dim('(S3/R2 + public base) ·')} ${bold('ipfs')} ${dim('·')} ${bold('arweave')} ${dim('— or render co-located with the resolver (`abx effects` on its host).')}`);
3623
+ }
3624
+ }
3615
3625
  if (dirUpload)
3616
3626
  await noteStorageReadiness(storageOptions(storageOverrides(flags)), dirUpload.sizes);
3617
3627
  if (deployer && salt && !parseSaltFlag(flags.salt)) {
@@ -3889,6 +3899,10 @@ async function cmdRender(address, tokenIds, flags) {
3889
3899
  const remote = remoteFlag(flags);
3890
3900
  const resolverUrl = (remote?.url ?? process.env.ABX_RESOLVER_URL ?? resolveBaseUrl()).replace(/\/$/, '');
3891
3901
  const adminToken = remote ? requireRemoteToken(remote) : undefined;
3902
+ // Refuse the combination that can't work before launching Chromium (a laptop render against a
3903
+ // hosted resolver, on a backend with no public URL) — the render would end in a 400 either way.
3904
+ if (remote)
3905
+ requirePublishableBackend(flags, 'abx render --remote');
3892
3906
  // Co-located (no --remote): record each declared output into the shared store's artifact
3893
3907
  // registry so the local resolver's `artifacts` manifest enumerates it. Remote: the publish
3894
3908
  // lane (adminToken) records rows on the hosted resolver instead.
@@ -3913,6 +3927,41 @@ async function cmdRender(address, tokenIds, flags) {
3913
3927
  else
3914
3928
  ok(summary);
3915
3929
  }
3930
+ /**
3931
+ * The publish topology's one hard prerequisite, checked BEFORE any capture.
3932
+ *
3933
+ * A resolver that doesn't share this machine's disk serves referenced output (the still, a video, a
3934
+ * model) by **redirect** — so it needs a URL, and it refuses the bytes (`specs/protocol/effects.md →
3935
+ * Bound vs referenced`). A backend that can't name a locator therefore has no publish lane at all,
3936
+ * and every render against one would be work spent to earn a 400.
3937
+ *
3938
+ * So this refuses up front and names the ways out, rather than warning and letting the render run.
3939
+ * It deliberately does NOT pick a backend for the operator: which store holds their art — and which
3940
+ * gateway serves it — is theirs to decide. The three that work are peers, not a ranking: `https://`
3941
+ * from S3/R2, an IPFS gateway, and Arweave are all fine, because derived output is re-creatable and
3942
+ * the only real requirement is that a third party can fetch it.
3943
+ */
3944
+ function requirePublishableBackend(flags, what) {
3945
+ const backend = resolveBackend(storageOptions(storageOverrides(flags)));
3946
+ // `cloud` is the one backend that HAS a locator method and can still return null: the URL is
3947
+ // `<publicBase>/<key>`, so without a public base it can name nothing. Catch that here too — it
3948
+ // would otherwise be the same failure one render later.
3949
+ if (backend.id === 'cloud' && !backend.publicBase) {
3950
+ throw new Error(`${what} hands the resolver a URL for each render, but the 'cloud' backend has no public read base — ` +
3951
+ `set ${bold('ABX_S3_PUBLIC_BASE')} to the bucket's public URL (an R2/CloudFront/S3-website base), ` +
3952
+ `or use ${bold('--backend ipfs')} / ${bold('--backend arweave')}, or render co-located with the resolver.`);
3953
+ }
3954
+ if (backend.locator)
3955
+ return;
3956
+ const { backend: name, source } = backendResolution(storageOverrides(flags));
3957
+ throw new Error(`${what} publishes each render to a resolver that doesn't share this machine's disk, but the '${name}' backend ` +
3958
+ `(${source === 'default' ? 'the default' : `from ${source}`}) can't produce a URL for what it stores — so the resolver would have nothing to serve.\n` +
3959
+ ` Point it at a backend that can (equal options — pick on cost/ops, not durability dogma):\n` +
3960
+ ` ${bold('--backend cloud')} S3 / R2 / B2 with a public base (ABX_S3_* + ABX_S3_PUBLIC_BASE)\n` +
3961
+ ` ${bold('--backend ipfs')} Pinata or your own Kubo + a public gateway (PINATA_JWT / ABX_IPFS_*)\n` +
3962
+ ` ${bold('--backend arweave')} pay-once permanent (uploads under 100KiB are free, no setup)\n` +
3963
+ ` …or run co-located instead: ${bold('abx effects')} on the same host as ${bold('abx serve')}, sharing one backend and no remote token.`);
3964
+ }
3916
3965
  /**
3917
3966
  * `abx effects` — run the reference effect runner LOCALLY, in-process (the local counterpart to
3918
3967
  * `abx deploy-effects`, which scaffolds a HOSTED runner). This is what makes a param change / new
@@ -3932,6 +3981,8 @@ async function cmdEffects(flags) {
3932
3981
  // the shared artifact registry so the resolver's `artifacts` manifest enumerates it. With an
3933
3982
  // admin token, the publish lane records rows on the hosted resolver instead.
3934
3983
  const localStore = adminToken ? null : new SqliteStore();
3984
+ if (adminToken)
3985
+ requirePublishableBackend(flags, 'abx effects against a remote resolver');
3935
3986
  const { EffectRunner, renderEffect } = await loadEffects();
3936
3987
  const runner = new EffectRunner({
3937
3988
  resolverUrl,
@@ -4397,6 +4448,101 @@ async function cmdTokenUri(address, flags) {
4397
4448
  console.log(` ${uri.slice(0, 240)}${uri.length > 240 ? dim(`… (${uri.length} chars)`) : ''}\n`);
4398
4449
  }
4399
4450
  }
4451
+ // ── contracturi ──────────────────────────────────────────────────────────────
4452
+ /**
4453
+ * `abx contracturi <address>` — the collection-level counterpart of `tokenuri`: read
4454
+ * `contractURI()` (ERC-7572) STRAIGHT FROM THE CONTRACT, then FOLLOW it and decode the JSON.
4455
+ *
4456
+ * Why this exists, and why it follows the URL: the resolver's route grammar is committed
4457
+ * on-chain at deploy (`contractURIBase` = `<baseUrl>/c`), so the contract — not a doc, not a
4458
+ * service descriptor — is the source of truth for where a collection's metadata lives. Without
4459
+ * this command the only way to look was to hand-build the URL from memory of the grammar, and a
4460
+ * guessed path that 404s reads exactly like a broken service. Ask the chain instead.
4461
+ */
4462
+ async function cmdContractUri(address, _flags) {
4463
+ if (!address || address.startsWith('--')) {
4464
+ console.error('usage: abx contracturi <address>\n');
4465
+ process.exit(1);
4466
+ }
4467
+ if (!/^0x[0-9a-fA-F]{40}$/.test(address)) {
4468
+ console.error(`abx contracturi: '${address}' isn't a 0x contract address.\n`);
4469
+ process.exit(1);
4470
+ }
4471
+ const publicClient = makePublicClient({ chainKey: CHAIN });
4472
+ let uri;
4473
+ try {
4474
+ uri = (await publicClient.readContract({
4475
+ address,
4476
+ abi: oneOfOneImageAbi,
4477
+ functionName: 'contractURI',
4478
+ args: [],
4479
+ }));
4480
+ }
4481
+ catch {
4482
+ const code = await publicClient.getCode({ address }).catch(() => undefined);
4483
+ if (!code || code === '0x') {
4484
+ // Name the endpoint we actually asked — "no contract here" and "you're pointed at the wrong
4485
+ // node" are indistinguishable otherwise (same reasoning as `tokenuri`).
4486
+ console.error(`abx contracturi: no contract at ${address} on ${CHAIN} (asked ${redactRpcUrl(resolveRpcUrl(CHAIN))}) — ` +
4487
+ `double-check the address, and that this endpoint is the network you deployed to. ` +
4488
+ `If you JUST deployed, give the tx a block or two to mine.\n`);
4489
+ }
4490
+ else {
4491
+ console.error(`abx contracturi: ${address} didn't return a contractURI — it may not be an ABX/ERC-7572 contract, or ` +
4492
+ `— on a large on-chain contractURI — an unauthenticated RPC read hit its gas cap (try a wallet-connected / high-gas RPC).\n`);
4493
+ }
4494
+ process.exit(1);
4495
+ }
4496
+ console.log(`\n ${bold('contractURI()')} ${dim(`— read directly from ${address} on ${CHAIN}`)}`);
4497
+ if (!uri) {
4498
+ console.error(`\n ${bold('empty')} — this contract has no contractURI set: no collection-level metadata to resolve. ` +
4499
+ `Set one with ${bold(`abx set-contract-uri ${address} --uri <base>`)}, or point it at the canonical renderer for the on-chain lane.\n`);
4500
+ process.exit(1);
4501
+ }
4502
+ const onChain = decodeOnChainJson(uri);
4503
+ if (onChain) {
4504
+ info('resolution: ON-CHAIN (data: URI from the renderer — no server in the path)');
4505
+ console.log(onChain.split('\n').map((l) => ' ' + l).join('\n') + '\n');
4506
+ return;
4507
+ }
4508
+ console.log(` ${dim('resolves to')} ${uri}`);
4509
+ if (!/^https?:\/\//i.test(uri)) {
4510
+ // ipfs:// / ar:// — a locator, not something we can fetch without choosing a gateway. Print it
4511
+ // rather than silently picking one; the creator's gateway choice is theirs.
4512
+ info(`not an http(s) URL — a ${uri.split(':')[0]}: locator needs a gateway to fetch. Nothing more to read from here.`);
4513
+ console.log('');
4514
+ return;
4515
+ }
4516
+ let body;
4517
+ try {
4518
+ const res = await fetch(uri, { headers: { accept: 'application/json' } });
4519
+ body = await res.text();
4520
+ if (!res.ok) {
4521
+ // The URL came FROM THE CHAIN, so a bad status here is genuinely about the service (or the
4522
+ // contract pointing somewhere stale) — never a mistyped path. Say which, so nobody re-guesses.
4523
+ console.error(`\n ${bold(`HTTP ${res.status}`)} from the contract's own contractURI — the URL is correct by construction (it came from ` +
4524
+ `${address} on-chain), so this is the SERVICE, not the path. Likely: the project isn't registered on that resolver ` +
4525
+ `(${bold('abx add ' + address + ' --remote')}), the node serves a different chain, or it's down. ` +
4526
+ `Response: ${body.slice(0, 200)}\n`);
4527
+ process.exit(1);
4528
+ }
4529
+ }
4530
+ catch (e) {
4531
+ console.error(`\n couldn't reach ${uri} — ${e.message}. The URL is what the contract commits to, so check that the ` +
4532
+ `host is up and publicly reachable (a localhost base URL resolves for no one but this machine).\n`);
4533
+ process.exit(1);
4534
+ }
4535
+ info('resolution: OFF-CHAIN (fetched from the URL the contract commits to)');
4536
+ try {
4537
+ console.log(JSON.stringify(JSON.parse(body), null, 2)
4538
+ .split('\n')
4539
+ .map((l) => ' ' + l)
4540
+ .join('\n') + '\n');
4541
+ }
4542
+ catch {
4543
+ console.log(` ${dim('(not JSON)')} ${body.slice(0, 400)}\n`);
4544
+ }
4545
+ }
4400
4546
  // ── serve ──────────────────────────────────────────────────────────────────--
4401
4547
  async function cmdServe(flags) {
4402
4548
  const port = Number(flags.port ?? process.env.ABX_PORT ?? DEFAULT_PORT);
@@ -5222,10 +5368,16 @@ async function cmdDoctor(flags) {
5222
5368
  try {
5223
5369
  const backend = resolveBackend(storageOptions());
5224
5370
  const h = await backend.health?.();
5371
+ // Whether this backend can name a public URL for what it stores decides one thing operators hit
5372
+ // later: a REMOTE effects runner needs it (the resolver serves referenced output by redirect, so
5373
+ // it takes a URL and refuses bytes). Co-located rendering doesn't care — hence a note, not a fail.
5374
+ const publishable = backend.locator
5375
+ ? 'can publish to a remote resolver'
5376
+ : 'local-only — a remote effects runner needs cloud/ipfs/arweave (co-located rendering is fine)';
5225
5377
  if (h)
5226
- check('storage', h.ok, `${backend.id} · ${h.detail ?? ''}`);
5378
+ check('storage', h.ok, `${backend.id} · ${h.detail ?? ''} · ${publishable}`);
5227
5379
  else
5228
- check('storage', true, `${backend.id} · configured`);
5380
+ check('storage', true, `${backend.id} · configured · ${publishable}`);
5229
5381
  }
5230
5382
  catch (err) {
5231
5383
  check('storage', false, `${backendId} · ${err.message}`);
@@ -5434,13 +5586,19 @@ async function cmdDeployEffects(flags) {
5434
5586
  const intervalMs = flags['interval-ms'] ? Number(flags['interval-ms']) : undefined;
5435
5587
  const environmentId = flags['env-id'] ?? process.env.ABX_ENVIRONMENT_ID;
5436
5588
  const storageBackend = process.env.ABX_STORAGE_BACKEND;
5437
- // A HOSTED runner must publish renders where the resolver can read them a durable locator
5438
- // (ipfs/arweave) or a shared bucket (s3). The default `fs` writes to the runner CONTAINER's disk,
5439
- // which the resolver can't see the thumbnail never clears. Flag it before scaffolding (not just
5440
- // in the artifact's comments), since it's the #1 silent misconfig for a hosted effects runner.
5589
+ // A HOSTED runner holds its own render bytes and hands the resolver a URLthat's the whole
5590
+ // topology (`specs/protocol/effects.md Bound vs referenced`). The default `fs` writes to the
5591
+ // runner CONTAINER's disk, which nothing else can reach, so there is no URL to publish: the runner
5592
+ // now REFUSES to start on that config. Scaffolding it anyway would just deploy a container that
5593
+ // exits, so this is a hard stop rather than the warning it used to be.
5441
5594
  if (!storageBackend || storageBackend === 'fs') {
5442
- warn(`ABX_STORAGE_BACKEND is ${storageBackend ? "'fs'" : 'unset (defaults to fs)'} — a HOSTED runner can't publish 'fs' renders anywhere the resolver can read ` +
5443
- `(the thumbnail will stay a placeholder). Set ABX_STORAGE_BACKEND to ${bold('ipfs')} / ${bold('arweave')} (durable locator) or ${bold('s3')} before deploying the runner.`);
5595
+ throw new Error(`ABX_STORAGE_BACKEND is ${storageBackend ? "'fs'" : 'unset (defaults to fs)'} — a hosted runner can't serve renders off its own container disk, ` +
5596
+ `and it refuses to start without a backend that can name a public URL for what it stores.\n` +
5597
+ ` Set ABX_STORAGE_BACKEND to one of (equal options — pick on cost/ops):\n` +
5598
+ ` ${bold('cloud')} (alias s3) S3 / R2 / B2 with ABX_S3_* + a public base\n` +
5599
+ ` ${bold('ipfs')} Pinata or your own Kubo + a public gateway\n` +
5600
+ ` ${bold('arweave')} pay-once permanent (<100KiB uploads are free)\n` +
5601
+ ` …then re-run. (Rendering on the resolver's own host instead? Use ${bold('abx effects')} co-located — no publish lane, no locator needed.)`);
5444
5602
  }
5445
5603
  const repoRoot = findRepoRoot();
5446
5604
  if (!repoRoot) {
@@ -5462,10 +5620,7 @@ async function cmdDeployEffects(flags) {
5462
5620
  const outDir = resolvePath(joinPath(dir, 'effects'));
5463
5621
  console.log(bold(`\n ABX Self-Host Toolkit — provision effects runner (fly)`));
5464
5622
  info(`resolver: ${bold(cleanResolver)} ${dim('(reads token state + publishes renders here)')}`);
5465
- info(`storage home: ${bold(storageBackend ?? 'fs')} ${dim('— where rendered thumbnails live; the resolver redirects/serves from here')}`);
5466
- if (!storageBackend || storageBackend === 'fs') {
5467
- warn('storage backend is fs (LOCAL) — a HOSTED runner needs a PUBLIC home (ipfs/arweave/s3) so the resolver can serve the render. Set ABX_STORAGE_BACKEND before deploying, or the runner will push raw bytes to the resolver.');
5468
- }
5623
+ info(`storage home: ${bold(storageBackend)} ${dim('— the runner HOLDS the render bytes here and publishes their URL; the resolver redirects to it and never proxies')}`);
5469
5624
  step('Write the effects artifact (self-contained — Dockerfile.effects + fly.toml + vendored source)');
5470
5625
  mkdirSync(outDir, { recursive: true });
5471
5626
  for (const f of art.files) {
@@ -5709,8 +5864,12 @@ const COMMAND_HELP = {
5709
5864
  No tokenId → sweeps all minted tokens; pass ids (${g('0 1 2')}) to target specific tokens.
5710
5865
  ${g('--force')} RE-RENDER even when the still already exists — the fix for a bad / blank / timed-out capture
5711
5866
  (the art is otherwise deterministic, so a plain render idempotent-skips an existing still). Overwrites it (+ republishes on --remote).
5712
- ${g('--remote [name|url]')} publish each render to a REMOTE resolver (the locator bridge: upload to ${bold('ABX_STORAGE_BACKEND')}, POST /v1/effect-artifacts).
5713
- Idempotent; a re-run restores a resolver that lost its volume. Needs its token (a named remote's
5867
+ ${g('--remote [name|url]')} register each render with a REMOTE resolver: the bytes go to ${bold('ABX_STORAGE_BACKEND')} (YOU hold them), and
5868
+ POST /v1/effect-artifacts hands the resolver the URL it redirects there and never proxies. Traits are the
5869
+ exception: they stitch into the token JSON, so their content (≤64KB) goes to the resolver itself.
5870
+ ${bold('Requires a backend that can name a public URL')} — ${g('--backend cloud')} (S3/R2 + public base), ${g('ipfs')}, or ${g('arweave')};
5871
+ equal options, pick on cost/ops. The default ${g('fs')} is refused up front rather than after the render.
5872
+ Idempotent; a re-run re-registers rows on a resolver that lost them. Needs its token (a named remote's
5714
5873
  ${g('ABX_REMOTE_<NAME>_TOKEN')}, else ${g('ABX_RESOLVER_ADMIN_TOKEN')}).
5715
5874
  --effects-url <url> enqueue on a running effect-runner service instead of rendering inline (else ${g('ABX_EFFECTS_URL')})
5716
5875
  ${dim('Inline (no --remote) renders on THIS machine (needs `npx playwright install chromium` once), pointing Chromium at the live')}
@@ -5727,8 +5886,10 @@ const COMMAND_HELP = {
5727
5886
  --port <n> HTTP port (default ${g('ABX_EFFECTS_PORT')} / 8788) — ${g('POST /notify')} enqueues (watcher lane) · ${g('POST /run')} sweeps synchronously (command lane)
5728
5887
  --interval-ms <n> the SAFETY-FLOOR sweep (default ${g('ABX_EFFECTS_INTERVAL_MS')} / 300000) — catches a missed notify / cold start; the watcher is the trigger
5729
5888
  --concurrency <n> parallel renders while draining (default ${g('ABX_EFFECTS_CONCURRENCY')} / 1 — Chromium is heavy; raise deliberately)
5730
- ${dim('Co-located with a local `abx serve` (same store) → no tokens needed. Against a REMOTE resolver: --remote <name|url> (its')}
5731
- ${dim('token publishes renders + reports status), or ABX_RESOLVER_URL + ABX_RESOLVER_ADMIN_TOKEN. PUBLIC runner? set ABX_EFFECTS_TOKEN — it gates /run + /notify.')}
5889
+ ${dim('Co-located with a local `abx serve` (same store) → no tokens needed, any backend works (fs included).')}
5890
+ ${dim('Against a REMOTE resolver: --remote <name|url> (its token registers renders + reports status), or ABX_RESOLVER_URL +')}
5891
+ ${dim('ABX_RESOLVER_ADMIN_TOKEN — and a backend that can name a public URL (cloud/ipfs/arweave), since the resolver takes the')}
5892
+ ${dim('URL and refuses the bytes. PUBLIC runner? set ABX_EFFECTS_TOKEN — it gates /run + /notify.')}
5732
5893
  ${dim('To HOST the runner (fly/docker), use `abx deploy-effects`.')}`,
5733
5894
  'configure-param': `
5734
5895
  ${bold('abx configure-param')} <address> <tokenId|-> <key> <value> ${dim('— set a PostParam (typed, canonical encode). Sends a tx.')}
@@ -5951,7 +6112,14 @@ const COMMAND_HELP = {
5951
6112
  ${dim(`${g('onChainStatus')} (branch · chain-complete · unresolved refs · URL budget) and decodes ${g('tokenURI')} straight from the contract.`)}`,
5952
6113
  tokenuri: `
5953
6114
  ${bold('abx tokenuri')} <address> [--token <id>] ${dim('— read tokenURI(id) straight from the contract on-chain + decode the JSON (read-only; no server).')}
5954
- ${dim('The proof a fully on-chain token self-resolves: any RPC returns the renderer-assembled metadata. Default token 0.')}`,
6115
+ ${dim('The proof a fully on-chain token self-resolves: any RPC returns the renderer-assembled metadata. Default token 0.')}
6116
+ ${dim('Collection-level (ERC-7572) counterpart:')} ${g('abx contracturi <address>')}`,
6117
+ contracturi: `
6118
+ ${bold('abx contracturi')} <address> ${dim('— read contractURI() (ERC-7572 collection metadata) from the contract, FOLLOW it, and decode (read-only).')}
6119
+ ${dim('The collection-level counterpart of')} ${g('tokenuri')}${dim('. On-chain lane: decodes the data: URI. Off-chain lane: fetches the')}
6120
+ ${dim('URL the contract itself commits to and prints the JSON.')}
6121
+ ${bold('Never hand-build a resolver URL to check this')} ${dim('— the contract holds the answer, so a URL from here is right by')}
6122
+ ${dim('construction. A bad status is then about the SERVICE (unregistered project · wrong chain · down), never a mistyped path.')}`,
5955
6123
  forget: `
5956
6124
  ${bold('abx forget')} <address> ${dim('— drop a project’s local registration + projection. On-chain data is untouched.')}
5957
6125
  ${g('--remote [name|url]')} deregister on a REMOTE resolver instead (it stops serving the project; re-add any time)`,
@@ -6022,6 +6190,7 @@ function help() {
6022
6190
  ${g('abx index')} [<address>] re-index from chain (incremental by default; ${g('--full')} forces a replay from deploy)
6023
6191
  ${g('abx verify')} <addr> re-hash served bytes vs the on-chain commitment (no server needed)
6024
6192
  ${g('abx tokenuri')} <addr> read tokenURI(0) on-chain + decode the JSON (proof a self-resolving token works)
6193
+ ${g('abx contracturi')} <addr> read contractURI() (ERC-7572 collection metadata) on-chain, follow it, decode — never hand-build the URL
6025
6194
  ${g('abx state')} <addr> one-glance on-chain snapshot: owner · supply · paused · minter · payee · royalty · renderer
6026
6195
  ${g('abx serve')} [--port ..] serve the token API + dashboard — and WATCH the chain: auto-index every registered
6027
6196
  project + notify the effects layer on change (${g('ABX_WATCH_INTERVAL_MS')}; 0 = off)