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

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
@@ -17,12 +17,14 @@
17
17
  * abx preview run a code project on localhost while it's still being made (no chain)
18
18
  * abx deploy-code deploy a code project (SeriesCode): --script <file> (on-chain template)
19
19
  * or --code-dir <dir> (build directory → ipfs/arweave `code` field)
20
+ * --resume <addr>: finish one whose setup tx failed (sends only what's missing)
20
21
  * abx add <address> register + index a project (--remote <name|url>: on a remote resolver, not this machine)
21
22
  * abx remote [<name|url>] inspect a remote service (descriptor · chains · managed rendering · your projects there)
22
23
  * abx index [<address>] re-index a project from chain (replay; --remote to nudge a remote resolver)
23
24
  * abx verify <address> re-hash served bytes vs the on-chain commitment (no server)
24
25
  * abx tokenuri <address> read tokenURI(id) from the contract + decode · contracturi: the ERC-7572 collection JSON
25
26
  * (the contract holds the URL — never hand-build a resolver path)
27
+ * abx tokens <address> every token's owner + seed + params, from chain alone (--json for the machine path)
26
28
  * abx configure-param <addr> <id> <key> <value> set a governed PostParam (typed encode; any lane)
27
29
  * abx set-param-hooks <addr> wire/clear a SeriesCode's configure/augment/transfer param hooks
28
30
  * abx render <addr> [id…] effect-runner repair lane: render missing stills/traits
@@ -34,6 +36,7 @@
34
36
  * abx migrate <addr> move a contract's off-chain state to another resolver (--from/--to)
35
37
  * abx set-token-uri <addr> · set-contract-uri · set-royalty · set-field · lock-field
36
38
  * abx storage show show byte custody (fs | cloud | ipfs | arweave); stateless — choose per command with flags
39
+ * abx storage status <loc> is a locator RETRIEVABLE yet, not just accepted? (Arweave propagation is minutes)
37
40
  * abx storage balance|topup Turbo (arweave) upload credits — view / buy by card (credits attach to the signing identity)
38
41
  * abx storage backup-key copy the managed Turbo/Arweave key (holds credits) to a safe path
39
42
  * abx status list indexed projects + node info
@@ -55,7 +58,7 @@ import { effectsArtifact, resolverArtifact } from './provision.js';
55
58
  import { mintPageArtifact, PUBLIC_RPC } from './mintpage.js';
56
59
  import { formatEther, toHex, zeroAddress } from 'viem';
57
60
  import { encodeFunctionData } from 'viem';
58
- import { DEP_RESOLUTION, deploySeedSource, deploySeriesCodeFactory, prepareCodeSetup, prepareDeploySeriesCode, resolveGenerator, resolveSeedSource, resolveSeriesCodeFactory, seriesCodeAbi, readParamSchema, seriesCodeFactoryAbi, } from '@artblocks/abx-sdk';
61
+ import { AUTH_OPTIONS, DEP_RESOLUTION, PARAM_TYPES, deploySeedSource, deploySeriesCodeFactory, listTokens, prepareCodeSetup, prepareDeploySeriesCode, resolveGenerator, resolveSeedSource, resolveSeriesCodeFactory, seriesCodeAbi, readParamSchema, seriesCodeFactoryAbi, } from '@artblocks/abx-sdk';
59
62
  import { checkRegistryDeps, dependencySetupCalls, parseDepFlag, resolveDepRegistryPointer } from './deps.js';
60
63
  import { expectedChainComplete, hasOnChainUriLane, onchainUriSetupCalls, onChainUriReport, readLegacyParamsKeys, readParamSchemaKeys, readSetParamKeys, } from './onchain-uri.js';
61
64
  import { parseFlags, positionalArgs, unknownFlags } from './flags.js';
@@ -64,6 +67,9 @@ import { analyzeScript, recommendLane } from './inspect.js';
64
67
  import { previewConfigFromFlags, previewDepTags, parsePreviewParams, shootPreview, startPreviewServer, DEFAULT_PREVIEW_PORT, PREVIEW_FLAGS } from './preview.js';
65
68
  import { parseSchemaSpecs, describeSchema } from './schema.js';
66
69
  import { copyRendererScaffold } from './scaffold.js';
70
+ import { jsonSafe, withJson } from './jsonout.js';
71
+ import { fetchServedTokenUri, prettyBody, servedOk } from './served.js';
72
+ import { planResume } from './resume.js';
67
73
  import { declinesSkillInstall } from './prompt.js';
68
74
  import { pinGas, waitForCodeAt } from './gas.js';
69
75
  import { parseSeriesTraits, looksPerTokenAttributes, parseSeriesTraitsById } from './series-traits.js';
@@ -78,7 +84,7 @@ import { uploadAndLocate } from './upload.js';
78
84
  import { assertChainId, discoverDeployBlock, deployFactory, deploySeriesFactory, deploySeries, deployOneOfOne, deployRenderer, predictRenderer, predictSeedSource, encodeTag, encodeFieldRenderer, isCodeProject, loadDotEnv, makePublicClient, makeWalletClient, oneOfOneImageAbi, oneOfOneImageFactoryAbi, seriesImageAbi, seriesImageFactoryAbi, abxMetadataRendererAbi, predictClone, probeRpcEndpoints, prepareDeployOneOfOne, prepareDeploySeries, reconstructProject, saltFor, saltGuard, resolveChain, resolveRpcUrl, redactRpcUrl, explorerUrl, DEFAULT_CHAIN_KEY, KNOWN_CHAIN_KEYS, normalizeAttributes, parseTraitPairs, resolveRecommendedTransferValidator, CREATOR_TOKEN_INTERFACE_ID, METADATA_FIELD as F, METADATA_REPRESENTATION as R, indexProgress, isAccepted, AbxIndexTimeoutError, AbxServiceError, } from '@artblocks/abx-sdk';
79
85
  import { SelfHostIndexer, SqliteStore } from '@artblocks/abx-indexer';
80
86
  import { artContentHash, currentRenderArtifact, generateArt, resolveBaseUrl, startChainWatcher, startTokenApiServer, verifyProject, watchIntervalMs, DEFAULT_PORT, } from '@artblocks/abx-token-api';
81
- import { ARWEAVE_FREE_UPLOAD_LIMIT, arweaveAddress, arweaveFunding, contentTypeFromPath, hashContent, resolveBackend, turboBalanceForAddress, turboUploadCostUsd, turboUploadWinc } from '@artblocks/abx-storage';
87
+ import { ARWEAVE_FREE_UPLOAD_LIMIT, arweaveAddress, arweaveFunding, contentTypeFromPath, hashContent, locatorStatus, resolveBackend, turboBalanceForAddress, turboUploadCostUsd, turboUploadWinc } from '@artblocks/abx-storage';
82
88
  import { cmdTransfer, cmdMint, cmdSetMinter, cmdSetMaxInvocations, cmdConfigureParam, cmdSetSchema, cmdRetireParam, cmdSetParamHooks, cmdSetDependency, cmdRemoveLastDependency, cmdSetDependencyRegistry, cmdLockDependencies, cmdSetPrimaryPayee, cmdPause, cmdUnpause, cmdRefresh, cmdSetTokenUri, cmdSetContractUri, cmdSetRoyalty, cmdSetTransferValidator, cmdSetField, cmdAttach, parseTransferValidatorValue, cmdLockField, cmdSetRenderer, cmdLockUri, cmdSetAdmin, cmdMinterConfigure, cmdMinterShow, cmdMinterBuy, computeContentPlan, envStagingSender, laneFromFlags, ONCHAIN_PROJECT_SOFT_LIMIT, parseCompress, previewImageStaging, sessionStagingSender, stageImageField, stageImageFieldsBatch, authorshipContractFields, AUTHORSHIP_DEPLOY_FIELDS, } from './ownerops.js';
83
89
  import { openWalletSession, signTx } from './signer.js';
84
90
  import { describeRemoteError, listConfiguredRemotes, misnamedRemoteVars, requireRemoteToken, resolveRemote, serviceClient, tokenSourceLabel } from './remote.js';
@@ -228,6 +234,7 @@ async function main() {
228
234
  case 'render': return cmdRender(rest[0], rest.slice(1), flags);
229
235
  case 'effects': return cmdEffects(flags);
230
236
  case 'tokenuri': return cmdTokenUri(rest[0], flags, rest.slice(1));
237
+ case 'tokens': return cmdTokens(rest[0], flags);
231
238
  case 'contracturi': return cmdContractUri(rest[0], flags);
232
239
  case 'serve': return cmdServe(flags);
233
240
  // owner operations — write + sign (hot/wallet/cold lane), then re-index
@@ -1161,6 +1168,11 @@ async function remoteLocators(address, stored, flags) {
1161
1168
  }
1162
1169
  // ── deploy (+ optionally serve) ──────────────────────────────────────────────
1163
1170
  async function cmdDeploy(flags, serveAfter) {
1171
+ // `--json`: the deployed ADDRESS is the value a program came for (B19). Narration goes to stderr,
1172
+ // so `ADDR=$(abx deploy … --json | jq -r .address)` works without parsing a success banner.
1173
+ return withJson(flags, async (emit) => cmdDeployBody(flags, serveAfter, emit));
1174
+ }
1175
+ async function cmdDeployBody(flags, serveAfter, emit) {
1164
1176
  refuseStrayFlags(flags, DEPLOY_FLAGS, 'deploy');
1165
1177
  warnSignWithoutFor(flags);
1166
1178
  const dimension = resolveDimension(flags.type ?? '1of1');
@@ -1527,6 +1539,10 @@ async function cmdDeploy(flags, serveAfter) {
1527
1539
  // values — nothing is added, inferred, or substituted between here and the on-chain tx.
1528
1540
  info(dim('the values above are exactly what a real deploy writes — nothing else is added.'));
1529
1541
  console.log(`\n ${g('dry run')} ${dim('— nothing sent, no bytes stored. Re-run without --dry-run to deploy.')}\n`);
1542
+ // A dry run DOES know the address (it is a pure function of factory+salt), so --json reports it
1543
+ // with `sent: false` rather than nothing. Note `saltPinned`: without --salt a real re-run
1544
+ // reserves a fresh salt and lands elsewhere, so a caller must not treat this as reserved.
1545
+ emit(jsonSafe({ command: 'deploy', dryRun: true, sent: false, address: predicted, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt, saltPinned: !!explicitSalt, name, symbol }));
1530
1546
  return;
1531
1547
  }
1532
1548
  // opt-in --confirm: a final y/N before the real send (no-op without --confirm; never blocks scripts)
@@ -1640,6 +1656,9 @@ async function cmdDeploy(flags, serveAfter) {
1640
1656
  blockNumber = result.blockNumber;
1641
1657
  ok(`deployed ${clone}`);
1642
1658
  }
1659
+ // Emitted the moment the address is known, and again below once minting is settled — so a crash in
1660
+ // the indexing steps that follow still yields the address of a contract that really does exist.
1661
+ emit(jsonSafe({ command: 'deploy', address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol }));
1643
1662
  step(serveAfter ? 'What the chain knows now' : 'Index it — replay the event spine from chain');
1644
1663
  const indexer = new SelfHostIndexer();
1645
1664
  // Off-chain traits ride in the registration (on-chain ones are already in the contract fields).
@@ -1775,6 +1794,9 @@ const tokenFieldOf = (tokenId, f) => ({
1775
1794
  value: f.value,
1776
1795
  });
1777
1796
  async function cmdDeploySeries(flags) {
1797
+ return withJson(flags, async (emit) => cmdDeploySeriesBody(flags, emit));
1798
+ }
1799
+ async function cmdDeploySeriesBody(flags, emit) {
1778
1800
  refuseStrayFlags(flags, DEPLOY_SERIES_FLAGS, 'deploy-series');
1779
1801
  warnSignWithoutFor(flags);
1780
1802
  const name = flags.name ?? 'ABX Series';
@@ -2115,6 +2137,7 @@ async function cmdDeploySeries(flags) {
2115
2137
  console.log(` ${bold(deploySeriesCommandLine(flags, salt))}`);
2116
2138
  }
2117
2139
  console.log(`\n ${g('dry run')} ${dim('— nothing sent, no bytes stored.')}\n`);
2140
+ emit(jsonSafe({ command: 'deploy-series', dryRun: true, sent: false, address: predicted, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt, saltPinned: !!explicitSalt, name, symbol }));
2118
2141
  return;
2119
2142
  }
2120
2143
  let clone;
@@ -2209,6 +2232,7 @@ async function cmdDeploySeries(flags) {
2209
2232
  blockNumber = result.blockNumber;
2210
2233
  ok(`deployed ${clone}`);
2211
2234
  }
2235
+ emit(jsonSafe({ command: 'deploy-series', address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol }));
2212
2236
  step('Index it — replay the event spine from chain');
2213
2237
  const indexer = new SelfHostIndexer();
2214
2238
  const baseReg = {
@@ -3145,12 +3169,15 @@ const DEPLOY_CODE_FLAGS = new Set([
3145
3169
  'sign', 'unsigned', 'for', 'salt', 'factory', 'bootstrap-factory', 'sign-url-file',
3146
3170
  // preview / confirm
3147
3171
  'dry-run', 'confirm', 'yes',
3172
+ 'json', // B19: the deployed ADDRESS as data, narration to stderr
3173
+ 'resume', // B15: finish an EXISTING contract whose setup tx never landed (no deploy)
3148
3174
  ]);
3149
3175
  // Storage + signing + preview flags shared by every deploy path (mirrors storageOverrides() + the lanes).
3150
3176
  const SHARED_DEPLOY_FLAGS = [
3151
3177
  'backend', 'endpoint', 'bucket', 'region', 'prefix', 'public-base', 'gateway', 'mode', 'api-url', 'upload-url', 'provider', 'storage-signer',
3152
3178
  'sign', 'unsigned', 'for', 'sign-url-file', 'dry-run', 'confirm', 'yes', 'salt', 'factory', 'bootstrap-factory', 'port', 'renderer', 'public-base-url',
3153
3179
  '721c', // opt-in ERC-721C enrollment (recommended | 0x…) — every deploy path takes it
3180
+ 'json', // B19: the deployed ADDRESS as data, narration to stderr — every deploy path takes it
3154
3181
  ];
3155
3182
  // A 1/1 `abx deploy` accepts these (see `abx help deploy`). Anything else warns (typo'd/unsupported).
3156
3183
  const DEPLOY_FLAGS = new Set([
@@ -3226,6 +3253,9 @@ function warnSignWithoutFor(flags) {
3226
3253
  }
3227
3254
  }
3228
3255
  async function cmdDeployCode(flags) {
3256
+ return withJson(flags, async (emit) => cmdDeployCodeBody(flags, emit));
3257
+ }
3258
+ async function cmdDeployCodeBody(flags, emit) {
3229
3259
  const usage = 'abx deploy-code (--script <file> | --code-dir <dir>) --name "Title" --symbol SYM ' +
3230
3260
  '(--public-base-url https://your.resolver.domain | --onchain-uri) ' +
3231
3261
  '[--description "<s>"] [--external-url <url>] [--image-base <url> | --image-renderer 0x..] [--attributes-renderer 0x..] ' +
@@ -3720,7 +3750,11 @@ async function cmdDeployCode(flags) {
3720
3750
  }
3721
3751
  // --dry-run: emit the full plan (the on-chain values the skill formats into its confirm table),
3722
3752
  // then stop — no upload, no factory/seed deploy, no send.
3723
- if (dryRun) {
3753
+ //
3754
+ // `--resume` skips this preview entirely and previews its OWN plan further down. Without the guard,
3755
+ // `--resume 0xLive --dry-run` printed a fresh-deploy plan complete with a newly-reserved salt and a
3756
+ // different predicted address — a preview of the exact thing the flag exists not to do.
3757
+ if (dryRun && flags.resume === undefined) {
3724
3758
  // The deployer is needed ONLY to derive the CREATE2 salt → the deterministic address (and the
3725
3759
  // pinned-salt re-run line). Everything else in the plan — content, deps, chain-complete
3726
3760
  // expectation, tx count, cost — is deployer-independent, so a wallet-less creator (the common
@@ -3893,6 +3927,9 @@ async function cmdDeployCode(flags) {
3893
3927
  info(`pass ${bold('--for 0x..')} to see the exact deterministic address + a pinned-salt re-run command.`);
3894
3928
  }
3895
3929
  console.log(`\n ${g('dry run')} ${dim('— nothing sent, no bytes stored. Re-run without --dry-run to deploy.')}\n`);
3930
+ // `address` is null in the wallet lane with no --for: the signer decides the salt, so no address
3931
+ // exists yet. Reporting null is the honest answer; inventing one would be a wrong reservation.
3932
+ emit(jsonSafe({ command: 'deploy-code', dryRun: true, sent: false, address: predicted ?? null, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt: salt ?? null, saltPinned: !!parseSaltFlag(flags.salt), name, symbol, onChainUri }));
3896
3933
  return;
3897
3934
  }
3898
3935
  // Real deploy: perform the deferred directory upload now (past the confirm gate).
@@ -3941,12 +3978,39 @@ async function cmdDeployCode(flags) {
3941
3978
  tokenFields: [],
3942
3979
  contractFields,
3943
3980
  });
3944
- const setupCalls = (owner) => [
3945
- ...scriptChunks.map((chunk, i) => encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setScriptChunk', args: [BigInt(i), chunk] })),
3946
- ...schemas.map(({ key, paramType, auth, authAddress, lockAfter, min, max, selectOptions }) => encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setParamSchema', args: [encodeTag(key), paramType, auth, authAddress, lockAfter, min, max, selectOptions] })),
3947
- ...dependencySetupCalls(deps, depRegistry), // setDependency legs in index order (+ the soft registry pointer)
3948
- ...(onchainUriLegs ?? []), // program lane only: animation_url field · the URI renderers (before the mints)
3949
- ].concat(Array.from({ length: setupMintCount }, () => encodeFunctionData({ abi: seriesCodeAbi, functionName: 'mint', args: [owner] }))); // folded mints ride init, not here
3981
+ // The setup legs, GROUPED. The normal deploy flattens them; `--resume` diffs them against chain
3982
+ // state and sends only what is missing (see resume.ts). One builder for both, so a resume can never
3983
+ // drift from what a fresh deploy would have written a second implementation of this sequence is
3984
+ // the failure mode a repair verb most easily introduces.
3985
+ const setupLegGroups = (owner) => ({
3986
+ chunks: scriptChunks.map((chunk, i) => ({
3987
+ index: i,
3988
+ hex: chunk,
3989
+ data: encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setScriptChunk', args: [BigInt(i), chunk] }),
3990
+ })),
3991
+ schemas: schemas.map(({ key, paramType, auth, authAddress, lockAfter, min, max, selectOptions }) => ({
3992
+ key,
3993
+ data: encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setParamSchema', args: [encodeTag(key), paramType, auth, authAddress, lockAfter, min, max, selectOptions] }),
3994
+ })),
3995
+ deps: { count: deps.length, registry: depRegistry ?? null, calls: dependencySetupCalls(deps, depRegistry) },
3996
+ // program lane only: animation_url field · the URI renderers (before the mints)
3997
+ uri: { calls: onchainUriLegs ?? [], animationField: onchainUriLegs?.length ? F.animationUrl : null },
3998
+ // The INTENDED TOTAL, not a count to add — a resume mints the shortfall. `mintCount` covers both
3999
+ // lanes: folded-into-init (renderer-only) and setup-carried, since either way it is what the
4000
+ // creator asked for.
4001
+ mints: { intendedTotal: mintCount, data: encodeFunctionData({ abi: seriesCodeAbi, functionName: 'mint', args: [owner] }) },
4002
+ });
4003
+ const setupCalls = (owner) => {
4004
+ const g = setupLegGroups(owner);
4005
+ return [
4006
+ ...g.chunks.map((c) => c.data),
4007
+ ...g.schemas.map((x) => x.data),
4008
+ ...g.deps.calls,
4009
+ ...g.uri.calls,
4010
+ // folded mints ride init, not here — so this uses setupMintCount, not the intended total.
4011
+ ...Array.from({ length: setupMintCount }, () => g.mints.data),
4012
+ ];
4013
+ };
3950
4014
  const cid = resolveChain(CHAIN).id;
3951
4015
  const preparedFor = async (owner) => {
3952
4016
  const salt = parseSaltFlag(flags.salt) ?? saltFor(owner);
@@ -3957,6 +4021,139 @@ async function cmdDeployCode(flags) {
3957
4021
  txs.push(prepareCodeSetup({ contract: clone, calls, chainId: cid, chunkCount: scriptChunks.length, chunkBytes: scriptChunks.map((h) => (h.length - 2) / 2), schemaKeys: schemas.map((x) => x.key), deps: deps.map((d) => d.display), onchainUri: onChainUri && hasProgram }));
3958
4022
  return { clone, txs };
3959
4023
  };
4024
+ // ── --resume: finish an EXISTING contract, deploy nothing (backlog B15) ─────────────────────
4025
+ // A code deploy is two transactions. When the second fails you own a live-but-unusable contract and
4026
+ // the salt reserved for its address is spent, so the dry run's pinned-salt reproduce command can
4027
+ // never be run again. The contract is recoverable, not lost — a tester proved that by resending the
4028
+ // setup by hand with `cast`. This is that, as a verb: read what is missing, send only that.
4029
+ const resumeAddr = flags.resume;
4030
+ if (resumeAddr !== undefined) {
4031
+ if (!/^0x[0-9a-fA-F]{40}$/.test(resumeAddr)) {
4032
+ throw new Error(`--resume wants the address of the contract to finish; '${resumeAddr}' isn't a 0x address.`);
4033
+ }
4034
+ const target = resumeAddr;
4035
+ const code = await publicClient.getCode({ address: target }).catch(() => undefined);
4036
+ if (!code || code === '0x') {
4037
+ throw new Error(`--resume ${target}: no contract at that address on ${CHAIN} (asked ${redactRpcUrl(resolveRpcUrl(CHAIN))}). ` +
4038
+ `There is nothing to finish — if the DEPLOY tx is what failed, run a normal deploy instead. ` +
4039
+ `If you JUST deployed, give the tx a block or two to mine.`);
4040
+ }
4041
+ // Deploy-time-only flags are refused rather than ignored: --salt/--721c/--bootstrap-factory all
4042
+ // describe how a contract is CREATED, and this creates nothing. 721C especially — enrollment is
4043
+ // permanent and deploy-time-only, so accepting the flag here would imply it can be added later.
4044
+ for (const [flag, why] of [
4045
+ ['salt', 'the address already exists, so no salt is used'],
4046
+ ['721c', 'ERC-721C enrollment is deploy-time-only and PERMANENT — it cannot be added to an existing collection'],
4047
+ ['bootstrap-factory', 'no factory is involved: nothing is being created'],
4048
+ ['mint-all', 'use --mint-count <n> on a resume; --mint-all is resolved against the cap at deploy time'],
4049
+ ]) {
4050
+ if (flags[flag] !== undefined)
4051
+ throw new Error(`--resume cannot be combined with --${flag}: ${why}.`);
4052
+ }
4053
+ const owner = await publicClient
4054
+ .readContract({ address: target, abi: seriesCodeAbi, functionName: 'owner' })
4055
+ .catch(() => undefined);
4056
+ if (!owner)
4057
+ throw new Error(`--resume ${target}: could not read owner() — is this an ABX SeriesCode contract?`);
4058
+ step('Resume — read what is missing, send only that');
4059
+ info(`target ${bold(target)} ${dim(`· owner ${owner}`)}`);
4060
+ // Every read is a view on the target; the intended legs come from the SAME builder a fresh deploy
4061
+ // uses, so what gets sent is by construction what the deploy would have written.
4062
+ const legs = setupLegGroups(owner);
4063
+ const readAt = async (functionName, args = []) => {
4064
+ try {
4065
+ return (await publicClient.readContract({ address: target, abi: seriesCodeAbi, functionName, args }));
4066
+ }
4067
+ catch {
4068
+ return null;
4069
+ }
4070
+ };
4071
+ const nonZero = (a) => (a && a !== zeroAddress ? a : null);
4072
+ const reader = {
4073
+ scriptChunkCount: async () => Number((await readAt('scriptChunkCount')) ?? 0n),
4074
+ scriptChunk: (index) => readAt('scriptChunk', [BigInt(index)]),
4075
+ schemaExists: async (key) => {
4076
+ const r = await readAt('paramSchema', [encodeTag(key)]);
4077
+ return !!r?.[0];
4078
+ },
4079
+ dependencyCount: async () => Number((await readAt('dependencyCount')) ?? 0n),
4080
+ dependencyRegistry: async () => nonZero(await readAt('dependencyRegistry')),
4081
+ tokenURIRenderer: async () => nonZero(await readAt('tokenURIRenderer')),
4082
+ contractURIRenderer: async () => nonZero(await readAt('contractURIRenderer')),
4083
+ contractFieldSet: async (field) => {
4084
+ const r = await readAt('contractField', [encodeTag(field)]);
4085
+ return !!r && r[0] !== `0x${'0'.repeat(64)}`;
4086
+ },
4087
+ totalSupply: async () => Number((await readAt('totalSupply')) ?? 0n),
4088
+ };
4089
+ const plan = await planResume(reader, legs);
4090
+ for (const line of plan.done)
4091
+ ok(line);
4092
+ for (const line of plan.todo)
4093
+ info(`will send — ${line}`);
4094
+ if (!plan.calls.length) {
4095
+ ok(`nothing missing — this contract's setup is already complete. ${dim('Confirm with `abx verify ' + target + '`.')}`);
4096
+ emit(jsonSafe({ command: 'deploy-code', resumed: target, chain: CHAIN, chainId: resolveChain(CHAIN).id, sent: false, complete: true, sentLegs: 0 }));
4097
+ console.log('');
4098
+ return;
4099
+ }
4100
+ const tx = prepareCodeSetup({
4101
+ contract: target,
4102
+ calls: plan.calls,
4103
+ chainId: resolveChain(CHAIN).id,
4104
+ chunkCount: plan.sending.chunkIndices.length,
4105
+ chunkBytes: plan.sending.chunkBytes,
4106
+ schemaKeys: plan.sending.schemaKeys,
4107
+ deps: plan.sending.deps ? deps.map((d) => d.display) : [],
4108
+ onchainUri: plan.sending.uri,
4109
+ });
4110
+ if (flags['dry-run']) {
4111
+ console.log(`\n ${bold('\u25c6 ' + tx.summary)} ${dim('(dry run — nothing sent)')}`);
4112
+ for (const [k, v] of Object.entries(tx.fields))
4113
+ console.log(` ${dim(k.padEnd(14))} ${v}`);
4114
+ console.log(dim(`\n Re-run without --dry-run to send (lane: ${lane}).\n`));
4115
+ emit(jsonSafe({ command: 'deploy-code', resumed: target, chain: CHAIN, chainId: resolveChain(CHAIN).id, dryRun: true, sent: false, complete: false, sentLegs: plan.calls.length }));
4116
+ return;
4117
+ }
4118
+ await confirmSend(`finish setup on ${target} (${plan.calls.length} call(s), one transaction)`, flags);
4119
+ // Signed through the same choke point as every other write, so --sign / --unsigned / the owner
4120
+ // check / the chain-id guard all apply exactly as they do to a deploy.
4121
+ const result = await signTx(() => tx, {
4122
+ lane,
4123
+ chainKey: CHAIN,
4124
+ yes: !!flags.yes,
4125
+ expectedSigner: owner,
4126
+ port: flags.port ? Number(flags.port) : undefined,
4127
+ signUrlFile: flags['sign-url-file'],
4128
+ });
4129
+ if (!result) {
4130
+ console.log(`\n${dim(' unsigned — broadcast it, then re-run `abx verify ' + target + '` to confirm the setup completed.')}\n`);
4131
+ emit(jsonSafe({ command: 'deploy-code', resumed: target, chain: CHAIN, chainId: resolveChain(CHAIN).id, sent: false, complete: false, sentLegs: plan.calls.length }));
4132
+ return;
4133
+ }
4134
+ ok(`${tx.summary}`);
4135
+ // Locals, not the outer clone/deployBlock: this branch never falls through to the deploy path, and
4136
+ // the authoritative scan floor is still the clone's CREATION block (getCode search), not this
4137
+ // repair tx's block — flooring the resolver above the deploy would hide the `code` field again.
4138
+ const resumedFloor = (await discoverDeployBlock(publicClient, target)) ?? result.blockNumber;
4139
+ emit(jsonSafe({
4140
+ command: 'deploy-code',
4141
+ resumed: target,
4142
+ address: target,
4143
+ chain: CHAIN,
4144
+ chainId: resolveChain(CHAIN).id,
4145
+ deployBlock: resumedFloor,
4146
+ sent: true,
4147
+ complete: true,
4148
+ sentLegs: plan.calls.length,
4149
+ txHash: result.txHash,
4150
+ }));
4151
+ step('Index');
4152
+ await cmdAdd(target, { 'from-block': resumedFloor.toString(), factory, label: name });
4153
+ console.log(`\n ${g('\u2713 setup finished')} \u2014 ${dim('confirm it resolves:')} ${bold(`abx verify ${target}`)}` +
4154
+ `${onChainUri ? dim(' (chain-complete + the on-chain tokenURI)') : ''}\n`);
4155
+ return;
4156
+ }
3960
4157
  let clone;
3961
4158
  let deployBlock;
3962
4159
  if (lane === 'unsigned') {
@@ -4039,6 +4236,20 @@ async function cmdDeployCode(flags) {
4039
4236
  if (discovered !== null)
4040
4237
  deployBlock = discovered;
4041
4238
  ok(`SeriesCode live: ${clone} ${dim(`(from block ${deployBlock})`)}`);
4239
+ // Emitted here rather than at the end: a code deploy is TWO transactions, and if the second (setup)
4240
+ // fails, the address of the live-but-incomplete contract is the single most valuable thing a caller
4241
+ // can be told — it is exactly the input `--resume <address>` takes to finish the job.
4242
+ emit(jsonSafe({
4243
+ command: 'deploy-code',
4244
+ address: clone,
4245
+ chain: CHAIN,
4246
+ chainId: resolveChain(CHAIN).id,
4247
+ factory,
4248
+ deployBlock,
4249
+ name,
4250
+ symbol,
4251
+ onChainUri,
4252
+ }));
4042
4253
  step('Index');
4043
4254
  await cmdAdd(clone, { 'from-block': deployBlock.toString(), factory, label: name });
4044
4255
  if (onChainUri) {
@@ -4223,6 +4434,9 @@ function noteArweavePropagation(flags) {
4223
4434
  return; // a gateway they chose — don't lecture
4224
4435
  info(`${dim('arweave: the locator points at')} ${gateway}${dim(', which can 404 for several minutes after upload while it catches up. That is PROPAGATION, not a failed render — the bytes are already confirmed. Do NOT re-run with')} ${bold('--force')}${dim('; the URL starts working on its own.')}`);
4225
4436
  info(` ${dim('to bake a different gateway into the locator instead (it is fixed at publish time):')} ${bold('ABX_ARWEAVE_GATEWAY=https://<gateway>')} ${dim('before you render.')}`);
4437
+ // The note is advisory; this is the answer. Naming it here is the difference between "wait and
4438
+ // hope" and a check a script can gate on — which is what both reporters ended up building.
4439
+ info(` ${dim('to KNOW rather than wait:')} ${bold('abx storage status <locator>')} ${dim('— ready vs propagating vs unreachable, exits non-zero until it serves.')}`);
4226
4440
  }
4227
4441
  /**
4228
4442
  * The publish topology's one hard prerequisite, checked BEFORE any capture.
@@ -4357,13 +4571,19 @@ async function cmdIndex(address, flags) {
4357
4571
  // so an agent can confirm integrity right after deploy/mint without curling.
4358
4572
  async function cmdVerify(address, flags) {
4359
4573
  if (!address || address.startsWith('--')) {
4360
- console.error('usage: abx verify <address>\n');
4574
+ console.error('usage: abx verify <address> [--json] [--remote <name|url>]\n');
4361
4575
  process.exit(1);
4362
4576
  }
4363
4577
  allowLargeScan(flags);
4364
4578
  const remote = remoteFlag(flags);
4365
4579
  if (remote)
4366
4580
  return cmdVerifyRemote(address, remote);
4581
+ // `--json` matters most here of all the read commands: verify already exits non-zero on a byte
4582
+ // mismatch, so it is the one command a CI job would gate on — and gating on prose is what B19 is
4583
+ // about. The payload's `ok` is the same boolean the exit code carries.
4584
+ return withJson(flags, async (emit) => cmdVerifyBody(address, flags, emit));
4585
+ }
4586
+ async function cmdVerifyBody(address, flags, emit) {
4367
4587
  const indexer = new SelfHostIndexer();
4368
4588
  let state = indexer.getProject(address);
4369
4589
  if (indexer.store.getRegistration(address)) {
@@ -4374,6 +4594,20 @@ async function cmdVerify(address, flags) {
4374
4594
  }
4375
4595
  if (!state)
4376
4596
  throw new Error(`could not load state for ${address}`);
4597
+ // Accumulated as the checks run, re-emitted at each stage so a payload survives an early return.
4598
+ const verifyReport = {
4599
+ address,
4600
+ chain: CHAIN,
4601
+ name: state.name ?? null,
4602
+ canonical: state.isCanonical, // TRUE tri-state: true | false | null (couldn't check)
4603
+ owner: state.owner ?? null,
4604
+ contentChecks: [],
4605
+ contentIntegrity: 'no-commitments',
4606
+ renders: null,
4607
+ onChainUri: null,
4608
+ ok: true,
4609
+ };
4610
+ emit(verifyReport);
4377
4611
  console.log(bold(`\n verify ${state.name ?? address}`));
4378
4612
  // isCanonical is a TRUE TRI-STATE (true | false | null) and collapsing it lost the only
4379
4613
  // distinction that matters: "the chain says this is NOT a clone of the configured factory" is a
@@ -4389,6 +4623,15 @@ async function cmdVerify(address, flags) {
4389
4623
  const tok = state.tokens.find((s) => s.tokenId === t.tokenId);
4390
4624
  info(`token #${t.tokenId}: ${tok?.minted ? 'minted' : 'not yet minted'}`);
4391
4625
  for (const ch of t.checks) {
4626
+ verifyReport.contentChecks.push({
4627
+ tokenId: t.tokenId,
4628
+ minted: !!tok?.minted,
4629
+ kind: ch.kind,
4630
+ // tri-state again: true (re-hashed, matched) · false (MISMATCH) · null (pointer-only,
4631
+ // nothing local to recompute). Collapsing null into false would report a normal state as a
4632
+ // failure, which is the bug this command exists to not have.
4633
+ verified: ch.verified,
4634
+ });
4392
4635
  anyCheck = true;
4393
4636
  if (ch.verified === true)
4394
4637
  ok(`${ch.kind} matches on-chain commitment — content is authentic`);
@@ -4402,6 +4645,9 @@ async function cmdVerify(address, flags) {
4402
4645
  }
4403
4646
  if (!anyCheck && !isCodeProject(state))
4404
4647
  info('no content commitments on this project');
4648
+ verifyReport.contentIntegrity = !anyCheck ? 'no-commitments' : allGood ? 'ok' : 'mismatch';
4649
+ verifyReport.ok = !anyCheck || allGood;
4650
+ emit(verifyReport);
4405
4651
  // Code projects have no on-chain `image` commitment (the still is rendered off-chain by the
4406
4652
  // effect runner), so the hash checks above are silent for them. Instead report what actually
4407
4653
  // matters after mint: does a REAL thumbnail exist yet, or is the marketplace image still the
@@ -4448,6 +4694,15 @@ async function cmdVerify(address, flags) {
4448
4694
  else
4449
4695
  missing.push(String(token.tokenId));
4450
4696
  }
4697
+ verifyReport.renders = {
4698
+ minted: minted.length,
4699
+ present,
4700
+ missing, // token ids, so a caller can re-render exactly these
4701
+ // Named for what it IS: this node's store. A render PUBLISHED to a hosted resolver lives
4702
+ // there, not here, so `missing` is a false negative for a hosted drop — hence the scope.
4703
+ scope: "this node's store",
4704
+ };
4705
+ emit(verifyReport);
4451
4706
  if (present)
4452
4707
  ok(`${present}/${minted.length} minted token(s): real render present (in this node's store)`);
4453
4708
  if (missing.length) {
@@ -4467,6 +4722,18 @@ async function cmdVerify(address, flags) {
4467
4722
  try {
4468
4723
  const report = await onChainUriReport(client, state, flags.generator);
4469
4724
  const { status, probe } = report;
4725
+ verifyReport.onChainUri = jsonSafe({
4726
+ generator: report.generator,
4727
+ tokenURIRenderer: state.tokenURIRenderer ?? null,
4728
+ branch: status.branchName,
4729
+ chainComplete: !!status.chainComplete,
4730
+ unresolvedRefs: [...status.unresolvedRefs],
4731
+ urlOverBudget: !!status.urlOverBudget,
4732
+ probe: probe
4733
+ ? { tokenId: probe.tokenId, onChainJson: !!probe.onChainJson, animation: probe.animation }
4734
+ : null,
4735
+ });
4736
+ emit(verifyReport);
4470
4737
  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)'}`);
4471
4738
  if (status.branchName === 'template') {
4472
4739
  ok(`generator branch: template — the document assembles from the on-chain script chunks`);
@@ -4731,7 +4998,7 @@ function decodeOnChainJson(uri, verbatim = false) {
4731
4998
  }
4732
4999
  async function cmdTokenUri(address, flags, extra = []) {
4733
5000
  if (!address || address.startsWith('--')) {
4734
- console.error('usage: abx tokenuri <address> [--token <id>] [--json]\n');
5001
+ console.error('usage: abx tokenuri <address> [--token <id>] [--fetch] [--json]\n');
4735
5002
  process.exit(1);
4736
5003
  }
4737
5004
  if (!/^0x[0-9a-fA-F]{40}$/.test(address)) {
@@ -4748,7 +5015,7 @@ async function cmdTokenUri(address, flags, extra = []) {
4748
5015
  console.error(`abx tokenuri: unexpected extra argument '${first}'.` +
4749
5016
  (looksLikeTokenId
4750
5017
  ? ` The token id is a flag — did you mean:\n abx tokenuri ${address} --token ${first}\n`
4751
- : `\n usage: abx tokenuri <address> [--token <id>] [--json]\n`));
5018
+ : `\n usage: abx tokenuri <address> [--token <id>] [--fetch] [--json]\n`));
4752
5019
  process.exit(1);
4753
5020
  }
4754
5021
  const tokenId = BigInt(flags.token ?? '0');
@@ -4779,10 +5046,33 @@ async function cmdTokenUri(address, flags, extra = []) {
4779
5046
  }
4780
5047
  process.exit(1);
4781
5048
  }
5049
+ // --fetch: FOLLOW the URL the contract commits to and print what is actually served (backlog B14).
5050
+ // The gap this closes: `tokenuri` read the chain, `verify` re-hashed bytes, `status` reported the
5051
+ // lifecycle — none of them showed the served JSON body, so an agent in a cold sweep fell back to raw
5052
+ // `curl`. It also makes a provider mismatch self-evident without a warning that cries wolf: if you
5053
+ // registered with one provider while a different base is baked on-chain, the fetch shows you what
5054
+ // the BAKED base returns (often a 404, or another project's document entirely).
5055
+ const served = flags.fetch !== undefined ? await fetchServedTokenUri(uri) : null;
4782
5056
  // --json: the verbatim document, nothing else on stdout — no banner, no ANSI, no truncation, so
4783
5057
  // `abx tokenuri <addr> --json | jq` is a supported read path rather than something to regex.
4784
5058
  if (flags.json !== undefined) {
4785
- process.stdout.write((decodeOnChainJson(uri, true) ?? uri) + '\n');
5059
+ const onChain = decodeOnChainJson(uri, true);
5060
+ if (served === null) {
5061
+ process.stdout.write((onChain ?? uri) + '\n');
5062
+ return;
5063
+ }
5064
+ // With --fetch the payload has to say WHICH answer is which: a marketplace reads the served body,
5065
+ // and conflating it with the on-chain URI is the confusion the flag exists to remove.
5066
+ process.stdout.write(JSON.stringify({
5067
+ tokenId: tokenId.toString(),
5068
+ contract: address,
5069
+ chain: CHAIN,
5070
+ tokenURI: uri,
5071
+ onChain: onChain !== null,
5072
+ served,
5073
+ }, null, 2) + '\n');
5074
+ if (served.status !== null && (served.status < 200 || served.status >= 300))
5075
+ process.exitCode = 1;
4786
5076
  return;
4787
5077
  }
4788
5078
  console.log(`\n ${bold(`tokenURI(${tokenId})`)} ${dim(`— read directly from ${address} on ${CHAIN}, no server`)}`);
@@ -4794,6 +5084,36 @@ async function cmdTokenUri(address, flags, extra = []) {
4794
5084
  else {
4795
5085
  console.log(` ${uri.slice(0, 240)}${uri.length > 240 ? dim(`… (${uri.length} chars)`) : ''}\n`);
4796
5086
  }
5087
+ if (served)
5088
+ reportServedTokenUri(served, address);
5089
+ }
5090
+ /** The human readout for `--fetch`. */
5091
+ function reportServedTokenUri(served, address) {
5092
+ if (served.skipped) {
5093
+ info(`--fetch: ${served.skipped}`);
5094
+ return;
5095
+ }
5096
+ console.log(` ${bold('served')} ${dim(`— GET ${served.url}`)}`);
5097
+ if (served.status === null) {
5098
+ warn(`the baked URL did not respond: ${served.error}. That host is what a marketplace will ask, so this is what they see too.`);
5099
+ return;
5100
+ }
5101
+ const line = `HTTP ${served.status}${served.contentType ? dim(` · ${served.contentType.split(';')[0]}`) : ''}`;
5102
+ if (servedOk(served))
5103
+ ok(line);
5104
+ else
5105
+ console.log(` ${c.orange}\u26a0${c.reset} ${line}`);
5106
+ const pretty = prettyBody(served.body);
5107
+ const clipped = pretty.length > 1200 ? `${pretty.slice(0, 1200)}\n \u2026 (${pretty.length} chars \u2014 --json for the full body)` : pretty;
5108
+ console.log(clipped.split('\n').map((l) => ' ' + l).join('\n'));
5109
+ if (!servedOk(served)) {
5110
+ // The mismatch case, stated where it is actionable rather than as a standing warning that would
5111
+ // cry wolf on the common custom-domain setup.
5112
+ console.log(`\n ${dim('the contract commits to that URL, so a non-2xx is about the SERVICE, never a mistyped path:')} ` +
5113
+ `${dim('the project may not be registered there, the service may serve a different chain, or the base baked on-chain may point at a provider you never registered with.')}\n` +
5114
+ ` ${dim('check the lifecycle:')} ${bold(`abx status ${address} --remote <name|url>`)}`);
5115
+ }
5116
+ console.log('');
4797
5117
  }
4798
5118
  // ── contracturi ──────────────────────────────────────────────────────────────
4799
5119
  /**
@@ -4997,6 +5317,9 @@ async function cmdStorage(rest) {
4997
5317
  if (sub === 'backup-key') {
4998
5318
  return cmdStorageBackupKey(flags);
4999
5319
  }
5320
+ if (sub === 'status') {
5321
+ return cmdStorageStatus(args[0], flags);
5322
+ }
5000
5323
  // show (default) — the RESOLVED effective config + where each value came from. No persisted state.
5001
5324
  const { backend, source } = backendResolution();
5002
5325
  const opts = storageOptions(); // resolved from env → default (no per-command flags here)
@@ -5020,6 +5343,91 @@ async function cmdStorage(rest) {
5020
5343
  console.log(dim('\n choose per command: --backend <fs|cloud|ipfs|arweave> [--gateway/--bucket/--region/…] · or declare in .env'));
5021
5344
  console.log(dim(' secrets from env only: ABX_S3_ACCESS_KEY_ID · ABX_S3_SECRET_ACCESS_KEY · PINATA_JWT · ARWEAVE_UPLOAD_TOKEN · ARWEAVE_JWK\n'));
5022
5345
  }
5346
+ /**
5347
+ * `abx storage status <locator>` — is it **retrievable** yet, not just accepted?
5348
+ *
5349
+ * The gap this closes: an upload service answers "accepted" the moment it holds your bytes, a gateway
5350
+ * answers "serving" only once they reach it, and on Arweave that runs to minutes. Two independent
5351
+ * integrators built their own version of this check eight days apart; the second saw 32/32 renders
5352
+ * 404 on `arweave.net` while 22/32 already served elsewhere and the uploader said `CONFIRMED`. The
5353
+ * expensive part is what a creator does next: a placeholder on a fresh drop reads as a failed render,
5354
+ * so you re-run `abx render --force` and re-upload everything for nothing.
5355
+ */
5356
+ const STORAGE_STATUS_FLAGS = new Set(['json', 'gateway', 'backend', 'primary-only', 'timeout']);
5357
+ async function cmdStorageStatus(locator, flags) {
5358
+ if (!locator || locator.startsWith('--')) {
5359
+ console.error('usage: abx storage status <locator> [--json] [--gateway <url>] [--primary-only]\n' +
5360
+ ' <locator> — ar://<txid> · ipfs://<cid> · an https:// gateway URL · or a bare txid/CID\n');
5361
+ process.exit(1);
5362
+ }
5363
+ warnStrayFlags(flags, STORAGE_STATUS_FLAGS, 'storage status');
5364
+ const jsonMode = flags.json !== undefined;
5365
+ // The gateway that matters is the one a baked locator resolves through, so default to the active
5366
+ // backend's configured gateway rather than a hardcoded host — otherwise the verdict is about a
5367
+ // gateway this project will never use.
5368
+ const opts = storageOptions(storageOverrides(flags));
5369
+ const configured = flags.gateway ?? opts.arweave?.gateway ?? opts.ipfs?.gateway ?? undefined;
5370
+ const status = await locatorStatus(locator, {
5371
+ gateway: configured,
5372
+ primaryOnly: flags['primary-only'] !== undefined,
5373
+ timeoutMs: flags.timeout === undefined ? undefined : Number(flags.timeout),
5374
+ });
5375
+ if (jsonMode) {
5376
+ process.stdout.write(JSON.stringify(status, null, 2) + '\n');
5377
+ // A program's whole reason to call this is to gate on it, so the verdict is also the exit code:
5378
+ // 0 = ready, 1 = not yet. `until abx storage status <loc> --json; do sleep 10; done` just works.
5379
+ if (status.readiness !== 'ready')
5380
+ process.exitCode = 1;
5381
+ return;
5382
+ }
5383
+ const probeLine = (p, label) => {
5384
+ const verdict = p.serving
5385
+ ? `${g('serving')}`
5386
+ : p.status !== null
5387
+ ? `${c.orange}${p.status}${c.reset}`
5388
+ : `${c.orange}no response${c.reset}`;
5389
+ const size = p.bytes !== null ? dim(` · ${p.bytes < 1024 ? `${p.bytes} B` : `${(p.bytes / 1024).toFixed(1)} KB`}`) : '';
5390
+ const type = p.contentType ? dim(` · ${p.contentType.split(';')[0]}`) : '';
5391
+ const why = p.error ? dim(` · ${p.error}`) : '';
5392
+ console.log(` ${label} ${bold(p.gateway)} → ${verdict}${size}${type} ${dim(`${p.ms}ms`)}${why}`);
5393
+ };
5394
+ console.log(`\n ${bold('storage status')} ${dim(`— ${status.network} · ${status.id.length > 60 ? status.id.slice(0, 60) + '…' : status.id}`)}`);
5395
+ probeLine(status.primary, dim('your gateway '));
5396
+ for (const a of status.alternates)
5397
+ probeLine(a, dim('also '));
5398
+ console.log('');
5399
+ if (status.readiness === 'ready') {
5400
+ ok(`${bold('ready')} — your gateway serves these bytes. Safe to reference in a token.`);
5401
+ }
5402
+ else if (status.readiness === 'propagating') {
5403
+ const where = status.alternates.filter((a) => a.serving).map((a) => a.gateway).join(', ');
5404
+ warn(`${bold('propagating')} — the data is provably on the network (${where} serves it) but ${bold(status.primary.gateway)} hasn't caught up.`);
5405
+ info('Waiting is the fix. Do NOT re-render or re-upload — the bytes are already stored, and a second upload just pays twice.');
5406
+ if (status.network === 'arweave') {
5407
+ info(`To serve sooner from a gateway that already has it, set ${bold('ABX_ARWEAVE_GATEWAY')} before the upload that bakes the locator ${dim('(it cannot be repaired afterwards — the gateway is part of the stored value).')}`);
5408
+ }
5409
+ }
5410
+ else {
5411
+ // Never call this "propagating": from outside, a settling locator and a wrong one look the same.
5412
+ warn(`${bold('not retrievable')} — no gateway probed is serving it.`);
5413
+ // One exception worth reading the statuses for. Gateways answer 404 for "I don't have these
5414
+ // bytes" (settling and wrong are identical there) but 400 for "that isn't a valid id at all".
5415
+ // Every probe agreeing on a non-404 client error is real evidence, and it matters because the
5416
+ // inverse mistake — waiting out a typo — costs more than a needless re-upload.
5417
+ const probes = [status.primary, ...status.alternates];
5418
+ const answered = probes.filter((p) => p.status !== null);
5419
+ const allInvalid = answered.length > 0 && answered.every((p) => p.status >= 400 && p.status < 500 && p.status !== 404);
5420
+ if (allInvalid) {
5421
+ info(`every gateway rejected the id itself (${answered.map((p) => p.status).join(', ')}), not merely "don't have it" — so this is very likely a ${bold('malformed locator')} (truncated txid/CID, or a stray character), not propagation. Re-check the value you stored; waiting will not fix it.`);
5422
+ }
5423
+ else {
5424
+ info('Two causes, and this check cannot tell them apart: it is still settling (wait and re-run — minutes on Arweave), or the locator is wrong (a truncated txid/CID, or the upload never completed).');
5425
+ }
5426
+ }
5427
+ console.log(`\n ${dim('gate a script on this:')} ${bold(`abx storage status ${locator} --json`)} ${dim('(exit 0 only when ready)')}\n`);
5428
+ if (status.readiness !== 'ready')
5429
+ process.exitCode = 1;
5430
+ }
5023
5431
  /**
5024
5432
  * `abx storage upload <path> [--backend ipfs|arweave|cloud]` — upload one file to a durable
5025
5433
  * backend and print the **locator** to point at. This is the missing first half of the data-plane
@@ -5150,6 +5558,106 @@ async function cmdStorageFunding(sub, flags) {
5150
5558
  info(' Credits land on the address above and persist for future uploads. Re-check with `abx storage balance`.');
5151
5559
  }
5152
5560
  // ── status ────────────────────────────────────────────────────────────────--
5561
+ // ── tokens: every token's seed + params, read straight from chain ────────────────────────────
5562
+ // For a generative collection the seed list IS the collection — it's the first thing you want after
5563
+ // a drop ("what did the seeds actually deal?") and, before this existed, the one thing no command
5564
+ // could answer. An agent's workaround was: start `abx serve`, GET the project API, base64-decode
5565
+ // each tokenURI, base64-decode the animation_url inside it, then regex `0x[0-9a-f]{64}` out of the
5566
+ // resulting HTML — 32 times. Every input to that was already a plain contract read.
5567
+ const TOKENS_FLAGS = new Set(['json', 'limit', 'from']);
5568
+ /** An owner address abbreviated for a human table. Abbreviating for eyes is fine; abbreviating for a
5569
+ * PROGRAM is not — which is why every value here is also reachable verbatim via `--json`. */
5570
+ function shortAddr(a) {
5571
+ return `${a.slice(0, 6)}…${a.slice(-4)}`;
5572
+ }
5573
+ async function cmdTokens(address, flags) {
5574
+ if (!address || address.startsWith('--')) {
5575
+ console.error('usage: abx tokens <address> [--json] [--from <id>] [--limit <n>] — every token\'s owner, seed, and params, from chain\n');
5576
+ process.exit(1);
5577
+ }
5578
+ if (!/^0x[0-9a-fA-F]{40}$/.test(address)) {
5579
+ console.error(`abx tokens: '${address}' isn't a 0x contract address.\n`);
5580
+ process.exit(1);
5581
+ }
5582
+ warnStrayFlags(flags, TOKENS_FLAGS, 'tokens');
5583
+ const jsonMode = flags.json !== undefined;
5584
+ const publicClient = makePublicClient({ chainKey: CHAIN });
5585
+ const code = await publicClient.getCode({ address }).catch(() => undefined);
5586
+ if (!code || code === '0x') {
5587
+ // Name the endpoint we actually asked — "no contract here" and "wrong node" are otherwise
5588
+ // indistinguishable (same reasoning as `tokenuri` / `contracturi`).
5589
+ console.error(`abx tokens: no contract at ${address} on ${CHAIN} (asked ${redactRpcUrl(resolveRpcUrl(CHAIN))}) — ` +
5590
+ `double-check the address, and that this endpoint is the network you deployed to. ` +
5591
+ `If you JUST deployed, give the tx a block or two to mine.\n`);
5592
+ process.exit(1);
5593
+ }
5594
+ const listing = await listTokens(publicClient, address, {
5595
+ from: flags.from === undefined ? undefined : Number(flags.from),
5596
+ limit: flags.limit === undefined ? undefined : Number(flags.limit),
5597
+ // Progress on stderr only — stdout must stay pure JSON in --json mode (the ANSI-in-a-locator
5598
+ // lesson: a program's channel is not a place to narrate).
5599
+ onProgress: (done, total) => total > 50 && done % 25 === 0 ? process.stderr.write(` reading ${done}/${total}…\r`) : undefined,
5600
+ });
5601
+ if (listing.tokens.length > 50)
5602
+ process.stderr.write(' \r');
5603
+ if (jsonMode) {
5604
+ process.stdout.write(JSON.stringify(listing, null, 2) + '\n');
5605
+ return;
5606
+ }
5607
+ const supply = listing.totalSupply ?? listing.tokens.length;
5608
+ const cap = listing.maxInvocations !== null && listing.maxInvocations > 0 ? ` of ${listing.maxInvocations} max` : '';
5609
+ console.log(`\n ${bold(`tokens ${address}`)} ${dim(`— ${supply} minted${cap} on ${CHAIN}`)}`);
5610
+ info('read straight from the contract — no indexer, no resolver, no server in the loop');
5611
+ if (!listing.hasParams) {
5612
+ info('this token type has no params extension — so no seeds or params to list (that is correct, not a failed read). Owners below.');
5613
+ }
5614
+ else if (!listing.hasParamEnumeration) {
5615
+ warn('this contract predates on-chain param enumeration — seeds still read, but params cannot be listed. Redeploy on a current implementation to enumerate them.');
5616
+ }
5617
+ const contractKeys = Object.keys(listing.contractParams);
5618
+ if (contractKeys.length) {
5619
+ info(`contract-scope params ${dim('(every token inherits these; a token-scope value of the same key wins)')}`);
5620
+ for (const k of contractKeys.sort())
5621
+ console.log(` ${g(k)}=${listing.contractParams[k]}`);
5622
+ }
5623
+ if (!listing.tokens.length) {
5624
+ console.log(`\n ${dim('no tokens in range — nothing minted yet, or --from is past the mint frontier.')}\n`);
5625
+ return;
5626
+ }
5627
+ const anySeed = listing.tokens.some((t) => t.seed !== null);
5628
+ console.log(`\n ${dim('id'.padStart(4))} ${anySeed ? dim('seed'.padEnd(66)) + ' ' : ''}${dim('owner')}`);
5629
+ for (const t of listing.tokens) {
5630
+ // A seed is the whole reason this command exists, so it prints in FULL. The owner truncates —
5631
+ // an address stays recognizable abbreviated, and `--json` carries both verbatim either way.
5632
+ const seedCell = anySeed ? `${t.seed ?? dim('—'.padEnd(66))} ` : '';
5633
+ const owner = t.owner ? shortAddr(t.owner) : dim('— unminted or burned');
5634
+ console.log(` ${bold(('#' + t.tokenId).padStart(4))} ${seedCell}${owner}`);
5635
+ const keys = Object.keys(t.params).sort();
5636
+ if (keys.length)
5637
+ console.log(` ${keys.map((k) => `${g(k)}=${t.params[k]}`).join(dim(' · '))}`);
5638
+ }
5639
+ // The token-scope param SPREAD — what collectors actually chose, which is the question a param
5640
+ // dump is usually a step toward. Deliberately NOT called a trait spread: traits come from running
5641
+ // the script against the seed (that's `abx render`), not from params.
5642
+ const spread = new Map();
5643
+ for (const t of listing.tokens) {
5644
+ for (const [k, v] of Object.entries(t.params)) {
5645
+ const counts = spread.get(k) ?? new Map();
5646
+ counts.set(v, (counts.get(v) ?? 0) + 1);
5647
+ spread.set(k, counts);
5648
+ }
5649
+ }
5650
+ if (spread.size && listing.tokens.length > 1) {
5651
+ console.log(`\n ${bold('token-scope param spread')} ${dim('(what has actually been set — not the trait spread, which comes from running the script)')}`);
5652
+ for (const key of [...spread.keys()].sort()) {
5653
+ const counts = [...spread.get(key).entries()].sort((a, b) => b[1] - a[1]);
5654
+ const shown = counts.slice(0, 8).map(([v, n]) => `${v} ${dim('×' + n)}`).join(dim(' · '));
5655
+ const rest = counts.length > 8 ? dim(` …+${counts.length - 8} more values`) : '';
5656
+ console.log(` ${g(key)}: ${shown}${rest}`);
5657
+ }
5658
+ }
5659
+ console.log(`\n ${dim('every value above verbatim + machine-readable:')} ${bold(`abx tokens ${address} --json`)}\n`);
5660
+ }
5153
5661
  // ── state: a one-glance operational snapshot of a deployed contract, read straight from chain ──
5154
5662
  // Read-only (no tx, no local index). Series-only getters revert on a 1/1, so each read is defensive
5155
5663
  // → the same command works for both. The agent-friendly "what's the state before/after an op" call.
@@ -5172,6 +5680,11 @@ async function cmdState(address, flags) {
5172
5680
  console.error('usage: abx state <address> — a read-only, on-chain operational snapshot\n');
5173
5681
  process.exit(1);
5174
5682
  }
5683
+ // `--json` emits the same facts as data (B19: a value a program needs must be obtainable without
5684
+ // parsing prose). The human narration below is unchanged and rides stderr under --json.
5685
+ return withJson(flags, async (emit) => cmdStateBody(address, flags, emit));
5686
+ }
5687
+ async function cmdStateBody(address, flags, emit) {
5175
5688
  const publicClient = makePublicClient({ chainKey: CHAIN });
5176
5689
  const read = async (functionName, args = []) => {
5177
5690
  try {
@@ -5200,6 +5713,29 @@ async function cmdState(address, flags) {
5200
5713
  }
5201
5714
  const isSeries = maxInvocations !== undefined; // series-only getter
5202
5715
  const addrOrNone = (a) => (a && a !== zeroAddress ? a : dim('none'));
5716
+ // The payload mirrors what is printed, with `undefined` (getter absent) kept distinct from a zero
5717
+ // address (present and deliberately unset) — collapsing those is what makes a readout lie.
5718
+ const zeroToNull = (a) => (a === undefined ? null : a === zeroAddress ? null : a);
5719
+ const payload = jsonSafe({
5720
+ address,
5721
+ chain: CHAIN,
5722
+ chainId: resolveChain(CHAIN).id,
5723
+ type: isSeries ? 'series' : '1of1',
5724
+ owner: owner ?? null,
5725
+ totalSupply: totalSupply ?? null,
5726
+ nextTokenId: nextTokenId ?? null,
5727
+ maxInvocations: maxInvocations ?? null,
5728
+ paused: paused ?? null,
5729
+ minter: zeroToNull(minter),
5730
+ primaryPayee: zeroToNull(primaryPayee),
5731
+ tokenURIRenderer: zeroToNull(renderer),
5732
+ onChainTokenUri: !!(renderer && renderer !== zeroAddress),
5733
+ royalty: royalty ? { receiver: royalty[0], bps: Number(royalty[1]) } : null,
5734
+ creatorToken: !!creatorToken,
5735
+ transferValidator: creatorToken ? zeroToNull(transferValidator) : null,
5736
+ params: { governed: [], contractScopeSet: [], legacy: false },
5737
+ });
5738
+ emit(payload);
5203
5739
  console.log(bold(`\n ${isSeries ? 'Series' : '1/1'} state`) + dim(` ${address} · ${CHAIN}`));
5204
5740
  info(`owner ${owner ?? dim('?')}`);
5205
5741
  if (isSeries) {
@@ -5243,6 +5779,21 @@ async function cmdState(address, flags) {
5243
5779
  : [];
5244
5780
  const governed = rows.filter((r) => r.s.exists);
5245
5781
  const now = Math.floor(Date.now() / 1000);
5782
+ payload.params.legacy = legacy;
5783
+ payload.params.governed = jsonSafe(governed.map(({ key, s }) => ({
5784
+ key,
5785
+ // Canonical NAMES, not the raw enum indices — a caller must not have to know the Solidity
5786
+ // enum's ordering to read this (the same reason `retired` is a boolean below).
5787
+ paramType: PARAM_TYPES[s.paramType] ?? String(s.paramType),
5788
+ auth: AUTH_OPTIONS[s.auth] ?? String(s.auth),
5789
+ authAddress: s.authAddress === zeroAddress ? null : s.authAddress,
5790
+ lockAfter: s.lockAfter,
5791
+ // A past lockAfter IS how a param is retired — surfaced as a boolean so a caller doesn't
5792
+ // have to re-derive the convention from a timestamp.
5793
+ retired: s.lockAfter !== 0 && s.lockAfter < now,
5794
+ selectOptions: s.selectOptions,
5795
+ })));
5796
+ emit(payload);
5246
5797
  if (governed.length) {
5247
5798
  console.log(`\n ${bold('PostParams')} ${dim(`${governed.length} governed · collectors/artist set these; the generator injects them`)}` +
5248
5799
  (legacy ? dim(' (legacy project — read from its params.keys list)') : ''));
@@ -5262,6 +5813,8 @@ async function cmdState(address, flags) {
5262
5813
  // it is a tokenData coordinate, read directly.
5263
5814
  const set = legacy ? null : await readSetParamKeys(publicClient, address);
5264
5815
  if (set?.contract.length) {
5816
+ payload.params.contractScopeSet = [...set.contract].sort();
5817
+ emit(payload);
5265
5818
  info(`${dim('contract-scope params set:')} ${[...set.contract].sort().join(', ')} ${dim('(apply to every token)')}`);
5266
5819
  }
5267
5820
  }
@@ -5542,6 +6095,16 @@ async function cmdRemote(spec, flags) {
5542
6095
  // SOURCE resolver's PUBLIC api. The two resolvers never talk: we read public endpoints + chain
5543
6096
  // and write through the admin control plane. It does NOT cut over — after a clean migration the
5544
6097
  // operator re-points DNS (custom domain) or the on-chain base URI (provider endpoint).
6098
+ //
6099
+ // ⚠ IF YOU EVER ADD A REDEPLOY-STYLE MIGRATION HERE — anything that deploys a NEW contract and
6100
+ // re-points identity to it (plausible under the greenfield-redeploy stance) — it MUST read the
6101
+ // source collection's creator-token status (`readCreatorTokenStatus`) and carry the enrollment +
6102
+ // active validator into the new deploy's `transferValidator` InitParam. ERC-721C enrollment is
6103
+ // deploy-time-only and PERMANENT, so a migration that forgets it silently converts an enforced
6104
+ // collection into a plain ERC-721 — unrecoverable except by yet another redeploy. Corollary: a
6105
+ // plain source collection must stay plain; do not "helpfully" enroll on migration. Today's
6106
+ // migrate moves off-chain state only, so enrollment lives on the untouched contract and none of
6107
+ // this applies — which is exactly why the trap is easy to walk into later.
5545
6108
  async function cmdMigrate(address, flags) {
5546
6109
  // Both sides accept a named remote or a URL. Only the DESTINATION needs a credential — the
5547
6110
  // source is read via its PUBLIC api (the exit ramp works with zero provider cooperation).
@@ -6254,6 +6817,15 @@ const COMMAND_HELP = {
6254
6817
  The live view 302s to the gateway, so the gateway ${bold('MUST serve HTML')} (a dedicated Pinata gateway or Arweave — never the shared public one)
6255
6818
  --name <s> --symbol <s> ERC-721 identity --royalty-bps <0-10000> (default 500)
6256
6819
  ${bold('--public-base-url <url>')} the resolver you run (${bold('REQUIRED')} unless --onchain-uri — a code project resolves live on-chain state; localhost/missing is refused)
6820
+ ${g('--resume')} <address> ${bold('FINISH an existing contract whose setup transaction failed')} — deploys nothing. A code deploy is TWO
6821
+ txns (create the clone, then one atomic setup multicall); when the second fails you own a
6822
+ live-but-unusable contract AND the salt for its address is spent, so the pinned-salt re-run can
6823
+ never work. Pass the SAME content flags the original deploy used: it reads what is already
6824
+ on-chain and sends only what is missing, in one transaction. Chunks compare by CONTENT (so a
6825
+ hand repair is respected), schemas are left alone if present (re-writing one is an upsert that
6826
+ can strand values), and mints are a SHORTFALL against current supply — never a re-send, since a
6827
+ token cannot be un-minted. Nothing missing ⇒ it sends nothing and says so.
6828
+ ${dim('Refuses --salt / --721c / --bootstrap-factory / --mint-all: those describe how a contract is CREATED.')}
6257
6829
  ${g('--onchain-uri')} the CHAIN-COMPLETE lane: tokenURI/contractURI resolve ON-CHAIN via the canonical metadata renderer, and
6258
6830
  animation_url is COMPUTED on-chain by the canonical ${bold('AbxGenerator')} (template: the full HTML document;
6259
6831
  directory: a parameterized gateway URL — 8KB URL budget, gateway liveness applies). No resolver base is baked
@@ -6398,6 +6970,7 @@ const COMMAND_HELP = {
6398
6970
  (+ the 721C transfer validator, for a collection that enrolled at deploy — plain ERC-721s show nothing new).
6399
6971
  Series-only fields are shown for a Series; a 1/1 shows just supply + royalty + renderer.
6400
6972
  Handy before/after owner ops (mint · pause/unpause · set-minter · set-primary-payee).
6973
+ ${g('--json')} the same facts as data (uncoloured, untruncated) — the machine path; narration moves to stderr.
6401
6974
  ${dim('state = what the CHAIN says. For who is SERVING it and how fresh that is, see `abx status`.')}`,
6402
6975
  predict: `
6403
6976
  ${bold('abx predict')} ${dim('— pre-compute a deploy address (read-only).')}
@@ -6406,6 +6979,8 @@ const COMMAND_HELP = {
6406
6979
  ${bold('abx mint')} <address> ${dim('— mint the NEXT token (a 1/1\'s #0, or a Series\' next-in-order). Owner-only. Sends a tx.')}
6407
6980
  --to 0x.. recipient (default: the owner — pre-mint; a buyer = primary sale)
6408
6981
  ${g('--count')} <n> mint N tokens in order in ONE tx (Series) — e.g. reserves/airdrops (do them BEFORE you unpause the sale)
6982
+ ${g('--json')} ${g('{tokenIds, txHash, blockNumber, sent}')} on stdout — the TOKEN ID as data, read from the mint's own
6983
+ Transfer logs (not by re-reading nextTokenId, which a concurrent mint would make wrong).
6409
6984
  ${dim('minting is sequential (by token id); repeat with different --to for an airdrop to N wallets.')}
6410
6985
  signing: ${g('--send')} (default) · ${g('--sign')} · ${g('--unsigned')} · ${g('--dry-run')} preview`,
6411
6986
  refresh: `
@@ -6459,10 +7034,13 @@ const COMMAND_HELP = {
6459
7034
  ${dim('up front: enrollment is a deploy-time decision and can never be added (or fully removed) later.')}
6460
7035
  signing: ${g('--send')} (default) · ${g('--sign')} · ${g('--unsigned')} · ${g('--dry-run')} preview (encodes + shows the tx, sends nothing)`,
6461
7036
  attach: `
6462
- ${bold('abx attach')} <address> <key> <ipfs://… | ar://… | https://…> ${dim('— attach a named file to a token. Sends a tx.')}
6463
- ${dim('The data-plane verb: the file joins the token\'s served')} ${bold('artifacts')} ${dim('manifest as {key, mimeType, uri}.')}
7037
+ ${bold('abx attach')} <address> <key> <uri> [<key> <uri> …] ${dim('— attach named file(s) to a token. Sends ONE tx.')}
7038
+ ${dim('The data-plane verb: each file joins the token\'s served')} ${bold('artifacts')} ${dim('manifest as {key, mimeType, uri}.')}
6464
7039
  <key> any name YOU choose (print · certificate · stems · readme · source) — how it appears in the manifest
6465
7040
  <uri> the file's locator; representation is ${bold('auto-detected')} from the scheme (ipfs → ipfs, ar → arweave, https → url)
7041
+ ${bold('Several pairs in one invocation become ONE transaction')} ${dim('— all-or-nothing, so a revert lands NONE of them. Attaching')}
7042
+ ${dim('artifacts one command at a time is what leaves a half-written token on a failure partway through, and a mint')}
7043
+ ${dim('cannot be undone. Every pair is validated before anything is sent, and a key repeated in one batch is refused.')}
6466
7044
  ${g('--file')} <path> instead of a URI: store TINY bytes on-chain (SSTORE2, ≈200 gas/byte) — locators are the norm otherwise
6467
7045
  --collection attach to the collection scope · else --token <id> (default 0)
6468
7046
  ${g('--dry-run')} preview the tx, send nothing · signing: ${g('--send')} (default, needs a hot key) · ${g('--sign')} (browser wallet, no key) · ${g('--unsigned')}
@@ -6541,9 +7119,15 @@ const COMMAND_HELP = {
6541
7119
  lifecycle: ${dim('queued')} → ${dim('backfilling')} → ${g('live')} · ${c.orange}stale${c.reset} ${dim('(was live, now lagging — still serving)')} · ${c.orange}failed${c.reset} ${dim('(carries a cause; retried with backoff)')}
6542
7120
  ${dim('status = who is SERVING it and how fresh. For what the CHAIN says (owner, royalty, locks), see `abx state`.')}`,
6543
7121
  storage: `
6544
- ${bold('abx storage')} <show|upload|balance|topup|backup-key> ${dim('— inspect / operate byte custody. Mostly read-only.')}
7122
+ ${bold('abx storage')} <show|upload|status|balance|topup|backup-key> ${dim('— inspect / operate byte custody. Mostly read-only.')}
6545
7123
  ${g('show')} the resolved backend (fs | cloud | ipfs | arweave) + where each value came from
6546
7124
  ${g('upload')} <path> upload ONE file → prints its locator (the URI ${g('abx attach')} wants) [--backend …] [--dry-run] [${g('--json')}]
7125
+ ${g('status')} <locator> is it ${bold('RETRIEVABLE')} yet, not just accepted? [${g('--json')}] [--gateway <url>] [--primary-only]
7126
+ An upload service says "accepted" the moment it holds your bytes; a gateway serves them only once
7127
+ they propagate — minutes, on Arweave. Probes YOUR gateway plus two others, because propagation is
7128
+ per-gateway: ${g('ready')} (yours serves it) · ${g('propagating')} (another does, so the data provably exists —
7129
+ ${bold('wait, do NOT re-upload')}) · ${g('unreachable')} (nothing does — still settling, or a bad locator).
7130
+ Exits non-zero unless ready, so ${g('until abx storage status <loc>; do sleep 10; done')} is the whole wait.
6547
7131
  ${g('balance')} · ${g('topup')} --usd <n> Turbo (arweave) upload credits · ${g('backup-key')} --out <path> copy the managed key`,
6548
7132
  demo: `
6549
7133
  ${bold('abx demo')} <${dim('no args')}> ${dim('— deploy a throwaway 1/1 to the testnet, index it, and serve it — a guided first run. Sends a tx.')}
@@ -6559,7 +7143,8 @@ const COMMAND_HELP = {
6559
7143
  ${g('buy')} <token> [--to 0x..] buy ONE token (public; any funded signer). ETH sales attach the price.
6560
7144
  ${dim('the full go-live: deploy-series → minter configure → set-minter <token> --minter <printed> → set-primary-payee → unpause')}
6561
7145
  --minter-contract 0x.. override the shared minter address (else ABX_FIXED_PRICE_MINTER → manifest)
6562
- signing: ${g('--send')} (default) · ${g('--sign')} · ${g('--unsigned')} · ${g('--dry-run')} preview (safe — sends nothing)`,
7146
+ signing: ${g('--send')} (default) · ${g('--sign')} · ${g('--unsigned')} · ${g('--dry-run')} preview (safe — sends nothing)
7147
+ ${g('--json')} the deployed ADDRESS as data on stdout (narration → stderr). With ${g('--dry-run')}: the PREDICTED address + saltPinned.`,
6563
7148
  add: `
6564
7149
  ${bold('abx add')} <address> ${dim('— register + index a project. Also edits off-chain display metadata + traits.')}
6565
7150
  --from-block <n> --factory 0x.. --label "<s>" --description "<s>" --external-url <url> [--full] [--yes]
@@ -6581,12 +7166,29 @@ const COMMAND_HELP = {
6581
7166
  or 200 bytes), so it accounts for a render PUBLISHED to that resolver. ${bold('Use this for a code project whose')}
6582
7167
  ${bold('renders were published to a hosted resolver')} — a plain \`abx verify\` only checks THIS machine's store and will
6583
7168
  report a false placeholder for a render that lives on the resolver.
7169
+ ${g('--json')} the findings as data, with ${g('ok')} matching the exit code — so CI can gate on a byte mismatch
7170
+ without parsing prose (a mismatch exits non-zero either way).
6584
7171
  ${dim('On-chain URI lane (a non-zero tokenURIRenderer / a generator-backed animation field): also eth_calls the generator')}
6585
7172
  ${dim(`${g('onChainStatus')} (branch · chain-complete · unresolved refs · URL budget) and decodes ${g('tokenURI')} straight from the contract.`)}`,
6586
7173
  tokenuri: `
6587
7174
  ${bold('abx tokenuri')} <address> [--token <id>] ${dim('— read tokenURI(id) straight from the contract on-chain + decode the JSON (read-only; no server).')}
6588
7175
  ${dim('The proof a fully on-chain token self-resolves: any RPC returns the renderer-assembled metadata. Default token 0.')}
6589
- ${dim('Collection-level (ERC-7572) counterpart:')} ${g('abx contracturi <address>')}`,
7176
+ ${g('--fetch')} FOLLOW the URL the contract commits to and print ${bold('what is actually served')} — the body a marketplace reads.
7177
+ On a fully on-chain token there is nothing to fetch (the tokenURI IS the document) and it says so.
7178
+ ${dim('This is also how you catch a provider mismatch: it shows what the BAKED base returns, so registering with')}
7179
+ ${dim('one provider while another is baked on-chain shows up as their 404 rather than as a warning that guesses.')}
7180
+ ${dim('A non-2xx exits non-zero under --json. A bad status here is about the SERVICE, never a mistyped path —')}
7181
+ ${dim('the URL came from the chain, so it is right by construction.')}
7182
+ ${dim('Collection-level (ERC-7572) counterpart:')} ${g('abx contracturi <address>')}${dim('. Whole-collection counterpart:')} ${g('abx tokens <address>')}`,
7183
+ tokens: `
7184
+ ${bold('abx tokens')} <address> ${dim('— every token’s owner, seed, and params, read straight from the contract (read-only; no server).')}
7185
+ ${g('--json')} the listing as data: ${g('{chainId, address, contractParams, tokens: [{tokenId, owner, seed, params}]}')} — verbatim,
7186
+ uncoloured, untruncated. The machine path; the human table abbreviates owners only.
7187
+ ${g('--from <id>')} first token id to read (default 0) ${g('--limit <n>')} read only n ids from there
7188
+ ${dim('For a generative collection the seed list IS the collection — this is the "what did the seeds actually deal?" read,')}
7189
+ ${dim('and the input to any distribution check before a real launch. Chain-only: no indexer projection and no running')}
7190
+ ${dim('resolver, because the params store enumerates its own keys on-chain and')} ${g('seed')} ${dim('is a reserved param read by name.')}
7191
+ ${dim('Traits are NOT here: a trait comes from running the script against the seed —')} ${g('abx render')}${dim(' does that.')}`,
6590
7192
  contracturi: `
6591
7193
  ${bold('abx contracturi')} <address> ${dim('— read contractURI() (ERC-7572 collection metadata) from the contract, FOLLOW it, and decode (read-only).')}
6592
7194
  ${dim('The collection-level counterpart of')} ${g('tokenuri')}${dim('. On-chain lane: decodes the data: URI. Off-chain lane: fetches the')}
@@ -6669,6 +7271,7 @@ function help() {
6669
7271
  ${g('abx verify')} <addr> re-hash served bytes vs the on-chain commitment (no server needed)
6670
7272
  ${g('abx tokenuri')} <addr> read tokenURI(0) on-chain + decode the JSON (proof a self-resolving token works)
6671
7273
  ${g('abx contracturi')} <addr> read contractURI() (ERC-7572 collection metadata) on-chain, follow it, decode — never hand-build the URL
7274
+ ${g('abx tokens')} <addr> EVERY token's owner + seed + params, from chain flags: [--json] [--from <id>] [--limit <n>]
6672
7275
  ${g('abx state')} <addr> one-glance on-chain snapshot: owner · supply · paused · minter · payee · royalty · renderer
6673
7276
  ${g('abx serve')} [--port ..] serve the token API + dashboard — and WATCH the chain: auto-index every registered
6674
7277
  project + notify the effects layer on change (${g('ABX_WATCH_INTERVAL_MS')}; 0 = off)
@@ -6721,6 +7324,7 @@ function help() {
6721
7324
  ${dim('storage is stateless — no config file. Pick a backend PER COMMAND with flags')}
6722
7325
  ${dim('(--backend ipfs --gateway … · --backend cloud --bucket … --public-base … · --backend arweave), or set defaults in .env.')}
6723
7326
  ${g('abx storage')} upload <path> upload ONE file → prints its locator (the URI ${g('abx attach')} wants) [--backend ipfs|arweave|cloud] [--dry-run]
7327
+ ${g('abx storage')} status <locator> is that locator RETRIEVABLE yet (not just accepted)? — ready | propagating | unreachable [--json]
6724
7328
  ${g('abx storage')} balance show Turbo (arweave) upload credits + the funded address
6725
7329
  ${g('abx storage')} topup buy Turbo credits by card --usd <n> (one-time; <100 KB is always free)
6726
7330
  ${g('abx storage')} backup-key copy the managed Turbo/Arweave key (holds credits) to a safe path --out <path>