@artblocks/abx-cli 0.1.0-alpha.40 → 0.1.0-alpha.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/assets/renderer-scaffold/README.md +12 -0
  3. package/assets/renderer-scaffold/foundry.toml +4 -0
  4. package/assets/renderer-scaffold/script/Preview.s.sol +99 -0
  5. package/dist/capabilities.d.ts +9 -4
  6. package/dist/capabilities.d.ts.map +1 -1
  7. package/dist/capabilities.js +13 -4
  8. package/dist/capabilities.js.map +1 -1
  9. package/dist/commands/deploy.d.ts.map +1 -1
  10. package/dist/commands/deploy.js +731 -65
  11. package/dist/commands/deploy.js.map +1 -1
  12. package/dist/commands/maintenance.d.ts +4 -0
  13. package/dist/commands/maintenance.d.ts.map +1 -0
  14. package/dist/commands/maintenance.js +114 -0
  15. package/dist/commands/maintenance.js.map +1 -0
  16. package/dist/commands/project.d.ts +175 -3
  17. package/dist/commands/project.d.ts.map +1 -1
  18. package/dist/commands/project.js +333 -27
  19. package/dist/commands/project.js.map +1 -1
  20. package/dist/commands/reads.d.ts +21 -0
  21. package/dist/commands/reads.d.ts.map +1 -1
  22. package/dist/commands/reads.js +171 -4
  23. package/dist/commands/reads.js.map +1 -1
  24. package/dist/commands/scaffold.d.ts.map +1 -1
  25. package/dist/commands/scaffold.js +5 -0
  26. package/dist/commands/scaffold.js.map +1 -1
  27. package/dist/commands/service.d.ts.map +1 -1
  28. package/dist/commands/service.js +7 -0
  29. package/dist/commands/service.js.map +1 -1
  30. package/dist/commands/storage.d.ts.map +1 -1
  31. package/dist/commands/storage.js +32 -3
  32. package/dist/commands/storage.js.map +1 -1
  33. package/dist/commands/submit-app.d.ts +43 -0
  34. package/dist/commands/submit-app.d.ts.map +1 -1
  35. package/dist/commands/submit-app.js +115 -6
  36. package/dist/commands/submit-app.js.map +1 -1
  37. package/dist/config.d.ts +16 -1
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/config.js +73 -7
  40. package/dist/config.js.map +1 -1
  41. package/dist/deploy-plan.d.ts +150 -0
  42. package/dist/deploy-plan.d.ts.map +1 -0
  43. package/dist/deploy-plan.js +31 -0
  44. package/dist/deploy-plan.js.map +1 -0
  45. package/dist/flag-allowlists.d.ts.map +1 -1
  46. package/dist/flag-allowlists.js +5 -1
  47. package/dist/flag-allowlists.js.map +1 -1
  48. package/dist/flags.d.ts +6 -0
  49. package/dist/flags.d.ts.map +1 -1
  50. package/dist/flags.js +14 -0
  51. package/dist/flags.js.map +1 -1
  52. package/dist/jsonout.d.ts.map +1 -1
  53. package/dist/jsonout.js +11 -0
  54. package/dist/jsonout.js.map +1 -1
  55. package/dist/main.js +46 -7
  56. package/dist/main.js.map +1 -1
  57. package/dist/ownerops.d.ts +69 -0
  58. package/dist/ownerops.d.ts.map +1 -1
  59. package/dist/ownerops.js +221 -17
  60. package/dist/ownerops.js.map +1 -1
  61. package/dist/script-chunks.d.ts +1 -1
  62. package/dist/script-chunks.d.ts.map +1 -1
  63. package/dist/script-chunks.js +2 -2
  64. package/dist/script-chunks.js.map +1 -1
  65. package/package.json +6 -6
  66. package/skill/SKILL.md +6 -4
  67. package/skill/reference/code.md +17 -8
  68. package/skill/reference/deploy.md +9 -2
  69. package/skill/reference/diagnose.md +15 -2
  70. package/skill/reference/hosting.md +26 -0
  71. package/skill/reference/operate.md +43 -4
  72. package/skill/reference/setup.md +14 -0
package/dist/ownerops.js CHANGED
@@ -10,15 +10,18 @@ import { MAX_ROYALTY_BPS, assertChainId, ensureChunkStore as sdkEnsureChunkStore
10
10
  import { encodeScalarParam, encodeTag as encodeTagSdk, isAccepted, prepareConfigureTokenParam, prepareConfigureTokenParamData, prepareSetContractParam, prepareSetContractParamData, seriesCodeAbi, tryReadContract, } from '@artblocks/abx-sdk';
11
11
  import { hasParamEnumeration, GATEWAY_FIELD, GATEWAY_FLOOR, gatewayPrefixFrom, readCollectionPolicy, readEnv } from '@artblocks/abx-sdk';
12
12
  import { decodeFieldRenderer, encodeFieldRenderer, resolveGenerator, DEP_RESOLUTION, parseDependencyRef, prepareLockDependencies, prepareLockParamHooks, prepareLockScript, prepareRemoveLastDependency, prepareSetDependency, prepareSetDependencyRegistry, } from '@artblocks/abx-sdk';
13
+ import { planScriptReplace, prepareRemoveLastScriptChunk, prepareSetScriptChunk, verifyScriptReplace, } from '@artblocks/abx-sdk';
13
14
  import { contentTypeFromPath } from '@artblocks/abx-storage';
14
15
  import { toHex as toHexSdk } from 'viem';
15
- import { readFileSync } from 'node:fs';
16
+ import { existsSync, readFileSync } from 'node:fs';
16
17
  import { basename, resolve as resolvePath } from 'node:path';
17
18
  import { gzipSync } from 'node:zlib';
18
19
  import { formatEther, getAddress, isAddress, parseEther, toHex, zeroAddress } from 'viem';
19
20
  import { backendResolution, CHAIN, chainId, fixedPriceMinterAddress, fixedPriceMinter1155Address, localIndexer } from './config.js';
20
21
  import { CliError } from './errors.js';
21
- import { detectTokenKind, isEditionContract, assertHasParamsSurface } from './kind.js';
22
+ import { detectTokenKind, isEditionContract, assertHasParamsSurface, hasParamsSurface } from './kind.js';
23
+ import { planOnChainScript } from './script-chunks.js';
24
+ import { fetchServedTokenUri, servedOk } from './served.js';
22
25
  import { openWalletSession } from './signer.js';
23
26
  import { gatedSend, laneFromFlags } from './riskgate.js';
24
27
  import { withJson } from './jsonout.js';
@@ -30,11 +33,12 @@ import { parseSchemaSpecs, describeSchema } from './schema.js';
30
33
  // definition below).
31
34
  export { ONCHAIN_PROJECT_SOFT_LIMIT, ONCHAIN_READ_WARN_BYTES, ETH_CALL_GAS_FLOOR, tokenUriGasEstimate, readableBytesAtGas };
32
35
  // ── ANSI (local) ─────────────────────────────────────────────────────────────
33
- const C = { reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m', green: '\x1b[38;5;115m', yellow: '\x1b[38;5;221m' };
36
+ const C = { reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m', green: '\x1b[38;5;115m', yellow: '\x1b[38;5;221m', red: '\x1b[31m' };
34
37
  const dim = (s) => `${C.dim}${s}${C.reset}`;
35
38
  const green = (s) => `${C.green}${s}${C.reset}`;
36
39
  const yellow = (s) => `${C.yellow}${s}${C.reset}`;
37
40
  const bold = (s) => `${C.bold}${s}${C.reset}`;
41
+ const red = (s) => `${C.red}${s}${C.reset}`;
38
42
  // ── data plane: attach files, and the keys you can't set by hand ──────────────
39
43
  // A token anchors named, typed files ("artifacts"). The served JSON's `artifacts` list is the
40
44
  // COMPLETE set — but it's COMPUTED by the resolver/renderer from the token's fields + effect
@@ -532,6 +536,139 @@ export async function cmdLockScript(address, flags) {
532
536
  console.log(dim(' that is not the same as a frozen OUTPUT: params stay writable and a Registry dependency is re-fetched live, so say "locked metadata" to a buyer, not "immutable".'));
533
537
  await runWrite(contract, prepareLockScript({ contract, chainId: chainId() }), flags, owner);
534
538
  }
539
+ /** Flags `abx replace-script` recognizes — anything else warns (non-fatal). */
540
+ const REPLACE_SCRIPT_FLAGS = [...SHARED_WRITE_FLAGS, 'script', 'chunk-size'];
541
+ /** A `ScriptChunkReader` backed by a real chain read — the only place `replace-script` touches
542
+ * `seriesCodeAbi` directly, so `planScriptReplace`/`verifyScriptReplace` themselves stay chain-free
543
+ * and testable (see packages/sdk/src/script-chunks.ts). `scriptChunk` returns `null` on a revert
544
+ * (an index at/past the CURRENT on-chain count) rather than throwing — exactly what both SDK
545
+ * functions expect for "nothing stored here yet". */
546
+ function chainScriptReader(contract) {
547
+ const client = makePublicClient({ chainKey: CHAIN });
548
+ return {
549
+ scriptChunkCount: async () => Number(await client.readContract({ address: contract, abi: seriesCodeAbi, functionName: 'scriptChunkCount' })),
550
+ scriptChunk: async (index) => {
551
+ try {
552
+ return (await client.readContract({ address: contract, abi: seriesCodeAbi, functionName: 'scriptChunk', args: [BigInt(index)] }));
553
+ }
554
+ catch {
555
+ return null;
556
+ }
557
+ },
558
+ };
559
+ }
560
+ /**
561
+ * `abx replace-script <address> --script <path>` — the safe, first-class way to replace an
562
+ * UNLOCKED code project's on-chain program (#119). `OnChainScript.sol` permits `setScriptChunk`/
563
+ * `removeLastScriptChunk` right up until `lock-script` — but before this command the only writer
564
+ * was `deploy-code` (its initial setup, or its `--resume` leg for a setup that never landed).
565
+ * Neither is "replace a program that's already live and correct on-chain", which is the gap this
566
+ * closes: a creator iterating on a generative project pre-lock had no supported way to ship a fix.
567
+ *
568
+ * Safety properties, in the order they're enforced:
569
+ * 1. REFUSE outright — never warn-and-proceed — on a locked script or a non-code target. Both are
570
+ * "there is nothing here for this command to safely do", not "proceed with caution".
571
+ * 2. Diff by CONTENT against what's on-chain now ({@link planScriptReplace}, mirroring
572
+ * `resume.ts`'s script-chunk leg) so an index that already matches is never re-sent — SSTORE2
573
+ * deposit is 200 gas/byte, so a needless re-store is the most expensive way to be "safe".
574
+ * 3. Every write AND every remove rides in ONE atomic `multicall` (`batchOps`, the same primitive
575
+ * `abx attach` uses for its own all-or-nothing guarantee). This is what makes "half-applied"
576
+ * structurally impossible from this command's own send: a revert undoes everything, a success
577
+ * lands everything. Writes are ordered before removes — the retained indices get their correct
578
+ * content before the tail is trimmed, so the intermediate calldata sequence never shrinks the
579
+ * script before it's right (the two groups touch disjoint indices, so this is a documentation
580
+ * choice about intent, not a correctness requirement of the atomic call itself).
581
+ * 4. After a real send, READ THE SCRIPT BACK and verify it reassembles EXACTLY to the source file
582
+ * ({@link verifyScriptReplace}) before reporting success. A transaction not reverting proves the
583
+ * EVM accepted each call; it does not prove the final program is what was intended. This is the
584
+ * step that actually justifies the command existing — skip it and "replace-script" is just
585
+ * "send some txs and hope".
586
+ *
587
+ * `--dry-run`/`--sign`/`--unsigned` all route through the shared `runWrite`/`gatedSend` choke point
588
+ * (the same one every owner-op uses), so a dry run sends nothing, per the house invariant.
589
+ */
590
+ export async function cmdReplaceScript(address, flags) {
591
+ const usage = 'abx replace-script <address> --script <path> [--chunk-size <bytes>] [--sign|--unsigned] [--dry-run]';
592
+ const contract = requireAddress(address, usage);
593
+ warnStrayFlags(flags, new Set(REPLACE_SCRIPT_FLAGS), 'replace-script');
594
+ const scriptPath = requireFlag(flags, 'script', usage);
595
+ if (!existsSync(scriptPath)) {
596
+ throw new Error(`no file at ${scriptPath} — pass the path to the replacement program (the same --script a deploy-code would take).`);
597
+ }
598
+ const source = readFileSync(scriptPath, 'utf8');
599
+ // REFUSE (not warn) on a target this command cannot safely act on — before any signing prompt.
600
+ const publicClient = makePublicClient({ chainKey: CHAIN });
601
+ const kind = await detectTokenKind(publicClient, contract);
602
+ if (!hasParamsSurface(kind)) {
603
+ throw new Error(`${contract} is a ${kind.label} — replace-script only applies to a SeriesCode/EditionCode on-chain program ` +
604
+ `(the OnChainScript extension). ${kind.label} has no script to replace.`);
605
+ }
606
+ const scriptLocked = (await publicClient.readContract({ address: contract, abi: seriesCodeAbi, functionName: 'scriptLocked' }));
607
+ if (scriptLocked) {
608
+ throw new Error(`${contract}'s script is LOCKED (\`abx lock-script\` was already sent) — setScriptChunk/removeLastScriptChunk ` +
609
+ `revert forever, on-chain, for everyone. There is no way to replace it; that refusal IS the safety property ` +
610
+ `lock-script exists to guarantee, so this command does not attempt a workaround.`);
611
+ }
612
+ const owner = await read(contract, 'owner');
613
+ const chunkSize = flags['chunk-size'] !== undefined ? Number(flags['chunk-size']) : undefined;
614
+ if (chunkSize !== undefined && (!Number.isFinite(chunkSize) || chunkSize < 1)) {
615
+ throw new Error(`--chunk-size must be a positive integer, got "${flags['chunk-size']}"`);
616
+ }
617
+ const nextChunks = planOnChainScript(source, chunkSize);
618
+ if (nextChunks.length === 0) {
619
+ console.log(yellow(' ⚠ ') + `${scriptPath} is empty — this will REMOVE every existing script chunk, leaving no program on-chain. Ctrl-C now if that's not what you meant.`);
620
+ }
621
+ const reader = chainScriptReader(contract);
622
+ const plan = await planScriptReplace(reader, nextChunks);
623
+ console.log(` script: ${plan.currentCount} chunk(s) on-chain → ${plan.targetCount} chunk(s) in ${scriptPath}`);
624
+ if (plan.noop) {
625
+ console.log(` ${green('✓')} no-op — the on-chain script already matches ${scriptPath} byte-for-byte. Nothing to send.`);
626
+ return;
627
+ }
628
+ if (plan.toWrite.length) {
629
+ console.log(dim(` write ${plan.toWrite.length} chunk(s): index ${plan.toWrite.map((w) => w.index).join(', ')}`));
630
+ }
631
+ if (plan.toRemove) {
632
+ console.log(dim(` remove ${plan.toRemove} trailing chunk(s) — shrinking ${plan.currentCount} → ${plan.targetCount} (removeLastScriptChunk has no "remove N"; this is ${plan.toRemove} queued call(s))`));
633
+ }
634
+ // Writes before removes (see the function doc for why this ordering, not the reverse). Both
635
+ // groups fold into ONE multicall below — that's what makes a half-applied result impossible from
636
+ // this command's own send, not the ordering.
637
+ const ops = [
638
+ ...plan.toWrite.map((w) => prepareSetScriptChunk({ contract, index: w.index, chunk: w.hex, chainId: chainId() })),
639
+ ...Array.from({ length: plan.toRemove }, () => prepareRemoveLastScriptChunk({ contract, chainId: chainId() })),
640
+ ];
641
+ const batched = batchOps(ops);
642
+ if (batched.length !== 1) {
643
+ // Defensive: every op above targets the SAME contract and carries no value, so batchOps must
644
+ // fold them to exactly one multicall. If that ever changes, fail loudly rather than send N
645
+ // separate transactions with no atomicity — the one property this command exists to guarantee.
646
+ throw new Error(`replace-script expected to batch ${ops.length} op(s) into one transaction, got ${batched.length}`);
647
+ }
648
+ // `batchOps`/`prepareMulticall` don't sum a folded gasFloor from their sub-ops (each PER-CHUNK
649
+ // gasFloor is real physics — see prepareSetScriptChunk's own doc — but is dropped once several
650
+ // ops merge into one multicall PreparedTx). Restate it here so the eth_estimateGas sanity check
651
+ // still fires for a replacement that stores real bytes.
652
+ const depositBytes = plan.toWrite.reduce((sum, w) => sum + (w.hex.length - 2) / 2, 0);
653
+ const tx = depositBytes > 0 ? { ...batched[0], gasFloor: `0x${(depositBytes * 200).toString(16)}` } : batched[0];
654
+ const sent = await runWrite(contract, tx, flags, owner);
655
+ if (!sent)
656
+ return; // dry run, or the cold lane — nothing landed to verify yet
657
+ const verify = await verifyScriptReplace(reader, source);
658
+ if (!verify.ok) {
659
+ console.error(`\n ${red('✗')} VERIFICATION FAILED — the on-chain script does NOT reassemble to ${scriptPath} after this write.\n` +
660
+ ` on-chain now reports ${verify.chunkCount} chunk(s) (wanted ${plan.targetCount}). This can mean the write only\n` +
661
+ ` partially landed, or something else wrote to this contract concurrently — either way, DO NOT assume the\n` +
662
+ ` program is what you intended.\n` +
663
+ ` Way forward: re-run \`abx replace-script ${contract} --script ${scriptPath}\` — it re-diffs against whatever\n` +
664
+ ` is ACTUALLY on-chain now and sends only what's still missing or wrong; it will not resend what already matches.\n` +
665
+ ` Inspect by hand: abx tokenuri ${contract} --fetch (or scriptChunkCount/scriptChunk directly).\n`);
666
+ process.exitCode = 1;
667
+ return;
668
+ }
669
+ console.log(` ${green('✓')} verified — the on-chain script reassembles EXACTLY to ${scriptPath} (${verify.chunkCount} chunk(s), ${source.length} bytes).\n` +
670
+ dim(` this is the safety property \`abx lock-script ${contract}\` later makes permanent.`));
671
+ }
535
672
  /** `abx lock-param-hooks <address>` — freeze the three param-lifecycle hook addresses forever.
536
673
  * The sibling of lock-script/lock-dependencies/lock-uri, and the only one aimed at a BUYER rather
537
674
  * than at metadata: the transfer hook is a veto over transfers and mints, so an unlocked hook set
@@ -1977,6 +2114,42 @@ async function reportUriAfterFieldWrite(contract, collection, flags) {
1977
2114
  ` a field renderer must NEVER revert.`);
1978
2115
  }
1979
2116
  }
2117
+ // ── attach: the canonical fetch URL + a real reachability probe (#127) ────────
2118
+ /**
2119
+ * The exact, directly-fetchable URL for one attached key — `{base}/{chainId}/{address}[/{tokenId}]
2120
+ * /data/{key}`, the token-api route grammar (`packages/token-api/src/server.ts`'s `/t` and `/c`
2121
+ * routes). `base` already carries its OWN `/t` or `/c` prefix (see `TokenURI.sol`/`ContractURI.sol`
2122
+ * — `deploy-code`/`deploy`/etc. bake `tokenURIBase = "${host}/t"`), so this never re-adds one.
2123
+ * Exported so a creator or an agent never hand-assembles this from the docs — the exact gap #127
2124
+ * reported (`attach` produced a bare path, not a URL anyone could actually GET).
2125
+ */
2126
+ export function canonicalAttachmentUrl(opts) {
2127
+ const base = opts.uriBase.replace(/\/+$/, '');
2128
+ const coords = opts.tokenId === undefined ? `${opts.chainId}/${opts.contract}` : `${opts.chainId}/${opts.contract}/${opts.tokenId}`;
2129
+ return `${base}/${coords}/data/${encodeURIComponent(opts.key)}`;
2130
+ }
2131
+ /**
2132
+ * Is ANY resolver actually answering for this project right now — the precondition for an
2133
+ * attachment to ever become fetchable, and the check `attach` never ran before #127 (it warned on
2134
+ * an EMPTY resolver base, but a non-empty one that answered nothing got a silent, confident "listed
2135
+ * in this project's resolver artifacts"). Reuses `cmdVerifyRemote`'s own GET-and-classify shape
2136
+ * (`served.ts`'s `fetchServedTokenUri`) rather than a second HTTP client.
2137
+ *
2138
+ * Deliberately probes the token's (or collection's) OWN metadata document — NEVER the specific key
2139
+ * being attached. That document already exists for any minted token; the key being attached here
2140
+ * does not exist server-side until the write this gates on lands and the resolver re-indexes, so
2141
+ * probing it directly would 404 on a perfectly healthy resolver and cry wolf on every attach.
2142
+ */
2143
+ export async function probeResolverReachable(opts) {
2144
+ const base = opts.uriBase.replace(/\/+$/, '');
2145
+ const coords = opts.tokenId === undefined ? `${opts.chainId}/${opts.contract}` : `${opts.chainId}/${opts.contract}/${opts.tokenId}`;
2146
+ const url = `${base}/${coords}`;
2147
+ const served = await fetchServedTokenUri(url, { fetchFn: opts.fetchFn });
2148
+ if (servedOk(served))
2149
+ return { reachable: true, url, detail: `HTTP ${served.status}` };
2150
+ const detail = served.error ? `no response: ${served.error}` : served.status !== null ? `HTTP ${served.status}` : (served.skipped ?? 'unreachable');
2151
+ return { reachable: false, url, detail };
2152
+ }
1980
2153
  // ── attach (the data-plane verb: put a named file on a token) ─────────────────
1981
2154
  /**
1982
2155
  * `abx attach <address> <key> <uri>` — attach a named, typed file to a token (or the collection),
@@ -2085,24 +2258,46 @@ export async function cmdAttach(rest, flags) {
2085
2258
  for (const p of pairs) {
2086
2259
  console.log(` attaching ${bold(p.key)} ${dim(`(${p.mimeType}, ${p.representation})`)} to ${scope}: ${dim(p.uri)}`);
2087
2260
  }
2088
- // Where it surfaces. This used to be one dim line, and it read as a footnote rather than as a
2089
- // dependency: an integrator attached five audio stems to a fully-on-chain token, paid to store
2090
- // them, and found `tokenURI` listed none of them "paid for, stored on-chain, and invisible".
2091
- // The on-chain renderer deliberately omits locator-represented artifacts (they duplicate no
2092
- // on-chain type information see site/content/docs/protocol/data-plane.mdx), so the artifacts manifest comes
2093
- // from a RESOLVER. When the project has no resolver baked in, that is not a footnote, it is the
2094
- // difference between a feature working and not existing, so say it as a warning.
2095
- const uriBase = await read(contract, 'tokenURIBase').catch(() => '');
2096
- const artifactsPath = `/t/${chainId()}/${contract}/${flags.token ?? '0'}`;
2261
+ // Where it surfaces and whether anything can actually SERVE it (#127). This used to be one dim
2262
+ // line assuming a non-empty resolver base meant the artifact would be listed; it never checked.
2263
+ // An integrator attached five audio stems to a fully-on-chain token, paid to store them, and found
2264
+ // `tokenURI` listed none of them — "paid for, stored on-chain, and invisible". The on-chain
2265
+ // renderer deliberately omits locator-represented artifacts (they duplicate no on-chain type
2266
+ // information see site/content/docs/protocol/data-plane.mdx), so the complete artifacts manifest
2267
+ // comes from a RESOLVER and a resolver base baked on-chain is a CONFIGURATION, not proof one is
2268
+ // actually running and knows this project. `--collection` reads the collection-scope base
2269
+ // (`contractURIBase`); a token-scope attach reads `tokenURIBase` — these are two separate on-chain
2270
+ // fields (the earlier version of this always read `tokenURIBase`, silently wrong for --collection).
2271
+ const uriBase = await read(contract, collection ? 'contractURIBase' : 'tokenURIBase').catch(() => '');
2272
+ const tokenIdForUrl = collection ? undefined : BigInt(flags.token ?? '0');
2273
+ let reachability = null;
2097
2274
  if (uriBase && uriBase.trim() !== '') {
2098
- console.log(dim(` → listed in this project's resolver artifacts (${artifactsPath} and /data/<key>); a bare on-chain tokenURI carries reserved fields only (params are chain state read them with abx tokens).`));
2275
+ // The canonical fetch URL for every key, printed directlyno hand-assembling a resolver
2276
+ // route from the docs. A PROMISE, not yet a fact: the key doesn't exist server-side until the
2277
+ // write below lands and the resolver re-indexes, so it is printed here, never probed here.
2278
+ for (const p of pairs) {
2279
+ console.log(dim(` → fetch: ${canonicalAttachmentUrl({ uriBase, chainId: chainId(), contract, tokenId: tokenIdForUrl, key: p.key })}`));
2280
+ }
2281
+ // What CAN be checked before any write: does ANY resolver answer for this project at all right
2282
+ // now — the precondition for the URL(s) above to ever resolve. Same GET-and-classify shape
2283
+ // `cmdVerifyRemote` uses for its own reachability check (served.ts).
2284
+ reachability = await probeResolverReachable({ uriBase, chainId: chainId(), contract, tokenId: tokenIdForUrl });
2285
+ if (reachability.reachable) {
2286
+ console.log(dim(` resolver answers for this project (${reachability.detail}) — the URL(s) above start serving once this write lands and re-indexes.`));
2287
+ }
2288
+ else {
2289
+ console.log(yellow(' ⚠ ') +
2290
+ `the resolver base baked on-chain (${uriBase}) does not currently answer for ${scope} (${reachability.detail}) — ` +
2291
+ `the on-chain FIELD WRITE below will still succeed, but nothing is known to be able to SERVE ${pairs.map((p) => bold(p.key)).join(', ')} right now. ` +
2292
+ `Local: is ${bold('abx serve')} running, and is this project ${bold('abx add')}-ed + indexed? Hosted: ${bold(`abx add ${contract} --remote <name>`)} then ${bold(`abx index ${contract} --remote <name>`)}.`);
2293
+ }
2099
2294
  }
2100
2295
  else {
2101
2296
  console.log(yellow(' ⚠ ') +
2102
- `this project resolves ON-CHAIN (no resolver base baked in), and the on-chain document carries reserved fields only ` +
2103
- `so ${pairs.map((p) => bold(p.key)).join(', ')} will NOT appear in ${bold('tokenURI')}. The bytes are stored and provable, but nothing surfaces them to a marketplace or wallet. ` +
2297
+ `this project resolves ON-CHAIN (no resolver base baked in) — the on-chain FIELD WRITE below will still succeed, but there is NO off-chain serving path at all: the on-chain document carries reserved fields only, ` +
2298
+ `so ${pairs.map((p) => bold(p.key)).join(', ')} will NEVER appear in ${bold('tokenURI')} for ANY resolver. The bytes are stored and provable, but nothing surfaces them to a marketplace or wallet. ` +
2104
2299
  dim('(Params are chain state, readable with abx tokens and by any contract call — attachments are the surface that needs a resolver.)'));
2105
- console.log(dim(` to make attached artifacts visible, point the project at a resolver (${bold('abx deploy-resolver')}, or a managed one via ${bold('abx add <addr> --remote <name>')}) — it serves the listing at ${artifactsPath}.`));
2300
+ console.log(dim(` to make attached artifacts servable at all, point the project at a resolver (${bold('abx deploy-resolver')}, or a managed one via ${bold('abx add <addr> --remote <name>')}) — then re-run this.`));
2106
2301
  }
2107
2302
  // ONE transaction for the whole set. `batchOps` folds a same-target run into a `multicall` and
2108
2303
  // passes a lone op through untouched, so a single attach is byte-identical to before.
@@ -2122,7 +2317,16 @@ export async function cmdAttach(rest, flags) {
2122
2317
  if (sent) {
2123
2318
  const id = flags.token ?? '0';
2124
2319
  const names = pairs.map((p) => bold(p.key)).join(', ');
2125
- console.log(` ${green('✓')} attached ${names} join${pairs.length > 1 ? '' : 's'} ${scope}'s ${bold('artifacts')} manifest (stored on-chain, anchored).\n` +
2320
+ // The distinction #127 asked for: the on-chain FIELD WRITE succeeding (this line, always true
2321
+ // when we get here) is not the same fact as the artifact being SERVABLE — say both, separately,
2322
+ // rather than one "✓ attached" that a reader takes to mean "and it's live".
2323
+ const servingLine = !uriBase || uriBase.trim() === ''
2324
+ ? dim(` serving: NONE — this project resolves fully on-chain (see the warning above); the bytes are stored and provable, but no resolver will ever list them.\n`)
2325
+ : reachability?.reachable
2326
+ ? dim(` serving: a resolver answered for ${scope} just before this write (see above); fetch the URL(s) above once it re-indexes to confirm.\n`)
2327
+ : dim(` serving: NOT CONFIRMED — no resolver answered for ${scope} just before this write (see the warning above). The FIELD is on-chain now; SERVING it is a separate, still-unmet, concern.\n`);
2328
+ console.log(` ${green('✓')} on-chain field write succeeded — ${names} join${pairs.length > 1 ? '' : 's'} ${scope}'s ${bold('artifacts')} manifest (stored on-chain, anchored).\n` +
2329
+ servingLine +
2126
2330
  dim(` verify (a resolver serves the complete listing): `) +
2127
2331
  `abx tokenuri ${contract}${id === '0' ? '' : ` --token ${id}`} --fetch` +
2128
2332
  dim(` ${collection ? '' : `→ artifacts[].key ${pairs.map((p) => `"${p.key}"`).join(', ')}; /data/<key> fetches each`}\n`) +